Cloud|

Pair devices and open customer visits

Let an employee pair a shared device while the application authorizes each customer visit.

16 min read Updated 2026-11-05 #pwa#devices#visits#sessions

See Who may do what for the access model.

Use an App-PWA for a shared device on which a customer reads or signs documents without a Cloud account. An employee owns the paired device. The device acts as a device, and a customer acts through a visit; neither receives the employee's identity or credentials.

The application decides what each visit permits. Cloud supplies pairing, browser binding, expiry, revocation, attempt reconciliation, and the transaction that joins these checks to the application's domain writes.

Pair by scanning

The employee opens the application's device page in an authenticated web browser, names the device, and requests a pairing QR. The device opens its own scanner and scans that QR. A QR lasts at most two minutes and can bind only one browser. The employee page then shows the paired device's name and a revoke action. There is no phone, comparison code, or pasted-link flow.

After preparing a customer's visit, the employee shows a visit QR for that device. Its scanner opens the visit, binding it to the paired browser. A second browser cannot claim it. A live visit cannot be replaced with another customer's visit; end or revoke it first.

A repeat claim from the same browser recovers a lost response. Repeating an issuance request with the same request ID rotates an unclaimed secret on the same record, without extending its original deadline. If it was already claimed, issuance returns its state with qr: null. The employee UI must show that state instead of attempting to render a missing QR. A new request ID starts a new issuance.

Register a shared device

A reception iPad that several employees use can be registered once instead of paired by each person. Issue the device with register: true. Its QR is a normal device QR, and the device claims it with the same scanner. The registration needs the staff action register-device, not issue-device.

A registered browser stays bound to this application for up to 400 days, the upper cookie lifetime that browsers allow. Its guest cookie then persists across restarts. The registration itself grants nothing: Cloud data and visits reach the device through a current use, or through a live visit when the application enables staff push below. The employee who registers it is its first user under the default use model.

A use is an ordinary device in the table above, with an owner, the owner's current web session, and at most 12 hours. It ends on logout, authority invalidation, revocation, expiry, or a takeover. When it ends, the device stays registered and shows its waiting screen; it does not return to pairing.

Take over a device

This holder-based workflow applies when registeredDevices.push is not "staff".

registrations/list (list-registrations) returns every registered device of the application, not only the caller's. Each item has holder with the current user's ID, display name, use deviceId, its expiry, and visitActive, or null when nobody uses it. The staff list falls back to the login name for holder. Each item also includes activeVisit: { id, issuedBy: { displayName }, issuedAt } | null. This uses only the issuing employee's display name, or null for an empty name. issuedAt is the visit's original issuance time. Visit-only staff pushes have no holder.

Rename a registration with registrations/rename and { registrationId, name }. It requires rename-registration, checked with the existing registration's { registrationId, name } target before writing and again before commit. The response is { id, name }; names are trimmed, nonempty, and subject to the same length limit as device names. The registration list and guest status show the new name. Renaming does not change a visit's authority. There is no lastSeenAt: status polling does not write a presence timestamp.

registrations/take-over (take-over-device) takes { registrationId, expectedDeviceId, presenceCode }. The device shows a six-digit presence code on its waiting screen; the employee types it at the desk. This proves that they stand at that device and replaces the QR scan. The code changes every 30 seconds, and the previous code is still accepted. It is bound to the registration and its browser, so a code from another device never matches, and a registration whose browser is gone can no longer be taken over. An empty or wrong code returns 403 PRESENCE_REQUIRED; a request without the presenceCode field is 400 INVALID_INPUT. Each registration accepts five attempts per 30 seconds, correct or not; more return 429 RATE_LIMITED. A Valkey failure returns 503 UNAVAILABLE.

A free device is taken without confirmation. When someone else uses it, Cloud returns 409 IN_USE unless expectedDeviceId is the use the staff page showed. Ask the employee to confirm the displayed holder, then send that holder.deviceId. A use that changed in the meantime is never replaced silently. A retry from the session that already uses the device returns its current use.

A takeover ends the previous use and its running visit in the same transaction and records app_pwa.registration.taken_over with the new user as actor, previousDeviceId, and the ended visitId. previousOwnerUserId is set only when the previous use was still live; it is null for a free device. End your own use with devices/revoke and its deviceId. registrations/revoke (revoke-registration) unregisters the device: its use and visit end, and the browser must bootstrap again.

Listing, taking over, renaming, and unregistering act on other people's state. By default any employee whom authorizeStaff admits for the action can do so for every registered device of the application. Cloud calls authorizeStaff once without a target and again with target: { registrationId, name } for the registration being changed or pushed to, and for each listed registration. These checks repeat before the transaction commits. Return false to narrow the action, for example to one location's devices. A listed registration that the app refuses is left out of the page.

Show who uses it

Guest status returns registration for a registered browser: { id, name, expiresAt, holder, presence }. holder is { displayName } while a use is live and null otherwise. presence is { code, expiresAt }; poll status at least every 30 seconds and show the current code. Staff responses never contain it. Without a use, device and visit are null. The device only learns the display name; it never receives an account ID, login name, or contact detail, and an empty display name is null. Show the name so customers and staff can see whose device it is.

Send a visit without a QR

visits/issue with push: true works for any active device that the caller owns: a device they paired by QR or their current use of a registered device. The visit opens immediately, bound to that device's browser, and appears on the next status poll. qr is null. authorizeVisit still decides the subject, and deadlines start at issuance. A live visit on the device returns 409 ACTIVE_VISIT; end or revoke it first. Repeating the request ID returns the same visit. This owner-scoped request remains unchanged by the opt-in below.

To let any admitted employee send a visit to an application-owned registered device, set this option in createAppPwa():

ts
registeredDevices: { push: "staff" },

Then send { requestId, registrationId, subject, push: true } to visits/issue instead of deviceId. Cloud checks issue-visit without a target and with { registrationId, name }, and separately checks authorizeVisit; both checks repeat before commit, including retries. No holder, takeover, or presence code is needed. New registrations start on the neutral waiting screen and grant no device access before a visit. Without this option, this request returns 403 FORBIDDEN and the existing device ownership and takeover rules apply. With the option enabled, registrations/take-over returns 403 FORBIDDEN; use staff push instead of starting a holder-based use. Before enabling it, make sure authorizeStaff checks target for any location or device restrictions your application needs.

The visit is bound to the issuing employee's current web session. It ends on that session's logout, expiry, or authority invalidation, even if the registering employee remains signed in. The registration itself stays valid. Resource operations intersect the explicit device ceiling with the issuer's current grants. No resource operation or Core query can fall back to device access after this visit ends. Guest status then returns device: null, visit: null, and the registration with holder: null.

A push atomically replaces an idle old-style use, including one held by another employee. A live visit on either kind of use returns 409 ACTIVE_VISIT and is never replaced. Concurrent pushes serialize on the registration: exactly one opens a visit and the other receives ACTIVE_VISIT. The replaced use does not resume after the visit ends.

The response keeps { id, state, expiresAt, idleExpiresAt, qr, qrExpiresAt }, with state: "active" and qr: null. The 15-minute idle and 60-minute total limits and command attempt reconciliation are unchanged. Reusing a request ID in the same issuing session returns the existing visit; changing its validated input returns 409 ATTEMPT_CONFLICT. A retry can return 410 REVOKED after a later push replaces the previous visit binding; a retry never opens another visit.

Any employee admitted for revoke-visit on that registration can end a visit with visits/revoke and { registrationId, visitId }, using activeVisit.id from the staff registration list; the response is { state: "revoked" }. This also works for an old-style visit on the same registration. The existing { deviceId, visitId } owner command remains. Guest visit/end and a completing domain command retain their behavior. app_pwa.visit.pushed, app_pwa.visit.revoked, and app_pwa.visit.completed include registrationId and issuedByUserId. A staff action records the acting employee separately as the audit actor; ending another employee's visit preserves the original issuer. A push also records the replaced previousDeviceId and previousOwnerUserId; the previous owner is null for a visit-only or expired binding.

Wrong or mixed target shapes return 400 INVALID_INPUT. A missing target returns 404 NOT_FOUND, and an expired or revoked registration returns 410 EXPIRED or 410 REVOKED. Target authorization failures are 403 FORBIDDEN; a dead issuing web session is 401 WEB_SESSION_REQUIRED.

Recover on the device

On 401 or 410, call bootstrap and then status. A registered browser keeps its credential through bootstrap and gets registration; show the waiting screen. Otherwise show pairing. A device on an older Cloud package treats the end of a use as terminal and must be registered again, but it never gains authority from a registration.

Keep the authorities separate

Action Authority
Issue a device, list it, or revoke it Current authenticated web session, application staff authorization, and device ownership
Push by registrationId (opt-in) Current issuing web session, issue-visit authorization for that registration, and visit authorization; no holder or presence code
End a registered visit (opt-in) Current web session and revoke-visit authorization for that registration
Rename a registration Current web session and rename-registration authorization for that registration
Push a visit by deviceId Current web session, issue-visit and visit authorization, and ownership of that active device
Register a shared device Current web session and register-device authorization; the registering employee becomes its first user by default; staff-push registrations start neutral
List registered devices or unregister one Current web session and application staff authorization for that registration; app-wide unless the app narrows it, no device ownership
Take over a registered device Same as listing, plus the presence code shown on that device and confirmation of a live holder; unavailable with staff push enabled
Open, read, or complete a customer's visit Paired device and browser, current visit, and the application's visit and document rules
Suggest addresses through an allowlisted Core query Current pairing and visit, charged to the owner’s Geo budget
Use a declared Cloud resource operation Explicit device permission ceiling intersected with the owner's current resource grants; for staff-pushed registered visits, the owner is the issuer and the visit must be live

The owner can issue visits from another currently authorized web browser while the original pairing session remains valid.

Customer commands do not intersect the employee's resource grants. For example, the application may let a customer sign a document offered by their visit without granting the pairing employee permission to read that document.

Use AppPwaDeviceActor and AppPwaVisitActor in handlers. ownerUserId records ownership; do not convert it into an employee request actor, bearer token, or session cookie. appPwaDeviceOperation() provides a local service adapter with current Cloud resource-grant checks. It does not create cross-application invocation credentials or authorize arbitrary downstream services. The adapter must preserve device provenance when it calls its owning service.

Offer address suggestions

Declare coreQueries: ["address.autocomplete"] in createAppPwa() to offer address suggestions on a paired device. The default is an empty allowlist. Unknown queries and actions fail at construction; autocomplete is the only supported query. The existing queries map still holds application-owned visit queries.

The closed guest route core/query/address.autocomplete accepts { visitId, input }, with the same cookie, CSRF, Origin, JSON, and byte budgets as other guest operations. With an explicit visitId, the visit must be live; an expired, ended, or revoked visit never falls back to device authority. Without visitId, a live visit still binds the call. After that visit ends, device mode resumes while the pairing remains valid. A staff-pushed registered visit never resumes device mode. Queries never renew a visit.

ts
const response = await fetch(`${guestApiPath}/core/query/address.autocomplete`, {
  method: "POST",
  headers: { "Content-Type": "application/json", "X-App-Pwa-CSRF": csrf },
  body: JSON.stringify({ visitId, input: { text: "Example Str", countryCode: "de" } }),
  signal,
});

A successful response is the autocomplete data (suggestions and provenance). Keep the linked Geoapify · OpenStreetMap attribution beside the suggestions. Address text is limited to 3–300 characters and results to five suggestions. Locale follows the request's normal Cloud locale preference.

This is the narrow exception to the ownership rule above: the platform's App-PWA adapter may use the validated pairing owner as the autocomplete budget subject. It calls the same Geo service as the owner's Core query, preserving the device or visit actor rather than creating an employee request actor. It creates no bearer token, employee cookie, mandate, or arbitrary cross-app invocation. Application code must continue to use the closed route.

Cloud checks pairing, browser, initiating web-session authority, and any visit in short transactions before and after the lookup. No database lock spans the provider request. A changed, expired, or revoked authority refuses the response; provider work already sent can still consume credits. An in-flight call cannot switch from one visit to another, or from a visit to device authority. If a visit opens during a device-mode lookup, 503 UNAVAILABLE asks the client to refresh status and retry. A paid lookup can also consume credits before the application's response-byte budget refuses an oversized result. Revocation and expiry use the existing 401/410 errors. An undeclared query returns 403 FORBIDDEN. Valkey separately limits the device and visit, including cache hits, to the smaller of security.rate_limit_per_second and the Geo plan's requests per second. 429 RATE_LIMITED means that request budget was reached. A Valkey failure returns 503 UNAVAILABLE and does not call the Geo service.

Paid lookups count against the owner's existing daily budget and the Cloud monthly budget; cache hits remain free. Geo failures retain their own code. 429 GEO_BUDGET_EXHAUSTED includes only scope and resetsAt in details; other Geo errors contain no details. Employee and operator balances and provider costs stay private. Show budget recovery separately from a request-rate refusal. The audit entry app_pwa.core.query records the owner in metadata.subject and the device or visit in metadata.via. It leaves the employee actor unset and stores no address input or results. Failed lookups and refusals after a lookup are recorded too. No content is created, so this read has no content-provenance stamp.

Choose lifetimes

deviceTtlSeconds chooses the device lifetime. A positive requested value above 12 hours is capped at 12 hours. The initiating web session's remaining lifetime and browser binding may shorten it. Logout of that session, owner authority invalidation, or device revocation also closes access. Device lists report that earlier device as expired or revoked even after its owner signs in again.

A visit expires after 15 minutes without a newly committed command, or after 60 minutes in total, or when its device expires. Queries, status polling, claim retries, and repeated command attempts never extend these deadlines. Do not send a heartbeat command merely to keep a visit alive.

Create the service and closed router

Import these server APIs from @k2b/cloud/app-pwa:

  • createAppPwa() returns service, router, and setup.
  • appPwaQuery() validates a read's input and output without renewing the visit.
  • appPwaCommand() validates a command and runs it with attempt reconciliation.
  • appPwaDeviceOperation() declares a local device operation and its resource permission.
  • AppPwaError reports a stable transport code and status for an application rejection.

Pass the application ID and declared installable.path, a canonical configured HTTPS publicOrigin, its Postgres schema, a subject schema, and application staff authorization. authorizeStaff(action, signIn, tx, target?) checks staff actions; authorizeVisit(signIn, subject, tx) must separately authorize exposing the validated domain subject to a customer. Both are required and repeated before issuance commits. Lock the subject's domain authority with tx; a schema-valid resource ID alone is not permission to create a visit. Supply explicit budgets for request and response bytes, list page size, and the maximum resource IDs checked by an operation. Size these for the actual document and signature format. An oversized request, response, or stored attempt result is rejected.

Register the finite queries, commands, and optional deviceOperations maps. Missing operations are denied. Registering a device operation alone grants nothing: devicePermissions must declare its permission ceiling too.

Run setup() in the application's normal startup setup, before listening. It creates reserved _cloud_pwa_* tables in the application's schema. A failed setup leaves the service unavailable. Keep these tables in the application's normal database backup and data-management contract.

Mount router.fetch at the returned guestApiPath and staffApiPath without adding routes inside it. The guest path is <installable.path>_api/; the staff path is /api/<app-id>/app-pwa/. The router exposes only its declared protocol, not a generic authenticated Hono context. Configure the installation's public page and guest API as explicit public app-access routes. The staff API still requires its own current web session and application authorization.

Write within the visit transaction

A query receives { actor, tx, signal }. A command also receives endVisit(). Read the visit's validated actor.subject, authorize the offered domain resource, and use the supplied tx for every domain read and write. Check the current document revision, whether it was offered to this visit, consent, and the signature format on the server. Never accept the browser's display model as authority.

For a final action, call endVisit() after the domain write. The domain result, attempt record, and visit completion commit together. A rollback leaves none of them committed. Device and visit revocation use the same authority and session locks as the command. Do not write through another connection, and do not send external effects from the handler; persist an outbox record in tx when delivery must survive a crash.

Commands carry a client-generated attemptId. Keep it for retries of that same route and validated input. Reusing it for different input is rejected. If the response is lost, query that attempt before offering another signature. An attempt lookup returns { state: "unknown" } or { state: "committed", completed }. completed means the visit ended through endVisit(); a committed command alone does not prove that the whole visit completed. Terminal command replays also include this flag with result: null. Neither response exposes stored customer content after terminal state. A new customer cannot read an earlier visit's result. Status-only recovery also remains available after a later staff-pushed visit replaces the earlier visit on the same registered browser. It never reopens the earlier visit or returns its stored result.

Handlers must honor signal and finish inside Cloud's bounded request budget. The transaction checks current database time after waiting for locks and again before COMMIT, after deferred constraints have been checked. Cancellation and the remaining request and authority budgets guard SQL through COMMIT. The platform requests PostgreSQL cancellation and keeps the connection out of reuse until cancellation and cleanup settle. It discards the cancelled connection. COMMIT may become durable before cancellation takes effect; those writes remain committed. A timeout or lost response therefore leaves the outcome uncertain: reconcile the same attempt before offering another signature. A pending UI must not infer success or rollback from a timeout.

Use the browser protocol

Import parseAppPwaQr() and clearAppPwaFragment() from @k2b/cloud/app-pwa/browser. The parser accepts only the configured HTTPS origin, exact installation path, QR kind, and secret format. Never navigate to or fetch the scanned URL. Clear a directly opened fragment before asynchronous work; it is not an alternate claim flow.

All protocol requests are same-origin JSON POST requests. Bootstrap returns a CSRF value and sets a generated, host-only Secure, HttpOnly, SameSite=Strict cookie scoped to the installation path. Send X-App-Pwa-CSRF with subsequent requests. The staff bootstrap binds its CSRF value to its web session instead. Requests require the exact configured Origin; there is no CORS grant or GET mutation. Rate limiting uses Cloud's existing request budget.

Guest operation Body
bootstrap, status {}
device/claim, visit/claim { qr }
core/query/address.autocomplete { visitId?, input }
query/<name> { visitId, input }
command/<name> { visitId, attemptId, input }
visit/attempt { visitId, attemptId }
visit/end { visitId }
device/<name> { input }

The staff protocol provides bootstrap, devices/issue, devices/list, devices/status, devices/revoke, visits/issue, visits/revoke, registrations/list, registrations/take-over, registrations/rename, and registrations/revoke. Lists return { items, next }; pass after: next for another bounded page. Issuance returns qr and qrExpiresAt alongside state. Treat QR content as a transient secret: do not log, persist in browser storage, or put it in a navigated URL.

Return to a neutral screen

Keep unfinished signatures and sensitive display state only in memory. Hide them on page hide, visibility loss, and loss of connection. On return, reload, or Back, revalidate before showing customer content. Aborted or late responses must not restore a previous customer's view.

Show distinct pending, unknown, rejected, and confirmed states. If ending a visit loses its response, conceal the content immediately but do not announce that the server has confirmed the end. Provide status recovery. An expired or revoked device must return to pairing without manual cookie removal.

Use an iPad browser profile with no employee sign-in. Installation paths are not separate origins; applications on the same origin share a script trust boundary. See App-PWA installation for the neutral offline page and restart-only worker updates.

Connect a coding agent

The Cloud skill provides compact working instructions. MCP supplies exact current documentation when details matter.

CLI command
bunx skills add https://docs.example.com

This command installs the working instructions published by this website in the selected coding agent.

For exact, current details, also connect the MCP server through one of the agent tabs.

Installation uses the open-source Vercel Skills CLI.

The skill and MCP complement each other: the skill describes workflows, while MCP supplies current documentation.