Repository navigation
Commit f127b04
fix(plugin-auth)!: under invite_only, a self-serve sign-up is admitted only by the invitation it presents (#31686)
Clause-②: yes (narrowing) — under the `invite_only` audience posture an
email-and-password sign-up is admitted by an invitation only when it
presents that invitation's id as `invitationId` (pending, unexpired,
addressed to the sign-up's email), and the address alone is refused
`SELF_REGISTRATION_CLOSED`; a social OAuth creation is admitted by an
invitation only when its identity provider verified the address. The
sign-up request widens: `RegisterRequestSchema` and `RegisterRequest`
gain `invitationId`, `SignUpData`, `RegisterForm` and the auth context's
`signUp` gain it, and `os register` gains `--invitation`.
Metadata-protocol: added — RegisterRequest gains the optional
invitationId key (packages/spec/authorable-surface/api.json)
Skills-docs: updated here — `content/docs/deployment/self-hosting.mdx`
states the rule, and its out-of-band recovery now sends the row's `id`
as `invitationId`; no file under `skills/**` teaches the sign-up request
or the invitation carve-out, so no skill or eval moves.
## 维护者速读
- 改了什么:`invite_only`(默认姿态)下,自助注册只凭它出示的邀请放行——注册请求带上邀请链接里的
id(`invitationId`),且该邀请须
pending、未过期、发给同一邮箱;只给邮箱一律按未受邀拒绝,同码同文案,不落任何行。控制台两个注册页、SDK
类型、CLI(`--invitation`)和全部测试夹具都改为携带 id。
- 为什么改:安全加固,规则层表述:`invite_only` 下,自助注册只由它出示的邀请放行。
- 风险与回滚:破坏性收窄——未升级的客户端为受邀者注册时不带 id 会得到 `403
SELF_REGISTRATION_CLOSED`;首个账号、运营方创建(admin / SCIM / 启动种子)、`open` 与
`email_domain`、魔法链接与邮箱 OTP、SSO 均不变。回滚即 revert 本 PR。
- 先看:① `packages/plugins/plugin-auth/src/presented-invitation.ts`(判定本身:按
id 读一行、id 精确比对、pending / 过期 / 地址三项)②
`packages/plugins/plugin-auth/src/auth-manager-membership-admission.ts`
新增的 proof 分支 ③ `packages/plugins/plugin-auth/src/audience-posture.ts` 的
`invitationProofFor`。
- 两分钟验证:`pnpm --filter @objectstack/plugin-auth exec vitest run
src/presented-invitation.test.ts`,预期 `Tests 13 passed (13)`。
- 需要你做的:无,席位落地。
## What changes
**The rule.** Under `invite_only`, a self-serve sign-up is admitted only
by the invitation it presents. The sign-up sends the invitation's id,
the last segment of its `/accept-invitation/` link, as `invitationId`
beside `email`, `password` and `name`; the invitation must be pending,
unexpired and addressed to that email. No id, an unknown id, an expired
or no-longer-pending invitation, another address's invitation, or an id
that is not a non-empty string all get the one refusal an uninvited
sign-up gets (`403 SELF_REGISTRATION_CLOSED`, same message, same body),
and nothing is written. No error code or message is added.
- **`@objectstack/plugin-auth`.** `presented-invitation.ts` answers
whether the request presented a pending invitation; `invitationProofFor`
(`audience-posture.ts`) decides which creations must present one. The
decision stays in `validateAudienceAdmission`, which the
`/sign-up/email` before-hook and the `user.validateUserInfo` gate both
call with the same endpoint context, so the two cannot disagree. Order
unchanged: operator class, bootstrap, invalid posture, invitation,
posture — the first account and the boot command's seed still enter
without an invitation.
- **Other creation doors under `invite_only`.** Magic link and email OTP
create an account only after the mailbox answered, so they keep matching
an invitation by address. A social OAuth creation is matched by address
only when its identity provider verified the address (`emailVerified:
true`); otherwise it gets the uninvited refusal. SSO providers
(`allowJit`) are unchanged.
- **Unchanged:** `invite_only` stays the default; `open` and
`email_domain` match an invitation by address as before; accepting and
listing invitations are unchanged; the boot warning, the verification
defaults and `requireEmailVerificationOnInvitation` are unchanged.
- **Accepting and listing.** Under this rule an unverified session at an
invited address exists only because its sign-up presented that address's
invitation, so what it lists is its own. Narrowing the listing to one id
would need the id stored on the account and would hide a second
organization's invitation from a real invitee, so the listing stays by
address.
- **Rate limiting.** No limiter is added: the sign-up door's existing
rate limit and the opt-in Turnstile challenge already front it, and an
invitation id is 32 random characters, which cannot be guessed within
them.
- **The id travels as one body field.** `RegisterRequestSchema`
(`@objectstack/spec`) declares the optional `invitationId`, the key
`packages/spec/authorable-surface/api.json` records (`Metadata-protocol:
added`, `@objectstack/spec` graded `minor`); the vendor's sign-up body
schema keeps an undeclared key and drops it before the row is written
(pinned, so a vendor bump that closes the body turns red).
- **Callers.** The Console's `/register` and app-shell's
`DefaultRegisterPage` pass the id their
`?redirect=/accept-invitation/ID` carries (`invitationIdOf`, now
exported from `@object-ui/app-shell`); `RegisterForm` takes an
`invitationId` prop, `SignUpData` and the context's `signUp` carry it,
and `createAuthClient` sends it in the body. The SDK's `auth.register()`
passes it through, typed by the widened `RegisterRequest`. `os register
--invitation ID`. The no-mail path is the invitation's **Copy Invite
Link** action, whose link carries the same id.
- **Fixtures sign up the way an invitee does.** `inviteForAudienceGate`
and `@objectstack/verify`'s harness answer the id they seed, and every
fixture sign-up presents it. No request origin is exempted.
- **The release smoke presents it too.** `scripts/publish-smoke.sh`
keeps the `id` the operator's invite-member call answers and its invited
sign-up sends it as `invitationId`; the uninvited probe still answers
`403 SELF_REGISTRATION_CLOSED`.
- **Texts.** `content/docs/deployment/self-hosting.mdx`, the boot
report's recovery remedy (`boot-sign-in-reachability.ts`) and the
checklist's invite recipe (`docs/qa/platform-checklist/RUNNER.md`, six
area items and two area recipes) state the rule; the generated
`content/docs/references/api/auth.mdx` follows the schema.
- **Who moves.** Nothing in this repository: the Console, the SDK, the
CLI, the `@objectstack/verify` harness and the fixtures all present the
id. A client outside it that signs invitees up under `invite_only` sends
the accept link's last path segment as `invitationId`. Operators deploy
the Console bundle together with the server, hand the link over with
**Copy Invite Link** where no mail transport is live, give an
out-of-band recovery invitation row an `id` the sign-up sends, and
review accounts created before the upgrade that are unverified and hold
or accepted an invitation.
BREAKING: under `invite_only`, a client that signs an invitee up sends
`invitationId` beside the `email`, `password` and `name` it already
sends; the one-line fix is to send the id the invitation link carries.
## Tests
The branch merged `origin/main` at `737b16098b`; each reading names its
head under Verification.
- **`presented-invitation.test.ts` (new, 13 cases)** — the real
`AuthManager` over ObjectQL + `@objectstack/driver-sql` on
better-sqlite3, with invitations written by
`/organization/invite-member`: the sign-up that presents its invitation
is admitted and the invitee accepts with the invited role; the first
account and the boot command's seed enter without one; an address with
no invitation, an invited address alone, an unknown id, an expired
invitation, a no-longer-pending one, another address's invitation, a
non-string id and an empty id each get the identical refusal (status,
code, message and body) and leave no `sys_user`, `sys_account` or
`sys_member` row; the presented address matches trimmed and lowercased;
an address-only attempt does not spend the invitation: the invitee still
signs up with the link, and accepts; a copied invitation link carries
the id that admits the invitee; the listing still shows the session its
own and another organization's pending invitation; an invite-member id
is 32 characters of `[a-zA-Z0-9]` and two never match; the vendor
sign-up body keeps an undeclared key; a magic-link creation holding an
invitation is admitted; an OAuth creation is admitted by its invitation
only with `emailVerified: true`; `open` and `email_domain` still match
an invitation by address with no id.
- **`audience-posture.test.ts`** — a new top-level `invitationProofFor`
matrix (4 cases); the end-of-chain invitation case presents the id; the
address probe's own pins (page boundary, narrowed read, folding
collation) drive a magic-link creation, the door that keeps the address
match.
- **`boot-sign-in-reachability.test.ts`** — the recovery remedy names
sending the row's `id` as `invitationId`.
- **`@objectstack/spec` `auth.test.ts`** — `invitationId` is accepted,
optional and string-only.
- **`@objectstack/cli` `register-requires-name.test.ts`** —
`--invitation` lands in the body as `invitationId`, beside the declared
three.
- **UI** — `registerVerificationCallback-10893.test.tsx` pins the wire
(`invitationId` on the `/sign-up/email` body, absent when none); both
`invitationSignUpJoin-30125.test.tsx` files pin that the console
`/register` and `DefaultRegisterPage` send the redirect's invitation id.
- **Fixtures** — every suite that uses `inviteForAudienceGate` (44
files; the two SSO-provider suites keep the address match and carry no
id), the direct invitation seeds in plugin-auth, `@objectstack/verify`'s
harness, five runtime pins and the client's rotated-token suite present
the id they seed.
- **Release smoke** — `bash scripts/publish-smoke.sh` (pack mode, packed
tarballs, scaffolded project) at `83438de273`: every probe ok, including
`POST /auth/sign-up/email (invited user — presents the invitation) →
200`; lock `VERDICT command-exit 0`. Control (one-off,
`scripts/ablation-replace.mjs` deleting the body's `invitationId` on
disk): `expected HTTP 200, got 403` on that probe; restored, blob equals
HEAD's, `git diff HEAD` empty.
- **Ablation (one-off)** — the by-id probe swapped for the by-address
one on disk (`scripts/ablation-replace.mjs`, anchor 1 → 0, blob
`97171743e039` → `0eefd9e8c89d`): `presented-invitation.test.ts` 2
failed / 11 passed (the uniform refusal and the
invitee-after-an-address-only-attempt cases). Restored: blob equals
HEAD's, `git diff HEAD` empty, 13 passed.
## Acceptance notes
- `content/docs/deployment/cli.mdx`'s `os register` section does not
list `--invitation` yet; the command's own flag description and examples
do.
- `packages/qa/dogfood/test/upgrade-harness/cli-prerelease-fixture.ts`
signs an invitee up without the id against its pinned
`@objectstack/cli@18.0.0-next.5` checkout, which predates this rule, so
it is unaffected; re-pointed at a release that carries the rule, that
sign-up sends the invite's id.
- The changeset writes the request mapping as an **Upgrading.**
paragraph, not a `FROM → TO` label: measured, the label reads to
`check:adr-0087-registration` as a migration prescription (evidence
`from-to-label`) and contradicts the `no-migration-prescription`
disposition, and no metadata migrates here.
- `docs/qa/platform-checklist/areas/identity-auth.json`
`identity-auth.self-signup-gate` (revision 8) and the `qa-scratch-authz`
recipe in `areas/attachments-storage.json` (recipes carry no revision;
its provenance records the change) now present the id too.
- The five runtime pins that now present the id also import
`@objectstack/metadata` at module top, which `check:test-source-alias`
asks of every test file a change edits.
- `invitationIdOf` is exported from `@object-ui/app-shell` so the
console's `/register` reads the id with the one helper
`DefaultRegisterPage` uses.
## Verification
Final head `83438de273`. After `0729e90b58` it changes only the
changeset (grading, **Who moves.**, **Operators.**),
`scripts/publish-smoke.sh`, two checklist area files and the name of one
test case. Readings below name their head: the dogfood battery is at
`0729e90b58`, and no file it imports or reads changed after it; the
release smoke, the renamed test file and the derived families those
files reach are at `83438de273`. Every heavy run went through
`scripts/pm/os-verify-lock.sh`; each reading is the run's own verdict
line. Dependency closures were built first (`pnpm turbo run build
--concurrency=2`, 120 of 120 tasks after the merge).
<details><summary>Dogfood battery</summary>
`pnpm --filter @objectstack/dogfood test` at `0729e90b58`: `Test Files
440 passed | 3 skipped (443)`, `Tests 3644 passed | 41 skipped (3685)`,
lock `VERDICT command-exit 0`.
</details>
<details><summary>plugin-auth</summary>
`pnpm --filter @objectstack/plugin-auth typecheck && pnpm --filter
@objectstack/plugin-auth exec vitest run --maxWorkers=2` after the merge
(`412cafad64`; later commits touch plugin-auth only in one test case's
name, re-run at `83438de273`): `Test Files 246 passed (246)`, `Tests
4396 passed | 10 skipped (4406)`, `VERDICT command-exit 0`.
`presented-invitation.test.ts` alone: `Tests 13 passed (13)`.
</details>
<details><summary>spec, client, cli, verify, runtime</summary>
- `@objectstack/spec`: `gen:schema && gen:docs` regenerated
`content/docs/references/api/auth.mdx`; `check:generated` "All 14
generated artifacts are up to date"; `src/api/auth.test.ts` +
`src/type-alias-convention.pin.test.ts` 36 passed; `typecheck` exit 0
after the merge.
- `@objectstack/client`: `typecheck` exit 0; `vitest run` `Test Files 56
passed (56)`, `Tests 716 passed (716)`.
- `@objectstack/cli`: `typecheck` exit 0; `vitest run --project unit`
`Test Files 405 passed (405)`, `Tests 5724 passed (5724)` after the
merge (the integration tier is CI's).
- `@objectstack/verify`: `typecheck` exit 0; `vitest run` `Test Files 48
passed (48)`, `Tests 409 passed (409)`.
- `@objectstack/runtime`: `typecheck` exit 0; the five edited pins `Test
Files 5 passed (5)`, `Tests 80 passed (80)` at `0729e90b58` (the full
`test:repo` and runtime suites are CI's).
</details>
<details><summary>UI</summary>
- `pnpm exec vitest run packages/auth/` `Test Files 45 passed (45)`,
`Tests 407 passed (407)`; `packages/app-shell/src/console/auth/` `14
passed`, `74 passed`; `apps/console/src/pages/auth/` `31 passed`, `265
passed`.
- `pnpm turbo run type-check --filter=@object-ui/auth
--filter=@object-ui/app-shell --filter=@object-ui/console
--concurrency=2`: `Tasks: 77 successful, 77 total`, zero TS errors.
- eslint over every changed file: 0 errors.
</details>
<details><summary>Release smoke (final head)</summary>
`bash scripts/publish-smoke.sh` at `83438de273` after `pnpm turbo run
build --concurrency=2`: 61/110 pinned packages from tarballs, 0 registry
leaks; anonymous `get-session` 401; seeded admin sign-in 200; uninvited
second sign-up 403; invite-member 200; invited sign-up presenting the
invitation 200; sign-in 200; session 200; CRUD 201/200/200/200; no error
or fatal log lines after the boot. `presented-invitation.test.ts` at the
same head: `Tests 13 passed (13)`; `publish-smoke-boot-failure.test.ts`
+ `publish-smoke-port-collision.test.ts` (repo project): `Tests 24
passed (24)`; `bash -n scripts/publish-smoke.sh` exit 0.
</details>
<details><summary>Derived gate families</summary>
`node scripts/pm/dispatch-gates.mjs --commands` (no paths) derived 132
families at `412cafad64`, the same 132 at `0729e90b58`; each ran, exit
code captured before any pipe. 128 exited 0 on the first pass. Four did
not: `check:test-source-alias` (1, the five runtime pins needed a
module-top `@objectstack/metadata` import, fixed in `0729e90b58`, then
0), `check:skill-examples` and `check:dual-build-cjs-loads` (3, a
workspace not yet fully built, 0 after the full build), and `pnpm --dir
ui exec vitest run scripts/` (killed by the container's memory limit;
with `--maxWorkers=1` `Test Files 170 passed | 2 skipped`, `Tests 5021
passed | 2 skipped`). At `0729e90b58` `check:test-source-alias`,
`check:undeclared-dep-imports`, `check:cross-package-test-inputs`,
`check:issue-citations`, `check:nul-bytes`, `check:tier-file-adoption`
and `check:live-db-isolation` exit 0. `--ran` reconciliation: 132
derived, 132 run, 0 UNRUN. Changeset gates:
`check:adr-0087-registration` (`not-required
(no-migration-prescription)` accepted), `check:empty-changeset`,
`check:changeset-no-major`, `check:changeset-gate-self-tests`,
`check:changeset-fixed` exit 0. Local-only `check:dev-prereqs` exit 0
after the full build. At `83438de273` the derived set is 145 families
(the 13 new ones come from `scripts/publish-smoke.sh`); the 67 that
reach a file changed after `8c440a3f0f`, the 13 new ones among them, all
exit 0, `check:platform-checklist`, `check:bash32-floor` and
`check:scripts-symbol-anchors` included.
</details>
Line budget: 90 files, +1085 / -243 = 1328 changed lines, generated
included. `check-changeset-no-major.mjs --base origin/main --event`
against this body: exit 0.
<!-- signed: security#1 · sunhaolin · session_017xRBcT1w3zpekijraHJE4f
-->
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent b126c13 commit f127b04
90 files changed
Lines changed: 1085 additions & 243 deletions
File tree
- .changeset
- content/docs
- deployment
- references/api
- docs/qa/platform-checklist
- areas
- packages
- client/src
- cli
- src/commands
- test
- plugins/plugin-auth/src
- qa/dogfood/test
- runtime/src
- spec
- authorable-surface
- src/api
- verify/src
- scripts
- ui
- apps/console/src/pages/auth
- __tests__
- packages
- app-shell/src
- console/auth
- __tests__
- auth/src
- __tests__
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
613 | 613 | | |
614 | 614 | | |
615 | 615 | | |
616 | | - | |
617 | | - | |
618 | | - | |
| 616 | + | |
| 617 | + | |
| 618 | + | |
| 619 | + | |
| 620 | + | |
619 | 621 | | |
620 | 622 | | |
621 | 623 | | |
| 624 | + | |
622 | 625 | | |
623 | 626 | | |
624 | 627 | | |
| |||
700 | 703 | | |
701 | 704 | | |
702 | 705 | | |
| 706 | + | |
| 707 | + | |
| 708 | + | |
| 709 | + | |
| 710 | + | |
| 711 | + | |
| 712 | + | |
| 713 | + | |
| 714 | + | |
| 715 | + | |
703 | 716 | | |
704 | 717 | | |
705 | 718 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
214 | 214 | | |
215 | 215 | | |
216 | 216 | | |
| 217 | + | |
217 | 218 | | |
218 | 219 | | |
219 | 220 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
504 | 504 | | |
505 | 505 | | |
506 | 506 | | |
507 | | - | |
508 | | - | |
| 507 | + | |
| 508 | + | |
509 | 509 | | |
510 | 510 | | |
511 | | - | |
512 | | - | |
513 | | - | |
514 | | - | |
| 511 | + | |
| 512 | + | |
| 513 | + | |
| 514 | + | |
| 515 | + | |
| 516 | + | |
| 517 | + | |
| 518 | + | |
515 | 519 | | |
516 | 520 | | |
517 | 521 | | |
| |||
0 commit comments