Skip to content

0041. Connect a mobile app to any instance: discovery, a PKCE device-session handoff and an Expo app

Discovery:

  • GET /.well-known/guestcraft answers without credentials, with Cache-Control: public, max-age=300, a JSON document with the instance name, the absolute api_base_url, server_version, api_revision, min_app_version, capabilities and auth (methods and the mobile sign-in URLs and schemes). It holds no secret, no organization and no identity provider. The same document is the API operation GET /api/v1/instance.

Sign-in on a phone:

  • The app opens /mobile/authorize in the system browser with a PKCE S256 challenge, a state, a device name and a redirect_uri whose scheme is in GUESTCRAFT_MOBILE_APP_SCHEMES (default dev.guestcraft.app). A person who is not signed in signs in on that page with the instance’s own form; then they see which device asks, in which organization and on which address, and choose Allow or Cancel.
  • Allow redirects to redirect_uri?code=…&state=…; the code is single-use, valid for two minutes and bound to the person, organization, challenge, redirect URI and device name. Cancel redirects with error=access_denied. Another scheme, a missing or malformed challenge or a method other than S256 shows an error and never redirects.
  • POST /api/v1/device_sessions with the code, the verifier and the same redirect URI returns 201 with a device token (gcd_…), shown once. A wrong verifier, another redirect URI, an expired, unknown or used code all answer the same 400 device_session.invalid_grant; each code serves one attempt, and a second use also revokes the session the first one created.
  • With Authorization: Bearer gcd_…, the token authenticates the API with the live permissions and property scope of that person, never more. It cannot issue another code, manage the team or call the identity routes.
  • Settings → Account → Devices lists the person’s device sessions (name, sign-in, last use) and signs one out; the app’s sign-out revokes its own session. After revocation, 30 days unused, 180 days of age, “sign out other sessions” or a password change that signs out the others, the token answers 401. A reload shows the list without it. A code is exchanged only while the browser session that allowed it exists: signing that browser out, by itself, by “sign out other sessions” or by a password change, refuses its codes, even one being issued at that moment. The last two also delete the codes the remaining browser issued before them, and the session that an exchange running at that moment creates.
  • Creation, revocation and a reused code are audited (device_session.created, .revoked, .code_reused) in each workspace whose team lists the person, without name, code or token.

The app (apps/mobile):

  • hotel.example.com is normalized to https://hotel.example.com and checked through discovery, with distinct errors for: not HTTPS (http only in development builds), unreachable, not GuestCraft or too old (no discovery, no mobile_sessions capability), app too old (min_app_version), API or sign-in on another origin, and an instance that does not allow the app’s scheme. Several instances can be saved, opened and removed, one at a time; the instance shown always has its own session and selection, whatever order its reads finish in.
  • Sign-in uses the system browser and the handoff; the token lives in expo-secure-store with the address that issued it, and the app sends it to that address only. Sign-out revokes the device session, then deletes the token; a 401 forgets that token, never a newer one.
  • A workspace and property picker shows only what the person reaches, and every property of a workspace, a page of 100 at a time.
  • Today lists arrivals, departures and in-house stays of the property, with pull to refresh and a permission state. Housekeeping lists rooms and sets dirty, clean or inspected one room at a time: the change shows at once and returns to the previous status with a message on refusal.
  • Strings come from the mobile namespace of the shared English catalog; controls have accessibility roles and labels and 48-point targets.

Chapter 14 of the analysis describes one app pointed at the user’s own deployment: discovery at a well-known URL, login in the system browser on the instance’s page (RFC 8252), a one-time code exchanged with PKCE S256 for a new revocable session bound to the device, and per-instance token storage. It rejects the default flow of Better Auth’s Expo plugin, which puts the session cookie in the deep link, and the oneTimeToken plugin, which hands out the browser’s own session. Chapter 10 makes the SDK the data layer of every client. ADR 0016 lists the discovery endpoint as still to come.

Facts checked on 29 October 2026, with versions that satisfy the seven-day minimumReleaseAge:

  • Expo SDK 58.0.17 pins React Native 0.88.0 and React 19.3.0; the web stays on React 19.1.10.
  • jest-expo 58.0.12 with Jest 30.5.2 and React Native Testing Library 14.1.0 run in this pnpm workspace; Metro bundles the app, with the workspace TypeScript of @guestcraft/sdk, offline.
  • Babel’s core requires semver@^6.3.1; trustPolicy: no-downgrade refuses semver@6.3.1, a hand-published security backport without provenance, and no later 6.x exists.
  • jest-environment-jsdom brings @types/jsdom; hoisted, it typed Vitest’s optional jsdom import with the DOM library in Node packages and broke crypto.getRandomValues in server.
  • Better Auth 1.7.10 offers bearer, oneTimeToken, expo, deviceAuthorization (RFC 8628) and an OAuth/OIDC provider.

Discovery. api serves GET /v1/instance (instance.get, security: [], five-minute public cache) from a description the composition root builds once: GUESTCRAFT_INSTANCE_NAME or the host of the base URL, absolute URLs from GUESTCRAFT_BASE_URL, a SERVER_VERSION constant that release-please updates, MIN_APP_VERSION, the capabilities of this build, ["password"], and the allowed schemes. The runtime also answers /.well-known/guestcraft with the same document; the web mounts it as a route and exempts both paths from its no-store default. Logo, push and mobile_app members wait for their features; clients ignore members they do not know.

Device sessions are GuestCraft’s own, not Better Auth sessions. Migration 0023 adds two global tables to the auth schema, declared expand, reached by the app role through db/auth only, without RLS like Better Auth’s:

  • device_authorizations: the SHA-256 of the code, person, organization, the browser session that allowed it (cleared when that session is deleted), challenge, redirect URI, device name, creation, expiry, consumption and the session it created; kept an hour past expiry to recognize a second use.
  • device_sessions: dvs_… identifier, person, organization, name, SHA-256 of the token, creation and last use. Revocation deletes the row.

Codes and tokens are 32 random bytes in base64url behind a prefix (gcc_, gcd_). The exchange locks the code, consumes it whatever the outcome, and creates the session, links the code and reads the person in one statement, with the audit entry, in one transaction. Last use is written at most every five minutes. A new code removes up to 100 expired codes and up to 100 sessions that are over; a session that a kept code still refers to waits for a later cleanup, after its code.

Revoking all of a person’s devices (“sign out other sessions”, a password change that signs out the others) deletes their sessions and their codes not yet exchanged, one transaction per organization. Every writer takes its locks in this order (the module comment of db/auth/devices.ts restates it):

Writer Locks, in order
Exchange the person’s row in guestcraft_auth.user (for no key update); a shared lock on the key of the code’s browser session; the code (for update, read again); the device session it creates, or, for a second use, the one it deletes
Revocation of one session the person; the session, whose deletion clears its code’s reference
Revocation of all the person; the codes not yet exchanged; the sessions
Issuance a shared lock on the browser session’s key; then only rows nobody holds (skip locked): expired codes, and sessions that are over and that no code refers to, so it never waits for a code or a device session; the person’s key, shared, which the person’s lock allows
Sign-out (Better Auth) the browser session; the codes whose reference it clears
Last use that device session only

Exchanges and revocations of one person therefore run one after the other: a racing exchange either completes first, and its session is deleted, or finds its code gone, and a code replay cannot deadlock with the revocation of its session. Deleting a person or an organization cascades outside this order; no route offers it.

A code is valid only while the browser session that allowed it exists. Its reference to that session is on delete set null, and the issuance inserts the code in the same statement that shares a lock on the session’s key. The database therefore orders any sign-out, which deletes the session in Better Auth, with the issuance: after the deletion, nothing is inserted and the request is unauthenticated; before it, the deletion waits and clears the reference, and the exchange refuses a code without one. This closes the window between the issuance’s cookie check and its insert, which the sweep of pending codes alone left open. The exchange shares the same key before it locks the code, and no writer that holds a code waits for a browser session, so a deletion of the session either comes first, and the code is refused, or waits for the exchange to end, then clears the reference; after a password change or “sign out other sessions”, the revocation that follows also ends the session just issued.

Revocation audits are one insert … select per organization for every deleted session, shared by single and account-wide revocation.

Better Auth. It still signs the person in on the instance’s page, and its cookie session authorizes the handoff. Its token plugins are not used: bearer would make the device token the browser session’s token, stored in clear and returned by list-sessions; oneTimeToken and the expo plugin are rejected by the analysis; the OAuth provider brings client registration, consent and refresh tokens that one first-party public client does not need. QR pairing (RFC 8628) needs a pairing screen, polling and its own tests: it is planned, not done.

Credential resolution. auth.principalOf has one early branch: Authorization: Bearer gcd_… authenticates the request by that token only, never by a cookie, and an unknown, revoked or expired token is unauthenticated. The principal comes from the person’s live organization access, as for a cookie. server/http exempts from the cookie origin policy only such requests and the cookie-less POST …/v1/device_sessions; a browser cannot add an Authorization header cross-site without a CORS preflight, which the API does not grant. The dashboard shell ignores device tokens. An API key of another credential type plugs into the same branch with its own prefix.

HTTP contract (API revision 3.4.0):

Operation Credentials Result
GET /v1/instance (instance.get) none discovery
POST /v1/device_authorizations (deviceAuthorizations.create) browser session, trusted Origin 201 { redirect_to, expires_at }
POST /v1/device_sessions (deviceSessions.create) the code and verifier 201 { token, device_session, user }
GET /v1/device_sessions (deviceSessions.list) browser session or device token the person’s sessions; current marks the caller’s
DELETE /v1/device_sessions/{id} (deviceSessions.revoke) browser session or device token, If-Match 204
GET /v1/workspaces (workspaces.list) browser session or device token workspaces with role, permissions and property scope

Every operation of the principal resolver lists the deviceSession bearer scheme; team routes stay cookie-only. (Channex administration became browser-only too: see the amendment below.) New problems: device_session.invalid_request (400, with field), device_session.invalid_grant (400), device_session.not_found (404) and device_session.version_mismatch (412). A session’s ETag is its creation instant: If-Match revokes the issuance the client saw. The SDK adds discoverInstance() and the operation groups.

The app is apps/mobile, MIT like the SDKs (chapter 14 proposes it; the roadmap keeps it an open decision): Expo SDK 58, Expo Router, @guestcraft/sdk with an injected fetch and the bearer header, expo-web-browser’s auth session, expo-crypto, expo-secure-store (this device only, never in a backup), use-intl over @guestcraft/i18n/messages/en.json, and @guestcraft/tokens for colours, spacing and type. Over-the-air updates are off. Its code reaches the device only through a Services interface, which tests replace with a fake instance and device.

The keystore names an instance’s entries after a random key that its canonical address gets when it is first saved and keeps afterwards. Each device session records the address that issued it: the keystore returns it only for that address, and the API client is built from the session alone, so no state of the app can send a token elsewhere. The app’s state publishes the instance in use with its session, selection and notice as one value, after they are read, and only for the latest choice of instance; a late sign-in, sign-out or 401 changes only its own instance and session.

The workspace. The app’s versions are a named pnpm catalog, mobile, so the web keeps its React. trustPolicyExclude lists exactly semver@6.3.1, and hoistPattern keeps @types/jsdom out of the hidden hoist. @guestcraft/tsconfig/expo.json resolves as Metro does. Jest with jest-expo and React Native Testing Library tests the app at 85/80/85/85 over every file of src/; pnpm quality and the CI quality job run it, and Vitest’s coverage leaves apps/mobile to it. The boundary matrix allows the app sdk, i18n and tokens, and lists it among MIT packages. The Docker image installs without the app; Vercel still installs the whole workspace. Two advisories of the Expo toolchain have no compatible fix yet (deepmerge 4.3.1, zod 3.25.76; node-forge’s arrived in 1.4.2); osv-scanner.toml accepts them until late November 2026 because only developer tools reach them and neither the images nor the web build install them. (Next ships its own compiled zod 3 in next/dist/compiled, outside the lockfile; it is already on main and unchanged by the app.)

  • The app needs nothing from the project at runtime: no Expo account, push service or relay. A store build needs EAS or a local native toolchain and the publisher’s own Apple and Google accounts.
  • Device tokens are bearer credentials: whoever holds one acts as the person until it is revoked or expires. They live only in the device’s keystore; the server keeps their digests.
  • A device session belongs to the organization active in the browser. A person in several organizations signs in once per organization.
  • The CI install grows by the Expo and Jest toolchain (about 600 packages), and pnpm quality by one Jest run.

Push notifications, an offline outbox, QR pairing, native passkeys, branded builds and the EAS workflow, the demo instance, and every screen beyond Today and Housekeeping remain planned. Room status writes carry no If-Match yet (finding S3 of the API rules), so the last write wins. Maestro flows and device builds are not run in CI. Development builds made before the address binding kept tokens without it: the app reads those as signed out, and the old device session ends when it is signed out in Settings or after 30 days unused. Nothing had been released.

On 29 October 2026: real PostgreSQL through PgBouncer for the store and Better Auth (packages/auth/test/integration/devices.integration.test.ts: one-attempt codes, digests only, reuse revocation, expiry, foreign schemes, revocation, idleness and age, sign-out of other sessions and password change, audit without names) and for the composed runtime (packages/server/test/integration/mobile-sessions.integration.test.ts: discovery at both paths, origin rules, a housekeeping member limited to one property who cannot reach the other, a manager’s or folio operation, the team or another token, and loses housekeeping writes at once when made a viewer; 401 after revocation; audit). The web’s Playwright spec (apps/web/test/e2e/mobile.spec.ts) runs the browser side on the production build: discovery, a refused request, sign-in and consent with axe checks, a burnt code and a successful retry, the device in Account settings after a reload, its sign-out and 401, and an anonymous issuance refused. The app’s Jest suites run the real routes against a fake instance; Metro bundles it offline.

After the first security and code-quality reviews, the same day: pending codes are refused after “sign out other sessions” and a password change, and an exchange held on its code while that revocation starts ends with no working session (it kept one before); three sessions in two workspaces give one revocation entry per session and workspace. In the app, the reviewers’ two addresses with the same former key, storage left by that build, overlapping opens (a held keystore read, through the routes and the state), a late sign-in, sign-out or 401, and a second page of properties with a failed and retried read; revokeDevice lets a programming error through. The reviewers’ probes reproduce each problem on the reviewed head.

Amendment 2026-10-29: Channex administration is browser-only

Section titled “Amendment 2026-10-29: Channex administration is browser-only”

A device token acts with the person’s live permissions on every operation of the principal resolver. An administrator’s lost or stolen phone could therefore replace the Channex credential, consume the whole account in exclusive mode, link and unlink properties, forget webhook removals or install the paid Messages application, with no browser session and no Origin. The app uses none of these operations (it has Today and Housekeeping only), so they become browser-only, like the team and the issuance of sign-in codes.

Mechanism. No new credential path: auth.principalOf(request, "browser") reads the session cookie alone, the one the team gateway reads, and a request that carries a device token has no principal, even next to a cookie, as the deviceSession scheme says. The API’s authenticate(principals, present, "browser") is the middleware of these operations, and their security lists the two session cookies without deviceSession. A device token gets the team’s answer: auth.unauthenticated (401). Because a cookie request without a device token always passes the cookie origin policy, every browser-only write also needs the instance’s Origin. API revision 3.7.0.

Operation Class Why
GET /channex/connection (channex.connection.get) operational credential-free state
PUT /channex/connection (channex.connection.put) administration replaces the credential (key rotation)
PATCH /channex/connection (channex.connection.patch) administration ownership mode
GET /channex/properties (channex.properties.list) administration reads the upstream account with the credential, to link
GET /channex/properties/{id} (channex.property.get) administration ownership confirmation before linking
GET /channex/room_types (channex.roomTypes.list) administration reads the upstream account, to map rooms
PUT /channex/mapping (channex.mapping.put) administration link, relink and room mappings
DELETE /channex/mappings/{property_id} (channex.mapping.delete) administration unlink, webhook removal
DELETE /channex/mappings/{property_id}/cleanup (channex.mapping.cleanup.delete) administration forgets a webhook removal
POST /channex/webhook (channex.webhook.register) administration webhook registration
GET /channex/sync (channex.sync.get) operational import health, backlog and receipts (the booking-import inbox)
POST /channex/drain (channex.drain.request) administration synchronization trigger
POST /channex/retry (channex.revision.retry) administration import retry
GET /properties/{property_id}/channex/ari (channex.ari.health) operational local publication health
GET /properties/{property_id}/channex/ari/mappings (channex.ari.mappings) operational saved publication mappings
GET /properties/{property_id}/channex/ari/plans (channex.ari.plans) administration reads upstream rate plans, to map them
PUT /properties/{property_id}/channex/ari/mappings (channex.ari.map) administration publication mappings
POST /properties/{property_id}/channex/ari/repair (channex.ari.repair) administration audited repair of stuck publication
POST /properties/{property_id}/channex/ari/resync (channex.ari.resync) administration synchronization trigger and takeover
GET /channex/messages (channexMessages.list) operational Messages state and health
GET /channex/mappings/{property_id}/messages (channexMessages.get) operational Messages state of a property
PUT /channex/mappings/{property_id}/messages (channexMessages.put) administration activation of the paid application, deactivation
POST …/messages/install_attempt/confirm (channexMessages.installAttempt.confirm) administration installation ownership
POST …/messages/install_attempt/forget (channexMessages.installAttempt.forget) administration installation ownership
POST /channex/messages/synchronizations (channexMessages.synchronize) administration synchronization trigger (messages.configure)
GET /message_threads, GET /message_threads/summary, GET /message_threads/{thread_id} operational conversations
PUT /message_threads/{thread_id}/read_state, GET …/variables operational conversations
POST /message_threads/{thread_id}/messages, POST …/attachments operational sending
GET …/outbox/{outbox_id}, POST …/outbox/{outbox_id}/retry, POST …/dismiss operational sending
POST /message_threads/{thread_id}/no_reply_needed operational conversations
GET /message_requests, GET /message_requests/{request_id} operational conversations
GET/POST /properties/{property_id}/message_templates, GET/PUT/DELETE …/{template_id} operational templates (messages.send)

Reads that use the stored credential to discover the upstream account (channex.properties.list, channex.property.get, channex.roomTypes.list, channex.ari.plans) are administration: they serve only linking and mapping, and list properties of the whole Channex account, not this instance’s state. Operational writes stay open to a device only where a workflow of the app may need them (conversations, replies, templates); the app calls none of the Channex operations today.

Evidence: packages/api/src/credentials.unit.test.ts checks that every operation asks the resolver for the credentials its security declares; packages/api/src/openapi.unit.test.ts names the administration operations; packages/auth/test/integration/devices.integration.test.ts resolves a browser-only request from the cookie alone, never from a valid token, also next to the cookie; packages/server/test/integration/mobile-sessions.integration.test.ts, on PostgreSQL through PgBouncer with the independent Channex simulator, refuses an administrator’s device token (alone and next to their cookie) on each of the 19 operations with no upstream call, the connection still absent and the audit log unchanged, gives the device the browser’s answers on the operational reads and a template write, and lets the browser session perform all 19.

Amendment 2026-10-29: one credential rule for every browser-only operation

Section titled “Amendment 2026-10-29: one credential rule for every browser-only operation”

The team gateway still read the session cookie of a request that also carried a device token, and the issuance of sign-in codes answered such a request auth.forbidden (403). Now browserSession (packages/auth/src/devices.ts) is the only rule: a request that carries a device token has no browser session, even next to a valid cookie. principalOf(request, "browser"), the team gateway and the issuance read it, so every browser-only operation answers a device token auth.unauthenticated (401) and changes nothing. The issuance still refuses a browser session that comes with any other Authorization credential (403), and team writes still need the instance’s Origin. Invitation acceptance reads the same rule: next to a device token, an existing account is not signed in. The identity routes under /api/auth keep reading the cookie, so “sign out other sessions” also revokes the devices. The contract (security, problem codes) is unchanged: API revision 3.7.0. Evidence: devices.integration.test.ts (the team and the issuance, token next to the cookie) and mobile-sessions.integration.test.ts, which sends the owner’s token, cookie and Origin to each of the 16 browser-only team operations and the issuance (401, members, invitations, custom roles and audit log unchanged) and lets the cookie alone perform each.

Amendment 2026-10-30: device tokens are admitted like API keys

Section titled “Amendment 2026-10-30: device tokens are admitted like API keys”

Each device token was one database lookup on the auth pool without any budget, so a flood of unknown tokens could take every connection of that pool, which sessions and sign-ins share. Now a device token is admitted by the budgets of API keys (ADR 0040), the same instance-wide admission: a token remembered as authenticated spends its own 600 requests a minute (at most 100 tokens of one person are remembered), and any other token is a guess that takes a permit of its client address and of the instance, and a failed request of its address and of its IPv6 /48, before its lookup. Unknown tokens therefore hold at most half the auth pool, and a remembered token is never refused for them. The principal resolver and the device session operations (GET and DELETE /device_sessions) look tokens up only through it, so those two operations can answer auth.rate_limited (429, Retry-After): API revision 3.9.1. A token’s first request on an instance is a guess, and the token’s other requests that arrive while it is looked up wait for that lookup and get its answer (ADR 0040), so the app’s parallel first reads, such as the two of Today after the instance restarted, all succeed. Evidence: key-budgets.unit.test.ts, device-gateway.unit.test.ts, devices.integration.test.ts (five requests of a token new to an instance at once: one lookup, five principals) and mobile-sessions.integration.test.ts (“unknown device tokens”: the 31st unknown token of one address answers 429 before its lookup while a remembered token answers 20 of 20 under a flood of 200 from 100 addresses; eight unknown tokens waiting for a lock hold two auth connections, and a browser session answers at once; Today’s two reads at once after a restart answer 200 and 200, 200 and 429 before).

Chapter 14 (“Pairing with QR”) lets a person signed in on the web pair a phone without a second sign-in: a one-time code, valid 60 to 180 seconds and bound to the instance’s origin, gives the app a new named device session of its own, never the browser’s session, listed and revocable one by one. The server side follows; the app’s part is added separately.

Data model. Pairing codes are rows of device_authorizations, so every rule above holds for them unchanged: only the SHA-256 of the code is stored; each code serves one attempt and a second use revokes the session the first one created (device_session.code_reused); the code is valid only while the browser session that issued it exists (the same shared key lock at issuance and exchange, on delete set null); revoking all of a person’s devices deletes pending codes; the same cleanup removes them an hour after expiry; device_session.created is written in the exchange’s transaction. Migration 0032_device_pairing.sql adds kind (sign_in or pairing; existing rows sign_in), id (uuid unique, the public dvp_… identifier of a pairing) and origin (the instance’s origin at issuance), makes code_challenge, redirect_uri and device_name nullable, and checks per kind that a sign-in code has all three and neither id nor origin, and a pairing code has an id and an origin and none of the three. It is a contract migration (ADR 0038): the expand linter refuses changes to existing columns, although an older runtime would keep working on the new schema (it inserts sign-in rows by the column default and refuses gcp_ codes by their prefix). Table grants are unchanged.

Code and lifetime. gcp_ and 32 bytes of the injected random source in base64url, valid for 120 seconds, like a sign-in code. The device name is not known at issuance: the app sends it with the exchange, and the same rule as the sign-in page’s (trimmed, NFC, 1 to 100 characters, no control or invisible character) refuses a malformed name before the code is looked up, so it never uses the code. The prefix and shape of a code are checked before any lookup, so a sign-in code on the pairing grant, or a pairing code on the sign-in grant, is refused without using it.

Origin binding. The code is shown in a QR code with pairing_url, the instance’s pairing page at its configured public base URL (GUESTCRAFT_BASE_URL, the root discovery uses): {root}/mobile/pair#gcp_…. The code is in the fragment, so a browser that a phone camera opens on the address never sends it to a server. The app reads discovery at the origin of pairing_url, requires the mobile_pairing capability, and sends the code only to that discovery’s token_url, an endpoint of the same origin. The server enforces the binding too: the issuance stores the instance’s origin with the code, and the exchange compares the origin of the request’s URL, as the trusted HTTP boundary (deviceGateway) receives it, with the instance’s current origin, and the stored origin with it. A request at another authority that reaches the same runtime (an alias or a stale address) is refused with the same 400 device_session.invalid_grant before the code is looked up, so it neither reveals whether the code is valid nor uses it: the phone that scanned the QR code can still exchange it at the right address. A code issued before GUESTCRAFT_BASE_URL changed reaches the store at the new origin and is used and refused there like any other invalid grant, one attempt. The Origin header is not read, so native clients need none. A code is still exchangeable only on the instance whose database holds its digest.

Admission. The exchange of either code is unauthenticated, and each attempt that would look a code up costs a transaction on the auth pool. Both grants of POST /device_sessions are therefore admitted before the store by the budgets of API key and device token guesses (ADR 0040, amendment of 2026-10-30), in memory and per instance: an attempt takes a permit of its client address (one lookup at a time) and of the instance (at most half the auth pool, shared with key and token guesses), and one failure of its address (30 a minute) and of its IPv6 /48 (300). A refusal answers 429 auth.rate_limited with Retry-After, before any lookup and whatever the code, so it reveals nothing about validity. A code that exchanges gives its failures back; a refused one keeps them spent; a code is never remembered like a key. Attempts of one code sent at once, from any address, wait for each other (up to 100) and are then admitted one by one: two phones racing for one QR code both reach the store, so the second use still revokes the first’s session instead of being refused for racing. Checks that need no lookup (a malformed name or code, another origin) come first and spend nothing.

Locks. Pairing adds one writer and reuses two:

Writer Locks, in order
Pairing issuance as an issuance: a shared lock on the browser session’s key; then only rows nobody holds
Pairing exchange as an exchange: the person; the browser session’s key, shared; the code; the session it creates, or, for a second use, the one it deletes
Withdrawal of a pairing code the person (for no key update); the code (for update), which it deletes if no device used it

A withdrawal and an exchange of one person therefore run one after the other: a withdrawal that starts while an exchange holds the code waits for it, then finds the code used. Reading a pairing takes no lock.

Operations (API revision 4.6.0, tag devices, browser session only, like the issuance of sign-in codes; a device token answers 401 auth.unauthenticated):

Operation Result
POST /v1/device_pairings (devicePairings.create) 201 IssuedDevicePairing: id, status: pending, expires_at, device_session: null, etag, and, in this answer only, code, pairing_url and expires_in (the whole seconds left when the answer is sent, by the instance’s clock after the issuance finished, RFC 8628, so the browser counts down without trusting its own clock; 0, with status: expired, if the issuance outlasted the code); trusted Origin; another Authorization credential, no active organization or a former member: 403 auth.forbidden
GET /v1/device_pairings/{id} (devicePairings.get) 200 DevicePairing without the code: status pending, paired (with device_session { id, name, created_at } while it exists), refused (used without a live session) or expired; one indexed read, for polling
DELETE /v1/device_pairings/{id} (devicePairings.cancel) If-Match with the pairing’s etag (its creation instant, like a device session’s); 204 deletes a code no device used, pending or expired
POST /v1/device_sessions (deviceSessions.create) also takes { pairing_code, device_name }, a strict alternative to the sign-in grant; the same 201 IssuedDeviceSession; 429 auth.rate_limited for either grant when not admitted; 400 request.malformed for a body that is not JSON

A pairing is read and withdrawn only by the browser session that issued it, not by every session of its person: its code is valid only while that session exists, so a pairing of another browser can never change for the caller, and its four statuses stay exact. Any other pairing, a withdrawn one, or one the cleanup removed is 404 device_pairing.not_found; a code a device used, paired or refused, is 409 device_pairing.used, whose device the person signs out from the device list instead; another etag is 412 device_pairing.version_mismatch. Every failure of a pairing code at the exchange answers 400 device_session.invalid_grant, and a malformed name 400 device_session.invalid_request with field: device_name. Discovery lists mobile_pairing. Issuing and withdrawing a code are not audited, like sign-in codes: no device session exists yet, and its creation, revocation and reuse are audited as before.

Room status precondition. The app queues room status changes offline and replays them. A clock instant is not a version: an injected clock may repeat an instant or go back, and two changes in one millisecond would look alike. Units therefore keep housekeeping_version (0032, integer not null default 0, 0 for existing and new units), and every writer of the status advances it by one while it holds the property’s write lock: the single and bulk status changes, and the dirty unit a check-out or a same-night room move leaves; editing a unit’s settings keeps it. Each change also moves housekeeping_updated_at forward by at least a millisecond, so the instant never repeats or goes back for a unit either. The wire Unit carries housekeeping_version. PUT /v1/properties/{property_id}/units/{id}/housekeeping takes an optional expected_housekeeping_version, which the app’s offline operation sends, and the kept optional expected_updated_at for clients that read no version. With either or both, a unit already in the requested status answers 200 unchanged, without a write, an audit entry or an event (a replay), and a unit whose version or instant is not the one sent answers 409 unit.status_conflict, never overwritten. Without them, the last write wins as before.

Evidence: packages/auth/src/devices.unit.test.ts and device-gateway.unit.test.ts (prefix and shape before lookup, name before consumption, two minutes, the credential rule); packages/auth/test/integration/devices.integration.test.ts on PostgreSQL through PgBouncer (issuance only to a live browser session, digests and the issuing origin only, one exchange with the app’s name, reuse revocation audited, expiry, the other grant’s code refused unused, an alias authority refused with the code left usable and no Origin header needed, a code issued before the base URL changed used and refused, sign-out, “sign out other sessions”, reads and withdrawal scoped to the issuing browser session, and a withdrawal that waits for a running exchange, then finds the code used); packages/auth/src/key-budgets.unit.test.ts (the exchange admission: an address’s 31st failed attempt refused before the store, valid code or not, while another address exchanges; one code per address and two per instance at once; a valid code gives its failure back and is never remembered; attempts of one code run one after the other, at most 100 waiting; a failing exchange gives everything back); packages/server/test/integration/mobile-sessions.integration.test.ts on the composed runtime (the pairing over HTTP from cookie and Origin to a token without cookies, listed, revoked, 401; device tokens, a missing Origin, a malformed name and a withdrawn code; the three operations in the browser-only list, refused next to the token and performed by the cookie alone; discovery; two phones scanning one QR code at once leave one session at most; a code sent to https://other-instance.example.com refused and still usable; 30 guesses from one address, then 429 with Retry-After for a guess and for a valid code, which stays pending, while another address exchanges it; an offline room status change applied once by version, replayed unchanged and refused over a newer desk change by version or instant; clean then inspected at one clock instant, then a change made from clean refused 409 by version and by instant, and a bulk change advancing the version); packages/core/src/reservations/room-status-replay.unit.test.ts (repeated and backward clock readings, every writer advancing the version, stale replays); packages/db/test/integration/migrations.integration.test.ts (existing units start at version 0); and the route tests in packages/api/src/routes/devices.unit.test.ts (429 for either grant, request.malformed, expires_in of an issuance 70 seconds late and of one that outlasted its code) and inventory.unit.test.ts.

Amendment 2026-10-31: the app pairs, works the front desk and reads offline

Section titled “Amendment 2026-10-31: the app pairs, works the front desk and reads offline”

The app part of the amendment above, with the front-desk screens and the offline layer of chapter 14. Still planned: push notifications, store release, branded builds and the EAS workflow, native passkeys, check-in with identification and collection with a payment link. Housekeeping and maintenance notes, task status and fault photos belong to the housekeeping and maintenance track, which registers them as its own operations in this outbox; this amendment builds none of them.

  • Reading the code. Connect and Sign-in offer “Scan a pairing code”. The camera (expo-camera, QR only, permission asked when the screen opens; a refused permission explains why and opens the system settings) reads the code inside the app: a universal app cannot register links for domains it does not know. readPairingCode accepts only https://<instance>/mobile/pair#gcp_… (plain http only in development builds), with nothing in the query and no credentials; the instance root is the address before /mobile/pair.
  • Checking the instance. The address goes through the same discovery checks as a typed one (HTTPS, GuestCraft, versions, API and sign-in on its own origin, the app’s scheme), and the instance must declare mobile_pairing. The app then shows the instance’s name and host and asks the person to confirm before it sends the code: a QR code printed by someone else names another instance, and the person sees which one before anything is exchanged.
  • Exchanging. POST /device_sessions with { pairing_code, device_name } goes to that instance only. The device session is kept like one from the browser sign-in, bound to the address that issued it. An instance the phone is already signed in to is refused (“sign out first”): a scanned code never replaces a session that the person did not sign out.
  • Routes. (work) is a stack: the tabs (Today, Search, Housekeeping, Account) live in (work)/(tabs)/, and reservations/[id] and guests/[id] open above them. Expo Router 58’s JS tabs send a focused href: null tab to the first visible tab, so hidden tabs cannot hold detail screens. The URLs of the existing tabs are unchanged.
  • Search runs on submit (two characters at least), with up to 20 reservations of the property (reservations.list with q) and 20 guests of the workspace (guests.list).
  • Reservation detail: code, guest and contact, confirmation and stay status, dates and nights, each room (type, unit, occupancy, status, check-in and check-out dates), notes, and the balance of the folio. Every write takes the reservation’s version as If-Match; a 412 reads it again and says so. A failure without an answer may follow a write the instance made (the answer was lost), so it says the instance did not answer, reads the reservation again like any other failure, and asks the person to check before trying again. Writes are online only: the outbox is for operations designed to be replayed.
  • Room assignment lists the active units of the booked type free for the whole stay (units.list with free_from, free_to, room_type_id); the list is never kept on the phone, since which rooms are free changes quickly and assigning needs the network. Check-in shows its readiness checks before the action (confirmed, expected, business date within the stay, a unit for tonight, the unit’s housekeeping status) and maps every refusal of the instance to a sentence; a dirty unit needs stays.override_readiness and a reason, which the server audits. Check-out reads checkoutBalance and asks for folios.approve_open_balance and a reason when the last room leaves with money owed. Undo of a check-in or check-out made on the current business date shows only then.
  • Capabilities and grants. Tabs and sections follow discovery: Today needs front_desk, Search reservations, Housekeeping housekeeping, the balance folios. The instance declares no guests capability, so the guest half of Search and the guest screen follow reservations and guests.read. Discovery is read again when the app starts or opens an instance, so an upgraded server’s capabilities show; an instance saved before capabilities were kept reads with none until then. The stay actions of a reservation (assignment, check-in, check-out and both undos) need front_desk as well as their grants, also when the reservation is opened from a link and when discovery read again while the screen is open withdraws it; the reads only those actions use (the rooms’ status, the check-out quotes) are not sent without it. An action the person’s role refuses is hidden or disabled with the reason as text.
  • Recovering from a refusal. The readiness and balance shown before an action are helps, not conditions: the server decides. A person with stays.override_readiness whose check-in the instance refuses with check_in.unit_not_ready, or with folios.approve_open_balance whose check-out it refuses with folio.open_balance, is offered the reason field and sends the action again with it, also without inventory.read or folios.read and when those reads fail.

Offline acceptance criteria.

  • A work screen opened without a network shows the copy of its last successful read, saved on the phone, with “Offline · last updated
  • A room status changed without a network shows at once, is kept on the phone and is sent once, with the housekeeping_version the phone read as expected_housekeeping_version, when the network returns, the app returns to the foreground, another change is made, the workspace becomes active again or the person presses “Send now”. A room changed meanwhile on the instance is never overwritten: the change is held as a conflict, the room shows its current status, and the person discards the change under Account.
  • Every work screen says how many changes wait and how many need a decision; Account lists them with Discard, a refused one with the instance’s explanation (never its problem code); signing out and removing an instance first say how many unsent changes they delete.
  • Signing out, a 401 and removing the instance delete what that account (or that instance) kept offline, in every workspace, and nothing of other accounts or instances. The session goes first, so each start also deletes what any account without a session on the phone still keeps: a deletion cut short by the app stopping is finished at the next start.

Reads go through the app’s own useResource, with a persisted cache behind it, not TanStack Query’s persister. Chapter 14 suggests TanStack Query with a persister on expo-sqlite/kv-store. The app does not use TanStack Query: every screen reads through useResource (loading, ready with pull to refresh, denied, failed, and the 401 that ends the session). Saving each successful answer in SQLite behind that hook, keyed by the screen’s own key and the partition, gives the behaviour the chapter asks for (saved copy at once, kept when offline, deleted on sign-out and revocation, expiry) without a new dependency or rewriting the screens: a screen opts in by passing work.partition. The chapter’s reasons against TanStack Query’s persisted mutations apply unchanged and are why writes use an outbox.

One SQLite file, partition columns. expo-sqlite opens guestcraft-offline.db once per app (Services.database()); src/offline/schema.ts brings it to the current schema by numbered steps counted in PRAGMA user_version, each in its own transaction; a released step is never edited. Two tables, both keyed by (instance_id, user_id, workspace_id):

  • cached_reads (…, key, body, saved_at, app_version), primary key partition + key;
  • outbox_entries (id autoincrement, …, operation, subject, payload, idempotency_key, created_at, held_reason ∈ {conflict, refused}, held_code), indexed by partition and id.

The UI research table proposes one file per partition; one file with partition columns makes a wipe one DELETE per table, needs one migration path and one open connection, and the engine reads one partition at a time anyway. instance_id is the instance’s random key on the device (saving the same address again keeps it; removing the instance deletes its rows, so a key is never reused for another address).

Expiry. A saved read is shown for 72 hours at most and deleted when the storage opens and on every later save: it holds guest names and stays on a phone that can be lost, and three days cover a weekend without a connection. A copy is also read only by the app version that saved it, so the shape the bundled SDK validated is the shape the screens get (TanStack’s buster plays this role). Outbox entries do not expire: they are the person’s unsent work, deleted only when sent, discarded or wiped with their account.

Wipe. AppProvider deletes the account’s rows (forgetAccount(instance, person)) when its session is forgotten — sign-out (even when the instance could not be told) and a 401 — unless the device holds a newer session of the same person on that instance; removing an instance deletes all its rows (forgetInstance). “Does not survive a reinstall” holds by location: the file is in the app’s sandbox, which uninstalling deletes.

Outbox. createOutbox (src/offline/outbox.ts) sends the entries of the active partition only, with that partition’s API client (device session + workspace), in id order, one at a time:

  • each entry is saved with a random 16-byte idempotency key before its first attempt and passes the same key to every attempt (send(api, payload, { idempotencyKey, signal })); the operation decides how the instance recognizes a repeat (an identifier derived from the key for creations, Idempotency-Key, or a precondition such as expected_housekeeping_version);
  • triggers: connectivity back online (expo-network), the app becoming active again (AppState), a new entry, the partition becoming active, and “Send now”; there is no timer and no polling;
  • no answer (GuestCraftError kind network or invalid_response), 408, 429 and 5xx keep the entry and stop the round; 401 stops it and ends the session, which wipes the account; 409 and 412 hold the entry as a conflict, any other 4xx as refused, with the problem code. A held entry is never sent again automatically and stops the later entries of its subject (an operation-defined key such as the room id); the person discards it. An error that is not a GuestCraftError is a bug and is thrown;
  • suspension: changing account, workspace or instance aborts the request in flight through its AbortSignal (the SDK honours it, also after the answer arrived); the entry stays for its own partition and is never sent with another client;
  • rebase: once an entry is sent, the later entries of the same operation and subject take the version it created (rebase(later, result)), so two offline changes of one room (clean, then inspected) are not a false conflict. The rebase is written before the sent entry is deleted, so an app stopped in between sends the entry again (harmless by its precondition) and rebases again.

Operations are registered in OPERATIONS (src/offline/operations.ts); OutboxProvider gives screens submit(operation, payload) → sent (typed result) | queued (waiting: connection | decision) | held | expired, the partition’s entries, discard(id), flush() and a replayed counter that screens use to read again what a background send changed. Room status (units.housekeeping) is the first operation. It sends expected_housekeeping_version, the unit’s housekeeping_version as the phone read it, which every writer of a room’s status advances on the instance while it holds the property’s lock; rebase gives later entries of the same room the version the sent one created. The instance answers a room already in the requested status unchanged and a room changed since, even within the same clock instant, with 409 unit.status_conflict. expected_updated_at stays accepted for online clients.

Generations (the wipe barrier). The session and workspace in use work in one generation of their partition: AppProvider begins it (offline.begin(partition), a random id and an AbortController, nothing written yet) when a session and a workspace are both chosen, and ends it when either changes or the instance in use changes. Work.partition is that generation. A deletion (forgetAccount, forgetInstance, retainAccounts) first ends the generations it covers, then deletes the data:

  • ending aborts the generation’s signal, which useResource passes to every read’s SDK request (load(signal)) and which also aborts on unmount, a new key or a reload; an aborted read changes nothing and saves nothing;
  • a generation’s row in live_generations (id, instance_id, user_id, workspace_id) (schema step 2) is written by its first save, never once it ended; every save of a read or an outbox entry is INSERT … SELECT … WHERE EXISTS (SELECT 1 FROM live_generations WHERE id = ?), so the check and the insert are one SQLite statement;
  • ending deletes the rows first (DELETE FROM live_generations WHERE <the deletion's condition>), then the deletion deletes cached_reads and outbox_entries rows, each statement awaited in that order. A save statement that runs before the row delete is deleted with the data; one that runs after it, even one already started when the session ended, inserts nothing;
  • the writes of live_generations run one after another in the order they were asked, so a row write that started before a deletion cannot land after its delete, even if the native driver ran two calls out of order; the storage deletes every row when it opens, so no generation of an earlier start lives.

An outbox entry submitted once its generation ended is not saved and answers expired. This replaces the earlier limit “a read answered during a sign-out can be saved after the wipe”: after a sign-out, a 401 or the removal of an instance has finished, nothing of that account is left or comes back, also when the same or another person signs in again before the late save runs.

Backups. The database (guestcraft-offline.db) and SQLite’s -wal, -shm and -journal files, which SQLite keeps beside it, live in a directory given by the app’s own Expo module apps/mobile/modules/private-storage (MIT, Swift and Kotlin, autolinked from modules/; backupExcludedDirectory("offline")), passed as openDatabaseAsync(name, undefined, directory):

  • iOS: Library/Application Support/offline, created if needed and marked URLResourceValues.isExcludedFromBackup = true, which excludes the directory with its contents from iCloud and computer backups. The flag is set again on every opening (some file operations clear it) and read back; the opening fails rather than use a directory a backup would include. Application Support, unlike Caches and tmp, is never purged by the system, so the unsent outbox stays durable. Neither expo-sqlite 58 (default Documents/SQLite) nor expo-file-system 58 offers the flag, hence the module.
  • Android: <noBackupFilesDir>/offline. Android leaves the no-backup directory out of Auto Backup and of device-to-device transfers, whatever the backup rules say (“always excluded even if you try to include them”). This matters on Android 12+, where allowBackup="false" “doesn’t disable D2D transfers” on some manufacturers’ devices. allowBackup: false stays in app.json; the rules expo-secure-store writes (android:dataExtractionRules) only concern shared preferences.
  • openOfflineDatabase is the only opener; Services.database opens on every call and the offline storage alone memoizes the opened database, resetting it after a failed opening (S3: the earlier second memoization in nativeServices kept a failed opening forever).

What CI verifies (apps/mobile/test/offline-backup.unit.test.ts, src/offline/native-database.unit.test.ts, src/services/native.unit.test.ts): the database is opened in the module’s directory and not at all when the module fails; Expo’s autolinking (expo-modules-autolinking resolve) links the module on iOS (pod PrivateStorage, class PrivateStorageModule) and Android (dev.guestcraft.privatestorage.PrivateStorageModule); the manifest expo config --type introspect generates has android:allowBackup="false". What CI cannot verify: no job compiles the Swift or Kotlin code or runs it on a device, so the exclusion itself is verified only in a device build (for example by inspecting the directory’s backup exclusion on a simulator, or an iCloud backup without the file). Development builds made before this change keep their old file in Documents/SQLite until reinstalled; no build was released.

Saved copies inside a screen (S7). One component, SavedCopyNotice (src/screens/saved-copy.tsx, with the timestamp’s formatting), shows a saved copy’s state for WorkHeader (the screen’s read) and for ReadState (each read inside it: a balance, a check-out quote, a guest’s stays). Reads age on their own, so each says it for itself: a fresh reservation with a balance that could not be refreshed shows “Offline · last updated

Offline limits.

  • Not encrypted at rest beyond the platform’s file protection (iOS data protection, Android file-based encryption); the device token itself stays in the keystore.
  • After a restart, a saved read shows the instance’s last answer, not the changes still in the outbox (the header counts them); screens may overlay their pending entries.
  • 5xx without a later trigger: an entry kept after a 5xx waits for the next trigger (or “Send now”); there is no backoff timer.

Evidence (app). Jest with React Native Testing Library through the real routes, against a fake instance whose rules follow the OpenAPI descriptions (test/fakes.ts, test/desk-fakes.ts), and the offline layer on Node’s own SQLite (test/sqlite.ts), not an imitation: test/pair.component.test.tsx and src/lib/pairing.unit.test.ts (the QR shape, the instance shown before the code leaves the phone, a refused code, an instance without pairing, a phone already signed in, the camera permission); test/desk-search.component.test.tsx, test/desk-reservation.component.test.tsx, test/desk-stays.component.test.tsx (search, detail, assignment, readiness and override, check-out with an open balance, undo, 412, refusals of the instance, missing grants and capabilities, a failure without an answer); src/offline/outbox.unit.test.ts, src/offline/storage.unit.test.ts and test/offline.component.test.tsx (the key saved before the first attempt and reused, replay on reconnection, order and subjects, conflicts held and never retried, 401, a workspace change that aborts the request in flight and never sends with another client, the wipe of one account only, expiry, Today read offline after a restart). The web side is in apps/web/test/e2e/mobile.spec.ts.

Evidence (round 2). test/offline-barrier.component.test.tsx (the reviewer’s held-INSERT probe, its interception unchanged, plus a 401, the removal of the instance, the same and another person signing in again before the held save runs, and reads aborted on sign-out and on a change of workspace); src/offline/storage.unit.test.ts “generations” (a held save refused after each deletion and after a generation ends, a new generation of the same account, an outbox entry of an ended generation, the order of row writes and deletions, a restart, a failed row write); test/app-state.component.test.tsx (generations follow the session and workspace; a read aborted when its account is deleted; a failing database leaves the answer shown, without an unhandled rejection); test/offline-saved-copy.component.test.tsx (the reviewer’s ReadState probe; partial connectivity on the reservation screen).

  • Better Auth sessions through the bearer plugin. Rejected: tokens stored in clear and listed to the browser, no device name, and “sign out other sessions” as the only revocation.
  • A full OAuth 2.1 authorization server. Rejected for now; the HTTP shapes follow RFC 6749 and RFC 7636, so a later one can replace them without changing the app’s browser step.
  • The app under Vitest. React Native Testing Library does not run with Vitest; Jest with jest-expo is the supported runner.
  • One React version for web and app. Expo SDK 58 requires React 19.3; a named catalog keeps each app on the React its framework supports.
  • A GUESTCRAFT_-less MOBILE_APP_SCHEMES. The analysis names it so; the repository’s rule prefixes every setting, so it is GUESTCRAFT_MOBILE_APP_SCHEMES.

Organization security policies (2026-11-01)

Section titled “Organization security policies (2026-11-01)”

A device session records the sign-in method of the browser session that paired or authorized it and its organization’s policy version then (ADR 0053). Once the organization requires more than that method, after the member’s grace period, or once the device is older than the policy’s session age, its requests answer 401 auth.reauthentication_required until the person signs the app in again; the app treats it like any 401. Pairing and authorizing need a browser session that meets the policy. A password reset revokes every device session and code of the person, with the reason password_reset. The app is unchanged.