0041. Connect a mobile app to any instance: discovery, a PKCE device-session handoff and an Expo app
- Status: proposed
- Date: 2026-10-29
- Deciders: maintainer (owner review pending)
- Extends: ADR 0016, ADR 0017, ADR 0036
Observable acceptance criteria
Section titled “Observable acceptance criteria”Discovery:
GET /.well-known/guestcraftanswers without credentials, withCache-Control: public, max-age=300, a JSON document with the instance name, the absoluteapi_base_url,server_version,api_revision,min_app_version,capabilitiesandauth(methodsand the mobile sign-in URLs and schemes). It holds no secret, no organization and no identity provider. The same document is the API operationGET /api/v1/instance.
Sign-in on a phone:
- The app opens
/mobile/authorizein the system browser with a PKCE S256 challenge, astate, a device name and aredirect_uriwhose scheme is inGUESTCRAFT_MOBILE_APP_SCHEMES(defaultdev.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 witherror=access_denied. Another scheme, a missing or malformed challenge or a method other thanS256shows an error and never redirects. POST /api/v1/device_sessionswith 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 400device_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.comis normalized tohttps://hotel.example.comand checked through discovery, with distinct errors for: not HTTPS (http only in development builds), unreachable, not GuestCraft or too old (no discovery, nomobile_sessionscapability), 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-storewith 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
mobilenamespace of the shared English catalog; controls have accessibility roles and labels and 48-point targets.
Context
Section titled “Context”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-expo58.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-downgraderefusessemver@6.3.1, a hand-published security backport without provenance, and no later 6.x exists. jest-environment-jsdombrings@types/jsdom; hoisted, it typed Vitest’s optionaljsdomimport with the DOM library in Node packages and brokecrypto.getRandomValuesinserver.- Better Auth 1.7.10 offers
bearer,oneTimeToken,expo,deviceAuthorization(RFC 8628) and an OAuth/OIDC provider.
Decision
Section titled “Decision”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.)
Consequences
Section titled “Consequences”- 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 qualityby one Jest run.
Limits and planned work
Section titled “Limits and planned work”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.
Evidence
Section titled “Evidence”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).
Amendment 2026-10-31: QR pairing
Section titled “Amendment 2026-10-31: QR pairing”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.
Pairing in the app
Section titled “Pairing in the app”- 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.readPairingCodeaccepts onlyhttps://<instance>/mobile/pair#gcp_…(plainhttponly 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_sessionswith{ 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.
Front desk
Section titled “Front desk”- Routes.
(work)is a stack: the tabs (Today, Search, Housekeeping, Account) live in(work)/(tabs)/, andreservations/[id]andguests/[id]open above them. Expo Router 58’s JS tabs send a focusedhref: nulltab 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.listwithq) 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.listwithfree_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 needsstays.override_readinessand a reason, which the server audits. Check-out readscheckoutBalanceand asks forfolios.approve_open_balanceand 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, Searchreservations, Housekeepinghousekeeping, the balancefolios. The instance declares noguestscapability, so the guest half of Search and the guest screen followreservationsandguests.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) needfront_deskas 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_readinesswhose check-in the instance refuses withcheck_in.unit_not_ready, or withfolios.approve_open_balancewhose check-out it refuses withfolio.open_balance, is offered the reason field and sends the action again with it, also withoutinventory.readorfolios.readand when those reads fail.
Offline reads and the outbox
Section titled “Offline reads and the outbox”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_versionthe phone read asexpected_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 asexpected_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 (
GuestCraftErrorkindnetworkorinvalid_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 aGuestCraftErroris 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
useResourcepasses 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 isINSERT … 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 deletescached_readsandoutbox_entriesrows, 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_generationsrun 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 markedURLResourceValues.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, unlikeCachesandtmp, is never purged by the system, so the unsent outbox stays durable. Neitherexpo-sqlite58 (defaultDocuments/SQLite) norexpo-file-system58 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+, whereallowBackup="false"“doesn’t disable D2D transfers” on some manufacturers’ devices.allowBackup: falsestays inapp.json; the rulesexpo-secure-storewrites (android:dataExtractionRules) only concern shared preferences. openOfflineDatabaseis the only opener;Services.databaseopens on every call and the offline storage alone memoizes the opened database, resetting it after a failed opening (S3: the earlier second memoization innativeServiceskept 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).
Alternatives considered
Section titled “Alternatives considered”- Better Auth sessions through the
bearerplugin. 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-expois 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_-lessMOBILE_APP_SCHEMES. The analysis names it so; the repository’s rule prefixes every setting, so it isGUESTCRAFT_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.