Skip to content

feat(console): register the mobile shell push token after sign-in and unregister it on sign-out - #30534

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-t8v3to
Nov 5, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-t8v3to

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Requested by Sales · project thread
Fixes #29042

Clause-②: yes (widening)
Metadata-protocol: none

维护者速读

  • 改了什么:在移动端原生壳里,Console 用自己的会话把壳的推送 token 注册到设备表;换 token 时重注册;登出前和壳移除连接时注销。
  • 为什么改:壳本身没有 API 凭证,只能由 Console 注册;不注册就收不到推送,登出不注销则已登出的设备还会收到这个人的推送。
  • 风险与回滚:只在原生壳里生效,浏览器不变;冒充会话下不注册;登出前的步骤有 3 秒上限,失败或超时都不挡登出。回滚即还原本 PR。
  • 先看:ui/apps/console/src/nativePushRegistration.ts(注册时机、去重、注销),ui/packages/auth/src/beforeSignOut.ts(登出前 seam),ui/apps/console/src/App.tsx(接线)。
  • 两分钟验证:pnpm --dir ui exec vitest run apps/console/src/__tests__/nativePushRegistration-29042.test.tsx,预期 17 个用例全过。
  • 需要你做的:无,席位落地。

What changes

  • Console (ui/apps/console) — new useNativePushRegistration(pathname), called by NativeShellRouting before useNativeHostRouting, whose return value is that call's onConnectionRemoved:
    • Asks the shell for the token (getPushToken()) once per page load, only when a signed-in person is on Home or an app and has stayed there for NATIVE_PUSH_PROMPT_DELAY_MS (1.5 s). The first call shows the OS permission prompt, so it never runs on a sign-in surface. A null answer sends nothing.
    • Registers through client.notifications.registerDevice (POST /api/v1/notifications/devices) with platform (ios or android; any other shell registers nothing), vendor, deviceId = the shell's installId when it is a string (else the id the server answered last time, else none), and origin only when window.location.protocol is http: or https: (an opaque origin reads "null", which the request grammar refuses for the whole body).
    • The last registration (person, token, vendor, device id) is kept in sessionStorage; an equal token for the same person is not sent again. A rotated token (objectstack-native:pushTokenChanged, announced as has('pushTokenChanged')) is registered again; a rotation that arrives before sign-in is registered at sign-in without asking the shell. Another person signing in on the page registers the device for them, and the registry's own token move takes it from the previous person.
    • Never registers while the session is an impersonation (session.impersonatedBy): that would move the impersonated person's pushes onto the administrator's device.
    • Unregister (DELETE /api/v1/notifications/devices/:deviceId) runs under the Console's session for the shell's connectionRemoved event and before sign-out. It resolves when nothing is left registered (a 404 counts), and rejects when the device may still be registered (no session left, or the request failed), so the shell records the truth.
  • @object-ui/mobile — ObjectStackNative gains the optional installId (string | null); the entry exports NATIVE_PUSH_TOKEN_CHANGED_EVENT and NativePushTokenChangedEvent. Both feature-detected. README updated.
  • @object-ui/auth — new registerBeforeSignOut(step) (and BEFORE_SIGN_OUT_TIMEOUT_MS): steps signOut() awaits while the session still exists, run together, bounded at 3 s, a rejection reported and dropped. Sign-out is never held back.

Why

The shell holds no API credential, so only the Console can register the device under its session (card ask 1, 3, 4). The card's comments are part of the work: the shell's installId and pushTokenChanged (record 6577618893), the onConnectionRemoved unregister seam (record 6588256589) and origin only on http:/https: (record 6595330380). Unregister on sign-out (ask 2) needs a step that runs before the session ends; no such hook existed, hence the @object-ui/auth seam (seat amendment 6659877507).

Tests

  • ui/apps/console/src/__tests__/nativePushRegistration-29042.test.tsx (new, 17): sign-in on Home registers after the delay, once, with deviceId and origin; no installId sends no deviceId and keeps the minted id; a sign-in surface and no session never ask the shell; leaving Home before the delay asks nothing; null sends no request; an impersonation registers nothing; a non-iOS/Android shell registers nothing; a rotated token re-registers and a repeat is not sent twice; a rotation before sign-in registers at sign-in; another person registers for themselves; connection removed deletes the device and forgets it; sign-out (runBeforeSignOut) runs the delete; 404 counts as unregistered; a failed delete rejects; no session rejects; nothing registered resolves with no request; origin for http, https and opaque origins.
  • ui/apps/console/src/__tests__/App.nativeShellMode-29039.test.tsx (shared harness, new top-level describe, 2): through the real App router, Home asks for the token and registers it; the shell's connectionRemoved waits for the DELETE of the registered device.
  • ui/packages/auth/src/__tests__/beforeSignOut-29042.test.ts (new, 4): every step runs and is awaited; a rejection does not reject the run; a step that never settles is cut off at the timeout; a removed step does not run.

Acceptance notes

  • Public surface added (Clause-② yes, widening): @object-ui/mobile: ObjectStackNative.installId? (optional member), NATIVE_PUSH_TOKEN_CHANGED_EVENT, type NativePushTokenChangedEvent. @object-ui/auth: registerBeforeSignOut, BEFORE_SIGN_OUT_TIMEOUT_MS. Nothing removed or narrowed.
  • Sequencing: the one-line AuthProvider.signOut() call to runBeforeSignOut() (before client.signOut()) waited for the sign-out PR that owned ui/packages/auth/src/AuthProvider.tsx (fix(console): after a sign-out, the next person to sign in lands on their own home, not the previous person's page #30490). That PR has merged (c3224219); the call landed on this branch after a merge of main, at head 9d7ef8b1 (seat amendment 6659877507).
  • Device acceptance (a device build receives a push and opens its route) is a manual run: no device lab in this container.
  • Branch is the harness-designated claude/project-thread-t8v3to (claim amendment 6659783719), not claude/issue-N-slug.
  • locale is not sent on registration (the card does not ask for it); the registry renders a push in the device locale only when one is stored.
  • The registration record lives in sessionStorage, so a cold app start re-registers once the person reaches Home (an idempotent refresh); with no record and no installId, a connection removal has no device id to delete and resolves.
  • No page under content/docs/frontend/ documents the native bridge today; the package README carries the new members.

Verification

Targeted tests (head 03b3034)
pnpm --dir ui exec vitest run apps/console/src/__tests__/nativePushRegistration-29042.test.tsx apps/console/src/__tests__/App.nativeShellMode-29039.test.tsx apps/console/src/__tests__/App.nativeShellOpenSet-29330.test.tsx apps/console/src/__tests__/main.nativeBridgeVersion-29330.test.tsx packages/mobile/src/ packages/auth/src/__tests__/beforeSignOut-29042.test.ts
 Test Files  14 passed (14)
      Tests  210 passed (210)
Type check
pnpm --filter @object-ui/mobile --filter @object-ui/auth --filter @object-ui/console --workspace-concurrency=1 run type-check
os-verify-lock: VERDICT command-exit 0
Derived gates (dispatch-gates --commands, tree a257694)
36 derived, each recorded as command :: exit N, all exit 0; plus pnpm --dir ui run check:spec-symbols :: exit 0
dispatch-gates --ran: 36 derived famil(ies) accounted for, 36 run, 0 NOT-MEASURED (a DERIVED zero)
pnpm --dir ui run check:new-line-citations: 0 new citation(s)

Re-measured on the final head 9d7ef8b1 (os-dev-report 6661373319): targeted tests 58 files, 604 passed; the three packages' type-check exit 0; 36 derived gates, all exit 0, 0 NOT-MEASURED.

🤖 Generated with Claude Code

https://claude.ai/code/session_01L6bhgWPe8iKWPE5j7CLjdF


Generated by Claude Code

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 5, 2026
@github-actions github-actions Bot added size/xl and removed size/l labels Nov 5, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#1 · os-sales · session_01L6bhgWn6zhNa4LL7VB1rtL

Contract-review-claim: session_01L6bhgWn6zhNa4LL7VB1rtL · head 9d7ef8b · 2026-11-05T15:09Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#1 · os-sales · session_01L6bhgWn6zhNa4LL7VB1rtL

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 9d7ef8b18ca6b77bc5e0a733aa77ea5c5004874b
Local-runs: none

Owed by the Clause-②: yes (widening) line alone (declaration face): no published schema, no governed path in the 13-file tree.

① Derived judgments

  1. @object-ui/auth adds registerBeforeSignOut(step: () => Promise<unknown>): () => void. Additive. The remover deletes by identity from a module Set, so the same function registered twice is one entry and one remover clears it. The Console registers its stable unregister once per host, in an effect whose cleanup removes it. Correct.
  2. @object-ui/auth adds BEFORE_SIGN_OUT_TIMEOUT_MS = 3000, the default of runBeforeSignOut(timeoutMs). Additive. Correct.
  3. The only change to an existing member is the behaviour of AuthProvider's signOut(). With no step registered, runBeforeSignOut returns at once (steps.size === 0), so every consumer that registers nothing signs out exactly as before. That is every consumer except the Console inside the shell. With steps registered it never rejects: each step's rejection is caught and warned, the steps run under Promise.allSettled, and the whole is raced against a timer that finally clears. So client.signOut() is always reached, after at most 3 s. Pins: AuthProvider.test.tsx covers the order and a failing step, and beforeSignOut-29042.test.ts covers the cut-off under fake timers and a removed step. The dev's ablation of the call reds the order pin. Correct.
  4. runBeforeSignOut and resetBeforeSignOutForTests are not in the accept set. The exports map of @object-ui/auth holds only ".", and src/index.ts exports neither of them. Correct.
  5. The step registry is module state, so two AuthProvider instances in one bundle share it. Accepted: a step is page-level cleanup (this device's registration), and the Console mounts one provider.
  6. @object-ui/mobile adds ObjectStackNative.installId?: string | null, readonly and optional. It widens the type for readers, who narrow with typeof === 'string' as the Console's installIdOf does. It puts no new obligation on an implementer: a mock or an older shell can simply omit it. It matches the shell's contract recorded on the card (comment 6577618893, feat(bridge): stable installId and pushTokenChanged for device registration; document the no-Service-Worker rule mobile#167): string | null, synchronous, and null off the configured server's origin. Correct.
  7. @object-ui/mobile adds NATIVE_PUSH_TOKEN_CHANGED_EVENT = 'objectstack-native:pushTokenChanged' and type NativePushTokenChangedEvent = CustomEvent<NativePushToken>. Three things match 6577618893: the event name, the capability has('pushTokenChanged') and detail: { token, vendor }. The prefix follows NATIVE_CONNECTION_REMOVED_EVENT. The Console checks detail with isPushToken instead of trusting the type, which suits a payload from another party. Correct.
  8. Nothing is removed or narrowed. The diff's only deletions are the 3 lines in the shared test harness App.nativeShellMode-29039.test.tsx; every published file is -0. Correct.

② Semver level

All three packages are patch, with Metadata-protocol: none. AGENTS.md (Post-Task Checklist step 3) says a non-protocol change, widening or breaking, ships patch. @object-ui/auth, @object-ui/mobile and @object-ui/console all sit in the changeset fixed group. This is a widening, so it carries no BREAKING banner and no ADR-0087 disposition. Consistent.

③ Boundary flags

  • os-dev-report 6661373319: open_questions: [], security_findings: none.
  • Impersonation boundary: userId is null whenever session.impersonatedBy is set, so an impersonation never asks the shell and never registers. Pinned by "an impersonation registers nothing", which uses the same harness and path as the positive case and differs only in impersonatedBy.
  • Unregister: a 404 counts as "not held", per the spec contract unregisterDevice (notification-service.ts: another user's or an unknown id answers NOT_FOUND). A failure, or no session left, rejects, so the shell records that the token was left registered. That is the semantics PR feat(mobile, app-shell): open the mobile shell's native connection switcher from the header organization button #29565's seam asks for (card comment 6588256589).
  • Author: user.login is objectstack-fleet[bot], so there is no outside author to name.

Implemented-by: claude/project-thread-t8v3to
Reviewed-by: session_01L6bhgWn6zhNa4LL7VB1rtL

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#1 · os-sales · session_01L6bhgWn6zhNa4LL7VB1rtL

维护者速读

  • 改了什么:在移动端原生壳里,Console 用自己的会话把壳的推送 token 注册到设备表;token 轮换或换人登录时重新注册;登出前和壳移除连接时注销。为此 @object-ui/auth 新增登出前钩子 registerBeforeSignOut,@object-ui/mobile 补上壳已提供的 installId 与 token 轮换事件类型。
  • 为什么改:壳本身没有 API 凭证,只能由 Console 注册;不注册就收不到推送,登出不注销则已登出的设备还会收到这个人的推送。
  • 风险与回滚:只在原生壳里生效,浏览器不变;冒充会话下不注册;登出前的步骤有 3 秒上限,失败或超时都不挡登出,没有注册步骤的应用登出行为不变。回滚即撤本 PR。
  • 先看:ui/apps/console/src/nativePushRegistration.ts(注册时机、去重、注销),ui/packages/auth/src/beforeSignOut.ts 与 AuthProvider.tsx 的 signOut,ui/apps/console/src/App.tsx(接线)。
  • 两分钟验证:pnpm --dir ui exec vitest run apps/console/src/__tests__/nativePushRegistration-29042.test.tsx packages/auth/src/__tests__/beforeSignOut-29042.test.ts,预期全部通过。
  • 需要你做的:无,席位落地。真机收推送并打开 route 的端到端没有设备实验室,按卡的验收记为手工项。
  • 席位意见:可合。整张卡一次交付(Fixes),契约复核 PASS 在案。

席位核对 — ACCEPT · head 9d7ef8b

  • 形态:13 文件 +1036/−3(Console 新模块 nativePushRegistration.ts 与 App.tsx 接线;@object-ui/auth 新 beforeSignOut.ts、index.ts 导出、AuthProvider.signOut 加一行;@object-ui/mobile 的 nativeHost.ts、index.ts、README;测试 4 份;changeset 三包 patch)。check-governed-merges --pr 30534:NOT governed,1039 行 ≤ 3000。Fixes #29042(整卡交付,修正 6659877507),目标 main。仅有的 3 行删除在共用测试 harness App.nativeShellMode-29039.test.tsx。
  • 契约:Clause-② yes (widening),Metadata-protocol none,与修正 2 一致。TIER_DEFAULT 契约复核 PASS:评论 6661500318(认领 6661480609),钉本 head。Skills-docs: 不适用(Metadata-protocol none)。
  • 前提实测:dev 报告 6661373319。席位对照:壳侧契约(卡评论 6577618893,feat(bridge): stable installId and pushTokenChanged for device registration; document the no-Service-Worker rule mobile#167)的 installId: string | null、objectstack-native:pushTokenChanged、has('pushTokenChanged')、detail: { token, vendor } 与 diff 同名同形;服务端 device-registry.ts 的 registerOnce 在别的用户持有同一 token 时先删其行再登记,所以换人登录重新注册即把设备转走;注销的 404 按 spec unregisterDevice 契约等于"不在册"。消融:删 await runBeforeSignOut(); 后顺序用例红,还原哈希一致;终 head 定向测试 58 文件 604 绿,三包 type-check 0,派生门禁 36/36 exit 0。
  • 门禁 check-runs:head 上 55 个,44 success,11 skipped(Core 侧与 opt-in 作业,ui-only diff 的预期跳过),0 红。mergeable_state clean。
  • 接受的偏差:真机端到端未跑(无设备实验室,卡验收允许记为手工项);分支用 harness 指定的 claude/project-thread-t8v3to;注册不带 locale(卡未要求);PR 正文 Sequencing 与 Verification 两处由席位按终 head 改写。
  • 范围外去向:dev 记的"content/docs/frontend 下没有原生桥文档"不立卡:桥契约的家是 mobile 仓的 docs/HYBRID-SHELL.md,@object-ui/mobile README 已写入新成员。
  • 落地条款:中继 pr_ready + automerge_enable。合并即关卡(Fixes),席位同一笔摘掉 pm:dispatched。

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 5, 2026 15:13
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 5, 2026
Merged via the queue into main with commit a4c3e73 Nov 5, 2026
60 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-t8v3to branch November 5, 2026 16:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Console: register the mobile shell's native push token after sign-in, unregister it on sign-out

1 participant