@volter/twin-expo
v0.1.35
Published
Local Expo push-notification-service twin (exp.host/--/api/v2/push) - device registration, per-recipient tickets, receipts. Built on @volter/world-core.
Readme
@volter/twin-expo
A local, stateful, vendor-faithful twin of Expo's push notification service —
exp.host/--/api/v2/push/*, the Expo surface a backend talks to and the one the official
expo-server-sdk speaks. Point an unmodified
expo-server-sdk at it (EXPO_BASE_URL) and it registers devices, sends notifications and reads
receipts against local state, with no notification ever leaving the process.
Built on the shared @volter/world-core kernel: every device registration and every accepted ticket is
an action in the append-only kernel log, and every read uses the kernel’s tree. No side-store, no wall
clock in the serve path, no network egress outside the connector's injected client.
Coverage
Honest and partial by construction. Expo is two products behind one brand, and this pack models one of them.
Modeled and verified — the push service:
POST /--/api/v2/push/getExpoPushToken— device registration. OneExponentPushToken[…]per (experience, device), stable across re-registration, distinct per device and per project.POST /--/api/v2/push/send— one push ticket per recipient (an array-valuedtoexpands, which is the countexpo-server-sdkasserts its response against). The single-ticket rule is the vendor's own recipient-based one —datais a lone ticket object only for a single message to a single recipient, so an object body addressed to two tokens still answers a two-element array. Plus the vendor's verbatimDeviceNotRegisterederror ticket for a token this service never issued, the publishedPUSH_TOO_MANY_NOTIFICATIONS(>100) andPUSH_TOO_MANY_EXPERIENCE_IDSrefusals with their complete published messages, the?useFcmV1=falsequery parameter, an optionalAuthorization: Bearer, and gzip request bodies — which is what the SDK sends for any payload over 1024 bytes.POST /--/api/v2/push/getReceipts— a receipt map keyed by ticket id that omits ids with no receipt, the documentedMessageTooBigfold at the 4KiB ceiling, and the publishedPUSH_TOO_MANY_RECEIPTSrefusal (>1000 ids). It is a pure read: a read-only twin serves it.- A non-POST on any push path answers the real service's wire:
405,allow: POST,content-type: text/plain, bodyMethod Not Allowed— not a JSON error envelope, becauseexpo-server-sdk's error path branches on exactly that difference. (Evidence scope:GETis the method that was probed againstexp.host; the twin applies the same answer to every non-POST because the push API is POST-only, butHEAD/OPTIONS/PUT/DELETEwere not observed.) - A connector that observes a real account's receipts over an injected client and folds them
onto the twin's own tickets — after which the twin's
getReceiptsserves the observed result. A pulled row carries only what was observed: no fabricated ticketstatus, and the outbox marks itobserved_onlyso a receipt pulled for an id this twin never issued can never be read as a send this twin accepted.
Todo (real vendor surface, enumerated, unbuilt) — the EAS platform: builds, submissions, updates/branches/channels, workflows (
api.expo.dev/v2/workflows/*), credentials, devices, metadata, projects, accounts and webhooks overapi.expo.dev/graphql; the EAS Update manifest protocol onu.expo.dev; and the push-security auth posture. Seeexpo-capabilities.tsfor the full ledger.⚠ What the coverage percentage does and does not say. The push half is enumerated per-operation and is genuinely near-complete. The EAS half is enumerated per-domain (the granularity
linearuses for GraphQL, and the one Expo's own CLI command groups use), so its ~30 rows stand in for a GraphQL schema with hundreds ofRootQuery/RootMutationfields. Read an EAS row as "this domain is unbuilt", never as "this domain is one operation". Deepening that denominator to per-field granularity is the honest next revision, and it will make the percentage drop — which is success, not regression. No notification ever leaves this process. The twin reproduces the acceptance contract faithfully — per-recipient tickets, theDeviceNotRegisteredrefusal, receipts and their error codes — and accepts whateverdeviceTokena registration call carries, issuing a faithfulExponentPushTokenfor it, which is the whole of what the server side of the protocol can observe. The published 600-notifications-per-second ceiling and itsTOO_MANY_REQUESTScode are modeled as vendor knowledge inexpo-budget.ts; the served response stays a pure function of (request, stored state), so the twin never fabricates a timing-dependent 429. The EAS build control surface (submit, list, view, cancel) is a filedtodo.
Everything else — every unmodeled route — answers a loud [twin gap] 404 inside Expo's own
{ errors: [...] } envelope, never a fake success.
No UI mirror
The API is the product. When someone does Expo's core job — building, submitting and updating a
React Native app, or sending a notification — they write code and drive a CLI (eas build,
eas submit, eas update; on a server, expo-server-sdk). expo.dev exists, but it is a
build-log / credentials / billing console around work that happens elsewhere, and eas-cli is
itself just a client of api.expo.dev/graphql. The surface this pack serves settles it beyond
argument: the push service has no human surface at all — it is a machine-to-machine endpoint a
backend calls. This pack therefore ships no mirror, declares no UI capabilities and registers no
journeys (census.json's ui slice records the ruling).
Hosts and world wiring
exp.host is the only host claimed by the injector. api.expo.dev (EAS) and u.expo.dev (update
manifests) are real Expo hosts this twin does not model, and claiming them would break a world
more quietly than leaving them alone: a real EAS call would meet this pack's [twin gap] 404
instead of the real API. They become hosts entries in the same revision that models them.
A world can also point an unmodified SDK here with nothing but an env var: EXPO_BASE_URL is the
vendor SDK's own base-URL override, read at module load in expo-server-sdk-node's
src/ExpoClientValues.ts (process.env['EXPO_BASE_URL'] || 'https://exp.host'). That is the
pack's declared endpointEnv.
Fidelity
expo-sdk.integration.test.ts drives the real, unmodified expo-server-sdk over real HTTP at
the twin: it chunks, gzips, POSTs, validates the response length against its own
_getActualMessageCount, parses the envelope, and reads its receipts back. The SDK major is
pinned to ^3.15.0 — the major our own consumer ships (Ponder's apps/server depends on
expo-server-sdk@^3.7.0). "Unmodified" here means un-patched, not un-configured: the base URL is
set through the SDK's own documented EXPO_BASE_URL, exactly as an integrator would.
The store door
Expo's push API has no listing endpoint of any kind — send is fire-and-forget and
getReceipts is keyed by an id the caller must already hold. So the pack declares two named
deterministic projections over stored state, served at the kernel's store door and listed in
GET /twin:
GET /twin/store/devices— the registered push tokens.GET /twin/store/outbox— the accepted push tickets.
These are the only way to observe what the twin accepted, and they are what makes this pack's determinism checkable at the resource level (invariant R9) rather than only at the manifest door.
Grounding
Every literal in this pack traces to a first-party Expo source, read-only on 2026-09-02:
| Fact | Source |
|---|---|
| send / getReceipts contracts, error codes, 100 & 1000 caps, 4KiB ceiling, 600/s | expo/expo docs/pages/push-notifications/sending-notifications.mdx, faq.mdx |
| device registration payload and response | expo/expo packages/expo-notifications/src/getExpoPushTokenAsync.ts |
| wire URLs, chunk limits, gzip threshold, optional bearer, ticket-count assertion | [email protected] (ExpoClientValues.ts, ExpoClient.ts) |
| EAS denominator | Expo's first-party GraphQL schema — introspection at api.expo.dev/graphql, pinned at expo/eas-cli packages/eas-cli/graphql.schema.json |
Three modeled details are disclosed approximations, not vendor facts, and each is called out in the capability title that claims it:
- The exact error envelope for a malformed request. Expo documents the whole-request
errors[].codevocabulary and the complete message text for each code, but not the envelope for every validation case, so the twin'sVALIDATION_ERRORcode and its HTTP 400 are modeled choices — theerrors: [{ code, message }]shape and the four published codes are not. - The byte measurement behind
MessageTooBig. The twin measures the message minus itstofield; the real service measures the provider-specific payload it builds, which a local twin cannot construct. The 4096-byte ceiling itself is published. - Registration validation. Expo documents no error shape for a
getExpoPushTokencall missingdeviceIdor an experience; the requirement is grounded ingetExpoPushTokenAsync's own payload, the refusal envelope is the twin's.
Deliberately not approximated, because the real wire was probed: the method-not-allowed reply
is plain text, not JSON; the four whole-request error messages are the complete published
sentences; and the push-token seed is JSON.stringify([project, device]) rather than a :-joined
string, because both operands come off the wire and a : delimiter provably collides
(('acme', 'phone:1') and ('acme:phone', '1') minted one token, silently rebinding a device's
experience).
