Skip to content

feat(bridge): stable installId and pushTokenChanged for device registration; document the no-Service-Worker rule - #167

Merged
os-zhuang merged 5 commits into
mainfrom
claude/issue-163-install-id-token-rotation
Nov 2, 2026
Merged

os-zhuang merged 5 commits into
mainfrom
claude/issue-163-install-id-token-rotation

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #163

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:桥接新增 installId(首次启动随机生成、存 SecureStore 的安装 ID,只给配置服务器源的页面,其他页面读到 null)和 pushTokenChanged 事件(APNs/FCM 换 token 时通知 Console);iOS 推送点击多读一个兜底位置 userInfo.data;文档写明壳内不注册 Service Worker,并把 ObjectStackShell/ UA 标记定为契约。
  • 为什么改:Console 目前只能把服务器发的 deviceId 存在 WebView 存储里,存储一清就会注册出第二条设备记录;token 轮换时 Console 也无从得知。补上这两样后,Console 可以做到每个安装只占一条设备记录。
  • 风险与回滚:改动是增量的,协议版本不变,老 Console 不受影响。installId 已按服务器源门控,EXPO_PUBLIC_TRUSTED_HOSTS 里的登录页只读到 null,子框架读不到。iOS 重装后 installId 可能保留。回滚直接 revert 本 PR 即可,没有数据迁移。
  • 先看:components/shell/ConsoleWebView.tsx(token 在 ready 前暂存、ready 时投递,onLoadStart 重新开始暂存,与 feat(shell): act on the Console's ready and routeChanged signals; the shell owns the safe area #166 有交叠);lib/install-id.ts(同步读写 SecureStore,失败时返回 null,绝不发出没存住的 ID);lib/shell/bridge/scripts.ts 的 buildBootstrapScript 与 buildPushTokenChangedScript(installId 与 token 都只给服务器源)。
  • 两分钟验证:npx jest --ci __tests__/components/ConsoleWebView.test.tsx __tests__/lib/install-id.test.ts,应看到 2 个测试套件、全部用例通过。
  • 需要你做的:用开发构建在真机上做一轮设备检查,两项都看:① 让 token 轮换(Android 可删掉 FCM 实例 ID 或重装 Google Play 服务数据;iOS 重新注册),确认已登录的 Console 收到 objectstack-native:pushTokenChanged;② 清掉 WebView 存储(Android 系统设置里清除 WebView 数据/缓存,或在 Console 里清站点数据)后,window.ObjectStackNative.installId 保持不变。

What changes

  • installId on host info (lib/install-id.ts, BridgeHostInfo.installId, bootstrap window.ObjectStackNative.installId):
    • It is a random UUID v4 from the platform CSPRNG, via expo-modules-core uuid.v4() (UUID() on iOS, UUID.randomUUID() on Android). No new dependency. It is not a hardware id or an advertising id.
    • It is kept in SecureStore under objectstack_install_id with AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY. That makes it readable after the first unlock (a notification can start the app in the background) and keeps it out of backups restored onto another device.
    • It is read synchronously (SecureStore.getItem / setItem), because the bootstrap is built synchronously when the WebView mounts. Doing this in ConsoleWebView avoids a loading gate in the region Shell: act on the Console's ready and routeChanged signals, and settle who pads the safe area #162 owns.
    • A failed read answers null and mints nothing. An id that could not be stored is never handed out. Either way, the next call tries again.
  • pushTokenChanged (shell → Console event, announced as a capability):
    • protocol.ts: SHELL_EVENTS = ["pushTokenChanged"]. BRIDGE_CAPABILITIES = methods plus shell events, and PUSH_TOKEN_CHANGED_EVENT = "objectstack-native:pushTokenChanged".
    • lib/push-notifications.ts: addPushTokenChangeListener wraps Notifications.addPushTokenListener, maps the token to { token, vendor }, and never throws. Expo Go on Android throws there.
    • hooks/usePushRouting.ts: usePushTokenChanges owns the listener lifecycle and subscribes once.
    • scripts.ts: buildPushTokenChangedScript dispatches only when window.location.origin is the configured server's origin. Those are the same pages getPushToken() answers, so a trusted IdP page in the main frame never receives the token.
    • ConsoleWebView.tsx delivers the event. A token reported before the Console's ready() is held (latest only) and delivered at ready(). onLoadStart (a new document) re-arms the hold. See the overlap note below.
  • Payload fallback: routeFromResponse reads userInfo.data as a fourth candidate, after content.data, userInfo and userInfo.body.
  • Docs:
    • HYBRID-SHELL.md covers the install id, the shell event and how to add one, the UA marker contract, "Service Workers: none inside the shell", the Console-side integration list, and a Known-limits entry on where host info reaches.
    • PUSH-NOTIFICATIONS.md covers deviceId = installId, rotation, and the fourth candidate.
  • Changeset: .changeset/163-install-id-token-rotation.md (patch).

Decision: the ObjectStackShell/ user-agent marker becomes a documented contract

Yes. Reasons:

  1. The Console's Service Worker gate (feat(console): installable desktop PWA with an offline app shell and a push-ready Service Worker objectstack#29326 head 55e3e5e, ui/apps/console/src/main.tsx:218-233) reads getNativeHost() after boot. Its own comment concedes that a bridge landing later "leaves that page load's worker in place". Android can inject the bridge after the page's first scripts (HYBRID-SHELL Known limits), and that window is exactly the masked-offline failure.
  2. The UA token is present from the first byte, and the server sees it on every request.
  3. The token already survived the ObjectOS rebrand (feat: rebrand the app to ObjectOS (name, objectos scheme, ai.objectos.mobile) #164) as a wire identifier, and __tests__/components/ConsoleWebView.test.tsx already pins ^ObjectStackShell\/.

The doc says what it is not: it is not a capability signal (use has()), and it is not a security signal.

Consumers re-measured on objectstack main at 5901871c (shallow clone of the current tip): git grep -n "ObjectStackShell" gives 0 hits, exit 1. Control leg on the same tree: git grep -c NATIVE_NAVIGATE_EVENT -- ui/packages/mobile/src/nativeHost.ts gives 1, exit 0. The #29326 head (55e3e5e) also has 0 hits. So nothing reads the marker today, and documenting it adds an option without breaking anyone.

Exposure reading: where installId is readable

Main frame only, and only on the configured server's origin. Sub-frames cannot read it. A trusted host's main-frame page reads null.

  • Injection is main-frame only on both platforms:
    • ConsoleWebView.tsx:350,354 passes the bootstrap without the ...ForMainFrameOnly props, so the defaults apply. react-native-webview 13.16: src/WebView.ios.tsx:61-62 defaults both to true.
    • iOS: apple/RNCWebViewImpl.m:1714,1754 installs the scripts as WKUserScript ... forMainFrameOnly: with that value.
    • Android: RNCWebView.java:309-316 runs evaluateJavascript from RNCWebViewClient.java:85-90 (onPageStarted), which is main frame only. src/WebViewTypes.ts:1283-1289 says "mandatory for Android".
  • No bridge method returns host info (handler.ts). A cross-origin sub-frame cannot read the main frame's window (same-origin policy).
    • Android exposes window.ReactNativeWebView to every frame: RNCWebView.java:253-269, with Set.of("*") or addJavascriptInterface. But that object only posts. Answers are injected into the main frame.
  • The bootstrap runs in every main-frame document. navigation-policy.ts:68 loads EXPO_PUBLIC_TRUSTED_HOSTS in the top frame, so a trusted IdP sign-in page runs it too.
  • Gated (seat answer to the open question, option B): buildBootstrapScript(info, serverUrl) (scripts.ts:39-48) exposes installId only when window.location.origin equals the configured server's origin, with the default port dropped. Every other page reads null, and it stays synchronous. This is the same rule getPushToken (handler.ts) and pushTokenChanged (scripts.ts:166) already follow.
  • A trusted host's page still gets the non-identifying fields (platform, appVersion, capabilities, safeArea), as before this PR.

Overlap with #162 / #166 (claude/issue-162-ready-route-safe-area)

Measured: delivering on loadedRef fired before the Console's listeners existed.

Tests

Head 526102f, run in the worktree:

Gate Exit Verdict line
npx tsc --noEmit 0 no output
npx eslint . --ext .ts,.tsx 0 no output (0 warnings)
npx jest --ci --coverage 0 Test Suites: 27 passed, 27 total · Tests: 324 passed, 324 total; thresholds met (install-id.ts 95.45 % statements, scripts.ts / protocol.ts 100 %)

New or extended coverage:

  • install-id.test.ts: stable across calls and launches, regenerated when missing, an invalid stored value replaced, null without minting on read failure, never handing out an unstored id, null without a UUID generator.
  • push-notifications.test.ts:
    • userInfo.data fallback;
    • read only after the other three (each of content.data, top-level and body wins over it);
    • an invalid earlier candidate is skipped;
    • token mapping;
    • listener mapping and remove;
    • Expo Go no-op.
  • usePushRouting.test.ts: subscribes once, uses the latest callback, unsubscribes on unmount.
  • bridge-scripts.test.ts:
    • installId exposed read-only, or null;
    • event detail and non-cancelable;
    • no delivery to another origin;
    • default-port origin compare;
    • escaping.
  • bridge-protocol.test.ts: capabilities are methods plus events; the event type name.
  • ConsoleWebView.test.tsx:
    • bootstrap announces installId and has("pushTokenChanged");
    • dispatched on rotation to a ready page;
    • held before ready (cold start, after load end) and delivered once, latest only;
    • held across the failure page and retry;
    • re-armed on onLoadStart;
    • unsubscribe on unmount.

Ablation: four legs, each committed first, mutated with an anchor count, and restored with git checkout HEAD -- plus a hash compare.

Ablation readings
  • Leg 1, remove the fourth candidate (lib/push-notifications.ts). Anchor count 1 → 0. push-notifications.test.ts exit 1: falls back to userInfo.data.route and skips an invalid earlier candidate and still reads userInfo.data fail (2 failed, 30 passed). Restored byte-identical.
  • Leg 2, remove the origin check (lib/shell/bridge/scripts.ts). Anchor count 1 → 0. Exit 1: pushTokenChanged script › delivers nothing to a page on another origin and ConsoleWebView › pushTokenChanged › delivers a rotated token to a ready page fail (2 failed, 43 passed). Restored byte-identical.
  • Leg 3, remove the hold (components/shell/ConsoleWebView.tsx, else heldPushTokenRef.current = token;). Anchor count 1 → 0. Exit 1: the three hold tests fail (3 failed, 21 passed). Restored byte-identical.
  • Leg 4 (head 526102f), remove the bootstrap's origin gate (scripts.ts, the onServer ? arm). Anchor count 1 → 0. Exit 1: bootstrap script › exposes a null install id to a page on another origin (a trusted sign-in host) fails (1 failed, 23 passed). Restored byte-identical.

Deviations

  • iOS reinstall: the card's "a reinstall resets it" does not hold on iOS (seat answer: keep, option A). The card's phrase is a description, not a requirement.
    • Android: app data goes on uninstall. A backup-restored value cannot be decrypted, so expo-secure-store SecureStoreModule.kt:159-168 removes it on BadPaddingException and a fresh id is minted.
    • iOS: keychain items can outlive an uninstall, so a reinstall may keep the id. docs/HYBRID-SHELL.md (Install id) states this.

Acceptance notes

  • The event can repeat an unchanged token. iOS emits on every completed registration, including the one getPushToken() triggers (PushTokenModule.swift didRegister). Android replays the last known token to a new listener (FirebaseMessagingDelegate.kt addTokenListener). The registry upsert is idempotent (feat(spec): push channel protocol face — device registration widening, stored device shape, device contract members, push route, APNs/FCM config by credentialRef objectstack#29218 notification-service.ts "Refresh, not duplicate"), so the doc says to treat each event as "register this token". On Android this means a cold start delivers one event at ready.
  • deviceId in #29218 is z.string() (protocol.zod.ts:3539), so a UUID fits.
  • The userInfo.data fallback is a lenient consumer read. The card rules it as belt-and-braces. It is documented as off-contract and read last.
  • The Console consumer card (send deviceId = installId, re-register on pushTokenChanged, optionally read the UA marker in the SW gate) is for the seat to file after acceptance. Not filed here.

Verification

  • Device checks are not verified here: token rotation delivery, and installId surviving a WebView storage clear. They are listed under 「需要你做的」.
  • __tests__/** only adds addPushTokenListener through per-file jest.mock factories. The shared __mocks__/expo-notifications.js is untouched, because it is outside this card's surface.

Generated by Claude Code

The Console installs its shell-event listeners in the commit that posts
ready, after the document's load end, so a token reported earlier was
dropped. Keep the latest token and deliver it at ready; a new document
start re-arms the hold.

Claude-Session: https://claude.ai/code/session_01QSeKYnW1Xf6HDSWz1eGAqv
Co-authored-by: Claude <noreply@anthropic.com>
…, no Service Worker in the shell

Claude-Session: https://claude.ai/code/session_01QSeKYnW1Xf6HDSWz1eGAqv
Co-authored-by: Claude <noreply@anthropic.com>
…s origin

The bootstrap runs in every main-frame document, a trusted sign-in host's
pages included. Host data only the Console should see is gated to the
server origin (getPushToken, pushTokenChanged); installId now follows the
same rule and reads null elsewhere.

Claude-Session: https://claude.ai/code/session_01QSeKYnW1Xf6HDSWz1eGAqv
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

repo:mobile#1 · os-justin · session_01QSeKYnW1Xf6HDSWz1eGAqv

Seat verdict: ACCEPT — Head-sha: 526102ffcf5465bac80645ec546c1c6f700c42a4

维护者速读

  • 改了什么: 桥接新增两样东西,Console 拿到后就能让每个安装只占一条设备记录。另有一处推送兜底和两处文档:
    • installId:首次启动随机生成,存在 SecureStore,只给你配置的服务器页面看,其他页面(含受信任的第三方登录页)读到 null。
    • pushTokenChanged 事件:APNs/FCM 换 token 时通知 Console。token 先暂存,等 Console 的 ready 再送,不会丢。
    • iOS 推送点击多读一个兜底位置 userInfo.data。
    • 文档写明壳内不注册 Service Worker,ObjectStackShell/ UA 标记定为文档化契约。
  • 为什么改: Console 现在只能把服务器发的 deviceId 存在 WebView 存储里,存储一清就会多注册出一条设备记录。token 轮换时 Console 也收不到通知。
  • 风险与回滚: 改动是纯增量,协议版本不变,老 Console 不受影响。iOS 重装后 installId 可能保留,因为钥匙串可能在卸载后还在;卡上写的「重装会重置」在 iOS 上不成立,席位接受并写进了文档。回滚就是 revert 本 PR。
  • 先看: lib/shell/bridge/scripts.ts 的 buildBootstrapScript(来源门控)→ lib/install-id.ts → components/shell/ConsoleWebView.tsx 的 token 暂存与送达。
  • 两分钟验证: 运行 npx jest --ci __tests__/lib/shell/bridge-scripts.test.ts __tests__/lib/install-id.test.ts __tests__/components/ConsoleWebView.test.tsx。
  • 需要你做的: ① 真机检查两项:token 轮换后,已登录的 Console 收到 objectstack-native:pushTokenChanged;清掉 WebView 存储后,installId 不变。本 PR 未上真机。② 审批并合并。

席位意见: 两处判断是席位定的,请你知道。

  1. installId 按服务器来源门控(dev 给的选项 B)。仓里已有「只给 Console 的数据要按来源门控」这条规则,推送令牌就是这样做的,这里只是补齐,所以没进决策箱。
  2. iOS 重装可能保留 ID(选项 A)。这正好减少孤儿设备记录,符合这张卡的目的。

四条消融实测都能让对应用例转红(第四个用例候选、来源检查、token 暂存、bootstrap 门控),用例确实钉住了行为。建议合并。

席位核对

  1. Shape: draft → main. First line Fixes #163; no other closing keyword sits beside an open card (body grep). Clause-②: no and Metadata-protocol: none are present, and the changeset is patch. The seat rewrote the body after round 1 (signed trailer); the dev correctly left body edits to the seat per os-dev.md.
  2. Premise, measured: on main, BridgeHostInfo had no install id, routeFromResponse read three candidates, and there were 0 addPushTokenListener hits.
  3. Gates: check-runs on head 526102f are Code Quality success and Lint & Type Check success. mergeable_state is clean. Local run: tsc 0, eslint 0, jest --ci --coverage 27/27 suites, 324 tests.
  4. Accepted deviations:
    • ConsoleWebView.tsx goes beyond "event delivery only". It changes the hostInfo and bootstrap memos (where BridgeHostInfo is built) and adds the ready hold with onLoadStart. The cover, held-route and last-route logic is untouched.
    • addPushTokenListener is added through per-file jest.mock factories; the shared mock is outside the surface.
    • The iOS reinstall deviation is described in the body.
  5. Out of scope:
    • The Console consumer card (send deviceId = installId only when it is a string; re-register on pushTokenChanged; optionally read the UA marker in the SW gate). The seat files it in objectstack now, per the card.
    • An unchanged token can repeat on the event; the registry upsert is idempotent. Acceptance notes.
    • Skills-docs: n/a.
  6. Landing:

@objectstack-fleet objectstack-fleet Bot added the needs-user-decision Blocked on a maintainer decision — do not dispatch; on a PR: awaiting the maintainer's review label Nov 2, 2026
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 2, 2026 12:51
@os-zhuang
os-zhuang merged commit 29c25df into main Nov 2, 2026
2 checks passed
@objectstack-fleet objectstack-fleet Bot removed the needs-user-decision Blocked on a maintainer decision — do not dispatch; on a PR: awaiting the maintainer's review label Nov 2, 2026
os-justin pushed a commit that referenced this pull request Nov 2, 2026
Reconciles #167 (installId, pushTokenChanged) with the load state machine:
one source of readiness. The held push token moves into
lib/shell/console-load.ts as state plus a sendPushToken effect, sent on
entering `ready` (the event or the ready timeout) and re-armed by the shell's
reload() and a re-created WebView. #167's consoleReadyRef, heldPushTokenRef
and onLoadStart re-arm are dropped: on Android react-native-webview 13.16
fires onLoadStart from doUpdateVisitedHistory, i.e. for SPA history changes
too. hostInfo and bootstrap stay exactly as #167 wrote them. Docs stack both
sides.

Claude-Session: https://claude.ai/code/session_01QSeKYnW1Xf6HDSWz1eGAqv
Co-authored-by: Claude <noreply@anthropic.com>
os-justin pushed a commit that referenced this pull request Nov 2, 2026
Reconciles #170 (bridge scan(), per-WebView answer guard) with the load
state machine. #170's documentRef and the onMessage answer guard stay; the
counter is bumped in the machine's `recreate` effect, the one place a new
WebView is created, and nowhere tied to onLoadStart (still unwired, as in the
#167 reconcile). Two #170 tests drove a load start through the
`onLoadStart` prop; they now deliver it the way react-native-webview's
WebViewShared does (onLoadStart if wired, then onNavigationStateChange).
Docs stack #170's Scanning codes and wire-format text beside Load states.

Claude-Session: https://claude.ai/code/session_01QSeKYnW1Xf6HDSWz1eGAqv
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants