Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/29042-native-push-registration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@object-ui/console': patch
'@object-ui/mobile': patch
'@object-ui/auth': patch
---

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

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

- **`@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.
- **`@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.
- **`@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.
7 changes: 7 additions & 0 deletions ui/apps/console/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import { studioRoutes } from './components/StudioRoute';
import { SetupRoute } from './components/SetupRoute';
import { UnmatchedRoute } from './components/UnmatchedRoute';
import { nativeShellRouteTarget } from './nativeShellRoute';
import { useNativePushRegistration } from './nativePushRegistration';
import { FormPage } from './components/FormPage';
import { InternalFormRoute } from './components/InternalFormRoute';
import { MetadataHmrReloader } from './components/MetadataHmrReloader';
Expand Down Expand Up @@ -160,6 +161,10 @@ const BASENAME = resolveBasename();
* active, else the first active app. The shell's published `currentAppName` is
* left to that guard because it is an app NAME, and only the app list turns a
* name into the address its route uses.
*
* Push registration (`useNativePushRegistration`) is called first, so its
* listener for a rotated token is installed in the commit that posts `ready()`,
* and its unregister answers the shell's removal of this connection.
*/
function NativeShellRouting() {
const location = useLocation();
Expand All @@ -174,9 +179,11 @@ function NativeShellRouting() {
},
[apps, hostHint, navigate],
);
const unregisterPushDevice = useNativePushRegistration(location.pathname);
const host = useNativeHostRouting({
route: `${location.pathname}${location.search}${location.hash}`,
navigate: navigateShellRoute,
onConnectionRemoved: unregisterPushDevice,
});
const platform = host?.platform;
useEffect(() => {
Expand Down
91 changes: 88 additions & 3 deletions ui/apps/console/src/__tests__/App.nativeShellMode-29039.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,20 @@ import { describe, expect, it, vi, afterEach, beforeEach } from 'vitest';
import { render, screen, cleanup, act } from '@testing-library/react';
import type { ReactNode } from 'react';
import {
NATIVE_CONNECTION_REMOVED_EVENT,
NATIVE_HOST_GLOBAL,
NATIVE_NAVIGATE_EVENT,
resetNativeHostEventsForTests,
type ObjectStackNative,
} from '../../../../packages/mobile/src/nativeHost';
import { resetNativeHostWatchForTests } from '../../../../packages/mobile/src/useNativeHost';
import {
NATIVE_PUSH_PROMPT_DELAY_MS,
NATIVE_PUSH_REGISTRATION_KEY,
resetNativePushRegistrationForTests,
} from '../nativePushRegistration';

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

vi.mock('@object-ui/app-shell', async (importOriginal) => ({
Expand Down Expand Up @@ -90,7 +99,7 @@ vi.mock('@object-ui/app-shell', async (importOriginal) => ({
vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
AuthProvider: passthrough,
useAuth: () => ({ user: { id: 'u1' } }),
useAuth: () => authState.value,
}));

vi.mock('../AppContent', () => ({
Expand Down Expand Up @@ -166,6 +175,9 @@ beforeEach(() => {
shellState.apps = [];
shellState.loading = false;
shellState.appStatus = undefined;
authState.value = { user: { id: 'u1' } };
resetNativePushRegistrationForTests();
sessionStorage.clear();
window.history.pushState({}, '', '/login');
});

Expand Down Expand Up @@ -358,3 +370,76 @@ describe('the catch-all hosts an app-relative first URL under an app', () => {
expect(window.location.pathname).toBe('/home');
});
});

/**
* objectstack#29042 — the router wiring of push registration: the route the
* router is on decides when the token is asked for, and the shell's removal of
* this connection is answered with the unregister, under the Console's session.
*/
describe('push registration through the router wiring', () => {
const signedIn = { user: { id: 'u1' }, session: { token: 'session' }, isAuthenticated: true };

function deviceCalls(fetchMock: ReturnType<typeof vi.fn>): string[] {
return fetchMock.mock.calls
.map(([url, init]) => `${(init as RequestInit | undefined)?.method ?? 'GET'} ${String(url)}`)
.filter((call) => call.includes('/api/v1/notifications/devices'));
}

function stubFetch() {
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => ({
ok: true,
status: 200,
headers: new Headers(),
json: async () => ({
success: true,
data: init?.method === 'POST' ? { deviceId: 'install-7', success: true } : { success: true },
}),
}));
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}

afterEach(() => {
vi.unstubAllGlobals();
});

it('Home asks the shell for the token once the delay has passed, and registers it', async () => {
const fetchMock = stubFetch();
authState.value = signedIn;
installBridge();
const bridge = window[NATIVE_HOST_GLOBAL as keyof Window] as unknown as ObjectStackNative;
const getPushToken = vi.spyOn(bridge, 'getPushToken').mockResolvedValue({ token: 'tok-1', vendor: 'fcm' });
window.history.pushState({}, '', '/home');
render(<App />);
await screen.findByTestId('home-page');
expect(getPushToken).not.toHaveBeenCalled();
await vi.waitFor(
() => expect(deviceCalls(fetchMock)).toEqual([expect.stringMatching(/^POST .*\/api\/v1\/notifications\/devices$/)]),
{ timeout: NATIVE_PUSH_PROMPT_DELAY_MS + 2000 },
);
expect(getPushToken).toHaveBeenCalledTimes(1);
});

it('the shell removing this connection waits for the unregister of the registered device', async () => {
const fetchMock = stubFetch();
authState.value = signedIn;
sessionStorage.setItem(
NATIVE_PUSH_REGISTRATION_KEY,
JSON.stringify({ userId: 'u1', token: 'tok-1', vendor: 'fcm', deviceId: 'install-7' }),
);
installBridge();
render(<App />);
await screen.findByTestId('login-page');

const held: Promise<unknown>[] = [];
const event = new Event(NATIVE_CONNECTION_REMOVED_EVENT);
Object.assign(event, { waitUntil: (promise: Promise<unknown>) => held.push(promise) });
act(() => {
window.dispatchEvent(event);
});
expect(held).toHaveLength(1);
await held[0];
expect(deviceCalls(fetchMock)).toEqual([expect.stringMatching(/^DELETE .*\/api\/v1\/notifications\/devices\/install-7$/)]);
expect(sessionStorage.getItem(NATIVE_PUSH_REGISTRATION_KEY)).toBeNull();
});
});
Loading
Loading