Skip to content

Commit a4c3e73

Browse files
feat(console): register the mobile shell push token after sign-in and unregister it on sign-out (#30534)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4Uu3UEbecgCQf9xiCeSoCrKC1)_ 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` (#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 <details><summary>Targeted tests (head 03b3034)</summary> ``` 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) ``` </details> <details><summary>Type check</summary> ``` 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 ``` </details> <details><summary>Derived gates (dispatch-gates --commands, tree a257694)</summary> ``` 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) ``` </details> 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.com/claude-code) https://claude.ai/code/session_01L6bhgWPe8iKWPE5j7CLjdF --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgWPe8iKWPE5j7CLjdF)_ <!-- signed: domain:ui#1 · os-sales · session_01L6bhgWn6zhNa4LL7VB1rtL --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ab159a9 commit a4c3e73

13 files changed

Lines changed: 1036 additions & 3 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/console': patch
3+
'@object-ui/mobile': patch
4+
'@object-ui/auth': patch
5+
---
6+
7+
feat(console,mobile,auth): the Console registers the mobile shell's push token under its own session and unregisters it on sign-out and when the shell removes the connection
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: none
11+
12+
- **`@object-ui/console`:** in the ObjectStack mobile shell, a signed-in person who has reached Home or an app is asked for push permission once per page load, after a short delay (never on a sign-in screen): the Console calls `getPushToken()` and registers the token with `POST /api/v1/notifications/devices`. A `null` answer (simulator, permission denied, no token) sends nothing. A token the shell rotates is registered again; an unchanged token for the same person is not sent twice. Another person signing in on the page registers the device for them, and the registry moves the token away from the previous person. An impersonation never registers, so the impersonated person's pushes never reach the administrator's device. The registration sends `deviceId` as the shell's `installId` when it has one, and `origin` only when the page was loaded over `http:` or `https:`. The device is unregistered (`DELETE /api/v1/notifications/devices/:deviceId`) before sign-out ends the session, and when the shell removes this connection; a shell that is neither iOS nor Android registers nothing. A browser is unchanged.
13+
- **`@object-ui/mobile`:** `ObjectStackNative` gains the optional `installId` (`string | null`), and the package exports `NATIVE_PUSH_TOKEN_CHANGED_EVENT` (`objectstack-native:pushTokenChanged`, announced as `has('pushTokenChanged')`) with its event type `NativePushTokenChangedEvent`. Both are feature-detected: a shell that predates them carries neither.
14+
- **`@object-ui/auth`:** `registerBeforeSignOut(step)` registers a step that `signOut()` awaits while the session still exists, and returns the function that removes it. The steps run together and can never hold sign-out back: a rejection is reported and dropped, and sign-out goes ahead after `BEFORE_SIGN_OUT_TIMEOUT_MS` (3 s) whether or not they have settled.

‎ui/apps/console/src/App.tsx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import { studioRoutes } from './components/StudioRoute';
4949
import { SetupRoute } from './components/SetupRoute';
5050
import { UnmatchedRoute } from './components/UnmatchedRoute';
5151
import { nativeShellRouteTarget } from './nativeShellRoute';
52+
import { useNativePushRegistration } from './nativePushRegistration';
5253
import { FormPage } from './components/FormPage';
5354
import { InternalFormRoute } from './components/InternalFormRoute';
5455
import { MetadataHmrReloader } from './components/MetadataHmrReloader';
@@ -160,6 +161,10 @@ const BASENAME = resolveBasename();
160161
* active, else the first active app. The shell's published `currentAppName` is
161162
* left to that guard because it is an app NAME, and only the app list turns a
162163
* name into the address its route uses.
164+
*
165+
* Push registration (`useNativePushRegistration`) is called first, so its
166+
* listener for a rotated token is installed in the commit that posts `ready()`,
167+
* and its unregister answers the shell's removal of this connection.
163168
*/
164169
function NativeShellRouting() {
165170
const location = useLocation();
@@ -174,9 +179,11 @@ function NativeShellRouting() {
174179
},
175180
[apps, hostHint, navigate],
176181
);
182+
const unregisterPushDevice = useNativePushRegistration(location.pathname);
177183
const host = useNativeHostRouting({
178184
route: `${location.pathname}${location.search}${location.hash}`,
179185
navigate: navigateShellRoute,
186+
onConnectionRemoved: unregisterPushDevice,
180187
});
181188
const platform = host?.platform;
182189
useEffect(() => {

‎ui/apps/console/src/__tests__/App.nativeShellMode-29039.test.tsx‎

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,20 @@ import { describe, expect, it, vi, afterEach, beforeEach } from 'vitest';
2626
import { render, screen, cleanup, act } from '@testing-library/react';
2727
import type { ReactNode } from 'react';
2828
import {
29+
NATIVE_CONNECTION_REMOVED_EVENT,
2930
NATIVE_HOST_GLOBAL,
3031
NATIVE_NAVIGATE_EVENT,
3132
resetNativeHostEventsForTests,
3233
type ObjectStackNative,
3334
} from '../../../../packages/mobile/src/nativeHost';
3435
import { resetNativeHostWatchForTests } from '../../../../packages/mobile/src/useNativeHost';
36+
import {
37+
NATIVE_PUSH_PROMPT_DELAY_MS,
38+
NATIVE_PUSH_REGISTRATION_KEY,
39+
resetNativePushRegistrationForTests,
40+
} from '../nativePushRegistration';
3541

36-
const { passthrough, stub, shellState } = vi.hoisted(() => {
42+
const { passthrough, stub, shellState, authState } = vi.hoisted(() => {
3743
const passthrough = ({ children }: { children?: ReactNode }) => <>{children}</>;
3844
const stub = (testid: string) => () => <div data-testid={testid} />;
3945
// objectstack#29750: what the shell's navigation context and the metadata
@@ -45,7 +51,10 @@ const { passthrough, stub, shellState } = vi.hoisted(() => {
4551
loading: false,
4652
appStatus: undefined as 'idle' | 'loading' | 'ready' | 'error' | undefined,
4753
};
48-
return { passthrough, stub, shellState };
54+
// objectstack#29042: who the auth context names. The default is what this
55+
// file's other cases read: a user id and no session answer.
56+
const authState = { value: { user: { id: 'u1' } } as Record<string, unknown> };
57+
return { passthrough, stub, shellState, authState };
4958
});
5059

5160
vi.mock('@object-ui/app-shell', async (importOriginal) => ({
@@ -90,7 +99,7 @@ vi.mock('@object-ui/app-shell', async (importOriginal) => ({
9099
vi.mock('@object-ui/auth', async (importOriginal) => ({
91100
...(await importOriginal<Record<string, unknown>>()),
92101
AuthProvider: passthrough,
93-
useAuth: () => ({ user: { id: 'u1' } }),
102+
useAuth: () => authState.value,
94103
}));
95104

96105
vi.mock('../AppContent', () => ({
@@ -166,6 +175,9 @@ beforeEach(() => {
166175
shellState.apps = [];
167176
shellState.loading = false;
168177
shellState.appStatus = undefined;
178+
authState.value = { user: { id: 'u1' } };
179+
resetNativePushRegistrationForTests();
180+
sessionStorage.clear();
169181
window.history.pushState({}, '', '/login');
170182
});
171183

@@ -358,3 +370,76 @@ describe('the catch-all hosts an app-relative first URL under an app', () => {
358370
expect(window.location.pathname).toBe('/home');
359371
});
360372
});
373+
374+
/**
375+
* objectstack#29042 — the router wiring of push registration: the route the
376+
* router is on decides when the token is asked for, and the shell's removal of
377+
* this connection is answered with the unregister, under the Console's session.
378+
*/
379+
describe('push registration through the router wiring', () => {
380+
const signedIn = { user: { id: 'u1' }, session: { token: 'session' }, isAuthenticated: true };
381+
382+
function deviceCalls(fetchMock: ReturnType<typeof vi.fn>): string[] {
383+
return fetchMock.mock.calls
384+
.map(([url, init]) => `${(init as RequestInit | undefined)?.method ?? 'GET'} ${String(url)}`)
385+
.filter((call) => call.includes('/api/v1/notifications/devices'));
386+
}
387+
388+
function stubFetch() {
389+
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => ({
390+
ok: true,
391+
status: 200,
392+
headers: new Headers(),
393+
json: async () => ({
394+
success: true,
395+
data: init?.method === 'POST' ? { deviceId: 'install-7', success: true } : { success: true },
396+
}),
397+
}));
398+
vi.stubGlobal('fetch', fetchMock);
399+
return fetchMock;
400+
}
401+
402+
afterEach(() => {
403+
vi.unstubAllGlobals();
404+
});
405+
406+
it('Home asks the shell for the token once the delay has passed, and registers it', async () => {
407+
const fetchMock = stubFetch();
408+
authState.value = signedIn;
409+
installBridge();
410+
const bridge = window[NATIVE_HOST_GLOBAL as keyof Window] as unknown as ObjectStackNative;
411+
const getPushToken = vi.spyOn(bridge, 'getPushToken').mockResolvedValue({ token: 'tok-1', vendor: 'fcm' });
412+
window.history.pushState({}, '', '/home');
413+
render(<App />);
414+
await screen.findByTestId('home-page');
415+
expect(getPushToken).not.toHaveBeenCalled();
416+
await vi.waitFor(
417+
() => expect(deviceCalls(fetchMock)).toEqual([expect.stringMatching(/^POST .*\/api\/v1\/notifications\/devices$/)]),
418+
{ timeout: NATIVE_PUSH_PROMPT_DELAY_MS + 2000 },
419+
);
420+
expect(getPushToken).toHaveBeenCalledTimes(1);
421+
});
422+
423+
it('the shell removing this connection waits for the unregister of the registered device', async () => {
424+
const fetchMock = stubFetch();
425+
authState.value = signedIn;
426+
sessionStorage.setItem(
427+
NATIVE_PUSH_REGISTRATION_KEY,
428+
JSON.stringify({ userId: 'u1', token: 'tok-1', vendor: 'fcm', deviceId: 'install-7' }),
429+
);
430+
installBridge();
431+
render(<App />);
432+
await screen.findByTestId('login-page');
433+
434+
const held: Promise<unknown>[] = [];
435+
const event = new Event(NATIVE_CONNECTION_REMOVED_EVENT);
436+
Object.assign(event, { waitUntil: (promise: Promise<unknown>) => held.push(promise) });
437+
act(() => {
438+
window.dispatchEvent(event);
439+
});
440+
expect(held).toHaveLength(1);
441+
await held[0];
442+
expect(deviceCalls(fetchMock)).toEqual([expect.stringMatching(/^DELETE .*\/api\/v1\/notifications\/devices\/install-7$/)]);
443+
expect(sessionStorage.getItem(NATIVE_PUSH_REGISTRATION_KEY)).toBeNull();
444+
});
445+
});

0 commit comments

Comments
 (0)