Skip to content

Commit f127b04

Browse files
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

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
'@objectstack/spec': minor
4+
'@objectstack/client': patch
5+
'@objectstack/cli': patch
6+
'@objectstack/verify': patch
7+
'@object-ui/auth': patch
8+
'@object-ui/console': patch
9+
---
10+
11+
fix(plugin-auth)!: under invite_only, a self-serve sign-up is admitted only by the invitation it presents
12+
13+
Clause-②: yes (narrowing) — under the `invite_only` audience posture an email-and-password sign-up for an invited address is admitted only when it presents that invitation's id as `invitationId`, 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`.
14+
Metadata-protocol: added — RegisterRequest gains the optional invitationId key (packages/spec/authorable-surface/api.json)
15+
16+
<!-- adr-0087: not-required (no-migration-prescription) No key is removed, renamed or re-shaped and no stored row is rewritten: RegisterRequest gains an optional key, and a sign-up under invite_only that does not present its invitation is refused with the posture's existing code and message. -->
17+
18+
**BREAKING** accept-set narrowing at the email-and-password sign-up door (`@objectstack/plugin-auth`), under the `invite_only` audience posture, the default.
19+
20+
A sign-up under `invite_only` presents the invitation it is admitted by: the invitation's id, the last segment of the `/accept-invitation/` link the invitation mail carries, sent in the `/sign-up/email` body as `invitationId` beside `email`, `password` and `name`. The invitation must be pending, unexpired and addressed to that email. Anything else is refused `403 SELF_REGISTRATION_CLOSED` with the same message an uninvited sign-up gets, and nothing is written.
21+
22+
**Upgrading.** A client that signs an invitee up sends `invitationId` beside the `email`, `password` and `name` it already sends. The one-line fix: send the id the invitation link carries as `invitationId`.
23+
24+
**Who moves.** Nothing in this repository: the Console, the SDK, the CLI, the `@objectstack/verify` harness and the test fixtures all present the id; a client outside it that signs invitees up under `invite_only`, such as a custom front-end or a script, sends the accept link's last path segment as `invitationId`.
25+
26+
**Operators.** Deploy the Console bundle together with the server; on a deployment with no mail transport, hand the invitee the link with the invitation's **Copy Invite Link** action; give an out-of-band recovery invitation row an `id` of your own, which the sign-up sends; and review accounts created before the upgrade that are unverified and hold or accepted an invitation.
27+
28+
- **Unchanged:** the first account of a new deployment, and the operator's own provisioning (admin create, SCIM, the boot command's seed), need no invitation. The `open` and `email_domain` postures match an invitation by address as before. A magic-link or email-OTP creation, whose own flow answers from the mailbox, matches by address; an SSO provider's creation is unchanged.
29+
- **Social OAuth under `invite_only`:** a creation through a social provider is admitted by an invitation only when the provider verified the address; otherwise it is refused with the same code as an uninvited sign-up.
30+
- **No mail transport:** hand the invitee the link with the invitation's **Copy Invite Link** action; it carries the id.
31+
- `@objectstack/spec`: `RegisterRequestSchema` declares the optional `invitationId`.
32+
- `@objectstack/client`: `auth.register()` sends it through, typed by the widened `RegisterRequest`.
33+
- `@objectstack/cli`: `os register --invitation ID`.
34+
- `@objectstack/verify`: the harness's `signUp` presents the invitation it seeds.
35+
- `@object-ui/auth`: `SignUpData.invitationId`, `RegisterForm`'s `invitationId` prop and the sixth argument of the context's `signUp` send it.
36+
- `@object-ui/console`: `/register` opened from an invitation link sends that link's id with the sign-up; so does `@object-ui/app-shell`'s `DefaultRegisterPage`, whose package now also exports `invitationIdOf`.

‎content/docs/deployment/self-hosting.mdx‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -613,12 +613,15 @@ dead end this section describes.
613613

614614
**The recovery is out of band, and it is one row.** Write a pending invitation
615615
directly against the store the deployment reads, then have that person register
616-
through the ordinary sign-up endpoint — the invitation carve-out admits that
617-
one creation under every posture, so the `invite_only` default stays where it
618-
is. One `sys_invitation` row:
616+
through the ordinary sign-up endpoint, sending the row's `id` as `invitationId`
617+
beside the email — under `invite_only` a sign-up is admitted only by the
618+
invitation it presents. The invitation carve-out admits that one creation under
619+
every posture, so the `invite_only` default stays where it is. One
620+
`sys_invitation` row:
619621

620622
| Column | Value |
621623
|:---|:---|
624+
| `id` | a long random value of your own: it is what the sign-up presents |
622625
| `email` | the address you intend to administer — one the directory does **not** already hold |
623626
| `status` | `pending` |
624627
| `expires_at` | any future timestamp |
@@ -700,6 +703,16 @@ a populated deployment, posting to `/api/v1/auth/sign-up/email`:
700703
| `email_domain` | **Only inside a domain you allowlisted.** Any address outside the list answers `403 EMAIL_DOMAIN_NOT_ALLOWED`, registered or not, so a caller must already hold an address on an allowlisted domain before they learn anything. |
701704
| `open` | **Nothing, while the mail transport is live.** A registered address gets the same answer as a new sign-up, nothing is written, and the address's holder is mailed that it already has an account. Without a live transport, or with email verification declared off and auto sign-in left on, a registered address is refused explicitly, so the answer tells a caller that the address is registered. |
702705

706+
**Under `invite_only`, a sign-up is admitted only by the invitation it
707+
presents.** The sign-up sends the invitation's `id`, the last segment of its
708+
`/accept-invitation/` link, as `invitationId` beside the email: the Console's
709+
register page does this for an invitee who arrived from the link, and
710+
`os register --invitation` does it from a terminal. An address alone gets the
711+
same `403 SELF_REGISTRATION_CLOSED` as an address nobody invited. The link
712+
reaches the invitee in the invitation mail; on a deployment with no mail
713+
transport, an administrator hands it over with the invitation's **Copy Invite
714+
Link** action. The first account of a new deployment needs no invitation.
715+
703716
**What decides it on `open` and `email_domain`.** A sign-up for an address that
704717
already has an account gets the same answer as a successful new sign-up, and
705718
writes nothing, when all three of these hold:

‎content/docs/references/api/auth.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ This schema accepts one of the following structures:
214214
| **password** | `string` | ✅ | |
215215
| **name** | `string` | ✅ | |
216216
| **image** | `string` | optional | |
217+
| **invitationId** | `string` | optional | The id of the invitation this sign-up presents: the last path segment of its /accept-invitation/ link. Under the invite_only audience posture a sign-up is admitted only by the invitation it presents, pending, unexpired and addressed to this email; an address alone is refused SELF_REGISTRATION_CLOSED. The first account of a new deployment needs none, and the other postures do not read it. |
217218

218219

219220
---

‎docs/qa/platform-checklist/RUNNER.md‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -504,14 +504,18 @@ contradicts it, and correct it here when it does.
504504
`invite_only` default (`packages/plugins/plugin-auth/src/audience-posture.ts`:
505505
`resolveAudience` — undeclared ⇒ `invite_only`; the migration entry
506506
`audience-posture-default-invite-only` records the flip), and
507-
`POST /api/v1/auth/sign-up/email` for an address holding no pending invitation answers
508-
**`403 SELF_REGISTRATION_CLOSED`** (`decideAudienceAdmission` in the same file; the code
507+
`POST /api/v1/auth/sign-up/email` that presents no pending invitation — an address alone
508+
included — answers **`403 SELF_REGISTRATION_CLOSED`** (`decideAudienceAdmission` in the same file; the code
509509
is in the ADR-0112 ledger, `error-code-ledger.zod.ts`). QA run #21056 met it on every
510510
member persona it needed. Two doors mint one instead:
511-
1. **Invite, then sign up.** As admin, `POST /api/v1/auth/organization/invite-member`
512-
with `{"email": "EMAIL", "role": "member"}`; a pending, unexpired `sys_invitation`
513-
row for that address is what admits the following `sign-up/email`
514-
(`packages/plugins/plugin-auth/src/auth-manager.ts`, `hasPendingInvitationFor`).
511+
1. **Invite, then sign up presenting the invitation.** As admin,
512+
`POST /api/v1/auth/organization/invite-member` with `{"email": "EMAIL", "role": "member"}`,
513+
and keep the `id` it answers: the following `sign-up/email` sends it as `invitationId`
514+
beside the email (`{"email": "EMAIL", "password": "…", "name": "…", "invitationId": "ID"}`).
515+
Under `invite_only` a sign-up is admitted only by the invitation it presents, pending,
516+
unexpired and addressed to that email
517+
(`packages/plugins/plugin-auth/src/presented-invitation.ts`); the address alone answers
518+
the same 403.
515519
⚠️ **The sign-up does not apply the invitation's role.** It binds the new user to the
516520
Default Organization as `member` whatever role was invited, and leaves the invitation
517521
`pending` (`reconcileMembership` in

0 commit comments

Comments
 (0)