npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@proappstore/sdk

v1.16.85

Published

Browser SDK for paid apps on proappstore.online — subscriptions, license keys, premium modules.

Downloads

6,999

Readme

@proappstore/sdk

Full SDK for premium apps on proappstore.online. Auth, per-user KV, counters, real-time rooms, API proxy, per-app SQL database, file storage, maps & routing, subscriptions, license keys, push notifications, SMS, email, webhooks, server-side AI, and multi-tenant helpers.

Installation

npm i @proappstore/sdk
# or
pnpm add @proappstore/sdk

Usage

import { initPro } from '@proappstore/sdk'

const app = initPro({ appId: 'my-app' })

Then wrap the whole app in <ProShell app={app} appName="…" nav={[…]}>, the standard app frame with the gates, topbar, main navigation and resilience layer. See ProShell Component.

Options:

| Option | Default | Description | |--------|---------|-------------| | appId | (required) | Your app's unique identifier | | authMode | legacy-bearer | Use platform-cookie for PAS-hosted HttpOnly cookie sessions | | proApiBase | https://api.proappstore.online | Platform API base URL | | dataApiBase | https://data-{appId}.proappstore.online, or /.pas/data in platform-cookie mode | Per-app data worker URL |

Types

import type { User, Subscription, QueryResult, ExecuteResult, Migration } from '@proappstore/sdk'
// Also available from hooks:
import type { User } from '@proappstore/sdk/hooks'

User — returned by app.auth.user and useProAuth():

interface User {
  id: string
  login: string
  avatarUrl: string | null
  dateOfBirth: string | null  // YYYY-MM-DD, null until set
}

Database types:

interface QueryResult<T> {
  rows: T[]
  meta: { changes: number; duration: number }
}

interface ExecuteResult {
  meta: { changes: number; duration: number; last_row_id: number }
}

interface Migration {
  name: string   // e.g. "0001_init" — tracked, only applied once
  sql: string    // semicolon-separated statements
}

interface MigrateResult {
  applied: string[]   // migrations just applied
  already: string[]   // previously applied
}

Modules

Auth

PAS-owned auth across all ProAppStore apps. GitHub is the default OAuth provider; Google OAuth and email magic links are also supported.

await app.auth.init()
app.auth.onChange((user) => console.log(user))
app.auth.signIn()          // GitHub (default)
app.auth.signIn('google')  // Google
app.auth.signInWithEmail('[email protected]')
app.auth.signOut()

Apps should not store PAS session tokens themselves. The default legacy-bearer mode keeps the signed-in session in memory and tries to cache it under the PAS-owned pas:session key. If browser storage is blocked or throws, the SDK falls back to memory-only state for the current page lifetime.

PAS-hosted apps can opt into host-only HttpOnly cookie sessions:

const app = initPro({
  appId: 'my-app',
  authMode: 'platform-cookie',
})

In platform-cookie mode, OAuth and normal SDK HTTP calls go through same-origin /.pas/auth/*, /.pas/api/*, and /.pas/data/* routes. Browser JavaScript does not receive the PAS bearer token. WebSocket rooms and usage beacon telemetry still use the legacy token path while those transports are migrated.

Actions

Registered app actions are the preferred path for browser-callable app data. Actions are declared in the app's mcp.json, registered on publish, and run server-side by name:

const result = await app.actions.call('list_my_items', { limit: 20 })

The platform loads the registered statement, injects :__user_id, :__now, and :__uuid, enforces declared platform/app roles, then forwards the prepared statement to the app data worker. Apps should migrate user-specific and role-specific reads/writes to actions instead of sending raw SQL from browser code.

A refused call rejects with an ActionError (status, code — the server's error, e.g. requires app role or step_up_required — and body). Its message is the same actions.<name> failed: <status> <body> as before.

KV (Per-user key-value storage)

await app.kv.set('profile', { name: 'Alice' })
const profile = await app.kv.get('profile')
const keys = await app.kv.list({ prefix: 'note:' })
await app.kv.delete('profile')

Counters (Shared atomic counters)

Cross-user counters for votes, views, leaderboards.

await app.counters.increment('views')
await app.counters.increment('likes', -1)   // decrement — pass a negative amount
const all = await app.counters.list()

Rooms (Real-time WebSocket)

const room = app.rooms.join('lobby')
room.send({ text: 'hello' })
room.onMessage((msg) => console.log(msg))
room.onPeers((peers) => console.log(peers))
room.close()

Your app worker can publish to a room (#351): pas.rooms.publish('doors:c1', { id }). Browsers receive it with onEvent, and refetch on onReconnect, because events are not replayed after a disconnect:

const room = app.rooms.join(`doors:${campaignId}`)
room.onEvent((e) => refetch())       // e = { from: { uid: 'system:worker' }, data, at, seq }
room.onReconnect(() => refetch())

user:<uid> rooms admit only that user. Other rooms are open unless the app's mcp.json declares a rooms pattern and an authorize action for them; a refused join closes 4401 room_forbidden and is not retried.

Proxy (Secret-injecting API proxy)

Call third-party APIs without exposing keys to the client.

const response = await app.proxy.fetch('/openai/chat/completions', {
  method: 'POST',
  body: JSON.stringify({ model: 'gpt-4', messages: [...] }),
})

Database (Per-app SQL)

Each Pro app gets its own D1 SQL database accessed through a dedicated data worker at data-{appId}.proappstore.online.

// Schema migrations — idempotent, tracked by name
const { applied, already } = await app.db.migrate([
  {
    name: '0001_init',
    sql: `
      CREATE TABLE IF NOT EXISTS users (
        id   TEXT PRIMARY KEY,
        name TEXT NOT NULL
      );
    `,
  },
])

// Query rows
const { rows } = await app.db.query<User>('SELECT * FROM users WHERE active = ?', [true])

// Execute writes
const { meta } = await app.db.execute('INSERT INTO users (id, name) VALUES (?,?)', ['u1', 'Alice'])
console.log(meta.last_row_id) // auto-increment id

// Batch (transactional)
const results = await app.db.batch([
  { sql: 'INSERT INTO orders (user_id, total) VALUES (?, ?)', params: ['u1', 99.99] },
  { sql: 'UPDATE users SET order_count = order_count + 1 WHERE id = ?', params: ['u1'] },
])

// List tables
const tables = await app.db.tables()

Recommended pattern: define migrations in a db/core.ts file and call ensureMigrated() at the top of each query function. See the kanban app for the full pattern.

Subscription (Stripe-powered)

// Check subscription status
const sub = await app.subscription.status()
// Returns: { status, tier, priceId, currentPeriodEnd, cancelAtPeriodEnd } | null

// Read the live platform price config
const pricing = await app.subscription.pricing()
const priceId = pricing.proMonthly?.priceId
if (!priceId) throw new Error('Subscription billing is not configured')

// Open Stripe checkout (navigates away)
await app.subscription.openCheckout({
  priceId,
  successUrl: 'https://my-app.proappstore.online/success',
  cancelUrl: 'https://my-app.proappstore.online/',
})

// Open Stripe billing portal (navigates away)
await app.subscription.openPortal('https://my-app.proappstore.online/')

License

Per-app license key validation.

// Get current user's license (requires auth)
const license = await app.license.current()
// Returns: { key, appId, issuedAt, expiresAt } | null

// Validate any key (no auth required)
const valid = await app.license.validate('LIC-ABC-123')

Maps (Geocoding, Routing + Embeds)

Address-to-coordinates, driving directions, and map embeds. Powered by OpenStreetMap/Nominatim/OSRM. No Google API keys needed.

// Geocode an address
const results = await app.maps.geocode('Times Square, New York')
// [{lat: 40.758, lng: -73.985, displayName: "Times Square...", address: {...}}]

// Reverse geocode
const place = await app.maps.reverseGeocode(40.758, -73.985)

// Driving route between two points
const route = await app.maps.route(
  { lat: 40.758, lng: -73.985 },  // from
  { lat: 40.748, lng: -73.986 },  // to
)
// route.geometry      — GeoJSON LineString ([lng, lat] pairs)
// route.distanceMeters
// route.durationSeconds

// Embed map in iframe
<iframe src={app.maps.embedUrl(40.758, -73.985)} />

// Static tile image
<img src={app.maps.staticUrl(40.758, -73.985)} />

Storage (File Upload)

Upload images, videos, documents. Public files get URLs usable in <img src> without auth.

// Private upload (owner-only access)
await app.storage.upload('docs/resume.pdf', file, 'application/pdf')

// Public upload (anyone can view)
await app.storage.uploadPublic('avatar.jpg', file, 'image/jpeg')
const url = app.storage.publicUrl('avatar.jpg')  // works in <img src>

// List, download, delete
const files = await app.storage.list()
const response = await app.storage.download('docs/resume.pdf')
await app.storage.delete('docs/resume.pdf')

Usage tracking (auto-on; drives creator payouts)

ProAppStore is a single $5/mo subscription that unlocks every Pro app. Creators are paid monthly from the pool (minus the 10% platform fee) in proportion to their app's share of total usage. To compute that, the SDK heartbeats POST /v1/usage/ping every 60 seconds while the tab is visible and the user is signed in.

Auto-started by initPro() — you don't need to do anything for your app's usage to count toward your payout. Hidden tabs don't accrue time; closed tabs flush a final ping via navigator.sendBeacon.

// Default behavior — telemetry on
const app = initPro({ appId: 'my-app' })

// Opt out (your app won't count toward payouts; you also won't see analytics)
const app = initPro({ appId: 'my-app', usage: { auto: false } })

// Manual controls (rarely needed)
app.usage.start()              // idempotent
app.usage.stop()               // halt heartbeats
app.usage.recordApiCall(1)     // piggybacks on next heartbeat
app.usage.flush()              // final ping (called automatically on pagehide)

What we record (also documented at https://proappstore.online/privacy#usage-analytics): per (app, user, day) rollups of session-seconds and API calls. No event-by-event logs, no IP, nothing while the tab is hidden or the user is signed out.

Error monitoring (app.logs)

Auto-started by initPro() — uncaught errors and unhandled promise rejections are captured and uploaded, so a user-visible failure leaves a platform record you can read afterwards instead of relying on "it said something about permissions".

// Default — monitoring on, no code required
const app = initPro({ appId: 'my-app' })

// Attach build metadata so a report names the deploy it came from
const app = initPro({
  appId: 'my-app',
  monitoring: { build: { sha: __COMMIT_SHA__, version: '1.4.2' } },
})

// Opt out
const app = initPro({ appId: 'my-app', monitoring: { auto: false } })

// Log deliberately
app.logs.error('checkout failed', { step: 'confirm' })
app.logs.warn('slow board render')
app.logs.info('tournament joined')
app.logs.capture('warn', 'checkout', 'card declined')  // your own category

// A caught error, with its cause chain; a render error from your own boundary
app.logs.captureException(err, { operation: 'checkout' })
app.logs.captureReactError(error, info.componentStack)  // in componentDidCatch

// Breadcrumbs: what led up to a failure (in memory only, newest 50, redacted)
app.logs.addBreadcrumb('navigation', 'opened game', { mode: 'blitz' })

// Delivery health: { queued, sent, dropped, duplicates, breadcrumbs, disabled, quietUntil }
app.logs.health()

// Manual controls (rarely needed)
app.logs.flush()            // fire-and-forget; also runs on pagehide
await app.logs.flushAsync() // await the upload
app.logs.stop()

Read them back as the app owner — the console's per-app view, or directly:

GET /v1/apps/:appId/logs          # rows: level, category, fingerprint, source
GET /v1/apps/:appId/logs/groups   # occurrences, affected clients, first/last seen

Works signed out. A crash before sign-in is the report most worth having, so entries upload anonymously, tagged with a rotating per-install clientId (browser storage only, clears with site data, never correlated across apps).

Never log secrets. Messages and payloads are scrubbed on both sides for password/token/bearer/JWT/email-shaped content, but that is a backstop:

Do not put passwords, tokens, student identifiers, or raw request bodies into a log message or its data.

Limits: debug is dropped unless you set monitoring: { level: 'debug' }; 100 entries per upload, 4 KB per entry; each app has a daily budget, and past it entries are still counted (so spikes stay visible) while detail stops and the SDK goes quiet for a minute rather than retrying. Detail rows are pruned after 30 days. Grouping works on minified stacks, and build identifies the deploy.

Readable stacks (opt-in). Build with hidden source maps (build: { sourcemap: 'hidden' } in vite.config) and pass the commit as build.sha: the canonical deploy then uploads the maps to the platform's private store for that commit (keyless, no secret) and strips them before the public upload, so they are never served. The owner reads mapped frames with GET /v1/apps/:appId/logs?symbolicate=true; each row resolves only against the release its own build.sha names, for 30 days, and otherwise says why (missing, unavailable, mismatched) with the raw stack kept. Outside the canonical workflow, pas sourcemaps upload does the same from GitHub Actions. See docs/build-and-deploy.md.

Best-effort, and never in the way. Telemetry never delays a move or an operation result, and never changes one. Delivery can lose entries:

  • The queue keeps at most 200 entries; the oldest are dropped first.
  • A network error, timeout (10 s) or 5xx is retried after 1, 2 and 4 seconds. The batch is dropped if it still fails, or 60 seconds after its first failure.
  • Offline, the queue waits and is sent on online.
  • A closing tab gets one last best-effort send.

Breadcrumbs (navigation, ui, network, room, auth, action) take up to eight primitive values; other categories, objects and arrays are ignored. The buffer clears when the user signs out or changes. Never put form text, DOM content, tokens or user data in a breadcrumb.

A call to app.actions.call() that gets no response is recorded client-side: a network failure or a timeout is one error entry with the call's trace (no server saw it). A cancellation you asked for ({ signal }) is only a breadcrumb. Every other platform call (app.db, app.kv, storage, …) is recorded the same way, as operation: 'platform.<family>' with no trace, so repeats fold into one entry. Rooms record disconnects and reconnects as breadcrumbs, and a failed recovery as one error entry. See docs/sdk-overview.md → "Exceptions, breadcrumbs and delivery health".

app.logs is independent of app.usage: usage measures engagement, logs record faults.

Notifications (Web Push)

Push notifications to your users. Subscribe from the browser, send targeted or broadcast pushes from your app (creator-only).

// User side — subscribe to push notifications
await app.notifications.subscribe()        // requests permission + registers SW
await app.notifications.unsubscribe()
const subscribed = await app.notifications.isSubscribed()
const permission = app.notifications.getPermission()  // 'granted' | 'denied' | 'default'

// Creator side — send notifications
await app.notifications.send('user-123', {
  title: 'Event starting!',
  body: 'The meetup begins in 10 minutes.',
  url: '/events/evt-1',                   // opens on click
})

// Broadcast to all subscribers
await app.notifications.broadcast({
  title: 'New feature!',
  body: 'Check out the new map view.',
})

// Peer-to-peer: notify another user in the same app (no creator check)
await app.notifications.notifyUser('gh:123', {
  title: '@serge mentioned you',
  body: 'In "Wire the broadcast"',
  url: 'https://kanban.proappstore.online/#/...',
  tag: 'mention:card-1',
})
// Rate-limited: 30/min per user per app

Your app needs a service worker for push. Save Notifications.getServiceWorkerScript() as /sw.js, or append it to an existing one:

import { Notifications } from '@proappstore/sdk'

// Generate sw.js content
const swCode = Notifications.getServiceWorkerScript()

SMS

Send text messages via the platform (Twilio-backed server-side). The platform owns the Twilio credentials — your app never sees them. Creator-only. Numbers must be E.164 format (+15551234567).

// Send to one recipient
await app.sms.send('+15551234567', 'Your reservation is confirmed!')

// Broadcast to many
await app.sms.broadcast(
  ['+15551234567', '+15559876543'],
  'Meetup starts in 30 minutes!',
)

AI (Server-side LLM + Embeddings)

Workers AI — text generation, chat, and embeddings included in the platform subscription. No per-app key management; the platform handles billing.

// Text generation
const { text } = await app.ai.generate('Write a haiku about coding')

// With model selection: 'fast' (Llama-3.1-8B) or 'smart' (Llama-3.3-70B)
const { text } = await app.ai.generate('Summarize this article...', {
  model: 'smart',
  maxTokens: 512,
  temperature: 0.7,
})

// Multi-turn chat
const { text } = await app.ai.chat([
  { role: 'system', content: 'You are a helpful event planner.' },
  { role: 'user', content: 'Suggest a venue for 50 people in SF.' },
])

// Embeddings — for search, recommendations, clustering
const { vectors } = await app.ai.embed('vinyasa flow')
// vectors[0] is a 1024-dim float array

// Batch embeddings with model selection: 'm3' (multilingual, 1024-dim) or 'base' (English, 768-dim)
const { vectors, dimensions } = await app.ai.embed(
  ['yoga', 'pilates', 'meditation'],
  { model: 'base' },
)

Tenant Scope (Multi-tenant helpers)

Safe-by-default CRUD helpers for multi-tenant tables. Auto-injects tenant_id on inserts and auto-scopes all reads/writes — prevents accidental cross-tenant data leaks.

// Create a scoped handle for a specific tenant
const tx = app.db.tenant('studio-123')

// All operations are automatically scoped to tenant_id = 'studio-123'
await tx.insert('clients', { id: 'c-1', name: 'Alice' })
const alice = await tx.find('clients', { id: 'c-1' })
const all = await tx.findMany('clients')
const count = await tx.count('clients')
await tx.update('clients', { id: 'c-1' }, { name: 'Alicia' })
await tx.delete('clients', { id: 'c-1' })

// Escape hatch — raw SQL with tenant_id available
const { rows } = await tx.db.query(
  'SELECT * FROM clients WHERE name LIKE ? AND tenant_id = ?',
  ['A%', tx.tenantId],
)

Your multi-tenant tables must have a tenant_id TEXT column. TenantScope doesn't replace app.db.query / app.db.execute — use those for joins, aggregates, or cross-tenant admin queries.

Roles (App-level RBAC)

Per-app role management. Every app gets a set of default roles out of the box:

| Role | How assigned | Description | |------|-------------|-------------| | owner | Automatic (app creator) | Full control — cannot be revoked | | member | Default for new users | Basic access | | moderator | Assigned by owner | Content moderation privileges | | editor | Assigned by owner | Can create and edit content | | viewer | Assigned by owner | Read-only access |

Custom roles are supported — pass any string as a role name.

// Assign a role
await app.roles.assign('user-456', 'moderator')

// Revoke a role
await app.roles.revoke('user-456', 'moderator')

// Check if the current user has a role
const isMod = await app.roles.check('moderator')

// List all roles for the current user
const myRoles = await app.roles.myRoles()
// ['member', 'moderator']

// List all role assignments for the app (owner-only)
const all = await app.roles.listAll()
// [{ userId: 'user-456', role: 'moderator' }, ...]

React Hooks

Every app is wrapped in ProShell, which already runs the sign-in and subscription gates. Use these hooks inside its screens to read identity and subscription state, or to gate a single screen. Import from @proappstore/sdk or @proappstore/sdk/hooks.

Account deletion (per app)

Deleting an account removes the user's data in this app only. The ProAppStore sign-in, the subscription and every other app are untouched. useProAuth().deleteAccount and the built-in "Delete account" menu items call app.account.delete(), which:

  1. runs your handler first. Delete or anonymise your own database rows there. If the handler throws, the deletion stops.
  2. has the platform delete the user's kv, files, public uploads (including unreferenced ones), review uploads, app roles, push subscriptions and app tokens in this app, and record the user as deleted for this app.
  3. signs out.
app.account.onDelete(async () => { await app.actions.call('delete_my_data', {}); });
await app.account.delete();
await app.account.isDeleted();   // true once deleted in this app, so the app can refuse them
await app.account.publicFiles(); // the user's public uploads in this app

useProAuth

Auth state + actions. The primary way apps interact with platform identity.

import { initPro } from '@proappstore/sdk'
import { useProAuth } from '@proappstore/sdk/hooks'

const app = initPro({ appId: 'my-app' })

// A screen inside <ProShell>: the shell has already signed the user in.
function Welcome() {
  const { user, signOut } = useProAuth(app)
  return <p>Welcome, {user?.login}! <button onClick={signOut}>Sign out</button></p>
}

useProSubscription

Subscription state + actions. Check if user is subscribed, upgrade, manage billing.

import { useProSubscription } from '@proappstore/sdk/hooks'

function Billing() {
  const { subscription, isPro, loading, upgrade, manageBilling } = useProSubscription(app)
  if (loading) return <p>Loading...</p>
  if (!isPro) return <button onClick={() => upgrade()}>Upgrade to Pro</button>
  return <button onClick={manageBilling}>Manage billing</button>
}

useProGate

Combined auth + subscription gate. Returns a single gate state for easy conditional rendering. ProShell gates the whole app; use this for one Pro-only screen in an app that otherwise allows free users.

import { initPro } from '@proappstore/sdk'
import { useProGate } from '@proappstore/sdk/hooks'

const app = initPro({ appId: 'my-app' })

// A screen inside <ProShell allowFree>: only this screen needs a subscription.
function ReportsScreen() {
  const { gate, upgrade } = useProGate(app, { allowFree: false })

  if (gate === 'loading') return <p>Loading...</p>
  if (gate === 'no-subscription') return <button onClick={() => upgrade()}>Upgrade</button>
  return <Reports />
}

Gate states: 'loading' | 'signed-out' | 'no-subscription' | 'ready'

allowFree defaults to true (free users pass); pass { allowFree: false } to require an active subscription.

Custom admin panels: AdminConsole, useAdminContext, useAction

A custom admin panel is app code on the app's own origin; nothing runs inside the PAS console. It reaches data only through the app's declared actions, and the platform enforces auth, roles, step-up and audit on every call (#299).

import { AdminConsole, useAdminContext, useAction, ActionError } from '@proappstore/sdk/hooks'

function Moderation() {
  const { app, user, roles, session } = useAdminContext()
  const deleteGroup = useAction('admin_delete_group', {
    // The action declares step_up: run a passkey check on this origin
    // (e.needsPasskey is true for app actions), then resolve true to retry
    // once or false to give up. Signing in again alone is refused again.
    onStepUp: (e: ActionError) => showReauth(e),
  })
  // Signed-out visitors have no roles to load: check the session first (#344).
  if (session.status === 'pending') return <p>Loading…</p>
  if (session.status === 'signed-out') return <p>Sign in to moderate.</p>
  if (session.rolesError) return <p>Couldn't load your roles. <button onClick={() => session.refreshRoles()}>Retry</button></p>
  if (!session.rolesLoaded) return <p>Loading…</p>
  if (!roles.includes('admin')) return <p>You don't hold the admin role.</p>
  return <button disabled={deleteGroup.pending} onClick={() => deleteGroup({ group_id: 'g1' })}>Delete</button>
}

<AdminConsole app={app}><Moderation /></AdminConsole>
  • useAdminContext() → { app: { id }, user, roles, session: { status, rolesLoaded, rolesError, refreshRoles } }. roles come from the server and are for rendering only; never a session token. rolesLoaded is false while signed out and while loading; a failed fetch sets rolesError (an empty roles without it means the caller holds none) and refreshRoles() retries.
  • useAction(name, { onStepUp }) returns a function that calls the action, plus pending, error (the latest call's failure; an older call settling later never overwrites it) and reset(). A refusal rejects with an ActionError: forbidden only when the caller's roles do not allow it (requires app role / requires platform role; other 403s such as a private app or a worker-only action keep their own code), stepUpRequired and needsPasskey when it needs a passkey check. An app's step_up action is bound to the app's own relying party (#331), so only a passkey step-up there passes: run /.pas/auth/passkey/step-up/options then /.pas/auth/passkey/step-up (on 404 no_passkey, register one first through /.pas/auth/passkey/register/options and /register within 10 minutes of signing in), then retry (#337). Every outcome is recorded in app.logs under admin.action (action, outcome, status — never params).
  • AdminConsole catches a render error in the panel and records it; AdminErrorBoundary does the same for one part of a panel.

A full sample is templates/template-membership/web/src/pages/Moderation.tsx.

ProShell Component

The app frame. It handles the auth gates, subscription checks, provider context, the topbar (app name, text size control, user menu), the footer, and the main navigation.

import { initPro, ProShell } from '@proappstore/sdk'

const app = initPro({ appId: 'meetup' })

export default function App() {
  return (
    <ProShell
      app={app}
      appName="Meetup"
      branding="app"
      nav={[
        { label: 'Events', href: '/' },
        { label: 'Groups', href: '/groups' },
      ]}
    >
      <MeetupApp />
    </ProShell>
  )
}

Props:

| Prop | Type | Description | |------|------|-------------| | app | ProAppStore | SDK instance from initPro() | | children | ReactNode | App content (rendered only when gates pass) | | appName | string? | Name shown in the topbar | | branding | 'platform' \| 'app'? | Branding mode. platform is the backwards-compatible default; app keeps app name, navigation, sign-in, subscription enforcement, text-size and account controls, while omitting PAS/ProAppStore/PRO advertising (including signed-out and subscriber states). New independently branded apps should pass "app". | | nav | { label: string; href: string; icon?: ReactNode; title?: string }[] | The app's screens, rendered as the main navigation in the topbar | | onNavigate | (href: string) => void | Client-side navigation for nav clicks (e.g. a router's navigate); without it items are links | | renderNav | (ctx) => ReactNode | Replace the built-in NavBar (ctx: items, currentPath, onNavigate) | | allowFree | boolean? | Skip subscription gate (default: true until platform billing is live) | | showThemeToggle | boolean? | Show theme toggle in the profile menu | | menuItems | { label: string; onClick: () => void }[] | Extra profile dropdown items | | hideTopbar | boolean? | Omit the default topbar (and the navigation) | | hideFooter | boolean? | Omit the default footer | | renderTopbar | (ctx) => ReactNode | Replace the default topbar; place ctx.nav in it | | renderFooter | (ctx) => ReactNode | Replace the default footer | | renderError | ({ error, reset }) => ReactNode | Replace the error-boundary fallback (errors are recorded via app.logs) | | renderLoading | () => ReactNode | Replace the spinner shown while a lazy screen loads |

ProShell handles:

  • Auth initialization and the sign-in gate

  • The subscription check and upgrade wall (unless allowFree=true)

  • The topbar with app name, text size toggle and user menu (sign out, manage billing, delete account; deletion is per app, see app.account below)

  • The main navigation from nav:

    • a <nav aria-label="Main"> landmark;
    • aria-current="page" on the current route, following location.pathname and back/forward;
    • a menu button below 640 px (aria-expanded, Escape to close);
    • 44 px targets and visible focus.

    Its styles are injected when the navbar renders, so no setup is needed. They are also available as @proappstore/sdk/shell.css.

  • Resilience and feedback:

    • an error boundary (with a Try again fallback, errors recorded via app.logs) and a Suspense spinner around the content;
    • useToast() for messages in one polite live region;
    • an offline banner, plus useOnline();
    • tab titles from a nav item's title or useDocumentTitle;
    • PageHeader for each screen's one h1;
    • a skip link to <main id="main">;
    • with onNavigate, scroll to top or restore on back/forward, and focus moved to the new screen's heading.

Do not build navigation into a page, and do not stack a second navbar under the shell. A custom topbar keeps the gates and places the rendered nav:

<ProShell
  app={app}
  appName="Chess Academy"
  nav={[{ label: 'Students', href: '/students' }, { label: 'Tournaments', href: '/tournaments' }]}
  renderTopbar={({ appName, nav, profileMenu, textSizeToggle }) => (
    <header className="top-nav">
      <a href="/">{appName}</a>
      {nav}
      {textSizeToggle}
      {profileMenu}
    </header>
  )}
>
  <ChessAcademy />
</ProShell>

For a fully custom layout, use <ProShell app={app} hideTopbar hideFooter> and compose NavBar, ProfileMenu, SignInButton, GateScreen, and hooks from @proappstore/sdk/ui and @proappstore/sdk/hooks.

New templates explicitly use branding="app"; the SDK default remains branding="platform" so upgrading an existing app does not silently change its visual chrome. App mode removes PAS/ProAppStore/PRO advertising from the ready shell and signed-out/subscription gates, but retains the real sign-in, subscription and billing controls. Apps that intentionally want legacy platform attribution can omit the prop or pass branding="platform"; renderTopbar and renderFooter remain explicit customisation hooks.

The children render only after the gates pass: signed-out visitors see the sign-in screen with no topbar or navigation, and platform-cookie apps show it briefly to returning users while the session hydrates. Routing caveats (routers, hash routing) and a step-by-step guide to replacing a hand-rolled header or navbar are in the SDK overview.

UI Components

Import from @proappstore/sdk/ui:

import { Avatar, SignInButton, ThemeToggle, TextSizeToggle, ProBadge, ProfileMenu } from '@proappstore/sdk/ui'
  • TextSizeToggle -- A/A+/A- button, cycles default/large/small text size. No props. Persists to localStorage.
  • ThemeToggle -- Sun/moon button, cycles system/light/dark. No props.

See the UI Component Library for the full list.

Per-app SQL Database

Each Pro app is provisioned with a dedicated Cloudflare D1 database fronted by a data worker (data-{appId}.proappstore.online). The SDK's db module provides a low-level client for this worker.

The data worker verifies the PAS session locally before executing queries, but raw SQL is not a row-level authorization boundary. Browser-facing app data should use registered actions (app.actions.call) so user ids and role checks are enforced by the platform before SQL reaches the data worker.

Tables are user-defined (create them via db.execute('CREATE TABLE IF NOT EXISTS ...')). The schema is entirely up to the app developer.

Monitoring (app.logs)

Runtime monitoring is on by default. initPro() captures uncaught error / unhandledrejection events and records failed app.actions.call() (action name + HTTP status only), batching them to POST /v1/apps/:appId/logs. Owners read them back (owner-only) via GET /v1/apps/:appId/logs / the dashboard.

app.logs.error('billing', 'checkout failed', { code });  // your own entries
app.logs.capture(err, 'payments');                       // a caught exception

Entries include route, auth mode, and any monitoring.build metadata. Error entries also carry recent breadcrumbs, and app.logs.health() reports how many entries were queued, sent and dropped. The SDK never logs tokens, credentials, SQL params, or request bodies. Works in both legacy-bearer and platform-cookie modes; flushes on an interval and on pagehide. Opt out with initPro({ monitoring: { auto: false } }).

Ingestion is hardened (issue #108): the app must exist and each (app, user) has a daily ingest quota, so logs can't be spoofed onto other apps or flooded.

License

MIT.