Skip to content

Bridge: stable install id and pushTokenChanged for device registration; document the no-Service-Worker rule #163

Closed

Description

@hotlong

Context

These come from the acceptance reviews of objectstack-ai/objectstack#29218 (the push device registry contract) and objectstack-ai/objectstack#29326 (the Console's Service Worker).

  • Duplicate device rows. The registry identifies a device by (userId, deviceId). The bridge exposes no install id (lib/shell/bridge/protocol.ts, BridgeHostInfo: platform, appVersion, capabilities, safeArea), so the Console can only keep a server-minted deviceId in WebView storage. After that storage is cleared, it re-registers the same token without a deviceId, and the server mints a second row.
  • Token rotation. The bridge events are only ready and routeChanged. The Console cannot re-register when APNs/FCM rotate the token (Console: register the mobile shell's native push token after sign-in, unregister it on sign-out objectstack#29042, ask 3); it can only query again at sign-in.
  • iOS payload placement. routeFromResponse reads content.data, the top-level keys beside aps, and userInfo.body, but never userInfo.data. The framework contract will say "never under data", so a fallback here is belt-and-braces only.
  • Service Worker. The Console now registers a Service Worker in browsers and skips it, unregistering any old one, when window.ObjectStackNative is present. A worker inside the Android WebView would serve a cached document offline, so onError would never fire and the shell's offline screen would be masked. iOS WKWebView has no navigator.serviceWorker without App-Bound Domains.

Ask

  1. installId. Generate a random id on first launch and keep it in SecureStore (it survives WebView storage loss; a reinstall resets it). Expose it on BridgeHostInfo. It is not a hardware identifier, and no advertising ID is used.
  2. pushTokenChanged event. Listen for token rotation with Notifications.addPushTokenListener and dispatch a window event to the Console carrying {token, vendor}, announced as a capability. Follow docs/HYBRID-SHELL.md (Adding a method) for the event.
  3. Payload fallback. Accept userInfo.data.route as a fourth candidate in routeFromResponse, after the existing three.
  4. Docs. In docs/HYBRID-SHELL.md, state that the Console registers no Service Worker inside the shell, and why. Decide in the PR whether the ObjectStackShell/ user-agent marker (applicationNameForUserAgent) becomes a documented contract that the Console may read synchronously; if so, document it.

After acceptance, the seat files the Console consumer card in objectstack-ai/objectstack: send deviceId = installId and re-register on pushTokenChanged.

Acceptance

  • Unit tests:
    • installId is stable across calls and regenerated when missing;
    • the event is dispatched on rotation;
    • the userInfo.data candidate is read only after the other three.
  • docs/HYBRID-SHELL.md and docs/PUSH-NOTIFICATIONS.md document the new field and event.

Activity

  1. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    Contributor

    repo:mobile#1 · os-justin · session_01QSeKYnW1Xf6HDSWz1eGAqv

    First touch (single-lane repo). Type enhancement, a native capability (bridge plus push).

    • Premise, read on main at 7cb17d1: it holds.
      • BridgeHostInfo has no install id (lib/shell/bridge/protocol.ts:88-94).
      • routeFromResponse reads content.data, userInfo and userInfo.body only (lib/push-notifications.ts:156).
      • There is no addPushTokenListener anywhere.
    • Seat note on the human floor: installId is read-only, so it is not a new bridge write capability. But it is a stable per-install identifier visible to every frame that can reach the bridge. docs/HYBRID-SHELL.md (Known limits) says old Android WebViews cannot tell an iframe's bridge message from the page's. The dispatch asks the dev to measure that exposure and report it. The seat raises it at review if it needs a ruling.
  2. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    Contributor

    repo:mobile#1 · os-justin · session_01QSeKYnW1Xf6HDSWz1eGAqv

    Claim: PM loop round 1
    Session: session_01QSeKYnW1Xf6HDSWz1eGAqv
    Account: os-justin
    Branch: claude/issue-163-install-id-token-rotation
    Worktree: mobile-issue-163
    Domain: n/a — single-lane repo (repo:mobile)
    Seat: repo:mobile#1
    File surface: lib/shell/bridge/**, lib/push-notifications.ts, hooks/usePushRouting.ts, a new install-id module under lib/, components/shell/ConsoleWebView.tsx (event delivery only), __tests__/**, docs/HYBRID-SHELL.md, docs/PUSH-NOTIFICATIONS.md, .changeset/163-install-id-token-rotation.md (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: default tier (opus) — dispatch-gates --tier --repo objectstack-ai/mobile: no path-derived mandate, effort: high
    Clause-②: no
    Metadata-protocol: none
    Responsibility: n/a — not a defect card
    Thread-read: none
    Premise: holds
    Serial constraints cleared: #162 is dispatched in the same round. It owns the cover and held-route region of ConsoleWebView.tsx; this card adds only event delivery there. docs/HYBRID-SHELL.md is shared with #154 and #162. Ordinary concurrency.

  3. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    Contributor

    repo:mobile#1 · os-justin · session_01QSeKYnW1Xf6HDSWz1eGAqv
    os-dev-report

    {
      "issue": 163,
      "status": "done",
      "branch": "claude/issue-163-install-id-token-rotation",
      "pr": "https://github.com/objectstack-ai/mobile/pull/167",
      "session": "session_01QSeKYnW1Xf6HDSWz1eGAqv",
      "premise_still_valid": true,
      "summary": "installId: lib/install-id.ts generates a CSPRNG UUID v4 (expo-modules-core uuid.v4, no new dependency) on first read. It is kept in SecureStore (AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY), read synchronously, and exposed as BridgeHostInfo.installId / window.ObjectStackNative.installId; it is null, and nothing is minted, when storage fails. pushTokenChanged: added as a SHELL_EVENTS capability. addPushTokenChangeListener wraps Notifications.addPushTokenListener and never throws; usePushTokenChanges owns its lifecycle. ConsoleWebView dispatches objectstack-native:pushTokenChanged {token, vendor} only to pages on the server origin. Mid-task seat note (#162/#166): measured that load-end delivery fired before the Console's listeners mount (main 7cb17d1 ConsoleWebView.tsx:150,320 vs objectstack 5901871c useNativeHost.ts:115-133). It now holds the latest token until ready and re-arms on onLoadStart; the overlap is named in the PR body. routeFromResponse reads userInfo.data as the fourth candidate. Docs cover the install id, the shell event and how to add one, the UA marker (decided: documented contract), no Service Worker in the shell, and where host info reaches. Premise held on main 7cb17d1: protocol.ts:88-94 has no install id, push-notifications.ts:156 has three candidates, addPushTokenListener has 0 hits (exit 1; control routeFromResponse 1 hit, exit 0).",
      "tests": "head bcfc984: npx jest --ci exit 0, Test Suites: 27 passed, 27 total, Tests: 321 passed, 321 total. Acceptance pins: install-id.test.ts covers 'is stable across calls', 'is stable across launches' and 'is regenerated when missing'; ConsoleWebView.test.tsx covers 'delivers a rotated token to a ready page as a window event' and the hold-until-ready cases; push-notifications.test.ts covers 'reads userInfo.data only after the other three candidates'. Ablation, three legs (jest transpiles src, no dist), each committed first, anchor grep -c 1 to 0, restored with git checkout HEAD plus a hash-object compare, all byte-identical. Leg 1, the fourth candidate removed: exit 1, 2 failed. Leg 2, the origin check removed: exit 1, 2 failed (the other-origin script test and the ConsoleWebView delivery test). Leg 3, the token hold removed: exit 1, 3 failed. Logs are in scratchpad issue-163/ablate1-3.log.",
      "gates": "local, head bcfc984, exit captured before any pipe (scratchpad issue-163/gates.txt): npx tsc --noEmit EXIT=0; npx eslint . --ext .ts,.tsx EXIT=0 (0 warnings, empty output); npx jest --ci EXIT=0. Control-byte self-scan of changed files: exit 1 = none. The objectstack dispatch-gates --commands/--ran derivation is n/a: the mobile repo has no scripts/pm/dispatch-gates.mjs, and the gate list came from the dispatch. PR check-runs at the moment of the report: 'Code Quality' in_progress, 'Lint & Type Check' in_progress (not waited on, per contract).",
      "dogfood_battery": "n/a — no ! in the PR title and no (narrowing) arm",
      "mcp_calls": "0",
      "api_writes": "2 — relay: pr_create objectstack-ai/mobile (POST /repos/objectstack-ai/mobile/pulls, run 41446810855, read-back identical 13044 bytes) and the os-dev-report comment on #163 via post-stamped.mjs (POST /repos/objectstack-ai/mobile/issues/163/comments). git push is not counted.",
      "line_budget": "n/a — no skills/** touched; diff vs origin/main 7cb17d1: 15 files, 925 insertions, 12 deletions (git diff --shortstat)",
      "files_changed": [
        ".changeset/163-install-id-token-rotation.md",
        "__tests__/components/ConsoleWebView.test.tsx",
        "__tests__/hooks/usePushRouting.test.ts",
        "__tests__/lib/install-id.test.ts",
        "__tests__/lib/push-notifications.test.ts",
        "__tests__/lib/shell/bridge-protocol.test.ts",
        "__tests__/lib/shell/bridge-scripts.test.ts",
        "components/shell/ConsoleWebView.tsx",
        "docs/HYBRID-SHELL.md",
        "docs/PUSH-NOTIFICATIONS.md",
        "hooks/usePushRouting.ts",
        "lib/install-id.ts",
        "lib/push-notifications.ts",
        "lib/shell/bridge/protocol.ts",
        "lib/shell/bridge/scripts.ts"
      ],
      "deviations": [
        "ConsoleWebView.tsx goes beyond 'event delivery only' in two ways. (1) The hostInfo memo gains installId: getInstallId() and capabilities: BRIDGE_CAPABILITIES; BridgeHostInfo is built there, so the card's ask cannot land anywhere else. (2) After the seat note, the event path adds one line in the existing onEvent ready branch and a new onLoadStart prop. The cover, held-route and last-route logic is untouched.",
        "__mocks__/expo-notifications.js lacks addPushTokenListener and is outside the surface. Three test files therefore add it through a per-file jest.mock factory spreading jest.requireActual of the shared mock.",
        "The local objectstack checkout was at d9885f5d, behind remote main 5901871c. The UA re-measure used a depth-1 scratch clone of 5901871c, with the PR heads #29326 (55e3e5e) and #29218 (eb9b237) fetched into scratch refs. /home/user/objectstack was not modified.",
        "Commit trailers use the model-free pair from os-dev.md and AGENTS.md (Co-authored-by: Claude), not the harness's model-named Co-Authored-By line."
      ],
      "open_questions": [
        {
          "question": "Exposure reading: installId (host info) is readable from the main frame only. No sub-frame can read it: iOS adds it as a WKUserScript forMainFrameOnly (RNCWebViewImpl.m:1714,1754; WebView.ios.tsx:61-62 defaults true); Android runs evaluateJavascript from onPageStarted (RNCWebView.java:309-316, RNCWebViewClient.java:85-90; WebViewTypes.ts:1283-1289 'mandatory for Android'); no bridge method returns host info. But buildBootstrapScript (lib/shell/bridge/scripts.ts:32) is not origin-gated, so every main-frame page gets it, including EXPO_PUBLIC_TRUSTED_HOSTS sign-in pages (navigation-policy.ts:68). Is a per-install id acceptable on trusted IdP pages?",
          "options": [
            "A — keep as the card specifies (current PR); trusted hosts are treated as same trust; documented in HYBRID-SHELL Known limits. Cost: none.",
            "B — the bootstrap includes installId only when window.location.origin equals the configured server's origin (same rule as getPushToken and pushTokenChanged). It stays synchronous. Cost: about 5 lines plus 2 tests, one small follow-up PR.",
            "C — move installId to an origin-gated bridge method getInstallId(). Cost: async for the Console, a new method and capability, a larger Console change."
          ],
          "recommendation": "B, because it applies the existing 'data only the Console should see is origin-gated' rule at the lowest cost and keeps the synchronous read; A is defensible if the maintainer treats trusted hosts as Console-equivalent."
        },
        {
          "question": "The card says 'a reinstall resets it'. That holds on Android (app data is wiped; a backup-restored value cannot be decrypted, and expo-secure-store SecureStoreModule.kt:159-168 removes it). On iOS, keychain items can outlive an uninstall, so the id may survive a reinstall. Keep that?",
          "options": [
            "A — accept it and document it (current PR). Cost: none. This favours the card's goal of fewer duplicate rows.",
            "B — add an app-sandbox marker (removed on uninstall) and regenerate when the marker is missing but a keychain value exists. Cost: a second store, more first-launch logic and tests."
          ],
          "recommendation": "A, because the id is app-scoped, THIS_DEVICE_ONLY and random; surviving a reinstall reduces orphan registry rows, which is the card's purpose."
        }
      ],
      "security_findings": "none",
      "out_of_scope_findings": [
        "carrier: seat (the Console consumer card it files after acceptance) · noted, not filed. The Console Service Worker gate in objectstack-ai/objectstack#29326 (head 55e3e5e, ui/apps/console/src/main.tsx:218-233) reads getNativeHost() after boot and concedes that a late bridge leaves that load's worker in place. Reading the ObjectStackShell/APPVERSION user-agent marker, now a documented contract, closes that window. Measured: 0 consumers of the marker on objectstack main 5901871c (git grep exit 1; control leg exit 0). Dedupe words: pushTokenChanged, installId deviceId, ObjectStackShell user agent, service worker native shell"
      ]
    }
  4. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    Contributor

    repo:mobile#1 · os-justin · session_01QSeKYnW1Xf6HDSWz1eGAqv
    os-dev-report

    {
      "issue": 163,
      "status": "done",
      "branch": "claude/issue-163-install-id-token-rotation",
      "pr": "https://github.com/objectstack-ai/mobile/pull/167",
      "head": "526102f",
      "session": "session_01QSeKYnW1Xf6HDSWz1eGAqv",
      "premise_still_valid": true,
      "summary": "Patch round 1, one normal commit 526102f on top of bcfc984 (no force-push). Q1, option B: buildBootstrapScript(info, serverUrl) (lib/shell/bridge/scripts.ts:39-48) exposes installId only when window.location.origin equals the configured server's origin (default port dropped). Every other main-frame page, including a trusted sign-in host, reads null, and it stays synchronous. The id is never spliced into the script when serverUrl has no origin. ConsoleWebView passes serverUrl to the bootstrap memo. docs/HYBRID-SHELL.md states the gate, and the Known-limits entry is rewritten: trusted hosts now get only the non-identifying fields and null for installId. The changeset states the gate. Q2, option A kept. main moved to 83d4716 (#165, which touches docs/HYBRID-SHELL.md), but PR #167 reads mergeable true / mergeable_state clean, so per AGENTS §10 there was no merge. #166's lib/shell/console-load.ts is not on main (git cat-file: does not exist), so nothing was routed through it; the PR body text names the reconcile path. The PR body was NOT edited: see deviations. The full revised body is ready for the seat at /tmp/claude-0/-home-user/b8d41080-bc0f-5479-b39a-9ebb93157dc8/scratchpad/issue-163/pr-body-r1.md.",
      "tests": "head 526102f: npx jest --ci --coverage exit 0, Test Suites: 27 passed, 27 total, Tests: 324 passed, 324 total; coverage thresholds met (install-id.ts 95.45% statements, scripts.ts 100%, protocol.ts 100%). New pins in bridge-scripts.test.ts: 'exposes the install id, read-only, to a page on the configured server's origin'; 'exposes a null install id to a page on another origin (a trusted sign-in host)', which also asserts the id is absent from the exposed object; 'compares the page origin with the default port dropped'; 'exposes a null install id with no usable server URL' (the id is not in the script text either). Ablation leg 4, committed first: the onServer arm removed from scripts.ts, anchor grep -c 1 to 0 (injected form 1). Exit 1: only the trusted-host test failed (1 failed, 23 passed). Restored with git checkout HEAD plus a hash-object compare, byte-identical. Log at /tmp/claude-0/-home-user/b8d41080-bc0f-5479-b39a-9ebb93157dc8/scratchpad/issue-163/ablate4.log.",
      "gates": "local, head 526102f, exit captured before any pipe (/tmp/claude-0/-home-user/b8d41080-bc0f-5479-b39a-9ebb93157dc8/scratchpad/issue-163/gates-r1.txt): npx tsc --noEmit EXIT=0; npx eslint . --ext .ts,.tsx EXIT=0 (0 warnings, empty output); npx jest --ci --coverage EXIT=0. PR check-runs on 526102f: 'Code Quality' completed/success, 'Lint & Type Check' completed/success. Previous head bcfc984: both completed/success.",
      "dogfood_battery": "n/a — no ! in the PR title and no (narrowing) arm",
      "mcp_calls": "0",
      "api_writes": "round 1: 1 — the os-dev-report comment on #163 via post-stamped.mjs (POST /repos/objectstack-ai/mobile/issues/163/comments). No issue_patch was sent. Cumulative: 3 (pr_create, the round-0 report comment, this comment). git push is not counted.",
      "line_budget": "n/a — no skills/** touched; diff origin/main...526102f: 15 files, 983 insertions, 26 deletions (git diff --shortstat)",
      "files_changed": [
        ".changeset/163-install-id-token-rotation.md",
        "__tests__/components/ConsoleWebView.test.tsx",
        "__tests__/lib/shell/bridge-scripts.test.ts",
        "components/shell/ConsoleWebView.tsx",
        "docs/HYBRID-SHELL.md",
        "lib/shell/bridge/scripts.ts"
      ],
      "deviations": [
        "The PR body was not edited, despite the order to edit it through relay issue_patch. Three things block it. (1) .claude/agents/os-dev.md says the dev writes the PR body once, at pr_create, never PATCHes it, and names later changes in the report for the seat to write; the file says it wins over a conflicting dispatch. (2) The relay refuses an unsigned issue_patch body (scripts/pm/fleet-write/dispatch.mjs:744-753): the first line must be the seat signature, or the last line must be an HTML-comment 'signed:' trailer (dispatch.mjs:695). (3) The order requires 'Fixes #163' as the first line, and os-dev.md forbids dev-written HTML comments. Remedy for the seat: issue_patch issue 167 with body = /tmp/claude-0/-home-user/b8d41080-bc0f-5479-b39a-9ebb93157dc8/scratchpad/issue-163/pr-body-r1.md plus the seat's signing trailer. That file keeps 'Fixes #163' as line 1. In 维护者速读 it updates 改了什么 and 先看, and 风险与回滚 now carries 'iOS 重装后 installId 可能保留。' and the gate. It replaces the exposure section with the gate reading, adds a '## Deviations' heading with the iOS reinstall deviation (and drops the old acceptance-note copy of it), and updates the Tests table to 526102f / --coverage / 324, ablation leg 4 and the #166 reconcile path.",
        "Surface: ConsoleWebView.tsx this round changes only the bootstrap memo, which now passes serverUrl (3 lines). Nothing in the cover, held-route or last-route logic.",
        "Worktree /home/user/mobile-issue-163 was re-created from origin/claude/issue-163-install-id-token-rotation at bcfc984 for this round."
      ],
      "open_questions": [],
      "security_findings": "none",
      "out_of_scope_findings": [
        "carrier: seat (the Console consumer card it files after acceptance) · noted, not filed. The Console SW gate in objectstack-ai/objectstack#29326 (head 55e3e5e, ui/apps/console/src/main.tsx:218-233) reads getNativeHost() after boot; the documented ObjectStackShell/APPVERSION UA marker closes the late-bridge window. 0 consumers on objectstack main 5901871c. The consumer card should also say that installId is null off the server origin and that the Console sends deviceId only when installId is a string. Dedupe words: pushTokenChanged, installId deviceId, ObjectStackShell user agent, service worker native shell"
      ]
    }
  5. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    Contributor

    repo:mobile#1 · os-justin · session_01QSeKYnW1Xf6HDSWz1eGAqv

    ACCEPT → PR #167 comment 6577598879 (head 526102f, after patch round 1: installId gated to the server origin). Ready for review; the maintainer is requested. The maintainer merges.

    The Console consumer half is recorded on its existing card, objectstack-ai/objectstack#29042 (comment 6577618893). It is not a new card, because #29042 ask 3 already owns rotation re-registration.

  6. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    Contributor

    repo:mobile#1 · os-justin · session_01QSeKYnW1Xf6HDSWz1eGAqv

    Landed. #167 merged as 29c25df, merged by os-zhuang, from head 526102f (CI green). The card closed completed via Fixes #163. pm:dispatched removed.

    Follow-ups:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions