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

@ptengine/app-backend

v0.8.3

Published

Backend runtime for Ptengine Custom Apps: router, App Token auth, typed ctx (D1/KV/secrets/vars/Ptengine data queries). Not a general-purpose library.

Readme

@ptengine/app-backend

The backend runtime for Ptengine Custom Apps. Not a general-purpose library — it only makes sense inside Ptengine's managed app runtime.

A Custom App backend is one Cloudflare Worker the platform deploys for you. You never own a Cloudflare account, never write production deployment config, and never wire your own authentication: you export one createApp() and the runtime does the rest.

Start here

Don't wire this package up by hand. Scaffold from the starter and follow the skill:

This package is normally consumed through the starter's backend/ folder.

// backend/src/index.ts
import { createApp } from '@ptengine/app-backend';
import type { ApiRoutes } from '../../shared/api';

export default createApp<ApiRoutes>({
    routes: {
        'GET /orders': async ctx => {
            const cached = await ctx.kv.get('orders', 'json');
            if (cached) return cached;
            const res = await ctx.fetch('https://api.shopify.com/…', {
                headers: { 'X-Shopify-Access-Token': ctx.secrets.SHOPIFY_TOKEN }
            });
            if (!res.ok) throw ctx.error(502, 'SHOPIFY_UNAVAILABLE');
            return res.json();
        },
        'GET /public/status': ctx => ({ ok: true })
    },
    publicRoutes: ['GET /public/status'],
    onError: (err, { requestId, route }) => undefined // optional; undefined = default envelope
});

What the runtime handles, so you must not re-implement it:

  • Token verification — Ed25519 (EdDSA) App Token, JWKS with a kid cache and clock-skew tolerance, and an aud check bound to this app.
  • Routing — route keys are 'METHOD /path' with :params, and never carry the /api prefix (the runtime strips it; a key that includes it is rejected at startup). Matching is first-declared-wins among same-length paths, so declare GET /orders/new before GET /orders/:id.
  • Undeclared routes 404 before authentication, so paths never leak via error shape.
  • One error envelope — { error: { code, message, requestId } }. Throw ctx.error(status, code, detail) for a controlled response; anything else becomes a 500 whose stack goes to the logs only. detail never reaches the response body — with ctx.error the client's message is just the code. To show the front end a human-readable message, throw badRequest(code, message) / unauthorized / forbidden / notFound (exported from the package), or new AppError(status, code, message, detail?) for any other status — that message is what the client receives.
  • Structured logs — one JSON line per ctx.log() carrying requestId / appId / versionId / route / userId / sid.
  • A built-in probe at /api/__health (unauthenticated, identity only). The platform calls it after deploy and rolls back automatically if it fails. Declaring your own /__health route throws at startup.
  • R2 key prefixing per app via ctx.files.

Use ctx, never env. The production env is assembled by the platform and does not match your local wrangler.jsonc; ctx is the stable contract and enforces the boundaries.

ctx reference

| Field | Notes | |---|---| | ctx.auth | { userId, sid, workspaceId, scopes, email?, name?, frontendVersionId? }, verified and frozen. Throws AUTH_NOT_AVAILABLE on a public route — use ctx.authOrNull there | | ctx.authOrNull | Same data, but null on a public route instead of throwing | | ctx.workspaceId | The workspace this call came from. Not ctx.app.workspaceId (that is who owns the app — identical for every installer of an official app). Falls back to ctx.app.workspaceId when there is no token | | ctx.scopes | Approved scopes from the token, read-only ([] on a public route). Use it for "do a bit more if present"; use ctx.requireScope(...) to enforce | | ctx.app | { appId, workspaceId, versionId, env } — env is production / staging / development | | ctx.params / ctx.query / ctx.body / ctx.request | query values are always strings — no implicit conversion, so do Number(ctx.query.days ?? '7') yourself. A non-JSON content type lands in body as text; malformed JSON is a 400 BODY_INVALID_JSON | | ctx.db | D1. Needs backend.resources.database: true, else 500 RESOURCE_NOT_DECLARED | | ctx.kv | KV. Needs backend.resources.kv: true. Minimum expirationTtl is 60s | | ctx.files | R2 with keys auto-prefixed per app. Needs backend.resources.files: true, which is rejected for customer apps (RESOURCE_NOT_ALLOWED) — official apps only | | ctx.pt.query(queryType, params) | Server-side Ptengine data query (see below). ctx.pt.describe() is not open yet (501) | | ctx.fetch(url, init) | Outbound fetch with a 10s default timeoutMs and structured logging; a caller-supplied signal still works. Reachability is decided by the manifest's outbound allow-list, not by this call | | ctx.secrets.X / ctx.vars.X | Only manifest-declared names resolve — see below | | ctx.requireScope(...scopes) | 403 SCOPE_REQUIRED when the token lacks it; 401 on a public route (no identity at all) | | ctx.error(status, code, detail?) | Returns an AppError to throw. detail goes to the logs only, never to the client, and the envelope's message is just code. For a client-facing message use badRequest(code, message) & co. or new AppError(status, code, message). AppError is exported — your own catch can read e.detail | | ctx.log(msg, fields?) | One structured JSON line | | ctx.waitUntil(p) | After-the-response work. It must not be required for the response to be correct | | ctx.requestId | The id echoed in every error envelope and log line — surface it in your UI |

Not available (don't design around them)

Durable Objects, raw TCP connect(), caches.default, request.cf, mTLS client certificates, Queues, Workflows, and Cron Triggers / scheduled() — cron config is silently dropped at deploy time: no error, no warning, the job simply never runs. There is no scheduling primitive yet, so anything periodic must be triggered by a request.

Public routes

publicRoutes lists route keys verbatim — exactly as they appear in routes, method and leading slash included. No wildcards. A key that is not in routes throws at startup. Every other route is authenticated, so ctx.auth has no "unverified" state.

Public routes have no identity at all: ctx.auth throws (AUTH_NOT_AVAILABLE), use ctx.authOrNull. Never read or write tenant data there, and note that ctx.pt.query() cannot work there either (401 TOKEN_MISSING).

Config and credentials: ctx.vars / ctx.secrets

| Read via | Declared in | Value filled in on | How a changed value takes effect | |---|---|---|---| | ctx.vars.API_BASE | backend.vars | the app admin page, Configuration tab | Vars are baked into the worker at deploy time. After editing the value, click Redeploy (重新部署) to redeploy the current version — no new version upload needed | | ctx.secrets.SHOPIFY_TOKEN | backend.secrets | the app admin page, Secrets tab | Changing the value of an already bound secret takes effect immediately — the worker stores a Secrets Store reference, not the value. But a binding is only baked in for secrets that already exist in the store at publish/redeploy time, so the first time a declared secret is filled in (an optional one left empty at publish, or one declared in a new version and filled after publishing) there is no binding yet and ctx.secrets.NAME fails until you click Redeploy (重新部署) |

Both throw on an undeclared name instead of yielding undefined: ctx.vars.NOPE → VAR_NOT_DECLARED, ctx.secrets.NOPE → SECRET_NOT_DECLARED. Otherwise you would hand undefined to a third-party API and spend an afternoon debugging a 401. An empty string is a legitimate value (leaving a config blank on the admin page is meaningful) and is not treated as "not set". PT_ is a reserved platform prefix — both accessors refuse it.

Locally both live in the same backend/.dev.vars file, one NAME=value per line (ptx dev reads it; it is git-ignored — keep it that way).

⚠️ The line between vars and secrets is a semantic boundary, not a security one. At runtime both are just string bindings on env and the runtime cannot tell them apart — ctx.vars.SOME_SECRET would technically read a credential's value. The value of this gate is the immediate error for the developer (a typo throws instead of silently yielding undefined). The real boundaries are elsewhere: manifest validation rejects the same name in both lists, and credential values live only in the Secrets Store and never land in version records. Don't treat ctx.vars as a guarantee that no secret is readable there.

Querying Ptengine data: ctx.pt.query()

Reads Ptengine analytics server-side; the request goes through the platform's data gateway.

const r = await ctx.pt.query('traffic_insight', {
    timeRange: { key: 'lastDays', days: 7 }
});
// r: { columns: string[]; rows: unknown[][]; rowCount: number; metadata: Record<string, unknown> }
  • timeRange is a required object, not a legacy string preset: { key: 'lastDays', days: 7 }, { key: 'thisMonth' }, or { key: 'custom', startTime: '2026/08/01', endTime: '2026/08/20' } (slash format; dates are cut in the site profile's timezone). Other param shapes are per queryType — never write them from memory; page_insight, for instance, also needs a page scope.
  • Declare the data scopes you need in manifest.json's scopes (analytics:read / profile:read / user:read), publish, and have a workspace admin approve them. An app without them gets no gateway binding → PT_GATEWAY_NOT_BOUND (501).
  • Local wrangler dev has no gateway binding either, whatever the manifest says — there is no platform gateway locally. So PT_GATEWAY_NOT_BOUND (501) under wrangler dev means "verify this on a deployed version", not "fix your manifest". The same holds for ctx.pt.asset.* and ctx.pt.ai.*; the error message spells out both causes.
  • Queries run as the caller: the runtime forwards this request's App Token to the gateway verbatim, so the data scope is exactly what that person can see in that workspace. The token is not on ctx.auth and is never stored — it is read from the request header once, at the moment of forwarding.
  • Public routes have no token, so calling it there yields TOKEN_MISSING (401).
  • Upstream error codes pass through verbatim (SCOPE_DENIED 403 / TOO_MANY_CONCURRENT_QUERIES 429 / UNKNOWN_QUERY_TYPE 400 …), because each is actionable; only an unparseable error envelope degrades to PT_QUERY_FAILED. The upstream response body goes to the logs only, never into the error message.
  • ctx.pt.describe() is not open yet and throws PT_GATEWAY_NOT_BOUND (501) — the front end can use PtApp.data.describe().

Reading Ptengine assets: ctx.pt.asset.list / sharedWithMe / get

Browses the caller's Ptengine Drive asset tree server-side, through the same data gateway as ctx.pt.query(). Requires asset:read in manifest.json's scopes (a read scope: apps built in the same workspace are treated as already granted; needs @ptengine/app-sdk ≥2.8.0).

  • list({ folderId?, types?, keyword?, scope?, cursor?, size? }) → { items, nextCursor, hasMore } — the nodes under one folder (omit folderId for the root). Fixed cursor pagination: pass the previous page's nextCursor, and trust hasMore — short pages are normal. size is 1..100, default 50. scope: 'subtree' searches below the folder instead of only its children (see "Searching is per folder" below).
  • sharedWithMe({ cursor?, size? }) → { items, nextCursor, hasMore } — the top-level nodes others have shared with the caller (they live in someone else's personal space, so the root never reaches them). Walk down from there with list({ folderId }).
  • get({ type, id, include? }) → detail — one node's detail. type is only folder / page / group / studio; image / font / shortcut nodes carry all their fields in list already. A studio's sources[].textContent is omitted by default (it can be large) — pass include: ['sourceText'] to get it (text sources only — see below).
// Read ONE folder (one page) and open ONE node — not a get per studio.
const folderId = 'f_xxx'; // a folder id you got from the front end or from a previous list
const page = await ctx.pt.asset.list({ folderId, types: ['studio'], size: 20 });
const first = page.items[0];
if (first) {
    const studio = await ctx.pt.asset.get({ type: 'studio', id: first.id });
    // …
}
// Next page, if you really need it: pass nextCursor straight back (null = omitted).
// const next = await ctx.pt.asset.list({ folderId, types: ['studio'], cursor: page.nextCursor });

Searching is per folder. keyword matches names inside one folder: pass its folderId; add scope: 'subtree' to also match inside its sub-folders. Without folderId there is no cross-tree search — the root listing only matches the root's own entries (for users with restricted permissions, just their visible top-level entries). This mirrors the product's own Drive search, which searches the open folder. To find something you wrote, start from the folder you wrote it into (e.g. the one ensureFolder returned).

textContent exists only for text sources — text pasted in the product UI (sources[].type === 'text'). Files added with uploadSource (or uploaded in the UI) are file / image sources: their textContent is always null, and their content is at url.

Each list page and each get is one subrequest against the Worker's per-invocation quota (see below) — do not loop get over every node of a page.

Which id is which. The write calls (create / update / uploadSource) call the Studio entity's id assetId. On the read side that same value is the node's id — pass it straight to get({ type: 'studio', id }), and match it against node.id when looking for what you just wrote. The assetId field on a read node is a different id: the permission system's (pacl) asset id. Never compare a written assetId with a node's assetId.

  • Same identity rules as ctx.pt.query(): the call runs as the caller, and what it sees is exactly what the caller can see in the product. Public routes yield TOKEN_MISSING (401). Once deployed, PT_GATEWAY_NOT_BOUND (501) only happens when the manifest declares no data scope at all (analytics:read / profile:read / user:read / openapi:read / asset:read) and no asset:write — then the app has no gateway binding. An app that declares some other data scope but not asset:read does have the binding, and these calls fail upstream with 403 SCOPE_DENIED instead. Under local wrangler dev there is never a binding, so 501 is expected there.
  • Upstream error codes pass through verbatim (SCOPE_DENIED 403 with requiredScope in AppError.detail, ASSET_TYPE_UNSUPPORTED 400, NOT_FOUND 404 when the asset does not exist …); only an unparseable envelope degrades to PT_ASSET_FAILED. Also expect 403 FORBIDDEN: the asset exists but the caller's permissions deny it — the permission service's own code, passed through verbatim and not among the schema's declared errorCodes. The list is not exhaustive; keep a generic fallback.
  • ASSET_TYPE_UNSUPPORTED means get was called with a type it does not serve (for example component, not open yet) — read that node from list instead. list answers the same code when its types filter names a value outside the 8 open node types (e.g. insight). Group details (get({ type: 'group' })) rely on the permission service filtering member pages by the caller's access (iam §6 #1); that fix ships in the same batch as this capability.
  • Walk whole trees in the front end, not here. Every page and every get is one subrequest, and a Worker invocation has a subrequest quota; a recursive crawl of a large Drive exhausts it. Keep backend reads targeted (one folder, one detail).
  • Types are exported: PtAssetListInput, PtAssetSharedInput, PtAssetGetInput, PtAssetListResult, PtAssetDetail, PtDriveNodeMeta, PtDriveNodeType, PtGettableAssetType. They mirror only the top-level shape (plus an index signature); the full per-type field sets are defined by asset-read.schema.json, shipped in @ptengine/app-sdk.
  • ⚠️ These capabilities need the upstream Ptengine read endpoints, which ship separately. Until those are live, the calls fail at the gateway.

Writing Ptengine assets: ctx.pt.asset.*

Creates and updates Ptengine assets (dashboards, reports and the like) server-side, through the same data gateway as ctx.pt.query(). Requires asset:write in manifest.json's scopes.

  • ensureFolder({ name }) → { folderId } — idempotently gets this app's own folder under the caller's personal root. There is deliberately no seedKey parameter: the folder's stable identity is derived server-side from the authenticated app id, so one app cannot claim another's folder. Renaming or moving the folder still returns the same id.
  • create({ folderId, kind, name, content }) → { assetId } — creates one asset in that folder. folderId is required: without it the server falls back to a per-kind default location that may sit under a different root, where the user won't find it. This runs as two upstream steps (create the shell, then write the content). If the second step fails you get an AppError carrying the already-created id, so retry the write instead of calling create again — calling create again leaves the empty shell behind, and a retry loop fills the library with them:
    import { AppError } from '@ptengine/app-backend';
    try {
        await ctx.pt.asset.create({ folderId, kind, name, content });
    } catch (e) {
        const assetId = e instanceof AppError ? (e.detail as { assetId?: string })?.assetId : undefined;
        if (assetId) { await ctx.pt.asset.update({ assetId, content }); } else { throw e; }
    }

content must be a PtAssetDoc. The upstream treats it as an opaque blob — it validates kind and nothing else — so the wrong shape does not error: it stores fine, returns 200, and then shows up in the asset library as a card with a title and no body. Nothing in the chain tells you; only the user finds out. The product decides "is this the current envelope?" with typeof content.name === 'string' && typeof content.content === 'string', which is why those two are required and the rest are optional. content is Markdown, stored and read back verbatim — never parsed back into discrete fields.

await ctx.pt.asset.create({
    folderId, kind: 'note', name: '本周转化报告',
    content: {
        name: '本周转化报告',          // required — same value as `name` above
        content: '## 结论\n\n转化率环比 +12%…',  // required — Markdown body
        tagline: '环比 +12%', tags: ['weekly', 'conversion']
    }
});

Two kinds opt out: design_spec and proposal keep their own structured content. Apps normally have no business writing those; if you must, assert the type yourself and confirm the shape with the product side first.

update replaces content wholesale — it does not merge. Changing only the body still means sending name again, otherwise the new blob has no name and stops being recognised as the current envelope.

Writing to an asset while the product is running AI generation on it is rejected upstream (HTTP 409) so a user's in-flight generation cannot be clobbered — retry later.

  • update({ assetId, name?, content? }) → { assetId } — renames an existing asset and/or replaces its body. Omitted fields are left untouched.
  • uploadSource({ assetId, filename, contentType, body }) → { sourceId } — attaches a file source (CSV, PDF, image…) to an asset. body may be a string or a ReadableStream, and a stream is never read in this layer — it is passed through verbatim, so large files never hit memory. Size limits and the mime allowlist live upstream only (the same rules as a manual upload in the product UI). Read back with get, an uploaded file is a file / image source: its textContent is always null — read its url.
const { folderId } = await ctx.pt.asset.ensureFolder({ name: 'Weekly reports' });
const { assetId } = await ctx.pt.asset.create({
    folderId, kind: 'dashboard', name: 'Week 39', content: { widgets: [] }
});
await ctx.pt.asset.uploadSource({
    assetId, filename: 'raw.csv', contentType: 'text/csv', body: csvText
});

Which id is which. The write calls (create / update / uploadSource) call the Studio entity's id assetId. On the read side that same value is the node's id — pass it straight to get({ type: 'studio', id }), and match it against node.id when looking for what you just wrote. The assetId field on a read node is a different id: the permission system's (pacl) asset id. Never compare a written assetId with a node's assetId.

  • Same identity rules as ctx.pt.query(): the call runs as the caller (this request's App Token is forwarded verbatim), so public routes yield TOKEN_MISSING (401), and an app without the scope has no gateway binding → PT_GATEWAY_NOT_BOUND (501) — as does local wrangler dev, regardless of the manifest.
  • Upstream error codes pass through verbatim (SCOPE_DENIED 403 / TOO_MANY_CONCURRENT_QUERIES 429 / CAPABILITY_UNKNOWN 400 …); only an unparseable envelope degrades to PT_ASSET_FAILED. Response bodies go to the logs, never into the error message.
  • Result types are exported: PtEnsureFolderResult, PtAssetResult, PtAssetSourceResult.
  • ⚠️ These capabilities need the upstream Ptengine endpoints, which ship separately. Until those are live, the calls reach the gateway and fail there — installing this version alone is not enough to make them work.

Calling the Ptengine Open API: ctx.pt.openApiUrl

ctx.pt.query() covers the query types the data gateway implements. When you need something the gateway doesn't expose yet, your backend can call the Ptengine Open API directly. The platform bakes its base URL for the current environment (something like https://xbackend.ptengine.com/open-api/v1) into the worker and exposes it as ctx.pt.openApiUrl; you bring your own API key.

1. Declare the scope and the key in manifest.json.

{
    "scopes": ["openapi:read"],
    "backend": { "secrets": ["PTENGINE_OPENAPI_KEY"] }
}

openapi:read is what makes the platform bake in the base URL and allow outbound requests to the Open API host. Publish the version and have a workspace admin approve the scope.

2. Fill in the key. A workspace admin pastes the profile's API key on the app admin page, Secrets tab (密钥) — the same place as any other backend.secrets entry, so a changed value takes effect immediately; if the key had never been set before, click Redeploy (重新部署) once so the binding gets baked in. The key itself is created by an Owner/Admin under Experience → Settings → External app integrations → API Keys.

3. Call it from a route.

const res = await ctx.fetch(`${ctx.pt.openApiUrl}/datacenter/query`, {
    method: 'POST',
    headers: {
        'x-api-key': ctx.secrets.PTENGINE_OPENAPI_KEY,
        'content-type': 'application/json'
    },
    body: JSON.stringify({ /* … */ })
});

Use ctx.fetch (not the global fetch) so the call gets the runtime's timeout and egress logging.

| What you see | What it means | What to do | |---|---|---| | PT_OPENAPI_NOT_DECLARED (501) on reading ctx.pt.openApiUrl | The scope isn't declared, so no base URL was baked in | Add openapi:read to scopes, republish, get it approved | | EGRESS_PLATFORM_BLOCKED (403) on the request | Same cause, seen from the outbound side (e.g. a hard-coded URL that skipped the accessor) | Same fix | | 401 with code 4010 from the Open API | No key, or a wrong one | Fill in PTENGINE_OPENAPI_KEY on the Secrets tab, then click Redeploy (重新部署) if the key was never set before | | 429 from the Open API | Plan rate limit (3 / 10 / 30 requests per minute by plan) | Back off and retry; batch your calls |

⚠️ One key per app means one workspace per app. The API key is stored against the app, not against the workspace that installed it, so every caller of a given deployment hits the Open API with the same profile's credentials. That is fine for an in-house app that serves a single workspace, and wrong for a store app installed by many workspaces — for that case wait for the data-gateway version, which carries the caller's identity the way ctx.pt.query() already does.

Identity and workspace

| Field | What it is | With no token (public route / /api/__health) | |---|---|---| | ctx.workspaceId | Which workspace this call came from (wid of the verified token) | Falls back to ctx.app.workspaceId | | ctx.app.workspaceId | Which workspace this app belongs to (baked in at deploy time, unrelated to the caller) | Unchanged | | ctx.scopes | Approved scopes from the token, read-only array | [] | | ctx.auth / ctx.authOrNull | The full verified identity | ctx.auth throws; ctx.authOrNull is null | | ctx.auth.email / ctx.auth.name | The current user's email / name (a platform-signed copy of their Ptengine session account). Optional — older tokens don't carry them, so null-check before reading | Same as above |

ctx.auth.email / ctx.auth.name are for display and attribution only ("who created this row"). The isolation keys are still ctx.workspaceId and ctx.auth.sid — never use email as a primary key or as an authorization input.

ctx.scopes is an observation point for "do a bit more if it's there"; to enforce a scope use ctx.requireScope('analytics:read') (which throws 401 when there is no identity).

Multi-tenancy: partition by ctx.workspaceId

The platform creates resources per app, not per installer: when an official (level 1) app is installed by several workspaces, they share one D1, one KV and one bucket. So every business row must carry the workspace it belongs to.

// ✅ Right: partition by the workspace this call came from
await ctx.db.prepare('INSERT INTO notes (workspace_id, body) VALUES (?, ?)')
    .bind(ctx.workspaceId, body).run();
await ctx.kv.put(`${ctx.workspaceId}:draft`, text);

// ❌ Wrong: ctx.app.workspaceId is "who owns the app" — identical for every installer of
//    an official app, so company A ends up reading company B's data.
await ctx.db.prepare('INSERT INTO notes (workspace_id, body) VALUES (?, ?)')
    .bind(ctx.app.workspaceId, body).run();

A workspace also contains several sites. ctx.auth.sid (the site id in the verified token) is the second isolation key. Anything that is per-site — which is most business data — must store both:

await ctx.db.prepare('INSERT INTO notes (workspace_id, site_id, body) VALUES (?, ?, ?)')
    .bind(ctx.workspaceId, ctx.auth.sid, body).run();

Partitioning by workspace alone lets a user of site A in that workspace read site B's data. The whole ctx.auth object is frozen, so sid cannot be rewritten mid-request.

The read side is the same: every SELECT and KV.list() filters or prefixes on ctx.workspaceId (plus ctx.auth.sid where data is per-site). ctx.files (R2) prefixes keys with apps/<app.workspaceId>/<appId>/files/, where the workspace id is the app owner's (ctx.app.workspaceId, baked in at deploy time) — so that prefix isolates apps from each other, not installers from each other, and it is a runtime convention rather than a platform-enforced boundary. With multiple installers you must still add ${ctx.workspaceId}/ to the key yourself.

Links

History

0.2.0 changed ctx.pt.query from query(req) to query(queryType, params?) and renamed the unbound-gateway code PT_GATEWAY_UNAVAILABLE → PT_GATEWAY_NOT_BOUND. The 0.1.0 path was a 501 placeholder that never worked, so no app depended on its success path.