Self-hostingData and receipts

Data and receipts

A static pwapp makes no network call beyond fetching its own files. The moment a page declares a data source — a list, a table, a form — the runtime fetches over HTTP. This page is written from the engine source and says, per call: where the base URL comes from, which method and path are used, what auth is attached, and what the response must look like. Where the code leaves a question open, it is listed under Unverified, not guessed.

Three transports, one contract

A source that…Goes throughTalks to
reads or writes the app’s own recordsDataService (kind: 'rest')the app’s API base — api.phiwebs.com or your own
is bound to a connection (connector: …)a declared engine on HttpEntityConnectorthe host named in the declaration (Graph, your API…)
has no address (kind: 'entity', no facade)nothing — renders fallback or an empty listnobody

There is one HTTP transport (HttpEntityConnector); every engine is a layer of declared facts on top of it, never a second fetch body.

Source: pwfabric-core/packages/runtime/src/data/manager.ts, pwfabric-core/packages/runtime/src/entity/http-connector.ts.

The data model the runtime reads

Data lives in a page’s receipt, inside two capability entries:

  • platform/persistence → config.dataSources[] (reads) and config.initialState.
  • platform/entities → config.formBindings[] (writes: { formId, sourceId }).

A data source is JSON with these fields (dataSourceSchema):

FieldMeaning
idkey in the binding context — {{products}}
kindrest · static · state · entity
endpoint, methodrest only; method is GET unless stated. {{state.x}} in the endpoint is resolved first
returnsthe property holding the list (data), dotted paths allowed; absent when the body is the array. A named path that is missing throws
authProviderRefa name the host answers with a live token; never a value
query{ filter, sort, pagination } — applied client-side after the fetch, never sent
entityType, connectorentity only — which collection, and which declared connection serves it
fallbackbound as the data when the source cannot be served; __errors.<id> stays set

The query language is filter: { field, operator, value, and?, or? } with operators eq neq gt gte lt lte in contains startsWith, sort: [{ field, direction }], pagination: { offset, limit }. It is applied by applyEntityQuery on whatever rows came back — so a self-host API does not need to implement filtering at all; it only needs to return the rows.

Source: pwfabric-core/packages/contracts/src/fabric/surface-schema.ts (dataSourceSchema), pwfabric-core/packages/runtime/src/entity/types.ts, pwfabric-core/packages/runtime/src/entity/apply-query.ts.

What a receipt is

A receipt is the per-page render input: pwpack/surfaces/<pageKey>.receipt.json, shaped { title?, blocks[], appearance?, capabilities[] }. It is a design-time artifact: the platform’s assembler writes one per page at publish, with entity sources already rewritten into REST sources (next section) and form bindings derived from the plan. The runner fetches it per route with plain fetch() and hands it to the renderer; nothing at runtime generates, validates or posts a receipt back.

The only runtime request that mentions a receipt is the file fetch itself. When the app is hosted on PhiWebs (<slug>-apps.phiwebs.com), the apps-gateway Worker refuses *.receipt.json with 401 SIGN_IN_REQUIRED unless the request carries x-app-session (or a bearer), which it validates against GET {API_ORIGIN}/api/app/:worldId/:projectId/session. On your own static host that gate does not exist: a receipt is a file, served like any other.

Source: pwfabric-core/packages/runner/src/types.ts (SurfaceReceipt), pwfabric-core/packages/runner/src/helpers.ts (fetchJson), pwfabric-core/packages/runner/src/run-app.ts (route()), PhiWebs API (closed source): web-app-assembly-service.ts, PhiWebs apps-gateway Worker (closed source).

Where the base URL comes from

There is no apiBaseUrl option on runApp(). The base is frozen into the bundle at publish and recovered from it at runtime:

  1. The publish route computes appApiBase = ${PUBLIC_API_ORIGIN}/api/app/<worldId>/<projectId>.
  2. The assembler rewrites every kind: 'entity' source with no connector into { kind: 'rest', endpoint: '<appApiBase>/records/<entityType>', method: 'GET', authProviderRef: '<appApiBase>', returns: 'data' }, and adds one such source per type the plan says the page reads.
  3. auth.json writes the platform door as addresses: loginUrl = <appApiBase>/login, registerUrl, logoutUrl, sessionUrl.
  4. The runner derives ownApiBase by stripping /login off auth.json.loginUrl. getAccessToken(ref) answers only when ref === ownApiBase, with the app session token, else the PKCE bridge token, else null — a ref that is not the app’s own door gets nothing, so a token never reaches a third party.

Consequence for a self-host: the addresses are in pwpack/surfaces/*.receipt.json (endpoint, authProviderRef) and pwpack/auth.json. To point the app at a different API you edit those strings; the two must stay equal or no token is attached. An app with no auth.json has no ownApiBase and every request goes out anonymous.

Requests carry Content-Type: application/json, the source’s headers, and Authorization: Bearer <token> when a ref resolves. credentials is 'include' only if DataServiceConfig.platformApiOrigin matches the URL’s origin; the runner never sets that field, so a downloaded app sends credentials: 'omit' — cookies are never used on this path.

Source: PhiWebs API (closed source): phico-projects.ts (appApiBase), PhiWebs API (closed source): web-app-assembly-service.ts (bindReadsToSources), PhiWebs API (closed source): phico-project-service.ts (door URLs), pwfabric-core/packages/runner/src/run-app.ts (ownApiBase, dataServiceConfig.getAccessToken), pwfabric-core/packages/runtime/src/data/manager.ts (fetchRest, isPlatformOrigin).

The endpoint table

Base: <appApiBase> = https://api.phiwebs.com/api/app/:worldId/:projectId. All routes are mounted under /api/app, which is exempt from the platform’s user JWT middleware; appAuth runs instead and resolves the visitor from x-app-session or Authorization: Bearer. A bad token is 401 SESSION_INVALID (or 401 INVALID_TOKEN for an external issuer). No token is allowed unless the project’s identity says required: true, in which case the answer is 401 { error: { code: 'SIGN_IN_REQUIRED', topology, methods?, issuer? } }.

MethodPathAuthBody2xx response
GET/records/:entityType?limit&offsetvisitor (optional unless required)—{ data: Record[], meta: { total, scope, timestamp } }
GET/records/:entityType/:idvisitor—{ data: Record }
POST/records/:entityTypevisitor{ …fields } — the record itself; a lone { data: { …fields } } is unwrapped201 { data: Record }
PATCH/records/:entityType/:idvisitor{ …partial } — same rule as POST{ data: Record }
DELETE/records/:entityType/:idvisitor—{ data: { id, deleted: true } }
POST/loginnone (5/min){ email, password }{ data: { id, principalId, projectId, expiresAt, token, principal } }
POST/registernone (5/min){ email, password, displayName? }201, same shape
POST/oauth/loginnone (5/min){ provider, code, redirectUri, codeVerifier? } or { provider, accessToken }same shape
POST/logoutx-app-session—{ data: { signedOut: true } }
GET/sessionvisitor—{ data: { principal: AppPrincipal | null } }

Record is { id, entityType, …fields, createdAt, updatedAt } — the stored fields are spread to the top level. limit is clamped to 1–500 (default 100); offset ≥ 0; ordering is updatedAt descending. sort, filter and include query parameters are not read by the server. meta.total is the count of the returned page, not of the collection. scope is project or, for a collection the plan marks per-visitor, owner — an owner-scoped read with no visitor matches nothing. Errors are { error: { code, message } } with 400 INVALID_BODY / VALIDATION_FAILED, 403 FORBIDDEN (URL world ≠ project’s world), 404 NOT_FOUND / PROJECT_NOT_FOUND. POST validates against the world’s entity definition and is stopped by the plan’s records ceiling. Sessions live seven days; only a hash is stored.

CORS: the API allows Authorization, Content-Type and X-App-Session from the configured origin list plus any https://*<APPS_HOST_SUFFIX> host. A pwapp served from an origin outside that list is refused at preflight.

Source: PhiWebs API (closed source): app-records.ts, PhiWebs API (closed source): app-auth-routes.ts, PhiWebs API (closed source): app-auth.ts, PhiWebs API (closed source): app-principal-service.ts, PhiWebs API (closed source): app.ts (mounts, authSkipPaths, cors).

Worked example — a list read and a form write

A published catalog page carries this source after assembly:

{ "id": "Product", "kind": "rest", "method": "GET",
  "endpoint": "https://api.phiwebs.com/api/app/w_01H…/p_01J…/records/Product",
  "authProviderRef": "https://api.phiwebs.com/api/app/w_01H…/p_01J…",
  "returns": "data" }

The runtime sends, for a signed-in visitor:

GET /api/app/w_01H…/p_01J…/records/Product HTTP/1.1
Host: api.phiwebs.com
Content-Type: application/json
Authorization: Bearer 8f3c…   ← the x-app-session token, as a bearer

and the API answers (row fields spread; data plucked by returns):

{ "data": [ { "id": "3b1e…", "entityType": "Product", "name": "Pear", "price": 4.5,
              "createdAt": "2026-09-15T09:12:03.000Z", "updatedAt": "2026-09-15T09:12:03.000Z" } ],
  "meta": { "total": 1, "scope": "project", "timestamp": "2026-09-16T08:00:00.000Z" } }

{{Product}} now binds to the array. A form bound with formBindings: [{ formId: "form-1", sourceId: "Product" }] submits through the same source with method: 'POST' and the form values as they are as the JSON body (JSON.stringify(values)), then emits refreshAll so the list re-reads. That is the engine’s write contract everywhere — the form bridge and HttpEntityConnector.create() both send the record itself — and the app door reads it that way. It also unwraps a lone { data: { … } }, because the response envelope is { data } and some callers mirror it; a record whose only field is named data and holds an object is read as that envelope, which no entity definition today has. A self-host implementing the write side should accept the record itself and may accept the mirrored envelope.

Source: pwfabric-core/packages/runtime/src/render/BehaviorSurfaceProvider.tsx (writer), pwfabric-core/packages/runtime/src/entity/http-connector.ts (create, request), PhiWebs API (closed source): app-records.ts (recordFromBody).

Sources bound to a connection — declared engines

A source with connector: '<name>' is served by the connection of that name in pwpack/connections.json, whose engine arrives as data in engines/manifest.json ({ connection, atomId, kind, engine }; a bundled engine carries moduleUrl + integrity instead). The declaration is seven facts, no code:

{ "host": "{baseUrl}", "path": "/{entityType}", "params": { "format": "json" },
  "auth": { "type": "bearer" },
  "read": { "items": "data", "id": "id", "flatten": "*" },
  "write": { "bodyWrap": "data" },
  "transforms": [ { "field": "price", "use": "numeric-string" } ] }

{name} placeholders are filled from the connection’s config strings; the scheme is stripped and https:// prepended. Reads are GET https://<host><path>?<params> with no surface query parameters (the query is applied client-side); read.items names the array, read.id the id, flatten: "*" means the item is the row. Creates POST the row (wrapped in bodyWrap if set); deletes DELETE …/<id>; update is refused. Auth bearer attaches the app’s live token; none sends no header. Retries 408 429 500 502 503 504 three times with backoff; 30 s timeout. These requests go to the declared host, never to PhiWebs. The facade is built only when the app has a live sign-in or some engine declares auth.type: 'none'; otherwise those panels draw fallback.

Source: pwfabric-core/packages/contracts/src/fabric/atom.ts (engineDeclarationSchema), pwfabric-core/packages/runtime/src/connectors/declared-connector.ts, pwfabric-core/packages/runtime/src/entity/http-connector.ts, pwfabric-core/packages/runner/src/entity-facade.ts, run-app.ts (needsNoIdentity).

Thin vs fat

The runner always renders in fat mode: the receipt is inline, blocks come from blocks/manifest.json, and no API is needed to draw the page; the data calls above are the only ones it makes. If a receipt declares platform/entities and no facade was built, fat mode supplies an offline in-memory facade — kind: 'entity' sources and their writes work for the life of the tab and vanish on reload. Thin mode is a different embed path (PhiWebs.render({ surfaceId }, { apiBaseUrl })) that fetches GET {apiBaseUrl}/api/surfaces/:id/published or /api/surfaces/by-slug/:tenant/:slug and needs a platform surface behind it; a downloaded pwapp never uses it.

Source: pwfabric-core/packages/embed/src/index.ts, fat.ts, capabilities-stub.ts, thin.ts; pwfabric-core/packages/runner/src/run-app.ts (mode: 'fat').

What your host must provide

Static only — nothing beyond the files.

Data against PhiWebs — nothing to configure: appApiBase is already in the bundle and points at api.phiwebs.com. Requirements are on the API side: your page origin must be in the API’s CORS allowlist or match APPS_HOST_SUFFIX, and the project’s identity declaration decides whether anonymous reads are answered.

Data against your own API — implement the table above under one base and rewrite that base into endpoint/authProviderRef in every receipt and into the URLs in auth.json. Minimum for read-only apps: GET /records/:type returning { data: Record[] } with permissive CORS on Authorization and Content-Type. For sign-in-gated apps, also POST /login returning { data: { token, expiresAt, … } } and honour Authorization: Bearer on reads. Filtering, sorting and pagination beyond limit/offset are optional — the runtime applies the declared query itself.

Unverified

  • The production value of PUBLIC_API_ORIGIN on the API container. The apps-gateway module is configured with api_origin = "https://api.phiwebs.com" (PhiWebs production infrastructure (closed source)); the API container’s own env was not read.
  • The exact CORS origin list per environment (CORS_ORIGINS) — only the rule was read, not the deployed values.
  • meta.total semantics: the code returns rows.length of the page; whether a collection count is intended was not confirmed from any contract.
  • The owner-door route /api/world/:worldId/entity-records/:entityType (world members, PhiWebs session) was not documented here; a downloaded app never addresses it.