Skip to content

Commit 455b4f0

Browse files
feat(plugin-auth): a platform admin can read the address the allowed IP ranges save check will judge (#30398) (#30483)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgBMFd7n39sLWSMN4mnBtvAW)_ domain:server#2 · os-bill · session_01Utp6CZbTJpa9PNbpp3fdB7 Fixes #30398 Clause-②: yes (widening) — a new authenticated HTTP read, `GET /api/v1/auth/admin/client-address`, ledgered `server-only` in the plugin's auth route ledger; no spec schema, SDK method or package export moves. Metadata-protocol: none ## 维护者速读 - 改了什么:新增一个只读接口 `GET /api/v1/auth/admin/client-address`,平台管理员在保存「允许的 IP 段」之前,就能看到服务器会拿哪个地址去判(`clientAddress`),以及是否信任代理转发头(`trustProxy`)。 - 为什么改:这个地址以前只出现在保存被拒的提示里,管理员只有先被拒一次才知道服务器认的是哪个地址;控制台也不能自己猜,因为放在代理后面时浏览器看到的地址不是服务器判的那个。 - 风险与回滚:只加不改。地址取自保存校验自己的那一次调用,两边不会说法不一;只有平台管理员能读,未登录 401、非管理员 403,匿名的 `/config` 不变;响应带 `no-store`。保存校验和登录闸门的判法一字未变。回滚即撤本 PR。 - 先看:`packages/plugins/plugin-auth/src/allowed-ip-ranges.ts` 的 `judgedClientAddress`(两扇门共用的取址);`client-address-read.ts`(接口本体);`allowed-ip-ranges.pin.test.ts` 末尾 `#30398` 用例(同一连接分别问接口和保存,结果逐字相等);两个 dogfood 的 `/admin/` 路由清点各加一行归类。 - 两分钟验证:`pnpm --filter @objectstack/plugin-auth exec vitest run src/allowed-ip-ranges.pin.test.ts`,预期 1 个文件、21 条全绿。 - 需要你做的:无,席位落地。 ## What changes **The rule: the address shown to an administrator before a save is the address that save is judged by, because both come from one call.** - **`judgedClientAddress` (`allowed-ip-ranges.ts`).** The save check's inline call to `AuthManager.resolveClientAddress` moves into an exported helper: the request's peer address plus only the forwarded-address headers the rule reads (`FORWARDED_CLIENT_ADDRESS_HEADERS`, the narrowing the settings service already applies to the save check's input, so on that path it is idempotent), `server.trustProxy` read inside the manager as before. The save check calls it with the same input it had; its refusal copy is untouched. The transport type it takes, formerly the file-private `WriteTransport`, is exported as `RequestTransport` (package-internal: `allowed-ip-ranges.ts` is not on the package's `exports`). - **`GET /api/v1/auth/admin/client-address` (`client-address-read.ts`, mounted in `auth-plugin.ts`).** Answers `200 { success: true, data: { clientAddress, trustProxy } }` with `Cache-Control: no-store`. `clientAddress` is `judgedClientAddress` on this request's headers and transport peer, in the rule's own text (an IPv4-mapped peer reads `::ffff:127.0.0.1`, as the save refusal's `constraint.clientAddress` names it), `null` when no address can be established. `trustProxy` is `AuthManager.trustsForwardedHeaders()`. Closed, empty query set: any parameter is refused `400 VALIDATION_ERROR` before the address is read (AGENTS.md route rule 5). No body is read. - **Who may read it.** The shared `gateAdmin` every ObjectStack `/admin/*` auth mount calls (ADR-0068 D2 judge, `platform-admin-gate.ts`): no session `401 UNAUTHENTICATED`, a held session (ADR-0069 D3) `403` with the posture's code, any other non-platform-admin `403 PERMISSION_DENIED`, each before the read runs. Not on the anonymous `GET /api/v1/auth/config`. - **Ledger.** One `objectstack-mount` row, `server-only`, in `auth-route-ledger.ts`, and the conformance pin's hand-written objectstack set gains the route. `pnpm check:auth-mount-ledger`: 25 mounts, all accounted for. - **The two `/admin/` route sweeps (dogfood).** A mounted `/admin/` route is in scope of both at once, so each classifies it: `admin-platform-admin-standing.dogfood.test.ts` lists it in `ADMITTED` (the platform admin is not refused), `admin-route-nonadmin-refusal.dogfood.test.ts` as `objectstack-gate` (anonymous `401`, member `403`, admin not refused). No payload is needed: nothing is read before the gate. - **For the ui half (#30313 item 1).** Call `GET /api/v1/auth/admin/client-address` (same-origin, session cookie) on the Authentication settings page and show `data.clientAddress` beside the Allowed IP ranges field; `null` means the server cannot establish the address (any non-empty list would then be refused). A `403 IP_NOT_ALLOWED` from it means the caller's address is already outside the list in force (the D5 gate answers it, see Acceptance notes). - Not changed: the save check's verdict and copy, the sign-in gate, `resolveClientAddress`, `service-settings`, `packages/spec`, the SDK. ## Why #30398: the save check from #30309 refuses a list that leaves out the saving request's address and names it, but nothing served that address before a save, so an administrator learned it only by being refused, and the console had no correct way to show it (triage 6655147452: the served address must come from the function the save check judges with; the client never guesses its own address). ## Verification Measured first, at `6fa842af`: no auth route, no `/api/v1/auth/config` field and no `GET /api/settings/auth` field answers `resolveClientAddress`; the save check (`allowed-ip-ranges.ts`) is its only settings-side caller. `get-session` serves the session row's `ip_address`, which is a different fact (see Acceptance notes). <details><summary>Tests</summary> `allowed-ip-ranges.pin.test.ts` › `#30398` (the shared harness: real `AuthPlugin`, real `SettingsService` with the shipped auth manifest, real settings routes for the save, and now the plugin's real auth routes on a real Hono app for the read, the transport peer supplied the way `@hono/node-server` supplies it). Each address case asks the read and then saves `192.0.2.0/24` from the same connection, and asserts the read's `clientAddress` equals the refusal's `constraint.clientAddress`: - direct peer `203.0.113.9`; IPv4-mapped peer `::ffff:127.0.0.1` (not rewritten to dotted); `trustProxy` declared with `X-Forwarded-For: 198.51.100.7, 10.0.0.2` (leftmost entry); `trustProxy` not declared with forged `X-Forwarded-For` and `X-Real-IP` (the peer `10.0.0.1` at both doors); no peer at all (`null` at both doors). - `Cache-Control: no-store`. - refusals before any address is read (`trustsForwardedHeaders` never called, no `data`): no session `401 UNAUTHENTICATED`; a signed-in non-admin `403 PERMISSION_DENIED`; a held platform admin `403 MFA_REQUIRED`; an unknown query parameter `400 VALIDATION_ERROR`. `auth-route-ledger.conformance.test.ts`: the objectstack-sourced set includes the route, and better-auth does not serve it. `pnpm --filter @objectstack/plugin-auth test` on the merged head `889cbec8`: Test Files 224 passed (224), Tests 3961 passed | 10 skipped (3971); again after merging `main` `f7afab86` (tree of `5db8d66e`, closure rebuilt): Test Files 224 passed (224), Tests 3964 passed | 10 skipped (3974). `pnpm --filter @objectstack/plugin-auth typecheck`: exit 0 (`check:test-typecheck` OK, debt ledger unchanged). Dogfood: the full suite under the verify lock on the tree of `5db8d66e` (closure built, no shard) went red on exactly the two `/admin/` sweeps, each naming `GET /api/v1/auth/admin/client-address` as unclassified (Test Files 2 failed | 417 passed | 3 skipped (422), Tests 2 failed | 3490 passed | 36 skipped (3528)). That run is the red leg for the two classifications above; with them, both files pass (Test Files 2 passed (2), Tests 15 passed (15)). On the final head `843cf4fe` (`main` `f273c9fe` merged, closure rebuilt): `pnpm --filter @objectstack/plugin-auth test` Test Files 224 passed (224), Tests 3964 passed | 10 skipped (3974); full dogfood under the verify lock (`pnpm --filter @objectstack/dogfood test`, no shard), lock verdict `command-exit 0`: Test Files 419 passed | 3 skipped (422), Tests 3492 passed | 36 skipped (3528). </details> <details><summary>Ablations (scripts/ablation-replace.mjs; the suite imports src directly, no build between legs)</summary> | # | Mutation | Red | |:--|:--|:--| | A1 | read answers `clientAddress: null` | direct peer, mapped peer, trustProxy, forged XFF (4 failed) | | A2 | read strips `::ffff:` (the session row's canonical text) | mapped peer (1 failed) | | A3 | read answers the transport peer only, ignoring `trustProxy` | trustProxy declared (1 failed) | | A4 | read believes `X-Forwarded-For` without `trustProxy` | forged XFF ignored (1 failed) | | A5 | read invents `0.0.0.0` for an undetermined address | undetermined reads null (1 failed) | | A6 | `Cache-Control` header dropped | no-store (1 failed) | | A7 | the route ignores the `gateAdmin` verdict | 401, 403 non-admin, 403 held (3 failed) | | A8 | closed query set opened | query refusal (1 failed) | | A9 | the save check judges the peer alone (drifts from the shared helper) | the #30203 trustProxy save pin and the #30398 trustProxy equality pin (2 failed) | Each mutation landed (anchor 1 to 0, blob changed) and each restore leg reported blob equal to HEAD and `git diff HEAD` empty. A7's first attempt was refused by the tool before running anything (its replacement was a substring of its anchor, so the replacement count could not rise); it was re-run with a replacement that lands, and only that run is in the table. </details> <details><summary>Gates (head 843cf4f)</summary> - Final change set (9 paths): `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 68 families at `510dffb6`; each ran with its exit code written to a file before any pipe, after a build of every package; `--ran` with `:: exit <code>` on every line: `68 derived famil(ies) accounted for — 68 run, 0 NOT-MEASURED (a DERIVED zero — all 68 recorded an exit code and none of them is 3)`. All 68 exit 0. `pnpm lint` exit 0. - Then `main` `f273c9fe` was merged (head `843cf4fe`, no conflict, nothing under `plugin-auth` or the two dogfood sweeps). The gate list re-derived there is byte-identical (68); the two gates whose inputs the merge touched (`scripts/cross-package-test-inputs.mjs` reads the changed codemod) re-ran, exit 0: `check:cross-package-test-inputs`, `check:ci-filter-parity`. - Earlier rounds, for the record: at `889cbec8` three families first exited 3 on an unmet prerequisite and passed on re-run (`check-plugin-teardown-shape.mjs --self-test` after fetching its fixture commit `621a4876`; `check:dual-build-cjs-loads` after a whole-closure build; `check:type-check-debt` after a lock queue timeout behind this card's own build). In the `510dffb6` round all three exited 0 first time. </details> ## Acceptance notes - **Gate population.** The read uses the `/admin/*` family's one judge (platform admin), not a fresh `manage_platform_settings` check through the settings door's composition. With the shipped permission sets the two populations are the same callers: platform-admin standing is derived from holding `admin_full_access` (`resolveAuthzContext`), which carries `manage_platform_settings`, the `auth` namespace's read and write capability, and `organization_admin` withholds that capability. They part only at two edges: a custom permission set granting `manage_platform_settings` without `admin_full_access` may save but is refused the read (it loses only the hint; the save check still names the address), and the judge's legacy `role === 'admin'` fallback may read its own address without being able to save. Judging the settings capability exactly would mean re-composing the settings door's resolver call, held-session and outage relays inside plugin-auth, a second copy of a door; not done here. - **The session row's address is not this address.** `get-session` serves `session.ipAddress`, stamped at sign-in in its canonical text (`client-address-canonical.ts`: an IPv4-mapped peer is stored dotted). It can differ from the current connection (the address changed since sign-in) and from the judged text, so it was not reused; ablation A2 pins the text difference. - **The read is behind the allow-list in force.** It sits under the auth mount, where the ADR-0069 D5 middleware judges every route except `/config` and `/bootstrap-status`. A caller whose address is already outside the list in force gets `403 IP_NOT_ALLOWED` from the read; no exemption is added to the gate. - **Docs.** `content/docs/permissions/authentication.mdx` › *Saving, clearing and lockout recovery* does not mention the read yet; it is outside this card's declared file surface. A one-line addition fits there once the ui half shows the address. Carrier: the seat. - Serial: `allowed-ip-ranges.ts`'s refusal copy (`COPY`, `copyFor`) is byte-untouched, for this seat's #30385. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic (untouched). - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 block is Chinese by the Communication rule). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Utp6CZbTJpa9PNbpp3fdB7 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f6faf75 commit 455b4f0

9 files changed

Lines changed: 339 additions & 11 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
feat(plugin-auth): a platform admin can read the address the allowed IP ranges save check will judge, before saving, at `GET /api/v1/auth/admin/client-address` (#30398)
6+
7+
Clause-②: yes (widening) — a new authenticated HTTP read on the auth mount, ledgered `server-only` in the plugin's auth route ledger; no spec schema, SDK method or package export moves.
8+
Metadata-protocol: none
9+
10+
A save of `auth.allowed_ip_ranges` (ADR-0069 D5) is refused when the list leaves out the address the saving request comes from, and the refusal names that address. Until now that refusal was the only place the address appeared, so an administrator learned which address counts by being refused. The new read answers it beforehand.
11+
12+
- **The answer.** `200 { success: true, data: { clientAddress, trustProxy } }` with `Cache-Control: no-store`. `clientAddress` is the caller's address exactly as the save check and the sign-in gate judge it, through the same call the save check makes: the forwarded `X-Forwarded-For` / `X-Real-IP` address only when the stack declares `server.trustProxy`, the connection's peer address otherwise, in the same text (an IPv4-mapped peer reads `::ffff:127.0.0.1`, as the refusal names it). It is `null` when the server cannot establish an address; while any list is set, such a connection is refused at sign-in and any non-empty list at the save. `trustProxy` says whether forwarded headers counted.
13+
- **Who may read it.** A platform admin, through the gate every ObjectStack `/admin/*` auth route uses: no session answers `401 UNAUTHENTICATED`, a session held by the authentication policy (an expired password, required two-factor not set up) answers `403` with that posture's code, and any other caller `403 PERMISSION_DENIED`. It is not served to anonymous callers, and `GET /api/v1/auth/config` is unchanged.
14+
- **Closed query set.** The route takes no query parameters; any one is refused with `400 VALIDATION_ERROR`.
15+
- **Behind the allow-list.** Like every auth route except the sign-in page's two render reads, the read is judged by the allow-list in force: a caller already outside it gets `403 IP_NOT_ALLOWED`.
16+
17+
The save check, the sign-in gate and the address rule are unchanged: the save check now reaches the rule through the helper the read shares, with the same inputs.

‎packages/plugins/plugin-auth/src/allowed-ip-ranges.pin.test.ts‎

Lines changed: 131 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,17 @@
2121
* the deployment declares `trustProxy`. A list that includes it saves.
2222
* - The recovery the refusal names: `OS_AUTH_ALLOWED_IP_RANGES` set empty
2323
* opens the gate on the next boot over a stored list.
24+
* - [#30398] The read an administrator asks before saving,
25+
* `GET /api/v1/auth/admin/client-address`, answers the address that same
26+
* save would be judged by: the plugin's real auth routes on a real Hono app,
27+
* the transport peer supplied the way `@hono/node-server` supplies it.
2428
*
2529
* `@objectstack/service-settings` resolves to its `src/` here (this package's
2630
* `vitest.config.ts`), so the routes and the service are the checkout's.
2731
*/
2832

2933
import { afterEach, describe, expect, it, vi } from 'vitest';
34+
import { Hono } from 'hono';
3035
import type { IHttpRequest, IHttpResponse, IHttpServer, RouteHandler } from '@objectstack/spec/contracts';
3136
import {
3237
SettingsService,
@@ -56,11 +61,18 @@ class MockHttp implements IHttpServer {
5661
getInstance() { return null; }
5762
}
5863

64+
type Transport = { headers?: Record<string, string>; remoteAddress?: string };
65+
66+
/** The session the read's platform-admin gate is handed: a platform admin unless a test says otherwise. */
67+
const PLATFORM_ADMIN_SESSION = { user: { id: 'usr_admin', email: 'admin@example.com', isPlatformAdmin: true } };
68+
5969
interface Rig {
6070
svc: SettingsService;
6171
manager: AuthManager;
62-
save(body: Record<string, unknown>, transport?: { headers?: Record<string, string>; remoteAddress?: string }):
63-
Promise<{ status: number; body: any }>;
72+
save(body: Record<string, unknown>, transport?: Transport): Promise<{ status: number; body: any }>;
73+
/** `GET /api/v1/auth/admin/client-address` on the plugin's real auth routes. */
74+
read(transport?: Transport, opts?: { session?: unknown; query?: string }):
75+
Promise<{ status: number; body: any; cacheControl: string | null }>;
6476
}
6577

6678
async function boot(
@@ -116,7 +128,28 @@ async function boot(
116128
await http.routes.get('PUT /api/settings/:namespace')!(req, res);
117129
return out;
118130
};
119-
return { svc, manager, save };
131+
132+
let app: Hono | undefined;
133+
const read: Rig['read'] = async (transport = {}, opts = {}) => {
134+
if (!app) {
135+
app = new Hono();
136+
(plugin as any).registerAuthRoutes({ getRawApp: () => app, getPort: () => 0 }, ctx);
137+
}
138+
const session = 'session' in opts ? opts.session : PLATFORM_ADMIN_SESSION;
139+
// The gate reads the session through the auth service's in-process read.
140+
const getApi = vi.spyOn(manager, 'getApi').mockResolvedValue({ getSession: async () => session } as any);
141+
try {
142+
const res = await app.request(
143+
`http://localhost:3000/api/v1/auth/admin/client-address${opts.query ?? ''}`,
144+
{ method: 'GET', headers: transport.headers ?? {} },
145+
transport.remoteAddress ? { incoming: { socket: { remoteAddress: transport.remoteAddress } } } : {},
146+
);
147+
return { status: res.status, body: await res.json(), cacheControl: res.headers.get('cache-control') };
148+
} finally {
149+
getApi.mockRestore();
150+
}
151+
};
152+
return { svc, manager, save, read };
120153
}
121154

122155
const stored = async (svc: SettingsService) => (await svc.get('auth', 'allowed_ip_ranges', {})).value ?? null;
@@ -257,3 +290,98 @@ describe('#30320 an IPv6 range is judged at both doors as the range it names', (
257290
expect(manager.isClientIpAllowed('2001:db8:1234::7')).toBe(true);
258291
});
259292
});
293+
294+
describe('#30398 the address read serves the address the save check judges', () => {
295+
// TEST-NET-1: no address any case below connects from falls inside it, so a
296+
// save of this list is always refused and its refusal names the judged address.
297+
const NOBODY = '192.0.2.0/24';
298+
299+
/** One connection, asked at both doors: the read's answer and the save check's judged address. */
300+
const bothDoors = async (rig: Rig, transport: Transport) => {
301+
const read = await rig.read(transport);
302+
expect(read.status, JSON.stringify(read.body)).toBe(200);
303+
const save = await rig.save({ allowed_ip_ranges: NOBODY }, transport);
304+
expect(save.status, JSON.stringify(save.body)).toBe(400);
305+
const judged = save.body.error.details.fields.find((f: any) => f.field === 'allowed_ip_ranges').constraint.clientAddress;
306+
return { shown: read.body.data, judged };
307+
};
308+
309+
it('a direct peer: the read shows the peer address the save judges', async () => {
310+
const { shown, judged } = await bothDoors(await boot(), { remoteAddress: '203.0.113.9' });
311+
expect(shown).toEqual({ clientAddress: '203.0.113.9', trustProxy: false });
312+
expect(judged).toBe(shown.clientAddress);
313+
});
314+
315+
it('an IPv4-mapped peer: the read shows the mapped text the save judges, not a rewritten one', async () => {
316+
const { shown, judged } = await bothDoors(await boot(), { remoteAddress: '::ffff:127.0.0.1' });
317+
expect(shown).toEqual({ clientAddress: '::ffff:127.0.0.1', trustProxy: false });
318+
expect(judged).toBe(shown.clientAddress);
319+
});
320+
321+
it('trustProxy declared: the read shows the forwarded address the save judges', async () => {
322+
const forwarded = { headers: { 'x-forwarded-for': '198.51.100.7, 10.0.0.2' }, remoteAddress: '10.0.0.1' };
323+
const { shown, judged } = await bothDoors(await boot({ plugin: { trustProxy: true } }), forwarded);
324+
expect(shown).toEqual({ clientAddress: '198.51.100.7', trustProxy: true });
325+
expect(judged).toBe(shown.clientAddress);
326+
});
327+
328+
it('trustProxy not declared: a forged X-Forwarded-For is ignored at both doors, the peer is shown', async () => {
329+
const forged = { headers: { 'x-forwarded-for': '198.51.100.7', 'x-real-ip': '198.51.100.8' }, remoteAddress: '10.0.0.1' };
330+
const { shown, judged } = await bothDoors(await boot(), forged);
331+
expect(shown).toEqual({ clientAddress: '10.0.0.1', trustProxy: false });
332+
expect(judged).toBe(shown.clientAddress);
333+
});
334+
335+
it('an address the server cannot establish reads null at both doors', async () => {
336+
const { shown, judged } = await bothDoors(await boot(), {});
337+
expect(shown).toEqual({ clientAddress: null, trustProxy: false });
338+
expect(judged).toBeNull();
339+
});
340+
341+
it('the answer is never stored by a cache', async () => {
342+
const res = await (await boot()).read({ remoteAddress: '203.0.113.9' });
343+
expect(res.status).toBe(200);
344+
expect(res.cacheControl).toBe('no-store');
345+
});
346+
347+
describe('who may read it: the platform-admin gate of every /admin mount, before any address is read', () => {
348+
const refusedBeforeTheRead = async (session: unknown) => {
349+
const rig = await boot();
350+
const asked = vi.spyOn(rig.manager, 'trustsForwardedHeaders');
351+
const res = await rig.read({ remoteAddress: '203.0.113.9' }, { session });
352+
expect(asked).not.toHaveBeenCalled();
353+
expect(res.body.success).toBe(false);
354+
expect(res.body.data).toBeUndefined();
355+
return res;
356+
};
357+
358+
it('no session: 401 UNAUTHENTICATED', async () => {
359+
const res = await refusedBeforeTheRead(null);
360+
expect(res.status).toBe(401);
361+
expect(res.body.error.code).toBe('UNAUTHENTICATED');
362+
});
363+
364+
it('a signed-in caller who is not a platform admin: 403 PERMISSION_DENIED', async () => {
365+
const res = await refusedBeforeTheRead({ user: { id: 'usr_member', email: 'member@example.com' } });
366+
expect(res.status).toBe(403);
367+
expect(res.body.error.code).toBe('PERMISSION_DENIED');
368+
});
369+
370+
it("a held platform admin's session (ADR-0069 D3): 403 with the posture's own code", async () => {
371+
const held = { user: { ...PLATFORM_ADMIN_SESSION.user, authGate: { code: 'MFA_REQUIRED', message: 'Set up two-factor authentication.' } } };
372+
const res = await refusedBeforeTheRead(held);
373+
expect(res.status).toBe(403);
374+
expect(res.body.error.code).toBe('MFA_REQUIRED');
375+
});
376+
});
377+
378+
it('the query set is closed and empty: any parameter is refused 400 VALIDATION_ERROR before the address is read', async () => {
379+
const rig = await boot();
380+
const asked = vi.spyOn(rig.manager, 'trustsForwardedHeaders');
381+
const res = await rig.read({ remoteAddress: '203.0.113.9' }, { query: '?address=198.51.100.7' });
382+
expect(res.status).toBe(400);
383+
expect(res.body.error.code).toBe('VALIDATION_ERROR');
384+
expect(res.body.data).toBeUndefined();
385+
expect(asked).not.toHaveBeenCalled();
386+
});
387+
});

‎packages/plugins/plugin-auth/src/allowed-ip-ranges.ts‎

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@
1111
* console, of a list that leaves out the address the saving request itself
1212
* comes from. Saved, such a list refuses the administrator's own next
1313
* sign-in, and nothing at the save said so.
14+
* - {@link judgedClientAddress} is THE reading of a request's address for this
15+
* setting, called by that save check and by the read that shows an
16+
* administrator the address before they save (`GET /admin/client-address`,
17+
* `client-address-read.ts`, #30398), so the address shown is the address
18+
* judged.
1419
*
1520
* The address judged at the save is the one the gate judges at sign-in: the
1621
* auth manager's {@link AuthManager.resolveClientAddress}, from the same two
@@ -32,6 +37,7 @@
3237

3338
import { resolveLocale } from '@objectstack/core';
3439
import { ipMatchesRange, type AuthManager } from './auth-manager.js';
40+
import { FORWARDED_CLIENT_ADDRESS_HEADERS } from './client-address-forwarding.js';
3541

3642
/** The settings env override that pins `auth.allowed_ip_ranges` (`OS_` + namespace + key). */
3743
export const ALLOWED_IP_RANGES_ENV = 'OS_AUTH_ALLOWED_IP_RANGES';
@@ -49,22 +55,53 @@ export function parseAllowedIpRanges(raw: unknown): string[] {
4955
}
5056

5157
/**
52-
* The HTTP request a settings write arrived on, as the settings routes hand it
53-
* to a write predicate (`SettingsWriteOptions.transport` in `service-settings`).
58+
* The two facts a door holds about the HTTP request it serves: its headers and
59+
* the transport's own peer address. The settings routes hand a write predicate
60+
* this shape (`SettingsWriteOptions.transport` in `service-settings`).
5461
*/
55-
interface WriteTransport {
62+
export interface RequestTransport {
5663
headers?: Record<string, string | string[] | undefined>;
5764
remoteAddress?: string;
5865
}
5966

67+
/**
68+
* [#30398] The address `auth.allowed_ip_ranges` judges for a request, or
69+
* `undefined` when the server cannot establish one: the auth manager's
70+
* {@link AuthManager.resolveClientAddress} (`server.trustProxy` read there),
71+
* handed the request's peer address and only the forwarded-address headers that
72+
* rule reads ({@link FORWARDED_CLIENT_ADDRESS_HEADERS}), the narrowing the
73+
* settings service applies to the save check's input before it arrives here.
74+
*
75+
* Both doors that speak about this address call it: the save check below, and
76+
* the read that serves the address to an administrator before a save
77+
* (`client-address-read.ts`). One call, so what the read shows and what the
78+
* save judges cannot drift apart.
79+
*/
80+
export function judgedClientAddress(
81+
manager: Pick<AuthManager, 'resolveClientAddress'>,
82+
transport: RequestTransport,
83+
): string | undefined {
84+
let headers: Record<string, string | string[] | undefined> | undefined;
85+
if (transport.headers) {
86+
headers = {};
87+
for (const [name, value] of Object.entries(transport.headers)) {
88+
if (FORWARDED_CLIENT_ADDRESS_HEADERS.includes(name.toLowerCase())) headers[name] = value;
89+
}
90+
}
91+
return manager.resolveClientAddress({
92+
...(headers ? { headers } : {}),
93+
...(transport.remoteAddress ? { remoteAddress: transport.remoteAddress } : {}),
94+
});
95+
}
96+
6097
/**
6198
* What this predicate reads of a settings write predicate's input. Structural,
6299
* for the reason `SettingsReadSurface` is: plugin-auth does not depend on
63100
* service-settings.
64101
*/
65102
export interface AllowedIpRangesWriteInput {
66103
next: unknown;
67-
transport?: WriteTransport;
104+
transport?: RequestTransport;
68105
locale?: string;
69106
}
70107

@@ -145,10 +182,7 @@ export function allowedIpRangesWritePredicate(
145182
if (ranges.length === 0) return undefined;
146183
const mgr = manager();
147184
if (!mgr) return undefined;
148-
const address = mgr.resolveClientAddress({
149-
...(transport.headers ? { headers: transport.headers } : {}),
150-
...(transport.remoteAddress ? { remoteAddress: transport.remoteAddress } : {}),
151-
});
185+
const address = judgedClientAddress(mgr, transport);
152186
if (address && ranges.some((range) => ipMatchesRange(address, range))) return undefined;
153187
const copy = copyFor(locale);
154188
return {

‎packages/plugins/plugin-auth/src/auth-plugin.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3658,6 +3658,30 @@ export class AuthPlugin implements Plugin {
36583658
}
36593659
});
36603660

3661+
// ────────────────────────────────────────────────────────────────────
3662+
// [#30398] ADR-0069 D5 — admin: the caller's own address as the
3663+
// `auth.allowed_ip_ranges` save check and the sign-in gate judge it, so
3664+
// Setup can show it before a save rather than the save refusal naming it
3665+
// after. The shared gate first, then the read, which takes the address from
3666+
// the save check's own call on this request's headers and transport peer
3667+
// (client-address-read.ts). Closed, empty query set; no body is read.
3668+
rawApp.get(`${basePath}/admin/client-address`, async (c: any) => {
3669+
try {
3670+
const actor = await gateAdmin(c);
3671+
if (actor instanceof Response) return actor;
3672+
const { runClientAddressRead } = await import('./client-address-read.js');
3673+
const answer = runClientAddressRead(this.authManager!, {
3674+
queries: (c.req.queries() ?? {}) as Record<string, string[]>,
3675+
transport: { headers: (c.req.header() ?? {}) as Record<string, string>, ...transportOf(c) },
3676+
});
3677+
return c.json(answer.body, answer.status as any, answer.headers);
3678+
} catch (error) {
3679+
const err = error instanceof Error ? error : new Error(String(error));
3680+
ctx.logger.error('[AuthPlugin] admin/client-address failed', err);
3681+
return c.json({ success: false, error: { code: 'INTERNAL_ERROR', message: err.message } }, 500);
3682+
}
3683+
});
3684+
36613685
// ────────────────────────────────────────────────────────────────────
36623686
// #2766 V1 — admin direct user management. `sys_user` CRUD is suppressed
36633687
// (managedBy better-auth), and until now the only add-a-teammate path was

‎packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,11 @@ describe('auth route ledger hygiene', () => {
220220
// ruling of 2026-08-23).
221221
const own = AUTH_ROUTE_LEDGER.filter((e) => e.source === 'objectstack').map((e) => e.route).sort();
222222
expect(own).toEqual([
223+
// [#30398] On this pin's own terms: `auth-plugin.ts` mounts it itself (a
224+
// `rawApp.get` on the `${basePath}/admin/client-address` template, ahead
225+
// of the catch-all), and the `live.has(route)` loop below holds it to the
226+
// same proof — better-auth serves no address read.
227+
'GET /api/v1/auth/admin/client-address',
223228
'GET /api/v1/auth/bootstrap-status',
224229
'GET /api/v1/auth/config',
225230
// On this pin's own terms: `auth-plugin.ts` mounts it itself (a

‎packages/plugins/plugin-auth/src/auth-route-ledger.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,10 @@ export const AUTH_ROUTE_LEDGER: readonly AuthRouteLedgerEntry[] = [
231231
// `requires` follows the add-member precedent: it names the better-auth
232232
// plugin the route's WORK needs, not whether the mount is conditional —
233233
// every one of these is mounted unconditionally on the raw app.
234+
//
235+
// #30398 — the tracker id stays HERE and never in the `note` string, per the
236+
// #16678 note below.
237+
{ route: 'GET /api/v1/auth/admin/client-address', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', note: 'no SDK method builds this URL — the console\'s Authentication settings page reads it beside the Allowed IP ranges field. Answers { clientAddress, trustProxy }: the caller\'s address exactly as the allowed_ip_ranges save check and the sign-in gate judge it (the save check\'s own call to AuthManager.resolveClientAddress, server.trustProxy deciding whether forwarded headers count), null when the server cannot establish one; Cache-Control: no-store; closed, empty query set; platform-admin gated (ADR-0068), ADR-0069 D5' },
234238
{ route: 'POST /api/v1/auth/admin/import-users', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', note: 'no SDK method builds this URL — objectui app-shell\'s identity-import wizard (views/identityImport.ts) posts it directly from the Users list; platform-admin gated (ADR-0068). A dedicated identity-import door rather than the generic sys_user import, because a direct ObjectQL write would bypass better-auth\'s password hashing and mint no sys_account credential' },
235239
{ route: 'POST /api/v1/auth/admin/list-platform-admins', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', note: 'no SDK method builds this URL — the sys_user list_platform_admins action posts it directly from the Users list; answers who holds platform-admin standing (the last-admin guard\'s enumerator, each id confirmed by hasPlatformAdminStanding), served only to a caller who holds that standing; reads no body. ADR-0095 D3' },
236240
{ route: 'POST /api/v1/auth/admin/oauth2/toggle-disabled', family: 'objectstack-mount', source: 'objectstack', disposition: 'server-only', note: 'no SDK method builds this URL — the sys_oauth_application disable/enable actions post it directly; ObjectStack mount closing a vendor gap (better-auth\'s /admin/oauth2/update-client strips `disabled` from its body schema), platform-admin gated (ADR-0068)' },

0 commit comments

Comments
 (0)