@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:
- Starter: https://github.com/ptxdev/ptengine-app-starter (v3.3.0 requires
@ptengine/app-backend ^0.4.0) - Skill (for humans and AI coding agents): https://github.com/ptxdev/ptengine-skills/tree/main/skills/ptengine-custom-app
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
audcheck bound to this app. - Routing — route keys are
'METHOD /path'with:params, and never carry the/apiprefix (the runtime strips it; a key that includes it is rejected at startup). Matching is first-declared-wins among same-length paths, so declareGET /orders/newbeforeGET /orders/:id. - Undeclared routes 404 before authentication, so paths never leak via error shape.
- One error envelope —
{ error: { code, message, requestId } }. Throwctx.error(status, code, detail)for a controlled response; anything else becomes a 500 whose stack goes to the logs only.detailnever reaches the response body — withctx.errorthe client'smessageis just thecode. To show the front end a human-readable message, throwbadRequest(code, message)/unauthorized/forbidden/notFound(exported from the package), ornew AppError(status, code, message, detail?)for any other status — thatmessageis what the client receives. - Structured logs — one JSON line per
ctx.log()carryingrequestId/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/__healthroute 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
varsandsecretsis a semantic boundary, not a security one. At runtime both are just string bindings onenvand the runtime cannot tell them apart —ctx.vars.SOME_SECRETwould technically read a credential's value. The value of this gate is the immediate error for the developer (a typo throws instead of silently yieldingundefined). 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 treatctx.varsas 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> }timeRangeis 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'sscopes(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 devhas no gateway binding either, whatever the manifest says — there is no platform gateway locally. SoPT_GATEWAY_NOT_BOUND(501) underwrangler devmeans "verify this on a deployed version", not "fix your manifest". The same holds forctx.pt.asset.*andctx.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.authand 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_DENIED403 /TOO_MANY_CONCURRENT_QUERIES429 /UNKNOWN_QUERY_TYPE400 …), because each is actionable; only an unparseable error envelope degrades toPT_QUERY_FAILED. The upstream response body goes to the logs only, never into the error message. ctx.pt.describe()is not open yet and throwsPT_GATEWAY_NOT_BOUND(501) — the front end can usePtApp.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 (omitfolderIdfor the root). Fixed cursor pagination: pass the previous page'snextCursor, and trusthasMore— short pages are normal.sizeis 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 withlist({ folderId }).get({ type, id, include? })→ detail — one node's detail.typeis onlyfolder/page/group/studio; image / font / shortcut nodes carry all their fields inlistalready. A studio'ssources[].textContentis omitted by default (it can be large) — passinclude: ['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.
keywordmatches names inside one folder: pass itsfolderId; addscope: 'subtree'to also match inside its sub-folders. WithoutfolderIdthere 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 oneensureFolderreturned).
textContentexists only for text sources — text pasted in the product UI (sources[].type === 'text'). Files added withuploadSource(or uploaded in the UI) arefile/imagesources: theirtextContentis alwaysnull, and their content is aturl.
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 idassetId. On the read side that same value is the node'sid— pass it straight toget({ type: 'studio', id }), and match it againstnode.idwhen looking for what you just wrote. TheassetIdfield on a read node is a different id: the permission system's (pacl) asset id. Never compare a writtenassetIdwith a node'sassetId.
- 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 yieldTOKEN_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 noasset:write— then the app has no gateway binding. An app that declares some other data scope but notasset:readdoes have the binding, and these calls fail upstream with 403SCOPE_DENIEDinstead. Under localwrangler devthere is never a binding, so 501 is expected there. - Upstream error codes pass through verbatim (
SCOPE_DENIED403 withrequiredScopeinAppError.detail,ASSET_TYPE_UNSUPPORTED400,NOT_FOUND404 when the asset does not exist …); only an unparseable envelope degrades toPT_ASSET_FAILED. Also expect 403FORBIDDEN: 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 declarederrorCodes. The list is not exhaustive; keep a generic fallback. ASSET_TYPE_UNSUPPORTEDmeansgetwas called with atypeit does not serve (for examplecomponent, not open yet) — read that node fromlistinstead.listanswers the same code when itstypesfilter 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
getis 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 byasset-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 noseedKeyparameter: 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.folderIdis 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 anAppErrorcarrying the already-created id, so retry the write instead of callingcreateagain — callingcreateagain 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; } }
contentmust be aPtAssetDoc. The upstream treats it as an opaque blob — it validateskindand 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?" withtypeof content.name === 'string' && typeof content.content === 'string', which is why those two are required and the rest are optional.contentis 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_specandproposalkeep 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.
updatereplacescontentwholesale — it does not merge. Changing only the body still means sendingnameagain, otherwise the new blob has nonameand 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.bodymay be a string or aReadableStream, 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 withget, an uploaded file is afile/imagesource: itstextContentis alwaysnull— read itsurl.
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 idassetId. On the read side that same value is the node'sid— pass it straight toget({ type: 'studio', id }), and match it againstnode.idwhen looking for what you just wrote. TheassetIdfield on a read node is a different id: the permission system's (pacl) asset id. Never compare a writtenassetIdwith a node'sassetId.
- Same identity rules as
ctx.pt.query(): the call runs as the caller (this request's App Token is forwarded verbatim), so public routes yieldTOKEN_MISSING(401), and an app without the scope has no gateway binding →PT_GATEWAY_NOT_BOUND(501) — as does localwrangler dev, regardless of the manifest. - Upstream error codes pass through verbatim (
SCOPE_DENIED403 /TOO_MANY_CONCURRENT_QUERIES429 /CAPABILITY_UNKNOWN400 …); only an unparseable envelope degrades toPT_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
- Full
ctxreference and hard boundaries:references/backend-runtime.mdin the skill - Repository: https://gitlab.com/PtmindDev/custom-apps/app-backend
- Starter: https://github.com/ptxdev/ptengine-app-starter
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.
