Skip to content

Commit f4b1f9e

Browse files
fix(console): register the push device without an origin the spec refuses (#31513)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9Ubb6etUG8W3NiG1863Cn9e1Ry)_ Fixes #31472 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:Console 注册移动端推送设备时,只有当发布的 `RegisterDeviceRequestSchema` 的 `origin` 成员接受 `window.location.origin` 才带上它;否则不带 `origin` 照常注册。 - **为什么改**:主机名带下划线(内网/compose 服务名)或末尾带点的自托管 Console,原来发出的 origin 被服务端整体拒收(400),这台设备收不到任何推送;spec 文档块写的是「此时生产者不带 origin」。 - **风险与回滚**:风险低,只影响这两类主机,其余主机照旧带 origin;回滚即还原 `nativePushRegistration.ts` 一个函数。 - **先看**:`ui/apps/console/src/nativePushRegistration.ts` 的 `registrationOrigin()`;其次是测试文件末尾新增的 describe。 - **两分钟验证**:`cd ui && pnpm exec vitest run apps/console/src/__tests__/nativePushRegistration-29042.test.tsx`,预期 20 个用例全绿。 - **需要你做的**:无,席位落地。 ## What changes - `registrationOrigin()` in `ui/apps/console/src/nativePushRegistration.ts` keeps the page origin only when `RegisterDeviceRequestSchema.shape.origin.safeParse(location.origin).success`, with the schema imported from `@objectstack/spec/api`. That is the schema the runtime door parses the whole registration body with, so the producer and the door cannot disagree. No regex is copied and no spec export is added: `PUSH_SERVER_ORIGIN_PATTERN` is internal to `@objectstack/spec` (route recorded in claim amendment 6720762259). - A host with an underscore (`http://my_svc:3000`) or a trailing dot (`https://crm.example.com.`) now registers its device without `origin`, as the `push-server-origin.ts` docblock says; its pushes carry no origin. Every other http or https host still sends its origin, and a non-http(s) page still sends none. - One root changeset, `.changeset/31472-push-origin-underscore-host.md` (`@object-ui/console` patch). ## Tests - New top-level describe in the shared harness `ui/apps/console/src/__tests__/nativePushRegistration-29042.test.tsx`: an underscore host and a trailing-dot host answer no origin, a normal host with a port and a dotted IPv4 host keep theirs; and, with the page URL moved to each refused host, the full registration flow sends a body with no `origin` that `RegisterDeviceRequestSchema` accepts, while the same body naming the page origin is refused. - CONTROL: the existing registration and unregister tests in that file and `App.nativeShellMode-29039.test.tsx` stay green (2 files, 37 tests). - Ablation (`scripts/ablation-replace.mjs`, guard line replaced by `return location.origin;`): the 3 new cases go red, the other 17 in the file stay green; restore verified blob == HEAD and `git diff HEAD` empty. ## Acceptance notes - `registrationOrigin()` keeps its explicit http/https check ahead of the schema judge; the schema alone would also refuse `"null"`, so the check is documentation of intent rather than a second rule. ## Verification All readings on objectstack `claude/project-thread-4wp8tl`; the derived gates at `075c3ef6`, the re-runs at merged head `16fff1a7` (main `2ae3e301` plus 4 commits, none touching these files). <details><summary>Derived gates: 36 of 36 exit 0 (dispatch-gates --ran: 0 NOT-MEASURED, 0 UNRUN)</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 36 families for the 3 changed paths (65 changed lines). Every one exited 0, including `node scripts/check-sdui-manifest.mjs` (+ `--self-test`), `check-changeset-no-major --base origin/main`, `check-adr-0087-registration --base origin/main`, `check-closing-keyword-parity` (on this body), `check:type-check-debt` (under the verify lock), `check:nul-bytes`, `check:cross-package-test-inputs`, `check:test-source-alias` and `pnpm --dir ui exec vitest run scripts/` (170 files passed, 2 skipped; 4997 tests). `--ran` reconciliation: 36 derived, 36 run. </details> <details><summary>Console type-check and tests</summary> - `pnpm exec turbo run type-check --filter=@object-ui/console` (console task a cache miss, `tsc --noEmit` covers test files): exit 0 at `075c3ef6` and again at `16fff1a7`. - `nativePushRegistration-29042.test.tsx` + `App.nativeShellMode-29039.test.tsx`: 2 files, 37 tests passed. - Every console test file importing `App` or `nativePushRegistration` (11 files, 68 tests): passed at `075c3ef6` and at `16fff1a7`. `App.tsx` already imports `@objectstack/spec/api` at runtime through `components/FormPage.tsx`, so the new import adds no entry to the Console's module graph. - Ablation at `075c3ef6`: anchor 1 to 0, blob `79894a6a` to `acf23d1c`; 3 failed, 17 passed; restored blob equals HEAD, `git diff HEAD` empty. </details> Dogfood battery: n/a, no `!` in the title and no (narrowing) arm. --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jS8ohBnEAnxBKpZcag)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0c0ede0 commit f4b1f9e

3 files changed

Lines changed: 61 additions & 4 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/console': patch
3+
---
4+
5+
fix(console): a Console served from a host with an underscore or a trailing dot registers the mobile shell's push device again, without an origin
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **`@object-ui/console`:** the push device registration sent `window.location.origin` for any `http:` or `https:` page. The server refuses an `origin` its host pattern cannot carry, an underscore (`http://my_svc:3000`, a container or compose service name) or a trailing dot (`https://crm.example.com.`), and a refused member refuses the whole registration, so that device got no pushes at all. The Console now keeps the origin only when the published `RegisterDeviceRequestSchema` (`@objectstack/spec/api`) accepts it, the same schema the server parses the body with; otherwise the device registers without `origin`, as the spec describes, and its pushes carry no origin. Every other host still sends its origin.

‎ui/apps/console/src/__tests__/nativePushRegistration-29042.test.tsx‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
2020
import { act, cleanup, render } from '@testing-library/react';
2121
import { useEffect, type ReactNode } from 'react';
22+
import { RegisterDeviceRequestSchema } from '@objectstack/spec/api';
2223
import { AuthCtx, type AuthContextValue } from '../../../../packages/auth/src/AuthContext';
2324
import { useAuth } from '../../../../packages/auth/src/useAuth';
2425
import { resetBeforeSignOutForTests, runBeforeSignOut } from '../../../../packages/auth/src/beforeSignOut';
@@ -366,3 +367,42 @@ describe('the origin a registration sends (objectstack#29042)', () => {
366367
expect(registrationOrigin({ protocol: 'data:', origin: 'null' })).toBeUndefined();
367368
});
368369
});
370+
371+
describe('a host the origin pattern cannot carry still registers, with no origin (objectstack#31472)', () => {
372+
const pageUrl = window.location.href;
373+
afterEach(() => {
374+
(window as unknown as { happyDOM: { setURL(url: string): void } }).happyDOM.setURL(pageUrl);
375+
});
376+
377+
it('an underscore host and a trailing-dot host send no origin; a normal host keeps its own', () => {
378+
expect(registrationOrigin({ protocol: 'http:', origin: 'http://my_svc:3000' })).toBeUndefined();
379+
expect(registrationOrigin({ protocol: 'https:', origin: 'https://crm.example.com.' })).toBeUndefined();
380+
expect(registrationOrigin({ protocol: 'https:', origin: 'https://crm.example.com:8443' })).toBe('https://crm.example.com:8443');
381+
expect(registrationOrigin({ protocol: 'http:', origin: 'http://10.0.0.7:3000' })).toBe('http://10.0.0.7:3000');
382+
});
383+
384+
it.each([
385+
['an underscore host', 'http://my_svc:3000/home'],
386+
['a trailing-dot host', 'https://crm.example.com./home'],
387+
])('%s: the device is registered without origin, and the server accepts the body', async (_label, url) => {
388+
(window as unknown as { happyDOM: { setURL(url: string): void } }).happyDOM.setURL(url);
389+
installBridge({ installId: 'install-7' });
390+
render(<Harness auth={{ userId: 'u1' }} pathname="/home" />);
391+
await passPromptDelay();
392+
await settle();
393+
394+
const requests = deviceRequests();
395+
expect(requests).toEqual([
396+
{
397+
method: 'POST',
398+
url: expect.stringContaining(DEVICES),
399+
body: { token: 'tok-1', vendor: 'apns', platform: 'ios', deviceId: 'install-7' },
400+
},
401+
]);
402+
// The door parses the whole body with this schema: the body sent passes,
403+
// and the same body naming the page origin is the refusal it avoids.
404+
expect(RegisterDeviceRequestSchema.safeParse(requests[0]!.body).success).toBe(true);
405+
const named = { ...(requests[0]!.body as object), origin: window.location.origin };
406+
expect(RegisterDeviceRequestSchema.safeParse(named).success).toBe(false);
407+
});
408+
});

‎ui/apps/console/src/nativePushRegistration.ts‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141

4242
import { useEffect, useRef, useState } from 'react';
4343
import { ObjectStackClient } from '@objectstack/client';
44+
import { RegisterDeviceRequestSchema } from '@objectstack/spec/api';
4445
import { createAuthenticatedFetch, registerBeforeSignOut, useAuth } from '@object-ui/auth';
4546
import {
4647
NATIVE_PUSH_TOKEN_CHANGED_EVENT,
@@ -134,12 +135,18 @@ function platformOf(host: ObjectStackNative): 'ios' | 'android' | null {
134135

135136
/**
136137
* The `origin` a registration sends: the page's origin, only when it was loaded
137-
* over http or https. A sandboxed or `about:blank` / `data:` page reads the
138-
* opaque `"null"`, which the request grammar refuses, and that would refuse the
139-
* whole registration; such a page sends no `origin`.
138+
* over http or https and the registration's own `origin` member accepts it. A
139+
* refused member refuses the whole registration, so a page whose origin the
140+
* request grammar cannot carry sends no `origin` and the device still
141+
* registers. That covers a sandboxed or `about:blank` / `data:` page, which
142+
* reads the opaque `"null"`, and a host the spec's DNS-label pattern is
143+
* narrower than: an underscore (`http://my_svc:3000`) or a trailing dot
144+
* (`https://crm.example.com.`). The judge is the published request schema the
145+
* server parses the body with, so the two cannot disagree.
140146
*/
141147
export function registrationOrigin(location: Pick<Location, 'protocol' | 'origin'>): string | undefined {
142-
return location.protocol === 'http:' || location.protocol === 'https:' ? location.origin : undefined;
148+
if (location.protocol !== 'http:' && location.protocol !== 'https:') return undefined;
149+
return RegisterDeviceRequestSchema.shape.origin.safeParse(location.origin).success ? location.origin : undefined;
143150
}
144151

145152
function isPushToken(value: unknown): value is NativePushToken {

0 commit comments

Comments
 (0)