You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit f5e23e2
Browse filesBrowse the repository at this point in the historyBrowse files
feat(plugin-auth)!: an organization administrator reads their judged client address; the console's retired MFA notices are gone (#31258)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"}
-->
_Requested by **Leehom** · [project
thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X2ArVyEECU1nJDS63wR7Z1H)_Fixes#31030
Clause-②: yes (narrowing) — the published `@object-ui/i18n` packs (all
eleven) drop `console.organizationMfa.needsTwoFactor` and
`.nowRequired`, which had no reader outside the removed notices. Both
keys were added by the still-pending
`.changeset/30295-org-mfa-notice-and-enroll-refusal.md` and never
released, so no published version of the packs carries them. The
widening half: a new served read, `GET /api/v1/auth/client-address`.
Metadata-protocol: none
Skills-docs: none — `skills/**` teaches no access-policy key,
client-address read or console notice, and `content/docs/**` teaches no
per-organization access policy; its `allowed_ip_ranges` section in
`permissions/authentication.mdx` is the deployment's own list, whose
platform-admin read it names correctly.
## 维护者速读
- 改了什么:组织管理员(在当前组织有 `setup.write`)可通过新接口 `GET
/api/v1/auth/client-address` 看到服务器判定的自己的 IP;auth 插件自挂的路由在进程内读会话时也会按组织的
IP 白名单判定;控制台删掉了组织表单上针对已退役 `require_mfa` 列的两条提示及其文案。
- 为什么改:组织管理员保存「允许的 IP 范围」前,原本只能从保存被拒的报错里得知自己的地址(旧接口只对平台管理员开放);另外 auth
插件自挂路由读会话时不判组织 IP 名单,与 better-auth 自身路由的判定不一致。
- 风险与回滚:新接口只回答调用者自己的地址,查询参数一律拒收;中间件只给 `/api/v1/auth/*`
下的会话读带上地址,不在名单内的会话会在这些路由上被结束(`ip_not_allowed`)。回滚即还原本 PR。
-
先看:`packages/plugins/plugin-auth/src/auth-plugin.ts`(新路由与地址中间件)、`packages/plugins/plugin-auth/src/auth-manager-plugin-list.ts`(会话读的
door
参数)、`packages/plugins/plugin-auth/src/client-address-read.ts`(准入判定)。
- 两分钟验证:`pnpm --filter @objectstack/dogfood exec vitest run
test/org-admin-client-address-read.dogfood.test.ts`,预期 4 条全绿:组织管理员
200、普通成员 403 PERMISSION_DENIED、匿名 401、组织管理员读平台接口 403。
- 需要你做的:无,席位落地。
## What changes
**S7: an organization administrator reads their judged address.** `GET
/api/v1/auth/client-address` (new, mounted by `auth-plugin.ts` ahead of
the catch-all, beside the platform-admin `GET
/api/v1/auth/admin/client-address`) answers the same `{ clientAddress,
trustProxy }`: the address the `access_policy.allowed_ip_ranges` save
check judges. The caller is resolved through core's
`resolveRequestExecutionContext` with the settings door's parameter set,
and admitted by `judgeOrganizationAddressReader`
(`client-address-read.ts`): no caller is `401 UNAUTHENTICATED`; a held
session is `403` with its posture's code (`judgeHeldSession`); a caller
without `setup.write` in their current organization is `403
PERMISSION_DENIED`; an unreachable permission store is `503
SERVICE_UNAVAILABLE`. It answers the caller's own address only: the
query set is closed and empty (`400 VALIDATION_ERROR` for any
parameter), and the answer is `Cache-Control: no-store`. The route is a
new row in `auth-route-ledger.ts` (objectstack-mount, server-only) and
in the ledger conformance pin.
**D3: an objectstack mount's in-process session read judges the
organization's IP list.** A middleware on `${basePath}/*` establishes
the request's address with `AuthManager.resolveClientAddress` (the D5
gate's rule: `server.trustProxy` decides whether forwarded headers
count) and runs the rest of the request in a better-auth request state
carrying it (`runAtMountDoor`, `request-client-address.ts`).
`customSession` passes that address as the `door` to
`enforceSessionControls` when no `ctx.request` is present
(`readMountDoor`), so `gateAdmin`, the authorized-AI-assistant routes,
session revoke, add member and the other routes that call
`auth.api.getSession` in process end a session outside the
organization's Allowed IP ranges (`revoke_reason: ip_not_allowed`), as
better-auth's own routes already did. Outside a mount no door is open,
so data requests outside the auth mount are judged by neither list, as
before. Measured that the mount has an established address (peer, or
forwarded under `trustProxy`), so the seat's ruling (pass the address)
applies.
**D2: no heartbeat before the switch judgement, through the real
route.** A new pin drives `POST /organization/set-active` on a real
better-auth instance: a session idle 30 minutes switching into an
organization with a 10-minute idle limit is answered `401
UNAUTHENTICATED`, its row ends with `idle_timeout`, stays scoped to the
old organization, and `last_activity_at` is unchanged; the control (idle
2 minutes) answers 200.
**D1:** `sys_session.revoke_reason`'s description names `ip_not_allowed`
(`sys-session.object.ts`); the six translation bundles carry it (en
regenerated by `check-i18n-bundles.mjs --write`, the other five
hand-edited the same way).
**S6: the organization form's notices are removed.**
`access_policy.mfa_required` has no reader a form notice would serve:
its save refusal already names two-factor itself, and
`sys_organization.require_mfa` is read-only and off the form, so nothing
on the form turns the requirement on. Per the seat ruling,
`organizationMfaNotices.ts`, its test, the `AppContent.tsx` call (the
`findOne` of the stored column and the `description` spread on the save
toast) and the two `console.organizationMfa.*` keys in all eleven packs
are removed; the German quote-pairing census moves by one span.
New test files:
`packages/plugins/plugin-auth/src/organization-access-policy-doors.test.ts`
(D3 on a real better-auth instance, D2 through the route; a new file
because #31175 is in flight on `organization-access-policy.test.ts`) and
`packages/qa/dogfood/test/org-admin-client-address-read.dogfood.test.ts`
(S7 end to end on a booted stack).
## Why
The card's acceptance: "An org admin reads their judged address; a plain
member is refused." / "No dead notice code remains." D1, D2 and D3 are
the should-fixes carried from the #31013 contract reviews.
## Tests
- `allowed-ip-ranges.pin.test.ts`: the organization read answers what
the platform read and the save's judged address answer across three
transports (peer, forwarded under `trustProxy`, forged header without
it); the platform route refuses a non-platform-admin `403
PERMISSION_DENIED`; anonymous `401 UNAUTHENTICATED`, plain member `403
PERMISSION_DENIED`, held `403 MFA_REQUIRED`, each refused before the
address is read; store outage `503 SERVICE_UNAVAILABLE`; three query
parameters each `400 VALIDATION_ERROR`; the mount middleware's door
(peer, forwarded, forged, unestablished, and none outside a mount).
- `organization-access-policy-doors.test.ts`: D3 (outside the list ends
the session `ip_not_allowed` and the next read is null; an unestablished
address is refused under a set list; no door, no IP judgement) and D2
(above).
- Every refusal case asserts `code` and `status`.
## Acceptance notes
- The console's Access policy page does not read the new route yet; this
PR serves it. Seam: runtime `GET /api/v1/auth/client-address` → renderer
SettingsView, `access_policy` namespace; consumer: none yet.
- `organization-access-policy.test.ts` › "the absolute limit is judged
too…" failed once in the full plugin-auth run on a loaded box and passes
alone: its fixture creates the session exactly 60 minutes ago and the
judgement is a strict `>` against a one-hour limit, so any elapsed
millisecond between insert and judgement flips `admitted` to `ended`.
Not touched here (#31175 holds that file).
- `content/docs/**` teaches no per-organization access policy at all
(only the deployment's own `allowed_ip_ranges`); noted, not changed
here.
## Verification
Final head `78ac3b00` (the branch merged with `origin/main` once, after
#31194 changed plugin-auth). Every heavy run went through
`scripts/pm/os-verify-lock.sh`. Head `4b3757cb` moves it by changeset
prose only (the two removed keys were never released); on it
`check-changeset-no-major --base origin/main` and
`check-adr-0087-registration --base origin/main` exit 0.
<details><summary>Red first and ablations</summary>
- Red first (the new tests against the base sources): 12 failed.
- `readMountDoor` in `customSession` replaced by `undefined`: 2 failed
(`expected undefined to be 'ip_not_allowed'`), restored, `git diff HEAD`
empty.
- The mount middleware made to call bare `next()`: 3 failed (door
undefined), restored.
- The `setup.write` check disabled: the plain-member case red (the
address was read), restored.
- A heartbeat written before the switch judgement: the set-active case
red (200, not 401), restored. A first attempt at this ablation was a
no-op (its replacement contained the anchor, so the anchor count stayed
1); re-run with a replacement that does not contain it, which landed and
went red.
</details>
<details><summary>Suites, type-checks, lint</summary>
On `78ac3b00`:
- `pnpm --filter @objectstack/plugin-auth test`: exit 0, Test Files 236
passed (236), Tests 4199 passed, 10 skipped.
- From `ui/`, `pnpm exec vitest run packages/i18n/src/__tests__/
packages/app-shell/src/console/__tests__/AppContent.recordSaveOneToast-27999.test.tsx`:
exit 0, Test Files 99 passed (99), Tests 1756 passed, 13 skipped.
On `0a6d22c6` (before the ledger-pin fix and the merge; neither touches
these packages' sources):
- `pnpm --filter @objectstack/plugin-auth typecheck` (with
`tsconfig.examples.json` and `check:test-typecheck`): exit 0.
- `pnpm --filter @objectstack/platform-objects test`: exit 0, Test Files
119 passed (119), Tests 1701 passed. `typecheck`: exit 0.
- `pnpm --filter @object-ui/i18n run type-check` and `pnpm --filter
@object-ui/app-shell run type-check` (after `pnpm --filter
'@object-ui/app-shell^...' build`): exit 0 each.
- Scoped lint: `eslint packages/plugins/plugin-auth/src
packages/platform-objects/src` plus the new dogfood file
`--no-inline-config`: exit 0, no output. From `ui/`, `eslint` on the
edited `AppContent.tsx`, its test, the locale packs and the de-quote
test: exit 0, 0 errors, 50 warnings, all on `AppContent.tsx` lines this
PR does not edit.
- The full plugin-auth run on `0a6d22c6` had two reds: the route-ledger
conformance pin (fixed in `4fd8b6be` by naming the new route) and the
boundary flake named under Acceptance notes; both files pass on
`78ac3b00`.
</details>
<details><summary>Derived gates</summary>
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` on `78ac3b00`: 86 commands, each run with
stdin from `/dev/null` and its exit captured before any pipe: 86 exit 0.
`--ran` with each line recorded as `COMMAND :: exit CODE`: "86 derived
famil(ies) accounted for — 86 run, 0 NOT-MEASURED (a DERIVED zero — all
86 recorded an exit code and none of them is 3)". The lock's verdict:
`VERDICT command-exit 0`.
On the earlier head (78 commands), four reds were fixed before this run:
`check:engine-double-contract` (the new test's doubles, ledger rewritten
with `--write`), `check:test-source-alias` (a dynamic
`@objectstack/core` import in a test body made static; the edited
AppContent test warms `@objectstack/lint` at module top),
`check-plugin-teardown-shape --self-test` (exit 3 until the pinned
fixture commit was fetched) and `check:dual-build-cjs-loads` (exit 3
until the closure was built).
</details>
<details><summary>Dogfood battery (final head)</summary>
`78ac3b00` · after `pnpm exec turbo run build --concurrency=2
--filter='@objectstack/dogfood^...'` (exit 0) · `pnpm --filter
@objectstack/dogfood test` under the lock: `VERDICT command-exit 0` ·
Test Files 436 passed, 3 skipped (439) · Tests 3613 passed, 41 skipped
(3654) · no failures. The new file alone: Test Files 1 passed, Tests 4
passed.
</details>
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
- [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: its generated artifacts are
regenerated and committed. (Not touched.)
- [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. (Except the maintainer
digest, which os-dev.md writes in Chinese.)
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01FEkQ5mEgSFeDT7pQA12if9)_
<!-- signed: domain:server#6 · os-project-manager ·
session_01FEkQ5p3T5YWhLjurajudnQ -->
---------
Co-authored-by: Claude <noreply@anthropic.com>
feat(plugin-auth)!: an organization administrator reads the client address their access-policy list is judged by, and the console's notices over the retired `require_mfa` column are gone
9
+
10
+
Clause-②: yes (narrowing) — the published `@object-ui/i18n` packs (all eleven) drop the `console.organizationMfa.needsTwoFactor` and `.nowRequired` keys, which had no reader outside the removed notices. Both keys were added by the still-pending `.changeset/30295-org-mfa-notice-and-enroll-refusal.md` and never released, so no published version of the packs carries them. The widening half: a new served read, `GET /api/v1/auth/client-address`.
11
+
Metadata-protocol: none
12
+
Skills-docs: none — `skills/**` teaches no access-policy key, client-address read or console notice, and `content/docs/**` teaches no per-organization access policy; its `allowed_ip_ranges` section in `permissions/authentication.mdx` is the deployment's own list, whose platform-admin read it names correctly.
13
+
14
+
<!-- adr-0087: not-required (runtime-interface-only ui/packages/i18n/src/locales/en.ts#TranslationKeys) the key type of the console's own translation catalogue: no Zod schema, no spec declaration and no stored metadata reads it, so the compiler is the channel that reaches every caller -->
15
+
16
+
**An organization administrator sees their judged address before saving their organization's Allowed IP ranges.** A save of `access_policy.allowed_ip_ranges` that leaves out the saving request's own address is refused, and until now an organization administrator learned that address only from the refusal: `GET /api/v1/auth/admin/client-address` answers platform administrators alone. `GET /api/v1/auth/client-address` answers the same `{ clientAddress, trustProxy }`, the address the save check judges (`server.trustProxy` deciding whether forwarded headers count; `null` when the server cannot establish one), to the caller who may save that list: `setup.write` in their current organization, resolved the way the settings door resolves a saving caller. It is the caller's own address only; the query set is closed and empty (`400 VALIDATION_ERROR`), the answer is `Cache-Control: no-store`. Anonymous: `401 UNAUTHENTICATED`. A held session: `403` with its posture's code. A member without `setup.write`: `403 PERMISSION_DENIED`. An unreachable permission store: `503 SERVICE_UNAVAILABLE`. The console's Access policy page does not read it yet.
17
+
18
+
**The organization's IP list is judged on an objectstack mount's own session read too.** The routes the auth plugin mounts itself under `/api/v1/auth` (the platform-admin routes, the authorized-AI-assistant routes, session revoke, add member and the others that read the caller's session in process) now carry the address the deployment's IP gate established for the request to that read, so a session whose organization's Allowed IP ranges no longer admit it is ended there (`revoke_reason: ip_not_allowed`), as it already was on a session read through better-auth's own routes. Data requests outside the auth mount are judged by neither list, as before.
19
+
20
+
`sys_session.revoke_reason`'s published vocabulary (the field description) now names `ip_not_allowed`.
21
+
22
+
**BREAKING** (`@object-ui/i18n`, TypeScript only): the exported `TranslationKeys` (`typeof en`) loses the two `console.organizationMfa.*` keys, in all eleven packs (ar, de, en, es, fr, ja, ko, pt-BR, ru, zh-Hans, zh-Hant). A `t()` call that names one still compiles, since its key is a `string`, and reads the key back. Both keys come from the still-pending `.changeset/30295-org-mfa-notice-and-enroll-refusal.md` and were never released: a consumer of a published `@object-ui/i18n` never had them.
23
+
24
+
| Removed key | Replacement |
25
+
|:---|:---|
26
+
|`console.organizationMfa.needsTwoFactor`| none: `sys_organization.require_mfa` is read-only and off the organization form; the multi-factor requirement is the access policy's `mfa_required` level, whose save refusal names two-factor itself |
27
+
|`console.organizationMfa.nowRequired`| none: the same column can no longer be turned on, so no save turns the requirement on from the organization form |
0 commit comments