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 through | Talks to |
|---|---|---|
| reads or writes the app’s own records | DataService (kind: 'rest') | the app’s API base — api.phiwebs.com or your own |
is bound to a connection (connector: …) | a declared engine on HttpEntityConnector | the host named in the declaration (Graph, your API…) |
has no address (kind: 'entity', no facade) | nothing — renders fallback or an empty list | nobody |
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) andconfig.initialState.platform/entities→config.formBindings[](writes:{ formId, sourceId }).
A data source is JSON with these fields (dataSourceSchema):
| Field | Meaning |
|---|---|
id | key in the binding context — {{products}} |
kind | rest · static · state · entity |
endpoint, method | rest only; method is GET unless stated. {{state.x}} in the endpoint is resolved first |
returns | the property holding the list (data), dotted paths allowed; absent when the body is the array. A named path that is missing throws |
authProviderRef | a name the host answers with a live token; never a value |
query | { filter, sort, pagination } — applied client-side after the fetch, never sent |
entityType, connector | entity only — which collection, and which declared connection serves it |
fallback | bound 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:
- The publish route computes
appApiBase = ${PUBLIC_API_ORIGIN}/api/app/<worldId>/<projectId>. - The assembler rewrites every
kind: 'entity'source with noconnectorinto{ 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. auth.jsonwrites the platform door as addresses:loginUrl = <appApiBase>/login,registerUrl,logoutUrl,sessionUrl.- The runner derives
ownApiBaseby stripping/loginoffauth.json.loginUrl.getAccessToken(ref)answers only whenref === ownApiBase, with the app session token, else the PKCE bridge token, elsenull— 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? } }.
| Method | Path | Auth | Body | 2xx response |
|---|---|---|---|---|
| GET | /records/:entityType?limit&offset | visitor (optional unless required) | — | { data: Record[], meta: { total, scope, timestamp } } |
| GET | /records/:entityType/:id | visitor | — | { data: Record } |
| POST | /records/:entityType | visitor | { …fields } — the record itself; a lone { data: { …fields } } is unwrapped | 201 { data: Record } |
| PATCH | /records/:entityType/:id | visitor | { …partial } — same rule as POST | { data: Record } |
| DELETE | /records/:entityType/:id | visitor | — | { data: { id, deleted: true } } |
| POST | /login | none (5/min) | { email, password } | { data: { id, principalId, projectId, expiresAt, token, principal } } |
| POST | /register | none (5/min) | { email, password, displayName? } | 201, same shape |
| POST | /oauth/login | none (5/min) | { provider, code, redirectUri, codeVerifier? } or { provider, accessToken } | same shape |
| POST | /logout | x-app-session | — | { data: { signedOut: true } } |
| GET | /session | visitor | — | { 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 bearerand 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_ORIGINon the API container. The apps-gateway module is configured withapi_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.totalsemantics: the code returnsrows.lengthof 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.