diff --git a/README.md b/README.md index a8cb2a830..8392c5e91 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,14 @@ creation does not repair existing files. The [realm guide](infra/keycloak/README.md#secret-continuity-and-recovery) owns setup, refusal, safe secret recovery and qualification details. +API startup also requires the fixed +[invitation SMTP settings and encryption key ring](docs/modules/identity/providers.md#configuration-surface-and-source-bindings). +Prepare these operator-owned values before starting the API or demo; an older +private file is not silently supplied with a replacement encryption key. +Keycloak's separate +[account-verification sender](infra/keycloak/README.md#invitation-email-and-recipient-verification) +must also work for the complete new-recipient journey. + ### 3. Prepare local HTTPS and start the applications The demo prepares a missing private 32-byte hop secret in the ignored root `.env`. diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 15f001bfe..5290d0756 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -171,6 +171,11 @@ + + + + + >Delivery: Execute the G8 delivery contract - Delivery-->>Invitee: Offer and permitted continuation - Invitee->>Web: GET inspects offer; no grant - Web->>KC: Approved sign-in/signup continuation - KC-->>Web: Bound callback under G1 bootstrap - Invitee->>Web: Confirm acceptance through admitted mutating act - Web->>API: Verify subject/email, offer and current delegation - API->>API: Reconcile membership grant and offer consumption under G8 + Web->>API: Admitted create command + API->>API: Commit offer + verifier + encrypted envelope + job + MUST audit + API-->>Admin: Queued; bounded version receipt + Job->>Job: Claim encrypted envelope and fence attempt as Sending + Job->>SMTP: Automatically submit invitation email + SMTP-->>Job: Accepted submission / definite no effect / unknown + Job->>Job: Persist provider evidence and truthful delivery state + SMTP-->>Invitee: Invitation email may arrive + Invitee->>Web: Clean GET landing; capture fragment through CSRF POST + Web->>KC: Bound sign-in/signup and fixed VERIFY_EMAIL continuation + KC-->>Web: Same-subject callback under state/nonce/PKCE + Invitee->>Web: Explicit acceptance confirmation + Web->>API: Fresh UserInfo and exact stored-offer entry + API->>API: Recheck delegation; commit offer + Membership + plural MUST audit + API-->>Web: Original Membership outcome; separate session admission remains ``` -Text fallback: an authorized admin creates a durable offer and delivery intent; -the invitee inspects it without a write, completes the approved identity flow and -confirms acceptance. The API verifies current identity/delegation and produces -the single authoritative grant/offer outcome. Exact routes and atomicity belong -to G8; this diagram admits no direct token callback to the API or multi-root write. - -Edge cases: - -- **Invitee uses a different email at Keycloak signup.** The invitation token is bound to the email; mismatched signup is rejected. -- **Invitation expires.** Default TTL 14 days, configurable per tenant. -- **Invitation revoked.** Tenant admin can revoke; accepting a revoked invitation is rejected. -- **Races and delivery failure.** G8 specifies accept/revoke/expiry/resend and - existing-member outcomes, one-way token storage, provider idempotency/ - reconciliation and truthful delivery state. Full notification orchestration - remains Phase 08a; the usable Phase 03 handoff cannot depend on it silently. +Text fallback: creation atomically queues an encrypted automatic SMTP submission. +The worker records provider evidence; accepted SMTP submission does not prove +inbox delivery. The invitee opens the message, completes the bound provider flow +and explicitly accepts. One database transaction consumes the offer with its +Membership result and required audit; native session admission is a separate act. + +- The offer fixes recipient email, stored inviter, organization, one Role and its + version. Email matches the fresh provider observation; it never merges Users. +- Creation stages generation 1. Reissue invalidates the old code and stages a new + generation/job with the current tenant TTL: default 14 days, bounded 1–30 days. + Revoke, reissue and acceptance invalidate old codes. Prepare refuses obsolete + work before its plan commits; already-prepared mail may still arrive. Provider + recovery can fence an unclaimed external attempt; business withdrawal itself + adds no provider-table cancellation writer. +- `IEmailProvider` uses the bounded MailKit/MimeKit adapter. Private encrypted + delivery custody is not a notification-module root. The + [provider standard](../standards/20-infrastructure-stack.md) owns its boundary; + the [database standard](../standards/05-database.md) owns exact function/grant, + attempt and reservation contracts. No link-read or manual-handoff API is added. +- Pending inspection and acceptance each fetch fresh, bounded UserInfo for the + exact verified subject. New users use Keycloak registration and its fixed + `kc_action=VERIFY_EMAIL` continuation. Existing Mailpit qualifies development + verification; production uses configured provider SMTP. Actual nonseed + verification is required; a fixture-set verified flag is insufficient. +- Exact offer-derived entry admits a first Membership without a general tenant + selector, directory, User reader or public write. The + [security standard](../standards/11-security.md) owns the native continuation, + clean fragment landing, CSRF, verified subject and confined scope contract. +- Acceptance creates a Membership when absent or after Revoked history. An + existing Active Membership consumes the offer as `already_member` without + changing Roles/profile. Suspended conflicts. Fresh User-before-Tenant fences, + current inviter delegation and Role version checks prevent stale grants. +- Accepted-result recovery verifies the same Active actor, consumed verifier and + original Active Membership before Pending-only checks. It needs no current + email or inviter authority and grants nothing. A Pending command that loses + the acceptance race returns conflict, followed by this separate Off result read. +- UI distinguishes queued, submission accepted, failed, uncertain, expired and + revoked. SMTP final `250` means accepted submission. An ambiguous send remains + uncertain and retains its reservation; it is not blindly retried. ## SSO and Federation diff --git a/docs/architecture/15-event-and-outbox.md b/docs/architecture/15-event-and-outbox.md index 5ec28b4a0..f21f894a3 100644 --- a/docs/architecture/15-event-and-outbox.md +++ b/docs/architecture/15-event-and-outbox.md @@ -900,3 +900,21 @@ liveness-only, with Phase 11 owning readiness exposure and dashboards. - Nexora reference: `Nexora/docs/decisions/0005-transactional-outbox.md`, `Nexora/docs/decisions/0011-outbox-service-atomicity.md`, `Nexora/docs/decisions/0010-notification-delivery-kafka.md`. + + +## Invitation email first consumer + +**Derives from:** [ADR-0093](../decisions/0093-verified-invitation-acceptance-and-handoff.md). +P03-4 owns the minimum automatic invitation SMTP slice. Identity stages its +encrypted provider custody and identifier-only typed job on the same ordinary +Invitation/audit transaction; no direct business PublishAsync or raw email/token +event is introduced. The two exact external definitions reuse prepare/execute/ +finalize and known-outcome/quiescence ownership. A named private provider source +is the only added external custody exception; ordinary UoW/tenant access remains +denied. SMTP response loss remains Unknown with retained occupancy. + +[Database](../standards/05-database.md#invitation-smtp-provider-storage) owns exact +storage/functions/source bounds; [Infrastructure](../standards/20-infrastructure-stack.md#bounded-invitation-smtp-consumer) +owns runtime composition. Phase 08a later supplies full notification orchestration +without a competing sender/retry contract. Acceptance supplies no delivered job +or SMTP proof; the [Phase 03 Status](../roadmap/phase-03-identity-admin.md) owns it. diff --git a/docs/architecture/23-data-protection.md b/docs/architecture/23-data-protection.md index 2e24ef90d..96649d441 100644 --- a/docs/architecture/23-data-protection.md +++ b/docs/architecture/23-data-protection.md @@ -217,10 +217,11 @@ values can still identify a person; absence of free text is no erasure exemption The inventory records subject locators and existing constraints. It grants no erasure authority and selects no retention period or deletion order. -The ten first-party tables are in LearnStack's database. Canonical DDL and grants +The twelve first-party tables are in LearnStack's database. Canonical DDL and grants remain in Database Standards: [Outbox](../standards/05-database.md#outbox), [Inbox and delivery ledger](../standards/05-database.md#inbox), and -[Background jobs](../standards/05-database.md#background-jobs). +[Background jobs](../standards/05-database.md#background-jobs), including +[invitation SMTP custody](../standards/05-database.md#invitation-smtp-provider-storage). | Store | Copies and subject linkage | Existing constraint | @@ -235,6 +236,8 @@ remain in Database Standards: [Outbox](../standards/05-database.md#outbox), | `background_job_recurrences` | Finite definition/revision/manifest/target/schedule/recipient-policy metadata; no user or arbitrary parameters | Infrastructure control; no ordinary app access | | `background_job_occurrences` | Manifest/slot identity and frozen tenant/org sweep bounds/checkpoints; no person column or arbitrary payload | Preserve occurrence history and monotonic progress | | `background_job_recipients` | Immutable tenant/org route, occurrence/manifest/target and accepted child-job receipt | Recipient is a tenant/org route, not a person; reciprocal job links constrain future purge order | +| `invitation_email_envelopes` | Encrypted recipient/subject/body/fragment link; tenant/org, Invitation/generation/job references, key version, nonce/tag and creation time | Private authenticated custody; application has only immutable-column INSERT and the two fixed provider functions, no directory/read/rewrite/purge grant. Encryption is not anonymisation | +| `invitation_email_attempt_receipts` | Envelope-to-Invitation/generation linkage plus job/attempt/operation/claim/fence/receipt references, finite submission outcome and timestamps | Terminal evidence is immutable; only the fixed claim/complete functions write it. Unknown does not establish quiescence or release the job reservation | The complete Platform migration census also includes this explicit scope exclusion. @@ -279,6 +282,7 @@ govern personal data; exclusion here grants no erasure exemption. | `membership_role_assignments` | Synchronous authorization links; exclusion from the asynchronous inventory grants no personal-data retention or erasure exemption | | `roles` | Synchronous tenant-owned authority templates, not queued payloads | | `role_permission_grants` | Synchronous explicit authority definitions, not delivery or diagnostic copies | +| `invitations` | Synchronous scoped Identity offer and acceptance result, including sensitive recipient/verifier and job linkage; the invitation inventory below owns fulfillment constraints | LearnStack job-table no-delete triggers also bind the owner; BYPASSRLS is not @@ -319,6 +323,7 @@ The inventory also covers these distinct copies and custody boundaries: | External diagnostics and local error files | Log, trace, Sentry and `LocalFileErrorTracker` JSON archives can retain linkable exception/context copies; source-row deletion does not erase them | | Database, WAL and backups | Retained copies need backup and post-restore reconciliation; source-row deletion alone does not remove them | | Native BFF session custody | `InMemoryBffSessionStore` is volatile process-local custody under ADR-0070, not a durable ledger. Local invalidation does not erase provider events or diagnostic archives | +| SMTP server, recipient mailbox and provider backups | Recipient address and delivered link/body are external personal/credential copies. Submission acceptance is not inbox delivery or erasure; invalidating a code cannot retract prepared mail or remove provider/backup copies | ADR-0072/0073 add identity linkage to the existing `audit_log` boundary above, @@ -330,6 +335,59 @@ data. Existing SecurityEvent retention applies, and metadata stays outside the six-column UPDATE grant. P03-10 locates and reports retained linkage under the same inventory boundary. +### Invitation offer and automatic email custody + +[ADR-0093](../decisions/0093-verified-invitation-acceptance-and-handoff.md) +owns this P03-4 inventory. The offer and both delivery-store migrations now land +in the complete table census above. Identity owns the offer; common Infrastructure +owns the two private SQL-only delivery stores. Administrator, recipient and native +consumer activation follows the [phase Status](../roadmap/phase-03-identity-admin.md); +the selected copy/fulfillment contract below does not claim every consumer or +production retention workflow is implemented. +Canonical schema, grants and source/attempt guards remain in +[Database Standards](../standards/05-database.md); provider implementation stays +in [Infrastructure Standards](../standards/20-infrastructure-stack.md). + +| Store/copy | Classification and subject locator | Custody / fulfillment constraint | +|---|---|---| +| `invitations` | Recipient email is PII-Identity; inviter/acceptor UserId, original MembershipId, tenant/org, Role/version, acceptance disposition and timestamps retain person/relationship linkage. Current and consumed token verifiers are sensitive credential material, even one-way | Identity's scoped root; immutable recipient/inviter/scope/Role pin. P03-10 locates by authorized recipient/actor/Membership relation, including expired/terminal history. Verifiers are never exported | +| `invitation_email_envelopes` | The first-party row above owns the physical copy/locator census; expiry belongs to the linked Invitation generation | AES-256-GCM bounds and key retention follow Database Standards. P03-10 locates authorized subject linkage; Phase 11 owns provider/backup retention and purge. No generic module/ExternalScope SQL access | +| `invitation_email_attempt_receipts` | The first-party row above owns direct references and the indirect envelope-to-Invitation locator; no recipient, raw SMTP text, body or token | Exact claim/complete functions do not read User/module tables. P03-10 follows authorized linkage; no generic receipt editor, status payload channel or ordinary purge grant | +| Existing authoritative job/attempt/reservation tables | Identifier-only invitation/envelope/generation references and existing causal actor; trace/failure/control links remain linkable | Existing typed external-job ownership, six-attempt budget and unresolved reservation rules continue. Erasure cannot prove SMTP quiescence or release uncertain occupancy | +| Native continuation and browser message link | Raw code/link, captured offer and same-subject verification continuation; volatile credentials under existing native owner custody | Fragment-only link; clean GET landing removes fragment before exact-Origin/CSRF POST. Bounded existing owners and teardown retain no durable link-read or handoff copy. History/BFCache/late-response proof is required | +| Protected HTTP replay | Typed original Invitation/policy target, bounded nonsecret response and current result linkage | No email link, raw code, verifier, ciphertext or provider payload in stored replay bytes; authority-checked MUST receipt read before disclosure | +| SMTP server, recipient mailbox and provider backups | The external custody row above owns this copy boundary | Revoke/reissue/accept invalidates codes; Prepare refuses obsolete generations, while prepared mail may still arrive. Recovery fencing is a separate provider action. Phase 11 qualifies retained-provider/backup reconciliation | + +Creation atomically stages generation 1, its verifier, encrypted envelope and +identifier-only job in the ordinary business unit of work. `IInvitationEmailStager` +is enrolled custody, not a new aggregate. Reissue replaces the usable code with a +new generation/token/job and current TTL. Before any socket, a provider-attempt +CAS records Sending; recovery's CancelledBeforeSend CAS fences an old worker. +Definite temporary no-effect can retry within the existing budget. Ambiguous +Sending/Unknown retains its reservation and cannot be blindly retried. Stable +terminal evidence reproduces the same result after restart; no arbitrary SMTP +exactly-once claim follows. The canonical database contract owns the exact state +machine and the dedicated four-slot/five-second source limited to two functions. + +Classify all mapped properties, SQL-only fields and volatile copies before their +first writer. Audit snapshots preserve redaction markers for recipient email and +token verifiers. No raw email, link, code, verifier, ciphertext, provider body or +SMTP exception text enters logs, spans, Sentry/local archives, audit values, +validation attempted values, bind-value logging, analytics or proof artifacts. +Key IDs and immutable receipt/job references are classified controls/linkage; +they are not safe high-cardinality diagnostic labels. Test each actual sink with +positive canaries; model classification alone establishes no secrecy guarantee. + +Diagnostic exclusion does not remove authorized access rights. P03-10's Identity +and common Infrastructure participants locate/report recipient email, +relationships, envelope and attempt/job linkage under subject/purpose authority. +Authorized recipient-email export is allowed; credentials, verifiers, encrypted +credential payloads and secret keys never enter DSAR artifacts. P03-10 also owns +closure fencing so delayed jobs and pending offers cannot recreate admission for +a Closing/Closed User. Phase 11 owns retained-copy/purge enforcement and key/ +backup/provider retention qualification. This inventory grants no early DELETE, +metadata UPDATE, ciphertext export or complete anonymisation claim. + ### Accepted authorization evidence linkage Jointly Accepted [ADR-0076](../decisions/0076-live-scoped-authorization.md) / diff --git a/docs/decisions/0003-tenant-isolation-defense-in-depth.md b/docs/decisions/0003-tenant-isolation-defense-in-depth.md index cf82c534e..1932fcc46 100644 --- a/docs/decisions/0003-tenant-isolation-defense-in-depth.md +++ b/docs/decisions/0003-tenant-isolation-defense-in-depth.md @@ -549,3 +549,22 @@ ADR-0078 and its canonical storage contract; it is not an ADR-0041 erratum or a historical-body rewrite. P03-3's actual app-role/applied-policy/function/privilege and foreign-subject dirty controls must qualify it before consumer activation. Acceptance and document checks are not isolation proof. + + +## Amendment 11 — Invitation entry and private SMTP owner functions (2026-11-04) + +Accepted [ADR-0093](0093-verified-invitation-acceptance-and-handoff.md) records the new +bounded invitation decision selected by the maintainer. ADR-0093 adds the exact +offer-entry function and two invitation SMTP provider functions to the eight-function +historical closure: eleven in the current canonical census. Named role-qualified +FORCE-RLS owner visibility covers only their fixed offer/User or provider/job/attempt +projections. Four login roles, NOBYPASSRLS, ordinary tenant/org RLS and the existing +physical context writers remain. No general app directory, raw SMTP plaintext projection +or owner credential enters runtime. + +This dated Amendment links the successor; it does not rewrite this record's historical +Decision, Status, Date, Deciders or earlier rationale. Canonical current +source/operation/permission/audit/storage details live in Standards +01/04/05/07/11/18/19/20/21 and the Identity/Tenancy specifications. The [Phase 03 +Status](../roadmap/phase-03-identity-admin.md) owns implementation and source-bound +qualification; acceptance supplies no runtime proof. diff --git a/docs/decisions/0036-tenant-resolution-trusted-inputs.md b/docs/decisions/0036-tenant-resolution-trusted-inputs.md index 6b5373e57..c0f52f66a 100644 --- a/docs/decisions/0036-tenant-resolution-trusted-inputs.md +++ b/docs/decisions/0036-tenant-resolution-trusted-inputs.md @@ -1354,3 +1354,21 @@ allocates. [Security Standards](../standards/11-security.md) owns exact placemen source features, statuses and current caller ceilings. Other source, assertion, host-budget and public rules remain; the historical body/metadata are preserved. Acceptance changes the contract, not runtime or matrix-test qualification. + + +### Amendment 18 — Exact offer-derived nonmember invitation scope (2026-11-04) + +Accepted [ADR-0093](0093-verified-invitation-acceptance-and-handoff.md) records the new +bounded invitation decision selected by the maintainer. ADR-0093 adds +HostAndInvitationOffer only for verified native inspection, acceptance and same-actor +result reading. The actual host and exact stored offer agree on scope before the one-use +witness is consumed; the three global-self descriptors and legacy matrix rows retain +their behavior. Platform/unknown hosts, browser selectors, generic unresolved operations +and ordinary protected/AccountSite admission gain no authority. + +This dated Amendment links the successor; it does not rewrite this record's historical +Decision, Status, Date, Deciders or earlier rationale. Canonical current +source/operation/permission/audit/storage details live in Standards +01/04/05/07/11/18/19/20/21 and the Identity/Tenancy specifications. The [Phase 03 +Status](../roadmap/phase-03-identity-admin.md) owns implementation and source-bound +qualification; acceptance supplies no runtime proof. diff --git a/docs/decisions/0040-ambient-unit-of-work.md b/docs/decisions/0040-ambient-unit-of-work.md index 4b918ff6a..d785be560 100644 --- a/docs/decisions/0040-ambient-unit-of-work.md +++ b/docs/decisions/0040-ambient-unit-of-work.md @@ -924,3 +924,22 @@ this amendment discloses it without independently replacing this decision. Historical Accepted bytes, status, date, Deciders and prior amendments remain unchanged. Acceptance registers the new controls; Step 3 still owes their actual owner, isolation, write-refusal and cleanup proof. + + +### Amendment 19 — Operation-bound invitation and private provider ownership (2026-11-04) + +Accepted [ADR-0093](0093-verified-invitation-acceptance-and-handoff.md) records the new +bounded invitation decision selected by the maintainer. ADR-0093 adds +InvitationEntryScope as the eleventh logical tenant setter, on physically ReadOnly +inspection/result or ordinary ReadWrite acceptance owners, reusing the same four +physical context writers. The preliminary lookup closes first; global and tenant owners +never join/retarget. Its separate four-slot SMTP provider source can execute only the +two named custody functions under validated external receipt authority, without UoW, +tenant GUC or module repository access. + +This dated Amendment links the successor; it does not rewrite this record's historical +Decision, Status, Date, Deciders or earlier rationale. Canonical current +source/operation/permission/audit/storage details live in Standards +01/04/05/07/11/18/19/20/21 and the Identity/Tenancy specifications. The [Phase 03 +Status](../roadmap/phase-03-identity-admin.md) owns implementation and source-bound +qualification; acceptance supplies no runtime proof. diff --git a/docs/decisions/0042-tenant-provisioning-cross-aggregate-transaction.md b/docs/decisions/0042-tenant-provisioning-cross-aggregate-transaction.md index e5b322a29..0ce54a866 100644 --- a/docs/decisions/0042-tenant-provisioning-cross-aggregate-transaction.md +++ b/docs/decisions/0042-tenant-provisioning-cross-aggregate-transaction.md @@ -318,3 +318,22 @@ than write the two aggregates itself. - [Database Standards](../standards/05-database.md) - [Tenancy module spec](../modules/tenancy/README.md) - [Phase 02a: Platform Kernel, Multi-Tenancy, Organization, and Foundation Sockets](../roadmap/phase-02a-kernel-tenancy.md) + + +### Amendment 2 — Enumerated invitation acceptance exception (2026-11-04) + +Accepted [ADR-0093](0093-verified-invitation-acceptance-and-handoff.md) records the new +bounded invitation decision selected by the maintainer. ADR-0093 supersedes only this +record's one-exception closure by adding exactly AcceptInvitationCommandHandler: one +Identity Invitation and at most one Membership with contained children, consumed +atomically with plural MUST audit. ProvisionTenantCommandHandler retains its +Tenant/default Organization exception. The current canonical two-name rule is +Cross_Aggregate_Writes_Are_Confined_To_Named_Exceptions. Cross-module writes and all +other cross-root writes remain forbidden. + +This dated Amendment links the successor; it does not rewrite this record's historical +Decision, Status, Date, Deciders or earlier rationale. Canonical current +source/operation/permission/audit/storage details live in Standards +01/04/05/07/11/18/19/20/21 and the Identity/Tenancy specifications. The [Phase 03 +Status](../roadmap/phase-03-identity-admin.md) owns implementation and source-bound +qualification; acceptance supplies no runtime proof. diff --git a/docs/decisions/0047-transactional-background-job-runtime.md b/docs/decisions/0047-transactional-background-job-runtime.md index 216d1cf4d..43c89278b 100644 --- a/docs/decisions/0047-transactional-background-job-runtime.md +++ b/docs/decisions/0047-transactional-background-job-runtime.md @@ -1010,3 +1010,23 @@ P03-2 implements/proves these tracked lanes and updates actual resource/caller censuses before activation. This acceptance supplies no completed shutdown, progress, cancellation or production-capacity evidence. Other decisions and the historical Accepted body/status/date remain unchanged. + + +### Amendment 6 — Bounded SMTP custody for the first production job consumer (2026-11-04) + +Accepted [ADR-0093](0093-verified-invitation-acceptance-and-handoff.md) records the new +bounded invitation decision selected by the maintainer. ADR-0093 registers the two exact +Identity invitation-email external definitions on the existing external pool and adds +only the attributed InvitationSmtpExecutionReceipt and four-slot provider-only +connector. It stages encrypted custody and identifier-only jobs atomically; the two +fixed custody functions expose no module data/UoW/tenant setter to external execution. +Final SMTP 250 is submission acceptance; lost reply is Unknown. Existing six-attempt +budgets, known-effect/quiescence gates, retained occupancy and checkpoint/late-evidence +ownership remain. A reissued invitation cannot release an older unknown reservation. + +This dated Amendment links the successor; it does not rewrite this record's historical +Decision, Status, Date, Deciders or earlier rationale. Canonical current +source/operation/permission/audit/storage details live in Standards +01/04/05/07/11/18/19/20/21 and the Identity/Tenancy specifications. The [Phase 03 +Status](../roadmap/phase-03-identity-admin.md) owns implementation and source-bound +qualification; acceptance supplies no runtime proof. diff --git a/docs/decisions/0053-trusted-public-server-rendering.md b/docs/decisions/0053-trusted-public-server-rendering.md index eb7e4b758..98b457c4f 100644 --- a/docs/decisions/0053-trusted-public-server-rendering.md +++ b/docs/decisions/0053-trusted-public-server-rendering.md @@ -440,3 +440,21 @@ No public cache, public bootstrap, visitor/peer quota, socket-provenance or host resolution rule is broadened. The native method allowlist is amended separately in ADR-0054. Original body, metadata and earlier delivery notes remain historical; P03-3 still owes native/API/browser, cleanup and containment proofs before delivery. + + +### Amendment 13 — Closed invitation caller and mail-link entry (2026-11-04) + +Accepted [ADR-0093](0093-verified-invitation-acceptance-and-handoff.md) records the new +bounded invitation decision selected by the maintainer. ADR-0093 extends only the exact +native server invitation operations enumerated in Standards 04/07: administration, +policy, inspection, acceptance and same-actor result recovery. No generic proxy, +arbitrary URL/header/bearer, browser API credential or RSC operation capability is +added. Email codes stay in fragment/private custody; clean GET and strict secret-body +POST have separate roles. + +This dated Amendment links the successor; it does not rewrite this record's historical +Decision, Status, Date, Deciders or earlier rationale. Canonical current +source/operation/permission/audit/storage details live in Standards +01/04/05/07/11/18/19/20/21 and the Identity/Tenancy specifications. The [Phase 03 +Status](../roadmap/phase-03-identity-admin.md) owns implementation and source-bound +qualification; acceptance supplies no runtime proof. diff --git a/docs/decisions/0054-bounded-public-renderer-admission.md b/docs/decisions/0054-bounded-public-renderer-admission.md index 253c48f16..685e66247 100644 --- a/docs/decisions/0054-bounded-public-renderer-admission.md +++ b/docs/decisions/0054-bounded-public-renderer-admission.md @@ -432,3 +432,21 @@ Public GET/HEAD behavior, raw signed-target identity, anonymous accounting and visitor/peer numeric quotas remain unchanged. The original body/metadata and prior amendments are preserved. P03-3 must prove real native admission, no-script/history, Origin/CSRF, late-response and actual-cleanup behavior before delivery. + + +### Amendment 12 — Bounded invitation native routes and continuation (2026-11-04) + +Accepted [ADR-0093](0093-verified-invitation-acceptance-and-handoff.md) records the new +bounded invitation decision selected by the maintainer. ADR-0093 adds only the finite +invitation document/POST set in Standard 07, plus a host/browser-bound preauthentication +continuation within the existing total/pending/source/five-minute caps and eight +verified-subject owner ceiling. Exact Origin/CSRF, immutable response/publication and +actual cleanup retain their contracts. It neither gives public reads credentials nor +changes existing deadlines or owner capacities. + +This dated Amendment links the successor; it does not rewrite this record's historical +Decision, Status, Date, Deciders or earlier rationale. Canonical current +source/operation/permission/audit/storage details live in Standards +01/04/05/07/11/18/19/20/21 and the Identity/Tenancy specifications. The [Phase 03 +Status](../roadmap/phase-03-identity-admin.md) owns implementation and source-bound +qualification; acceptance supplies no runtime proof. diff --git a/docs/decisions/0064-tenant-api-authentication-boundary.md b/docs/decisions/0064-tenant-api-authentication-boundary.md index 9bf44aa91..8d52a5172 100644 --- a/docs/decisions/0064-tenant-api-authentication-boundary.md +++ b/docs/decisions/0064-tenant-api-authentication-boundary.md @@ -685,3 +685,21 @@ ADR-0036's own Amendment extends the matrix/source ceiling. Standards 04/11 and the source/SDK/surface censuses adopt these exact exceptions before dependent code. Original body/status/date and earlier amendments remain historical; contract acceptance supplies no runtime, OpenAPI/SDK or native/browser qualification. + + +### Amendment 7 — Purpose-confined invitation HTTP entry (2026-11-04) + +Accepted [ADR-0093](0093-verified-invitation-acceptance-and-handoff.md) records the new +bounded invitation decision selected by the maintainer. ADR-0093 adds exact native +invitation inspection/acceptance/result descriptors after stock credential, host and +issuer/sub quota admission. Their offer-derived scope/witness is separate from ordinary +Membership admission and the three global-self descriptors. Invalid-present credentials +keep 401, missing/foreign/invalid link admission is masked; no ordinary route becomes a +User allocator or gets generic pre-Membership authority. + +This dated Amendment links the successor; it does not rewrite this record's historical +Decision, Status, Date, Deciders or earlier rationale. Canonical current +source/operation/permission/audit/storage details live in Standards +01/04/05/07/11/18/19/20/21 and the Identity/Tenancy specifications. The [Phase 03 +Status](../roadmap/phase-03-identity-admin.md) owns implementation and source-bound +qualification; acceptance supplies no runtime proof. diff --git a/docs/decisions/0070-owned-tenant-bff-sessions.md b/docs/decisions/0070-owned-tenant-bff-sessions.md index 4fa3b2e9b..da17e6f9f 100644 --- a/docs/decisions/0070-owned-tenant-bff-sessions.md +++ b/docs/decisions/0070-owned-tenant-bff-sessions.md @@ -653,3 +653,22 @@ navigate the accepted boundary; ADR-0083 receives its bounded supersession amendment while remaining Accepted. This amendment changes none of this record's Decision, historical body, status or date. Acceptance is not executed native or hosted qualification. + + +### Amendment 8 — Invitation continuation and fixed email verification action (2026-11-04) + +Accepted [ADR-0093](0093-verified-invitation-acceptance-and-handoff.md) records the new +bounded invitation decision selected by the maintainer. ADR-0093 adds only the fixed +invitation continuation/capture and kc_action=VERIFY_EMAIL purpose under existing +state/nonce/PKCE/subject/host fences. It includes invitation owners in existing +pending/source/global and verified-subject ceilings. Membership acceptance and +post-acceptance session publication remain separate; a different live account cannot be +replaced by email/display-label equality. Actual cleanup and provider/cookie uncertainty +remain. + +This dated Amendment links the successor; it does not rewrite this record's historical +Decision, Status, Date, Deciders or earlier rationale. Canonical current +source/operation/permission/audit/storage details live in Standards +01/04/05/07/11/18/19/20/21 and the Identity/Tenancy specifications. The [Phase 03 +Status](../roadmap/phase-03-identity-admin.md) owns implementation and source-bound +qualification; acceptance supplies no runtime proof. diff --git a/docs/decisions/0073-scoped-membership-and-identity-data-foundation.md b/docs/decisions/0073-scoped-membership-and-identity-data-foundation.md index fe6317515..fa55572ea 100644 --- a/docs/decisions/0073-scoped-membership-and-identity-data-foundation.md +++ b/docs/decisions/0073-scoped-membership-and-identity-data-foundation.md @@ -372,3 +372,21 @@ the bounded origin contract; this amendment is its navigation/disclosure, not an independent replacement of this decision. Historical Accepted bytes, status, date, Deciders and prior amendments remain unchanged. Actual P03-3 Step 3 proof remains required. + + +### Amendment 6 — Invitation lifecycle and successor navigation (2026-11-04) + +Accepted [ADR-0093](0093-verified-invitation-acceptance-and-handoff.md) records the new +bounded invitation decision selected by the maintainer. ADR-0093 supplies the separately +gated P03-4 offer/acceptance/email contract. It preserves this foundation's compatible +Role scopes, Active/Suspended natural uniqueness, terminal Revoked predecessor and fresh +successor, current eligibility and total-admin guards. The added same-module two-root +exception belongs to ADR-0093/0042 successor navigation, not an implicit foundation +permission. + +This dated Amendment links the successor; it does not rewrite this record's historical +Decision, Status, Date, Deciders or earlier rationale. Canonical current +source/operation/permission/audit/storage details live in Standards +01/04/05/07/11/18/19/20/21 and the Identity/Tenancy specifications. The [Phase 03 +Status](../roadmap/phase-03-identity-admin.md) owns implementation and source-bound +qualification; acceptance supplies no runtime proof. diff --git a/docs/decisions/0076-live-scoped-authorization.md b/docs/decisions/0076-live-scoped-authorization.md index 865acc0a5..c828a1a33 100644 --- a/docs/decisions/0076-live-scoped-authorization.md +++ b/docs/decisions/0076-live-scoped-authorization.md @@ -487,3 +487,21 @@ ADR-0081 supplies the additional authority; this amendment discloses it without independently replacing this decision. Historical Accepted bytes, status, date, Deciders and prior amendments remain unchanged. Acceptance alone qualifies no new source or runtime control. + + +### Amendment 4 — Exact invitation recipient and stored-inviter consumers (2026-11-04) + +Accepted [ADR-0093](0093-verified-invitation-acceptance-and-handoff.md) records the new +bounded invitation decision selected by the maintainer. ADR-0093 adds only the closed +InspectInvitationQuery, AcceptInvitationCommand and GetOwnInvitationResultQuery +admission, with an invocation-bound offer source rather than a fabricated HTTP candidate +for the stored inviter. Separate same-scope current inviter delegation and compatible +whole-Role pin checks remain mandatory. Ordinary default-deny policies, committed +per-act revocation and the existing global-self/native Membership paths remain. + +This dated Amendment links the successor; it does not rewrite this record's historical +Decision, Status, Date, Deciders or earlier rationale. Canonical current +source/operation/permission/audit/storage details live in Standards +01/04/05/07/11/18/19/20/21 and the Identity/Tenancy specifications. The [Phase 03 +Status](../roadmap/phase-03-identity-admin.md) owns implementation and source-bound +qualification; acceptance supplies no runtime proof. diff --git a/docs/decisions/0078-native-session-scope-and-self-entry.md b/docs/decisions/0078-native-session-scope-and-self-entry.md index 5e9eda7fe..89178ae57 100644 --- a/docs/decisions/0078-native-session-scope-and-self-entry.md +++ b/docs/decisions/0078-native-session-scope-and-self-entry.md @@ -514,3 +514,21 @@ extension is now explicit in Standard05 remains the sole canonical policy/function owner. No additional function, projection, caller or grant is selected, and this ADR's Accepted body, Date and Deciders remain unchanged. Actual isolation proof remains pending. + + +### Amendment 2 — Bounded invitation entry without Membership (2026-11-04) + +Accepted [ADR-0093](0093-verified-invitation-acceptance-and-handoff.md) records the new +bounded invitation decision selected by the maintainer. ADR-0093 adds a separate +exact-purpose offer-entry function/origin and eleventh logical setter for +first-Membership acceptance; it does not add a global-self descriptor or bypass opt-in +creation budgets. Native verification/capture uses the existing bounded owners and exact +subject continuity. Current provider email is a fresh per-Pending-act UserInfo +observation, not an identity merge or provider/DB atomicity claim. + +This dated Amendment links the successor; it does not rewrite this record's historical +Decision, Status, Date, Deciders or earlier rationale. Canonical current +source/operation/permission/audit/storage details live in Standards +01/04/05/07/11/18/19/20/21 and the Identity/Tenancy specifications. The [Phase 03 +Status](../roadmap/phase-03-identity-admin.md) owns implementation and source-bound +qualification; acceptance supplies no runtime proof. diff --git a/docs/decisions/0079-bounded-identity-administration.md b/docs/decisions/0079-bounded-identity-administration.md index 77358ecb3..f0c66db2b 100644 --- a/docs/decisions/0079-bounded-identity-administration.md +++ b/docs/decisions/0079-bounded-identity-administration.md @@ -656,3 +656,23 @@ owns the canonical completion, marker, runner and recovery contract. All other accepted administration, pristine-resume, protected replay and audit decisions remain. Historical Accepted bytes, status, date, Deciders and prior amendments remain unchanged; acceptance is not P03-3 Step 3 qualification. + + +### Amendment 4 — Invitation policy authority and typed original receipts (2026-11-04) + +Accepted [ADR-0093](0093-verified-invitation-acceptance-and-handoff.md) records the new +bounded invitation decision selected by the maintainer. ADR-0093 explicitly expands +Tenant tenancy.setting.write beyond branding.theme to the exact orgless +identity.invitation.ttl_days setting only. Existing holders gain that operation; v1 +without the pair keeps the 14-day default and must explicitly adopt v2 to obtain the +pair. No new registry pair/release, automatic grant or generic settings authority is +selected. Protected original-target receipts gain Invitation and that exact +TenantSetting, with fresh scoped MUST disclosure and no raw email code/message in replay +storage. + +This dated Amendment links the successor; it does not rewrite this record's historical +Decision, Status, Date, Deciders or earlier rationale. Canonical current +source/operation/permission/audit/storage details live in Standards +01/04/05/07/11/18/19/20/21 and the Identity/Tenancy specifications. The [Phase 03 +Status](../roadmap/phase-03-identity-admin.md) owns implementation and source-bound +qualification; acceptance supplies no runtime proof. diff --git a/docs/decisions/0093-verified-invitation-acceptance-and-handoff.md b/docs/decisions/0093-verified-invitation-acceptance-and-handoff.md new file mode 100644 index 000000000..81358bc5f --- /dev/null +++ b/docs/decisions/0093-verified-invitation-acceptance-and-handoff.md @@ -0,0 +1,561 @@ +# ADR-0093: Verified Invitation Acceptance and Durable Email Handoff + +## Status + +Accepted + +**Date:** 2026-11-04 +**Deciders:** @cemil + +The maintainer selects automatic invitation email, authorizes the verified review +corrections, accepts this revised decision and directs implementation. This record +replaces its unaccepted manual-sharing proposal. The [Phase 03 Status](../roadmap/phase-03-identity-admin.md) +owns implementation and qualification; no runtime behavior is claimed here. + +**Bounded successor decisions:** + +- [ADR-0003](0003-tenant-isolation-defense-in-depth.md)'s eight-definer closure gains + the exact offer-entry function and two SMTP custody functions: eleven in total. + Their named FORCE-RLS owner visibility is not ordinary application isolation. +- [ADR-0042](0042-tenant-provisioning-cross-aggregate-transaction.md)'s one-exception + clause gains exactly `AcceptInvitationCommandHandler`, writing Invitation and + Membership inside Identity. Provisioning remains valid; cross-module writes + remain forbidden. The literal allowlist has exactly those two handlers. +- [ADR-0047](0047-transactional-background-job-runtime.md)'s external-provider + boundary gains only the named encrypted SMTP custody connector and attributed + execution receipt below. Ordinary external application DB/tenant access remains + denied. Unknown effects, retained occupancy and six-attempt rules are unchanged. +- [ADR-0079](0079-bounded-identity-administration.md)'s `tenancy.setting.write` + expands from `branding.theme` to also cover only `identity.invitation.ttl_days`. + Existing holders gain that bounded operation; no new pair or automatic grant + adoption occurs. Version-1 admins without the pair cannot edit the policy. +- [ADR-0036](0036-tenant-resolution-trusted-inputs.md), + [ADR-0040](0040-ambient-unit-of-work.md), + [ADR-0053](0053-trusted-public-server-rendering.md), + [ADR-0054](0054-bounded-public-renderer-admission.md), + [ADR-0064](0064-tenant-api-authentication-boundary.md), + [ADR-0070](0070-owned-tenant-bff-sessions.md), + [ADR-0076](0076-live-scoped-authorization.md) and + [ADR-0078](0078-native-session-scope-and-self-entry.md) gain only the exact + invitation scope, policy, native-route and replay consumers named below. + +These are new bounded decisions, recorded by dated Amendments linking this +successor and current canonical carriers. They are not false-at-entry corrections +under ADR-0041. Earlier Accepted bodies and metadata remain historical; none is +turned into a whole-record redirect. ADR-0073 receives invitation navigation; +ADR-0072/0074/0077 remain the binding, lock and evidence baselines. No historical +numeric census is rewritten merely because this packet adds a later consumer. + +## Decision Drivers + +- P03-4 owes real new/existing-user acceptance, verified current email, fresh + delegation, revocation/reissue, usable delivery and a reconciled grant outcome. +- The maintainer chooses automatic email in this packet. Full Phase 08a templates, + preferences and notification orchestration are not prerequisites for its minimum + invitation sender; the earlier manual-only recommendation is not selected. +- Existing native scope requires Membership. Its three global-self descriptors + cannot authorize first-Membership acceptance, and token email is not current. +- One ambient transaction supplies no unrecorded two-root exception. SMTP supplies + neither exactly-once delivery nor a reliable lookup after an uncertain send. +- Secrets in a queued email need durable private custody. Identifier-only job + parameters/plans/outcomes and the external ordinary-DB denial must remain true. + +## Considered Options + +1. **Automatic SMTP with typed jobs and private encrypted custody** (selected). + Send and track invitations now, preserving explicit uncertain outcomes. +2. **Administrator-only link sharing** (not selected by the maintainer). Smaller + delivery scope, but does not satisfy the selected automatic-email experience. +3. **Send during the API/business transaction** (rejected). External delay and + uncertainty cannot share the database's atomic commit or safely retry it. +4. **Place email/token/ciphertext in job plans or replay responses** (rejected). + Breaks identifier-only contracts or retains a second uncontrolled credential. +5. **Eventual Membership after invitation consumption** (rejected). Cannot supply + the required single consumption/grant outcome. +6. **New invitation-policy permission/release** (not selected). Adds unnecessary + grant migration for one tenant setting. Explicit existing-key expansion is + chosen and tested; it is not described as an unchanged permission meaning. +7. **Unconditional exclusive Tenant lock** (not selected). Preserve ADR-0074's + strongest actual-effect plan; do not serialize every invitation unnecessarily. + +## Decision + +LearnStack delivers automatic invitation email, verified-recipient acceptance and +truthful recovery in P03-4. A narrowly admitted offer establishes only its exact +nonmember scope. Fresh provider email and current inviter delegation precede one +atomic Invitation/Membership result. Typed external jobs send an encrypted, +version-bound email through a closed SMTP provider store, preserving uncertain +submission and retained reservations. Acceptance itself publishes no session; +normal explicit API admission and account continuity still govern publication. + +## Context + +### Offer and recipient + +Invitation is an Identity tenant/organization-scoped root with typed UUIDv7 ID, +immutable normalized recipient email, inviter UserId, nullable organization, one +compatible RoleId and pinned Role version. No invitee UserId is supplied by an +administrator. Email is a recipient condition, never an account-merge key. + +Use ASCII dot-atom local part and DNS domain, at most 254 bytes, with no display +name, quoted local part, comment, whitespace or control. Lowercase ASCII across +that address; perform no alias/dot/plus stripping or Unicode compatibility +folding. This is a deliberate matching policy, not universal SMTP case semantics. +The provider address uses the same parser. Provider account uniqueness remains +separate and is neither changed nor inferred from invitation matching. + +Tenant-capability Role requires NULL organization; Organization-capability Role +requires non-NULL target organization. The same-key Tenant-to-Organization +**delegation** bridge never permits assigning an incompatible Role template. +Current whole grants must fit the stored inviter's live ceiling. A changed pinned +Role requires revocation/new offer; reissue does not silently repin it. + +### Lifecycle, lifetime and resend + +| State | Authoritative behavior | +|---|---| +| Pending, unexpired | Inspect; explicitly accept; revoke; or explicitly resend by reissue. | +| Pending, expired | Reject acceptance; show expiry; an authorized reissue renews it. | +| Accepted | Retain actor, exact result MembershipId and `created`/`already_member` disposition. No reissue, revocation or second grant. | +| Revoked | Terminal. A replacement offer has a new ID. | + +Create commits generation 1, its verifier, frozen expiry, queued delivery custody +and job together. There is no undated `not_prepared` state. One Pending offer per +null-safe tenant/org/email tuple includes expired offers awaiting reissue. Duplicate +creation is an authorized conflict, never a hidden resend. + +The Tenancy-owned `identity.invitation.ttl_days` setting defaults to 14 days and +allows 1–30. Only orgless Tenant `tenancy.setting.write` can edit it. Existing v2 +holders gain this operation; v1 without that pair still creates invitations using +14 days and may explicitly adopt v2 through the existing accepted procedure. +Registration/seed repetition neither grants nor restores the pair. A new value +affects future generations only. The absent/default precondition is distinct from +an existing version-zero setting; first creation uses presence plus version and +natural uniqueness, not an unconditional upsert. + +**Resend means explicit reissue:** create a new generation, secret, expiry, +encrypted message and job; atomically invalidate the old acceptance verifier. +It requires current authority/delegation, pinned Role and canonical If-Match plus +protected idempotency. Repeat of the same key returns its version receipt rather +than queuing twice. No API returns the raw link. Copying links is not the selected +administrator delivery workflow. + +Revocation, acceptance and reissue invalidate obsolete acceptance authority in +their ordinary transaction. Prepare refuses an obsolete generation observed +before its plan commits. There is no ordinary provider-table cancellation writer: +a message already prepared or admitted to SMTP can still arrive with an invalid +link. Neither a new generation nor an expired lease releases an uncertain older +delivery reservation. Provider recovery cancellation below fences an unclaimed +external attempt; it is distinct from business withdrawal. + +### Automatic email and private custody + +Identity is the first production job consumer. It registers two external-profile +keys, `identity.invitation_email_tenant.v1` and +`identity.invitation_email_organization.v1`, for exact TenantWide/Organization +routes. Existing external queue/pool counts stay fixed. The shared tenant-wide +`identity.invitation_email` capacity group has four retained slots; uncertainty +can fill them and delay that tenant's later mail. + +Creation/reissue uses `IInvitationEmailStager` and `IBackgroundJobScheduler` on +its current announced writable owner. The durable job, Invitation generation and +private envelope commit together. The provider spool is framework/provider +control storage, not another business root or a generic notification module. +`invitation_email_envelopes` and `invitation_email_attempt_receipts` are owned by +common Infrastructure. Their custody/receipt changes have their own explicit +capture exclusions and no fabricated business snapshots. + +Encrypt the bounded recipient/subject/body/link message using AES-256-GCM with a +fresh 12-byte nonce, 16-byte tag and immutable scope/generation/spool/job identity +as authenticated data. Plaintext is at most 32 KiB. A named keyring of 1–4 keys +comes only from `ISecretProvider`; ciphertext identifies its key. Active key +changes do not discard keys required by outstanding ciphertext or restoration. +Missing keys refuse; they never regenerate links or silently select another key. +The setup/rotation/restore runbook and key-loss controls ship in this packet. + +Parameters and plan carry only immutable GUID Invitation/generation/spool IDs; +outcome carries only a provider ReceiptId. Generation ordinals, enums, email, +URLs, ciphertext, keys and exceptions never enter jobs/outbox/inbox/vendor state. +Prepare/finalize are exact audited Identity requests. Finalization changes only +the still-current Invitation generation; stale-generation completion is a truthful +no-op with its retained provider evidence, not a second send or status overwrite. + +The Identity Contracts port `IInvitationEmailOperation` retains the existing +Execute/Reconcile shape; `InvitationSmtpOperation` uses shared `IEmailProvider`. +The SMTP adapter pins MailKit/MimeKit 4.18.2. Provider configuration fixes host, +port, sender and required TLS; credentials use the secret provider. Only explicit +Development Mailpit configuration permits unauthenticated plaintext SMTP. Request +or tenant-authored content supplies no endpoint, TLS downgrade or headers. Render +bounded localized en/tr text and HTML with escaped tenant labels; the full Phase +08a template/preference engine later integrates this boundary. + +SMTP resilience disables automatic send retry. Only the typed job outcome policy +can admit another attempt; no nested Polly retry repeats an uncertain send. +A stable Message-ID identifies the same logical operation, without claiming +SMTP deduplication. Final SMTP 250 means **submission accepted**, not inbox delivery. +[RFC 5321's final-reply timeout rule](https://www.rfc-editor.org/rfc/rfc5321.html#section-4.5.3.2.6) +explains why a missing final response cannot establish failure or justify resend. + +### Confined external provider source + +Ordinary external UoW/DbContext/repository/ISender/tenant-setter access remains +refused. `JobExecutor.InvokeExternalAsync` alone mints a nonserialized immutable +`InvitationSmtpExecutionReceipt` from the exact registered definition/adapter, +validated route, job/attempt/operation, immutable plan fingerprint, current fence, +deadline and Execute/Reconcile purpose. Plain DTO IDs are not this authority. +The receipt is invocation-bound, closes new acquisitions on invalidation and +retains already-owned work through actual cleanup. + +Only a dedicated provider connector consumes it, on a separate app-role source +with four retained slots including checkout/cleanup and five-second SQL operation +budgets. No migration credential, fifth login role, ordinary UoW, raw connection +escape, general callback or tenant GUC writer is added. A timed-out physical owner +keeps its slot until disposal; lost COMMIT has bounded exact reconciliation. + +Two fixed migration-owned SECURITY DEFINER functions, on this connector only, +validate actual job/attempt/plan/ExecuteIntent/spool bindings and their narrow +role-qualified owner visibility before accessing provider storage: + +- `fn_invitation_smtp_claim_or_read`: Execute may atomically claim its exact + Created attempt as Sending before **any** socket starts. Reconcile reads only + immutable evidence; fresh eligible recovery may durably fence Created as + CancelledBeforeSend, preventing a paused former Execute. Reconcile never sends. +- `fn_invitation_smtp_complete_attempt`: write-once terminal observation for an + already-issued exact provider claim, including reproducible late evidence + after lease loss. It neither starts work, changes Invitation/business state, + releases job occupancy nor bypasses the runtime's checkpoint authority. + +Functions return encrypted material only to the winning original claim, or +bounded receipts. No Invitation/User/Membership directory or plaintext projection +exists. Standard 05 owns the exact SQL signatures, static bodies/search paths, +policy arms, source/fence/grant/caller census and known/unknown commit rules. +This is an explicitly enumerated provider-storage exception to ADR-0047's source +boundary, not permission for other external adapters to access application data. + +| Proven SMTP/provider observation | Runtime outcome and UI | +|---|---| +| Final 250, actual send/socket cleanup and durable receipt | Succeeded; submission accepted, never delivered/read. | +| Definite temporary rejection/no acceptance, actual cleanup and durable receipt | Failed/TransientRepeatable; bounded existing retry, same logical operation/generation. | +| Definite permanent rejection, cleanup and durable receipt | Failed/Permanent; visible failed result/recovery. | +| Durable cancellation before any socket, former start fenced | Cancelled/TransientRepeatable with positive provider evidence. This is not framework NeverStarted. | +| Lost final reply, crash after Sending before receipt, unsettled I/O or contradictory evidence | Unknown; visible uncertain/quarantined state, retained reservation, no blind retry. | +| Exact durable terminal receipt found after restart | Reproduce it; never execute the completed operation again. | + +UI separates queued, submission accepted, failed, uncertain, expired and revoked. +Known failure offers authorized reissue; uncertainty explains possible late old +mail and the retained-capacity limit. It does not claim that reissue reconciles the +old effect. No evidence can manufacture a missing SMTP response after an +unobservable crash. P03-9 owns privileged recovery; this packet supplies the +provider-specific evidence/runbook, not a bypass or an invented release proof. + +### Current provider email and verification + +`ICurrentInvitationRecipientReader` calls the configured issuer's fixed UserInfo +endpoint with this request's stock-validated access token. No supplied URL, +redirect, realm-admin credential, User email mirror or account search is added. +For every Pending inspection and first acceptance, independently require the same +exact `sub`, one typed `email`, Boolean `email_verified == true` and mailbox match. +Reject duplicate/ill-typed claims. Bound response to 16 KiB and provider I/O to +five seconds within the existing ten-second API deadline; no business locks or +retry extending that deadline. Evidence binds request/issuer/subject/token and +expires with that invocation. Provider and database observations are not atomic. + +Show the complete escaped, bidirectionally isolated verified address, not a +possibly colliding display label. The later accept POST makes its own observation; +inspection is no authorization cache. Wrong or unverified email yields bounded +recovery without revealing the expected address. + +New people use existing opt-in global-self bootstrap, which commits/disposes +before tenant acceptance. It keeps the deployment budgets of 10 attempts/minute, +100/day and 10,000 lifetime allocations; disabled/exhausted creation cannot be +bypassed by an invitation. Account binding stays authority plus exact subject. + +The fixed `kc_action=VERIFY_EMAIL` purpose reuses state/nonce/PKCE and exact callback +under the original invitation/subject. Cancellation or a different subject never +verifies or replaces that continuation. Realm-wide `verifyEmail=false` can remain; +qualification explicitly verifies that the action is enabled/supported and its +send/cooldown UI works. Existing Mailpit handles development verification email; +production requires configured Keycloak SMTP. No new LearnStack password/reset or +verification sender replaces Keycloak. The pinned +[UserInfo endpoint](https://github.com/keycloak/keycloak/blob/26.6.2/services/src/main/java/org/keycloak/protocol/oidc/endpoints/UserInfoEndpoint.java), +[property mapper](https://github.com/keycloak/keycloak/blob/26.6.2/services/src/main/java/org/keycloak/protocol/oidc/mappers/UserPropertyMapper.java) +and [VerifyEmail action](https://github.com/keycloak/keycloak/blob/26.6.2/services/src/main/java/org/keycloak/authentication/requiredactions/VerifyEmail.java) +support this choice; actual changed-email/nonseed verification proof remains due. + +### Offer entry, secrets and native ownership + +Email contains a stored, live host-mapping-derived HTTPS origin and fixed localized +landing with `#.`. The origin is server-derived/frozen for +that generation and independently revalidated; no caller-supplied URL, scheme, +port or return destination is accepted. No secret enters path, query, OIDC state, +returnTo, callback or another host's URL. A separate code enables manual entry. + +**Browser GET renders only navigation/inspection UI.** Secret-carrying API +inspection/result operations are POST with strict bodies, explicitly physically +ReadOnly and Off where self-only. This clarifies the earlier phase/Architecture +13 GET wording; no GET accepts, consumes, queues or grants an invitation. + +The native landing runs before Next/RSC, with no third-party scripts, analytics or +error reporter. Its first bounded script scrubs the fragment then captures through +exact-Origin/CSRF POST. Without JavaScript the clean form accepts a pasted code. +GET allocates the bounded preauthentication continuation/CSRF/browser binding; +capture verifies that exact host/browser proof and never consumes the offer. +Clean confirmation forms use existing exact-Origin-compatible same-origin +referrer policy, not an unqualified no-referrer change. + +One continuation per host/browser group uses the existing 1,024 total/128 pending/ +16 per-source/five-minute limits. After verified issuer/subject association it +also counts inside the existing eight-owner subject ceiling. It has a separate +opaque `__Host-ls-invitation` cookie, HttpOnly/Secure/Path=/, SameSite=Lax. It grants +no session. Replacing a continuation affects only that invitation owner, preserving +live sessions. Epoch/late-publication/cleanup rules persist. Five-minute expiry or +restart asks the person to reopen the original email; this cannot consume a second +grant. Actual cleanup controls retention, not a timeout or cleared cookie. + +A 32-byte CSPRNG secret has one domain-separated verifier. Hash unsigned-32-bit +big-endian length-prefixed UTF-8 `learnstack.identity.invitation.v1`, RFC UUID +bytes and raw random bytes with SHA-256. The API computes it before SQL; database +`verifier_digest` input is exactly those 32 digest bytes, never the raw secret. +Application-held secret/digest comparisons use fixed-time APIs where applicable; +indexed SQL digest equality is **not claimed constant-time**. High entropy and +masked outcomes remain separate controls. The consumed digest is the accepted +generation's verifier hash, retained only for exact same-actor reconciliation. +Reissue replaces the active verifier; revoke clears it; expiry disables it by +predicate; Accepted moves it to consumed-verifier custody. No old verifier grants +new admission. Sensitive digests/SQL parameters stay out of server error/statement +logs and provider diagnostics; the packet includes DB bind-log configuration and +actual leakage controls rather than assuming application redaction covers them. + +After peer/body/host, provider, issuer/sub quota and native-source checks, a +separate exact invitation branch normalizes the existing Active binding and calls +`fn_identity_invitation_entry` with ID/digest, independently classified host tenant +and installed authority/subject key. It returns only offer tuple/version/generation, +bound actor admission, stored inviter global admission and result identifiers. +The inviter Boolean is not delegation proof. No email/profile/grants/arbitrary +User lookup is returned. The short lookup closes before the scoped owner starts. + +`InvitationEntryScope` is the eleventh logical setter: one operation-bound witness +on ReadOnly inspection/result or ordinary ReadWrite acceptance owners. This is +not classified as a ninth short-ReadOnly-only setter. Both reuse the four existing +physical context writers. The one-use witness binds exact request, credential, +subject/actor, native source, real host, stored offer and mode. The new +`HostAndInvitationOffer` origin admits only `InspectInvitationQuery`, +`AcceptInvitationCommand` and `GetOwnInvitationResultQuery`. Extend enum/factory, +minter/resolver, middleware/policy/source and caller tests together. No AccountSite, +directory, public write, Platform/unknown-host or general protected operation is +admitted. TenantHost caps the tenant; OrganizationHost requires exact organization. +Provider scope/assertions must agree and never fill or override missing authority. + +### Atomic acceptance, current authority and audit + +Use existing ordered locks: complete sorted exclusive invitee/inviter User keys, +then the strongest actual Tenant mode, offered Role, Invitation, and existing +Membership row. Shared covers nonadministrator effects, including create/revoke/ +reissue and ordinary-member acceptance; actual Tenant Admin eligibility/assignment +changes select Exclusive. Derive the full plan from prepared OLD/NEW effects and +revalidate after waiting; changed classification conflicts rather than upgrades. +Current delegation has ADR-0076's committed per-act snapshot boundary, not a +promise to cancel work already admitted before a later revocation. + +Fresh post-wait state proves both User accounts Active, including the existing +Active-member case without a Membership INSERT. The offer-derived function +rechecks only those exact bound/global facts; `IInvitationDelegationReader` +separately reads current stored-inviter Membership/Role/grant facts on the same +scope. No invented HTTP candidate, seed/SystemActor flag or `was_admin` authorizes +it. Trial/Active tenant and Active/nondeleted target organization admit ordinary +work through `IIdentityScopeLifecycleReader`; Suspended/Archived/deleted scope +refuses. NULL organization requires eligible tenant work, without a fake branch. + +| Exact target Membership | Initial acceptance | +|---|---| +| None | Create one Active Membership/Profile/assignment; consume offer. | +| Active | Consume as `already_member`; explicitly tell both parties existing Roles/profile are unchanged. | +| Suspended | Conflict; no reactivation/consumption. Use the existing status operation. | +| Only Revoked history | Create one fresh successor; never resurrect a predecessor. | + +Exactly one Invitation and at most one Membership are written by +`AcceptInvitationCommandHandler`. Central plural MUST +`identity.invitation.accept` and `identity.membership.admit_invitation` commit on +that owner with actual subject IDs. **Disposition and result MembershipId live on +the tracked Invitation**, so its captured after-state/diff provides created versus +already_member evidence. The unchanged Membership event is subject-only, with +NULL snapshots/diff; it does not claim a disposition metadata field or a fabricated +creation. Designating an unchanged subject is supported independently of the +multiple-instances-of-one-type rule. No generic audit-result channel is added. +Natural uniqueness, global admission and last-admin guards remain; MUST failure +rolls back consumption and grant together. + +Predispatch Accepted goes to exact Off `GetOwnInvitationResultQuery`, not mutation +intents. It requires consumed digest, same currently Active actor and original +current Active Membership, before Pending-only version/expiry/email/inviter/Role +conditions. Suspended/Revoked/deleted/or successor original Membership gives masked +404 and no new grant. If a Pending command sees Accepted after lock wait, return +recoverable conflict with truthful failed-attempt audit; a fresh result read can +reconcile. Unknown COMMIT stays unknown until authoritative observation. It never +repeats a successful consumption or substitutes a successor. + +Only Pending checks expected version, current generation, post-wait database +expiry, Role pin and fresh email/delegation before its conditional transition. +Accept/accept, accept/revoke, expiry and reissue races have one locked winner. +No global/tenant owner joins or retargets; no native session follows merely from +this result. Explicit API scope admission/AccountSite and same-subject continuity +still govern publication. Accepted-but-no-session recovery preserves the grant. + +### Finite transport, privacy and completion + +The [revised phase package](../roadmap/phase-03-delivery-log.md#p03-4-automatic-email-review-corrections-and-accepted-plan-2026-11-04) +owns exact routes/steps; Standards 04/05/07/11/18/19 own their canonical DTO, +precondition, source, permission, audit and storage contracts. Reuse applicable +membership-admin and exact scope. Invitable-Role summaries expose only currently +delegable ID/key/scope/version, not general Role-admin/grant configuration. + +Create/revoke/reissue/policy writes use bounded durable protected receipts, strong +preconditions as applicable and fresh target disclosure. Add Invitation and exact +policy TenantSetting receipt kinds; store no raw secret or message. Entry accepts +strict body-held code/version and rejects a generic Idempotency-Key; offer/actor/ +consumed-digest result convergence is its natural key. Missing/malformed/weak/ +wildcard/list required If-Match is 400 validation_failed; stale canonical version +is 409 concurrency_conflict. Wrong-purpose/foreign/invalid/expired/revoked/replaced +link is masked 404 with usable generic reopen/contact-admin recovery. Matching +unverified/mismatched email is bounded 403; no expected-email disclosure. Existing +quota 429 and unavailable/indeterminate 503 remain. Standard 09 owns the exact +finite additional error keys before writer activation; no proposed 428 applies. + +Classify recipient, inviter/acceptor/Membership linkage, Role pin, verifier, +consumed verifier, ciphertext/key IDs, provider receipts/job linkage and volatile +continuations in canonical Data Protection. Snapshot markers and real per-sink +canaries cover application/Sentry/archive/traces, database bind logs and SMTP +instrumentation. No credential/email/message leaks into diagnostic/test artifacts, +audit snapshots, validation attempted values or identifier-only events. Authorized +recipient email/relationship DSAR remains P03-10; credentials are not exported. +Phase 11 owns retained-copy/purge extensions, without implying this packet already +erases or anonymizes those stores. Current actor closure still fences acceptance. + +## Consequences + +### Positive + +- Administrators send and track invitations inside Studio; a real new verified + person joins the exact institution/branch through a complete recovery path. +- Current provider email, fresh delegation, one locked consumption/grant and + central audit preserve identity/scope without self-escalation or email merging. +- Durable encrypted custody and provider evidence survive ordinary restart while + generic job payloads, external DB denials and truthful delivery status remain. + +### Negative + +- SMTP/keyring/TLS/sender setup, two private tables, two provider functions, a + dedicated four-slot source and a narrow external execution-receipt seam add + maintenance. Total confined definers become eleven; the scope setter count is + eleven and physical context writers remain four. +- SMTP response loss/crash can quarantine delivery and occupy all four tenant + slots until positive recovery evidence. No exactly-once/inbox guarantee exists. + Reissue does not free unknown old work; old invalid messages can arrive later. +- Existing setting-write holders gain the invitation policy act. Without v2 + adoption, administrators can use the default but cannot change it. +- New-user creation remains opt-in, 10 attempts/minute, 100/day and 10,000 lifetime + allocations per authority. Verification depends on SMTP/provider availability; + five-minute local/provider continuations may require reopening the email. +- Provider and DB state have separate observation points; later email/delegation + changes cannot retroactively cancel an already admitted operation. + +### Neutral + +- This packet brings forward only invitation email. Phase 08a owns the full engine; + no new notification module, tenant template editor or generic sender surface. +- Existing job attempts, worker counts, owner quotas, API/shutdown/CI/test windows, + Role grant manifests, global-self descriptors and four login roles stay fixed. +- Acceptance, implementation, real qualification and merge remain distinct. + +## Implementation Notes + +Before runtime, reconcile current Standards 01/04/05/07/08/09/10/11/13/16/18/19/20/21 +where applicable, Identity/Tenancy module specs/diagrams/matrices/providers, +Architecture 13 invitation diagram/23 inventory/15 job handoff, glossary and G8. +Use dated Amendments in ADR-0003/0036/0040/0042/0047/0053/0054/0064/0070/0073/0076/ +0078/0079, each naming this successor and exact changed current carriers. No +false-at-entry erratum or historical Decision rewrite is selected. Preserve +ADR-0033/0044 durability/composer behavior and ADR-0072/0074/0077 baseline semantics. + +The seven-step package assigns exact provider/caller/source/ACL/precondition/ +privacy/route/error controls before activation. Every step commits, runs two +fresh independent review rounds, fixes verified findings in new commits and +continues. The canonical aggregate rule becomes +`Cross_Aggregate_Writes_Are_Confined_To_Named_Exceptions`, with exactly +ProvisionTenantCommandHandler and AcceptInvitationCommandHandler; retain a +navigation alias for its historical name, not duplicate rule ownership. + +## Architecture tests + +Extend real nonempty subjects in existing aggregate/context/setter/SQL/permission/ +privacy/audit/native/job/isolation rules. New source exceptions must be executable +closed lists, not comments. Preserve current rule status until actual tests land. + +Mandatory behavior: + +- Real app-role tenant/two-branch RLS/FK/uniqueness; exact eleven-function body/ACL/ + owner-policy set, wrong-purpose/mode/host/subject, scope cleanup and pool reuse. +- Provider signup and actual verification email, current UserInfo change/unverified/ + wrong subject/duplicate fields/outage; actual committed realm registration, + email-edit/username/duplicate-email settings, not presumed account edits. +- Strongest lock-mode dirty/clean controls, after-wait inviter revocation/Role pins, + Trial/Active positive and Suspended/Archived/deleted scope refusal; actual closure + before the User fence even for already_member; no compatible-Role shortcut. +- Both commit orders for accept/revoke/expiry/reissue, Active unchanged-role audit, + fresh successor, plural MUST rollback and original-version unknown-COMMIT retry. +- Real SMTP acceptance/rejection, receive-once normal path, deterministic same + operation retry only after known no-effect, pre-send fencing, final-250-before- + checkpoint restart, lost-reply/crash Unknown with retained occupancy, stale + generation finalization, key rotation/loss/restore and positive leakage controls. +- Native HTTPS/new/existing/different-account, no-JavaScript code entry, clean + token URL/history/BFCache, verification cancellation/expiry/restart, exact subject + owner ceiling and accepted-but-no-session recovery; actual human accessibility. + +Final same-source required CI/A/B/original-archive proof remains mandatory. No +local fixture, old P03-3 follow-up or maintainer merge is substituted for it. + +## References + +- [Phase 03/G8](../roadmap/phase-03-identity-admin.md#invitation-flow), + [Identity](../modules/identity/README.md), [permissions](../modules/identity/permissions.md), + [audit](../modules/identity/audit.md) +- [ADR-0072](0072-application-owned-identity-bootstrap.md), + [ADR-0073](0073-scoped-membership-and-identity-data-foundation.md), + [ADR-0074](0074-membership-admission-under-ordered-locks.md), + [ADR-0077](0077-trusted-authorization-evidence.md) +- [ADR-0033](0033-audit-durability-model.md), [ADR-0044](0044-audit-write-path.md), + [ADR-0041](0041-correcting-false-statements-in-accepted-adrs.md) +- [Identity architecture](../architecture/13-identity-and-auth.md#invitations), + [Data Protection](../architecture/23-data-protection.md), + [Phase 08a](../roadmap/phase-08a-assessment-notifications.md#notifications) +- [Database](../standards/05-database.md), [Security](../standards/11-security.md), + [Audit](../standards/18-audit-coverage.md), [Catalogue](../standards/21-architecture-tests-catalogue.md) +- [MailKit 4.18.2](https://www.nuget.org/packages/MailKit/4.18.2), + [MimeKit 4.18.2](https://www.nuget.org/packages/MimeKit/4.18.2) + + +## Amendments + +### 2026-11-04 — Acceptance version transport clarification + +The finite-transport paragraph combines “body-held code/version” with required +canonical If-Match. It does not name a duplicate-version reconciliation rule. +For the Step 5 API and Step 6 native forms, observed acceptance version has one +carrier: canonical strong **If-Match**. All three entry JSON bodies contain only +Invitation ID and code; a duplicate body version is not admitted. Inspection and +result reject conditional headers. This concretizes the already-required strong +precondition rather than claiming a false-at-entry runtime defect. + +Pending acceptance validates that header and returns 400 for missing/malformed +required preconditions or 409 for a stale canonical version. Predispatch Accepted +still selects the exact own-result read before Pending-only preconditions, +provider work or mutation intents; an obsolete original tag cannot block that +reconciliation. Generic Idempotency-Key remains refused. + +[Standard 04](../standards/04-api-design.md#verified-invitation-transport) owns the +current wire contract. Its implementation carriers are the finite API +`InvitationEntryHttpInput` and `InvitationEntryController`, the served OpenAPI +snapshot and SDK entry operations, and the native confirmation forms as their +owning steps land. The phase Status block owns delivery and qualification. +The historical body is preserved. The Decision, authority, lifecycle, lock, +audit, provider and execution-limit choices are unchanged. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 08e5df92a..6f683edac 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -135,6 +135,7 @@ records hosted qualification. Phase 11 owns production qualification. | 0090 | [SDK Cold Prefix Proof Settings](0090-sdk-cold-prefix-proof-settings.md) | Accepted 2026-11-03 after maintainer review: only the cold-prefix shared stop request 90 seconds and both cases’ Vitest setting 100 seconds; SDK aggregate 180 seconds and distinct accepted/output_unowned controls remain. The [Phase 03 Status](../roadmap/phase-03-identity-admin.md) owns implementation and qualification. | | 0091 | [Native Inventory Phase Allocations](0091-native-inventory-phase-allocations.md) | Accepted 2026-11-03 after maintainer review: preflight inventory 60 seconds, image metadata commands 10 in both phases, final inventory total 20, native job 1,200 and reserve 203 unchanged. The [Phase 03 Status](../roadmap/phase-03-identity-admin.md) owns implementation and qualification. | | 0092 | [SDK Completion and Frontend Job Windows](0092-sdk-completion-and-frontend-job-windows.md) | Accepted 2026-11-04 under maintainer closure delegation: canonical SDK aggregate 360, shared cold-prefix stop 150, both case thresholds 160 seconds and frontend job 30 minutes; exact proof/ownership and complete A/B remain. The [Phase 03 Status](../roadmap/phase-03-identity-admin.md) owns delivery and qualification. | +| 0093 | [Verified Invitation Acceptance and Durable Email Handoff](0093-verified-invitation-acceptance-and-handoff.md) | Accepted 2026-11-04 after maintainer selection of automatic email/review corrections: confined verified-recipient entry, atomic Invitation/Membership, encrypted SMTP custody and exact external source; no runtime proof implied. [Phase 03 Status](../roadmap/phase-03-identity-admin.md) owns implementation. | ## Proposed ADRs diff --git a/docs/glossary.md b/docs/glossary.md index e25441818..1e1809e01 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -36,6 +36,12 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across | **Role** | A named scoped capability bundle. Accepted [ADR-0073](decisions/0073-scoped-membership-and-identity-data-foundation.md) stores tenant-wide templates with Tenant or Organization capability; exact branch authority comes from the Membership assignment. Platform roles have no tenant template. Finite initial/adopted grants follow [Permissions](standards/19-permissions.md#proposed-identity-foundation-contract). | | **Permission** | A fine-grained capability `{module}.{resource}.{action}` with a scope (Platform / Tenant / Organization). Action set is closed: `read \| write \| delete \| admin`. | | **Invitation** | A pending offer for a user to accept a membership in a tenant + organization. | +| **Invitation handoff** | Durable transfer of an invitation message to the SMTP submission boundary, distinct from inbox delivery or recipient acceptance. The private custody/provider evidence contract is in [ADR-0093](decisions/0093-verified-invitation-acceptance-and-handoff.md). | +| **Invitation entry witness** | Single-use evidence binding verified subject/actor, native request/host, exact stored offer and operation mode to `InvitationEntryScope`. It grants only the closed nonmember invitation consumers, never ordinary Membership or session authority. [ADR-0093](decisions/0093-verified-invitation-acceptance-and-handoff.md) owns the boundary. | +| **Invitation generation** | A monotonically advancing offer-link revision with an immutable generation identity. Reissue invalidates the former acceptance verifier and queues new email; it does not release an older uncertain SMTP operation. [ADR-0093](decisions/0093-verified-invitation-acceptance-and-handoff.md) owns its lifecycle. | +| **Invitation verifier** | Domain-separated one-way digest of the random invitation code; sensitive credential-derived material. The consumed verifier permits only exact same-actor result reconciliation. Encoding and custody belong to [Database Standards](standards/05-database.md#invitation-entry-and-consumption). | +| **Invitation continuation** | Bounded volatile host/browser custody of one opened invitation through sign-in, verification, confirmation and recovery. Its opaque cookie supplies no Membership. [Security](standards/11-security.md#verified-invitation-admission) owns lifetime and quota rules. | +| **SMTP submission evidence** | Reproducible provider-attempt observation of accepted submission, definite rejection, positively fenced pre-send cancellation or unresolved effect. It is not inbox delivery, recipient read or reservation-release authority. [Database Standards](standards/05-database.md#invitation-smtp-provider-storage) owns the receipt contract. | | **Development realm reconciliation** | P02b-6 tooling operation that converges the named tenant-realm configuration and demo identity attributes on a fresh or existing local Keycloak database. It preserves account identity and credentials, does not provision application memberships and is not the runtime `IIdentityProvider`. The [P02b-6 decision package](roadmap/phase-02b-delivery-log.md#p02b-6-decision-package-2026-10-20) owns its approved field/capability boundaries; the [Step 3 record](roadmap/phase-02b-delivery-log.md#p02b-6-step-3--shared-seeddemo-lifecycle-2026-10-21) records delivered entry-point convergence and the Step 4 record owns remaining qualification. | | **Verified provider subject** | Immutable `VerifiedProviderSubject` evidence produced by stock credential validation: exact issuer and opaque subject, with optional compatibility actor and tenant/organization claims. It grants no application actor, Membership or permission; ordinary admission independently resolves an existing binding before publishing a validated tenant identity. [Security Standards](standards/11-security.md#credential-quota-and-binding-order) owns the ordered handoff. | | **Validated tenant identity** | Normalized request-local application identity after verified provider-subject admission and existing-binding resolution under Accepted [ADR-0072](decisions/0072-application-owned-identity-bootstrap.md). Its actor is nonnullable; optional API wire `user_id` is evidence checked against the binding. It proves neither live Membership nor permission. ADR-0078 keeps provider scope evidence distinct from separately confirmed native scope. | diff --git a/docs/modules/identity/README.md b/docs/modules/identity/README.md index 0e3a8d524..f949f93a0 100644 --- a/docs/modules/identity/README.md +++ b/docs/modules/identity/README.md @@ -38,6 +38,9 @@ its implementation and proofs remain governed by the phase Status block. Identity owns global application bindings, contextual Membership/Profile/ assignments and tenant-wide Role templates. Keycloak owns credentials/provider authentication; Tenancy owns Tenant/Organization lifecycle; Audit owns audit rows. +Invitations add the scoped offer and its verified acceptance contract under +[ADR-0093](../../decisions/0093-verified-invitation-acceptance-and-handoff.md); +its first writers remain subject to the phase Status block. Cross-module facts use application contracts, without an Organization FK, navigation or database join. Branch-specific Role configuration is not selected. @@ -131,6 +134,122 @@ table. Persisted grants select `(scope,key)` from the registry; unknown/inactive entries never authorize. [Standard 19's accepted foundation contract](../../standards/19-permissions.md#proposed-identity-foundation-contract) owns the exact registry, version-1 grants and generated kernel artifact. +### Invitation contract + +[ADR-0093](../../decisions/0093-verified-invitation-acceptance-and-handoff.md) +owns the decision. The following defines the P03-4 boundary. The +[phase Status block](../../roadmap/phase-03-identity-admin.md) owns delivery. + +| Subject | Contract | +|---|---| +| Invitation root | Tenant-owned, org-scoped typed `InvitationId`; immutable normalized recipient email, inviter `UserId`, nullable OrganizationId, one compatible RoleId and offered Role version; no invitee UserId selector | +| Pending uniqueness | One Pending offer per tenant/org/normalized email, null-safe, including an expired Pending offer awaiting explicit reissue | +| Lifecycle | Pending → Accepted or Revoked; expiry is evaluated from database UTC after waits; Accepted/Revoked are terminal | +| Accepted disposition | Immutable accepted actor, original MembershipId and `created`/`already_member`; retained consumed verifier permits only own-result recovery | +| Generation | Create stages generation 1; explicit reissue advances generation, token and job, using the current TTL; old codes are invalid immediately | +| Email and verifier | Email is personal data; current/consumed domain-separated SHA-256 token verifiers are sensitive credential material; all raw values are excluded from audit/diagnostic sinks | + +`CreateInvitationCommand` automatically uses `IInvitationEmailStager` to enroll +its encrypted envelope and identifier-only typed external job on the Invitation's +ordinary unit of work. Invitation, envelope, job and MUST audit commit together. +Reissue creates a new generation/envelope/job; it does not repin changed Role +content. A lost response is resolved through the ordinary bounded receipt, +without a recoverable link in replay storage. No prepare/handoff or raw-link-read +API is part of this contract. The private SQL-only delivery tables are common +Infrastructure custody, not Identity aggregates or a new notification module. + +Administrative create/reissue use existing live Membership-admin admission, +exact resource scope and current whole-Role delegation. Revocation is withdrawal: +current exact-scope membership-admin, Pending target and strong root version are +required; a changed/deleted Role or uncovered offered grant does not prevent +withdrawal. No Role assignment or reissue authority follows. All admin readers, +mutations and original receipts additionally compare tenant and exact nullable +organization; an Organization context must not disclose a tenant-wide offer +merely because the base EF/RLS filters admit that row. Invitable-Role +summaries disclose only delegable ID/key/scope/version, without a general +Role/registry reader. Own inspection and own accepted-result reading are Off; +other-person offer lists/detail, invitable Roles and protected receipt reads are +MUST sensitive reads. Mutations remain MUST. [The matrix](audit.md#invitation-bindings) +owns each consumer binding; the phase Status owns source-bound qualification. + +`ICurrentInvitationRecipientReader` performs a new configured-issuer UserInfo +observation for each Pending inspection/acceptance: exact `sub`, one valid email, +Boolean verified=true, 16 KiB/five-second bound and no business locks during I/O. +Email is matched under ADR-0093's explicit bounded mailbox policy; it does not +identify or merge Users. A provider edit can race database commit. Keycloak owns +registration and verification through the fixed same-subject `VERIFY_EMAIL` +continuation; actual provider verification is required. No global email mirror +is added to User. + +The confined `fn_identity_invitation_entry` accepts the application-derived +32-byte verifier digest, independent host tenant and verified authority/subject +key. It returns only exact offer/actor facts and the stored inviter's global +admission Boolean; it does not prove delegation or expose arbitrary User state. +Its disposed read owner precedes `InvitationEntryScope`, the eleventh logical +tenant setter, whose single-use witness admits only exact inspection/acceptance/ +own-result consumers. This ordinary owner is operation-bound ReadOnly or +ReadWrite according to the admitted consumer, not a general scope hatch. New User +bootstrap commits and disposes first. Neither owner joins or retargets the other. +The [database contract](../../standards/05-database.md) owns the exact closed +function/setter/source ledger; [Security](../../standards/11-security.md) owns +host/source and native authority. + +`AcceptInvitationCommandHandler` is the sole added two-root exception alongside +provisioning: one Invitation and at most one Membership with contained Profile +and assignment, one ordinary transaction, central plural MUST intents. It writes +no Role, User or Tenancy root. The complete sorted invitee/stored-inviter User +plan precedes the Tenant fence. Preserve the canonical strongest mode derived +from actual effects: shared for nonadmin effects, exclusive for actual Tenant +Admin eligibility/assignment effects, without upgrades or caller exemptions. +Fresh reads after waits recheck actor, generation, database expiry, lifecycle, +pinned Role and the stored inviter's complete current delegation. Standard 05 +alone owns lock ordering and retained-reader details. + +| Exact target Membership | Outcome | +|---|---| +| None | Create one Active Membership with Profile and the offered assignment | +| Active | Consume as `already_member`; retain that exact Membership and all Roles/profile | +| Suspended | Conflict without consumption/reactivation | +| Only Revoked history | Create a fresh successor ID and children; never resurrect history | + +The Invitation's accepted disposition is centrally captured. The unchanged +Membership outcome emits its separately declared subject-only security event, +without fabricated before/after or arbitrary handler metadata. A required audit +failure rolls back both roots, envelope/job staging and their state changes. + +The dispatcher routes an already Accepted offer to `GetOwnInvitationResultQuery` +before declaring mutation intents. That Off read requires the same fresh Active +actor, consumed verifier and original current Active Membership. It precedes +Pending-only expiry/version/Role/inviter checks and does not require current +email. A Pending mutation that discovers a raced acceptance returns 409 with +truthful failed-attempt evidence; it cannot report a duplicate successful grant. +Unknown COMMIT stays indeterminate until authoritative result recovery. + +Native capture and provider sending follow the +[architecture journey](../../architecture/13-identity-and-auth.md#invitations). +Revoke/reissue/accept invalidates old codes. Prepare refuses an obsolete +generation before its plan commits; already-prepared mail may still arrive. +Business withdrawal adds no ordinary provider-table cancellation writer. +`250` is accepted submission, not inbox delivery. Queued, submission accepted, +failed, uncertain, expired and revoked remain distinct product states. + +### Invitation storage foundation + +The Invitation aggregate and its Identity migration map `invitations` with the +canonical tenant/org filters, FORCE RLS, same-module reference keys, explicit +credential redaction and guarded finite transitions. Email is normalized through +`InvitationMailbox`; invitation authoring and recipient matching reuse that +parser. SMTP receives the validated staged address and independently enforces +transport/header safety. The local +dot-atom is 1–64 ASCII bytes, the full mailbox at most 254, and each ASCII DNS +label at most 63. No provider identity is selected by email. + +The exact preliminary SQL function returns bounded offer/actor facts without +email or grants. Its operation-bound HTTP/pipeline activation belongs with the +three actual recipient operations; schema availability grants no public entry. +The [phase Status](../../roadmap/phase-03-identity-admin.md) owns implementation +and qualification. + ### Lifecycle, profile and admission The database requires Active/Suspended Memberships to have NULL deletion fields @@ -159,7 +278,7 @@ Profile/nonadmin/organization mutations use its shared tenant invariant mode; first-admin and tenant-admin eligibility/assignment/kernel transitions use its exclusive mode. Actual affected rows bind the mode before acquisition; no shared-to-exclusive upgrade or caller-declared nonadmin exemption is admitted. -Only the admission operations above automatically require the User fence; +Only the admission operations above, including invitation acceptance, automatically require the User fence; profile-only changes and assignment removals retain their existing tenant-lock requirements. Standard 05 also owns backend-heldness limits, fixed internal plan fault versus target refusal, and the identical outward refusal. No algorithm or @@ -544,8 +663,10 @@ global/provider adoption remains ADR-0072's confined owner. ## Integration-event catalogue P03-1 publishes no Identity integration event and activates no provider ingress, -Invitation or closure consumer. P03-4/6/10 decide their first writers separately; -framework domain-event dispatch does not imply a published contract. +Invitation or closure consumer. P03-4 defines the bounded invitation writers +above without an email-bearing integration event; P03-6/10 own later consent/ +closure contracts. Framework domain-event dispatch does not imply a published +contract. ## Permission matrix diff --git a/docs/modules/identity/audit.md b/docs/modules/identity/audit.md index fc35c6280..8bf3b5d5a 100644 --- a/docs/modules/identity/audit.md +++ b/docs/modules/identity/audit.md @@ -49,6 +49,48 @@ the request exists, and off-path events register by slug when their writer lands | Original MembershipProfile outcome | `identity.membership_profile.read_receipt` | **MUST** (`read-sensitive`) | Exact stored original Membership and self/admin predicate; no later successor substitution. | | Original Role outcome | `identity.role.read_receipt` | **MUST** (`read-sensitive`) | Fresh Role receipt disclosure admission; does not reexecute grant/adoption mutation. | +## Invitation bindings + +The P03-4 contract derives from +[ADR-0093](../../decisions/0093-verified-invitation-acceptance-and-handoff.md). +Invitation consumers and the four email job roots have actual policy and +catalogue registrations; inspection and own-result navigation are explicit Off. +The [phase Status](../../roadmap/phase-03-identity-admin.md) owns delivery. + +| Resource | Operation | Class | Why | +|---|---|---|---| +| Invitation | `identity.invitation.create` | **MUST** (`security-event`) | `CreateInvitationCommand`; offer, generation-1 verifier, encrypted envelope and identifier-only job share the ordinary business transaction and required audit. | +| Invitation | `identity.invitation.reissue` | **MUST** (`security-event`) | `ReissueInvitationCommand`; new generation/token/envelope/job with current TTL; former verifier invalidates atomically, without raw secret replay. | +| Invitation | `identity.invitation.revoke` | **MUST** (`security-event`) | `RevokeInvitationCommand`; terminal withdrawal; later Prepare refuses obsolete work, while already-prepared email may still arrive with an invalid link. | +| Invitation | `identity.invitation.accept` | **MUST** (`security-event`) | `AcceptInvitationCommand`; central capture includes accepted actor, original Membership and `acceptanceDisposition`. The row shares the grant transaction. | +| Original Membership outcome | `identity.membership.admit_invitation` | **MUST** (`security-event`) | Second central intent on acceptance. New Membership includes tracked children; `already_member` identifies the unchanged original Membership with a subject-only row, no fabricated change or arbitrary handler metadata. | +| Invitation preparation | `identity.invitation.prepare_email` | **MUST** (`security-event`) | `PrepareTenantInvitationEmailCommand` / `PrepareOrganizationInvitationEmailCommand`; ordinary module-owned preparation records the exact current-generation submission intent before the external stage. No SMTP socket is opened inside its business transaction. | +| Invitation submission outcome | `identity.invitation.email_submission` | **MUST** (`security-event`) | `FinalizeTenantInvitationEmailCommand` / `FinalizeOrganizationInvitationEmailCommand`; ordinary owner applies validated durable provider evidence to the exact Invitation. SMTP acceptance is submission only; ambiguous outcomes remain uncertain and retain occupancy. No private audit INSERT or provider receipt substitutes for this row. | +| Invitation directory | `identity.invitation.list` | **MUST** (`read-sensitive`) | Administrative bounded offer list under current exact-scope authority before other-person disclosure. | +| Invitation detail/status | `identity.invitation.read` | **MUST** (`read-sensitive`) | Administrative detail and bounded normalized current-generation submission evidence, through the owning port; no encrypted body, raw link or another envelope is disclosed. | +| Delegable Role summaries | `identity.invitation.list_invitable_roles` | **MUST** (`read-sensitive`) | Exact delegation-limited ID/key/scope/version choices; no general Role/registry authority is inferred. | +| Original Invitation outcome | `identity.invitation.read_receipt` | **MUST** (`read-sensitive`) | Fresh current original-target disclosure admission before protected replay; retained bytes contain no recipient credential/link. | + +`InspectInvitationQuery` and `GetOwnInvitationResultQuery` are explicit +Off registrations, not `OperationClass` values or mutation rows. Pending +inspection performs fresh verified UserInfo; accepted-result recovery checks the +same Active actor, consumed verifier and original Active Membership, without +Pending-only email/expiry/Role/inviter checks. Already Accepted is routed to Off +before declaring mutation intents. A Pending mutation that sees Accepted after a +race returns conflict with truthful failed-attempt evidence, never another +successful acceptance row. No provider attempt receipt or UI poll declares a +successful Membership mutation. + +`AcceptInvitationCommandHandler` is the only added two-root exception: central +plural MUST intents for Invitation and the actual Membership share one ordinary +unit of work. No User/provider transaction joins it. A missing required row or +failed audit rolls back the whole grant/consumption outcome. Raw email, token, +verifier, ciphertext and SMTP response/exception text never enter audit values; +redacted properties remain present to preserve that a change occurred. Typed +closed provider outcomes and subject references are evidence, not free metadata. +Provider claim/complete functions retain immutable operational receipts; the +common audit composer remains the only audit owner. + **Explicit Off reads.** Contextual seed verification request types are registered Off before use; they inspect only the exact bounded declared seed facts, with no directory/provider profile. Ordinary existing-binding lookups create no mutation @@ -104,10 +146,12 @@ census or a reflection/name heuristic. | MembershipProfile | Display name and locale preference | Both personal scalars marked for snapshot redaction; fixed diagnostics | | Assignment | Membership/person linkage and assignment history | Exact paths under Membership row; never log raw DTOs | | Role/Grant | Built-in key, capability scope, scoped keys, tenant and inherited actor references | None only for reviewed nonpersonal metadata; actor/linkage paths stay classified | +| Invitation | Recipient email; current/consumed verifier; inviter, acceptor, result Membership, Role/version and generation/job references | Email/verifiers are redacted in snapshots; actor/relationship links remain classified; closed disposition is centrally captured, with no value-bearing diagnostics | -No credentials/token/browser binding, global active scope or dormant custom-field -blob belongs in these models. Publication does not make arbitrary tenant text -diagnostically safe. SQL-only/control/vendor inventory remains in canonical +No provider credentials, raw invitation token, browser binding, global active +scope or dormant custom-field blob belongs in these models. Invitation verifiers +remain the explicitly sensitive one-way credential fields described above. +Publication does not make arbitrary tenant text diagnostically safe. SQL-only/control/vendor inventory remains in canonical [Data Protection](../../architecture/23-data-protection.md), not another durable-store list. Translate expected conflicts/upstream failures to fixed bounded descriptors diff --git a/docs/modules/identity/permissions.md b/docs/modules/identity/permissions.md index 3f4dc3c96..5f435b56d 100644 --- a/docs/modules/identity/permissions.md +++ b/docs/modules/identity/permissions.md @@ -39,6 +39,7 @@ adoption supplies identity facts; it does not independently grant seed authority | `Membership` | – | – | – | Tenant + Organization | Exact Standard 19 v1 manifest; includes contained assignments | | `Role` | – | – | – | Tenant | Tenant Admin alone; authoring stays tenant-wide | | `MembershipProfile` | – | Tenant + Organization | – | – | Exact v1 manifest, bounded contextual fields | +| `Invitation` | – | – | – | Existing Tenant + Organization membership-admin | ADR-0093's exact invitation operations; no new assignable key | Empty cells mean no foundation key exists. Bootstrap/seed verification reads do not invent directory/read keys; their exact owner/audit classification is @@ -120,6 +121,56 @@ original-target disclosure and never reuses the former mutation permission. The [Standard 05 read authority contract](../../standards/05-database.md#operation-bound-read-authority) owns immutable invocation/transaction scope and EF/RLS agreement. +## Verified Invitation Contract + +**Accepted under [ADR-0093](../../decisions/0093-verified-invitation-acceptance-and-handoff.md); +runtime delivery and proof remain P03-4.** The +[API contract](../../standards/04-api-design.md#verified-invitation-transport) +owns exact method/path/body/response shapes. These bindings extend the closed +policy census; acceptance does not claim production subjects already exist. + +| Application consumer | Capability / exact authority | Audit and boundary | +|---|---|---| +| `ListInvitationsQuery`, `GetInvitationQuery` | Applicable membership-admin, exact current tenant/nullable organization | MUST sensitive read; no verifier or raw link; no tenant-wide read widening | +| `ListInvitableRolesQuery` | Same current capability/scope and whole-Role ceiling | MUST sensitive read; delegable ID/key/scope/version summaries only, no role-admin or grant graph | +| `CreateInvitationCommand` | Applicable membership-admin and fresh whole pinned-Role delegation | MUST create; offer, generation 1, encrypted mail custody and identifier-only typed job staged atomically | +| `RevokeInvitationCommand` | Current exact-scope membership-admin withdrawal authority and exact Pending target/version; no offered-Role pin or grant-ceiling requirement | MUST update; Accepted/Revoked states remain terminal | +| `ReissueInvitationCommand` | Same capability, exact target/version, current delegation and unchanged Role pin | MUST update; protected keyed new generation/expiry invalidates old verifier; no raw-code response | +| `ReadInvitationReceiptQuery` | Fresh applicable membership-admin and original exact Invitation scope | MUST read; no mutation rerun, secret or successor substitution | +| `InspectInvitationQuery` | Exact native source, host/offer witness, Active bound actor and fresh current verified recipient | Off, physical ReadOnly; only the own matching Pending offer | +| `AcceptInvitationCommand` | Same exact entry, repeated fresh recipient evidence and current stored-inviter delegation | Two central MUST resource intents, Invitation plus designated Membership; one atomic ordinary ReadWrite owner | +| `GetOwnInvitationResultQuery` | Consumed digest, same Active actor and original current Active Membership | Off, physical ReadOnly; no email/inviter/Role/expiry revalidation, new grant or replacement Membership | + +Applicable capability means eligible Tenant `identity.membership.admin` or the +exact current Organization pair. Tenant authority does not widen the selected +invitation write scope; orgless offers require explicit orgless context. One +compatible Role is pinned by version: Tenant Role iff organization is NULL, +Organization Role iff non-NULL. Its complete current grants must fit the current +inviter's ceiling, including only the established same-key Tenant-to-Organization +bridge. That bridge never permits a cross-scope Role assignment. Acceptance +reads the stored inviter from the locked offer on the same owner; it cannot +mint another actor's HTTP candidate or use a supplied arbitrary UserId. + +Acceptance retains sorted exclusive invitee/inviter User fences before the +strongest actual Tenant mode, Role, Invitation and Membership locks. Shared +Tenant mode remains for nonadministrator effects; actual eligible Tenant Admin +effects require exclusive. Changed post-wait mode refuses 409 without upgrade. +The fresh global invitee check also covers unchanged Active `already_member`. +Suspended refuses without consumption; Revoked history receives a new successor. +The disposition is captured Invitation state. The unchanged Membership's row is +subject-only security evidence with no fabricated creation diff or new audit +metadata channel. Failed MUST durability rolls both roots back. A preflight +Accepted offer dispatches result read before mutation intents; a Pending race +loser records truthful failure and reconciles through a fresh result read. + +Invitation entry is separate from the three global-self descriptors and ordinary +native scope. It grants no directory, AccountSite, general protected read/write, +Platform authority or session. Global bootstrap commits/disposes before tenant +work and remains opt-in. Membership success still requires ordinary explicit +session admission. Private SMTP source custody/job execution is finite trusted +infrastructure admission, not a human permission or SystemActor bypass; Standard +05 and the module specification own its exact source/lease consumers. + ## Proof ownership P03-1 Step 4 proves actual command metadata, the nonempty five-pair registry, diff --git a/docs/modules/identity/providers.md b/docs/modules/identity/providers.md new file mode 100644 index 000000000..d3c32ec12 --- /dev/null +++ b/docs/modules/identity/providers.md @@ -0,0 +1,191 @@ +# Identity — Provider Adapters + +**Derives from:** [ADR-0093](../../decisions/0093-verified-invitation-acceptance-and-handoff.md). +This is the Accepted bounded invitation provider contract; the +[Phase 03 Status block](../../roadmap/phase-03-identity-admin.md) owns implementation +and qualification. Acceptance is not evidence of a configured production sender. + +## Current recipient UserInfo + +| Boundary | Source binding | +|---|---| +| Application port | Identity Application.Contracts `ICurrentInvitationRecipientReader`; only the current inspection or initial acceptance invocation | +| Credential owner | API `ValidatedInvitationCredential`, captured from the actual stock-validated bearer; neither a reparsed header nor a caller-supplied token is authority | +| Provider adapter | API `CurrentInvitationRecipientReader` and its fixed backchannel; configured tenant issuer only, no supplied endpoint, redirect, cookie or certificate override | +| Observation | One fresh request/token/invocation-bound evidence object; normalized mailbox matching belongs to the exact offer handler, with no User email mirror or reusable inspection proof | +| Accepted recovery | `GetOwnInvitationResultQuery` does not call this provider; it reads the original same-actor Membership outcome under normal host/lifecycle availability | + +[Security Standards](../../standards/11-security.md#recipient-atomicity-and-fences) +own the claim grammar, response/deadline limits and no-business-lock observation +boundary. Actual transport cleanup precedes evidence publication; provider +exception payloads do not cross the adapter. The finite +[API contract](../../standards/04-api-design.md#verified-invitation-transport) +owns bounded failure responses. No realm-admin credential or account search is +introduced. Keycloak owns verification email separately from LearnStack's +invitation SMTP sender; the [realm setup](../../../infra/keycloak/README.md) +and phase qualification must establish that provider journey. + +## Invitation SMTP + +| Boundary | Contract | +|---|---| +| Ordinary staging | `IInvitationEmailStager`, called only by declared Identity invitation writers; encrypted provider envelope and typed job share their existing business/audit transaction | +| Typed job port | Identity Application.Contracts `IInvitationEmailOperation : IBackgroundJobExternalOperation`; exactly Execute/Reconcile | +| Job adapter | Identity Infrastructure `InvitationSmtpOperation`; prepare/finalize remain exact ordinary Application requests | +| SMTP transport | Common `IEmailProvider`, implemented in common Infrastructure using MailKit/MimeKit 4.18.2; SDK types/exceptions stay inside the adapter namespace | +| Resilience | `IProviderResilience`, configuration `Resilience:email`; retry disabled, finite timeout/circuit-breaker/bulkhead configuration; actual task/socket cleanup remains owned | +| Capacity | Tenant-wide `identity.invitation_email`, ceiling 4, no new Hard limit; existing external queue/pool/worker counts | +| Custody | Common Infrastructure SQL-only provider envelope/attempt records and the exact provider connector; no Identity aggregate or additional invitation root | + +The [database contract](../../standards/05-database.md#invitation-smtp-provider-storage) +is the sole owner of encryption/key-ring format, provider tables and grants, the +two SECURITY DEFINER functions, receipt authority, connector bounds and uncertain +COMMIT handling. The [infrastructure contract](../../standards/20-infrastructure-stack.md#bounded-invitation-smtp-consumer) +owns composition with the existing job runtime. Neither general Phase 08a +notification orchestration nor a tenant-selectable SMTP provider is introduced. + +Create/reissue freezes the actual enabled tenant locale tag in `EntryLocale`. +Email copy selects the Turkish catalogue for a `tr` language subtag and otherwise +the English fallback, following the +[UI/content distinction](../../decisions/0027-frontend-i18n.md). A regional tag +such as `tr-TR` or `en-US` does not require a separate literal `tr` or `en` tenant +locale. The frozen tag and enabled content locales are not rewritten by copy +fallback. Email links use the fixed `/invitations` path, with the code only in +the fragment; no content-locale prefix selects an invitation route. + +## Deployment configuration + +- One operator-configured SMTP host/port and sender apply to this deployment. + Message content and caller input cannot select a host, credentials, sender or + alternate transport. Validate addresses and reject header injection. +- Production requires authenticated TLS with certificate/hostname validation; + implicit TLS or required STARTTLS is explicit configuration, without plaintext + fallback or opportunistic downgrade. Missing or invalid configuration fails + qualification; a log-only/null sender cannot report success. +- Development uses the existing local Mailpit endpoint. A development plaintext + connection is an explicit Development-only composition choice; the module never + branches on DeploymentMode. A reachable local capture service proves local SMTP + submission, not an external mailbox delivery or production configuration. +- Resolve credentials and the named encryption key ring at startup through + `ISecretProvider`, following the existing fixed-for-process secret lifecycle. + Keys/credentials do not enter checked-in settings, DTOs, jobs, logs or audit. + Retain decryption versions while provider envelopes need them; a lost key is a + visible custody failure, not permission to generate another token silently. +- Disable SMTP protocol logging and reject provider exception/message/body text + at the boundary. Expose only closed outcome/status diagnostics. Preserve the + SDK-exception translation and sensitive-message controls from the common + provider-adapter standards. + +## Delivery and recovery meaning + +`queued`, `submission_accepted`, `failed` and `unresolved` describe provider work; +only the Invitation acceptance owner can report an accepted membership offer. +A final SMTP 250 establishes submission acceptance, never inbox delivery. +Definite no-effect temporary failures may use the existing bounded job retry; +permanent rejection is final. Positive pre-send cancellation fences the old +provider claim and remains distinguishable from framework no-ExecuteIntent proof. + +Reconcile reproduces the exact durable provider-attempt receipt without sending. +A crash or lost final response before that receipt remains Unknown with its +reservation retained. A stable Message-ID is identification, not deduplication. +An explicit administrator resend creates new generation/token/job and invalidates +the old acceptance token; it cannot release an old unknown reservation or promise +that previously started mail will not arrive. Duplicate or delayed notices never +authorize a second membership grant. + +Provider qualification includes final-250-before-runtime-checkpoint restart, +crash-before-provider-receipt quarantine, fenced delayed Execute, exact terminal +receipt replay/conflict refusal, late original-owner evidence, uncertain provider +COMMIT, no hidden retry after possible acceptance, retained actual cleanup and +cross-tenant/org ciphertext/function refusal. Local qualification and production +SMTP configuration remain separate evidence. + +## Configuration surface and source bindings + +The API resolves `InvitationEmail:KeyRing` through `ISecretProvider` once at +startup. Its JSON shape is `{ "active": "version", "keys": { "version": +"base64-encoded-32-byte-key" } }`; one to four distinct keys and a declared active +version are required. This is a secret value, never a checked-in example key. +SMTP settings are `InvitationEmail:Smtp:Host`, `Port`, `Sender`, and `Tls` +(`RequiredStartTls` or `ImplicitTls`); `Username` and `Password` resolve through +`ISecretProvider`. `DevelopmentPlaintext` is admitted only by Development host +composition. Missing configuration prevents startup. No connection is made by +configuration validation. + +The paired local API launcher selects only these seven email configuration +keys from matching private-file/process sources. Values are compact single-line +strings of at most 8 KiB in UTF-8; SMTP credentials may be empty for explicit +Development plaintext. The API owns key-ring, address, TLS and credential tuple +validation. API file selection uses one no-follow handle and requires a regular, +single-link, current-user-owned source with owner-only permissions, valid UTF-8 +and at most 256 KiB. Ordinary web and Seeder child environments receive none of +these settings. CI public-demo construction uses disposable synthetic settings and +does not qualify SMTP delivery; local operator configuration has no generated +fallback key or sender. + +For the paired launcher's value-free `Local public configuration or launch +failed` diagnostic, check the selected source's file properties, UTF-8/size and +matching settings first. After verifying that the path is your own regular, +nonsymlink, single-link file, use `chmod 600` on that exact file if permissions +need correction. Check compact key-ring JSON and the SMTP tuple privately; +printing the environment is not a diagnostic step. A source-selection fix does +not establish SMTP connectivity or delivery. + +Two active definition keys, `identity.invitation_email_tenant.v1` and +`identity.invitation_email_organization.v1`, bind four exact ordinary roots: +`PrepareTenantInvitationEmailCommand`, `FinalizeTenantInvitationEmailCommand`, +`PrepareOrganizationInvitationEmailCommand`, and +`FinalizeOrganizationInvitationEmailCommand`. Their shared parameters/plan are +three GUIDs (InvitationId, GenerationId, SpoolId); the outcome is only ReceiptId. +The scope-specific roots share logical audit operations, not request authority. + +The stager and provider admission verify the actual database logging settings +listed in [Database Standards](../../standards/05-database.md#invitation-smtp-provider-storage). +The invitation creation/reissue owner must qualify privacy before its first +person-field write. A clean provider connection does not retroactively protect an +earlier business INSERT. The staging implementation accepts only those exact +writer identities. Create and Reissue call the real stager/scheduler/envelope +writer on their announced business owner. A failed MUST write rolls their work +back together; no test-only producer exemption supplies production authority. +Admin status uses only the authorized root's current JobId. Missing-job data is +finite Unavailable; SQL failure/private timeout fails the whole audited read with +503 and rollback-only state, while actual caller cancellation remains cancellation. +A page shares a five-second status budget under the parent request deadline. + +## Setup, key rotation and restoration runbook + +The deployment operator owns these actions through the existing +[secret configuration](../../standards/12-infrastructure.md#secrets-management) +boundary. Never place keys, credentials or captured invitation messages in a +ticket, terminal transcript, repository, test report or deployment log. + +1. Generate a cryptographically random 32-byte encryption key in the approved + secret store. Give it a new version identifier and configure the key ring and + fixed SMTP settings above. The default configuration provider reads these + exact configuration keys; environment variables use `__` in place of `:`. + Development plaintext requires the Development environment and explicit TLS + mode; production uses authenticated required TLS. +2. Apply the [database privacy settings](../../standards/05-database.md#database-diagnostic-privacy-qualification) + before enabling invitation writes. Qualify independent collectors separately. + Startup configuration validation makes no SMTP connection and proves neither + credentials nor delivery. Use the packet's isolated provider controls before + enabling the real invitation producer. +3. For rotation, retain every old key required by retained envelopes, add a + distinct key/version and select it as active. Restart through the normal + owned shutdown/startup path because the key ring is fixed for the process. + New envelopes use the new version; old envelopes keep their original version. + Never reuse a version for different key bytes. Four retained versions are the + limit, not permission to discard an older required key to make room. +4. Retire a version only after an authorized retention assessment proves no + retained envelope or required restoration backup needs it. Application + credentials intentionally cannot enumerate provider custody. Phase 11 owns + the production retention/purge workflow; expiry or a terminal job alone is + not key-retirement evidence. +5. Restore missing keys from the protected backup under their original version + identifiers, then restart. Never substitute a new key or regenerate the + encrypted link. An unclaimed job can continue through normal admission; + already-Sending/Unknown work remains uncertain until authoritative recovery. + Restoring a key is not proof that SMTP never accepted an earlier attempt. + [P03-9](../../roadmap/phase-03-identity-admin.md) owns privileged unresolved-work + recovery. Explicit reissue neither releases that reservation nor prevents + already-prepared old mail from arriving. diff --git a/docs/modules/tenancy/README.md b/docs/modules/tenancy/README.md index da160a7fc..4e25974cb 100644 --- a/docs/modules/tenancy/README.md +++ b/docs/modules/tenancy/README.md @@ -270,6 +270,37 @@ read/selection budget in P02d-3. The [accepted package](../../roadmap/phase-02d-delivery-log.md#p02d-3-decision-package-2026-10-02) records the tests and step plan. +### Invitation lifetime policy + +[ADR-0093](../../decisions/0093-verified-invitation-acceptance-and-handoff.md) +adds the exact typed tenant-wide setting `identity.invitation.ttl_days`, default +14 days, integer range 1–30. The [phase Status](../../roadmap/phase-03-identity-admin.md) owns delivery. +Tenancy owns the setting/typed accessor; Identity consumes it through the existing +application-contract mechanism, not a table query or generic JSON export. + +- `SetTenantInvitationLifetimeCommand` writes one TenantSetting root in orgless context + under the existing Tenant `tenancy.setting.write` pair. The permission knowingly + gains this exact policy key beyond branding; no organization override, generic + setting editor or new permission grant is implied. +- Existing v1 manifests without that pair cannot edit the policy. The 14-day + default still permits ordinary invitations; no automatic grant adoption is added. +- The own-current-tenant orgless getter is Off and physically ReadOnly. It returns + `days`, `present`, nullable decimal-string `version` and advisory `canEdit`; + write capability is not required to read it. The setter still checks its own + current authority independently. +- Reads return the explicit absent/default creation precondition or actual setting + version. Absence has no ETag and uses `If-None-Match: *`; a stored version-zero + setting has ETag `"0"` and requires `If-Match: "0"`. Both successful PUT branches + return 200 version receipts. The protected fingerprint distinguishes presence. Conditional create/replacement plus natural uniqueness refuses races; + no unconditional upsert or blind retry. Protected receipts bind the original + exact setting ID/key and require a fresh MUST sensitive read before disclosure. +- The setter uses the canonical effect-derived Tenant fence and fresh lifecycle/ + post-lock authority check. Its MUST `tenancy.setting.write` row shares the + business transaction and retains whole-value TenantSetting audit redaction. +- Offer creation and explicit reissue freeze expiry from current policy. Policy + changes do not alter existing generations or require a Tenancy/Identity + multi-root write. Identity owns acceptance and all delivery state. + ### Job lifecycle projection Tenancy declares the status meanings for ADR-0047's closed job lifecycle @@ -310,6 +341,7 @@ translations. Missing/invalid configuration or default removal/disable refuses; select another enabled default first. `branding.theme` remains one complete TenantSetting root/version and its existing safe palette grammar. Organization settings/branding overrides and generic settings power remain outside this set. +P03-4 adds only the exact tenant-wide invitation lifetime policy above. All human writes require orgless context and live post-lock authority/target checks. [Standard 05](../../standards/05-database.md#p03-3-storage-and-replay-contract) owns the strongest complete fences, fresh lifecycle reads and invoker guards. diff --git a/docs/modules/tenancy/audit.md b/docs/modules/tenancy/audit.md index a6ebfbb86..d0dad85b4 100644 --- a/docs/modules/tenancy/audit.md +++ b/docs/modules/tenancy/audit.md @@ -284,6 +284,28 @@ The model finalizer and complete census reject undeclared/new neutral fields; provider/domain-verification and privileged toggle writers retain their named phase authority and fixed-diagnostic requirements before activation. +## Invitation policy binding + +[ADR-0093](../../decisions/0093-verified-invitation-acceptance-and-handoff.md) +adds the exact tenant-wide `identity.invitation.ttl_days` policy. Its setter is a +consumer of the existing MUST `tenancy.setting.write` operation, +not a second catalogue slug. `SetTenantInvitationLifetimeCommand` writes one +TenantSetting with tracked whole-value redaction and same-transaction audit; +conditional first creation/replacement cannot silently upsert. Branding retains +its separate typed request and the same established audit operation. + +| Resource | Operation | Class | Why | +|---|---|---|---| +| Original invitation-policy TenantSetting outcome | `tenancy.setting.read_receipt` | **MUST** (`read-sensitive`) | `ReadInvitationPolicyReceiptQuery`; original setting ID/key, current exact orgless authority and committed read evidence before protected replay. | + +The internal typed accessor creates no separate request/event. The existing +Tenant `tenancy.setting.write` pair knowingly covers this exact policy beyond +branding. Historical v1 Roles without it cannot edit; default 14 days works +without a new grant. [The module contract](README.md#invitation-lifetime-policy) +owns range/default and expiry freezing; [Phase 03 Status](../../roadmap/phase-03-identity-admin.md) +owns implementation and qualification. These rows supply no completed census or +provider/browser proof. + ## Bounded administration bindings Internal human requests and catalogue bindings implement @@ -339,3 +361,8 @@ independent resource intent or general cross-tenant directory. Own-current-Tenan navigation uses the separate invocation-bound ordinary ReadOnly contract and also adds no independent resource intent. AccountSite remains Off under its actual-host ceiling, including the separately validated native-session origin. + +`GetTenantInvitationPolicyQuery` is explicit Off/physical ReadOnly own- +current-tenant navigation in orgless scope. It returns only the fixed policy +value/version/default-presence precondition and advisory canEdit; it grants no +write authority and reads no arbitrary setting. Its Off registration precedes dispatch. diff --git a/docs/modules/tenancy/permissions.md b/docs/modules/tenancy/permissions.md index 0e2025887..3cf25e796 100644 --- a/docs/modules/tenancy/permissions.md +++ b/docs/modules/tenancy/permissions.md @@ -104,7 +104,7 @@ authority. Their named Phase 02c/P03-9 surfaces retain separate admission decisi |----------|:----:|:-----:|:------:|:-----:|---------------------| | `Organization` (`organization`) | – | – | – | Tenant | Exact v2 Tenant Admin delta; only admitted Organization/default operations | | `TenantLocale` (`locale`) | – | Tenant | – | – | Same delta; only admitted locale operations through Tenant root | -| `TenantSetting` (`setting`) | – | Tenant | – | – | Same delta; only whole tenant-wide `branding.theme` | +| `TenantSetting` (`setting`) | – | Tenant | – | – | Same delta; whole tenant-wide `branding.theme` plus ADR-0093's exact invitation policy | `Tenant` has no `delete`: deprovisioning has no owning phase, and [Database Standards § GRANT matrix](../../standards/05-database.md) records that @@ -136,6 +136,7 @@ Organization editor capabilities require a separate Phase 06 decision/consumer. | `RemoveTenantLocaleCommand` | Same | Tenant | | `SetManagedTenantBrandingCommand` | `tenancy.setting.write` | TenantSetting | + These requests share module business logic with the existing Seed acts without dispatching them or obtaining Seed provenance. `TenancyOperationPolicySource` classifies each human request separately; [audit](audit.md#bounded-administration-bindings) @@ -152,3 +153,31 @@ unit of work and two MUST intents. No general host-mapping route or permission is introduced. The [module contract](README.md#warm-seed-and-administration-origins) and [Standard 05](../../standards/05-database.md#p03-3-storage-and-replay-contract) own exact witness/adoption/fence rules and write-free repeats. + +## Invitation lifetime policy + +**Derives from:** [ADR-0093](../../decisions/0093-verified-invitation-acceptance-and-handoff.md). +This Accepted extension adds the exact tenant-wide `identity.invitation.ttl_days` +setting operation to existing Tenant `tenancy.setting.write`; runtime/proof +remain P03-4 in the phase Status block. Existing holders gain this bounded +operation. No new pair, release, automatic grant or generic settings power is +introduced. A v1 tenant without the pair uses the 14-day default but cannot +edit; the explicit v2 adoption remains the only exceptional new-key grant path. + +| Application consumer | Exact authority | Root / audit | +|---|---|---| +| `GetTenantInvitationPolicyQuery` | Own-current-tenant Membership coverage, orgless context | Only default/current bounded lifetime and absent/default or root version precondition; Off and physical ReadOnly | +| `SetTenantInvitationLifetimeCommand` | Tenant `tenancy.setting.write`, orgless context, fresh target/version checks | One TenantSetting root, fixed key, integer 1–30 days; MUST update and protected keyed receipt | +| `ReadInvitationPolicyReceiptQuery` | Current own-Membership coverage in the same orgless tenant | Exact original retained TenantSetting ID/key; MUST read, no old write-permission requirement or other-setting disclosure | + +First creation requires the explicit absent/default precondition and natural-key +uniqueness; a race refuses, never becomes an unconditional upsert. Later writes +require canonical strong If-Match. Policy changes do not extend prepared links; +each initial send or explicit reissue freezes its current policy expiry. Read +contracts supply no arbitrary setting directory or Organization setting variant. +Identity consumes the bounded policy through Tenancy's application contract, +never direct Tenancy SQL. [Standard 19](../../standards/19-permissions.md#invitation-capability-extension) +owns grant adoption semantics and the +[API standard](../../standards/04-api-design.md#verified-invitation-transport) +owns exact transport/replay consumers. + diff --git a/docs/roadmap/phase-03-delivery-log.md b/docs/roadmap/phase-03-delivery-log.md index bcc69436c..9e9f7fdae 100644 --- a/docs/roadmap/phase-03-delivery-log.md +++ b/docs/roadmap/phase-03-delivery-log.md @@ -145,7 +145,7 @@ not acceptance of implementation choices. Navigate by the generated index below. - [P03-2 PR 39 cancellation control review round 1 (2026-10-28)](#p03-2-pr-39-cancellation-control-review-round-1-2026-10-28) — 18 lines - [P03-2 PR 39 cancellation control review round 2 (2026-10-28)](#p03-2-pr-39-cancellation-control-review-round-2-2026-10-28) — 17 lines - [P03-2 merge and closeout (2026-10-28)](#p03-2-merge-and-closeout-2026-10-28) — 62 lines -- [P03-3 — Protected Studio and membership administration](#p03-3--protected-studio-and-membership-administration) — 9,799 lines +- [P03-3 — Protected Studio and membership administration](#p03-3--protected-studio-and-membership-administration) — 9,852 lines - [P03-3 entry review and provider feasibility (2026-10-28)](#p03-3-entry-review-and-provider-feasibility-2026-10-28) — 91 lines - [P03-3 proposed decision package (2026-10-28)](#p03-3-proposed-decision-package-2026-10-28) — 254 lines - [P03-3 first proposal review and revised contract (2026-10-28)](#p03-3-first-proposal-review-and-revised-contract-2026-10-28) — 197 lines @@ -342,7 +342,43 @@ not acceptance of implementation choices. Navigate by the generated index below. - [P03-3 hosted VM compatibility validation and corrective CI handoff (2026-11-04)](#p03-3-hosted-vm-compatibility-validation-and-corrective-ci-handoff-2026-11-04) — 39 lines - [P03-3 PR 40 review reconciliation and recovery corrections (2026-11-04)](#p03-3-pr-40-review-reconciliation-and-recovery-corrections-2026-11-04) — 76 lines - [P03-3 warm-provider visitor-window pacing correction (2026-11-04)](#p03-3-warm-provider-visitor-window-pacing-correction-2026-11-04) — 41 lines - - [P03-3 pending seed safeguard and documentation cleanup (2026-11-04)](#p03-3-pending-seed-safeguard-and-documentation-cleanup-2026-11-04) — 30 lines + - [P03-3 pending seed safeguard and documentation cleanup (2026-11-04)](#p03-3-pending-seed-safeguard-and-documentation-cleanup-2026-11-04) — 31 lines + - [P03-3 merge and delivery closeout (2026-11-04)](#p03-3-merge-and-delivery-closeout-2026-11-04) — 52 lines +- [P03-4 — Invitations and bounded delivery](#p03-4--invitations-and-bounded-delivery) — 1,649 lines + - [P03-4 document review and proposed decision package (2026-11-04)](#p03-4-document-review-and-proposed-decision-package-2026-11-04) — 148 lines + - [P03-4 proposal review corrections and approval readiness (2026-11-04)](#p03-4-proposal-review-corrections-and-approval-readiness-2026-11-04) — 45 lines + - [P03-4 automatic-email review corrections and accepted plan (2026-11-04)](#p03-4-automatic-email-review-corrections-and-accepted-plan-2026-11-04) — 116 lines + - [P03-4 Step 1 first review corrections (2026-11-04)](#p03-4-step-1-first-review-corrections-2026-11-04) — 24 lines + - [P03-4 Step 1 second review and contract closeout (2026-11-04)](#p03-4-step-1-second-review-and-contract-closeout-2026-11-04) — 15 lines + - [P03-4 Step 2 foundation and entry activation boundary (2026-11-04)](#p03-4-step-2-foundation-and-entry-activation-boundary-2026-11-04) — 32 lines + - [P03-4 Step 2 storage validation and review handoff (2026-11-04)](#p03-4-step-2-storage-validation-and-review-handoff-2026-11-04) — 23 lines + - [P03-4 Step 2 second review UUID correction (2026-11-04)](#p03-4-step-2-second-review-uuid-correction-2026-11-04) — 18 lines + - [P03-4 Step 2 review closeout and SMTP continuation (2026-11-04)](#p03-4-step-2-review-closeout-and-smtp-continuation-2026-11-04) — 25 lines + - [P03-4 Step 3 SMTP foundation and review handoff (2026-11-04)](#p03-4-step-3-smtp-foundation-and-review-handoff-2026-11-04) — 49 lines + - [P03-4 Step 3 first review startup correction (2026-11-04)](#p03-4-step-3-first-review-startup-correction-2026-11-04) — 33 lines + - [P03-4 Step 3 second review privacy inventory correction (2026-11-04)](#p03-4-step-3-second-review-privacy-inventory-correction-2026-11-04) — 24 lines + - [P03-4 Step 3 review closeout and administrator continuation (2026-11-04)](#p03-4-step-3-review-closeout-and-administrator-continuation-2026-11-04) — 30 lines + - [P03-4 Step 4 administration and producer review handoff (2026-11-04)](#p03-4-step-4-administration-and-producer-review-handoff-2026-11-04) — 68 lines + - [P03-4 Step 4 first review correction (2026-11-04)](#p03-4-step-4-first-review-correction-2026-11-04) — 23 lines + - [P03-4 Step 4 second review closeout and Step 5 start (2026-11-04)](#p03-4-step-4-second-review-closeout-and-step-5-start-2026-11-04) — 28 lines + - [P03-4 Step 5 recipient admission and atomic acceptance review handoff (2026-11-05)](#p03-4-step-5-recipient-admission-and-atomic-acceptance-review-handoff-2026-11-05) — 70 lines + - [P03-4 Step 5 first review diagnostic correction (2026-11-05)](#p03-4-step-5-first-review-diagnostic-correction-2026-11-05) — 32 lines + - [P03-4 Step 5 second review delegation and transport corrections (2026-11-05)](#p03-4-step-5-second-review-delegation-and-transport-corrections-2026-11-05) — 40 lines + - [P03-4 Step 5 review closeout and Step 6 continuation (2026-11-05)](#p03-4-step-5-review-closeout-and-step-6-continuation-2026-11-05) — 28 lines + - [P03-4 Step 6 Studio and native invitation implementation (2026-11-05)](#p03-4-step-6-studio-and-native-invitation-implementation-2026-11-05) — 90 lines + - [P03-4 Step 6 first review recovery and quota corrections (2026-11-05)](#p03-4-step-6-first-review-recovery-and-quota-corrections-2026-11-05) — 48 lines + - [P03-4 Step 6 second review original-result and form corrections (2026-11-05)](#p03-4-step-6-second-review-original-result-and-form-corrections-2026-11-05) — 55 lines + - [P03-4 Step 6 implementation review closeout and Step 7 continuation (2026-11-05)](#p03-4-step-6-implementation-review-closeout-and-step-7-continuation-2026-11-05) — 37 lines + - [P03-4 Step 7 actual SMTP owner and recovery proof implementation (2026-11-05)](#p03-4-step-7-actual-smtp-owner-and-recovery-proof-implementation-2026-11-05) — 78 lines + - [P03-4 Step 7 final reviews and PR readiness (2026-11-05)](#p03-4-step-7-final-reviews-and-pr-readiness-2026-11-05) — 52 lines + - [P03-4 Step 7 first hosted corrections (2026-11-05)](#p03-4-step-7-first-hosted-corrections-2026-11-05) — 80 lines + - [P03-4 Step 7 correction review closeout and CI candidate (2026-11-05)](#p03-4-step-7-correction-review-closeout-and-ci-candidate-2026-11-05) — 44 lines + - [P03-4 Step 7 second hosted corrections (2026-11-05)](#p03-4-step-7-second-hosted-corrections-2026-11-05) — 54 lines + - [P03-4 Step 7 second correction review closeout (2026-11-05)](#p03-4-step-7-second-correction-review-closeout-2026-11-05) — 35 lines + - [P03-4 Step 7 third hosted A and census repair (2026-11-05)](#p03-4-step-7-third-hosted-a-and-census-repair-2026-11-05) — 65 lines + - [P03-4 Step 7 census repair review closeout (2026-11-05)](#p03-4-step-7-census-repair-review-closeout-2026-11-05) — 33 lines + - [P03-4 Step 7 fourth hosted A and detector-index repair (2026-11-05)](#p03-4-step-7-fourth-hosted-a-and-detector-index-repair-2026-11-05) — 72 lines + - [P03-4 Step 7 detector-index repair review closeout (2026-11-05)](#p03-4-step-7-detector-index-repair-review-closeout-2026-11-05) — 33 lines @@ -15685,3 +15721,1705 @@ Head `620fdaf3` has complete same-source A/B evidence in that head. Publishing this cleanup creates a new head whose CI/qualification is tracked separately in the existing unmerged PR #40. Manual keyboard, focus, reflow, contrast and VoiceOver remain maintainer-deferred and unexecuted. + + +### P03-3 merge and delivery closeout (2026-11-04) + +The maintainer merges [PR #40](https://github.com/HodeTech/LearnStack/pull/40) +at 15:42:15 Europe/Istanbul (12:42:15 UTC). Reviewed head is `b97214e7`; +the squash merge is `ef344c0f`. Both have tree `f8e89aea`. GitHub PR/commit +metadata independently confirms the merge and identical source tree. +P03-3 implementation delivery, including D03–D05, is complete and merged. +The phase Status block owns packet state; this record owns the closeout evidence. + +Delivered behavior: + +- Provider-authenticated native self-entry, opt-in own-account creation without + implicit membership, own context discovery and tenant/organization switching. +- Protected Studio membership/profile/status/Role operations, explicit permission + release adoption, localized own-profile preferences and editable validation + recovery, including the no-JavaScript flow and dirty-edit extension consent. +- Bounded Tenancy organization/default/locale/setting operations, live host, + Membership and lifecycle checks, durable MUST audit and authorized protected + receipt replay, without broad provider-administration authority. +- Preservation of valid warm-seed edits, fenced continuation/completion checks, + management-origin adoption and owned native response/process cleanup. + +Evidence and its limits: + +- Final-head [PR run 41826206204](https://github.com/HodeTech/LearnStack/actions/runs/41826206204), + attempt 1/A, completes all 27 jobs successfully. The jobs API independently + matches all jobs to `b97214e7`, the run and attempt; none is pending or skipped. +- Parent `620fdaf3` has complete same-source A/B in + [run 41809078251](https://github.com/HodeTech/LearnStack/actions/runs/41809078251), + 27/27 jobs per attempt, 50 API/digest/CRC-verified original archives, the same + 7,951 backend cases, 3,586 frontend cases per attempt and distinct 27-pair + public-demo invocations with confirmed actual cleanup. This is parent evidence. +- The final seed/documentation follow-up also passes its CI=true Release build, + 3,434 unit cases, 46 real PostgreSQL seed-continuation cases, the aggregate seed + architecture control and documentation guards before publication. +- Final-head B and the final-head original-archive audit are not established by + the observed PR run. They remain open P03-3 proof follow-ups; the merge does + not substitute for the required qualification. P03-11 must reconcile them + before phase exit. No new CI run or qualification is asserted by this closeout. +- Manual keyboard/focus/reflow/contrast/VoiceOver checks remain deferred and + unexecuted under the existing maintainer decision. Branch-administrator Role + choices and full organization/settings editors remain Phase 06 work under + the existing permission/disclosure ceiling. + +Next delivery is P03-4: a delegated administrator offers a bounded invitation, +then a verified new/existing invitee joins the intended tenant/organization. +G8 must close before Invitation storage or an acceptance writer: decide trusted +email matching, one-way single-use token storage, lifecycle/expiry/resend, +current delegation, atomic membership/consumption, bounded delivery/recovery and +accept/revoke/expiry races. GET remains inspection-only. This closeout starts +no invitation implementation and accepts no new decision. + +## P03-4 — Invitations and bounded delivery + +### P03-4 document review and proposed decision package (2026-11-04) + +**Proposed; approval pending.** The maintainer requests `/implement-task` on +`development`, existing-document review before implementation, up-front decisions +and a complete step plan. Every implementation step commits, receives two fresh +independent review rounds, fixes confirmed findings in new commits and then +continues automatically. New ADRs require explicit approval before their code. +This package starts no runtime, migration, provider change or CI workload. + +Baseline is `e1339a39`, a clean `development` tree after the user-merged PR #40. +Existing P03-3 final-head B/original-archive and deferred manual evidence remain +as recorded in its merge closeout. They neither become completed nor authorize +waiving P03-4's proof. Settings and existing stashes remain untouched. + +**Problem and phase fit.** P03-4/D06 completes the path from an administrator's +scoped offer to a verified new/existing person's Membership in that exact +institution/branch. It owns C08, its C01 invitation entry and C20 screen proof. +Keycloak authenticates/verifies accounts; Identity owns the offer and grant; +Tenancy owns lifecycle and the bounded lifetime setting; central Audit owns +required evidence. There is no invitation implementation to extend by merely +turning on a route. + +#### Existing-document and source review + +Read repository guidance and `implement-task`/its `start-task` dispatch, then the +README, Platform Vision, MVP Scope, Roadmap, Principles and relevant glossary. +Review the current phase/G8/criteria, completed 02a/02d/02b boundaries, Identity +README/permission/audit matrices, Tenancy lifecycle/settings contracts, +Architecture 13/23 and the Phase 08a notification handoff. Read relevant Accepted +ADR-0003/0036/0040/0042/0064/0070/0072/0073/0074/0076/0077/0078/0079 decisions and +amendments and governing Standards 01/04/05/07/08/11/13/16/18/19/21 sections. +Delivery logs are navigated by index and specific entries, never read wholesale. + +Trace the actual native provider/entry/session owners, exact global-self branch, +source/host/surface admission, operation policy/candidate minters, Membership +write/delegation/lock adapters, protected idempotency and failure diagnostics. +The committed realm pins Keycloak 26.6.2, email mappers and SMTP to existing +Mailpit. Fresh upstream source review checks that version's UserInfo endpoint and +property mapper; real changed-email/verification proof is still required. + +Two independent initial read-only audits cover security/atomicity/data owners +and native experience/delivery/privacy. Their verified findings are decision +inputs, not bugs attributed to completed P03-3: + +| Confirmed gap or promise | Proposed action / owning carrier | +|---|---| +| G8 is Open; ordinary native entry needs existing Membership and global-self has exactly three descriptors | Separate exact invitation admission, host/offer witness and finite consumers; ADR-0093, Standards 05/11, source/caller census | +| Neither `VerifiedProviderSubject` nor User stores current verified email | New request-bound current UserInfo adapter, exact subject and verified mailbox checks; no mirror or provider admin credential | +| ADR-0042 permits only Tenant provisioning to write two roots | Enumerated same-module Invitation/Membership acceptance exception; plural MUST evidence and atomic rollback; dated successor disclosure | +| Inviter is another actor, not the current HTTP candidate | Offer-derived same-owner delegation read; no fabricated HTTP candidate or arbitrary User lookup | +| Generic/protected replay can retain response bytes; version receipt has no raw-secret slot | One-response handoff outside replay storage; explicit version-checked replacement after lost response | +| Suspended/Active current uniqueness and terminal Revoked predecessors already exist | No acceptance reactivation/elevation; Active `already_member`; Revoked creates one successor | +| Token-bearing path/query/returnTo can reach logs/provider/other hosts | Fragment-only native landing, immediate scrub, bounded server custody and manual-code/no-JavaScript fallback | +| Blanket `no-referrer` can break the existing exact-Origin no-JavaScript forms | Clean confirmation uses the established compatible policy; actual browser controls qualify containment | +| New registration has email verification disabled; seeds alone are not proof | Bounded Keycloak verification-action continuation, existing SMTP/Mailpit and an actual verified nonseed journey | +| Architecture 13 promises configurable default 14-day expiry and useful delivery | Typed Tenancy setting, frozen per-generation expiry and explicit administrator sharing; no fake sent state or notification-engine dependency | +| Branch admin cannot read general Role configuration | Separate current-delegable Role summaries; no general registry/grant disclosure or Role-admin grant | + +**Decision vehicle:** [ADR-0093](../decisions/0093-verified-invitation-acceptance-and-handoff.md) +settles the coupled boundaries. Until acceptance it is a proposal, including its +recommended administrator-handoff delivery choice. Automatic invitation email is +an explicit alternative with additional provider/custody/job/reconciliation work; +it must be selected before the corresponding implementation plan is finalized. + +#### Proposed finite operation surface + +These names describe implementation obligations, not existing endpoints. All API +paths carry `/api/v1`. No arbitrary native proxy, browser bearer or Server Action +is added. Input bodies are strict and at most 8 KiB; no unknown/duplicate fields +or supplied actor/tenant/org authority is accepted. Lists use 20 default/100 +maximum, opaque actor/scope/operation-bound cursors and bounded no-store pages. +The exact serialized DTO/OpenAPI and byte/deadline checks land with each writer. + +| API operation | Application consumer / capability and response | +|---|---| +| GET `/identity/invitations` and `/{id}` | `ListInvitationsQuery` / `GetInvitationQuery`; applicable membership-admin, exact current write scope, MUST read; email/Role/state/expiry/version, never verifier or raw link | +| GET `/identity/invitable-roles` | `ListInvitableRolesQuery`; same capability/scope, current-delegable ID/key/scope/version only, MUST read; no grant graph | +| POST `/identity/invitations` | `CreateInvitationCommand`; normalized email + one Role/version, exact current context, fresh delegation; protected keyed 201 version receipt/Location | +| POST `/identity/invitations/{id}/revocations` | `RevokeInvitationCommand`; fresh target authority, canonical If-Match, protected keyed bounded receipt | +| POST `/identity/invitations/{id}/handoffs` | `PrepareInvitationHandoffCommand`; current delegation, fixed Role pin, canonical If-Match; one secret response, rejects Idempotency-Key; explicit replacement renews generation/expiry | +| GET `/tenancy/settings/invitation-policy` | Tenancy-owned bounded 14-day/default-or-configured policy read through its contract; own-current-tenant coverage, Off; no arbitrary setting value | +| PUT `/tenancy/settings/invitation-policy` | `SetTenantInvitationLifetimeCommand`; Tenant setting-write and orgless, canonical If-Match/protected replay; one TenantSetting root, 1–30 days, MUST setting update | +| POST `/identity/invitation-entry/inspections` | `InspectInvitationQuery`; strict body ID/code, exact native/host/subject/offer admission; current verified recipient, Off physically ReadOnly; bounded own offer/Role/scope labels/version | +| POST `/identity/invitation-entry/acceptances` | `AcceptInvitationCommand`; strict ID/code/observed version, fresh provider proof and locked authority; natural offer/actor convergence, no generic Idempotency-Key; exact Membership/result/version receipt | +| POST `/identity/invitation-entry/results` | `GetOwnInvitationResultQuery`; strict ID/code, exact same Active actor/original Active Membership and consumed verifier; Off physically ReadOnly. No current-email/inviter/Role/expiry requirement for an already committed result, no new mutation intent. The acceptance dispatcher may select this exact read before mutation dispatch when its preflight already observes Accepted. | +| Internal original Invitation receipt read | `ReadInvitationReceiptQuery`; fresh exact admin target disclosure and MUST read; no token, mutation replay or successor substitution | +| Internal original lifetime-policy receipt read | `ReadInvitationPolicyReceiptQuery`; exact original TenantSetting ID and fixed key, orgless own-Membership coverage, MUST read; no generic setting directory or old write-permission requirement | + +Native exact routes add localized `/studio/invitations`, invitation detail and +lifetime controls, plus the fixed localized invitation landing and clean +confirmation. Closed auth POSTs cover capture/inspect, verification, accept, +create/revoke/handoff and lifetime changes. They retain exact Origin/CSRF, +immutable response ownership and current session/scope. The new invitation +continuation can coexist with a live session, but cannot replace another subject. +Existing `canManageMemberships` applicability can expose the corresponding link; +no advisory Boolean becomes a grant. Exact private route/operation census lands +before activation; provider verification accepts only its fixed action/purpose, +not arbitrary OIDC parameters or destinations. + +#### Six implementation steps after approval + +Every step includes relevant tests and docs in its implementation commit. Then +run review round 1 with two independent fresh reviewers, fix verified findings +and commit; run round 2 with two different fresh reviewers, fix/commit again. +Reviewers read contracts and actual adjacent code, assess security, correctness, +UX/accessibility, failure/concurrency, performance and maintainability, and state +limits. Source/line/effect/reproduction distinguish confirmed findings from +preferences. Choose model/effort for the surface; security/transaction/native +entry reviews use high reasoning. Test workloads remain serialized. Do not add +a generic orchestration/proof framework or manufacture findings to fill a round. + +| Step | Deliverable and main carriers | Required proof before moving on | +|---|---|---| +| 1 | Accept G8/ADR-0093; dated disclosures including ADR-0003's owner-execution/definer census, canonical contract/operation/ACL/owner/lock/replay details, module diagrams/matrices, classification inventory, glossary, Status/index/package. Docs only. | Maintainer acceptance; complete carrier reconciliation; fragment/roadmap/index/ADR guards; two independent review rounds. No runtime claim. | +| 2 | Invitation ID/aggregate/state/policy port, EF mapping, forward migration, canonical RLS/FK/unique/transition/attribution guards, exact offer lookup and scope witness foundation. Identity Domain/Application.Contracts/Infrastructure; Tenancy bounded policy read contracts; SharedKernel context seam only as authorized. | Unit lifecycle/validation; real app-role PostgreSQL tenant/org isolation, uniqueness/expiry/terminal guards, exact applied schema/function/ACL and wrong-purpose controls; compile and architecture census. No exposed half-wired endpoint. | +| 3 | Admin create/list/detail/invitable-Role/revoke/handoff; Tenancy lifetime writer; audit/policy/replay integration and OpenAPI/SDK operations. Identity/Tenancy Application/Infrastructure, API, generated SDK. | Real authorized/denied HTTP and direct application dispatch, current delegation/Role pin and lock waits, token-free admin/replay, link loss/replacement/unknown COMMIT, policy version conflicts and MUST rollback. | +| 4 | Fresh recipient adapter, exact pre-Membership HTTP/application admission, invocation-bound scope owner, atomic accept/reconcile and central plural evidence. Infrastructure provider adapter, Identity acceptance application, API closed branch. | Real provider UserInfo/changed email/outage; same-subject binding, User-before-Tenant plan, Active/Suspended/Revoked outcomes, both competing-commit orders, closure fence, unknown COMMIT and failed-audit rollback; wrong-origin/host/purpose and cleanup controls. | +| 5 | Full native continuation, provider verification action and signup/bootstrap reuse, administrator screens/link copy/policy form, invitee confirmation/recovery and post-acceptance session admission. Native auth/response owner, Studio, generated SDK callers, en/tr messages. | Actual HTTPS/browser and no-JavaScript flows, real nonseed verification via provider SMTP/Mailpit, new/existing/different-account cases, scope and form recovery, token leakage/BFCache/late responses/restart; typecheck/lint/full guarded frontend; actual human keyboard/reflow/contrast/VoiceOver checklist. | +| 6 | Complete packet qualification, source-bound receipts, final review fixes and D06/C01/C08/C20 documentation closeout; prepare PR for the maintainer. Existing test owners/workflow, no timer-policy change. | Complete required same-source A/B and original-archive reconciliation, regenerated contract, relevant full backend/frontend/architecture/integration and provider/browser proof; two fresh final review rounds; truthful remaining external/manual evidence. No merge. | + +Workflow skills selected under `implement-task`: `write-adr`, +`add-tenant-owned-entity`, `add-mediatr-handler`, `add-ef-migration`, +`add-audit-coverage`, `add-provider-adapter`, `add-frontend-route`, `add-i18n-key`, +`add-integration-test`, `run-tests-locally`, `standards-check`, `code-review`, +`update-glossary` and `commit-and-pr`. Existing permissions are reused; no new +assignable pair or automatic seed grant is planned. Load each specific workflow +before its implementation rather than assuming its checklist is satisfied. + +#### Up-front maintainer decision and completion boundary + +The approval package covers administrator handoff versus automatic invitation +email; the one-response secret/lost-link policy; case-insensitive bounded mailbox +matching and current-provider-read boundary; 14-day default/1–30-day tenant policy; +one pinned Role; Active no-elevation/Suspended refusal/Revoked successor behavior; +new exact offer scope/definer and same-module two-root exception. No broader +platform/provider administration, old manual deferral or production-readiness +claim is implied. Production provider SMTP remains deployment configuration; +the packet must prove and document its verification/recovery prerequisite. + +The user will review the final PR. Human accessibility checks for newly delivered +screens need actual evidence; prior P03-3 deferral is not copied as a pass or an +automatic waiver. If an external dependency genuinely prevents proof, report the +exact dependency and keep that evidence open instead of marking it complete. +Runtime work waits only for the requested new-ADR decision; independent document +review/validation can continue. Once accepted, routine step choices and verified +fixes proceed without repeated permission requests; a materially new authority or +incompatible product decision returns to the maintainer before implementation. + +### P03-4 proposal review corrections and approval readiness (2026-11-04) + +**Proposed; no G8 acceptance or runtime implementation.** Two independent initial +contract/source audits and two proposal review rounds are complete. Each proposal +round uses two reviewers; round 2 uses different reviewers from round 1. All +reviewers operate read-only apart from their private reports. This qualifies the +decision text for the maintainer's decision, not any database/provider/browser +implementation. + +Confirmed round-1 findings are corrected in the unaccepted draft: + +- Repeat the authenticated invitee's global admission check after the retained + User fence, including Active `already_member` consumption without a Membership + INSERT. The offer-derived function binds its actor to the verified subject; + trigger-only eligibility functions are not turned into arbitrary probes. +- Separate Accepted result disclosure from Pending mutation conditions. The exact + Off `GetOwnInvitationResultQuery` is named in the finite route table and can be + selected before mutation-intent dispatch. It requires the same Active actor, + consumed verifier and original current Active Membership, without reapplying + old version/expiry/inviter/Role/email conditions. A Pending command losing a + race returns truthful conflict/failed-attempt evidence and uses a fresh result + read; it cannot invent a second successful consumption or suppress MUST intent. +- Preserve authorized recipient-email/relationship access/export under P03-10; + distinguish that data from credentials/verifiers, which never enter DSAR + artifacts. Diagnostic/snapshot/event redaction remains mandatory. +- Name exact Invitation and invitation-policy TenantSetting replay targets, + original-resource receipt reads, conditional first policy creation, fixed + verification-action purpose and deterministic verifier encoding. No raw link + enters durable receipt storage. + +Fresh round 2 confirms no Blocker or Major. Its one Minor carrier omission is +fixed and rechecked: acceptance must append an ADR-0003 dated disclosure for the +ninth definer/Invitation owner visibility, alongside the already listed scope, +aggregate, provider/native/policy/replay carriers. ADR-0003's existing body and +four-role/ordinary RLS boundary remain unchanged. Final independent security and +experience verdicts have no open confirmed Blocker, Major or Minor. + +The complete approval surface is ADR-0093 and the preceding six-step package, +including delivery choice, owner/authority costs, exact result/secret recovery, +verification prerequisites and human proof obligations. Current standards and +Accepted ADRs are not rewritten before approval. The optional delivery-preference +question is not itself ADR acceptance; administrator handoff remains the proposed +recommendation until the maintainer decides. No invitation source, schema, +endpoint, provider configuration, test timer or CI run changes in this preparation. + +### P03-4 automatic-email review corrections and accepted plan (2026-11-04) + +**Accepted — @cemil.** The maintainer selects automatic invitation email in this +packet and directs verified review corrections, ADR acceptance and implementation. +This record replaces the earlier Proposed manual-sharing/six-step package. Its +older text and review findings remain dated history, not the current delivery +choice or runtime qualification. ADR-0093 is the accepted G8 contract; Step 1 +reconciles canonical carriers before the first schema/provider/runtime writer. + +#### Verified review actions + +- The actual audit composer has no general business-result metadata channel. + Persist created/already_member disposition and exact result MembershipId on the + tracked Invitation. Its central captured state records that outcome; unchanged + Membership gets a truthful designated subject-only event with NULL changes. + No generic metadata extension or fabricated Membership creation is introduced. +- Explicitly expand only Tenant tenancy.setting.write from branding.theme to + identity.invitation.ttl_days. Existing holders gain this exact policy operation; + v1 without the pair uses 14 days but cannot edit it until explicit v2 adoption. + A new permission/release and automatic seed grant are not selected. +- Keep ADR-0074's complete strongest actual plan: Shared for ordinary effects, + Exclusive only for actual protected Tenant Admin effects. Recheck after waiting; + a changed mode conflicts, never upgrades. The earlier unconditional Exclusive + proposal is discarded, not silently treated as the existing contract. +- Separate clean browser GET from secret-body POST API inspection/result reads; + the former neither sends nor grants. Name exact origin/mode/CSRF/subject owners, + all four existing owner ceilings and the operation-bound eleventh setter. +- Define verifier_digest as application-derived 32-byte digest, not raw secret; + SQL indexed equality is not called constant-time. Separate stored inviter + global admission from complete current delegation; retain exact Role scope/pin. +- Preserve valid new/Active/Suspended/Revoked behavior, terminal-result precedence, + same actor/original Active Membership and explicit no-disclosure after its loss. + Pending expiry/lifecycle/Role/email checks cannot block a committed own-result + read or create a second grant. +- Add automatic encrypted SMTP custody, typed prepare/execute/finalize jobs, + immutable per-attempt receipts, truthful queued/accepted/failed/uncertain state, + explicit reissue and actual restart/response-loss/cleanup proof. No raw message, + code, URL or ciphertext enters generic job/replay/event payloads. + +Some external claims do not describe actual defects: a dated Amendment may point +to a new bounded successor decision without rewriting history; ADR-0041 does not +require a false-at-entry erratum for a new capability. Correct historical numeric +statements are not rewritten merely to restate today's census. ADR-0053/0054 +closed native callers/POSTs do need the added successor navigation. ADR-0033/0044 +composition/durability, ADR-0072 binding/budgets and ADR-0074 lock semantics remain; +no new metadata or global bootstrap exception is smuggled into their names. +The prior reviewers' no-open-findings result was bounded to the then-Proposed +surface; these later verified findings and the new delivery selection supersede +that approval surface. No runtime safety is inferred from either document review. + +#### Current contract and first-consumer ownership + +[ADR-0093](../decisions/0093-verified-invitation-acceptance-and-handoff.md) owns the +decision. Current finite routes/DTO/preconditions are in +[Standard 04](../standards/04-api-design.md#verified-invitation-transport), native +paths/owner/copy are in [Standard 07](../standards/07-frontend-architecture.md#verified-invitation-experience), +source/evidence in [Security](../standards/11-security.md#verified-invitation-admission), +root/entry/provider storage in [Standard 05](../standards/05-database.md#invitation-entry-and-consumption), +and SMTP runtime/source in the [provider contract](../standards/05-database.md#invitation-smtp-provider-storage) +and [Infrastructure](../standards/20-infrastructure-stack.md#bounded-invitation-smtp-consumer). +Module matrices own exact planned audit/policy consumers; their planned markers +are removed only when actual consumers/catalogues land. Architecture controls +are Registered, not falsely promoted by acceptance. + +The first production jobs are Identity's exact TenantWide and Organization email +definitions. Ordinary staging submits the job first to obtain JobId, then encrypts +scope/generation/job-bound message custody on the same existing transaction. +The private provider connector consumes only a validated JobExecutor receipt and +two fixed functions. It never restores external ordinary UoW/tenant access. Total +confined definers are eleven; logical tenant setters eleven; physical context +writers four. SMTP adds two private SQL-only tables and a separate four-slot +source, not a fifth login role, arbitrary SQL callback or notification module. + +A final SMTP 250 proves submission acceptance only. Lost reply/crash before a +durable receipt remains Unknown, with retained occupancy; reissue does not release +that old work. The UI and runbook explain this unavoidable limit. Actual positive +provider evidence, not an absent worker, permits the runtime's existing outcome +transition. Production SMTP/TLS/keyring and Keycloak verification SMTP are distinct +configuration prerequisites; local Mailpit is a real dev/test transport. Existing +creation and five-minute continuation budgets are explicit accepted costs, not +quietly increased in this packet. + +#### Seven-step implementation and review sequence + +Every step implements its own meaningful tests and related docs, then commits on +`development`. Review round 1 uses two fresh independent reviewers; verify/fix +findings and commit. Review round 2 uses two different fresh reviewers; verify/fix +and commit before automatically moving on. Read surrounding contracts/source and +use high reasoning for authority, concurrency, cryptography/provider and native +ownership seams. A clean report is not a quota or proof that unexecuted tests ran. +No broad generic runner/proof framework or unrelated refactor is part of P03-4. + +| Step | Deliverable | Verification before advancing | +|---|---|---| +| 1 | Accepted ADR-0093/G8, dated successor Amendments, canonical API/DB/native/security/audit/permission/provider contracts, module diagrams/inventory, catalogue/glossary/index and this replacement plan. Docs only. | Mechanical document/status/index checks; two fresh independent review rounds against actual seams. No runtime claim. | +| 2 | Invitation domain/ID/EF schema, lifecycle/constraints/RLS, policy contract, exact preliminary lookup and operation-bound scope foundation. | Domain unit tests; actual app-role two-tenant/two-org isolation, shape/uniqueness/terminal/verifier/role-pin guards; applied function/body/ACL/caller and scope cleanup tests; architecture/build. No incomplete endpoint activation. | +| 3 | Minimum automatic SMTP: encrypted provider store/migration, stager, attributed external receipt/four-slot connector, exact two functions, MailKit adapter, two typed jobs and bounded status projection. Consumer foundation remains closed to requests until the real administrator writer in Step 4. | Real SMTP/Mailpit positive/rejection/drop-final-reply, stage/audit/job atomicity, positive pre-send fencing, final250 receipt/restart, crash-before-receipt Unknown, immutable late/contradictory evidence, stale generation, retained slots, key rotation/loss/restore and DB/SMTP leakage. Existing external DB denial remains tested. | +| 4 | Administrator create/list/detail/invitable-Role/revoke/reissue, Tenancy lifetime policy and exact protected replay/SDK contracts. | Independent HTTP/application permission denial, existing-holder/v1 policy cases, version/creation-presence conflicts, fresh scope/Role delegation and concurrency; atomic produced-work/audit rollback, no secret replay. Use the real Step 3 sender/job foundation; no later feature supplies a missing stager. | +| 5 | Fresh recipient provider adapter, exact nonmember API/application admission and atomic consumption/result reconciliation. | Real current UserInfo/changed/unverified email, actual strongest lock modes, post-wait global eligibility/delegation, Active no-elevation audit, Suspended/Revoked, archived scope; both race orders, unknown COMMIT, original result IDs and plural MUST rollback. | +| 6 | Full Studio invitation/lifetime UI; native email landing/capture/verification/signin/bootstrap/confirm/recovery and separate session admission; en/tr and no-JavaScript forms. | Actual new/existing/nonseed verified-provider HTTPS journeys, wrong live account and realm edit settings; expired/replaced/revoked codes, restart/cancel/429/503, history/BFCache/late responses/cleanup; full guarded frontend/typecheck/lint and actual human keyboard/reflow/contrast/VoiceOver evidence. | +| 7 | Complete packet/source-bound proof, review closure and current documentation; prepare PR for the maintainer. | Relevant full backend/frontend/architecture/integration/provider/browser proof, complete required same-source A/B and original-archive reconciliation; two fresh final review rounds, no skipped/claimed manual evidence, no merge. | + +Source/workflow ownership remains `implement-task` with the relevant entity, +handler, migration, background-job, provider-adapter, audit, frontend/i18n, +integration-test, local-validation and commit/review workflows. Existing provider +storage semantics, current contracts and known failure policies are reused only +within their exact named boundaries. A materially incompatible new authority or +product decision returns to the maintainer before code; ordinary decisions and +verified fixes proceed under the current authorization without repeated asks. + +The maintainer will review the final PR. Earlier P03-3 B/archive and manual +follow-ups retain their own identity and status; they are neither marked done nor +silently waived by this acceptance. New human evidence is gathered honestly when +the screens exist. If an external prerequisite genuinely prevents proof, record +that precise dependency and continue independent work without inventing a pass. + +### P03-4 Step 1 first review corrections (2026-11-04) + +Step 1 contract commit `d2924665` receives two fresh independent reviews: one +authority/audit/locking lens and one SMTP custody/job/privacy/experience lens. +These are document/source feasibility reviews, not runtime qualification. + +Verified corrections: + +- Ordinary `GetTenantInvitationPolicyQuery` remains Off/physical ReadOnly; only + original policy receipt disclosure is MUST. Standard 18 is reconciled with the + API and Tenancy matrices. The canonical setter name is + `SetTenantInvitationLifetimeCommand` in both Tenancy carriers. +- Architecture 13 and Data Protection 23 no longer promise business withdrawal + cancels provider work. Withdrawal invalidates codes; Prepare rejects obsolete + generations before its plan commits. Prepared email may still arrive. Recovery's + durable pre-send CAS is distinct; no ordinary provider cancellation writer exists. +- Phase 08a points at ADR-0093's selected minimum SMTP slice and preserves the + full notification-engine ownership boundary. + +The existing runtime distinguishes original attempt identity from fresh recovery +ownership; implementation must retain that distinction and actual connector/SMTP +cleanup. No new runtime evidence or completed architecture-rule status follows. +A second fresh independent review round follows the correction commit. + +### P03-4 Step 1 second review and contract closeout (2026-11-04) + +The correction commit `4d355fc9` and full Step 1 range receive a second round +from two different fresh reviewers: authority/contracts and delivery/custody. +Both approve with no open Blocker or Major. One verified Minor is corrected: the +planned Tenancy receipt row is joined to its Markdown table without an intervening +blank line. The stored inviter remains immutable; reissue never transfers it or +guarantees future acceptance after authority changes. + +Document checks resolve 3,378 fragment links, validate roadmap structure and keep +all three generated indexes current. Accepted historical bodies and shipped log +records remain intact. No runtime code, provider/browser/database proof or manual +accessibility is claimed. Step 1 is complete after this closeout commit; Step 2 +starts automatically on `development` under the accepted seven-step plan. + +### P03-4 Step 2 foundation and entry activation boundary (2026-11-04) + +Step 2 implements the Invitation aggregate/typed identifiers, ASCII mailbox +parser, versioned delivery generations, finite acceptance/submission state, EF +mapping and a forward Identity migration. The schema applies canonical tenant/ +organization isolation, same-module reference keys, pending-slot uniqueness, +immutable pins and terminal facts, actual database expiry and ordered-fence +backstops. The ninth confined definer is the bounded preliminary entry function; +the two SMTP functions belong to Step 3. No public endpoint is activated. + +Tenancy adds the registered typed `identity.invitation.ttl_days` value and strict +1–30-day parser behind its existing settings accessor. This adds no writer, +permission grant, organization override or generic configuration response. + +**Sequence refinement within this packet:** the operation-bound witness, native +origin and TransactionBehavior integration land in Step 5 with the three actual +recipient request types and current-email/delegation enforcement. The earlier +Step 2 scope-foundation wording does not authorize a dormant permissive policy, +placeholder request handler or claim of a qualified setter before those consumers +exist. Step 2 owns the complete storage/preliminary-function boundary; Step 5 owns +its real request/caller/witness/mode/cleanup qualification. This changes sequence, +not ADR-0093's authority contract, and defers nothing outside P03-4. + +Targeted controls first pass 45 unit, 13 actual app-role storage and 10 model/ +architecture cases. The full unit population then passes 3,480/3,480. The first +wider database population identifies three test-inventory backfills, not a +relaxed runtime rule: the exact grant matrix, an independent clean/dirty +Invitation WITH CHECK control, and the enlarged FK-safe permanent-User TRUNCATE +challenge. After those corrections the same database population passes +186/186, with no skipped cases. Full architecture validation and the Step 2 +commit/review rounds remain in progress; no provider/browser/API proof follows. + +### P03-4 Step 2 storage validation and review handoff (2026-11-04) + +The full architecture attempt reports three verified failures and is cancelled +after more than 27 minutes for corrective targeted validation. Its actual owned +testhost retirement is observed. No full-suite success or complete-case credit +is claimed. Corrections preserve the implemented aggregate-rule baseline while +renaming its actual test to the canonical successor; the two-handler extension +remains Registered until the acceptance handler exists. The pending unique index +adds the canonical `deleted_at IS NULL` term, equivalent under the root's explicit +no-delete CHECK; the current unreleased migration/designer/snapshot agree. + +The writable eligibility repeat of the exact entry function also requires the +announced offer scope, real Tenant fence and both exclusive User fences. Its +initial ReadOnly lookup remains separate. Real app-role controls cover missing +fences and accepted same-actor reconciliation without exposing recipient data. + +Final affected validation before review: 48/48 architecture cases and 187/187 +database cases pass, zero skipped. The unchanged domain/application behavior +also has the full 3,480/3,480 unit result above. Complete final-source architecture +and required CI/A/B/archive qualification remain Step 7 obligations; the aborted +full scan is not substituted for them. Formatting and document guards accompany +the implementation commit. Two fresh independent review rounds follow. + +### P03-4 Step 2 second review UUID correction (2026-11-04) + +Implementation commit `000d3442` receives two fresh reviewers per round. Both +first-round reviewers approve with no findings. In the second round, behavior +approves; integrity verifies one Major: PostgreSQL `uuid_extract_version()` can +return NULL for non-RFC UUIDs, and a CHECK accepts NULL unless its result is +explicitly required to be true. + +The correction wraps the complete Invitation identifier conjunction in +`(...) IS TRUE`. A new forward migration updates the constraint and EF snapshot; +the committed initial migration is preserved. Three actual `learnstack_app` +negative controls insert a nil offer ID, generation ID and delivery JobId in turn +and require the named constraint's `23514`, while valid populated controls remain. +The affected database population passes 190/190; the 48 affected architecture +cases pass with zero skips. The original domain/application code keeps its full +3,480-unit result. No aborted full architecture or future HTTP/SMTP evidence is +claimed. Correction verification and Step 2 closeout follow this commit. + +### P03-4 Step 2 review closeout and SMTP continuation (2026-11-04) + +The integrity reviewer rechecks correction commit `1f7ec80c` and closes the sole +second-round Major. Both rounds now have no open verified finding. Step 2's +storage foundation is complete: domain/settings tests, real app-role storage/ +policy/FK/constraint controls, mapped snapshot and affected architecture checks +are recorded above. Full packet proof remains Step 7; no public entry or sender +is claimed by this closeout. + +Step 3 proceeds automatically. Its read-only preparation confirms two exact +scope definitions require four distinct prepare/finalize request types in the +existing Type-keyed registry. Use scope-specific roots and shared identifier-only +parameters/plan/outcome/port; do not weaken that registry or add Domain references +to Contracts. Known runtime-selected NoStart finalization may have no provider +outcome: its truthful workflow-failure transition uses a new forward migration, +never an invented provider ReceiptId or Unknown release. + +Before sender activation, qualify actual PostgreSQL logging, including failing- +row DETAIL and independent statement-sampling paths, as well as application/ +trace/SMTP sinks. Do not modify the running user stack. The stager's actual +create/reissue submission/audit atomicity and nonempty caller census join its +real writers in Step 4; Step 3 qualifies the closed mechanism with owned fixtures +and does not invent a test-only production admission exemption. This dependency +clarification leaves all work inside P03-4. + +### P03-4 Step 3 SMTP foundation and review handoff (2026-11-04) + +Step 3 implements the accepted automatic-mail mechanism, with no invitation +administrator or recipient endpoint activated. Two active external definitions +bind four scope-specific prepare/finalize roots, the existing two external +workers and the shared four-slot tenant capacity group. Parameters/plan contain +only the three immutable GUIDs; outcome contains only the provider ReceiptId. + +Two SQL-only custody tables, two fixed definer functions and a private four-owner +app-role source retain the original attempt/fence separately from fresh recovery +ownership. Actual `JobExecutor` entry alone issues the nonserialized authority. +Lost COMMIT permits one exact locked verification after actual prior cleanup, +within the original five-second SQL budget. SMTP runs outside SQL transactions; +late original completion can preserve evidence without checkpoint authority. + +AES-256-GCM custody uses the fixed startup key ring and immutable authenticated +scope/generation/job bindings. One shared verifier codec matches the accepted +byte encoding. MailKit transport has no send retry or protocol logger. Final +250 plus actual cleanup and a durable receipt means submission accepted; missing +final reply stays Unknown. Unconfirmed physical SMTP cleanup retains concrete +transport resources, provider owner and connector, closes that lane and blocks +shutdown instead of returning capacity. The actual database privacy canary +demonstrates failing-row leakage under dirty settings and suppression under the +qualified settings. Setup, rotation and restoration are documented in the +[provider runbook](../modules/identity/providers.md#setup-key-rotation-and-restoration-runbook). + +The Invitation's separate receiptless workflow-failure transition accepts only +the existing runtime-selected known NoStart finalization path. Terminal selected +delivery status is immutable; stale-generation completion cannot overwrite the +new generation. Both new forward migrations include generated target models; +committed Step 2 migrations remain unchanged. + +Focused final checks pass with zero skips: 103 unit cases, 10 controlled real +SMTP cases, 36 runtime/schema cases, 22 composition/database cases and architecture +runs of 33 and six cases. These populations overlap and are not a unique summed +count. Actual tenant and organization execution, ordinary external DB denial, +exact-scope refusal, lost-COMMIT verification, fresh recovery and durable-result +replay are exercised. Configuration is now mandatory at API startup; construction +fixtures supply explicit synthetic settings without connecting to SMTP. + +This is an implementation handoff, not review or packet qualification. Two fresh +independent review rounds follow. Step 4 supplies actual create/reissue callers +and atomic root/job/envelope/MUST-audit proof. Step 7 still owes full final-source +suites/native fixture compatibility, process-qualified provider saturation and +failed-cleanup shutdown, native restart/late-original races and the Identity +six-NoStart finalizer path, alongside hosted A/B and original-archive proof. +No current user stack, production credentials/settings or inbox delivery is +qualified. No existing P03-3 manual deferral is promoted to evidence. + +### P03-4 Step 3 first review startup correction (2026-11-04) + +Two fresh reviewers inspect implementation commit `583ecd20`. Custody/authority +approves without findings. Consumer review verifies one Major: the new mandatory +SMTP/key-ring startup validator exposes missing configuration in existing +Contract, composition and real-worker fixtures. The served OpenAPI control +independently fails 1/1 with the fixed custody-unavailable error. A full unit run +also identifies two affected construction cases and the old request-catalogue +population, which omits the four newly registered job roots. + +The correction supplies one linked test-only synthetic configuration to those +fixtures. It neither removes the production validator nor replaces the actual +provider with a success stub. Its invalid DNS destination is never contacted by +construction. The catalogue discovers the real Identity Application request +assembly and pins 84 requests/46 finite-trusted policies; protected permissions +and all previous request subjects remain. The architecture catalogue counts 335 +compiled methods while its new custody rule remains Registered until complete +in-packet proof; four foundation methods do not establish that whole obligation. + +After restoring the new transitive dependency across the complete solution, all +3,537 unit cases, all nine Contract cases, 36 affected existing integration cases +and nine corpus/provider architecture cases pass, with zero skips. The actual +Outbox and Customization worker hosts are included. The earlier stale Contract +assets run is not qualification. Verification of the review correction and a +second fresh independent round follow. + +One nonblocking integration suggestion is retained inside Step 4: a status-query +failure aborts its shared owner, so the real admin reader must define and prove +storage-unavailable/rollback behavior before activation, instead of treating an +aborted transaction as auditable successful disclosure. No public reader caller +exists in this foundation. The explicit Step 7 process/full-source obligations +recorded above remain open. + +### P03-4 Step 3 second review privacy inventory correction (2026-11-04) + +Two new reviewers inspect `42d585a9` independently. Failure/ownership review +approves without findings. Integration review identifies one Major: the actual +provider tables are documented only outside Architecture 23's marker-bound +storage census. Running its existing controls confirms three failures out of 76, +naming both provider tables and Step 2's Invitation root as unclassified actual +migration storage. This is a current integration gap, not missing future native +qualification. + +The correction adds both provider tables to the canonical twelve-store +asynchronous inventory with their encrypted/linkable content and strict custody +constraints. Invitation joins the explicit synchronous module exclusions without +an erasure exemption. SMTP server/mailbox/provider-backup custody joins the +existing external-copy inventory. The selected future-consumer description stays +separate from actual storage and no longer claims an envelope expiry column. +The complete migration comparison and every removed/duplicated-row dirty control +remain strict; explicit expected population and its planted negative are updated. + +All 81 inventory/corpus cases now pass with zero skips. Prior full-unit/Contract/ +affected-integration results remain applicable because the correction changes +classification/document guards only. The finding reviewer verifies the committed +correction before Step 3 closes and Step 4 activates its actual writers. + +### P03-4 Step 3 review closeout and administrator continuation (2026-11-04) + +The first-round consumer reviewer verifies `42d585a9` and closes its startup +fixture Major. The second-round integration reviewer verifies `b3448ab8`, +independently reads the 81/81 zero-skip receipt and closes its inventory Major. +Both other reviewers approve without findings. All four reports now approve; +no verified Blocker, Major or Minor remains open in this foundation step. + +Step 3 is complete as the automatic-email foundation. Its controlled SMTP, +actual app-role storage/runtime and full unit/Contract evidence remain bounded by +the recorded proof scope. The custody rule stays Registered overall; complete +process/native, final-source suites and hosted A/B/archive obligations remain +inside Step 7. No production inbox, operator configuration or manual browser/ +accessibility check is claimed. + +Step 4 proceeds automatically: actual create/list/detail/compatible-Role/revoke/ +reissue operations, the fixed invitation-lifetime policy, original-target +protected receipts, strict HTTP/OpenAPI/SDK contracts and real producer atomicity. +The policy distinguishes absent creation from an existing version-zero row. +Administrative targets enforce exact nullable-organization equality, including +against tenant-wide rows visible to ordinary branch RLS. Raw links never enter +administrative responses or stored replay bytes. + +Before activating the actual status consumer, SQL failure makes the whole read +unavailable with rollback and the existing dependency-unavailable response; +private query timeout is distinguished from caller cancellation. A successful +missing-job observation is separate finite data. Real reader/audit tests prove +this behavior. Actual Create/Reissue invokes the confined stager, scheduler and +MUST path on one ordinary owner; no test-only producer replaces that proof. + +### P03-4 Step 4 administration and producer review handoff (2026-11-04) + +Step 4 implements the eight administrative HTTP actions, scoped invitation +directory/detail and compatible-Role summaries, fixed lifetime policy and strict +SDK wrappers. Actual create/reissue invokes the confined stager, scheduler and +encrypted custody on the same audited business owner. Database diagnostic +qualification precedes the first recipient-bearing root INSERT. Frozen entry +host/locale/label come from actual live Tenancy projections; the existing +server-configured HTTPS-port policy formats the origin. + +Every target read, retained lock, page and original receipt enforces tenant plus +exact nullable-organization equality. Current membership-admin is checked at +HTTP and application boundaries. Create/reissue additionally require compatible +live Role content, the immutable pin and whole-grant delegation. Withdrawal +requires current scoped membership-admin, Pending target and strong root version, +independently of changed/deleted offered Role content. This preserves the +accepted revoke/new-offer recovery without granting assignment, reissue or +repinning authority. Positive withdrawal and forbidden-scope/capability/stale-root +controls exercise the distinction. + +Policy absence uses `If-None-Match: *`; an existing version-zero row uses +`If-Match: "0"`. The fingerprints differ while prior protected-operation framing +remains unchanged. Getter is own-only Off/physical ReadOnly; original policy +receipt is own-only, original TenantSetting ID/key and MUST writable audit, +without the former write grant. Four protected mutations add only Invitation +and TenantSetting target kinds in a forward Platform migration with generated +target metadata. The migration's UTC ID follows the committed SMTP migration. +No raw code/link enters administrative responses or durable replay bytes. + +Actual SQL status failure/private timeout poisons the shared owner and returns +whole-read 503; caller cancellation is distinct. A page shares one five-second +status budget. Known missing-job data remains separate. Real denial and table- +lock timeout controls expose no DTO or success audit. + +Broader controls also close earlier SMTP source-accounting gaps: its job store +is an explicitly countable Invitation write port, rejects Add and saves only its +exact tracked root without forcing Modified state. A real stale-generation +finalizer preserves the replacement generation, job and root version. Staging +is a narrowly counted metadata/custody seam with exact production callers. +Credential, enrollment, audit, framework-list and hosted-source controls retain +their exact subjects and independent dirty negatives. Pure Location construction +belongs to descriptor metadata, without a metadata-to-dispatcher dependency. + +Validation records 3,548/3,548 full unit cases and all 73 selected integration +subjects with green final/corrected receipts. The original selected receipt has +72 passes and one test-only TTL sampling failure; that corrected subject passes +separately. An additional tightened real Create/Reissue MUST-refusal control +positively observes rollback/cleanup and expired in-flight claims with no typed +target, unchanged root/job facts and no success audit. Abandon expires its row; +state alone is not evidence of uncertainty or deletion. + +The original full architecture attempt is 570 passed/38 failed/zero skipped, +608 cases in 26m15s. It is not a passing final full-source receipt. Its affected +follow-up is 53 passed/16 failed; all remaining failures and new dirty controls +then pass 18/18. All five corpus controls pass after reconciling the 337 compiled +architecture methods; registered whole-packet obligations are not promoted by +partial control delivery. +The served API snapshot supplies SDK generation: Contract passes 9/9, guarded +SDK passes 163/163 across ten discovered files, and SDK lint/typecheck pass. +Release compilation, changed-file formatting, whitespace and document guards +accompany the commit. + +Two fresh independent review rounds follow. Recipient entry/acceptance and +Studio/native/Keycloak activation remain Steps 5–6. Complete final-source suites, +native process/cleanup/race qualification, hosted A/B and original archives +remain Step 7. No production inbox delivery or manual accessibility evidence is +claimed by this administrative implementation handoff. + +### P03-4 Step 4 first review correction (2026-11-04) + +Two fresh independent reviews examine administration authority/atomicity and +protected replay/HTTP/SDK. The replay review has no verified finding. The +authority review finds one Major: unavailable committed delegation facts were +collapsed into Boolean false, producing a permission refusal or a successful +incomplete invitable-Role directory after a recovered final check. + +Delegation now preserves failure separately from actual nondelegability. +Unavailable facts or lost invocation continuity return dependency-unavailable; +create/reissue propagates that failure and the directory aborts its whole result. +Actual insufficient grants still deny grant-bearing mutations and omit only +nondelegable Roles. Cancellation remains cancellation; withdrawal is unchanged. + +Four real HTTP/pipeline regression subjects pass without skips. They inject the +one-shot delegation failure after successful admission/rechecks, require 503 and +no successful read audit or partial directory, verify unchanged target versions, +and then observe a recovered complete directory. A genuine actor-grant removal +independently proves mutation denial and selective Role omission remain intact. +The original full-suite receipt limits in the handoff remain unchanged; this is +focused correction evidence. The verified finding is checked for closure before +two new independent second-round reviewers examine the complete Step 4 range. + +### P03-4 Step 4 second review closeout and Step 5 start (2026-11-04) + +The first-round delegation fix is committed as `80e94416`; its finding reviewer +verifies closure. Two different fresh second-round reviewers then examine the +complete Step 4 range, including authority/scope/locks/producer/audit and protected +replay/policy/HTTP/SDK/control integration. Both approve with no verified remaining +Blocker, Major or Minor. The four focused failure-classification regressions, +formatter, whitespace and documentation checks pass. Two stale planned labels +are removed for the actual TTL setter and already-mapped Invitation privacy row; +recipient acceptance entries remain planned until Step 5 registers them. + +The review explicitly preserves the existing protected idempotency limit: +positive connection/transaction cleanup does not prove business rollback. +[ADR-0079](../decisions/0079-bounded-identity-administration.md#durable-idempotency-and-protected-replay) +and [Standard 04](../standards/04-api-design.md#idempotency) retain non-2xx claim +release and at-least-once work across business COMMIT and missing outcome +recording. No stronger unknown-outcome retention or exactly-once policy is +introduced. Known audit-INSERT-refusal rollback evidence is not generalized to +that uncertain window; completed retained receipts never rerun their mutation. + +Steps 1–4 and their two review rounds are complete. Step 5 begins actual +operation-bound nonmember admission, fresh provider recipient evidence, atomic +Invitation/Membership acceptance and exact original-result reconciliation. +Step 6 owns Studio/native/provider journeys; Step 7 owns complete final-source +suites, native process/cleanup/race proof, hosted A/B and original archives. +The earlier architecture receipt limits and unexecuted manual evidence remain +explicit; review approval does not complete those later steps. + +### P03-4 Step 5 recipient admission and atomic acceptance review handoff (2026-11-05) + +Step 5 implements the three exact recipient requests, private stock-validated +credential capture, fresh fixed-endpoint UserInfo and operation-bound nonmember +scope. The preliminary app-role ReadOnly lookup fully closes before the scoped +owner. Actual server privacy qualification precedes sensitive SQL binds. The +single-use witness binds the current request/invocation, native proof, real host, +actor, offer and physical owner/mode/generation. Eleven logical setters use the +existing four physical context writers; no new migration, function or login role +is added. General protected, public and account authority remains separate. + +Entry bodies contain ID/code only; Pending acceptance uses canonical strong +If-Match. A dated ADR-0093 transport clarification preserves its historical body +and metadata. Accepted preflight selects the exact Off original-result request +before old/malformed/repeated preconditions, provider work or mutation intents. +Normal lifecycle and frozen-origin availability still apply. Pending email, +inviter, Role pin, expiry and version conditions do not block that own-result +read, which never substitutes a successor Membership or publishes a session. + +Acceptance takes both sorted User fences before the strongest actual Tenant +mode, then Role, Invitation and current Membership locks. Fresh post-wait global, +delegation, lifecycle, pin, target and database-expiry checks precede the actual +transition. One tracker reset preserves both roots for central plural MUST and +one COMMIT. Active members retain their full graph; Suspended conflicts; Revoked +history permits a fresh successor. A shared mutable-graph guard also closes the +new write port after validation. Its real late-write probe preserves the original +Queued/version-one outcome and two central audit rows. + +The controller consumes an immutable prepared request, without a reachable +preliminary persistence graph. Infrastructure/OpenAPI paths resolve no invitation +source eagerly. Exact central response-privacy classification covers the three +entry paths. The served schema and strict SDK add no ordinary BFF operation. + +| Validation | Observed scope | +|---|---| +| Full Unit after graph-seal correction | 3,589 passed; zero failed/skipped | +| Full Contract | 10 passed; actual served refresh and SDK generation also pass | +| Full guarded SDK | 189 passed across 11 exact discovered files; zero failed/skipped/todo; typecheck, lint and native emit pass | +| Actual recipient HTTP/PostgreSQL | All 39 exact subjects have passing latest outcomes: 38 in the final combined receipt and one corrected pooling fixture separately; no single all-green combined run is claimed | +| Affected Architecture | All 119 exact subjects have passing latest outcomes: 117 plus two corrected methods; bounded subset, not full Architecture qualification | +| Controlled UserInfo adapter | 41 passing subjects included in Unit; not real Keycloak verification qualification | + +C# formatting and verification pass. The measured compiled architecture count is +340; whole-packet Registered obligations retain their status. The interrupted +unfiltered architecture diagnostic and earlier failed/corrected receipts remain +history, not passing full-source evidence. Corrections retain exact nonempty +caller/owner sets and missing/alias/opcode negatives, without broad exemptions. + +HTTP proof includes nine wait races, actual expiry, both User fences and strongest +mode/same-person cases, unchanged-member subject-only audit, both MUST failures, +strict input/source/branch controls, privacy refusal before sensitive SQL, +owner/witness/join/mode refusal and postrequest closure. Pool reuse explicitly +enables the fixture's owned two-connector source, asserts equal physical backend +PID, and checks clean GUCs/ReadOnly state through a fresh owner. The failed owner +remains permanently poisoned; fresh-scope success alone is not pool-reuse proof. + +Global Closing cases use the existing isolated owner-only corruption fixture; +they prove defensive rereads, not a P03-10 production closure writer. A deferred +server COMMIT refusal proves atomic rollback. Separate successful COMMIT followed +by synthetic HTTP observation loss proves explicit original-result convergence; +it is not a driver COMMIT-ack fault claim. ADR-0079's protected-receipt outcome +limits remain unchanged. + +The implementation now enters two fresh independent review rounds. Step 6 owns +Studio/native/verification/browser/manual experience, including the minimal admin +acceptance-disposition projection needed to explain already-member outcomes to +both parties. Step 7 owns complete final-source suites, actual provider/native +process proof, hosted A/B and original archives. No manual or production-provider +qualification is inferred from these bounded receipts. + +### P03-4 Step 5 first review diagnostic correction (2026-11-05) + +Two fresh independent reviews cover authority/owner/atomicity and provider/ +HTTP/SDK/privacy. The transport review finds no verified defect. The authority +review finds one Major: the new Invitation statement-save adapter omits the +nonretrying Npgsql strategy's transient InvalidOperationException wrapper, +although the existing Identity graph writer sanitizes that exact shape. + +A real API/PostgreSQL regression reproduces 500 before the correction. The +bounded sibling pattern now marks the owner rollback-only and throws the fixed +identity-storage exception without retaining the provider inner payload. It +changes only statement-save translation; direct invariants and physical +COMMIT/unknown-outcome behavior remain unchanged. No retry is introduced. + +Six focused tests pass with zero skips: the new invitation regression, four +existing immediate/deferred transient/nontransient profile-failure controls and +the actual post-validation graph-seal control. A stored-recipient canary trigger +executes exactly once only after observing the new Active result Membership. +The corrected response is 503, with no result DTO or success audit; authoritative +reads show Pending/version zero, no acceptance result and no Membership in the +target scope. Existing real diagnostic serializers/exporters exclude the canary +from logs, local archive, Sentry, response and traces; positive controls remain. +Formatting and verification pass. + +A provenance comment is also narrowed to actual private construction/live-source +binding. It no longer claims an independent provider-evidence minter census that +has not been implemented; no present alternative producer or bypass is found, +and no generic proof framework is added. The compiled architecture count remains +340. The finding reviewer checks closure, followed by two different fresh +second-round reviewers of the complete Step 5 range. All broader qualification +and Step 6 boundaries in the implementation handoff remain explicit. + +### P03-4 Step 5 second review delegation and transport corrections (2026-11-05) + +Two different fresh reviewers examine the complete Step 5 range, including the +first-review correction. The authority review identifies a Blocker: separate +READ COMMITTED Membership/assignment and Role/grant reads can combine facts that +never formed one committed delegation ceiling. Removal-only organization Role +unassignment does not take the inviter User fence; its Shared Tenant fence also +permits an unrelated Role's ordinary grant restoration. Holding the offered Role +does not exclude that interleaving. + +An actual app-role regression reproduces successful acceptance before the fix, +despite the inviter lacking a coherent sufficient ceiling throughout the act. +The concurrent assignment removal and grant restoration commit with normal +guards, valid root versions, the actual editor and Shared Tenant fences; no +User fence or disabled trigger supplies the counterexample. The stored inviter's +complete delegation facts now use one explicitly single-query EF projection on +the existing owner. Provenance, fact bounds, isolation level and lock plans stay +unchanged, following ADR-0076's committed Identity statement contract. + +The transport review identifies a Minor mismatch: both declared and counted +entry bodies above 8 KiB return 400 although the served contract specifies 413. +Both overflow paths now use the existing bounded payload-too-large response; +malformed in-limit input retains 400 and owned buffers are still cleared. + +Eight focused HTTP tests pass with zero skips. The raced acceptance now returns +403, leaves the offer Pending and creates no Membership; a coherent delegation +control still creates one. The test observes exactly one inviter query with +joined grants. Declared-length and genuinely undeclared/chunked 8,193-byte bodies +return no-store 413/payload_too_large before provider work, while both exact +8,192-byte controls succeed. Existing malformed-input, new/unchanged-member, +result recovery and post-wait delegation-refusal controls also pass. +Five affected architecture controls pass with zero skips; six changed C# files +pass formatting and format verification. The compiled architecture count remains +340, without a catalogue status promotion. + +These are bounded correction receipts, not a new full-suite or packet +qualification. Finding reviewers verify closure after the correction commit; +Step 6 retains the full recipient/Studio experience and Step 7 the final-source +qualification already recorded above. No additional authority or ADR is added. + +### P03-4 Step 5 review closeout and Step 6 continuation (2026-11-05) + +Step 5 closes after implementation `222d17eb`, first-review correction `84c10444` +and second-review correction `53b59b06`. Four independent reviewers complete the +two planned rounds. Both second-round finding owners inspect the committed +corrections and direct red/green receipts, confirming closure with no verified +residual. The authority correction preserves one committed delegation graph; +the transport correction preserves the published 8 KiB/413 boundary. Document +fragment, roadmap and generated-index controls pass. No review approval supplies +unexecuted runtime or manual evidence. + +The earlier full Unit/Contract/SDK receipts, bounded HTTP/architecture outcomes +and separate focused correction receipts keep their exact source and scope. +Forty-four invitation-entry HTTP cases now exist; no single combined all-green +44-case run is claimed here. The final-source full suites remain Step 7. + +Step 6 proceeds automatically under the Accepted seven-step plan: Studio +invitation and lifetime forms; pre-Next landing and private native custody; +signin, explicit bootstrap and fixed email verification; deliberate acceptance, +original-result recovery and separate session admission; English/Turkish and +no-JavaScript paths. The minimal administrator disposition projection exposes +created versus already-member truthfully to both parties without adding a +Membership identifier or directory authority. Actual nonseed provider journeys +and human accessibility evidence retain their planned verification obligations. +The human checklist remains unexecuted until a person records the checks on the +working screens. No new decision or maintainer approval is pending. + + +### P03-4 Step 6 Studio and native invitation implementation (2026-11-05) + +The Studio lists and explains invitations, shows their offered Role and UTC +expiry, creates, revokes and explicitly reissues them, and edits the existing +Tenant lifetime policy. Absent-default and stored-version-zero policy writes +remain distinct. Forms preserve validation edits; unknown protected outcomes +retain their original preconditions and idempotency material for recovery. +Administrator and recipient surfaces distinguish created from already-member +acceptance without exposing a Membership identifier to the administrator or +silently changing an active member's Role. + +The fixed native `/invitations` landing and confirmation run before Next. The +first nonce-bound script removes the fragment before UI or network work; the +no-JavaScript alternative accepts a deliberately pasted code through an +Origin/CSRF-protected POST. Six closed recipient POST operations use the existing +native owner store, shared quotas, sweep and physical cleanup accounting. The +private continuation has its own revision and original pending deadline. Raw +codes do not enter URLs, provider state, return targets, generic drafts, RSC, +logs or public response output. Cancel revokes server custody immediately; +late cancellation or recovery replies do not delete a newer opaque invitation +cookie. A clean landing replaces an obsolete handle. + +The fixed Keycloak verification purpose retains state, nonce, S256 and current +subject binding. Only that owned transaction accepts the complete +`kc_action=VERIFY_EMAIL` and known status tuple; incomplete, duplicate or wrong +purpose values refuse without consuming it. Callback status is not verification +proof. Fresh API inspection and explicit acceptance/result recovery precede +separate normal session admission. Same-scope already-member admission preserves +the live cookie, CSRF and lifetime; a different-scope admission inherits the +existing session's absolute lifetime. Committed Membership truth survives a +subsequent session-admission refusal. English and Turkish copy covers both +parties, verification, recovery and no-JavaScript paths. + +The API launcher selects exactly the seven SMTP/key-ring settings from matching +sources, never arbitrary ambient credentials. Its API-only private-file reader +uses one no-follow handle, regular/single-link/current-owner/private permissions, +bounded UTF-8 input and cleared buffers. Web and Seeder environments receive none +of those settings. Public CI construction uses explicit disposable synthetic +configuration; this is not SMTP delivery. Setup guides distinguish LearnStack +invitation SMTP from Keycloak verification SMTP and explain the stock **Send +email** action. The ordinary fixture pre-pull inventory now includes pinned +Mailpit, with its existing total allowance unchanged. Four missing current +shared-fixture classes join the exact sorted shard inventory; no partition count, +resource policy, deadline or reserve changes. + +Create/reissue also preserves an enabled regional content tag such as `tr-TR` +or `en-US`. Turkish mail copy follows primary `tr`; other tags use the existing +English fallback independently of content membership, as ADR-0027 requires. +Links use the fixed native path rather than a locale-prefixed content route. +An actual red run exposed 404 for regional-only locales before the correction. +Four actual app-role HTTP cases now pass after both create and reissue, asserting +persisted tags, unchanged locale membership and decrypted staged subject/text/ +HTML. Staged-copy assertions do not themselves prove SMTP submission. + +Verification before the implementation commit: + +- The canonical guarded frontend runner passes 3,903 cases across both packages, + with zero skips/todo: web 3,712 across 119 files in 313 seconds; SDK 191 across + 11 files in 94 seconds, within the unchanged 900/360-second allowances. + Whole-workspace typecheck and lint pass. The initial full run's eleven copied + fixture/census failures are retained and corrected without broader exemptions. +- Thirteen focused owner/verification-tuple/lifetime cases pass. Six actual + native HTTP controls pass, including a red/green held cancellation reply that + previously erased a newer cookie. A held inspection reply did not reproduce + that erasure and is not presented as a second reproduced defect. +- Three actual PublicLocalLaunch controls pass; 87 canonical offline CI controls + pass in 6.46 seconds, below the unchanged 27-second TERM allocation. The four + regional cases, two actual staging/idempotency/MUST-rollback regressions and + nine invitation architecture controls pass with zero skips. Release build and + scoped formatting pass with zero warnings/errors. +- Two real nonseed journeys pass on the final backend source, with zero skips, + in 49.66 and 35.78 seconds under unchanged 180-second individual wrappers. + Fresh owned PostgreSQL, pinned Keycloak and Mailpit run actual invitation jobs, + stock registration/login, explicit global bootstrap, unverified refusal, + provider **Send email**, delivered verification links, fresh inspection, + acceptance and separate session admission. The existing-account path also + proves already-member behavior, unchanged live session and wrong-recipient + refusal without grant/disclosure. Actual realm/action settings are read back. + The new-account fixture additionally exercises stock Chromium fragment + removal/history/storage and no-JavaScript Origin/form capture. These are + automated provider/browser observations, not human accessibility evidence. + +The implementation enters two fresh independent two-reviewer rounds. The human +keyboard, focus, reflow, contrast and VoiceOver checklist remains unexecuted +until a person reports observations on the working screens. Step 7 still owns +SMTP saturation/retained-cleanup/restart/late-receipt/native-finalizer proofs, +complete final-source suites, hosted A/B and original-archive qualification. +Neither these bounded receipts nor review approval completes those obligations. + + +### P03-4 Step 6 first review recovery and quota corrections (2026-11-05) + +Two fresh independent reviewers inspect the complete Step 6 implementation at +`3440b3bb`. They identify three Majors. The product review finds that own-result +404 after an unconfirmed accept is treated as expiry, even while the offer can +remain Pending. The owner review finds that delayed entry/session signout replies +can still delete a newer invitation cookie, and that the native landing collapses +bootstrap/source quota 429 into 503. The parent verifies all three against the +actual callers before authorizing bounded corrections. + +Both generic signout paths now revoke their original server custody without +sending an invitation-cookie deletion. Existing session, entry and logout-cookie +behavior is unchanged. Real HTTP regressions hold the old generic redirect, +create/capture a newer continuation, emit the old reply and verify the newer +confirmation remains reachable while old custody is revoked. + +A private acceptance-uncertainty marker is set only after state/version validation, +immediately before the actual accept call. It survives response cancellation and +fresh Pending inspection; neither proves that prior provider/API work ended. +Own-result 404 and a later inspection 404 retain uncertainty and expose deliberate +inspection/result actions. A fresh successful inspection permits explicit user +acceptance, without an automatic second accept. Only a confirmed original receipt +or new capture clears the marker. One additional abort-after-start red control +exposes the earlier placement after the cancellation guard; the corrected ordering +passes that control, including later original-result convergence. + +The native landing preserves public bootstrap 404/429/503 and the existing local +source/subject 429 versus pending/global 503 distinction. Bounded Retry-After and +no-store behavior use the existing convention. Actual HTTP tests prove source +saturation refuses before allocation and the distinct bootstrap responses. + +The meaningful red run reports 33 passes and twelve failures across 45 cases; +these are three defects exercised by multiple controls, not twelve independent +production findings. The final focused run passes 46 cases across three files, +with zero skips or unhandled errors. The delayed-commit controls preserve +uncertainty through result 404, successful Pending inspection, later inspection +404 and dependency failure, then reconcile the original receipt. No deadline, +quota, provider authority, general cookie framework or automatic retry is added. +A further 551 existing authentication/session regressions pass with zero skips; +workspace typecheck, lint and native emission pass. The earlier 3,903-case full +frontend and two provider receipts remain explicitly pre-correction evidence. +The full Release solution build, 3,589 Unit tests and ten Contract tests also pass +with zero warnings/errors or skipped tests on the unchanged backend source. +These receipts do not replace Step 7's complete final-source qualification or +human accessibility observations. Finding-owner closure and two different fresh +second-round reviewers follow the correction commit. + + +### P03-4 Step 6 second review original-result and form corrections (2026-11-05) + +Two different fresh reviewers inspect the complete range through `fca08b60`. +The protocol reviewer finds one Major: after accepted-response/custody loss, +reopening the original email offers inspection, whose Accepted-offer 404 leads +only to another restart. The existing own-result authority remains valid for the +same actor and original Active Membership, but its action is unreachable. + +The native renderer now exposes a deliberate original-result check on fresh +inspection, masked inspection refusal and account-verification recovery. Localized +copy explains that possibility without inferring acceptance from 404. The existing +result command and separate session admission are reused; no new acceptance is +submitted automatically. Actual native HTTP/renderer/store controls use a controlled +API to commit acceptance, fail the first admission, expire only native custody, +recapture the original code, exercise inspection 404 and reconcile the original +result before separate admission. They assert no second acceptance and preserve +changed-current-email independence. A masked result-404 UI control keeps the +original refusal private. These establish UI/protocol reachability; controlled +replies do not independently prove backend wrong-actor or original-Membership +authorization, whose real integration qualification remains separately required. + +The experience reviewer finds two Minors. The Role permission label still names +appearance alone although the existing setting-write pair also grants invitation +lifetime editing. Both catalogues now name those two exact powers. New required +email, offered-Role, lifetime and manual-code controls gain localized visible +required wording and explicit aria-required, retaining native validation and +associated hints. Recovery evidence remains readonly or hidden according to its +existing grammar; it is not mislabeled as editable required input. A parent +self-check also labels the skip link with the selected UI language/direction, +retaining the document's admitted content locale and its independent fallback. + +The focused red run reports eleven failures among 116 cases. The final focused +run passes 118 cases across six files; another 39 existing recovery/catalogue +regressions pass across two files, all with zero skips/todo. Workspace typecheck, +lint and native emission pass. These bounded receipts preserve the earlier full +guard/provider results as earlier-source evidence, rather than a fresh full pass. + +A parent full Architecture run also exposes a stale call census from the earlier +Identity graph-write integration. SaveAsync calls RequireMutableGraph, which +already invokes RequireWriteAllowed before mutation; the rule still expects a +direct SaveAsync-to-guard call. The test now requires both exact existing edges, +with hostile controls independently removing each. No production write barrier +is changed or duplicate guard inserted to satisfy a test. The exact affected +architecture test passes, with zero skips. The known-failed full run is +interrupted for correction and remains incomplete, with no full-suite pass or +complete population credit; Step 7 reruns the complete final-source suite. +The architecture rule name/status and compiled method count are unchanged. + +Review closure requires both second-round finding owners to check the correction +commit and bounded receipts. Human keyboard/focus/reflow/contrast/VoiceOver +observations remain unexecuted. The Accepted package permits independent work +while that external evidence is pending; Step 7's provider/process, full-suite, +hosted A/B and original-archive obligations are not waived. + + +### P03-4 Step 6 implementation review closeout and Step 7 continuation (2026-11-05) + +Step 6 implementation `3440b3bb`, first-review correction `fca08b60` and +second-review correction `418c6293` complete the planned implementation review +cycle. Four fresh independent reviewers cover the two rounds. All finding owners +inspect their committed corrections and scoped red/green evidence, confirming +closure without a verified residual. The parent also verifies the restored exact +architecture-barrier census and selected-language skip-link correction. + +This closes implementation/review work, not the entire Step 6 human requirement. +The maintainer has received the working-screen keyboard/focus/reflow/contrast/ +VoiceOver checklist; observations remain unexecuted and pending. Earlier full +frontend/provider and later focused receipts retain their exact source/scope. +The interrupted known-failed full Architecture run has no complete-run credit. +No manual, production SMTP/TLS or whole-packet qualification is inferred. + +Independent Step 7 work proceeds automatically under the Accepted package's +external-prerequisite clause. Its finite provider/runtime additions reuse the +actual Identity executor, SMTP source and native scheduler: four physical provider +owners and fifth refusal; actual SDK disposal failure with retained resources +and blocked shutdown inside an owned, joined child process; real process loss +after Sending/final250 before durable receipt and fresh Unknown recovery; both +late-original-receipt/recovery orders; and six real NoStart admissions followed +by the actual Identity native finalizer, including its MUST rollback control. +A narrowly staged test executable follows the existing portable process-proof +pattern; the Linux fatal/native collector fixture keeps its separate purpose. +Fixture setup, controlled replies, fresh hosts and actual process loss must be +reported distinctly. No new production authority, timer or general runner is +selected by this continuation. + +After these additions, Step 7 requires complete final-source backend/frontend/ +architecture/integration/provider/browser checks, both fresh final review rounds, +complete same-source hosted A/B and independent original-archive reconciliation. +The final PR remains for maintainer review without merge. P03-3's separate +B/archive/manual follow-ups are not silently completed by this packet. + + +### P03-4 Step 7 actual SMTP owner and recovery proof implementation (2026-11-05) + +Eight added cases complete the five finite provider/runtime proof families on +actual Identity definitions, executor, SMTP adapter/storage and native runtime. +A narrow two-mode test executable follows the existing portable process-proof +pattern, with an exact test-only constructor friend and staged build output. +No product behavior, production function/permission/minter, migration, general +runner, CI topology or existing test-window policy changes. + +- Four real SMTP submissions complete socket cleanup while four distinct provider + SQL owners/connectors retain their pending observations. An actually admitted + tenant-B fifth job avoids tenant-A reservation masking and refuses before claim + or SMTP. After release and join, owners retire and a subsequent submission works. + This proves four checked-out provider connectors, not four still-open SMTP sockets. +- A real MailKit Dispose failure through Identity retains the connected client, + sent message, provider, source owner and connector. The unhealthy source refuses + an eligible separate-tenant job. Source disposal and actual API root-provider + async disposal remain pending. The test positively resolves the live root, then + observes the same resolver refuse with ObjectDisposedException after disposal + begins; a scheduled-task signal or elapsed wait is not an acknowledgement. + Irrecoverable tasks stay inside an owned child that is killed and joined; + actual peer closure and unchanged unresolved durable facts follow. The WAF + wrapper's own synchronous disposal is not claimed as this proof. +- Tenant and organization cases kill the real child after SDK final250 and socket + cleanup but before durable provider completion. A fresh host/source/run cell + and stock native recovery wait for the original lease to elapse, without a + fixture expiry UPDATE. They preserve the Sending claim/attempt/operation, + quarantine Unknown, retain the reservation and perform no resend, finalizer, + invented receipt or NoStart. +- Both late-original/recovery orders reproduce the same immutable receipt/evidence + and refuse contradiction. Recovery-first observes Unknown before its checkpoint; + explicit store-fixture eligibility permits a new acquisition after the late + receipt commits. This is neither a process restart nor privileged release from + an already recorded quarantine. Only the fresh real Outcome checkpoint releases + occupancy, followed by actual Identity finalization and its central MUST row. +- Six real admissions prepare one Identity plan, record five actual setup NoStart + checkpoints/releases, then leave the sixth unarmed. Stock native recovery owns + its NoStart and receiptless Identity finalizer; no seventh normal attempt or + SMTP occurs. A real first MUST INSERT failure rolls back business/job/audit + observations, then fresh native finalization succeeds. Four exact fixture + observations use pg_current_xact_id for one top-level transaction; mutable tuple + xmin and EF child savepoints do not supply that witness. Setup is explicitly + not described as six native backoffs. + +Children clear inherited configuration, use owned 0700 profile/CWD and explicit +content roots, emit only fixed markers, and join exit plus stdout/stderr before +profile deletion. The existing peer keeps its 30-second default; only the new +long-lived process proofs supply an explicit finite listener lifetime within +existing wrappers. Runtime tests join the existing SharedSchema collection and +use its fixture, serializing global Hangfire mutations without a second unused +fixture/container. Each case still seeds its own disposable database. The CI +class inventory and all resource/deadline policies stay unchanged. + +Verification and source boundaries: + +- Combined SMTP/runtime/storage/transport/database-privacy: 43 passed, zero skips, + about 84 seconds after the collection correction; all eight new cases included. + Unit cipher/key-ring/registry/configuration: 55 passed; affected SMTP/trait + architecture: six passed, with the two collection controls repeated afterward. +- The final positive root-disposal-entry tightening changes only the probe body. + All three affected process cases pass again on that source, zero skips, about + 45 seconds. Earlier combined43/Unit55/Architecture6 retain their earlier source + snapshots; this is not falsely presented as a single current full-suite run. +- Root aggregate/seed architecture controls pass six cases. Their comments and + catalogue now describe the actual two-handler allowlist and delivered source + companions; no Registered extension is promoted by these bounded receipts. + Canonical offline CI controls pass 87 cases in 7.395 seconds under existing + allocations. Release builds and scoped formatting pass; the format tool's + generic workspace-load warning is retained separately from compiler results. + +Initial compile/fixture-output/WAF-wrapper and transaction-witness failures remain +historical failed evidence. No product regression or pass is invented from them. +The implementation enters two fresh final review rounds, then complete current +source/backend/frontend/provider/browser/hosted A/B and original-archive review. +Human observations remain separately pending; neither this proof nor process +termination supplies a manual pass, production SMTP/TLS or inbox-delivery guarantee. + + +### P03-4 Step 7 final reviews and PR readiness (2026-11-05) + +The Step 7 implementation is committed as `92499308`. Two fresh final reviewers +cover lifecycle/contracts and whole-packet interactions; two different fresh +second-round reviewers cover authority/runtime and experience/evidence. All four +report no verified actionable finding within their stated review scopes. They +trace actual production callers and independently check source/TRX provenance, +retaining the distinction between earlier focused snapshots and the final three +process-case receipt. No review approval replaces unexecuted qualification. + +The complete guarded frontend run on that candidate passes 3,932 reported cases +in both discovered packages, with zero skips/todo: all 119 web files/3,741 cases +in 304.99 seconds and all eleven SDK files/191 cases in 79.95 seconds. Full Release +solution build passes with zero warnings/errors; all 3,589 Unit and ten Contract +tests pass with zero skips. Whole-workspace typecheck and lint pass. The full +Architecture/Docker/native/provider/public-demo populations still require the +complete same-source hosted proof; earlier interrupted or partial runs supply +no substitute. + +A bounded status reconciliation by both second-round reviewers confirms the +three invitation catalogue assertions now have actual source and behavioural +owners. Registered means no code exists, so keeping it solely for pending packet +qualification misstates delivered enforcement. Entry and atomic consumption name +their real integration owners, with structural companions separately listed; +SMTP retains its exact named architecture owner and runtime/storage/crypto +companions. The two-handler aggregate extension is also implemented enforcement. +The catalogue publishes 257 Implemented rules, 150 in the architecture assembly +and 107 elsewhere, with 340 compiled architecture test methods unchanged. All +five corpus consistency/owner/count/no-skip controls pass after reconciliation. +This status promotion establishes enforcement, not hosted, human or production +SMTP qualification. Atomicity wording distinguishes lost HTTP observation after +successful COMMIT from the shared owner's indeterminate-COMMIT controls; no +invitation-specific dropped driver acknowledgement is invented. + +The implementation is ready for maintainer PR review. The final publication +candidate includes these current records; actual PR head/base/merge-source, +required full A followed by whole-run B, original ZIP API sizes/SHA-256/CRC, +complete case unions and independent non-backend surface review bind qualification +to that candidate. Original attempt logs are retained separately; their local +hash is not mislabeled as a GitHub artifact digest. No passing union of failed, +cancelled, partial or different-source attempts is admissible. No duplicate +manual CI runs are needed while the automatic PR workflow runs, and no merge is +authorized. The PR carries the actual source-bound result links and evidence. + +Human keyboard/focus/reflow/contrast/VoiceOver observations remain pending with +the maintainer and unexecuted; the working checklist was requested explicitly. +Implementation review readiness is not a human pass or full packet-delivery +claim. The Accepted external-prerequisite clause permits independent CI/archive +work meanwhile. Production SMTP/TLS and inbox delivery are not established by +local synthetic SMTP. P03-3 evidence/manual follow-ups retain their own identity. + + +### P03-4 Step 7 first hosted corrections (2026-11-05) + +PR #41 opens from `a55aa31e`, base `ef344c0f`, actual merge/workflow source +`3d27263e`. Complete run `41948315873`, attempt 1, finishes with six successful +and 21 failed jobs. Frontend, public-demo, native crash/cleanup, OpenAPI, secret +scan and Architecture-2 pass. This is a failed A, not packet qualification. +All 23 available original artifact ZIPs match their API byte counts/SHA-256 and +pass CRC; the two failed aggregates produce no archive. The original attempt +logs ZIP is retained separately with CRC and local SHA-256 +`279d3bdb96a62c21fb433409f04e1774c5e763a2d3465efbfc71ef8b1e95b88b`. +That hash is not mislabeled as an artifact-API digest. + +The finite CI diagnostic vocabulary omitted six existing invitation browser +stages. The exact existing control reproduces red; the correction adds only +those literals, retaining unknown/private-input refusal and all timers. A +component-host registration gap and old endpoint/job inventories reproduce +locally. The fail-closed test recipient reader cannot mint evidence; exact +42-action/four-keyed-addition and two scoped Identity definition inventories +retain their existing dirty controls. All 211 tests in the 18 originally failing +HTTP class groups pass, with zero skips, covering the 194 failed case identities. + +The compiled authority census now closes the exact invitation credential/source/ +recipient-reader consumers, witness/signatures, encoded-token field and fixed +UserInfo sink. Object forwarding and compiler exception storage have finite +provenance checks, not whole-type/method exemptions. Substituted token/sink/ +caller/request/evidence/storage mutations and the existing bypass controls pass; +the complete affected class passes 30 tests. The job-definition/controller test +passes with its embedded mutants. One additional Fact makes 341 compiled +Architecture test methods; rule counts/status are unchanged. + +Three real PostgreSQL schema censuses reproduce red. The exact SQL-only provider +metadata class stays in relation, trigger/body/ACL and isolation coverage; +independent application-role WITH CHECK/immutable-scope clean/dirty controls +exercise both provider tables. Invitation's nullable original-result relation +retains its checked admitting transition. Two additive forward migrations add +only missing parent `FOR KEY SHARE` locks, preserving latest prior bodies, +function identities/grants and all historical DDL. An old-schema upgrade and +real application-role BEFORE gates with unlocked twins prove the added locks +before FK enforcement. Final scoped schema/adjacent storage verification passes +246 tests; eight affected architecture/trait/chain controls pass, zero skips. +Standards 05's three canonical job/attempt policy USING clauses now reproduce the +Accepted SMTP owner-read arms; WITH CHECK/DELETE predicates remain unchanged. +An independently applied canonical database exactly matches migrated inventory. + +Architecture-4 passes 76 of 77 selected cases before its deadline; the unfinished +credential-census case has no duration or passing credit. A reviewed advisory +plan revision increases 184 existing method weights only from complete all-Passed +discovery-ID groups. Original TRXs bind five owners; the censored owner's original +job-log records match its 76 artifact JSONL records row-for-row and stay inside +its execution step. Their scope/source/run/artifact identities are recorded in +the plan. No failed/incomplete group is inferred or known slow weight reduced. +The original full manifest's A/B assignment diff moves 291/289 selectors and +separates three concentrated heavy selectors. Counts, algorithms, salt, default, +selectors, resources and all execution/cleanup clocks remain unchanged. These +summed case measurements and balanced estimates establish no wall-fit guarantee. + +The remaining original database-failure methods pass 46 tests, zero skips: +eight tenant HTTP, two composed-lifetime, 23 delivery, four job-migration, one +rollback, one HTTP outbox and seven idempotency variants. Test-owned synthetic +configuration and throwing recipient readers preserve production fail-closed +startup. Historical rollback/reapply stays at its exact boundary; current-head +checks retain the full 59-table/18-Identity-function inventory and explicit +forward-only refusal. The invalid replay-target control now uses an actually +unregistered kind, since TenantSetting is deliberately admitted by ADR-0093. + +Final local correction controls pass: 106 native-launcher offline controls and +87 plan/result controls (6.085 seconds for the latter under its unchanged +27-second TERM/three-second kill CI allocation), Release solution build with +zero warnings/errors, and all five corpus/count/owner/no-skip controls. These +scoped receipts do not replace complete new-source qualification. The correction +commit enters two fresh independent review rounds; prior reviews and source-bound +results keep their historical scopes. + +Fresh correction reviews, complete new-source A followed by whole-run B, original +archive/log and independent surface reconciliation remain required. Prior-source +or failed passing fragments cannot be combined into qualification. Human +keyboard/focus/reflow/contrast/VoiceOver remains pending and unexecuted. No merge, +production SMTP/TLS, inbox delivery or P03-3 follow-up closure is claimed. + + +### P03-4 Step 7 correction review closeout and CI candidate (2026-11-05) + +Correction commit `501863f8` completes two fresh independent review rounds. The +first covers authority/schema and contracts/CI; two different second-round +reviewers cover authority/database and evidence/contracts. All approve within +their scopes with no verified actionable finding. No source correction is +required by either round. + +Independent source checks match all 38 committed file hashes. Both forward +migration bodies retain their latest predecessor except for one parent +`FOR KEY SHARE`, and their generated target models remain unchanged. The second +round independently recomputes the 23 failed-A original ZIP API byte counts, +SHA-256 and CRC, the original logs archive, all 184 complete-Passed-group weight +increases and the original manifest's 291/289 A/B selector moves. It also +reconstructs the producer-only failure ledger: 330 Failed cases in 17 of 20 +producers, one censored case, and the separate 27-job six-success/21-failure +outcome. Meta's synthetic negative-fixture row is not a real producer failure. +These observations preserve the failed attempt's status. + +The reviewers inspect the scoped TRXs and final source boundaries recorded in +[the correction entry](#p03-4-step-7-first-hosted-corrections-2026-11-05); they run +no tests or hosted qualification. Structural IL and schema checks remain finite +controls, not general data-flow, external-delivery or complete concurrency proof. +No timer, count, resource ceiling, general permission or provider authority is +added by the correction or its advisory plan revision. + +The publication candidate adds only this closeout/current status reconciliation +after the reviewed implementation. Its actual PR head/base/merge tree, plan and +attempt identities accompany PR #41. A single push starts a fresh automatic A; +all 27 jobs and complete populations must pass before a whole-run B on the same +source. Retain original artifacts/logs before rerun, then independently reconcile +both layouts, exact case unions and every backend/frontend/native/public-demo/ +OpenAPI/SARIF/log surface. No failed, partial or stale-source fragments combine +into qualification. Actual final-source results belong with the PR; publication +of this record neither reports a run that has not completed nor changes source +again merely to narrate a green result. + +Human keyboard/focus/reflow/contrast/VoiceOver remains pending and unexecuted. +The existing external-prerequisite clause permits independent CI/archive work; +review closure is not a human pass or full packet-delivery claim. No merge, +production SMTP/TLS, inbox delivery, driver COMMIT-ack fault or P03-3 follow-up +closure is inferred. + + +### P03-4 Step 7 second hosted corrections (2026-11-05) + +Complete automatic A `41957367950`, attempt 1, binds PR head `416951f1`, +base `ef344c0f`, actual merge/workflow source `17a12276` and the unchanged +candidate tree. It finishes 22 successful and five failed jobs. All six +Architecture producers, base, native crash/cleanup, frontend, public-demo, +meta, OpenAPI and secret scan pass. Four ordinary producers and their required +integration aggregate fail. The producer ledger contains 8,276 Passed and +11 Failed cases, with no censored cases. This remains a failed A. + +All 24 available original artifact ZIPs match their API byte counts/SHA-256 and +pass CRC; the failed integration aggregate has no archive. The original attempt +logs archive is retained separately with its local SHA-256 and CRC. These raw +originals are retained before changing either CI-validator file. No successful +fragments or previous-source results establish qualification. + +A fresh owned PostgreSQL run reproduces the exact function-census failure: +`fn_invitation_email_envelope_guard:body`. The Platform expectation reads only +its original custody migration, ignoring the additive parent-lock replacement; +the applied production body is current. The test reader now folds compiled +Platform Up SQL in ordinal migration order from the exact installation boundary. +The existing strict declaration parser and exact body, signature, owner, ACL, +execution-property and caller checks remain unchanged: 22 functions, 11 definers +and 26 trigger callers. Three new regression cases independently inspect the +latest compiled envelope body and reject committed superseded-body and +literal-whitespace mutants against clean database twins. The full affected class +and adjacent parent-lock/upgrade controls pass 77 tests, zero skipped. Production +migrations and target models are unchanged. + +The separately failing seed-observation class passes its fresh current-source +run, one test with zero skips; it calls the corrected shared inspector. The new +nonseed invitation journey also passes its actual PostgreSQL/Keycloak/Mailpit/ +Next/browser recheck, one test with zero skips and successful fixture disposal. +Neither local pass establishes a unique cause for its earlier hosted failure; +the native journey's failure cause remains unknown. No driver or timer changes. + +The CI checkpoint now separately projects existing closed session-driver failures +for exactly the two invitation facts in the integration assembly. Only finite +phase/reason labels are published; the older browser-result decoder is unchanged. +Unknown text, wrong cases/assemblies/phases, oversized messages and private +canaries are refused or omitted. No email, verifier, URI, cookie, provider text +or stack enters the projection. Source-census, phase-pairing, privacy and actual +failure-checkpoint controls pass within 108 native-launcher controls, 87 shared +shard controls and 41 result controls, zero skips. No execution, cleanup or +resource allowance changes. The correction enters two fresh independent review +rounds before a single push and fresh automatic A. + +Complete final-source A followed by whole-run B, original archive/log and +independent surface reconciliation remain required. Human keyboard/focus/reflow/ +contrast/VoiceOver remains pending and unexecuted; the existing external- +prerequisite clause permits independent CI/archive work. No merge, production +SMTP/TLS, inbox-delivery or P03-3 follow-up closure is claimed. + + +### P03-4 Step 7 second correction review closeout (2026-11-05) + +Correction `8ceb7b37` completes two fresh independent review rounds. Both +first-round reviewers approve with no actionable finding. Two different +second-round reviewers inspect the full correction; one identifies a minor +unreachable fixed diagnostic. Session completion rejects a nonzero exit before +setting its completed state, so the separate disposal-only exit label cannot +describe either invitation fact. `6692876b` removes only that invitation mapping, +adds both-fact rejection and completion-order controls, and preserves the older +browser decoder. All 108 native offline controls pass again, zero skips. Both +second-round reviewers inspect the exact follow-up and close it with no remaining +finding. + +Independent source/hash and raw-TRX checks confirm the corrected Platform reader, +unchanged strict inspector, 77 passing schema/adjacent cases, committed body +mutants and exact reported red-to-green identity. The finite diagnostic/checkpoint +and private-canary boundary remain intact. The reviewers run no tests; they +reconcile retained scoped receipts and source rather than claiming new execution. +No production, migration, driver, timer or resource policy changes. + +The next publication adds only this dated closeout and current Status after the +reviewed source. A single push starts fresh automatic A on its actual PR merge +source. Complete same-source A then whole-run B, all original artifacts/logs and +independent backend/frontend/native/public/OpenAPI/SARIF reconciliation remain +mandatory. The [second failed A](#p03-4-step-7-second-hosted-corrections-2026-11-05) +retains its own source and outcome; its passing fragments supply no qualification. +Actual final-source results accompany PR #41 without another source change just +to narrate CI. No duplicate manual dispatch or merge is authorized. + +Human keyboard/focus/reflow/contrast/VoiceOver remains pending and unexecuted. +The external-prerequisite clause permits independent CI/archive work; review +closure is not full packet delivery, production SMTP/TLS, inbox-delivery or +P03-3 follow-up closure. + + +### P03-4 Step 7 third hosted A and census repair (2026-11-05) + +Complete automatic A `41963228768`, attempt 1, binds PR head `0e7cf754`, +base `ef344c0f` and actual merge/workflow source `63958227`. It finishes +23 successful and four failed jobs. Architecture partition 0 observes 59 of +60 selected cases, all Passed, before its execution deadline; the authenticated +HTTP census mutation case has no completed result. Ordinary partition 6 observes +all 246 cases, with two Failed and 244 Passed. The required backend and +integration aggregates also fail. All other producers, frontend, public-demo, +meta, OpenAPI and secret scan pass. The producer ledger contains 8,287 Passed, +two Failed and one censored case. This is a failed A, not qualification. + +All 23 available original artifact ZIPs match their API byte counts/SHA-256 and +pass CRC before any source edits. Both failed aggregate archives are absent. +The complete original attempt logs archive is retained separately with its local +SHA-256 and CRC. Passing fragments and earlier-source results do not supply +qualification credit. + +The exact provider self-service case passes a fresh owned real-provider recheck; +all four retry-observation theory variants pass their fresh owned PostgreSQL +recheck, including the failed ACK/capacity variant. Both processes exit after +fixture disposal, with zero skips. The hosted provider diagnostic records only +the last readiness outcome as TLS, not every attempt's outcome. Its fixture +ordinal and cached SDK state do not establish a live provider or unique failure +location. The cancellation-only retry diagnostic likewise does not locate its +cause. Neither local recheck establishes or repairs the hosted cause; no +production, provider, TLS, test-timer or resource policy changes follow from it. + +A source-restored diagnostic profile locates repeated graph work in the +architecture census. Its clean production walk schedules 2,324,123 queue entries +for 31,934 visited root/type pairs; nested-dispatch analysis consumes about 83% +of that diagnostic walk. The original exact mutation case passes locally in +246.187 seconds. A separate table-predicate caching experiment passes the same +case in 250.748 seconds and demonstrates no gain. Diagnostic instrumentation is +removed and its source restoration is verified; those runs are not hosted +qualification or new shard weights. + +The repair preserves every full census, predicate, root, assembly/type identity +and mutation/restoration control. Per-walk scheduling distinguishes the root, +type and implementation-expansion flag: a later explicit abstract collaborator +must still expand after an earlier inherited reference. Only redundant queue +states and duplicate diagnostic paths are eliminated; no cross-walk cache or +new execution allowance is introduced. The ineffective table-predicate cache +is removed; its three useful token/literal/opcode mutation controls remain. + +A fresh canonical Release rebuild passes the exact previously censored mutation +case in 133.911 seconds, with zero skips and unchanged source during execution. +Its case identity matches the earlier local baseline and the missing hosted +result. This single local comparison is about 46% faster than the 246.187-second +baseline; it establishes no hosted fit guarantee. The exact case plus the +remaining class/adjacent run pass 13 distinct cases: all four authenticated-HTTP +and nine public-surface facts, with zero skips. Their canonical source hashes +match. A temporary expansion-flag omission fails the new late-acquisition +control; its diagnostic call-order change and key mutation are restored +byte-for-byte before the fresh canonical adjacent rebuild. No diagnostic +instrumentation enters the correction. Two fresh independent review rounds +govern publication of this repair. + +Complete same-source hosted A followed by whole-run B, all original archives/logs +and independent surface reconciliation remain mandatory. Human keyboard/focus/ +reflow/contrast/VoiceOver remains pending and unexecuted. The existing external- +prerequisite clause permits independent CI/archive work; no merge, production +SMTP/TLS, inbox delivery or P03-3 follow-up closure is claimed. + + +### P03-4 Step 7 census repair review closeout (2026-11-05) + +Correction `63edd92c` completes two fresh independent review rounds. Both +first-round reviewers and two different second-round reviewers approve the +four-file correction with no verified actionable finding. No further source +correction is required. Reviewers inspect the exact graph/projection changes, +production mutation callers and the scoped raw receipts; they run no tests. + +Independent checks confirm the preserved per-root expansion/visited order, +assembly-qualified identity, generated/inherited controls and first forbidden +witnesses. The raw exact/adjacent TRXs contain 13 distinct Passed cases with +zero skips, and all 153 recorded source paths match canonical committed bytes. +The expansion-flag counterfactual loses the two late-acquisition witnesses and +fails as intended; saved source, restoration and fresh adjacent-build chronology +agree. Diagnostic instrumentation and the ineffective cache remain absent. The +local timing improvement remains a single scoped comparison, not hosted fit. + +The next publication adds only this dated closeout and current Status after the +reviewed code. A single push starts fresh automatic A on its actual PR merge +source. All 27 jobs and complete populations must pass before whole-run B on +the same source. Retain original artifacts/logs before rerun, then independently +reconcile both layouts and every proof surface. The +[third failed A](#p03-4-step-7-third-hosted-a-and-census-repair-2026-11-05) +retains its source and outcome; no failed or prior-source fragments combine +into qualification. Actual final-source results accompany PR #41 without a new +source change merely to narrate CI. No duplicate manual dispatch or merge. + +Human keyboard/focus/reflow/contrast/VoiceOver remains pending and unexecuted. +The existing external-prerequisite clause permits independent CI/archive work; +review closure is not full packet delivery, production SMTP/TLS, inbox delivery +or P03-3 follow-up closure. + + +### P03-4 Step 7 fourth hosted A and detector-index repair (2026-11-05) + +Complete automatic A `41977090425`, attempt 1, binds head `05c5892f`, +base `ef344c0f`, merge/workflow source `daba186f` and tree `ec349b50`. +It finishes 23 successful and four failed jobs. The earlier authenticated-HTTP +census partition and the earlier two failing integration cases pass. Frontend, +public-demo, native crash/cleanup, base, meta, OpenAPI and secret scan pass. +Architecture partition 5 and ordinary partition 2 fail, so both required +aggregates also refuse. The exact producer ledger contains 8,280 Passed, +one Failed and nine censored cases out of 8,290 selected. This remains a failed A. + +All 23 available original artifact ZIPs match their API byte counts/SHA-256 and +pass CRC; both failed aggregate archives are absent. The original attempt logs +archive is retained separately with its local SHA-256 and CRC. Retention completes +before any tracked diagnostic or implementation edit. No successful fragment +combines with an earlier source or another failed attempt into qualification. + +Architecture partition 5 observes 139 of 148 selected cases, all Passed, before +its deadline. The nine censored cases belong to one 48-case recurrence theory, +whose other 39 cases complete. Its shared method is not established as the sole +cause of exhaustion, and the prefix supplies no duration for a complete group. +The advisory summed-method estimate is not a wall-time bound. The completed +credential, Hangfire and bridge mutation cases consume substantial observed time; +no new timer, resource, theory split, selector or weight follows from censoring. + +The separate existing browser/session fact records a positive-journey Chromium +DevTools readiness failure followed by a secondary browser-signal cleanup error. +The operation probe has not run; this is not evidence of a logout/history +assertion failure. One unchanged fresh owned recheck passes the complete fact in +51.137 seconds, including its three two-host journeys, stock Chromium, explicit +fake provider/API fixtures and normal joined cleanup. Its history counter uses +the separately labelled persisted event and records zero natural restorations. +The actual test and runner exit successfully with unchanged source. This local +pass neither establishes the original startup cause nor qualifies hosted CI. + +A fresh canonical three-Fact baseline passes with zero skips. A temporary +fixed-span profile then preserves those original invocations and separately runs +one actual recurrence theory row with all clean/dirty/restored evaluations. +Every span completes with exact accounting. In that scoped row, the main and +SDK projections consume about 40% of detector time; repeated exact-guard scans +consume about 42%. Each evaluation projects 150,187 operands twice. The unscoped +clean recurrence Fact is explicitly distinct from the scoped theory evidence. +All four instrumented source files restore byte-for-byte; diagnostic timings +supply neither qualification nor new weights. A fresh canonical rebuild follows. + +The repair reuses the existing ordered typed instruction snapshot and exact +method-name index. Each detector captures the snapshot once, including the +unscoped caller and SDK handoff; complete identity/opcode/owner predicates, +duplicate occurrences, original order and generated-owner attribution remain. +Owner formatting occurs after selection. Every clean/dirty/restored evaluation +keeps its own scope; no cross-mutation cache or broader capability/observation +cache is introduced. Same-Fact index controls compare the original raw ordered +method/field tuples, foreign/generic operands, fresh scopes and exception disposal. +A fresh canonical exact-row baseline passes in 3.090 seconds; the final +candidate passes the same row in 2.156 seconds. This is one local per-case +comparison, about 30% lower. Other clean-Fact and build-inclusive wall timings +move in both directions, so no whole-command or hosted-fit improvement is +inferred. Final canonical runs pass 57 distinct cases with zero skips: three +clean Facts, all 48 recurrence cases, the existing extended index Fact and five +shared-caller Facts. The extra focused row is already in the 48-case set and is +not counted twice. All 48 identities match the retained assignment, including +the nine previously censored cases, and all 153 recorded source paths match +across candidate runs and final source. No diagnostic code remains. Two fresh +independent review rounds govern publication of this six-file correction. + +Complete same-source hosted A followed by whole-run B, original archives/logs +and independent all-surface reconciliation remain mandatory. Human keyboard/ +focus/reflow/contrast/VoiceOver remains pending and unexecuted; the existing +external-prerequisite clause permits independent CI/archive work. No merge, +production SMTP/TLS, inbox delivery or P03-3 follow-up closure is claimed. + + +### P03-4 Step 7 detector-index repair review closeout (2026-11-05) + +Correction `dfa0b972` completes two fresh independent review rounds. Both +first-round reviewers and two different second-round reviewers approve the +six-file correction with no verified actionable finding. No further source +correction is required. The reviewers inspect exact predicates, typed ordering, +identity/multiplicity, scoped/unscoped lifetime, SDK handoff and independent +raw controls; they run no tests or builds. + +Independent raw-TRX/child-receipt reconciliation confirms 57 distinct Passed +cases across 58 executions, zero skips, all 48 original recurrence identities +including the nine censored cases, and 153 canonical source paths. Diagnostic +accounting, byte-exact restoration and subsequent canonical builds agree. +Reviewers retain the local timing limits and the unchanged browser recheck's +unknown original startup cause; no natural BFCache, native or human proof is +inferred from structural tests. No policy, timer, resource, weight, selector +or test-population changes enter this correction. + +The next publication adds only this dated closeout and current Status after the +reviewed source. A single push starts fresh automatic A on the actual PR merge +source. All 27 jobs and complete populations must pass before whole-run B on +the same source. Retain originals before rerun and independently reconcile all +backend/frontend/native/public/OpenAPI/SARIF/log surfaces. The +[fourth failed A](#p03-4-step-7-fourth-hosted-a-and-detector-index-repair-2026-11-05) +retains its own source and outcome; no failed or prior-source fragments combine +into qualification. Actual final-source results accompany PR #41 without a new +source change merely to narrate CI. No duplicate manual dispatch or merge. + +Human keyboard/focus/reflow/contrast/VoiceOver remains pending and unexecuted. +The existing external-prerequisite clause permits independent CI/archive work; +review closure is not full packet delivery, production SMTP/TLS, inbox delivery +or P03-3 follow-up closure. diff --git a/docs/roadmap/phase-03-identity-admin.md b/docs/roadmap/phase-03-identity-admin.md index 053fab4df..d4a3adaa0 100644 --- a/docs/roadmap/phase-03-identity-admin.md +++ b/docs/roadmap/phase-03-identity-admin.md @@ -1,31 +1,38 @@ # Phase 03: Identity Domain, Authorization, and Admin Foundation -> **Status (2026-11-04).** P03-0, P03-1 and P03-2 are complete. -> P03-1 ([PR #38](https://github.com/HodeTech/LearnStack/pull/38)) and -> P03-2 ([PR #39](https://github.com/HodeTech/LearnStack/pull/39)) are merged. -> P03-3 implementation and both independent review rounds are complete for -> Steps 1–9 and Step 10's source corrections. Accepted ADR-0078–0092 govern the -> native entry, administration, seed, framework ownership and CI changes; the -> decision register below links their decisions and proof obligations. -> ADR-0091's native inventory and [ADR-0092](../decisions/0092-sdk-completion-and-frontend-job-windows.md) -> SDK settings are implemented. The [VM compatibility handoff](phase-03-delivery-log.md#p03-3-hosted-vm-compatibility-validation-and-corrective-ci-handoff-2026-11-04) -> records the narrow standard-host signature correction, passing 23/41/106 -> offline controls and two fresh reviews. -> [PR #40](https://github.com/HodeTech/LearnStack/pull/40) owns parallel product -> review and current complete same-source hosted A/B/archive qualification. -> Its proof record distinguishes local, hosted and manual evidence. -> Failed/cancelled runs receive no qualification credit. +> **Status (2026-11-05).** P03-0 through P03-3 implementation delivery is complete. +> P03-1 ([PR #38](https://github.com/HodeTech/LearnStack/pull/38)), +> P03-2 ([PR #39](https://github.com/HodeTech/LearnStack/pull/39)) and +> P03-3 ([PR #40](https://github.com/HodeTech/LearnStack/pull/40)) are merged. +> The [P03-3 closeout](phase-03-delivery-log.md#p03-3-merge-and-delivery-closeout-2026-11-04) records delivered operations, source-bound CI evidence +> and the remaining proof boundaries under Accepted ADR-0078–0092. +> Final-head B/original-archive verification remains an open P03-3 evidence +> follow-up to reconcile before P03-11 phase exit; delivery closure does not +> establish that proof. > [Manual keyboard/focus/reflow/contrast/VoiceOver evidence](phase-03-delivery-log.md#p03-3-maintainer-approved-manual-deferral-and-step-10-continuation-2026-10-30) -> remains maintainer-deferred and unexecuted for PR review. -> Phase 03 remains the critical path; later packets have not started. +> remains maintainer-deferred and unexecuted. +> P03-4/G8 is Accepted under [ADR-0093](../decisions/0093-verified-invitation-acceptance-and-handoff.md), +> after maintainer selection of automatic email and verified review corrections. +> Its [seven-step package](phase-03-delivery-log.md#p03-4-automatic-email-review-corrections-and-accepted-plan-2026-11-04) +> has Steps 1–5 implemented and reviewed. Step 6 and both review rounds are closed +> ([closeout](phase-03-delivery-log.md#p03-4-step-6-implementation-review-closeout-and-step-7-continuation-2026-11-05)); +> human accessibility evidence remains unexecuted and pending. Step 7 +> implementation and both final review rounds are closed +> ([PR readiness](phase-03-delivery-log.md#p03-4-step-7-final-reviews-and-pr-readiness-2026-11-05)). +> P03-4's [second correction reviews](phase-03-delivery-log.md#p03-4-step-7-second-correction-review-closeout-2026-11-05) are closed, including the verified diagnostic fix. +> The [detector-index repair reviews](phase-03-delivery-log.md#p03-4-step-7-detector-index-repair-review-closeout-2026-11-05) are closed with no actionable finding; failed-source proof boundaries remain. +> Complete same-source hosted A/B and original-archive qualification remain mandatory +> before delivery/merge; actual source-bound results accompany the PR and its +> [workflow evidence](https://github.com/HodeTech/LearnStack/actions/workflows/ci.yml?query=branch%3Adevelopment). +> P03-5 through P03-11 implementation has not started. Phase 03 remains in progress. > > | Packet | Title | State | > |---|---|---| > | P03-0 | Entry preparation | Complete — [reconciliation](phase-03-delivery-log.md#entry-preparation-2026-10-25) | > | P03-1 | Identity authority and membership foundation | Complete and merged — [closeout](phase-03-delivery-log.md#p03-1-merge-and-closeout-2026-10-27) | > | P03-2 | Live membership and authorization | Complete and merged — [closeout](phase-03-delivery-log.md#p03-2-merge-and-closeout-2026-10-28) | -> | P03-3 | Protected Studio and membership administration | Implemented, in [PR #40](https://github.com/HodeTech/LearnStack/pull/40) review; current qualification tracked there | -> | P03-4 | Invitations and bounded delivery | Not started | +> | P03-3 | Protected Studio and membership administration | Complete and merged — [closeout](phase-03-delivery-log.md#p03-3-merge-and-delivery-closeout-2026-11-04); evidence follow-ups remain open | +> | P03-4 | Invitations and bounded delivery | In progress — G8 Accepted; fourth CI correction reviewed; final-source A/B/archive gates and human evidence govern delivery | > | P03-5 | Custom fields and privacy classification | Not started | > | P03-6 | Provider event ingress and audit mirror | Not started | > | P03-7 | Back-channel session revocation | Not started | @@ -131,14 +138,14 @@ named part, and the register links each explicit acceptance and vehicle. | Gate | Question | Existing boundary / required answer | First consumer | Vehicle | Status | |---|---|---|---|---|---| -| G1 | Who establishes issuer + opaque `sub` → stable `UserId` and proves actor association before an accepted login? | Preserve seeded/provider IDs, reject substitution/email merging, name trusted pre-membership admission, first User-writer audit attribution, concurrency and cross-system reconciliation; self-registration alone grants no membership/session; new-user HTTP activation also requires provider registration and durable creation budgets | P03-1 storage/seed; P03-3 activation | Identity authority ADR; bounded ADR-0064/0070 disclosures if affected; Identity spec | Accepted foundation and P03-3 activation contract — [ADR-0078/0079 package](phase-03-delivery-log.md#p03-3-maintainer-corrections-joint-acceptance-and-step-1-2026-10-28); P03-3 runtime qualification tracked in [PR #40](https://github.com/HodeTech/LearnStack/pull/40) | +| G1 | Who establishes issuer + opaque `sub` → stable `UserId` and proves actor association before an accepted login? | Preserve seeded/provider IDs, reject substitution/email merging, name trusted pre-membership admission, first User-writer audit attribution, concurrency and cross-system reconciliation; self-registration alone grants no membership/session; new-user HTTP activation also requires provider registration and durable creation budgets | P03-1 storage/seed; P03-3 activation | Identity authority ADR; bounded ADR-0064/0070 disclosures if affected; Identity spec | Accepted foundation and P03-3 activation contract — [ADR-0078/0079 package](phase-03-delivery-log.md#p03-3-maintainer-corrections-joint-acceptance-and-step-1-2026-10-28); P03-3 delivery is merged, with evidence and follow-ups in the [closeout](phase-03-delivery-log.md#p03-3-merge-and-delivery-closeout-2026-11-04) | | G2 | How do membership and permission readers reach data before resolved tenant SQL exists, and how does authorized `app.scope` reach EF/RLS? | Exact principal, grants, candidate-scope owner and transaction/setter handoff; live authorization of rows 6/9; qualified rows 7/10/14 and distinct successful claim-only header-mismatch evidence outside host-tenant budgets | P03-1 contract; P03-2 execution | ADR-0036/0040 authority; Security/Database standards and closed-set tests | Accepted — 2026-10-27, [ADR-0076/0077 and package](phase-03-delivery-log.md#p03-2-joint-acceptance-and-step-1-2026-10-27); execution implemented and qualified in the [Step 6 closeout](phase-03-delivery-log.md#p03-2-step-6-qualification-and-review-handoff-2026-10-28) | -| G3 | What are membership/profile/role-binding boundaries, nullable natural keys, lifecycle and global-user projections? | Decide active uniqueness, tenant/org grant composition, profile granularity, archive/reinvite and last-admin/org invariants, same-module composite FKs and Tenancy lifecycle contracts; no cross-module Organization FK/join | P03-1 | Identity module spec; ADR for aggregate/ownership changes; database class declaration | Accepted foundation and [ADR-0074 timing delta](../decisions/0074-membership-admission-under-ordered-locks.md); foundation and per-act lifecycle proofs implemented/reviewed in Step 4; P03-3 archive/default contract Accepted under ADR-0079 with P03-3 runtime qualification tracked in [PR #40](https://github.com/HodeTech/LearnStack/pull/40); global closure remains P03-10 | -| G4 | How is application scope admitted per session, discovered and switched safely? | Current mappers use user-global attributes and ordinary BFF refresh preserves scope; decide storage implications first, then independent native scope, callbacks/hosts, no-membership entry and switch/logout/refresh races | P03-1 implications; P03-3 transition | Provider/session ADR and applicable ADR-0070 disclosure; auth architecture | Accepted storage boundary and P03-3 native transition contract — [ADR-0078](../decisions/0078-native-session-scope-and-self-entry.md); P03-3 runtime qualification tracked in [PR #40](https://github.com/HodeTech/LearnStack/pull/40) | -| G5 | What identifies a scoped permission, who may delegate it, and when do revocations take effect? | Reconcile disabled-default versus Tenant Admin seed expansion; decide registry shape, closed-role defaults, bootstrap/recovery, grant ceilings, last-admin concurrency, finite SystemActor permissions, anonymous Portal Public and API/UI freshness | P03-1 schema/bootstrap; P03-2 enforcement; P03-3 UI | ADR-0017/0064-derived permission contract; Standard 19 and module matrices | Accepted — 2026-10-27, [P03-2 enforcement contract](phase-03-delivery-log.md#p03-2-joint-acceptance-and-step-1-2026-10-27); enforcement implemented and qualified; P03-3 finite UI/delegation/adoption contract Accepted under ADR-0079; fixed-seed prerequisite source [ADR-0080](../decisions/0080-fixed-seed-prerequisite-read-scope.md) and separate origin-completion source [ADR-0081](../decisions/0081-fixed-seed-completion-before-origin-adoption.md) Accepted 2026-10-29; their separate censuses remain required and runtime qualification is tracked in [PR #40](https://github.com/HodeTech/LearnStack/pull/40) | -| G6 | Which protected endpoint/application policies and native web/SDK operations are admitted? | Decide denial precedence/resource lookup first; then exact routes/methods, Origin/CSRF, input/deadline/no-store bounds, ETag/conflict, actor/scope-bound idempotent replay and cache cleanup; no generic proxy or Server Action implied | P03-2 policy ordering; P03-3 transport | API/BFF contract; ADR-0064/0070/0071 disclosures if affected; Standards 04/07 | Accepted — 2026-10-27, [policy/directory slice](phase-03-delivery-log.md#p03-2-joint-acceptance-and-step-1-2026-10-27); policy/directory slice implemented and qualified; P03-3 native/mutation/UI transport contract Accepted under ADR-0078/0079; P03-3 runtime qualification tracked in [PR #40](https://github.com/HodeTech/LearnStack/pull/40) | +| G3 | What are membership/profile/role-binding boundaries, nullable natural keys, lifecycle and global-user projections? | Decide active uniqueness, tenant/org grant composition, profile granularity, archive/reinvite and last-admin/org invariants, same-module composite FKs and Tenancy lifecycle contracts; no cross-module Organization FK/join | P03-1 | Identity module spec; ADR for aggregate/ownership changes; database class declaration | Accepted foundation and [ADR-0074 timing delta](../decisions/0074-membership-admission-under-ordered-locks.md); foundation and per-act lifecycle proofs implemented/reviewed in Step 4; P03-3 archive/default contract Accepted under ADR-0079; P03-3 delivery is merged, with evidence and follow-ups in the [closeout](phase-03-delivery-log.md#p03-3-merge-and-delivery-closeout-2026-11-04); global closure remains P03-10 | +| G4 | How is application scope admitted per session, discovered and switched safely? | Current mappers use user-global attributes and ordinary BFF refresh preserves scope; decide storage implications first, then independent native scope, callbacks/hosts, no-membership entry and switch/logout/refresh races | P03-1 implications; P03-3 transition | Provider/session ADR and applicable ADR-0070 disclosure; auth architecture | Accepted storage boundary and P03-3 native transition contract — [ADR-0078](../decisions/0078-native-session-scope-and-self-entry.md); P03-3 delivery is merged, with evidence and follow-ups in the [closeout](phase-03-delivery-log.md#p03-3-merge-and-delivery-closeout-2026-11-04) | +| G5 | What identifies a scoped permission, who may delegate it, and when do revocations take effect? | Reconcile disabled-default versus Tenant Admin seed expansion; decide registry shape, closed-role defaults, bootstrap/recovery, grant ceilings, last-admin concurrency, finite SystemActor permissions, anonymous Portal Public and API/UI freshness | P03-1 schema/bootstrap; P03-2 enforcement; P03-3 UI | ADR-0017/0064-derived permission contract; Standard 19 and module matrices | Accepted — 2026-10-27, [P03-2 enforcement contract](phase-03-delivery-log.md#p03-2-joint-acceptance-and-step-1-2026-10-27); enforcement implemented and qualified; P03-3 finite UI/delegation/adoption contract Accepted under ADR-0079; fixed-seed prerequisite source [ADR-0080](../decisions/0080-fixed-seed-prerequisite-read-scope.md) and separate origin-completion source [ADR-0081](../decisions/0081-fixed-seed-completion-before-origin-adoption.md) Accepted 2026-10-29; their separate censuses remain required; the [P03-3 merge closeout](phase-03-delivery-log.md#p03-3-merge-and-delivery-closeout-2026-11-04) records delivery, evidence and open follow-ups | +| G6 | Which protected endpoint/application policies and native web/SDK operations are admitted? | Decide denial precedence/resource lookup first; then exact routes/methods, Origin/CSRF, input/deadline/no-store bounds, ETag/conflict, actor/scope-bound idempotent replay and cache cleanup; no generic proxy or Server Action implied | P03-2 policy ordering; P03-3 transport | API/BFF contract; ADR-0064/0070/0071 disclosures if affected; Standards 04/07 | Accepted — 2026-10-27, [policy/directory slice](phase-03-delivery-log.md#p03-2-joint-acceptance-and-step-1-2026-10-27); policy/directory slice implemented and qualified; P03-3 native/mutation/UI transport contract Accepted under ADR-0078/0079; P03-3 delivery is merged, with evidence and follow-ups in the [closeout](phase-03-delivery-log.md#p03-3-merge-and-delivery-closeout-2026-11-04) | | G7 | Who audits HTTP/application/resource denials and supplies trusted actor/request context? | One evidence owner per refusal, MUST sensitive reads, membership/role/platform/operator/route provenance, trusted IP and bounded user-agent; preserve refusal on audit failure; no generic ADR-0065 budget | P03-2 | ADR-0033/0044; Audit spec, enrichment port and matrices | Accepted — 2026-10-27, [ADR-0077 / P03-2 evidence](phase-03-delivery-log.md#p03-2-joint-acceptance-and-step-1-2026-10-27); runtime implemented and qualified; privileged population remains G13 Open | -| G8 | What makes invitation acceptance and delivery authoritative and recoverable? | Verified current provider email, hashed single-use token, GET inspection only, explicit mutating act, current delegation, accept/revoke/expiry/resend races, aggregate atomicity and bounded delivery independent of Phase 08a | P03-1 only if shared aggregate shape changes; P03-4 writer | Invitation contract; ADR-0042 successor if an exception changes; phase package and delivery handoff | Open | +| G8 | What makes invitation acceptance and delivery authoritative and recoverable? | Verified current provider email, hashed single-use token, GET inspection only, explicit mutating act, current delegation, accept/revoke/expiry/resend races, aggregate atomicity and bounded delivery independent of Phase 08a | P03-1 only if shared aggregate shape changes; P03-4 writer | Invitation contract; ADR-0042 successor if an exception changes; phase package and delivery handoff | Accepted — 2026-11-04, [ADR-0093](../decisions/0093-verified-invitation-acceptance-and-handoff.md) and [automatic-email package](phase-03-delivery-log.md#p03-4-automatic-email-review-corrections-and-accepted-plan-2026-11-04); implementation/proof pending | | G9 | How do field definitions and values preserve schema/classification history? | Decide revision pins/active uniqueness, scalar-field compatibility with the existing admitted object profile, nested data, required/default/retire/reclassify/key-reuse behavior, consent and supported localized forms; no raw JSONB bypass | P03-5 before its schema | Customization/privacy contract; ADR-0043 disclosure or successor if needed; module specs | Open | | G10 | What authenticated durable provider ingress is selected, and how are global events attributed? | Name producer, acknowledgement, stable provider-event dedup, ordering, tenantless/multi-membership routing, privacy and retention; enqueue through outbox, never direct business publication; no invented tenant or privileged app consumer | P03-6, or an earlier mechanism needing ingress | Provider-event ADR; ADR-0046/0057 boundaries, exact grants and Audit subscription | Open | | G11 | Which back-channel token profile, route and session targets are supported? | Separate OIDC purpose validation, signature/issuer/audience/time/events/no-nonce, replay retention, provider/session indexing and reachability; preserve late-work/accounting fences and unrelated/new session owners | P03-7 | ADR-0070 bounded extension; native route/provider configuration package | Open | @@ -604,27 +611,32 @@ The APISIX adapter's owning phase and ### Invitation Flow -G8 closes before the Invitation schema or acceptance writer. G1 supplies the +Accepted [ADR-0093](../decisions/0093-verified-invitation-acceptance-and-handoff.md) +closes G8 before the Invitation schema or acceptance writer. G1 supplies the actor/bootstrap contract; a new invitee cannot already be assumed to hold the membership they are accepting. - An authorized admin offers email + role + organization within their current delegation ceiling. Bind a random single-use token to a stored one-way verifier; a prefilled email is not proof of the authenticated person's current verified - provider email. G8 decides exact matching and email-change behavior. -- GET inspects an offer without accepting it. The admitted mutating act uses the + provider email. ADR-0093 owns exact matching and email-change behavior. +- Browser GET renders clean invitation navigation/inspection UI without accepting + it. Secret-carrying API inspection/result reads use strict POST bodies and + explicit ReadOnly/Off classification; only the acceptance POST can grant. + The admitted mutating act uses the chosen authenticated continuation and CSRF/transaction binding. Keep tokens out of diagnostic payloads and arbitrary return URLs. - Define pending/accepted/revoked/expired/resend states, default 14-day expiry, existing-member behavior, current inviter grant/org lifecycle checks and accept/accept, accept/revoke and accept/expiry races. - Membership/grant creation and invitation consumption need one authoritative - outcome. G8 decides aggregate boundaries and atomicity/reconciliation under + outcome. ADR-0093 decides aggregate boundaries and atomicity/reconciliation under [ADR-0042](../decisions/0042-tenant-provisioning-cross-aggregate-transaction.md); the ambient transaction alone grants no new multi-root exception. -- Deliver a usable bounded invitation handoff in this phase. G8 decides its - email/provider or explicit admin-handoff mechanism, delivery/retry/idempotency - and truthful queued/sent/failed/resend UX. Do not wait implicitly for Phase 08a, +- Deliver automatic invitation email through the minimum SMTP/job/private-custody + slice selected by ADR-0093. Report queued/submission-accepted/failed/uncertain + state; explicit resend reissues the generation. Do not claim inbox delivery + or wait implicitly for Phase 08a, display a fake sent result or put raw email/token data in identifier-only events. An invitation grants authority in one tenant/org only. It does not let an admin diff --git a/docs/roadmap/phase-08a-assessment-notifications.md b/docs/roadmap/phase-08a-assessment-notifications.md index baa089d24..a1016e1c6 100644 --- a/docs/roadmap/phase-08a-assessment-notifications.md +++ b/docs/roadmap/phase-08a-assessment-notifications.md @@ -134,10 +134,11 @@ and an unread template store is a schema nobody validates. This phase owns the full engine and template-driven channel orchestration. [Phase 03 G8](phase-03-identity-admin.md#the-decision-register) owns the earlier -usable bounded invitation delivery/handoff and its privacy/idempotency/recovery -contract. That first consumer must name any required minimum email slice there; -it cannot assume this engine is already running. This phase integrates that -accepted boundary instead of introducing a competing sender or retry policy. +usable bounded invitation delivery and its privacy/idempotency/recovery contract. +[ADR-0093](../decisions/0093-verified-invitation-acceptance-and-handoff.md) selects +the minimum automatic SMTP/job/encrypted-custody slice for that first consumer. +This phase integrates that boundary instead of introducing a competing sender or +retry policy; P03-4 does not assume the full engine is already running. - Notification dispatch orchestration in the Notifications module. - Channels: email wired through the `IEmailProvider` adapter diff --git a/docs/standards/01-architecture-standards.md b/docs/standards/01-architecture-standards.md index 087ff7be2..60c5cbd52 100644 --- a/docs/standards/01-architecture-standards.md +++ b/docs/standards/01-architecture-standards.md @@ -149,13 +149,16 @@ packages are not in that category: Vogen is the source generator itself, and Pol - The aggregate root is the only entry point for state changes inside the aggregate. - Repositories return aggregates, not raw entities. - Cross-aggregate writes inside a single transaction are forbidden. Use an integration event. - **One standing exception, bounded by enumeration:** tenant provisioning writes `Tenant` - and its default `Organization` in one transaction, because - `tenants.default_organization_id` carries an invariant no eventual-consistency - mechanism can deliver - ([ADR-0042](../decisions/0042-tenant-provisioning-cross-aggregate-transaction.md)). - It covers those two roots and that one operation; the allow-list is literal, and - `Cross_Aggregate_Writes_Are_Confined_To_Tenant_Provisioning` holds it at one entry. + **Two named exceptions, bounded by enumeration:** `ProvisionTenantCommandHandler` + writes Tenancy's Tenant/default Organization under + [ADR-0042](../decisions/0042-tenant-provisioning-cross-aggregate-transaction.md); + `AcceptInvitationCommandHandler` writes Identity's Invitation/at-most-one Membership + under [ADR-0093](../decisions/0093-verified-invitation-acceptance-and-handoff.md). + Each protects its named atomic invariant, including contained children and central + plural MUST evidence. No other root or handler inherits either exception. + `Cross_Aggregate_Writes_Are_Confined_To_Named_Exceptions` owns the literal two-handler + contract; its [catalogue status](21-architecture-tests-catalogue.md#cross_aggregate_writes_are_confined_to_named_exceptions) + distinguishes the implemented provisioning baseline from pending invitation backfill. Cross-**module** writes remain forbidden with no exception at all. ## Cross-Module Communication diff --git a/docs/standards/04-api-design.md b/docs/standards/04-api-design.md index e1f3ca8f6..b9056bfdd 100644 --- a/docs/standards/04-api-design.md +++ b/docs/standards/04-api-design.md @@ -395,6 +395,76 @@ continuation cursor. The count limit remains an upper bound: escaped labels and verified origin expansion may shorten a page to preserve ADR-0079's 256 KiB API/SDK response budget, while subsequent pages retain every remaining item. +### Verified invitation transport + +**Derives from:** [ADR-0093](../decisions/0093-verified-invitation-acceptance-and-handoff.md). +This is the Accepted P03-4 contract; the phase Status block owns runtime delivery +and qualification. The following finite extension adds no general nonmember API +or native proxy. All paths below carry `/api/v1`. + +| Method and path | Exact consumer / response boundary | +|---|---| +| GET `/identity/invitations`, `/identity/invitations/{id}` | `ListInvitationsQuery` / `GetInvitationQuery`; applicable membership-admin in exact current scope, MUST read; email, Role, lifecycle, expiry, version and bounded delivery status only | +| GET `/identity/invitable-roles` | `ListInvitableRolesQuery`; same capability/scope, MUST read; currently delegable ID/key/scope/version summaries, no Role grant graph | +| POST `/identity/invitations` | `CreateInvitationCommand`; email and one pinned compatible Role/version; atomically creates generation 1, private encrypted mail custody and typed job; protected keyed 201 version receipt and Location | +| POST `/identity/invitations/{id}/revocations` | `RevokeInvitationCommand`; current exact-scope membership-admin withdrawal authority, Pending target and canonical strong If-Match and protected keyed version receipt | +| POST `/identity/invitations/{id}/reissues` | `ReissueInvitationCommand`; current delegation and unchanged Role pin, canonical strong If-Match and protected keyed receipt; new verifier/generation/expiry invalidates the old link | +| GET `/tenancy/settings/invitation-policy` | `GetTenantInvitationPolicyQuery`; own-current-tenant policy only, orgless context, Off; absent/default or stored version precondition | +| PUT `/tenancy/settings/invitation-policy` | `SetTenantInvitationLifetimeCommand`; Tenant `tenancy.setting.write`, orgless scope, fixed key `identity.invitation.ttl_days`, 1–30 days; conditional creation or canonical If-Match, protected keyed receipt and MUST update | +| POST `/identity/invitation-entry/inspections` | `InspectInvitationQuery`; body ID/code, exact host/source/subject/offer entry, fresh current verified recipient; Off, physically ReadOnly and no-store | +| POST `/identity/invitation-entry/acceptances` | `AcceptInvitationCommand`; body ID/code only, canonical strong If-Match for Pending, fresh recipient proof and locked authority; natural offer/actor convergence; Membership/result/version receipt | +| POST `/identity/invitation-entry/results` | `GetOwnInvitationResultQuery`; body ID/code, consumed digest, same Active actor and original current Active Membership; Off, physically ReadOnly; no new grant or email/inviter/Role/expiry revalidation | + +Administrative writes never return raw codes or links. Create/revoke/reissue use +the existing bounded protected receipt rules; replays disclose the original +Invitation through `ReadInvitationReceiptQuery` with fresh membership-admin and +MUST read audit. Policy replay uses `ReadInvitationPolicyReceiptQuery`, the exact +original TenantSetting ID/key in the same orgless tenant, own-current-Membership +coverage and MUST read audit; it requires no old write capability and exposes no +generic settings directory. No secret enters durable replay bytes. Policy +absence is a conditional create precondition, never an unconditional upsert. +The policy getter returns `days`, `present`, nullable decimal-string `version` +and advisory `canEdit`. Absent means 14 days, `present=false`, `version=null` +and no ETag. A stored version-zero row has `present=true`, `version="0"` and +ETag `"0"`. PUT accepts only `{days}` and Idempotency-Key, with exactly one +of `If-None-Match: *` for absence or canonical `If-Match: "N"` for presence. +Missing, combined, weak or list preconditions are 400; a changed presence/version +is 409. Both setter branches return the 200 version receipt without Location; +protected fingerprints bind the presence distinction. The retained receipt +contains the actual original TenantSetting ID, including first creation. + +Revocation withdraws a Pending offer under current exact-scope membership-admin +and strong root If-Match. It assigns nothing and does not require the obsolete +offered Role or its pin/grant ceiling to remain usable. Create and reissue retain +the live compatible Role/version and complete delegation checks; changing that +pin requires withdrawal and a new offer. Reissue preserves the original inviter. + +Entry bodies are strict UTF-8 JSON, at most 8 KiB, with no unknown/duplicate +fields or actor/tenant/org authority selectors. Inspection and result use POST +to keep credentials out of URL paths/queries; their consumers remain ReadOnly. +All three entry bodies carry only Invitation ID and code. Observed acceptance +version has one carrier: canonical strong If-Match, not a duplicate body field. +Inspection/result reject conditional headers. Acceptance rejects Idempotency-Key +and introduces no tenantless replay namespace. A preflight Accepted offer +dispatches the exact result read before mutation intents or Pending-only If-Match +validation; an obsolete original precondition does not block committed-result +recovery. A Pending command losing the race returns recoverable 409 with truthful +failed-attempt evidence; a fresh result read reconciles the committed outcome. +Unknown COMMIT remains indeterminate until authoritative reconciliation. + +The browser landing GET renders only. Its fragment code reaches the API only +through bounded native capture and these POST bodies; no GET consumes an offer, +creates Membership or records a send. Admin pages use 20 default/100 maximum, +actor/scope/operation-bound opaque cursors, no-store and the inherited bounded +response/deadline rules. Provider observation is limited to 16 KiB/five seconds +within the existing ten-second native API deadline, with no business locks held. +Malformed fields/preconditions are 400; stale versions/changed lock plans and +actionable target conflicts are 409. Missing source/admission, invalid link or +foreign target is masked 404; an invalid-present bearer remains 401. A matching +offer with unverified/mismatched recipient gets bounded 403 recovery without the +expected mailbox. Uncertain storage/provider state is 503; exhausted quotas are +429. Submission accepted by SMTP is not proof of inbox delivery. + ## Pagination Cursor pagination by default: diff --git a/docs/standards/05-database.md b/docs/standards/05-database.md index d657a98cd..310a20b98 100644 --- a/docs/standards/05-database.md +++ b/docs/standards/05-database.md @@ -453,6 +453,11 @@ Rules: through its append-only guard, whose refusal of an organization change must be proved independently of the role's absent UPDATE privilege. + The closed invitation SMTP control-metadata class has its own insert-only and + scope-frozen guards under [Provider records and ordinary staging](#provider-records-and-ordinary-staging). + Its exact bodies, role privileges and independent mutation controls participate + in the applied-schema census; a new metadata label is not an exemption. + ### Parent organization mirrors When a child must share its parent's organization, its factory derives the scope @@ -2457,7 +2462,9 @@ before implementation qualification. ## Background jobs **Derives from:** [ADR-0047](../decisions/0047-transactional-background-job-runtime.md) -and [ADR-0058](../decisions/0058-bounded-stock-hangfire-maintenance.md). +and [ADR-0058](../decisions/0058-bounded-stock-hangfire-maintenance.md); +[ADR-0093](../decisions/0093-verified-invitation-acceptance-and-handoff.md) adds only +the closed SMTP provider metadata-read arms. The Platform chain owns seven raw infrastructure tables. Job, attempt and recipient rows use the org-scoped class; capacity and reservation rows use the tenant-wide @@ -2467,7 +2474,9 @@ control access. None receives Platform or PUBLIC privileges. No runtime DELETE o TRUNCATE is granted; ordinary owner writes also meet the immutable guards. Parameters, plans and outcomes preserve canonical JSON text and inclusive UTF-8 -byte bounds. Control SQL never inspects their contents. Strict typed identifier +byte bounds. Generic runtime control SQL never inspects their contents. The exact +[provider-custody binding](#provider-records-and-ordinary-staging) separately checks +its declared identifier tuple. Strict typed identifier validation remains the submission/read boundary's obligation. The immutable job plan and each attempt's write-once evidence retain history. A final outcome pointer selects one normal attempt for all separately counted finalize admissions. @@ -2499,8 +2508,15 @@ reader; complete caller enrollment and runtime qualification remain its closure Eligibility indexes lead with their actual clock/order for the confined global scans; tenant and FK indexes remain complete. Clocks are runtime predicates. The -canonical SQL below is copied verbatim into the additive Platform migration and -compared against a separately applied database by schema proofs. +canonical SQL below describes the current additive Platform migration chain and +is compared against a separately applied database by schema proofs. + +The SMTP owner-read arms name only the two fixed Identity definitions, external +profile/queue and invitation-email capacity group. Row-locking SELECT also checks +UPDATE USING, so the exact job arm occurs there too. The original job/attempt +WITH CHECK predicates and DELETE guards remain tenant/scope-bound; the owner gains +neither arbitrary job mutation nor a general reader. The +[provider contract](#invitation-smtp-provider-storage) confines the runtime caller. The quarantine-presence index also serves a confined global status scan. One bounded observation runs at the snapshot cadence on the existing serial control @@ -3915,16 +3931,22 @@ ALTER TABLE background_jobs ENABLE ROW LEVEL SECURITY; ALTER TABLE background_jobs FORCE ROW LEVEL SECURITY; CREATE POLICY background_jobs_isolation ON background_jobs - USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid + USING ((current_user = 'learnstack_migration' + AND definition_key IN ('identity.invitation_email_tenant.v1', 'identity.invitation_email_organization.v1') + AND profile = 'external' AND capacity_group = 'identity.invitation_email' AND queue = 'external') + OR (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid AND (organization_id IS NULL OR organization_id = NULLIF(current_setting('app.organization_id', true), '')::uuid - OR current_setting('app.scope', true) = 'tenant')) + OR current_setting('app.scope', true) = 'tenant'))) WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid AND ((organization_id IS NULL AND NULLIF(current_setting('app.organization_id', true), '') IS NULL) OR organization_id = NULLIF(current_setting('app.organization_id', true), '')::uuid)); CREATE POLICY background_jobs_org_write_guard ON background_jobs AS RESTRICTIVE FOR UPDATE - USING ((organization_id IS NULL AND NULLIF(current_setting('app.organization_id', true), '') IS NULL) - OR organization_id = NULLIF(current_setting('app.organization_id', true), '')::uuid); + USING ((current_user = 'learnstack_migration' + AND definition_key IN ('identity.invitation_email_tenant.v1', 'identity.invitation_email_organization.v1') + AND profile = 'external' AND capacity_group = 'identity.invitation_email' AND queue = 'external') + OR ((organization_id IS NULL AND NULLIF(current_setting('app.organization_id', true), '') IS NULL) + OR organization_id = NULLIF(current_setting('app.organization_id', true), '')::uuid)); CREATE POLICY background_jobs_org_delete_guard ON background_jobs AS RESTRICTIVE FOR DELETE USING ((organization_id IS NULL AND NULLIF(current_setting('app.organization_id', true), '') IS NULL) OR organization_id = NULLIF(current_setting('app.organization_id', true), '')::uuid); @@ -3935,10 +3957,15 @@ ALTER TABLE background_job_attempts ENABLE ROW LEVEL SECURITY; ALTER TABLE background_job_attempts FORCE ROW LEVEL SECURITY; CREATE POLICY background_job_attempts_isolation ON background_job_attempts - USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid + USING ((current_user = 'learnstack_migration' AND EXISTS ( + SELECT 1 FROM public.background_jobs j + WHERE j.id = job_id AND j.tenant_id = background_job_attempts.tenant_id + AND j.definition_key IN ('identity.invitation_email_tenant.v1', 'identity.invitation_email_organization.v1') + AND j.profile = 'external' AND j.capacity_group = 'identity.invitation_email' AND j.queue = 'external')) + OR (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid AND (organization_id IS NULL OR organization_id = NULLIF(current_setting('app.organization_id', true), '')::uuid - OR current_setting('app.scope', true) = 'tenant')) + OR current_setting('app.scope', true) = 'tenant'))) WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid AND ((organization_id IS NULL AND NULLIF(current_setting('app.organization_id', true), '') IS NULL) OR organization_id = NULLIF(current_setting('app.organization_id', true), '')::uuid)); @@ -5418,3 +5445,325 @@ fairness policy requires review rather than a silent reset. the corrected policy — silently, because a policy that filters everything looks exactly like a table with no matching data. - Tenant tables without RLS in production migrations (CI rejects). + +## Invitation SMTP provider storage + +**Derives from:** [ADR-0093](../decisions/0093-verified-invitation-acceptance-and-handoff.md). +This is the Accepted P03-4 storage contract; acceptance does not install its +tables, grants, functions or source. The [Phase 03 Status +block](../roadmap/phase-03-identity-admin.md) owns implementation and qualification. + +### Provider records and ordinary staging + +Common `LearnStack.Infrastructure` owns two SQL-only provider control tables. +They are neither Identity aggregates nor Invitation children and have no module +`DbContext` mapping. Invitation remains the business authority; provider records +cannot grant membership, revoke an offer or establish current delegation. + +| Table | Immutable binding / stored facts | Constraints | +|---|---|---| +| `invitation_email_envelopes` | Envelope UUID; tenant and nullable organization; Invitation UUID; delivery-generation UUID; JobId; encryption-format version, key version, nonce, ciphertext, tag; database creation time | One envelope per job and per offer/generation; nonempty IDs; exact tenant/job FK; immutable scope and encrypted bytes; finite time; no Invitation/User/Membership FK or person-field projection | +| `invitation_email_attempt_receipts` | Tenant and nullable organization; EnvelopeId, JobId, AttemptId, stable OperationId, plan fingerprint, immutable claim UUID and original admission fence; provider state; selected completed-report facts and evidence | Unique envelope/AttemptId; exact tenant/job/attempt FK; plan/operation agreement; no overwrite of a selected terminal observation; finite times; no runtime DELETE | + +Both tables carry tenant/organization metadata and ENABLE/FORCE RLS through the +[canonical policy template](#tenant-owned-and-organization-scoped-tables). Explicit role-qualified owner +arms support only the functions below. The ordinary app INSERT path checks its +announced exact tenant/organization and the stored job's immutable producer, +definition and scope. No provider control row supplies tenant authority. + +`IInvitationEmailStager` has only the enumerated Identity invitation writers as +production callers. On their existing enrolled writable owner, it submits the +typed Identity job through `IBackgroundJobScheduler`, encrypts the immutable +message bound to that JobId and INSERTs the envelope. Business state, envelope, job +and required business audit commit together. A refusal after produced work +retains the existing poisoned-owner/produced-work rules. The stager opens no +second connection, performs no SMTP and uses no provider-store owner function. +The job payload/plan carries only identifiers; it contains no address, subject, +body, token, continuation, ciphertext or encryption key. + +| Actor | Direct provider-table privileges | Function privilege | +|---|---|---| +| `learnstack_migration` | Owns both tables; NOBYPASSRLS and FORCE RLS remain | Owns the two exact functions | +| `learnstack_app`, ordinary stager | INSERT of the envelope's declared immutable columns under its announced business owner; no receipt INSERT and no direct SELECT/UPDATE/DELETE | EXECUTE only on the two enumerated functions; application call-site controls confine this to the provider connector | +| `learnstack_platform`, `learnstack_outbox_admin`, PUBLIC | None | None | + +Foreign keys do not cross into Identity business tables. Cross-table guards bind +the envelope to the exact Identity definition/parameters and, once prepared, its +canonical identifier plan. Provider functions may read only the matching job and +attempt metadata needed for that binding. Their owner-read policy arms add no +module-table visibility or general background-job reader. + +This is an exact control-metadata class, not the ordinary module-child class. +The envelope's invoker INSERT guard locks its exact job with `FOR KEY SHARE` and +compares nullable organization plus the closed definition/parameter tuple; every +UPDATE and DELETE refuses. The receipt's invoker mutation guard freezes its +scope/envelope/job/attempt/claim binding. The runtime can INSERT a receipt only +through the owning functions after checking the actual locked job and attempt. +Applied-schema checks retain +the exact foreign keys, bodies, trigger coverage and grants. Independent +`learnstack_app` controls isolate WITH CHECK and scope immutability, including +test-only grants and dirty twins; absent runtime UPDATE privilege is not proof of +an effective guard. Neither table silently disappears from isolation coverage. + +### Encryption and custody + +- Encrypt at most 32 KiB of UTF-8 plaintext using AES-256-GCM, a fresh random + 12-byte nonce and a 16-byte tag. Ciphertext has the same byte bound; checked + lengths, a supported format and valid key-version syntax are storage guards. +- The authenticated additional data is a versioned, unambiguous binary encoding + of the format/key version, envelope, tenant, nullable-organization marker/value, + Invitation, delivery-generation and JobId. UUIDs use RFC byte order and variable + components are length-prefixed. All these bindings and the encrypted bytes are + immutable; swapping a scope, generation or key version fails authentication. +- Composition resolves a named key ring of one to four distinct 256-bit keys + through `ISecretProvider` at startup, with one active encryption version. + Unknown versions, missing keys, invalid key sizes and authentication failure + refuse with fixed diagnostics. Key versions are not silently reused or removed + while retained envelopes require them. No per-request secret fetch, generated + fallback key, plaintext database copy or key material in audit/jobs is admitted. +- Provider plaintext exists only in the bounded staging/SMTP invocation. SMTP + protocol tracing and body/recipient/token logging are disabled. Phase 11 owns + production retention/purge; expiry does not delete unresolved provider evidence + or release a job reservation. + +### Database diagnostic privacy qualification + +Before the invitation writer's first person-field INSERT and before provider +admission, qualify the actual server settings: `log_error_verbosity=terse`, +`log_min_error_statement=panic`, `log_statement=none`, +`log_min_duration_statement=-1`, `log_min_duration_sample=-1`, +`log_transaction_sample_rate=0`, `log_parameter_max_length=0` and +`log_parameter_max_length_on_error=0`. Bind-parameter logging controls alone do +not suppress CHECK failing-row or UNIQUE-key DETAIL. PostgreSQL's +[logging documentation](https://www.postgresql.org/docs/current/runtime-config-logging.html) +defines the TERSE and sampling behavior. A real-server qualification uses +synthetic email/verifier canaries for CHECK, UNIQUE and bind failures, plus a +positive control with default verbosity that demonstrates the leak. Do not print +or inspect real provider/business logs as a qualification shortcut. + +### Two confined provider functions + +The total admitted Identity/invitation SECURITY DEFINER census is **eleven**: +the existing eight, the exact invitation-entry function and these two provider +functions. They execute as `learnstack_migration` under FORCE RLS with fixed +`search_path = pg_catalog`, schema-qualified relations, no dynamic SQL and PUBLIC +execution revoked in the creating transaction. Owner-policy/body/grant/caller +controls enumerate their actual reachable reads and writes. Table ownership +carries column privileges; this is function confinement, not a claim that owner +column grants prevent wider reads. + +`JobExecutor.InvokeExternalAsync` alone mints the immutable, nonserialized +external provider receipt after validating its stored envelope/plan and exact +original or recovery authority. It binds the definition/adapter, tenant/org, +JobId, AttemptId, OperationId, plan fingerprint, fence, deadline and invocation +purpose. The dedicated connector consumes that receipt; a caller-supplied GUID +tuple, ambient tenant or public external-context record cannot replace it. + +| Function | Permitted effect / projection | +|---|---| +| `fn_invitation_smtp_claim_or_read` | Checks the exact receipt against actual job/normal-attempt/ExecuteIntent and envelope metadata. Execute may atomically create/claim one provider attempt as `sending` under live original authority and return that claim's encrypted immutable envelope. Reconcile returns only the exact stored finite receipt; fresh eligible recovery may instead fence an unclaimed attempt as `cancelled_before_send`. Reconcile never claims `sending` or starts SMTP. | +| `fn_invitation_smtp_complete_attempt` | Against an already issued exact claim, records one immutable completed observation, report state/policy and positive quiescence evidence. Exact repetition verifies the same receipt; conflict refuses. Late actual observations remain recordable after original lease/deadline loss. The function cannot mint a new claim, change a job/attempt/reservation, finalize business state or give a stopped runtime owner checkpoint authority. | + +Both functions reject wrong definition, plan, operation, scope, attempt, fence, +purpose and substituted envelope before returning ciphertext or mutating a row. +They read no User, Invitation, Membership, tenant content or module repository and +return no general list, person field, provider text, plaintext message or token. +They perform no context announcement or `set_config`; SMTP adds neither a logical +tenant setter nor a physical context writer. The admitted eleventh +`InvitationEntryScope` setter and four physical context writers remain unchanged. + +The provider-attempt state machine is closed: a fresh unclaimed attempt may +become `sending` or `cancelled_before_send`; a claimed attempt may acquire one +terminal receipt. Once `sending` is claimed, missing terminal evidence is +unresolved. Recovery's winning pre-send cancellation CAS prevents the paused old +Execute from claiming or sending. This is a real cancelled provider operation +with positive `provider_fenced` evidence, not framework `NeverStarted` after +ExecuteIntent. A later admitted retry uses the same envelope/OperationId and a +new AttemptId; every prior attempt's selected facts remain readable unchanged. + +### Dedicated provider source and uncertain commits + +`InvitationSmtpProviderDataSource` uses the existing guarded `learnstack_app` +credential through its own provider-only source. It accepts no raw connection +string from a module and exposes only the two fixed function operations. It +opens no ordinary `IUnitOfWork`, uses no tenant setter and leaves the external +ordinary-application denial marker intact. + +| Bound | Provider-source contract | +|---|---| +| Local capacity | Four retained owners and at most four physical checkouts per API process, including open, SQL, cancellation and actual cleanup; no unbounded admission queue | +| SQL operation | One shared five-second I/O deadline across open, function call/COMMIT and any sequential verification; positive finite driver/server statement, lock and cancellation bounds; no command override to infinite | +| Session surface | Reset-safe READ COMMITTED transactions, no tenant/identity GUC announcement, session state or arbitrary SQL; same physical role/reachable-bypass guard as the app credential | +| Response loss | At most one bounded, sequential locked reconciliation on the same retained owner after actual prior cleanup; a missing acknowledgement, unlocked absence, timeout or failed probe never proves rollback | +| Source lifetime | Independent shutdown closes fresh admission and joins actual owners before source disposal; elapsed caller waiting cannot free slots, dispose in-use resources or authorize replacement | + +These four provider connectors extend the physical-source ledger; they are not +hidden in the existing application/job floor or privileged dispatcher pool. +Failed or unconfirmed cleanup retains its concrete owner/connector and makes the +provider lane unhealthy. Later eligible runtime Reconcile can read durable facts; +it does not inherit authority to Execute from a failed storage probe. + +A durable final-250 receipt means SMTP submission was accepted, not inbox +delivery. Only definite no-effect temporary failure with actual task/socket +completion grants a repeatable failure. Durable permanent rejection is permanent; +runtime cancellation retains its actual closed cancellation meaning. Lost final +reply, process death after the send claim but before a durable receipt, missing +evidence and an unresolved SMTP task remain Unknown. Existing job quarantine and +occupancy retention apply; no expiry, host exit, reissue or message identifier +releases that reservation. The fresh runtime checkpoint/finalizer alone applies +the [known-outcome policy](../decisions/0061-known-external-job-failure-policy.md). + +Known workflow failure without a provider receipt is a separate business +transition: only the actual runtime-selected failed finalization of a prepared +plan for known NoStart exhaustion/permanent failure may mark the matching +generation `Failed` with a null provider receipt. It neither fabricates a receipt +nor asserts SMTP rejection or acceptance. Submission status is a write-once +terminal marker, including this receiptless failure; Unknown cannot invoke that +transition or release occupancy. Reissue creates a new generation instead of +rewriting the selected terminal status. + +## Invitation entry and consumption + +**Derives from:** [ADR-0093](../decisions/0093-verified-invitation-acceptance-and-handoff.md). +Accepted P03-4 contract; schema, source and runtime backfill remain pending in the +phase Status block. This section extends the earlier global/contextual contracts; +the earlier eight-function/ten-setter counts describe their historical slices. +Current total is eleven confined definers, eleven logical tenant setters and four +physical context writers. SMTP adds no tenant setter. + +### Invitation root and constrained transitions + +`invitations` is Identity-owned tenant/organization-scoped storage. Use canonical +ENABLE/FORCE RLS, one role-qualified isolation policy with explicit WITH CHECK, +restrictive UPDATE/DELETE organization guards, EF filter and immutable-scope +triggers. No ordinary tenant read hatch is implied. The separate exact owner +visibility arm supports only the entry function below. + +| Facts | Bound storage contract | +|---|---| +| Identity/scope | UUIDv7 InvitationId; full `(tenant_id,id)` reference key; tenant/nullable organization immutable; no cross-module Tenant/Organization FK or join | +| Recipient/source | Sensitive normalized ASCII email; immutable inviter UserId, RoleId and pinned Role version; same-module User FK and tenant-composite Role FK, supporting indexes; compatible capability scope checked independently of nullable FK behavior | +| Current generation | Positive monotonic ordinal and fresh UUID generation ID; sensitive 32-byte active verifier, database creation/expiry, frozen server-derived entry origin and current BackgroundJobId by value; no raw token/message column or job-table FK | +| Terminal result | Accepted actor/User FK, tenant-composite MembershipId FK, `created` or `already_member` disposition, accepted timestamp and consumed-verifier hash; all together only for Accepted | +| Audit/concurrency | All six canonical audit columns and row_version; explicit personal-data declarations and sensitive snapshot markers; every actual mutation advances root version | + +States are Pending, Accepted or Revoked; Expired is effective Pending state at +database UTC. Terminal states cannot reopen. Revocation is status, not soft +removal: Invitation deletion columns remain NULL because no deletion owner is +selected. Thus authorized scoped list/detail can expose terminal state without +IgnoreQueryFilters or a hidden history directory. Runtime has SELECT/INSERT/UPDATE +only, no root DELETE/TRUNCATE or arbitrary User update. A later erasure owner +requires its separately accepted grants/transition before changing this boundary. + +A partial unique index enforces Pending `(tenant_id,organization_id,recipient_email)` +with NULLS NOT DISTINCT, regardless of current clock time. Revoke/Accepted releases +that slot; expiry alone does not. Root CHECK/guard pairs enforce finite state, +positive generation, 32-byte verifier shape, finite expiry after creation and +all-or-none terminal facts. Pending has active verifier and no consumed/result +facts; Accepted clears active verifier and retains exactly its consumed verifier +and result; Revoked clears both verifiers and has no accepted result. + +Create stages generation 1, policy-frozen expiry, queued payload/job and audit on +one owner. Reissue increments generation, replaces verifier/expiry/origin/job and +invalidates the former token atomically; its Role/email pin does not change. +Expiry is checked using fresh database `clock_timestamp()` after lock waits at +the conditional Pending-to-Accepted statement. Admission then committing after +that instant is not retroactively expired. No transaction-start timestamp or +background expiry task substitutes for this check. + +Use sorted exclusive invitee/inviter User keys before the strongest actual Tenant +mode, then offered Role, Invitation and current Membership row. Shared handles +ordinary effects; actual protected Tenant Admin eligibility/assignment selects +Exclusive, with no shared upgrade. Create/revoke/reissue acquire their shared +Tenant fence and applicable root locks, not an unconditional exclusive override. +Prepare the complete intended graph/mode and refresh it after waiting; changed +classification returns conflict and requires a fresh act. Existing membership +admission/last-admin/Role-scope/deferred constraint guards still run and MUST +failure rolls the whole business transaction back. + +### Exact preliminary function and operation-bound scope + +`fn_identity_invitation_entry(invitation_id uuid, verifier_digest bytea, +host_tenant uuid, authority_id uuid, subject_key bytea)` is migration-owned, +SECURITY DEFINER, VOLATILE, fixed `search_path=pg_catalog`, static schema-qualified +SQL, PUBLIC execution revoked and app execution granted only for its closed +caller. Digest inputs are exactly 32 bytes. The API computes its domain-separated +verifier from the raw code before SQL; SQL equality is not claimed constant-time. +The actual bound subject is already normalized against exact issuer/sub and the +installed authority; body/header authority/User/tenant input is refused. + +The body reads only the exact Invitation matching ID/digest/host tenant, the +verified subject-bound User and that Invitation's immutable stored inviter User. +It returns offer ID/version/generation, exact tenant/org, bound actor admission, +stored inviter global admission, state and immutable result identifiers. No raw +email/provider subject/grants/profile or arbitrary User result is projected. The +stored-inviter global Boolean never replaces the separate same-scope delegation +reader. Existing migration-owner `users_isolation` visibility remains; the new +Invitation arm and fixed function body are explicitly counted/qualified together. + +Initial physically ReadOnly lookup uses the ordinary guarded app source, closes +before the next owner and returns one immutable captured witness. The later +`InvitationEntryScope` consumes it once: it binds request/invocation, credential, +verified subject/actor, native source, actual host, offer/version/generation and +transaction mode. ReadOnly inspection/result and ordinary ReadWrite acceptance +reuse the existing physical UoW writer. They cannot join/retarget a global owner, +reuse another purpose/mode or widen app.scope. This is the eleventh logical setter +(category: operation-bound invitation), not an extra physical context writer. + +Within acceptance, the exact function can repeat its bound eligibility observation +after both User fences. Writable calls independently require the exact announced +scope, Tenant fence and both exclusive User keys; the initial ReadOnly lookup +does not require them. This covers already_member with no Membership INSERT. +All pending authorization/lifecycle/Role checks then use fresh post-wait facts on +that owner. The exact User/offer projection does not authorize general queries. +Known Tenant/Organization host ceiling and HostAndInvitationOffer consumer census +are defined in [Security](11-security.md#verified-invitation-admission). + +Accepted result reads precede Pending version/expiry/email/inviter conditions. +They require exact consumed digest, same Active actor and original current Active +Membership. Suspended/Revoked/deleted original Member or another actor yields +masked no-disclosure, never a successor result. Predispatch result reads are Off; +a Pending command losing a race cannot drop declared MUST intents or claim a +second success. Unknown COMMIT requires fresh authoritative observation. + +### Policy, replay and provider status reads + +The lifetime policy is one Tenancy TenantSetting root with exact immutable key +`identity.invitation.ttl_days`, NULL organization, integer 1–30 and default 14 +when absent. Presence plus root version distinguish a first conditional create +from an existing version-zero setting. The existing natural key and checked +version prevent a racing upsert. The wire preconditions are the exact +[API policy contract](04-api-design.md#verified-invitation-transport). +Only future generations consume changed values. + +Add protected original-target kinds Invitation and TenantSetting only for this +policy. Retain existing actor/scope fingerprint, 1,000 tenant slots, capacity +fence/claim-before-business order and 24-hour replay rules. Current exact-target +receipt reads are separately MUST; no code/link/message/ciphertext enters stored +response bytes. Keyed reissue creates one generation/job through its actual +version/produced-work guards even across the commit-to-receipt uncertainty window. + +`IInvitationEmailStatusReader` is a bounded common Infrastructure port on the +current announced owner, called only by authorized current Invitation reads with +that root's actual CurrentDeliveryJobId. It reads only that exact same-scope job's +closed phase/outcome/finalization/quarantine metadata, not its payload/plan, +provider tables, recipient or another job directory. It returns finite status or +unavailable, never provider messages. A successful missing-job read may return +Unavailable; an actual SQL failure or private five-second timeout marks the +shared owner rollback-only and fails the complete sensitive read with 503. +Caller cancellation retains its meaning. A page shares one five-second status +budget, clipped by its request deadline, rather than one budget per row. +This supplies visible uncertainty before a +module finalizer can run. It uses no second owner or external denied-scope access. + +### Qualification obligations + +Before activation: real app-role tenant/two-org isolation and foreign-ID/FK/mirror +controls; applied policies/functions/privileges and caller/owner/witness/mode +census; raw/digest/response leakage and positive redaction; root-state/pending +uniqueness; actual lock-mode classification/after-wait changes; Active no-elevation +and Invitation-disposition versus subject-only Membership audit; Suspended refusal, +Revoked successor and MUST rollback; both commit orders for revoke/accept/expiry/ +reissue/global closure; stale receipt/result identities and unknown COMMIT; pooled +reuse/cancellation/cleanup. SMTP custody additionally obeys the preceding separate +provider-store contract. Acceptance alone qualifies none of these behaviors. diff --git a/docs/standards/07-frontend-architecture.md b/docs/standards/07-frontend-architecture.md index 24ef95955..2f5d50816 100644 --- a/docs/standards/07-frontend-architecture.md +++ b/docs/standards/07-frontend-architecture.md @@ -136,8 +136,9 @@ evidence belongs to the [delivery record](../roadmap/phase-02d-delivery-log.md#r - Admit GET/HEAD on HTTP paths reaching the native listener callback before Next dispatch, including matcher-exempt health/assets and exact scaffolds. - The existing ADR-0070 sign-in/sign-out/extend POSTs and the exact ADR-0078/0079 - [entry/administration form set](#native-entry-and-administration) are the only + The existing ADR-0070 sign-in/sign-out/extend POSTs, the exact ADR-0078/0079 + [entry/administration form set](#native-entry-and-administration) and ADR-0093's + [invitation form set](#verified-invitation-experience) are the only additional admitted methods, with their closed proof/body rules. All other methods receive masked no-store 404 without bootstrap or Next method conversion. HEAD is bodyless; Node owns malformed protocol input. Adding the @@ -292,7 +293,8 @@ native AccountSite/session entry. The source credential, subject association, live candidate and exact host ceiling follow Security 11. Public wrappers remain anonymous; no browser-controlled header, cookie or hidden field selects authority. -Only these additional native POST paths are admitted: +The P03-3 additional native POST set is below; ADR-0093 extends it only with the +[invitation form set](#verified-invitation-experience). | Path | Fixed operation | |---|---| @@ -324,10 +326,11 @@ GET `/api/auth/entry` serves own-entry confirmation/recovery, or fresh context choices for the current session when no entry owner is present. The current context is identified without another selection form; other choices use the closed switch operation. The -protected document set is exactly `/studio`, `/studio/members`, +P03-3 protected document set is `/studio`, `/studio/members`, `/studio/members/{MembershipId}`, `/studio/roles`, `/studio/roles/{RoleId}`, `/portal` and `/portal/profile`, with canonical resource IDs. Only member-list -GET accepts cursor/limit; exact route/query/history censuses reject alternatives. +GET in that set accepts cursor/limit; the invitation documents below extend the +set. Exact route/query/history censuses reject alternatives. An actual native-captured `RSC: 1` request may additionally carry one nonblank safe-ASCII `_rsc` transport value of at most 128 characters. Remove only that field for route-query validation; keep the signed raw target and the native @@ -476,6 +479,79 @@ Equal en/tr namespaces and actual keyboard/focus/error/reflow/contrast/assistive technology evidence are required. Veiled restoration and no-script recovery need real browser proof; a script-disabled browser has no script-based expiry promise. +## Verified Invitation Experience + +**Derives from:** [ADR-0093](../decisions/0093-verified-invitation-acceptance-and-handoff.md). +This Accepted P03-4 contract requires actual native/API/provider/browser proof +before activation; acceptance does not add a runtime route. The exact API +consumer set is [API Standards](04-api-design.md#verified-invitation-transport). + +The localized document extension is `/studio/invitations`, +`/studio/invitations/{InvitationId}` and `/studio/invitation-policy`, plus +`/invitations` and `/invitations/confirm` for the recipient. Only invitation-list +GET additionally accepts cursor/limit. Documents and bounded previews are +no-store; raw credentials never enter RSC, immutable page snapshots, analytics, +error reporting or generic action-recovery storage. The existing +`canManageMemberships` flag may expose an admin link; every action reauthorizes. + +| Additional exact native POST path | Fixed purpose | +|---|---| +| `/api/auth/invitation/capture` | Capture ID/code into the exact host/browser invitation owner | +| `/api/auth/invitation/inspect` | Fresh current-recipient inspection from owned custody | +| `/api/auth/invitation/verify` | Fixed `kc_action=VERIFY_EMAIL` continuation only | +| `/api/auth/invitation/accept` | Deliberate acceptance of the displayed offer/version | +| `/api/auth/invitation/result` | Same-actor original-result reconciliation only | +| `/api/auth/invitation/cancel` | Cancel and clean only that invitation continuation | +| `/api/studio/invitation-create` | Create the offer and automatic email job | +| `/api/studio/invitation-revoke` | Revoke the exact current offer/version | +| `/api/studio/invitation-reissue` | Explicit protected replacement with new generation/expiry | +| `/api/studio/invitation-policy` | Exact orgless lifetime-policy replacement | + +Forms retain exact Origin, current invitation/entry/session-bound CSRF, strict +16 KiB decoding before Next and single-valued named fields. They accept no +tenant/org authority, bearer, arbitrary OIDC action, URL, proxy path or headers. +Version and idempotency fields retain canonical string and deliberate-retry +semantics. Fixed same-host destinations and response ownership precede all +publication. There is no Server Action or browser bearer. + +Automatic email contains the independently admitted stored HTTPS origin and +fixed localized landing with `#.`, plus a separately +pasteable code. GET renders only a small owned HTML landing before Next/RSC, +without third-party script or reporter. Its bounded script removes the fragment +with history replacement before exact-Origin/CSRF capture. No token enters +path/query, OIDC state, callback or returnTo. Without JavaScript the clean form +accepts the separately supplied code. Clean confirmation retains `same-origin` +referrer policy for exact-Origin-compatible no-JavaScript forms; callback/logout +retain their own policy. + +The separate `__Host-ls-invitation` HttpOnly/Secure/SameSite=Lax/Path=/ cookie is +only an opaque handle. Its preauthentication owner shares the existing 1,024 +total/128 pending/16 per verified source and five-minute pending bounds; after +subject verification it also counts within eight owners per verified issuer/sub. +It coexists with a live session and replaces only an earlier invitation owner +for that host/browser group. Provider credentials remain in their existing +owner. A different subject cannot replace an established session. Cancellation, +logout and completion clear contained secrets while unsettled work retains +capacity until actual cleanup. Restart, lost custody or expiry asks the person +to reopen the email. No raw link is returned to an administrator or replayed. + +Confirmation shows complete escaped current verified email with bidirectional +isolation and explicit institution/branch/Role labels. Inspection does not +authorize acceptance: the latter repeats fresh UserInfo evidence. Wrong account, +unverified/changed email, verification cancellation, expired/revoked/replaced +link, Suspended Membership and provider outage have localized recovery. +Accepted-but-no-session recovery performs explicit ordinary session admission; +a Membership receipt alone is not a tenant cookie. + +Administration reports **queued**, **submission accepted**, **failed**, +**uncertain**, **expired** or **revoked** from actual facts. SMTP acceptance is +not inbox delivery. Lost/uncertain work is not silently retried or reported as +rollback. Explicit authorized reissue invalidates the prior verifier; a late old +email may still arrive but cannot grant access, and its unsettled source retains +capacity. Actual HTTPS/no-JavaScript, nonseed verification, history/BFCache, +late-response/cleanup, sink canaries and human accessibility evidence qualify +these screens; fixture tests do not supply human proof. + ## Version-Qualified Next Response Cleanup **Derives from:** [ADR-0083](../decisions/0083-owned-next-response-compression-cleanup.md) and diff --git a/docs/standards/11-security.md b/docs/standards/11-security.md index 74f0dce6d..eb2dad2f7 100644 --- a/docs/standards/11-security.md +++ b/docs/standards/11-security.md @@ -246,7 +246,9 @@ survivable. The real cookie controls compare emitted headers, not browser defaul protected-document history veil require fresh network admission on return. - Retain 1,024 total owners, including unsettled work; 128 pending for five minutes, 16 pending per verified visitor IP, eight verified entry/session/logout-return - owners per validated issuer/subject. Same-browser replacement settles old work first. + owners per validated issuer/subject, including ADR-0093 invitation owners once + verified. Preauthentication invitation owners share the pending/source limits. + Same-browser replacement settles old work first. No live other-device eviction or capacity bypass; source/subject refusal is 429 and global capacity refusal is 503. Full expiry scans precede global refusal. AccountSite also remains subject to the API's shared peer/visitor limits under @@ -512,7 +514,8 @@ Off and GET/HEAD only. Factory HostScope is an independent host ceiling intersec with normal reconciliation, query filters and RLS; it cannot widen through claims or the tenant-scope read hatch. Publication, access and lifecycle remain explicit application predicates. The four context writers remain unchanged; the current -closed contract has ten logical Tenant setters below. Host resolution, flag-loader +closed contract has eleven logical Tenant setters below under ADR-0093; its +invitation contract is Accepted with runtime proof pending. Host resolution, flag-loader connections and rejected-assertion audit keep their separately sanctioned paths. READ ONLY protects the enlisted ambient transaction. Structural guards detect @@ -577,7 +580,7 @@ context writer, factory privilege or tenant retargeting authority. ### The out-of-band setters `TransactionBehavior` delegates the general announcement path to -`TransactionExecutionCoordinator`. The current ten logical setter paths are +`TransactionExecutionCoordinator`. The current eleven logical setter paths are enumerated below. ADR-0076 adds the ninth candidate reader and ADR-0078 the tenth own-context discovery reader; their source bounds are in the [owner census](#owner-and-setter-census). ADR-0077's confined evidence methods and @@ -586,7 +589,10 @@ is closed ([ADR-0040 § Who sets `app.tenant_id`, completely](../decisions/0040-ambient-unit-of-work.md), with its dated Amendments, is the authority; it is reproduced here because this section is the placement authority). Two set it on the **ambient** transaction; -eight own a **short transaction of their own** under their exact source contract: +eight own a **short transaction of their own** under their exact source contract; +ADR-0093 adds one **operation-bound invitation witness** setter using the existing +physical unit-of-work writer, ReadOnly for inspection/result and ordinary +ReadWrite for acceptance: | Setter | Transaction | Why it is not `TransactionBehavior` | |---|---|---| @@ -600,6 +606,7 @@ eight own a **short transaction of their own** under their exact source contract | The tenant-flag loader (`FeatureFlags`) | its own short read-only one | A cached projection of `tenant_feature_flags`, read on a cache miss wherever `IFeatureFlags` is asked — inside a request's transaction or outside any ([ADR-0045 § 2](../decisions/0045-entitlement-and-feature-flag-socket.md), [ADR-0040 Amendment 7](../decisions/0040-ambient-unit-of-work.md)) | | `PreContextIdentityAuthorizationReader` | its isolated physical READ ONLY child | Exact verified candidate coverage/grant and Tenancy lifecycle reads; never parent business state or ambient TenantContext | | `PreContextSelfDiscoveryScopeReader` | its own short physical READ ONLY one | Single-use captured own-Membership scope witness, followed only by bounded Tenancy label/destination reads | +| `InvitationEntryScope` | operation-bound existing owner, physical ReadOnly for inspection/result or ordinary ReadWrite for acceptance | Single-use exact offer/host/verified-subject witness; no ordinary native context, directory or general-purpose scope | > **`IOrganizationScopeValidator` performs row-7 organization narrowing after > affirmative live Membership admission.** `TenantAssertionMiddleware` separately @@ -624,7 +631,7 @@ variable is unset fails `WITH CHECK` ([ADR-0033 Amendment 2 § 5](../decisions/0033-audit-durability-model.md), [ADR-0044 § 9](../decisions/0044-audit-write-path.md)). -Every one of the ten setters connects as `learnstack_app`. A setter that reached +Every one of the eleven setters connects as `learnstack_app`. A setter that reached for `learnstack_platform` would be invisible to the isolation suite, which is the failure mode [ADR-0003](../decisions/0003-tenant-isolation-defense-in-depth.md) names by hand. @@ -641,7 +648,7 @@ runs on it. unit's announcement seam. Matching `[TenantOwned]` table names would put a parser between every query and the database to decide something the transaction already answers. -Of the ten logical paths, the MediatR transaction owner and P02b-3's integration +Of the eleven logical paths, the MediatR transaction owner and P02b-3's integration transport mark the ambient transaction through sanctioned `IUnitOfWork` announcement methods. Durable acceptance and prepared subscriber execution both announce before their first app-role command; the production call-site census @@ -652,7 +659,10 @@ idempotency activation/proof belongs to the the two standalone audit writers and the two cached-projection loaders, issue raw `NpgsqlCommand`s on connections of their own, which EF interception never sees. The ninth and tenth use their confined common-owner announcement seams and exact -source/generation guards. `CachedHostToTenantResolver` is not in this set at all: it sets +source/generation guards. The eleventh consumes its operation-bound invitation +witness through the existing physical unit-of-work writer. The preliminary +offer function owner closes before that consumer owner starts. +`CachedHostToTenantResolver` is not in this set at all: it sets `app.resolving_host`, not `app.tenant_id`. That is also why the exemption list is empty, and why `PlatformAdminScope`, whose `BYPASSRLS` connection announces no tenant by design, is invisible to the guard by construction rather than by a @@ -753,8 +763,9 @@ and redirect/query rules. These are the ongoing security obligations: P02d-5 delivers this boundary and ADR-0054's accounting, native admission and URL controls. The [remediation record](../roadmap/phase-02d-delivery-log.md#p02d-5-external-review-remediation-2026-10-09) owns execution, independent reviews and PR closeout evidence. -Native paths, including matcher exemptions, admit GET/HEAD; ADR-0070 adds only -the three exact proof-bearing POST actions named in Frontend Standards. +Native paths, including matcher exemptions, admit GET/HEAD; ADR-0070's three +proof-bearing POSTs, ADR-0078/0079's entry/administration forms and ADR-0093's +invitation forms are the closed extensions named in Frontend Standards. Other methods remain masked. Production upgrades close and development retains only validated GET HMR. See [Frontend Standards § Native Admission and URL Identity](07-frontend-architecture.md#native-admission-and-url-identity) @@ -807,7 +818,8 @@ for the native controls and their delivery/review boundary. before Next can clone a body; the form parser independently bounds actual bytes, UTF-8, declared scalar fields and duplicates. - Only the [exact native auth/administration POST set](07-frontend-architecture.md#native-entry-and-administration) - is open. SameSite and implicit framework checks + and [invitation POST set](07-frontend-architecture.md#verified-invitation-experience) + are open. SameSite and implicit framework checks authorize neither sibling origins nor Server Actions. - Webhook endpoints verify signatures, not CSRF tokens. @@ -1255,14 +1267,15 @@ or new global User/profile/directory projection. It closes before ordinary business continuation, including when a nested/post-lock parent already exists. Tenancy's lifecycle reader uses this same physical child owner/generation. -Ten is the existing Accepted logical setter set under ADR-0078. The +Ten is the P03-3 logical setter baseline under ADR-0078; ADR-0093 adds the +eleventh operation-bound invitation setter described below. The [phase Status block](../roadmap/phase-03-identity-admin.md) owns current implementation, activation and proof for those paths. The ninth's reader/owner has source, database and exact trusted-source controls. Global subject owners and the confined central denial-event methods add no tenant-context writer or logical -setter. ADR-0080's fixed-seed scope-only sites preserve those ten setters and four -context writers; Standard 05 owns their distinct source/caller census and +setter. ADR-0080's fixed-seed scope-only sites preserve the P03-3 ten-setter +baseline; ADR-0093 retains the same four physical context writers. Standard 05 owns their distinct source/caller census and temporary state/write barriers as well as the source/configuration/retained-cleanup ledger. @@ -1564,6 +1577,127 @@ terminal binding, held-work/switch/logout, source rotation, native HTTPS and same-label/different-subject browser controls. Gate acceptance and document lint establish none of those runtime outcomes. +## Verified Invitation Admission + +**Derives from:** [ADR-0093](../decisions/0093-verified-invitation-acceptance-and-handoff.md). +This is an Accepted contract with P03-4 implementation and qualification pending +in the phase Status block. Ordinary native/global-self admission above remains; +the three global-self descriptors do not gain a fourth generic consumer. + +### Exact offer authority + +The separate invitation branch follows captured visitor/body/host checks, +stock credential validation, issuer/sub quota and independent native-purpose +source validation. Only known Tenant/Organization hosts participate; Platform +or unknown hosts refuse. Browser body/header tenant/org selectors cannot supply +authority. Provider scope and legacy assertions, when present, must exactly +agree with the final offered scope and never win a conflict. + +A short physically ReadOnly app-role owner invokes the exact migration-owned +offer-entry function with Invitation ID, the application-derived 32-byte +domain-separated SHA-256 digest, admitted host tenant, installed authority and +verified subject key. Raw codes do not cross into SQL parameters. Standard 05 +owns its exact signature, static body, fixed search path, ACL, Invitation-only +owner visibility and projection. It exposes only the exact offer tuple, +actor/inviter admission facts and bounded terminal reconciliation facts, never +email, provider subject, profile, Role graph or arbitrary User state. SQL digest +equality is not claimed constant-time; actual fixed-length application secret +comparisons use fixed-time equality. This is no general User reader. + +The preliminary owner closes before `InvitationEntryScope` consumes a +single-use immutable witness on the subsequent operation owner. It binds actual +request/invocation, credential, verified subject/actor, native source, admitted +host and exact stored offer/version/generation/tenant/org tuple. TenantHost caps +tenant; OrganizationHost requires the exact organization. Changed, missing, +foreign-purpose or reused evidence refuses before announcement. The new +`HostAndInvitationOffer` origin admits only inspection, acceptance and original +own-result consumers. It grants no PublicSurface, AccountSite, directory, +ordinary protected act, Platform capability or SQL hatch. The existing four +physical context writers implement eleven logical tenant setters. Standard 05 +owns eleven confined definers: the prior eight, exact offer entry and the two +SMTP source lease functions; accepting this census proves no runtime body/ACL. + +New actors use deliberate opt-in global-self bootstrap first, committing and +disposing its owner before tenant work. Disabled bootstrap stays disabled. +Matching email cannot merge identities; global User and tenant owners never +join or retarget. Membership creation does not itself admit a native session. + +### Recipient, atomicity and fences + +`ICurrentInvitationRecipientReader` calls only the configured issuer's fixed +UserInfo endpoint with the stock-validated access token. Bound one fresh +no-cache observation to each Pending inspection/first acceptance, exact `sub`, +token/request/invocation identity and original deadline. Require one well-typed +email, Boolean verified=true and the ADR's normalized mailbox match. Duplicate, +ill-typed, malformed or missing claims refuse; bound the response to 16 KiB and +five seconds within the existing native deadline, with no retry extension and +no business lock during I/O. No realm-admin credential, supplied URL, redirect +or email mirror is admitted. A later provider edit may race database commit; +fresh UserInfo is not an atomic provider/database snapshot. + +Acceptance takes sorted exclusive invitee/stored-inviter User keys before the +complete strongest Tenant fence, then offered Role, Invitation and existing +target Membership locks. Nonadministrator effects retain shared Tenant mode; +actual eligible Tenant Admin effects require exclusive mode. Create/revoke/ +reissue take shared mode unless an actual protected Membership effect requires +more. Fresh post-wait reads revalidate the complete mode/graph; changed mode +returns 409 without fallback or upgrade. Role scope must match the offer exactly: +Tenant Role for NULL organization, Organization Role for non-NULL organization. +The same-key delegation bridge never licenses a cross-scope Role assignment. +The stored inviter's current capability and whole pinned Role must remain within +the current ceiling. Recheck invitee admission under the retained User fence, +including `already_member`, where no INSERT trigger can supply that check. + +One existing ordinary ReadWrite owner atomically consumes Invitation and creates +Membership when needed, with two central MUST resource intents. Active existing +Membership consumes without elevation; Suspended refuses; Revoked history gets +a fresh successor ID. The `created`/`already_member` disposition is tracked +Invitation state and its central capture, never arbitrary audit metadata. An +unchanged Membership is a designated subject-only security event with no fake +creation diff. Rollback or failed MUST audit persists neither new grant nor +consumption. Accepted preflight selects the exact Off result query before +mutation intents; a Pending race loser records truthful failure and uses a fresh +read. Original-result disclosure requires the same Active actor, consumed digest +and original current Active Membership, without reimposing old email, inviter, +Role pin, expiry or version. Unknown COMMIT never becomes asserted rollback. + +### Automatic email and private custody + +Offer creation and reissue atomically stage generation/expiry/verifier, private +authenticated encrypted mail custody and an identifier-only typed job. No raw +code/link enters API admin responses, replay receipts, job/event payloads, +snapshots, diagnostics or audit. Credentials/verifiers stay outside DSAR +artifacts; authorized recipient/relationship access remains P03-10's contract. +The exact SMTP adapter, lease functions, private data source and four-slot +source admission follow Standard 05. Only `JobExecutor` mints the source witness; +external SMTP execution owns no application database unit of work. A timeout or +crash with unknown effect retains the reservation until positive +quiescence/reconciliation; no elapsed timer releases unsettled work. A positively +observed final 250 survives a subsequent disconnect fault only after actual local +socket/task cleanup and a durable exact provider receipt. Unconfirmed SMTP +cleanup retains its concrete transport resources and admitted provider connector, +marks that lane unhealthy and blocks its disposal; Unknown alone does not prove +physical cleanup. Actual server DETAIL and sampling qualification follows the +[database privacy controls](05-database.md#database-diagnostic-privacy-qualification). +Explicit reissue can cause a late old invalid email but does not release its +reservation or imply exactly-once external delivery. Report queued/submission +accepted/failed/uncertain facts; never claim inbox delivery from SMTP acceptance. + +Raw codes are confined to encrypted private mail custody, email/SMTP delivery +and bounded native volatile continuation. Fragment-only email links, native +capture, the separate opaque `__Host-ls-invitation` cookie, owner/subject quotas, +fixed verification action and cleanup follow the +[frontend contract](07-frontend-architecture.md#verified-invitation-experience). +The preauthentication invitation owner shares the 1,024 total/128 pending/16 +per-source/five-minute pending bounds and, after verification, eight owners per +issuer/sub, including other entry/session/logout owners. A new invitation +replaces only an earlier invitation continuation in that host/browser group. +It cannot replace another subject's live session. Actual cleanup retains credit +for unsettled work. GET renders navigation; secret inspection/result use POST +bodies. Real app-role source/body/ACL, competing-commit, provider changed-email, +SMTP fault/reconciliation, sink-canary and HTTPS/browser controls precede +activation. Human accessibility evidence remains an independent obligation. + ## Incident Response - A security incident playbook lives in `docs/runbooks/security-incident.md` (Phase 11 deliverable). diff --git a/docs/standards/18-audit-coverage.md b/docs/standards/18-audit-coverage.md index b6060faf9..cf0ed7a3d 100644 --- a/docs/standards/18-audit-coverage.md +++ b/docs/standards/18-audit-coverage.md @@ -35,6 +35,9 @@ The internal fixed-seed prerequisite boundary additionally derives from [ADR-0080](../decisions/0080-fixed-seed-prerequisite-read-scope.md). The separate fixed-origin completion purpose derives from [ADR-0081](../decisions/0081-fixed-seed-completion-before-origin-adoption.md). +Invitation grant, policy and automatic submission additions derive from +[ADR-0093](../decisions/0093-verified-invitation-acceptance-and-handoff.md); +its new consumers remain planned until implemented. This standard defines **which operations must be audited**, **what an audit entry contains**, **how long entries are retained**, and how each module signs up for its @@ -237,7 +240,7 @@ not prove implementation or production fit. | Domain | MUST-audit operation | |--------|----------------------| -| Identity | Membership created / removed (per `(user_id, tenant_id, organization_id)`); role assigned / revoked; permission set changed; protected-act authorization denied under trusted resolved tenant (`identity.authorization.deny`, ADR-0077); invitation created / accepted / revoked; platform-admin tenant access; Hub operator access to a tenant resource. | +| Identity | Membership created / removed (per `(user_id, tenant_id, organization_id)`); role assigned / revoked; permission set changed; protected-act authorization denied under trusted resolved tenant (`identity.authorization.deny`, ADR-0077); invitation created / reissued / accepted / revoked and its consequential email preparation/submission outcome (ADR-0093); platform-admin tenant access; Hub operator access to a tenant resource. | | Tenancy | Tenant created / suspended / deleted; organization created / archived; tenant setting changed; custom domain added / verified / removed; feature flag toggled; entitlement projection refresh (`tenancy.entitlement.refresh`); killswitch toggled (`tenancy.killswitch.toggle`). | | Customization | `TenantContentType` / `TenantPageBlock` / `TenantLessonItemType` / `TenantLevelTaxonomy` / `TenantScoringRule` / `TenantCompletionRule` / `TenantCustomFieldDef` / `TenantTemplateLibrary` created / updated / deleted (schema changes are MUST; both `before` and `after` snapshots required). | | Content / Pages | Page published / unpublished; content type schema changed; redirect created / changed. | @@ -249,7 +252,7 @@ not prove implementation or production fit. | Recording | Recording started / stopped; recording downloaded; retention policy changed; legal hold applied / removed. | | Billing | Order paid / refunded; subscription created / cancelled; payment provider account changed. | | Hub contract | Inbound Hub command received (`tenancy.tenant.create_from_hub`, and the entitlement push, which is the Tenancy row's `tenancy.entitlement.refresh` — one operation, one slug); outbound usage report (`platform.usage.report`); license verification result. | -| Notifications | Template changed; outbound delivery to a recipient (SHOULD for non-PII channels; MUST for password reset / invitation / billing). | +| Notifications | Template changed; outbound delivery to a recipient (SHOULD for non-PII channels; MUST for password reset / invitation / billing). ADR-0093's bounded P03-4 invitation submission belongs to Identity before the Phase 08a notification engine; no new module or second audit owner is implied. | | Integrations | External provider credential created / rotated / revoked; webhook secret rotated. | | Security | Login failure burst beyond rate limit; MFA challenge failed; admin override of any guard; mTLS / signed-JWT / HMAC verification failure on `/api/internal/*`; admitted cross-tenant assertion mismatch carrying a validated principal (`tenancy.tenant_assertion.reject`, bounded admission under ADR-0065); anonymous assertion-mismatch burst beyond threshold (`tenancy.tenant_assertion.anonymous_burst`); a header conflicting with successfully confirmed ClaimAndMembership context (`tenancy.assertion.reject_claim_context`, ADR-0077; no host allowance). | @@ -881,6 +884,68 @@ implemented automatic purge. Phase 11 owns enforcement and production volume qualification. New relationship/target/subject links remain personal data under the Data Protection inventory; ordinary redaction grants are not widened. +## Verified invitation and submission evidence + +The P03-4 contract derives from +[ADR-0093](../decisions/0093-verified-invitation-acceptance-and-handoff.md). +[Identity's matrix](../modules/identity/audit.md#invitation-bindings) and +[Tenancy's policy binding](../modules/tenancy/audit.md#invitation-policy-binding) +own exact operations/resources. Rows and new request bindings remain planned +until their consumers and catalogue entries exist. A decision or diagram is not +an implemented catalogue population. + +- Create, reissue, revoke and accept are MUST security events. Creation/reissue + enroll encrypted SMTP custody and the identifier-only job on the same ordinary + transaction as Invitation state and its required audit. No notification/private + audit table or SMTP call inside the business transaction is admitted. +- Acceptance uses central plural MUST intents for the Invitation and actual + original Membership. New Membership/contained children receive real capture; + `already_member` keeps the Membership unchanged and produces a subject-only + security-event row. The Invitation's mapped `acceptanceDisposition`, actor and + original Membership record the result through central capture. Handlers cannot + fabricate a diff or supply a general arbitrary metadata payload. +- The only added two-root handler is `AcceptInvitationCommandHandler`, alongside + provisioning's existing exception. User creation/disposal precedes this owner; + no global and tenant transaction joins or retargets the other. Both required + resource rows commit together or the grant/consumption is rolled back. +- Already Accepted routes to the exact Off own-result query before mutation + intents. Same Active actor, consumed verifier and original Active Membership + prove that disclosure. Pending-only email/expiry/version/Role/inviter checks do + not invalidate a committed result. A dispatched Pending command losing the + race returns 409 with truthful failed-attempt evidence; it cannot emit another + successful acceptance or silently discard a declared intent. +- Pending own inspection and accepted own-result navigation are explicitly Off. + Administrative list/detail, delegation-limited Role choices and protected + receipt disclosure are MUST sensitive reads, committed before bytes. The exact + orgless own-current-tenant `GetTenantInvitationPolicyQuery` is Off/ReadOnly. Status detail returns only normalized current-generation submission + evidence under current administration authority, without a delivery-body read. +- The existing MUST `tenancy.setting.write` covers the exact tenant-wide TTL + setting. Its setter uses whole-value TenantSetting redaction; + no weaker class or generic settings grant follows from the extra key. +- Ordinary module preparation/finalization is MUST and binds the exact + Invitation/generation. `identity.invitation.email_submission` applies validated + durable provider evidence through the common audit owner. The private immutable + provider receipt is operational evidence, not a substitute audit row or a new + direct INSERT seam. ExternalScope's ordinary database denial remains intact; + the dedicated two-function source cannot write module/User/audit tables. +- Final SMTP `250` records accepted submission, never inbox delivery. Definite + temporary no-effect can consume the existing retry budget; ambiguous Sending/ + Unknown retains occupancy without blind retry. Durable evidence reproduces the + same terminal result after restart. Revoke/reissue/accept invalidates codes; + Prepare refuses obsolete generations, but prepared email can still arrive. + Provider recovery fencing is separate from business withdrawal. + +No raw recipient email, link, code, verifier, ciphertext, SMTP response/exception +text or provider body enters audit values, metadata or diagnostics. Keep sensitive +properties with redaction markers and typed closed outcomes; references remain +personal linkage under the canonical +[Data Protection inventory](../architecture/23-data-protection.md#invitation-offer-and-automatic-email-custody). +Authorized recipient-email DSAR disclosure remains possible under separate +subject/purpose authority; credential and verifier disclosure does not. Existing +SecurityEvent/ReadSensitive retention targets remain; P03-10 owns locators and +closure integration, Phase 11 retained-copy/purge enforcement. No runtime or +production qualification is claimed by these additions. + ## Architecture and Integration Tests Canonical names and each rule's kind live in diff --git a/docs/standards/19-permissions.md b/docs/standards/19-permissions.md index a6c644b9e..08e5c2f9a 100644 --- a/docs/standards/19-permissions.md +++ b/docs/standards/19-permissions.md @@ -500,10 +500,42 @@ No Organization Tenancy pair, custom Role or generic settings power is selected. Tenant `tenancy.organization.admin` covers Organization create/rename/archive and the Tenant-root default-reference sub-operation; target guards still prove the selected Organization belongs to that Tenant and is Active/nondeleted. Locale -writing concerns contained Tenant locales; setting writing concerns only whole -tenant-wide `branding.theme`. Their full editors and any separately selected +writing concerns contained Tenant locales; setting writing covers whole +tenant-wide `branding.theme` and the exact invitation policy extension below. +Their full editors and any separately selected Organization-capability variants belong to Phase 06. +### Invitation capability extension + +**Derives from:** [ADR-0093](../decisions/0093-verified-invitation-acceptance-and-handoff.md). +This Accepted contract adds no assignable pair, grant release, template or +automatic grant. P03-4 runtime qualification remains in the phase Status block. +Existing holders of Tenant `tenancy.setting.write` gain the additional exact +orgless operation on `identity.invitation.ttl_days`, 1–30 days. A v1 tenant +without that pair uses the 14-day default but cannot edit it. The explicit v2 +adoption below remains the only exceptional new-key grant path; registration, +warm seed and invitation creation cannot grant or restore the pair. An additional +invitation-policy permission is not selected. No generic setting writer follows. + +Invitation administration uses applicable Tenant or exact-current-Organization +`identity.membership.admin`, exact current target/write scope and fresh complete +delegation; another branch contributes nothing. One pinned compatible Role is +Tenant-scoped iff the offer organization is NULL, Organization-scoped iff it is +non-NULL; the delegation bridge permits no cross-scope Role assignment. The Role's +whole current grants must fit the inviter's current ceiling, including the +existing same-key Tenant-to-Organization rule. A separate invitable-Role summary +does not require or confer `identity.role.admin` or general registry access. +Acceptance reads the stored inviter's current authority from the exact locked +offer; it does not mint an HTTP candidate for that other actor. The current +verified recipient, offer/host witness and exact invocation supply only the +finite inspection/acceptance/own-result admission, never membership-admin or +ordinary directory authority. Membership creation alone publishes no session. +The [Identity matrix](../modules/identity/permissions.md#verified-invitation-contract) +owns the finite operation bindings; [Security](11-security.md#verified-invitation-admission) +owns source, witness and owner limits. + +### Explicit first v2 adoption + First v2 adoption requires an Active NULL-org Membership whose User admits work, assigned the Tenant Admin template with both kernel pairs, current Tenant `identity.role.admin`, orgless scope, expected Role version and exact approved diff --git a/docs/standards/20-infrastructure-stack.md b/docs/standards/20-infrastructure-stack.md index da0d20c59..725ae1300 100644 --- a/docs/standards/20-infrastructure-stack.md +++ b/docs/standards/20-infrastructure-stack.md @@ -24,6 +24,8 @@ Delivery/recovery additions derive from [ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md). Background-job additions derive from [ADR-0047](../decisions/0047-transactional-background-job-runtime.md). +The bounded invitation SMTP provider and custody source derive from +[ADR-0093](../decisions/0093-verified-invitation-acceptance-and-handoff.md). Vendor-maintenance configuration and ownership derive from [ADR-0058](../decisions/0058-bounded-stock-hangfire-maintenance.md). Accepted server lifecycle and its bounded availability qualification derive from @@ -894,6 +896,49 @@ phase. and locally qualifies both extended catalogue obligations; its delivery record owns full-review, local packet-closeout and real PR-head CI qualification status. +### Bounded invitation SMTP consumer + +**Accepted contract; runtime qualification remains separate.** P03-4 supplies the +first production external definition through +`IInvitationEmailOperation : IBackgroundJobExternalOperation` in Identity Application.Contracts and +`InvitationSmtpOperation` in Identity Infrastructure. Common Infrastructure owns +the SMTP transport and encrypted provider storage; neither is a notification +aggregate or a new scheduling engine. Phase 08a continues to own the general +notification feature. The [Identity provider specification](../modules/identity/providers.md) +owns port/adapter/deployment configuration; [Database Standards](05-database.md#invitation-smtp-provider-storage) +owns provider tables, encryption, grants, exact functions and source accounting. + +- `IInvitationEmailStager` stages an immutable encrypted envelope and the typed job + on the invitation writer's existing ordinary owner. Prepare/finalize use the + delivered audited MediatR owner through separate tenant and organization request + types with shared logical audit slugs; Execute/Reconcile keep ordinary application + access denied. Their identifier-only DTOs carry no authored content or secret. +- The two fixed provider-store functions are the explicit additional external + custody capability. A nonserialized receipt from the exact validated + `JobExecutor` invocation binds their authority. No adapter receives a sender, + UoW, module repository, tenant setter or arbitrary SQL/callback capability. +- The provider-only data source has four retained owner/connector slots and + actual-cleanup lifetime independent of ordinary business and dispatcher + sources. SQL timeout/failed cleanup never becomes rollback, quiescence or a + released physical slot. The database standard owns its full ledger. +- Register the tenant-wide capacity group `identity.invitation_email` with + operational ceiling **4**, without a new plan limit. Retain the existing + `external` queue, pool and worker counts; a capacity ceiling does not create + workers. Unknown operations continue occupying their exact reservations. +- Common `IEmailProvider` uses MailKit/MimeKit **4.18.2** behind + `IProviderResilience` with port retry disabled. Definite no-effect + transient retries use the existing counted job admissions. Neither Polly nor + SMTP reconnection may silently resubmit after possible acceptance. +- Preserve actual SMTP task, cancellation and socket cleanup ownership when a + finite wait ends. Final 250 plus positively completed work supplies submission + acceptance; missing final reply or missing durable provider evidence stays + Unknown. Restart replays an existing immutable receipt, never another send. +- Explicit resend creates a new generation/token/job and invalidates the old + acceptance secret. It does not release unresolved old provider reservations or + claim that an already-started old message cannot arrive. Membership acceptance + remains separately fenced against a second grant. + ## Network and Service Topology - LearnStack monolith → APISIX edge → backend pods (1+). diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 5ec017455..6c7db4e8a 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -173,7 +173,7 @@ not implemented is the failure mode this column exists to prevent. ### Implemented today -**331 test methods run in +**341 test methods run in [`backend/tests/LearnStack.Tests.Architecture`](../../backend/tests/LearnStack.Tests.Architecture),** shipped by [Phase 01](../roadmap/phase-01-repository-tooling.md), [Phase 02a Packets 2–3](../roadmap/phase-02a-kernel-tenancy.md), Packet 4, Packet 6, Packet 7, @@ -214,8 +214,8 @@ two fifths of its subject is the defect this section is about. It also refuses a test class that exists nowhere, because otherwise a renamed or deleted file drops its entries out of the subject instead of failing. -**254 rules in this catalogue are Implemented, and 149 of them are in that assembly.** -The other 105 are no less binding, and most could not live there. The table says where +**257 rules in this catalogue are Implemented, and 150 of them are in that assembly.** +The other 107 are no less binding, and most could not live there. The table says where and why, and deliberately carries no per-row count: those are the numbers nothing recomputes, and the first version of this table claimed "three rules" for a suite that holds ten. @@ -1483,20 +1483,29 @@ otherwise). wrong module. - **Phase:** 02a (Packet 6). -#### `Cross_Aggregate_Writes_Are_Confined_To_Tenant_Provisioning` + + +#### `Cross_Aggregate_Writes_Are_Confined_To_Named_Exceptions` - **Asserts:** no type implementing `IRequestHandler<,>`, `IRequestHandler<>`, `INotificationHandler<>` or `IIntegrationEventHandler<>` can reach more than one **distinct aggregate root** through its constructor parameters' `IAggregateWriteStore` derivations, except the - single exact assembly/type identity — Tenancy's `ProvisionTenantCommandHandler`, - which writes - `Tenant` and its default `Organization` in one transaction per ADR-0042. + two exact assembly/type identities: Tenancy's `ProvisionTenantCommandHandler` + writes Tenant/default Organization under ADR-0042; Identity's + `AcceptInvitationCommandHandler` writes Invitation/at-most-one Membership under + ADR-0093. Contained children do not create another root. No cross-module + write or third handler is admitted. The historical + `Cross_Aggregate_Writes_Are_Confined_To_Tenant_Provisioning` name resolves here; + it is not a second owner of this rule. - **Source:** [ADR-0042](../decisions/0042-tenant-provisioning-cross-aggregate-transaction.md); [Architecture Standards § Aggregate Ownership](01-architecture-standards.md). - **Type:** xUnit + reflection. **Kind:** structural. -- **Status:** **Implemented** (`AggregateWriteTests`). -- **Phase:** 02a (Packet 7). +- **Status:** **Implemented** in `AggregateWriteTests`, including ADR-0093's + actual two-handler production census and hostile controls. + The [phase Status block](../roadmap/phase-03-identity-admin.md) owns that evidence. + The current implementation requires both exact handler identities. +- **Phase:** 02a baseline; P03-4 extension. - **Note:** the rule counts **aggregate roots reached through ports**, not `DbSet` use, and the change is not cosmetic. ADR-0042 § Implementation Notes specified a source scan for `Add` / `Update` / `Remove` against more than one `DbSet`; under the @@ -2195,11 +2204,15 @@ diagnostic above row security, not the boundary, as its own note says. value-to-NULL transitions. The canonical shape is the shared BEFORE UPDATE trigger; `audit_log`'s append-only guard is accepted only with independent proof that it refuses the same change. The shared function cannot assume a surrogate `id` exists. + ADR-0093's two exact SMTP control-metadata tables use their insert-only and + scope-frozen invoker guards, with exact body/trigger/ACL checks and independent + app-role mutation controls; no new table inherits this classification by name. Scope follows table class: `platform_host_to_tenant` is platform-scoped and `outbox_messages`' organization column is tenant-wide event metadata, so those two are not subjects. An exemption must not silently exclude a newly introduced org-scoped table. - **Source:** [ADR-0003 Amendment 6](../decisions/0003-tenant-isolation-defense-in-depth.md#amendment-6--insert-scope-and-parent-mirrors-2026-09-14); + [ADR-0093](../decisions/0093-verified-invitation-acceptance-and-handoff.md); [05-database.md § Tenant-Owned and Organization-Scoped Tables](05-database.md#tenant-owned-and-organization-scoped-tables). - **Type:** xUnit + applied PostgreSQL schema catalogue, with behavioral companion evidence for the append-only alternative. **Kind:** structural. @@ -2221,9 +2234,15 @@ diagnostic above row security, not the boundary, as its own note says. CourseTranslation → Course and LessonTranslation → Lesson. Identity adds MembershipProfile → Membership and MembershipRoleAssignment → Membership; its combined invoker trigger retains the same parent-scope contract. + Invitation's optional original-result Membership is checked at its only + admitting transition with the same parent lock. The exact provider-control + metadata relations retain their foreign keys and class-specific invoker/owner + custody checks under the + [SMTP storage contract](05-database.md#provider-records-and-ordinary-staging). Subject discovery and declaration coverage must make an omitted relation fail. - **Source:** [ADR-0003 Amendment 6](../decisions/0003-tenant-isolation-defense-in-depth.md#amendment-6--insert-scope-and-parent-mirrors-2026-09-14); [ADR-0073](../decisions/0073-scoped-membership-and-identity-data-foundation.md); + [ADR-0093](../decisions/0093-verified-invitation-acceptance-and-handoff.md); [05-database.md § Parent organization mirrors](05-database.md#parent-organization-mirrors); [Education data model](../modules/education/README.md#data-model-and-invariants). - **Type:** xUnit + EF model and applied PostgreSQL schema inspection. **Kind:** structural. @@ -5540,7 +5559,8 @@ structural test proves — and what it does not. membership and permission authority. - **Source:** [ADR-0064](../decisions/0064-tenant-api-authentication-boundary.md) § Access-token profile and § Resolution and attribution; ADR-0004; - [ADR-0072](../decisions/0072-application-owned-identity-bootstrap.md). + [ADR-0072](../decisions/0072-application-owned-identity-bootstrap.md); + [ADR-0093](../decisions/0093-verified-invitation-acceptance-and-handoff.md). - **Type:** complete production source/IL call-site census with planted compiled violations and predicate-mutation controls. **Kind:** structural. - **Test owner:** @@ -5567,6 +5587,13 @@ structural test proves — and what it does not. delegates to the separately enumerated protected-replay owner; both adapters still reject extra identity formatting. The policy/receipt censuses close the replay owner's actual candidate and three HTTP decision branches. + ADR-0093 adds exact invitation credential/source/recipient-reader sites and the + invitation-aware witness shape. Encoded-token reads retain their validated-token + source, pending-token digest comparison, request-local field and sole fixed + UserInfo Bearer sink. Request/evidence object forwarding and compiler exception + storage have finite provenance checks. Substituted token, sink, caller, + operation, observation and exception-storage mutations remain violations; + no whole-method or type-wide forwarding exemption is inferred. - **Status:** **Implemented** (`AuthenticationAuthorityTests`). - **Phase:** P02b-5 Step 1 profile constraints; Step 5 complete production census. @@ -7093,3 +7120,83 @@ and fixture definitions make the implemented controls non-vacuous. - [Phase 02a Roadmap § Architecture Tests](../roadmap/phase-02a-kernel-tenancy.md) - [Phase 02b Roadmap § Architecture Tests](../roadmap/phase-02b-events-auth.md) - [add-architecture-test skill](../../.claude/skills/add-architecture-test/SKILL.md) + + +#### `Invitation_Entry_Is_Offer_And_Subject_Confined` + +- **Asserts:** only the exact native authenticated inspection/accept/result + consumers may use HostAndInvitationOffer and InvitationEntryScope. Eleven + logical setters/four physical context writers and eleven confined definers + remain exact; mode/host/subject/operation reuse, arbitrary scope selectors, + raw-code SQL parameters and generic external DB access refuse. +- **Source:** [ADR-0093](../decisions/0093-verified-invitation-acceptance-and-handoff.md), + [Security](11-security.md#verified-invitation-admission), + [Database](05-database.md#invitation-entry-and-consumption). +- **Type:** compiled/source/body/ACL/caller checks and real PostgreSQL/native + wrong-purpose, lifetime, isolation and cleanup controls. **Kind:** both. +- **Status:** **Implemented** — `BoundedAdministrationHttpTests`, + `InvitationStorageTests` and `GlobalIdentitySchemaTests` enforce the actual + request, owner, applied function/ACL and isolation boundaries. +- **Structural companions:** `InvitationEntryConventionTests`, + `AuthenticatedHttpSurfaceTests` and `PersistenceConventionTests` enforce the + nonempty production-source, dispatcher, scope-owner and hostile-call census. + The [Phase 03 Status block](../roadmap/phase-03-identity-admin.md) owns complete + packet qualification; implemented controls do not establish hosted or human proof. +- **Phase:** 03 (P03-4). + +#### `Invitation_Consumption_And_Membership_Are_Atomic` + +- **Asserts:** one consumed generation and at most one new Membership share + strongest actual ordered fences and plural MUST durability. Fresh email, + stored-inviter delegation/global admission, compatible pinned Role, original + result identity and lifecycle predicates cannot be replaced by cached facts. + Already-member disposition lives on captured Invitation; its unchanged + Membership event has no fabricated snapshot or result metadata. Accepted + reconciliation precedes Pending-only checks and never dispatches mutation + intents for an already-observed result. +- **Source:** [ADR-0093](../decisions/0093-verified-invitation-acceptance-and-handoff.md), + [Audit](18-audit-coverage.md), [Database](05-database.md#invitation-entry-and-consumption). +- **Type:** architecture invocation/aggregate/audit controls and real app-role + competing transitions, audit/commit failure and lost-HTTP-response reconciliation, + with shared-owner indeterminate-COMMIT controls. + **Kind:** both. +- **Status:** **Implemented** — `BoundedAdministrationHttpTests` and its + invitation companions enforce actual ordered admission, competing transitions, + plural MUST rollback, unchanged-member evidence and original-result identity. + `TransactionBehaviorTests`, `AuditLogBehaviorTests` and + `AuthorizationEvidenceBarrierTests` cover the shared owner's indeterminate + commit boundary. Invitation-specific response loss follows successful COMMIT; + it is not a claimed dropped driver acknowledgement. +- **Structural companions:** `AggregateWriteTests`, + `IdentityFoundationConventionTests` and invitation audit/dispatch controls + enforce the production two-root/owner contract. Complete packet qualification + remains with the [Phase 03 Status block](../roadmap/phase-03-identity-admin.md). +- **Phase:** 03 (P03-4). + +#### `Invitation_Email_Custody_Is_Closed_And_Owned` + +- **Asserts:** automatic invitation email uses exact typed identifier-only job + definitions, encrypted private provider storage and the attributed four-slot + connector. External providers retain ordinary DB/tenant denial. Only the two + fixed custody functions may expose encrypted material or immutable per-attempt + evidence; no recipient/code/body/ciphertext enters generic jobs or replay. + SMTP final 250 is submission acceptance; missing reply is Unknown, not retry or + reservation release. Positive pre-send fencing and known completed receipts + alone permit their exact runtime transitions; reissue invalidates old grant + authority without releasing an old uncertain reservation. +- **Source:** [ADR-0093](../decisions/0093-verified-invitation-acceptance-and-handoff.md), + [Database](05-database.md#invitation-smtp-provider-storage), + [Infrastructure](20-infrastructure-stack.md#bounded-invitation-smtp-consumer). +- **Type:** registry/source/function/ACL/PII checks plus real PostgreSQL/SMTP + prepare/send/finalize, dropped-reply/restart/quiescence/key-loss and leakage + controls with positive twins. **Kind:** both. +- **Status:** **Implemented** — `InvitationSmtpBoundaryTests`, + `InvitationSmtpStorageTests`, `InvitationSmtpTransportTests`, + `InvitationSmtpRuntimeTests`, `InvitationDatabasePrivacyTests` and + `InvitationEmailCipherTests` enforce actual custody, source, crypto and failure + boundaries, including process-owned restart and retained cleanup. + Production stager/status, credential/payload and applied schema companions + preserve the same closed authority. The + [Phase 03 Status block](../roadmap/phase-03-identity-admin.md) owns complete + packet qualification; SMTP submission acceptance is not inbox delivery. +- **Phase:** 03 (P03-4). diff --git a/frontend/apps/web/README.md b/frontend/apps/web/README.md index 76425c420..134ff24af 100644 --- a/frontend/apps/web/README.md +++ b/frontend/apps/web/README.md @@ -147,6 +147,40 @@ There is **no `extensions/` folder for vertical-provided components** — per tenant-specific renderers are composite renderer keys resolved by [`src/lib/customization/`](src/lib/customization/) against a closed set. +## Invitations + +[ADR-0093](../../../docs/decisions/0093-verified-invitation-acceptance-and-handoff.md) +adds the bounded invitation experience to the native owner. The +[Phase 03 Status block](../../../docs/roadmap/phase-03-identity-admin.md) records +implementation and qualification. Its exact routes, private custody and +publication rules live in +[Frontend Architecture](../../../docs/standards/07-frontend-architecture.md#verified-invitation-experience). + +- Studio invitation forms use the current admitted tenant or branch. Create + queues automatic email; it does not return a link for the administrator to + share. Reissue explicitly replaces the code. An old message may still arrive + and unresolved old work retains capacity. +- Queueing, SMTP submission acceptance and membership acceptance are different + outcomes. SMTP acceptance does not mean inbox delivery. Already-member + acceptance preserves the existing Roles and profile; the displayed Role is + the offered Role, not evidence of an assignment change. +- The email opens `/invitations` with its code only in the fragment. The minimal + native landing removes the fragment before capture and precedes Next/RSC. + For JavaScript-disabled use, open the clean `/invitations` address and paste + the separately supplied code. No-JavaScript navigation cannot itself scrub + an existing fragment. +- Signin, explicit application-account creation, email verification, invitation + acceptance and tenant-session admission are separate steps. A wrong live + account is not silently replaced. After a lost result, use the explicit + original-result recovery; reopening the email recovers lost volatile custody. + +The API requires the operator-configured +[invitation SMTP settings and key ring](../../../docs/modules/identity/providers.md#configuration-surface-and-source-bindings). +Keycloak's verification sender is separate; complete its +[verification setup](../../../infra/keycloak/README.md#invitation-email-and-recipient-verification) +as well. A working login with an unverified seed account or a configured +invitation sender does not qualify a real new-user verification journey. + ## Customization Runtime (ADR-0018) [`src/lib/customization/`](src/lib/customization/) holds the runtime resolver for diff --git a/frontend/apps/web/scripts/bff-proof-host.mjs b/frontend/apps/web/scripts/bff-proof-host.mjs index 1ac9bfe2d..46d5c1678 100644 --- a/frontend/apps/web/scripts/bff-proof-host.mjs +++ b/frontend/apps/web/scripts/bff-proof-host.mjs @@ -156,6 +156,7 @@ export function instrumentCopiedBffRuntime( external, uiMode, isolatedProtocol, + randomListener, disabledOwnPreference, fakeIssuer, fakeLogout, @@ -339,7 +340,7 @@ export function instrumentCopiedBffRuntime( "if (app) ownShutdown(() => observeShutdownJoin('next', app.close()));", ); } - if (isolatedProtocol) { + if (isolatedProtocol || randomListener) { // Only the physical test listener changes. Logical Host/origin, ingress, // Next's configured port, TLS and all production admission remain intact. launcher = replace( @@ -379,6 +380,7 @@ export async function prepareBffProof({ clockBase = 1800000000000, external, isolatedProtocol = false, + randomListener = false, frameworkTemplate, fakeIssuer = 'https://identity.example.test/realms/learnstack', fakeLogout = fakeIssuer + '/protocol/openid-connect/logout', @@ -389,6 +391,11 @@ export async function prepareBffProof({ ], }) { assert.ok(!isolatedProtocol || !external, 'isolated protocol excludes external capabilities'); + assert.equal(typeof randomListener, 'boolean'); + assert.ok( + !randomListener || (external && uiMode), + 'random external listener requires real provider UI fixture', + ); assert.equal(typeof transportProof, 'boolean', 'closed transport fixture selection'); assert.equal(typeof routerMatrix, 'boolean', 'closed router matrix fixture selection'); assert.equal(typeof routerFaults, 'boolean', 'closed router fault fixture selection'); @@ -445,7 +452,7 @@ export async function prepareBffProof({ privateValues, cookieCanaries, scanners, - profile: external ? 'none' : 'bff', + profile: external && !randomListener ? 'none' : 'bff', }); } function get(path, cookie, extra = {}) { @@ -558,6 +565,12 @@ export async function prepareBffProof({ 'native-operation-contract', 'native-api-transport', 'native-session-client', + 'native-invitation-contract', + 'native-invitation-client', + 'native-invitation-http', + 'invitation-session', + 'invitation-copy', + 'invitation-html', 'entry-cursor', 'native-administration-client', 'http-forms', @@ -572,6 +585,15 @@ export async function prepareBffProof({ join(source, 'src/server/auth', name + '.ts'), join(app, 'src/server/auth', name + '.ts'), ); + // The native launcher imports these exact authored assets before Next starts. + for (const locale of ['en', 'tr']) { + const directory = join(app, 'src/i18n/messages', locale); + mkdirSync(directory, { recursive: true }); + cpSync( + join(source, 'src/i18n/messages', locale, 'invitation.json'), + join(directory, 'invitation.json'), + ); + } if (mutation === 'activity') { const path = join(app, 'src/server/auth/request-runtime.ts'); file( @@ -584,7 +606,11 @@ export async function prepareBffProof({ for (const name of ['tailwind.config.ts', 'postcss.config.cjs']) cpSync(join(source, name), join(app, name)); cpSync(join(source, 'scripts/development-hmr.mjs'), join(app, 'scripts/development-hmr.mjs')); - prepareFrameworkProof({ sourceApp: source, app, profile: external ? 'none' : 'bff' }); + prepareFrameworkProof({ + sourceApp: source, + app, + profile: external && !randomListener ? 'none' : 'bff', + }); file( join(app, 'package.json'), JSON.stringify({ @@ -730,6 +756,7 @@ export const config = { runtime: 'nodejs', matcher: ['/((?!_next/|favicon\\.ico$ external, uiMode, isolatedProtocol, + randomListener, disabledOwnPreference, fakeIssuer, fakeLogout, @@ -918,7 +945,12 @@ export const config = { runtime: 'nodejs', matcher: ['/((?!_next/|favicon\\.ico$ }, }); if (external) { - const trust = installCopiedExternalProviderCa({ root, app, authority: external.authority }); + const trust = installCopiedExternalProviderCa({ + root, + app, + authority: external.authority, + randomListener, + }); env.NODE_EXTRA_CA_CERTS = trust.caPath; } // Compile the copied current sources: never let stale workstation output @@ -1019,7 +1051,7 @@ export const config = { runtime: 'nodejs', matcher: ['/((?!_next/|favicon\\.ico$ listenerPort: () => listenerPort, }; const ready = (async () => { - if (isolatedProtocol) { + if (isolatedProtocol || randomListener) { try { listenerPort = await waitForBffProofListener(server.proofChannel); } catch (error) { diff --git a/frontend/apps/web/scripts/bff-proof-support.mjs b/frontend/apps/web/scripts/bff-proof-support.mjs index afa6b0d3e..b09970f5c 100644 --- a/frontend/apps/web/scripts/bff-proof-support.mjs +++ b/frontend/apps/web/scripts/bff-proof-support.mjs @@ -242,9 +242,13 @@ export function rememberBffCookies(headers, canaries) { const transaction = /^(?:__Host-ls-login-|__Host-ls-logout-)([A-Za-z0-9_-]{43})$/.exec(name); assert.ok( transaction || - ['__Host-ls-session', '__Host-ls-browser', '__Host-ls-csrf', '__Host-ls-entry'].includes( - name, - ), + [ + '__Host-ls-session', + '__Host-ls-browser', + '__Host-ls-csrf', + '__Host-ls-entry', + '__Host-ls-invitation', + ].includes(name), ); for (const secret of transaction ? [transaction[1], value] : [value]) { if (canaries.includes(secret)) continue; diff --git a/frontend/apps/web/scripts/framework-external-provider-ca-source.mjs b/frontend/apps/web/scripts/framework-external-provider-ca-source.mjs index 9ceb71c30..32e257290 100644 --- a/frontend/apps/web/scripts/framework-external-provider-ca-source.mjs +++ b/frontend/apps/web/scripts/framework-external-provider-ca-source.mjs @@ -92,7 +92,9 @@ function exactOwnedCa(path, expectedHash) { } } -export function installCopiedExternalProviderCa({ root, app, authority }) { +export function installCopiedExternalProviderCa({ root, app, authority, randomListener = false }) { + assert.equal(typeof randomListener, 'boolean'); + const profile = randomListener ? 'bff' : 'none'; const privateRoot = realpathSync(root); const appRoot = realpathSync(app); if ( @@ -118,7 +120,9 @@ export function installCopiedExternalProviderCa({ root, app, authority }) { 'Only one copied proof profile call', ); assert.equal( - owner.match(/^[ ]{2}copiedProof = createCopiedParentProof\('none'\);$/gm)?.length, + owner.match( + new RegExp("^[ ]{2}copiedProof = createCopiedParentProof\\('" + profile + "'\\);$", 'gm'), + )?.length, 1, 'Only external copied owner', ); diff --git a/frontend/apps/web/scripts/framework-proof-admission-source.mjs b/frontend/apps/web/scripts/framework-proof-admission-source.mjs index fd50ab47a..c53a737cd 100644 --- a/frontend/apps/web/scripts/framework-proof-admission-source.mjs +++ b/frontend/apps/web/scripts/framework-proof-admission-source.mjs @@ -73,7 +73,11 @@ export function instrumentCopiedAdmissionRuntime(sourceText) { `.then(() => admission.run(binding, request, response, () => bffRequests - ? bffRequests.run(binding, request, response, () => handle(request, response)) + ? bffRequests.run(binding, request, response, () => + nativeInvitationTarget(binding.target) + ? invitationHandler(binding, request, response) + : handle(request, response), + ) : handle(request, response), ), )`, @@ -83,7 +87,10 @@ export function instrumentCopiedAdmissionRuntime(sourceText) { request, response, () => { completions.track(response); return bffRequests - ? bffRequests.run(binding, request, response, () => handle(request, response)) + ? bffRequests.run(binding, request, response, () => + nativeInvitationTarget(binding.target) + ? invitationHandler(binding, request, response) + : handle(request, response)) : handle(request, response); }))`, ); diff --git a/frontend/apps/web/scripts/framework-proof-relay.mjs b/frontend/apps/web/scripts/framework-proof-relay.mjs index 2c89e5b18..95de6491e 100644 --- a/frontend/apps/web/scripts/framework-proof-relay.mjs +++ b/frontend/apps/web/scripts/framework-proof-relay.mjs @@ -47,6 +47,14 @@ export const NATIVE_OPERATION_KINDS = Object.freeze([ 'renameOrganization', 'archiveOrganization', 'selectDefaultOrganization', + 'listInvitations', + 'getInvitation', + 'listInvitableRoles', + 'getInvitationPolicy', + 'createInvitation', + 'revokeInvitation', + 'reissueInvitation', + 'setInvitationLifetime', ]); const OPERATION_SET = new Set(NATIVE_OPERATION_KINDS); diff --git a/frontend/apps/web/scripts/invitation-proof-browser.mjs b/frontend/apps/web/scripts/invitation-proof-browser.mjs new file mode 100644 index 000000000..9090571d6 --- /dev/null +++ b/frontend/apps/web/scripts/invitation-proof-browser.mjs @@ -0,0 +1,100 @@ +import assert from 'node:assert/strict'; + +import { withProofWaiters } from './bff-proof-support.mjs'; + +/** Two finite browser journeys over the real pre-Next HTTPS invitation handler. */ +export async function observeInvitationCapture(browser, origin, code, privateFree, mark) { + let networkPrivateFree = true; + let browserPrivateFree = true; + let historyPrivateFree = true; + let exactOrigin = true; + let captures = 0; + for (const javaScriptEnabled of [true, false]) { + const context = await browser.newContext({ javaScriptEnabled }); + context.setDefaultTimeout(10000); + context.setDefaultNavigationTimeout(10000); + try { + const page = await context.newPage(); + const cdp = await context.newCDPSession(page); + let documents = 0; + context.on('request', (request) => { + if (request.resourceType() !== 'document') return; + documents++; + networkPrivateFree &&= documents <= 12 && privateFree(request.url()); + }); + mark(javaScriptEnabled ? 'browser-invitation-capture' : 'browser-invitation-noscript'); + const capture = page.waitForRequest( + (request) => new URL(request.url()).pathname === '/api/auth/invitation/capture', + ); + const arrived = page.waitForURL(origin + '/invitations/confirm', { + waitUntil: 'domcontentloaded', + }); + const [submitted] = await withProofWaiters([capture, arrived], async () => { + if (javaScriptEnabled) { + await page.goto(origin + '/invitations#' + code, { waitUntil: 'commit' }); + } else { + const landing = await page.goto(origin + '/invitations', { + waitUntil: 'domcontentloaded', + }); + assert.equal(landing.status(), 200); + assert.equal(await page.locator('main[data-invitation-state="landing"]').count(), 1); + await page.locator('input[name="code"]').fill(code); + await page + .locator('form[action="/api/auth/invitation/capture"] button[type="submit"]') + .click(); + } + }); + mark('browser-invitation-origin'); + assert.equal(submitted.method(), 'POST'); + const fields = new URLSearchParams(submitted.postData()); + assert.equal([...fields.keys()].sort().join(','), 'code,csrfToken'); + assert.ok(fields.get('code') === code, 'capture retains the bounded submitted value'); + assert.match(fields.get('csrfToken'), /^[A-Za-z0-9_-]{43}$/); + const headers = await submitted.allHeaders(); + exactOrigin &&= headers.origin === origin; + assert.equal(new URL(headers.referer).pathname, '/invitations'); + assert.equal(new URL(headers.referer).hash, ''); + captures++; + mark('browser-invitation-confirm'); + assert.equal(await page.locator('main[data-invitation-state="signin"]').count(), 1); + browserPrivateFree &&= privateFree(page.url()) && privateFree(await page.content()); + if (javaScriptEnabled) { + browserPrivateFree &&= privateFree( + await page.evaluate(() => + JSON.stringify({ + history: history.state, + local: Object.entries(localStorage), + session: Object.entries(sessionStorage), + }), + ), + ); + mark('browser-invitation-history'); + await page.goto(origin + '/invitations', { waitUntil: 'domcontentloaded' }); + await page.goBack({ waitUntil: 'domcontentloaded' }); + await page.waitForURL(origin + '/invitations/confirm', { waitUntil: 'domcontentloaded' }); + assert.equal(await page.locator('main[data-invitation-state="signin"]').count(), 1); + } + const navigation = await cdp.send('Page.getNavigationHistory'); + historyPrivateFree &&= + navigation.entries.length <= 8 && + navigation.entries.every((entry) => privateFree(entry.url) && privateFree(entry.title)); + mark('browser-invitation-cancel'); + // Release each captured custody owner through its actual native form. + await page + .locator('form[action="/api/auth/invitation/cancel"] button[type="submit"]') + .click(); + await page.waitForURL(origin + '/invitations', { waitUntil: 'domcontentloaded' }); + } finally { + await context.close(); + } + } + return { + captures, + script: true, + noScript: true, + exactOrigin, + networkPrivateFree, + browserPrivateFree, + historyPrivateFree, + }; +} diff --git a/frontend/apps/web/scripts/invitation-proof-view.mjs b/frontend/apps/web/scripts/invitation-proof-view.mjs new file mode 100644 index 000000000..8c8985028 --- /dev/null +++ b/frontend/apps/web/scripts/invitation-proof-view.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; + +const states = new Set([ + 'landing', + 'signin', + 'bootstrap', + 'inspect', + 'forbidden', + 'ready', + 'accepted', + 'unknown', + 'unavailable', + 'expired', + 'account_change', +]); +const actions = new Map([ + ['/api/auth/invitation/capture', 'csrfToken'], + ['/api/auth/invitation/cancel', 'csrfToken'], + ['/api/auth/signin/keycloak', 'signinCsrfToken'], + ['/api/auth/entry/create', 'entryCsrfToken'], + ['/api/auth/signout', 'sessionCsrfToken'], +]); + +/** Fixed safe native-view projection for the existing private fixture pipe. */ +export function invitationProofView(html) { + assert.ok(typeof html === 'string' && Buffer.byteLength(html) <= 256 * 1024); + const statesFound = [...html.matchAll(/data-invitation-state="([a-z_]+)"/g)]; + assert.equal(statesFound.length, 1); + const state = statesFound[0][1]; + assert.ok(states.has(state)); + const value = { + state, + hasRecipientAddress: + /[A-Za-z0-9][A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]*@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/.test(html), + }; + for (const form of html.matchAll(/]*)>([\s\S]*?)<\/form>/g)) { + const action = /\baction="([^"]+)"/.exec(form[1])?.[1]; + if (!actions.has(action) && action !== '/api/auth/invitation/accept') continue; + assert.match(form[1], /\bmethod="post"/); + const csrf = [...form[2].matchAll(/name="csrfToken" value="([A-Za-z0-9_-]{43})"/g)]; + assert.equal(csrf.length, 1); + const key = actions.get(action) ?? 'csrfToken'; + assert.ok(value[key] === undefined || value[key] === csrf[0][1]); + value[key] = csrf[0][1]; + if (action === '/api/auth/invitation/accept') { + const version = /name="version" value="(0|[1-9][0-9]{0,18})"/.exec(form[2])?.[1]; + assert.ok(version !== undefined); + value.version = version; + } + } + if (state === 'accepted') { + const disposition = /data-invitation-disposition="(created|already_member)"/.exec(html)?.[1]; + const session = /data-invitation-session="(ready|unavailable)"/.exec(html)?.[1]; + assert.ok(disposition && session); + value.disposition = disposition; + value.sessionAvailable = session === 'ready'; + } + return value; +} diff --git a/frontend/apps/web/scripts/public-server-runtime.mjs b/frontend/apps/web/scripts/public-server-runtime.mjs index 662b9713f..09b0e98d2 100644 --- a/frontend/apps/web/scripts/public-server-runtime.mjs +++ b/frontend/apps/web/scripts/public-server-runtime.mjs @@ -8,6 +8,8 @@ export async function startPublicRuntime(control) { let admission; let bffRequests; let bffSessions; + let invitationHandler; + let nativeInvitationTarget; let upgradeSink; let app; let server; @@ -187,6 +189,11 @@ export async function startPublicRuntime(control) { { createNativeAdministrationCaller }, { installBffSessionRuntime }, { installBffRequestRuntime }, + { createNativeInvitationApi }, + { createNativeInvitationHandler, nativeInvitationTarget: invitationTarget }, + { createInvitationRenderer }, + { default: invitationEn }, + { default: invitationTr }, ] = await Promise.all([ ownSetup(import('../.server/auth/provider-core.js')), ownSetup(import('../.server/auth/account-client.js')), @@ -194,6 +201,11 @@ export async function startPublicRuntime(control) { ownSetup(import('../.server/auth/native-administration-client.js')), ownSetup(import('../.server/auth/session-runtime.js')), ownSetup(import('../.server/auth/request-runtime.js')), + ownSetup(import('../.server/auth/native-invitation-client.js')), + ownSetup(import('../.server/auth/native-invitation-http.js')), + ownSetup(import('../.server/auth/invitation-html.js')), + ownSetup(import('../src/i18n/messages/en/invitation.json', { with: { type: 'json' } })), + ownSetup(import('../src/i18n/messages/tr/invitation.json', { with: { type: 'json' } })), ]); assertStarting(); const nativeConfiguration = { @@ -218,12 +230,23 @@ export async function startPublicRuntime(control) { account, native, administration, + invitations: createNativeInvitationApi(nativeConfiguration, globalThis.fetch), diagnostic: (reason) => console.warn('BFF session lifecycle', { reason }), }; assertStarting(); bffRequests = installBffRequestRuntime(admission.holder); assertStarting(); bffSessions = installBffSessionRuntime(dependencies, bffRequests.preparation); + nativeInvitationTarget = invitationTarget; + invitationHandler = createNativeInvitationHandler({ + configuration, + admission: admission.holder, + store: bffSessions.store, + responseLease: bffRequests.nativeResponse, + render: createInvitationRenderer({ en: invitationEn, tr: invitationTr }), + providerOrigin: new URL(bff.issuer).origin, + transport: globalThis.fetch, + }); assertStarting(); } // Next reads the same values; root/projection conflicts were refused before prepare. @@ -265,7 +288,11 @@ export async function startPublicRuntime(control) { .then(() => admission.run(binding, request, response, () => bffRequests - ? bffRequests.run(binding, request, response, () => handle(request, response)) + ? bffRequests.run(binding, request, response, () => + nativeInvitationTarget(binding.target) + ? invitationHandler(binding, request, response) + : handle(request, response), + ) : handle(request, response), ), ) diff --git a/frontend/apps/web/scripts/verify-bff-session.mjs b/frontend/apps/web/scripts/verify-bff-session.mjs index 27683a681..4f68c767a 100644 --- a/frontend/apps/web/scripts/verify-bff-session.mjs +++ b/frontend/apps/web/scripts/verify-bff-session.mjs @@ -20,6 +20,8 @@ import { createPrivateScanner, fixtureEnvironment, } from './fixture-support.mjs'; +import { observeInvitationCapture } from './invitation-proof-browser.mjs'; +import { invitationProofView } from './invitation-proof-view.mjs'; import { stopTestChild } from './stop-test-child.mjs'; const owner = createFixtureOwner(); @@ -88,14 +90,21 @@ const send = (value) => // This is one fixed browser observation of the real entry endpoint, not a browser // command proxy. Its native server, certificate, API and cookie all belong to the // same real-provider fixture. Every child remains under the existing EOF owner. -async function observeEntryBrowser(host, config, command, privateValues) { +async function observeNativeBrowser(host, config, command, privateValues) { const site = config.sites.find((candidate) => candidate.host === command.host); assert.ok(site); - assert.match(command.subjectKey, /^[0-9a-f]{64}$/); + const invitation = command.action === 'invitation-browser'; + if (invitation) { + assert.match( + command.code, + /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\.[A-Za-z0-9_-]{43}$/, + ); + privateValues.push(command.code); + } else assert.match(command.subjectKey, /^[0-9a-f]{64}$/); assert.ok(typeof command.cookie === 'string' && command.cookie.length <= 16384); const origin = 'https://' + site.host; const entryUrl = origin + '/api/auth/entry'; - privateValues.push(command.subjectKey, config.authorityId); + if (!invitation) privateValues.push(command.subjectKey, config.authorityId); const scanner = createPrivateScanner(privateValues); const privateFree = (value) => { assert.ok(typeof value === 'string' && Buffer.byteLength(value) <= 256 * 1024); @@ -120,7 +129,7 @@ async function observeEntryBrowser(host, config, command, privateValues) { ); return url.href; }; - const cookies = command.cookie.split('; ').map((pair) => { + const cookies = (invitation ? [] : command.cookie.split('; ')).map((pair) => { const separator = pair.indexOf('='); const name = pair.slice(0, separator); const value = pair.slice(separator + 1); @@ -129,25 +138,30 @@ async function observeEntryBrowser(host, config, command, privateValues) { if (!host.cookieCanaries.includes(value)) host.cookieCanaries.push(value); return { name, value, url: origin + '/', secure: true, httpOnly: true, sameSite: 'Lax' }; }); - assert.ok(cookies.length >= 2 && cookies.length <= 3); - assert.equal(new Set(cookies.map((cookie) => cookie.name)).size, cookies.length); - assert.ok(cookies.some((cookie) => cookie.name === '__Host-ls-entry')); - assert.ok(cookies.some((cookie) => cookie.name === '__Host-ls-browser')); - stage = 'browser-entry-confirmation'; - const html = await host.get('/api/auth/entry', command.cookie, { - headers: { host: site.host, accept: 'text/html', 'sec-fetch-dest': 'document' }, - }); - assert.equal(html.status, 200); - assert.ok(html.headers['content-type']?.includes('text/html')); - assert.equal(html.headers['cache-control'], 'no-store'); - assert.equal( - [...html.body.matchAll(/
/g)].length, - 20, - ); - const htmlLinks = [...html.body.matchAll(/href="(\/api\/auth\/entry\?cursor=[A-Za-z0-9_-]+)"/g)]; - assert.equal(htmlLinks.length, 1); - entryLocation(htmlLinks[0][1]); - const htmlPrivateFree = privateFree(html.body) && privateFree(htmlLinks[0][1]); + let htmlPrivateFree = true; + if (!invitation) { + assert.ok(cookies.length >= 2 && cookies.length <= 3); + assert.equal(new Set(cookies.map((cookie) => cookie.name)).size, cookies.length); + assert.ok(cookies.some((cookie) => cookie.name === '__Host-ls-entry')); + assert.ok(cookies.some((cookie) => cookie.name === '__Host-ls-browser')); + stage = 'browser-entry-confirmation'; + const html = await host.get('/api/auth/entry', command.cookie, { + headers: { host: site.host, accept: 'text/html', 'sec-fetch-dest': 'document' }, + }); + assert.equal(html.status, 200); + assert.ok(html.headers['content-type']?.includes('text/html')); + assert.equal(html.headers['cache-control'], 'no-store'); + assert.equal( + [...html.body.matchAll(//g)].length, + 20, + ); + const htmlLinks = [ + ...html.body.matchAll(/href="(\/api\/auth\/entry\?cursor=[A-Za-z0-9_-]+)"/g), + ]; + assert.equal(htmlLinks.length, 1); + entryLocation(htmlLinks[0][1]); + htmlPrivateFree = privateFree(html.body) && privateFree(htmlLinks[0][1]); + } stage = 'browser-executable'; const candidates = @@ -198,7 +212,7 @@ async function observeEntryBrowser(host, config, command, privateValues) { '--password-store=basic', '--use-mock-keychain', '--no-proxy-server', - '--host-resolver-rules=MAP ' + site.host.split(':')[0] + ' 127.0.0.1', + '--host-resolver-rules=MAP ' + site.host + ' 127.0.0.1:' + host.listenerPort(), '--ignore-certificate-errors-spki-list=' + fingerprint, 'about:blank', ], @@ -242,6 +256,10 @@ async function observeEntryBrowser(host, config, command, privateValues) { artifactsDir: artifacts, }); stage = 'browser-context'; + if (invitation) + return observeInvitationCapture(browser, origin, command.code, privateFree, (value) => { + stage = value; + }); const context = await browser.newContext(); context.setDefaultTimeout(10000); context.setDefaultNavigationTimeout(10000); @@ -430,6 +448,7 @@ try { uiMode: true, sites: config.sites, external: config, + randomListener: true, mutation: shutdownCounter ? 'shutdown' : heldBuild ? 'held-build' : undefined, mark: (value) => { stage = value; @@ -443,6 +462,7 @@ try { await send({ protocol: 1, phase: 'ready' }); let finished = false; let entryBrowserUsed = false; + let invitationBrowserUsed = false; let count = 0; for await (const command of input) { stage = 'command'; @@ -452,11 +472,20 @@ try { finished = true; break; } + if (command.action === 'invitation-browser') { + assert.equal(Object.keys(command).sort().join(','), 'action,code,cookie,host,protocol'); + assert.equal(command.cookie, ''); + assert.equal(invitationBrowserUsed, false); + invitationBrowserUsed = true; + const receipt = await observeNativeBrowser(host, config, command, privateValues); + await send({ protocol: 1, phase: 'invitation_browser', ...receipt }); + continue; + } if (command.action === 'entry-browser') { assert.equal(Object.keys(command).sort().join(','), 'action,cookie,host,protocol,subjectKey'); assert.equal(entryBrowserUsed, false); entryBrowserUsed = true; - const receipt = await observeEntryBrowser(host, config, command, privateValues); + const receipt = await observeNativeBrowser(host, config, command, privateValues); await send({ protocol: 1, phase: 'entry_browser', ...receipt }); continue; } @@ -492,6 +521,14 @@ try { '/api/auth/entry/select', '/api/auth/switch', '/api/account/profile', + '/invitations', + '/invitations/confirm', + '/api/auth/invitation/capture', + '/api/auth/invitation/inspect', + '/api/auth/invitation/verify', + '/api/auth/invitation/accept', + '/api/auth/invitation/result', + '/api/auth/invitation/cancel', ].includes(path.pathname) || bffPageKind(path.pathname) !== null, ); assert.ok( @@ -500,6 +537,10 @@ try { !/[\r\n]/.test(command.cookie), ); assert.ok(typeof command.body === 'string' && Buffer.byteLength(command.body) <= 16384); + if (path.pathname === '/api/auth/invitation/capture') { + const code = new URLSearchParams(command.body).get('code'); + if (code) privateValues.push(code); + } for (const key of ['code', 'state', 'error_description']) { const value = path.searchParams.get(key); if (value) privateValues.push(value); @@ -551,7 +592,10 @@ try { command.method !== 'HEAD' && response.headers['content-type']?.includes('application/json') ? JSON.parse(response.body) - : null; + : response.headers['content-type']?.includes('text/html') && + response.body.includes('data-invitation-state="') + ? invitationProofView(response.body) + : null; await send({ protocol: 1, phase: 'response', diff --git a/frontend/apps/web/src/app/(studio)/studio/invitation-policy/page.tsx b/frontend/apps/web/src/app/(studio)/studio/invitation-policy/page.tsx new file mode 100644 index 000000000..f7e86697a --- /dev/null +++ b/frontend/apps/web/src/app/(studio)/studio/invitation-policy/page.tsx @@ -0,0 +1,6 @@ +import { AccountPage, accountPageMetadata } from '@/components/auth/shell'; + +export const generateMetadata = () => accountPageMetadata('invitation_policy'); +export default function InvitationPolicyPage() { + return ; +} diff --git a/frontend/apps/web/src/app/(studio)/studio/invitations/[invitationId]/page.tsx b/frontend/apps/web/src/app/(studio)/studio/invitations/[invitationId]/page.tsx new file mode 100644 index 000000000..fd5491964 --- /dev/null +++ b/frontend/apps/web/src/app/(studio)/studio/invitations/[invitationId]/page.tsx @@ -0,0 +1,6 @@ +import { AccountPage, accountPageMetadata } from '@/components/auth/shell'; + +export const generateMetadata = () => accountPageMetadata('invitation'); +export default function InvitationPage() { + return ; +} diff --git a/frontend/apps/web/src/app/(studio)/studio/invitations/page.tsx b/frontend/apps/web/src/app/(studio)/studio/invitations/page.tsx new file mode 100644 index 000000000..2ddec10e5 --- /dev/null +++ b/frontend/apps/web/src/app/(studio)/studio/invitations/page.tsx @@ -0,0 +1,6 @@ +import { AccountPage, accountPageMetadata } from '@/components/auth/shell'; + +export const generateMetadata = () => accountPageMetadata('invitations'); +export default function InvitationsPage() { + return ; +} diff --git a/frontend/apps/web/src/app/globals.css b/frontend/apps/web/src/app/globals.css index cd0d4d74f..ff11555a6 100644 --- a/frontend/apps/web/src/app/globals.css +++ b/frontend/apps/web/src/app/globals.css @@ -70,6 +70,9 @@ .auth-locale-choices input[type='radio'] { margin-inline-end: 0.5rem; } +.auth-invitation-address { + overflow-wrap: anywhere; +} .auth-values dd { margin-block-end: 0.75rem; } diff --git a/frontend/apps/web/src/components/auth/admin-forms.tsx b/frontend/apps/web/src/components/auth/admin-forms.tsx index 4651dcc42..94d664dea 100644 --- a/frontend/apps/web/src/components/auth/admin-forms.tsx +++ b/frontend/apps/web/src/components/auth/admin-forms.tsx @@ -14,7 +14,7 @@ export function MutationFields({ readonly snapshot: NativePageSnapshot; readonly index: number; readonly version: string; - readonly target?: readonly ['membershipId' | 'roleId', string]; + readonly target?: readonly ['membershipId' | 'roleId' | 'invitationId', string]; }) { const key = snapshot.formKeys[index]; if (!key || !/^[A-Za-z0-9_-]{43}$/.test(key)) throw new Error('Prepared form unavailable'); @@ -28,6 +28,23 @@ export function MutationFields({ ); } +export function FormIdentityFields({ + snapshot, + index, +}: { + readonly snapshot: NativePageSnapshot; + readonly index: number; +}) { + const key = snapshot.formKeys[index]; + if (!key || !/^[A-Za-z0-9_-]{43}$/.test(key)) throw new Error('Prepared form unavailable'); + return ( + <> + + + + ); +} + export function ProfileFields({ value, snapshot, diff --git a/frontend/apps/web/src/components/auth/admin-invitations.test.tsx b/frontend/apps/web/src/components/auth/admin-invitations.test.tsx new file mode 100644 index 000000000..a0de2897b --- /dev/null +++ b/frontend/apps/web/src/components/auth/admin-invitations.test.tsx @@ -0,0 +1,251 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; + +import { PreparedAccountPage } from './shell'; +import { bffAdministrationForm } from '../../server/auth/http-forms'; +import type { NativePageSnapshot, PageInvitation, PageView } from '../../server/auth/page-contract'; +import { administrationSnapshot, uiRole } from '../../test/administration-ui-fixture'; + +vi.mock('@/server/auth/page', () => ({ optionalPreparedPage: vi.fn() })); +vi.mock('@/client/bff-session', () => ({ extendBffSession: vi.fn() })); + +const invitation: PageInvitation = { + invitationId: '019a154b-0000-7000-8000-000000000044', + email: 'person@example.test', + organizationScoped: false, + inCurrentScope: true, + offeredRole: uiRole, + roleVersion: uiRole.version, + version: '9223372036854775807', + status: 'Pending', + expiresAt: '2026-11-18T12:00:00.0000000+00:00', + deliveryStatus: 'Queued', + acceptanceDisposition: null, + canReissue: true, + canRevoke: true, +}; +function documentFor(view: PageView, overrides: Partial = {}) { + const snapshot = administrationSnapshot(view, overrides); + return new DOMParser().parseFromString( + renderToStaticMarkup(), + 'text/html', + ); +} +function adminForms(document: Document) { + return [...document.querySelectorAll('form')].filter((form) => + form.action.includes('/api/studio/'), + ); +} +function formFields(form: HTMLFormElement) { + const fields = new URLSearchParams(); + for (const [key, value] of new FormData(form)) fields.append(key, String(value)); + return fields; +} + +describe('prepared invitation administration', () => { + it.each(['en', 'tr'])( + 'submits a lossless pinned choice without JavaScript or Role-directory authority (%s)', + (locale) => { + const view = { + kind: 'invitations', + items: [], + roles: [uiRole], + endCursor: null, + hasNextPage: false, + limit: 20, + } as const; + const base = administrationSnapshot(view); + const document = documentFor(view, { + capabilities: { ...base.capabilities!, canReadRoles: false }, + ownProfile: { displayName: null, preferredLocale: locale, version: '1' }, + }); + const form = document.querySelector( + 'form[action="/api/studio/invitation-create"]', + )!; + expect(form).not.toBeNull(); + expect(form.checkValidity()).toBe(false); + form.querySelector('[name="email"]')!.value = 'New.person@example.test'; + form.querySelector('[name="offeredRole"]')!.value = + uiRole.roleId + '.' + uiRole.version; + expect(form.checkValidity()).toBe(true); + expect(form.method).toBe('post'); + const fields = formFields(form); + expect([...fields.keys()]).toEqual(['csrfToken', 'idempotencyKey', 'email', 'offeredRole']); + expect(bffAdministrationForm('/api/studio/invitation-create', fields)).toMatchObject({ + operation: { + kind: 'createInvitation', + invitation: { + email: 'New.person@example.test', + roleId: uiRole.roleId, + roleVersion: uiRole.version, + }, + }, + }); + expect(document.querySelector('section')?.lang).toBe(locale); + expect(document.querySelector('a[href="/studio/roles"]')).toBeNull(); + expect(document.querySelector('script')).toBeNull(); + expect(document.body.textContent).not.toContain(uiRole.roleId); + }, + ); + + it.each(['en', 'tr'])( + 'marks required invitation and policy controls visibly and semantically in %s', + (locale) => { + const documents = [ + documentFor( + { + kind: 'invitations', + items: [], + roles: [uiRole], + endCursor: null, + hasNextPage: false, + limit: 20, + }, + { ownProfile: { displayName: null, preferredLocale: locale, version: '1' } }, + ), + documentFor( + { + kind: 'invitation_policy', + policy: { days: 14, present: false, version: null, canEdit: true }, + }, + { ownProfile: { displayName: null, preferredLocale: locale, version: '1' } }, + ), + ]; + const controls = documents.flatMap((document) => [ + ...document.querySelectorAll('[required]'), + ]); + expect(controls.map((control) => control.name)).toEqual(['email', 'offeredRole', 'days']); + for (const control of controls) { + expect(control.getAttribute('aria-required')).toBe('true'); + expect(control.labels?.[0]?.textContent).toContain( + locale === 'en' ? 'Required' : 'Zorunlu', + ); + } + }, + ); + it('renders separate lifecycle and submission facts with safe mailbox isolation and issued pagination', () => { + const document = documentFor({ + kind: 'invitations', + items: [ + { + ...invitation, + email: '@example.test', + status: 'Expired', + deliveryStatus: 'SubmissionAccepted', + }, + ], + roles: [], + endCursor: 'YQ', + hasNextPage: true, + limit: 20, + }); + expect(document.querySelector('bdi')?.textContent).toBe('@example.test'); + expect(document.querySelector('recipient')).toBeNull(); + expect(document.body.textContent).toContain('Expired'); + expect(document.body.textContent).toContain('Submission accepted by the mail server'); + expect( + document.querySelector('a[href="/studio/invitations?cursor=YQ&limit=20"]'), + ).not.toBeNull(); + expect(adminForms(document)).toHaveLength(0); + }); + + it('shows the offered role and truthful already-member result with no membership navigation or mutations', () => { + const value = { + ...invitation, + status: 'Accepted', + acceptanceDisposition: 'already_member', + canReissue: false, + canRevoke: false, + } as const; + const document = documentFor({ kind: 'invitation', invitation: value }); + expect(document.body.textContent).toContain('Offered role'); + expect(document.body.textContent).toContain( + 'Their existing roles and profile were preserved; the offered role was not added.', + ); + expect(adminForms(document)).toHaveLength(0); + expect(document.querySelector('a[href^="/studio/members/"]')).toBeNull(); + }); + + it('uses independent keys for explicit replacement and revocation and explains uncertain old delivery', () => { + const document = documentFor({ + kind: 'invitation', + invitation: { ...invitation, deliveryStatus: 'Unresolved' }, + }); + const forms = adminForms(document); + expect(forms).toHaveLength(2); + const keys = forms.map((form) => formFields(form).get('idempotencyKey')); + expect(new Set(keys).size).toBe(2); + for (const form of forms) { + expect(formFields(form).get('version')).toBe(invitation.version); + expect(formFields(form).get('invitationId')).toBe(invitation.invitationId); + expect( + bffAdministrationForm(form.getAttribute('action')!, formFields(form)).destination, + ).toBe('/studio/invitations/' + invitation.invitationId); + } + expect(document.body.textContent).toContain( + 'Unfinished earlier email work retains its delivery slot.', + ); + expect(document.body.textContent).toContain('not a confirmed failure'); + expect(document.body.textContent).toContain('does not confirm inbox delivery'); + expect(document.querySelector('a[href*="#"]')).toBeNull(); + }); + + it('preserves an unavailable offered-role label without granting a new pin or reissue', () => { + const document = documentFor({ + kind: 'invitation', + invitation: { ...invitation, offeredRole: null, canReissue: false }, + }); + expect(document.body.textContent).toContain('Offered role label is no longer available'); + expect(document.body.textContent).toContain('Revoke this offer and create a new invitation'); + expect(document.querySelector('form[action="/api/studio/invitation-reissue"]')).toBeNull(); + expect(document.querySelector('form[action="/api/studio/invitation-revoke"]')).not.toBeNull(); + }); + + it.each([false, true])( + 'keeps absent policy separate from stored version zero (present=%s)', + (present) => { + const view = { + kind: 'invitation_policy', + policy: { days: 14, present, version: present ? '0' : null, canEdit: true }, + } as const; + const document = documentFor(view); + const form = adminForms(document)[0]!; + const fields = formFields(form); + expect(fields.has('version')).toBe(present); + expect(bffAdministrationForm('/api/studio/invitation-policy', fields)).toMatchObject({ + operation: { + kind: 'setInvitationLifetime', + days: 14, + precondition: present ? { kind: 'present', version: '0' } : { kind: 'absent' }, + }, + }); + expect(document.body.textContent).toContain( + present ? 'saved lifetime policy' : 'default lifetime is 14 days', + ); + form.querySelector('[name="days"]')!.value = '31'; + expect(form.checkValidity()).toBe(false); + }, + ); + + it('keeps policy editing separate from member capability and prevents branch editing', () => { + const view = { + kind: 'invitation_policy', + policy: { days: 14, present: false, version: null, canEdit: true }, + } as const; + const base = administrationSnapshot(view); + expect( + adminForms( + documentFor(view, { capabilities: { ...base.capabilities!, canManageMemberships: false } }), + ), + ).toHaveLength(1); + expect(adminForms(documentFor(view, { scopeKind: 'organization' }))).toHaveLength(0); + expect( + adminForms(documentFor({ ...view, policy: { ...view.policy, canEdit: false } })), + ).toHaveLength(0); + expect( + documentFor(view, { scopeKind: 'organization' }).querySelector( + 'a[href="/studio/invitation-policy"]', + ), + ).toBeNull(); + }); +}); diff --git a/frontend/apps/web/src/components/auth/admin-invitations.tsx b/frontend/apps/web/src/components/auth/admin-invitations.tsx new file mode 100644 index 000000000..e1853813f --- /dev/null +++ b/frontend/apps/web/src/components/auth/admin-invitations.tsx @@ -0,0 +1,291 @@ +/* eslint-disable @next/next/no-html-link-for-pages -- Protected views use freshly admitted full document navigation. */ +import type { AdminCopy } from '@/i18n/administration-catalogues'; +import type { NativePageSnapshot, PageInvitation, PageView } from '@/server/auth/page-contract'; + +import { FormIdentityFields, MutationFields } from './admin-forms'; +import { roleLabel } from './admin-labels'; + +function invitationStatus(value: PageInvitation, admin: AdminCopy): string { + return { + Pending: admin('invitations.pending'), + Accepted: admin('invitations.accepted'), + Revoked: admin('invitations.revoked'), + Expired: admin('invitations.expired'), + }[value.status]; +} +function deliveryStatus(value: PageInvitation, admin: AdminCopy): string { + return { + Queued: admin('invitations.queued'), + SubmissionAccepted: admin('invitations.submission_accepted'), + Failed: admin('invitations.failed'), + Unresolved: admin('invitations.uncertain'), + Unavailable: admin('invitations.unavailable'), + }[value.deliveryStatus]; +} +function InvitationFacts({ + value, + locale, + admin, +}: { + readonly value: PageInvitation; + readonly locale: string; + readonly admin: AdminCopy; +}) { + const expiresAt = new Intl.DateTimeFormat(locale, { + dateStyle: 'long', + timeStyle: 'short', + timeZone: 'UTC', + }).format(new Date(value.expiresAt)); + return ( + <> +
+
{admin('invitations.recipient')}
+
+ {value.email} +
+
{admin('invitations.offered_role')}
+
+ {value.offeredRole + ? roleLabel(value.offeredRole, admin) + : admin('invitations.role_unavailable')} +
+
{admin('invitations.scope')}
+
+ {value.organizationScoped + ? admin('scope.branch_member') + : admin('scope.institution_member')} +
+
{admin('invitations.state')}
+
{invitationStatus(value, admin)}
+
{admin('invitations.expires')}
+
+ +
+
{admin('invitations.delivery')}
+
{deliveryStatus(value, admin)}
+
+ {value.acceptanceDisposition === 'already_member' && ( +

{admin('invitations.already_member')}

+ )} + {value.acceptanceDisposition === 'created' &&

{admin('invitations.created_member')}

} + + ); +} + +export function InvitationDirectory({ + snapshot, + view, + locale, + admin, +}: { + readonly snapshot: NativePageSnapshot; + readonly view: Extract; + readonly locale: string; + readonly admin: AdminCopy; +}) { + const next = + view.hasNextPage && view.endCursor !== null + ? '/studio/invitations?' + + new URLSearchParams({ cursor: view.endCursor, limit: String(view.limit) }) + : null; + return ( + <> +
+

{admin('invitations.create')}

+

{admin('invitations.create_hint')}

+ {snapshot.capabilities?.canManageMemberships && view.roles.length > 0 ? ( + + +
+ + +

{admin('invitations.email_hint')}

+
+
+ + +
+ + + ) : ( +

{admin('invitations.no_roles')}

+ )} +
+
+

{admin('invitations.list')}

+ {view.items.length === 0 ? ( +

{admin('invitations.empty')}

+ ) : ( + + )} + +
+ + ); +} + +export function InvitationDetails({ + snapshot, + view, + locale, + admin, +}: { + readonly snapshot: NativePageSnapshot; + readonly view: Extract; + readonly locale: string; + readonly admin: AdminCopy; +}) { + const value = view.invitation; + const target = ['invitationId', value.invitationId] as const; + return ( + <> +

+ {admin('invitations.back')} +

+ +

{admin('invitations.delivery_hint')}

+ {(value.deliveryStatus === 'Unresolved' || value.deliveryStatus === 'Unavailable') && ( +

{admin('invitations.uncertain_hint')}

+ )} + {!value.inCurrentScope &&

{admin('invitations.other_scope')}

} + {snapshot.capabilities?.canManageMemberships && value.inCurrentScope && ( + <> + {value.canReissue ? ( +
+

{admin('invitations.reissue')}

+

{admin('invitations.reissue_hint')}

+
+ + + +
+ ) : ( + value.canRevoke &&

{admin('invitations.role_changed')}

+ )} + {value.canRevoke && ( +
+ {admin('invitations.revoke')} +

{admin('invitations.revoke_hint')}

+
+ + + +
+ )} + + )} + + ); +} + +export function InvitationPolicy({ + snapshot, + view, + admin, +}: { + readonly snapshot: NativePageSnapshot; + readonly view: Extract; + readonly admin: AdminCopy; +}) { + const policy = view.policy; + return ( + <> +

{admin('invitations.policy_hint')}

+

+ {policy.present ? admin('invitations.policy_stored') : admin('invitations.policy_default')} +

+ {policy.canEdit && snapshot.scopeKind === 'tenant' ? ( +
+ + + {policy.present && policy.version !== null && ( + + )} +
+ + +

{admin('invitations.policy_range')}

+
+ + + ) : ( + <> +

+ {admin('invitations.policy_days')}: {policy.days} +

+

{admin('invitations.policy_read_only')}

+ + )} + + ); +} diff --git a/frontend/apps/web/src/components/auth/admin-pages.test.tsx b/frontend/apps/web/src/components/auth/admin-pages.test.tsx index 240df7216..05b766f64 100644 --- a/frontend/apps/web/src/components/auth/admin-pages.test.tsx +++ b/frontend/apps/web/src/components/auth/admin-pages.test.tsx @@ -98,6 +98,25 @@ describe('prepared administration views', () => { } }, ); + it.each(['en', 'tr'])( + 'names appearance and invitation lifetime in the actual granted settings permission (%s)', + (locale) => { + const base = roleView(); + const view: Extract = { + ...base, + role: { ...base.role, grants: [{ scope: 'Tenant', key: 'tenancy.setting.write' }] }, + }; + const { document } = documentFor(view, { + ownProfile: { displayName: null, preferredLocale: locale, version: '1' }, + }); + const text = + locale === 'en' + ? 'Manage institution appearance and invitation lifetime' + : 'Kurum görünümünü ve davet süresini yönetme'; + expect(document.body.textContent).toContain(text); + expect(document.body.textContent).not.toContain('tenancy.setting.write'); + }, + ); it('uses site fallback for missing/disabled preference and never uses the viewed member preference', () => { const view = { kind: 'member', diff --git a/frontend/apps/web/src/components/auth/history.ts b/frontend/apps/web/src/components/auth/history.ts index 6517ff0a3..6bf4769fd 100644 --- a/frontend/apps/web/src/components/auth/history.ts +++ b/frontend/apps/web/src/components/auth/history.ts @@ -3,4 +3,4 @@ // Native documents always carry a fixed GET recovery target: reloading a POST // result could replay a mutation. The inline guard independently closes the DOM // attribute to the same finite screens; invalid attributes fall back to sign-in. -export const BFF_HISTORY_SOURCE = `(()=>{let leaving=false;addEventListener('pagehide',()=>{leaving=true;document.documentElement.style.visibility='hidden'});addEventListener('pageshow',e=>{if(e.persisted||leaving){document.documentElement.style.visibility='hidden';const r=document.documentElement.getAttribute('data-bff-history-recovery');if(r===null){location.reload()}else{const allowed=['/','/studio','/studio/members','/studio/roles','/portal','/portal/profile','/api/auth/entry','/api/auth/signin'];location.replace(allowed.includes(r)||/^\\/studio\\/(?:members|roles)\\/[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(r)?r:'/api/auth/signin')}}})})();`; +export const BFF_HISTORY_SOURCE = `(()=>{let leaving=false;addEventListener('pagehide',()=>{leaving=true;document.documentElement.style.visibility='hidden'});addEventListener('pageshow',e=>{if(e.persisted||leaving){document.documentElement.style.visibility='hidden';const r=document.documentElement.getAttribute('data-bff-history-recovery');if(r===null){location.reload()}else{const allowed=['/','/studio','/studio/members','/studio/roles','/studio/invitations','/studio/invitation-policy','/portal','/portal/profile','/api/auth/entry','/api/auth/signin'];location.replace(allowed.includes(r)||/^\\/studio\\/(?:members|roles|invitations)\\/[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(r)?r:'/api/auth/signin')}}})})();`; diff --git a/frontend/apps/web/src/components/auth/shell.tsx b/frontend/apps/web/src/components/auth/shell.tsx index 5fea15894..a1cef01ab 100644 --- a/frontend/apps/web/src/components/auth/shell.tsx +++ b/frontend/apps/web/src/components/auth/shell.tsx @@ -12,6 +12,7 @@ import { optionalPreparedPage } from '@/server/auth/page'; import type { NativePageSnapshot, PageView } from '@/server/auth/page-contract'; import { MutationFields, ProfileFields, ProfileValues } from './admin-forms'; +import { InvitationDetails, InvitationDirectory, InvitationPolicy } from './admin-invitations'; import { MemberDetails, MemberDirectory } from './admin-members'; import { RoleDetails, RoleDirectory } from './admin-roles'; import { SessionControls } from './session'; @@ -26,6 +27,9 @@ function title(kind: PageKind, admin: AdminCopy, profile: ProfileCopy, copy: Aut roles: admin('roles.title'), role: admin('roles.detail_title'), profile: profile('title'), + invitations: admin('invitations.title'), + invitation: admin('invitations.detail_title'), + invitation_policy: admin('invitations.policy_title'), }; return titles[kind]; } @@ -44,6 +48,12 @@ function PageContents({ readonly profile: ProfileCopy; }) { switch (view.kind) { + case 'invitations': + return ; + case 'invitation': + return ; + case 'invitation_policy': + return ; case 'members': return ; case 'member': @@ -165,6 +175,12 @@ export function PreparedAccountPage({ {admin('navigation.members')} )} {capabilities?.canReadRoles && {admin('navigation.roles')}} + {capabilities?.canManageMemberships && ( + {admin('navigation.invitations')} + )} + {snapshot.scopeKind === 'tenant' && ( + {admin('navigation.invitation_policy')} + )} {admin('navigation.profile')} {admin('navigation.contexts')} {copy('shell.catalog')} @@ -184,7 +200,11 @@ export function PreparedAccountPage({

{snapshot.outcome.reason === 'invalid_cursor' diff --git a/frontend/apps/web/src/i18n/messages/en/admin.json b/frontend/apps/web/src/i18n/messages/en/admin.json index ab3c5ccb6..7242a4fad 100644 --- a/frontend/apps/web/src/i18n/messages/en/admin.json +++ b/frontend/apps/web/src/i18n/messages/en/admin.json @@ -4,7 +4,9 @@ "members": "Members", "roles": "Roles", "profile": "My profile", - "contexts": "Change account access" + "contexts": "Change account access", + "invitations": "Invitations", + "invitation_policy": "Invitation lifetime" }, "scope": { "tenant": "Institution-wide access", @@ -39,7 +41,11 @@ "revoke_hint": "Revoking ends this membership permanently. It cannot be reactivated from this page.", "suspend_hint": "Suspension pauses access while preserving the membership and its assignments." }, - "status": { "active": "Active", "suspended": "Suspended", "revoked": "Revoked" }, + "status": { + "active": "Active", + "suspended": "Suspended", + "revoked": "Revoked" + }, "roles": { "title": "Roles", "detail_title": "Role details", @@ -74,7 +80,7 @@ "organization_profiles": "Edit branch membership profiles", "organizations": "Manage organizations", "locales": "Manage institution languages", - "settings": "Manage institution appearance", + "settings": "Manage institution appearance and invitation lifetime", "unavailable": "Permission label unavailable" }, "failure": { @@ -86,5 +92,54 @@ "busy": "Too many requests are in progress. Wait before trying again.", "retry": "Check this view again", "retry_after": "Seconds to wait before trying again" + }, + "invitations": { + "title": "Invitations", + "detail_title": "Invitation details", + "create": "Invite a person", + "create_hint": "Send an invitation to this institution or branch with one offered role. Email is sent automatically.", + "recipient": "Recipient email", + "email_hint": "Use the email address the person will verify on their account.", + "offered_role": "Offered role", + "choose_role": "Choose an offered role", + "send": "Create and email invitation", + "no_roles": "No roles are currently available to offer with your access.", + "list": "Invitation history", + "empty": "No invitations are available in this view.", + "next": "Next invitations", + "back": "Back to invitations", + "scope": "Membership scope", + "state": "Invitation status", + "expires": "Expires at (UTC)", + "delivery": "Email submission status", + "pending": "Pending acceptance", + "accepted": "Accepted", + "revoked": "Revoked", + "expired": "Expired", + "queued": "Queued", + "submission_accepted": "Submission accepted by the mail server", + "failed": "Submission failed", + "uncertain": "Submission uncertain", + "unavailable": "Submission status unavailable", + "delivery_hint": "Mail server acceptance does not confirm inbox delivery or that the email was read.", + "uncertain_hint": "The email may still arrive. Unfinished work continues to use delivery capacity; an unknown result is not a confirmed failure.", + "already_member": "The person already had an active membership. Their existing roles and profile were preserved; the offered role was not added.", + "created_member": "A new membership was created with the offered role.", + "role_unavailable": "Offered role label is no longer available with your access.", + "role_changed": "The offered role is no longer available with its original version. Revoke this offer and create a new invitation to choose the current role.", + "other_scope": "This invitation is outside your current access context. Change account access to manage it.", + "reissue": "Replace and email invitation", + "reissue_hint": "This replaces the current code and expiry. An older email can still arrive, but its code will no longer work. Unfinished earlier email work retains its delivery slot.", + "revoke": "Revoke invitation", + "revoke_hint": "This ends the offer. An email already in progress may still arrive, but it cannot grant membership.", + "policy_title": "Invitation lifetime", + "policy_hint": "Choose the lifetime for future invitations and explicit replacements. Existing invitation expiry times stay unchanged.", + "policy_stored": "This institution has a saved lifetime policy.", + "policy_default": "No policy has been saved. The default lifetime is 14 days.", + "policy_days": "Lifetime in days", + "policy_range": "Enter a whole number from 1 to 30.", + "policy_save": "Save invitation lifetime", + "policy_read_only": "You can view this policy. Editing requires institution-wide settings permission.", + "required": "Required" } } diff --git a/frontend/apps/web/src/i18n/messages/en/invitation.json b/frontend/apps/web/src/i18n/messages/en/invitation.json new file mode 100644 index 000000000..aac090315 --- /dev/null +++ b/frontend/apps/web/src/i18n/messages/en/invitation.json @@ -0,0 +1,60 @@ +{ + "common": { + "title": "Invitation", + "skip": "Skip to content", + "cancel": "Cancel invitation", + "home": "Return to institution site", + "signout": "Sign out to use another account", + "required": "Required" + }, + "landing": { + "title": "Open your invitation", + "description": "Continue with the invitation from your email, or paste the complete code from that email below.", + "code_label": "Invitation code", + "continue": "Continue", + "no_script": "JavaScript is off. Open the clean /invitations address and paste the separate code from the email. A fragment in the address cannot be removed automatically without JavaScript.", + "invalid": "This invitation code is not valid. Paste the complete code from the original email." + }, + "confirm": { + "signin": "Sign in to the account that received the invitation. You can create an account on the sign-in page.", + "signin_action": "Sign in or create an account", + "bootstrap": "Create your LearnStack identity to continue. This does not yet join the institution.", + "bootstrap_action": "Create my LearnStack identity", + "inspect": "Check this invitation using your account’s current verified email. If you already accepted it, check the original result.", + "inspect_action": "Check invitation", + "forbidden": "This account cannot currently confirm the invitation. Verify your email, or sign out and use the account that received the invitation. If you accepted it before, check the original result.", + "verify_action": "Verify my email", + "verification_hint": "After completing email verification, return here and check the invitation again. Verification alone does not accept it.", + "ready": "Confirm the verified account and the offered membership before accepting.", + "email": "Your verified email", + "institution": "Institution", + "branch": "Branch", + "tenant_scope": "Institution-wide", + "offered_role": "Offered role", + "expires": "Invitation expires", + "accept": "Accept invitation", + "accepted": "Your invitation is accepted and the membership is created.", + "already_member": "You already have an active membership in this scope. The invitation is accepted; your existing roles and profile are unchanged.", + "session_ready": "Your session is ready. Continue to the institution site.", + "session_unavailable": "Your membership is confirmed, but a session could not be opened. Check the result to try session admission again.", + "result_action": "Check acceptance result", + "continue": "Continue to your account" + }, + "failure": { + "unknown": "The acceptance outcome could not be confirmed. Check the result or check the invitation again; neither check submits a new acceptance.", + "unavailable": "The invitation service is temporarily unavailable. Try again when you are ready.", + "expired": "This invitation could not be inspected. If you already accepted it, check the original result. Otherwise, reopen the original email or ask the institution for a new invitation.", + "account_change": "The signed-in account changed. Your existing session has been preserved. Sign out explicitly before continuing with another account.", + "restart": "Open a new invitation", + "retry": "Try again" + }, + "roles": { + "tenant_admin": "Institution administrator", + "instructor": "Instructor", + "learner": "Learner", + "guardian": "Guardian", + "editor": "Editor", + "org_admin": "Branch administrator", + "org_instructor": "Branch instructor" + } +} diff --git a/frontend/apps/web/src/i18n/messages/tr/admin.json b/frontend/apps/web/src/i18n/messages/tr/admin.json index 64e86c48c..9eb348fdf 100644 --- a/frontend/apps/web/src/i18n/messages/tr/admin.json +++ b/frontend/apps/web/src/i18n/messages/tr/admin.json @@ -4,7 +4,9 @@ "members": "Üyeler", "roles": "Roller", "profile": "Profilim", - "contexts": "Hesap erişimini değiştir" + "contexts": "Hesap erişimini değiştir", + "invitations": "Davetler", + "invitation_policy": "Davet geçerlilik süresi" }, "scope": { "tenant": "Kurum genelinde erişim", @@ -39,7 +41,11 @@ "revoke_hint": "Sonlandırma bu üyeliği kalıcı olarak bitirir. Üyelik bu sayfadan yeniden etkinleştirilemez.", "suspend_hint": "Askıya alma, üyeliği ve rol atamalarını koruyarak erişimi duraklatır." }, - "status": { "active": "Etkin", "suspended": "Askıda", "revoked": "Sonlandırılmış" }, + "status": { + "active": "Etkin", + "suspended": "Askıda", + "revoked": "Sonlandırılmış" + }, "roles": { "title": "Roller", "detail_title": "Rol ayrıntıları", @@ -74,7 +80,7 @@ "organization_profiles": "Şube üyelik profillerini düzenleme", "organizations": "Organizasyonları yönetme", "locales": "Kurum dillerini yönetme", - "settings": "Kurum görünümünü yönetme", + "settings": "Kurum görünümünü ve davet süresini yönetme", "unavailable": "Yetki adı kullanılamıyor" }, "failure": { @@ -86,5 +92,54 @@ "busy": "Çok fazla istek işleniyor. Tekrar denemeden önce bekleyin.", "retry": "Bu görünümü tekrar kontrol et", "retry_after": "Tekrar denemeden önce beklenecek saniye" + }, + "invitations": { + "title": "Davetler", + "detail_title": "Davet ayrıntıları", + "create": "Bir kişiyi davet et", + "create_hint": "Bu kurum veya şube için tek bir rol teklif ederek davet oluşturun. E-posta otomatik gönderilir.", + "recipient": "Alıcının e-posta adresi", + "email_hint": "Kişinin hesabında doğrulayacağı e-posta adresini kullanın.", + "offered_role": "Teklif edilen rol", + "choose_role": "Teklif edilecek rolü seçin", + "send": "Daveti oluştur ve e-posta gönder", + "no_roles": "Mevcut erişiminizle teklif edebileceğiniz bir rol bulunmuyor.", + "list": "Davet geçmişi", + "empty": "Bu görünümde davet bulunmuyor.", + "next": "Sonraki davetler", + "back": "Davetlere dön", + "scope": "Üyelik kapsamı", + "state": "Davet durumu", + "expires": "Bitiş zamanı (UTC)", + "delivery": "E-posta gönderim durumu", + "pending": "Kabul bekliyor", + "accepted": "Kabul edildi", + "revoked": "İptal edildi", + "expired": "Süresi doldu", + "queued": "Sırada", + "submission_accepted": "Posta sunucusu gönderimi kabul etti", + "failed": "Gönderim başarısız", + "uncertain": "Gönderim sonucu belirsiz", + "unavailable": "Gönderim durumu alınamadı", + "delivery_hint": "Posta sunucusunun kabulü, e-postanın gelen kutusuna ulaştığını veya okunduğunu doğrulamaz.", + "uncertain_hint": "E-posta yine de ulaşabilir. Tamamlanmamış işlem gönderim kapasitesini kullanmaya devam eder; bilinmeyen sonuç kesinleşmiş başarısızlık değildir.", + "already_member": "Kişinin zaten etkin bir üyeliği vardı. Mevcut rolleri ve profili korundu; teklif edilen rol eklenmedi.", + "created_member": "Teklif edilen rolle yeni bir üyelik oluşturuldu.", + "role_unavailable": "Teklif edilen rolün adı mevcut erişiminizle artık görüntülenemiyor.", + "role_changed": "Teklif edilen rolün özgün sürümü artık kullanılamıyor. Güncel rolü seçmek için bu daveti iptal edip yeni bir davet oluşturun.", + "other_scope": "Bu davet mevcut erişim bağlamınızın dışında. Yönetmek için hesap erişiminizi değiştirin.", + "reissue": "Daveti yenile ve e-posta gönder", + "reissue_hint": "Bu işlem mevcut kodu ve bitiş zamanını değiştirir. Eski bir e-posta yine de ulaşabilir ancak kodu artık çalışmaz. Tamamlanmamış eski e-posta işlemi gönderim kapasitesindeki yerini korur.", + "revoke": "Daveti iptal et", + "revoke_hint": "Bu işlem teklifi sonlandırır. Gönderimi başlamış bir e-posta yine de ulaşabilir ancak üyelik sağlayamaz.", + "policy_title": "Davet geçerlilik süresi", + "policy_hint": "Yeni ve açıkça yenilenen davetlerin geçerlilik süresini seçin. Mevcut davetlerin bitiş zamanları değişmez.", + "policy_stored": "Bu kurumun kayıtlı bir geçerlilik süresi kuralı var.", + "policy_default": "Kayıtlı bir kural yok. Varsayılan geçerlilik süresi 14 gündür.", + "policy_days": "Gün olarak geçerlilik süresi", + "policy_range": "1 ile 30 arasında bir tam sayı girin.", + "policy_save": "Davet geçerlilik süresini kaydet", + "policy_read_only": "Bu kuralı görüntüleyebilirsiniz. Düzenlemek için kurum genelinde ayar yetkisi gerekir.", + "required": "Zorunlu" } } diff --git a/frontend/apps/web/src/i18n/messages/tr/invitation.json b/frontend/apps/web/src/i18n/messages/tr/invitation.json new file mode 100644 index 000000000..f744afd58 --- /dev/null +++ b/frontend/apps/web/src/i18n/messages/tr/invitation.json @@ -0,0 +1,60 @@ +{ + "common": { + "title": "Davet", + "skip": "İçeriğe geç", + "cancel": "Daveti iptal et", + "home": "Kurum sitesine dön", + "signout": "Başka bir hesap kullanmak için çıkış yap", + "required": "Zorunlu" + }, + "landing": { + "title": "Davetinizi açın", + "description": "E-postanızdaki davetle devam edin veya aynı e-postadaki kodun tamamını aşağıya yapıştırın.", + "code_label": "Davet kodu", + "continue": "Devam et", + "no_script": "JavaScript kapalı. Temiz /invitations adresini açıp e-postadaki ayrı kodu yapıştırın. Adresteki parça, JavaScript olmadan otomatik olarak kaldırılamaz.", + "invalid": "Bu davet kodu geçerli değil. İlk e-postadaki kodun tamamını yapıştırın." + }, + "confirm": { + "signin": "Davetin gönderildiği hesapla giriş yapın. Giriş sayfasından yeni bir hesap da oluşturabilirsiniz.", + "signin_action": "Giriş yap veya hesap oluştur", + "bootstrap": "Devam etmek için LearnStack kimliğinizi oluşturun. Bu işlem henüz kuruma üye olmanızı sağlamaz.", + "bootstrap_action": "LearnStack kimliğimi oluştur", + "inspect": "Daveti hesabınızın güncel doğrulanmış e-posta adresiyle kontrol edin. Daha önce kabul ettiyseniz önceki kabul sonucunu kontrol edin.", + "inspect_action": "Daveti kontrol et", + "forbidden": "Bu hesap şu anda daveti doğrulayamıyor. E-postanızı doğrulayın veya çıkış yapıp davetin gönderildiği hesapla giriş yapın. Daha önce kabul ettiyseniz önceki kabul sonucunu kontrol edin.", + "verify_action": "E-postamı doğrula", + "verification_hint": "E-posta doğrulamasını tamamladıktan sonra buraya dönüp daveti yeniden kontrol edin. E-posta doğrulaması tek başına daveti kabul etmez.", + "ready": "Kabul etmeden önce doğrulanmış hesabınızı ve sunulan üyeliği kontrol edin.", + "email": "Doğrulanmış e-posta adresiniz", + "institution": "Kurum", + "branch": "Şube", + "tenant_scope": "Kurum genelinde", + "offered_role": "Sunulan rol", + "expires": "Davetin son geçerlilik zamanı", + "accept": "Daveti kabul et", + "accepted": "Davetiniz kabul edildi ve üyeliğiniz oluşturuldu.", + "already_member": "Bu kapsamda zaten etkin bir üyeliğiniz var. Davet kabul edildi; mevcut rolleriniz ve profiliniz değişmedi.", + "session_ready": "Oturumunuz hazır. Kurum sitesinden devam edebilirsiniz.", + "session_unavailable": "Üyeliğiniz onaylandı ancak oturum açılamadı. Oturum açmayı yeniden denemek için kabul sonucunu kontrol edin.", + "result_action": "Kabul sonucunu kontrol et", + "continue": "Hesabınıza devam edin" + }, + "failure": { + "unknown": "Kabul işleminin sonucu doğrulanamadı. Sonucu kontrol edin veya daveti yeniden inceleyin; bu kontroller yeni bir kabul isteği göndermez.", + "unavailable": "Davet hizmeti geçici olarak kullanılamıyor. Hazır olduğunuzda yeniden deneyin.", + "expired": "Bu davet incelenemedi. Daha önce kabul ettiyseniz önceki kabul sonucunu kontrol edin. Aksi durumda ilk e-postayı yeniden açın veya kurumdan yeni bir davet isteyin.", + "account_change": "Giriş yapılan hesap değişti. Mevcut oturumunuz korundu. Başka bir hesapla devam etmeden önce açıkça çıkış yapın.", + "restart": "Yeni bir davet aç", + "retry": "Yeniden dene" + }, + "roles": { + "tenant_admin": "Kurum yöneticisi", + "instructor": "Eğitmen", + "learner": "Öğrenci", + "guardian": "Veli", + "editor": "Editör", + "org_admin": "Şube yöneticisi", + "org_instructor": "Şube eğitmeni" + } +} diff --git a/frontend/apps/web/src/server/auth/cookies.ts b/frontend/apps/web/src/server/auth/cookies.ts index efe7905ba..afae95f39 100644 --- a/frontend/apps/web/src/server/auth/cookies.ts +++ b/frontend/apps/web/src/server/auth/cookies.ts @@ -6,6 +6,7 @@ export { AUTH_INPUT_LIMIT } from './http-paths.js'; export const BFF_COOKIES = Object.freeze({ session: '__Host-ls-session', entry: '__Host-ls-entry', + invitation: '__Host-ls-invitation', browser: '__Host-ls-browser', csrf: '__Host-ls-csrf', login: '__Host-ls-login-', @@ -15,6 +16,7 @@ const prefix = '__Host-ls-'; const fixed = new Set([ BFF_COOKIES.session, BFF_COOKIES.entry, + BFF_COOKIES.invitation, BFF_COOKIES.browser, BFF_COOKIES.csrf, ]); diff --git a/frontend/apps/web/src/server/auth/entry-cursor.ts b/frontend/apps/web/src/server/auth/entry-cursor.ts index 6e2c433d8..08561710a 100644 --- a/frontend/apps/web/src/server/auth/entry-cursor.ts +++ b/frontend/apps/web/src/server/auth/entry-cursor.ts @@ -7,7 +7,7 @@ type EntryCursorBinding = Readonly<{ id: string; host: string; epoch: string; - purpose?: 'members' | 'scopes'; + purpose?: 'members' | 'scopes' | 'invitations'; }>; const VERSION = 1; const MAX_PLAINTEXT_BYTES = 739; diff --git a/frontend/apps/web/src/server/auth/html.ts b/frontend/apps/web/src/server/auth/html.ts index b7dd9b4ff..0b8b2b3a5 100644 --- a/frontend/apps/web/src/server/auth/html.ts +++ b/frontend/apps/web/src/server/auth/html.ts @@ -111,12 +111,24 @@ function mutationEdits( roleIds: copy('mutation.edit_role_ids'), grants: copy('mutation.edit_grants'), manifestVersion: copy('mutation.edit_manifest_version'), + email: admin('invitations.recipient'), + offeredRole: admin('invitations.offered_role'), + days: admin('invitations.policy_days'), + presence: admin('invitations.policy_title'), }; const edits = page.fields .filter(([name]) => Object.hasOwn(labels, name)) .map(([name, value]) => { let visible = value; - if (name === 'roleIds') { + if (name === 'offeredRole') { + const role = page.roleLabels?.find((role) => role.roleId === value.split('.', 1)[0]); + visible = role ? roleLabel(role, admin) : admin('invitations.role_unavailable'); + } else if (name === 'presence') { + visible = + value === 'absent' + ? admin('invitations.policy_default') + : admin('invitations.policy_stored'); + } else if (name === 'roleIds') { const ids: unknown = JSON.parse(value); const named = Array.isArray(ids) ? ids.map((id) => page.roleLabels?.find((role) => role.roleId === id)) @@ -149,6 +161,9 @@ function mutationContent( locale: string, site: PageSite, ): string { + if (page.reason === 'validation' && page.action.startsWith('/api/studio/invitation-')) { + return `

${escape(copy('mutation.validation'))}

${mutationEdits(page, copy, locale)}

${escape(copy('mutation.lookup'))}

`; + } if (page.reason === 'validation') { const { profile } = createAdministrationTranslator(locale); const values = new Map(page.fields); diff --git a/frontend/apps/web/src/server/auth/http-admission.ts b/frontend/apps/web/src/server/auth/http-admission.ts index 102e85084..7ec2f6484 100644 --- a/frontend/apps/web/src/server/auth/http-admission.ts +++ b/frontend/apps/web/src/server/auth/http-admission.ts @@ -57,7 +57,9 @@ export async function admitBffHttp( if (location?.startsWith('/')) { if ( !isNativeReturnPath(location) && - !['/api/auth/entry', '/api/auth/signin'].includes(location) + !['/api/auth/entry', '/api/auth/signin', '/invitations', '/invitations/confirm'].includes( + location, + ) ) throw new Error('BFF response destination unavailable'); headers.set('location', 'https://' + context.host + location); diff --git a/frontend/apps/web/src/server/auth/http-forms.ts b/frontend/apps/web/src/server/auth/http-forms.ts index 9ecc07771..8ed317f5e 100644 --- a/frontend/apps/web/src/server/auth/http-forms.ts +++ b/frontend/apps/web/src/server/auth/http-forms.ts @@ -121,6 +121,12 @@ export class BffProfileInputError extends BffInputError { super(); } } +/** User-editable invitation values are recoverable after proof/target grammar succeeds. */ +export class BffInvitationInputError extends BffInputError { + constructor(readonly invalidFields: readonly ('email' | 'offeredRole' | 'days')[]) { + super(); + } +} export const BFF_ADMIN_FORMS: Readonly> = Object.freeze({ '/api/studio/membership-profile': [...common, 'membershipId', 'displayName', 'preferredLocale'], '/api/studio/membership-roles': [...common, 'membershipId', 'roleIds'], @@ -133,6 +139,10 @@ export const BFF_ADMIN_FORMS: Readonly> = Obje 'manifestFingerprint', ], '/api/account/profile': [...common, 'displayName', 'preferredLocale'], + '/api/studio/invitation-create': ['csrfToken', 'idempotencyKey', 'email', 'offeredRole'], + '/api/studio/invitation-revoke': [...common, 'invitationId'], + '/api/studio/invitation-reissue': [...common, 'invitationId'], + '/api/studio/invitation-policy': ['csrfToken', 'idempotencyKey', 'presence', 'version', 'days'], }); function profile(fields: URLSearchParams) { const invalid: ProfileField[] = []; @@ -159,15 +169,95 @@ export function bffAdministrationForm( path: string, fields: URLSearchParams, ): Readonly<{ operation: NativeOperation; destination: string }> { - const version = required(fields, 'version'); - const idempotencyKey = required(fields, 'idempotencyKey'); - if ( - !/^(0|[1-9][0-9]{0,18})$/.test(version) || - (version.length === 19 && version > '9223372036854775807') || - !/^[\x21-\x7e]{8,128}$/.test(idempotencyKey) - ) + const allowed = BFF_ADMIN_FORMS[path]; + if (!allowed) throw new BffInputError(); + const names = [...fields.keys()]; + if (new Set(names).size !== names.length || names.some((name) => !allowed.includes(name))) throw new BffInputError(); - const write = { version, idempotencyKey }; + const idempotencyKey = required(fields, 'idempotencyKey'); + if (!/^[\x21-\x7e]{8,128}$/.test(idempotencyKey)) throw new BffInputError(); + const readVersion = (name = 'version') => { + const value = required(fields, name); + if ( + !/^(0|[1-9][0-9]{0,18})$/.test(value) || + (value.length === 19 && value > '9223372036854775807') + ) + throw new BffInputError(); + return value; + }; + if (path === '/api/studio/invitation-create') { + const email = required(fields, 'email'); + if ( + [...email].some((character) => { + const point = character.codePointAt(0)!; + return point < 32 || (point >= 127 && point <= 159); + }) + ) + throw new BffInputError(); + const offeredRole = required(fields, 'offeredRole'); + const invalid: ('email' | 'offeredRole')[] = []; + if ( + !email || + email.length > 254 || + !/^[\x21-\x7e]+$/.test(email) || + !/^[^@]+@[^@]+$/.test(email) + ) + invalid.push('email'); + if (!offeredRole) invalid.push('offeredRole'); + if (invalid.length) throw new BffInvitationInputError(Object.freeze(invalid)); + const choice = offeredRole.split('.'); + if (choice.length !== 2) throw new BffInputError(); + const roleId = id(choice[0]!); + const roleVersion = choice[1]!; + if ( + !/^(0|[1-9][0-9]{0,18})$/.test(roleVersion) || + (roleVersion.length === 19 && roleVersion > '9223372036854775807') + ) + throw new BffInputError(); + return { + operation: { + kind: 'createInvitation', + invitation: { email, roleId, roleVersion }, + idempotencyKey, + }, + destination: '/studio/invitations', + }; + } + if (path === '/api/studio/invitation-policy') { + const presence = required(fields, 'presence'); + const days = required(fields, 'days'); + if ( + (presence !== 'absent' && presence !== 'present') || + (presence === 'absent' && fields.has('version')) + ) + throw new BffInputError(); + const precondition = + presence === 'absent' + ? ({ kind: 'absent' } as const) + : ({ kind: 'present', version: readVersion() } as const); + if (!/^(?:[1-9]|[12][0-9]|30)$/.test(days)) throw new BffInvitationInputError(['days']); + return { + operation: { + kind: 'setInvitationLifetime', + days: Number(days), + idempotencyKey, + precondition, + }, + destination: '/studio/invitation-policy', + }; + } + const write = { version: readVersion(), idempotencyKey }; + if (path === '/api/studio/invitation-revoke' || path === '/api/studio/invitation-reissue') { + const invitationId = id(required(fields, 'invitationId')); + return { + operation: { + kind: path === '/api/studio/invitation-revoke' ? 'revokeInvitation' : 'reissueInvitation', + ...write, + id: invitationId, + }, + destination: '/studio/invitations/' + invitationId, + }; + } if (path === '/api/account/profile') return { operation: { kind: 'setOwnProfile', ...write, profile: profile(fields) }, diff --git a/frontend/apps/web/src/server/auth/http-paths.ts b/frontend/apps/web/src/server/auth/http-paths.ts index 17857ea65..cda314758 100644 --- a/frontend/apps/web/src/server/auth/http-paths.ts +++ b/frontend/apps/web/src/server/auth/http-paths.ts @@ -2,6 +2,16 @@ export const AUTH_INPUT_LIMIT = 16 * 1024; /** The only native POST exemptions, gated off until the complete BFF is installed. */ export const BFF_POST_PATHS = Object.freeze([ + '/api/auth/invitation/capture', + '/api/auth/invitation/inspect', + '/api/auth/invitation/verify', + '/api/auth/invitation/accept', + '/api/auth/invitation/result', + '/api/auth/invitation/cancel', + '/api/studio/invitation-create', + '/api/studio/invitation-revoke', + '/api/studio/invitation-reissue', + '/api/studio/invitation-policy', '/api/auth/signin/keycloak', '/api/auth/signout', '/api/auth/extend', diff --git a/frontend/apps/web/src/server/auth/http-protocol.ts b/frontend/apps/web/src/server/auth/http-protocol.ts index cd19dc459..751cdc89d 100644 --- a/frontend/apps/web/src/server/auth/http-protocol.ts +++ b/frontend/apps/web/src/server/auth/http-protocol.ts @@ -11,6 +11,7 @@ import { import { BFF_ADMIN_FORMS, BffProfileInputError, + BffInvitationInputError, bffAdministrationForm, bffContextHint, bffCursor, @@ -313,6 +314,9 @@ export function createBffHttpProtocol(store: BffSessionStore, preparation?: Nati const httpSignal = request.signal; let postDeadline: AbortSignal | undefined; const path = request.target.split('?', 1)[0]!; + // Recipient actions belong only to the pre-Next native handler. + if (path.startsWith('/api/auth/invitation/')) + return result(request, { code: 'not_found' }, 404); // An inert public query is not a logout action. Only an owned proof-cookie // family selects the root return protocol; unrecognized states touch nothing. const rootFields = @@ -448,6 +452,8 @@ export function createBffHttpProtocol(store: BffSessionStore, preparation?: Nati 'code', 'iss', 'session_state', + 'kc_action', + 'kc_action_status', 'error', 'error_description', 'error_uri', @@ -467,16 +473,31 @@ export function createBffHttpProtocol(store: BffSessionStore, preparation?: Nati signal: request.signal, }); if (request.signal.aborted) throw new BffInputError(); - return redirect(request, '/api/auth/entry', [ - bffCookie(BFF_COOKIES.entry, completed.handle, 300), - bffCookie(BFF_COOKIES.browser, completed.browser, cookieLife), - clearBffCookie(BFF_COOKIES.login + state), - ]); + return redirect( + request, + completed.invitation ? '/invitations/confirm' : '/api/auth/entry', + [ + bffCookie(BFF_COOKIES.entry, completed.handle, 300), + bffCookie(BFF_COOKIES.browser, completed.browser, cookieLife), + clearBffCookie(BFF_COOKIES.login + state), + ], + ); } bffQuery(request.target, []); exactBffOrigin(request); if (path === '/api/auth/signin/keycloak') { - const fields = await bffForm(request, ['csrfToken', 'returnTo', 'contextHint']); + const fields = await bffForm(request, [ + 'csrfToken', + 'returnTo', + 'contextHint', + 'invitation', + ]); + const invitation = fields.get('invitation'); + if ( + invitation !== null && + (invitation !== 'continue' || !cookies.has(BFF_COOKIES.invitation)) + ) + throw new BffInputError(); if (!bffSecretEquals(fields.get('csrfToken'), cookies.get(BFF_COOKIES.csrf))) return result(request, { code: 'csrf' }, 403); const started = await store.start({ @@ -486,6 +507,9 @@ export function createBffHttpProtocol(store: BffSessionStore, preparation?: Nati handle: cookies.get(BFF_COOKIES.session), returnPath: returnPath(fields.get('returnTo')), contextHint: bffContextHint(fields.get('contextHint')), + ...(invitation === 'continue' + ? { invitation: cookies.get(BFF_COOKIES.invitation)! } + : {}), signal: request.signal, }); if (request.signal.aborted) throw new BffInputError(); @@ -511,6 +535,11 @@ export function createBffHttpProtocol(store: BffSessionStore, preparation?: Nati if (switching && !bffSecretEquals(csrf, store.peek(selectedBinding)?.csrfToken)) return result(request, { code: 'csrf' }, 403); if (creating) { + const invitationBinding = binding(request, cookies, BFF_COOKIES.invitation); + if (invitationBinding && store.invitationEntry(invitationBinding, selectedBinding)) { + await store.create(selectedBinding, request.source, csrf, request.signal); + return redirect(request, '/invitations/confirm', []); + } const snapshot = await store.preparePage(selectedBinding, request.source, { kind: 'create', ticket: ticket(request), @@ -573,7 +602,11 @@ export function createBffHttpProtocol(store: BffSessionStore, preparation?: Nati try { form = bffAdministrationForm(path, fields); } catch (error) { - if (!(error instanceof BffProfileInputError)) throw error; + if ( + !(error instanceof BffProfileInputError) && + !(error instanceof BffInvitationInputError) + ) + throw error; return await actionDocument( request, render, @@ -582,9 +615,13 @@ export function createBffHttpProtocol(store: BffSessionStore, preparation?: Nati kind: 'mutation_error', action: path, destination: - path === '/api/account/profile' - ? '/portal/profile' - : '/studio/members/' + fields.get('membershipId')!, + path === '/api/studio/invitation-create' + ? '/studio/invitations' + : path === '/api/studio/invitation-policy' + ? '/studio/invitation-policy' + : path === '/api/account/profile' + ? '/portal/profile' + : '/studio/members/' + fields.get('membershipId')!, fields: [...fields.entries()], reason: 'validation', invalidFields: error.invalidFields, @@ -635,21 +672,39 @@ export function createBffHttpProtocol(store: BffSessionStore, preparation?: Nati 503, ); } - if (outcome.kind === 'success') return redirect(request, form.destination, []); + if (outcome.kind === 'success') { + if (path === '/api/studio/invitation-create') { + const location = outcome.location; + const match = + /^\/api\/v1\/identity\/invitations\/([0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/.exec( + location ?? '', + ); + if (!match) throw new BffSessionError('response'); + return redirect(request, '/studio/invitations/' + match[1], []); + } + return redirect(request, form.destination, []); + } const status = outcome.kind === 'api-error' ? outcome.status : outcome.kind === 'invalid-request' ? 400 : 503; - const code = outcome.kind === 'api-error' ? outcome.error.code : 'unavailable'; + const code = + outcome.kind === 'api-error' + ? outcome.error.code + : outcome.kind === 'invalid-request' && path.startsWith('/api/studio/invitation-') + ? 'validation_failed' + : 'unavailable'; const profileErrors = outcome.kind === 'api-error' && outcome.error.code === 'validation_failed' ? Object.keys(outcome.error.fieldErrors) : []; const reason = code === 'validation_failed' && - (path === '/api/account/profile' || path === '/api/studio/membership-profile') + (path === '/api/account/profile' || + path === '/api/studio/membership-profile' || + path.startsWith('/api/studio/invitation-')) ? 'validation' : code === 'concurrency_conflict' ? 'stale' @@ -674,7 +729,9 @@ export function createBffHttpProtocol(store: BffSessionStore, preparation?: Nati outcome.kind === 'api-error' && outcome.error.code === 'validation_failed' ? { - invalidFields: (['displayName', 'preferredLocale'] as const).filter((name) => + invalidFields: ( + ['displayName', 'preferredLocale', 'email', 'offeredRole', 'days'] as const + ).filter((name) => profileErrors.some((key) => key.toLowerCase() === name.toLowerCase()), ), } @@ -718,6 +775,17 @@ export function createBffHttpProtocol(store: BffSessionStore, preparation?: Nati ]; return redirect(request, logout.redirectUrl ?? '/', edits); } catch (error) { + if ( + path === '/api/auth/callback/keycloak' && + error instanceof BffSessionError && + error.clearTransaction + ) { + const cookies = bffCookies(request.headers.get('cookie')); + if (cookies.has(BFF_COOKIES.invitation)) + return redirect(request, '/invitations/confirm', [ + clearBffCookie(BFF_COOKIES.login + error.clearTransaction), + ]); + } const entryRequest = [ '/api/auth/entry', '/api/auth/entry/create', diff --git a/frontend/apps/web/src/server/auth/invitation-administration-forms.test.ts b/frontend/apps/web/src/server/auth/invitation-administration-forms.test.ts new file mode 100644 index 000000000..6742203b4 --- /dev/null +++ b/frontend/apps/web/src/server/auth/invitation-administration-forms.test.ts @@ -0,0 +1,104 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; + +import { bffAdministrationForm, BffInvitationInputError } from './http-forms'; + +const id = '019a154b-0000-7000-8000-000000000022'; +const version = '9223372036854775807'; +const key = 'exact-invitation-key'; +const common = { csrfToken: 'A'.repeat(43), idempotencyKey: key }; +const create = { ...common, email: 'Person@example.test', offeredRole: id + '.' + version }; + +describe('strict invitation administration form input', () => { + it('unpacks one no-script Role choice while preserving the pinned Int64 string', () => { + expect( + bffAdministrationForm('/api/studio/invitation-create', new URLSearchParams(create)), + ).toEqual({ + operation: { + kind: 'createInvitation', + invitation: { email: create.email, roleId: id, roleVersion: version }, + idempotencyKey: key, + }, + destination: '/studio/invitations', + }); + }); + it('rejects selectors, duplicate choice, malformed IDs, versions and aliases before any operation', () => { + for (const fields of [ + new URLSearchParams({ ...create, organizationId: id }), + new URLSearchParams({ ...create, version: '1' }), + new URLSearchParams({ ...create, roleId: id, roleVersion: '1' }), + new URLSearchParams({ ...create, offeredRole: id + '.9223372036854775808' }), + new URLSearchParams({ ...create, offeredRole: id + '.00' }), + new URLSearchParams({ ...create, offeredRole: id.toUpperCase() + '.1' }), + new URLSearchParams({ ...create, email: 'Person\n@example.test' }), + new URLSearchParams([...Object.entries(create), ['offeredRole', id + '.1']]), + ]) + expect(() => bffAdministrationForm('/api/studio/invitation-create', fields)).toThrow(); + }); + it('classifies editable invitation values for safe recovery while rejecting contradictory target grammar', () => { + for (const [fields, invalidFields] of [ + [{ ...create, email: '', offeredRole: '' }, ['email', 'offeredRole']], + [{ ...create, email: 'not-an-address' }, ['email']], + ] as const) { + try { + bffAdministrationForm('/api/studio/invitation-create', new URLSearchParams(fields)); + throw new Error('Expected invalid form'); + } catch (error) { + expect(error).toBeInstanceOf(BffInvitationInputError); + expect((error as BffInvitationInputError).invalidFields).toEqual(invalidFields); + } + } + expect(() => + bffAdministrationForm( + '/api/studio/invitation-policy', + new URLSearchParams({ ...common, presence: 'absent', days: '31' }), + ), + ).toThrow(BffInvitationInputError); + }); + it.each(['revoke', 'reissue'])('binds %s to an exact UUID and strong version', (action) => { + expect( + bffAdministrationForm( + '/api/studio/invitation-' + action, + new URLSearchParams({ ...common, invitationId: id, version }), + ), + ).toMatchObject({ + operation: { + kind: action === 'revoke' ? 'revokeInvitation' : 'reissueInvitation', + id, + version, + idempotencyKey: key, + }, + destination: '/studio/invitations/' + id, + }); + }); + it('separates absent policy from present zero and refuses contradictory conditions', () => { + for (const presence of ['absent', 'present']) { + const fields = new URLSearchParams({ ...common, presence, days: '14' }); + if (presence === 'present') fields.set('version', '0'); + expect(bffAdministrationForm('/api/studio/invitation-policy', fields)).toMatchObject({ + operation: { + kind: 'setInvitationLifetime', + days: 14, + precondition: + presence === 'absent' ? { kind: 'absent' } : { kind: 'present', version: '0' }, + }, + }); + } + for (const extra of [ + { presence: 'absent', version: '0', days: '14' }, + { presence: 'present', days: '14' }, + { presence: 'absent', days: '0' }, + { presence: 'absent', days: '31' }, + { presence: 'absent', days: '1.5' }, + { presence: 'absent', days: '014' }, + { presence: 'missing', days: '14' }, + { presence: 'present', version: '00', days: '14' }, + ] as Record[]) + expect(() => + bffAdministrationForm( + '/api/studio/invitation-policy', + new URLSearchParams({ ...common, ...extra }), + ), + ).toThrow(); + }); +}); diff --git a/frontend/apps/web/src/server/auth/invitation-copy.ts b/frontend/apps/web/src/server/auth/invitation-copy.ts new file mode 100644 index 000000000..6e90294c6 --- /dev/null +++ b/frontend/apps/web/src/server/auth/invitation-copy.ts @@ -0,0 +1,92 @@ +export const invitationKeys = [ + 'common.title', + 'common.required', + 'common.skip', + 'common.cancel', + 'common.home', + 'common.signout', + 'landing.title', + 'landing.description', + 'landing.code_label', + 'landing.continue', + 'landing.no_script', + 'landing.invalid', + 'confirm.signin', + 'confirm.signin_action', + 'confirm.bootstrap', + 'confirm.bootstrap_action', + 'confirm.inspect', + 'confirm.inspect_action', + 'confirm.forbidden', + 'confirm.verify_action', + 'confirm.verification_hint', + 'confirm.ready', + 'confirm.email', + 'confirm.institution', + 'confirm.branch', + 'confirm.tenant_scope', + 'confirm.offered_role', + 'confirm.expires', + 'confirm.accept', + 'confirm.accepted', + 'confirm.already_member', + 'confirm.session_ready', + 'confirm.continue', + 'confirm.session_unavailable', + 'confirm.result_action', + 'failure.unknown', + 'failure.unavailable', + 'failure.expired', + 'failure.account_change', + 'failure.restart', + 'failure.retry', + 'roles.tenant_admin', + 'roles.instructor', + 'roles.learner', + 'roles.guardian', + 'roles.editor', + 'roles.org_admin', + 'roles.org_instructor', +] as const; +export type InvitationKey = (typeof invitationKeys)[number]; +export type InvitationCopy = (key: InvitationKey) => string; +export type InvitationCatalogues = Readonly<{ en: unknown; tr: unknown }>; + +/** Native startup supplies the same authored JSON used by catalogue validation. */ +export function prepareInvitationCatalogues(catalogues: InvitationCatalogues) { + const prepared = new Map>(); + for (const locale of ['en', 'tr'] as const) { + const values = new Map(); + const visit = (value: unknown, prefix = '') => { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new Error('Invitation catalogue unavailable'); + for (const [key, child] of Object.entries(value)) { + const path = prefix ? prefix + '.' + key : key; + if (typeof child === 'string') { + if ( + !invitationKeys.includes(path as InvitationKey) || + !child.trim() || + child.length > 1000 || + /[{}]/.test(child) || + child.includes('\0') + ) + throw new Error('Invitation catalogue unavailable'); + values.set(path as InvitationKey, child); + } else visit(child, path); + } + }; + visit(catalogues[locale]); + if (values.size !== invitationKeys.length) throw new Error('Invitation catalogue unavailable'); + prepared.set(locale, values); + } + return (contentLocale: string) => { + let locale = contentLocale; + while (!prepared.has(locale) && locale.includes('-')) + locale = locale.slice(0, locale.lastIndexOf('-')); + if (!prepared.has(locale)) locale = 'en'; + const messages = prepared.get(locale)!; + const invitation: InvitationCopy = (key) => + messages.get(key) ?? messages.get('failure.unavailable')!; + return Object.freeze({ locale, invitation }); + }; +} diff --git a/frontend/apps/web/src/server/auth/invitation-html.test.ts b/frontend/apps/web/src/server/auth/invitation-html.test.ts new file mode 100644 index 000000000..2e5034767 --- /dev/null +++ b/frontend/apps/web/src/server/auth/invitation-html.test.ts @@ -0,0 +1,270 @@ +import { readFileSync } from 'node:fs'; +import { runInNewContext } from 'node:vm'; + +import { describe, expect, it, vi } from 'vitest'; + +import { invitationKeys, prepareInvitationCatalogues } from './invitation-copy'; +import { + createInvitationRenderer, + INVITATION_CONFIRM_SCRIPT, + INVITATION_LANDING_SCRIPT, +} from './invitation-html'; +import type { InvitationInspection, InvitationPage } from './native-invitation-contract'; +import en from '../../i18n/messages/en/invitation.json'; +import tr from '../../i18n/messages/tr/invitation.json'; +import { checkCatalogues, publicCallsiteKeys } from '../../test/ui-catalogue-checks'; + +const csrfToken = 'c'.repeat(43); +const render = createInvitationRenderer({ en, tr }); +const inspection: InvitationInspection = { + invitationId: '019a154b-0000-7000-8000-000000000031', + version: '9007199254740993', + expiresAt: '2026-11-20T15:30:00.0000000+00:00', + tenantLabel: 'School ', + organizationLabel: 'Branch & center', + roleId: '019a154b-0000-7000-8000-000000000032', + roleKey: 'Learner', + roleScope: 'Organization', + verifiedEmail: 'recipient+full@example.test', +}; +const page = ( + value: Partial> = {}, +): InvitationPage => ({ + kind: 'confirm', + locale: 'en', + csrfToken, + state: 'inspect', + ...value, +}); +const parse = (html: string) => new DOMParser().parseFromString(html, 'text/html'); + +describe('native invitation documents and complete authored copy', () => { + it('checks the actual typed callsites, equal en/tr keys and ICU grammar', () => { + const source = readFileSync('src/server/auth/invitation-html.ts', 'utf8'); + const used = publicCallsiteKeys(source, 'invitation-html.ts', 'invitation'); + expect(used.length).toBeGreaterThan(30); + checkCatalogues([en, tr], used); + expect(new Set(invitationKeys).size).toBe(invitationKeys.length); + const select = prepareInvitationCatalogues({ en, tr }); + expect(select('tr-Latn-TR').invitation('common.title')).toBe(tr.common.title); + expect(select('ar').invitation('common.title')).toBe(en.common.title); + const incomplete = structuredClone(tr); + delete (incomplete.confirm as Partial).already_member; + expect(() => createInvitationRenderer({ en, tr: incomplete })).toThrow( + 'Invitation catalogue unavailable', + ); + expect(() => checkCatalogues([en, tr], [...used, 'confirm.misspelled'])).toThrow('Used UI key'); + expect(() => + publicCallsiteKeys('invitation(problem.messageKey)', 'dirty.ts', 'invitation'), + ).toThrow('literal'); + }); + + it('renders a clean code-paste form without scripts being required and places scrub before visible UI', () => { + const html = render({ kind: 'landing', locale: 'tr-TR', csrfToken }); + const document = parse(html); + expect(document.querySelector('script')?.textContent).toBe(INVITATION_LANDING_SCRIPT); + expect(html.indexOf('${escape(invitation('common.title'))}

${escape(landing ? invitation('landing.title') : invitation('common.title'))}

${content}

${escape(invitation('common.home'))}

`; + }; +} diff --git a/frontend/apps/web/src/server/auth/invitation-session.ts b/frontend/apps/web/src/server/auth/invitation-session.ts new file mode 100644 index 000000000..a7e1ff9fd --- /dev/null +++ b/frontend/apps/web/src/server/auth/invitation-session.ts @@ -0,0 +1,36 @@ +import type { NativeInvitationInspection } from './native-invitation-contract.js'; +import type { LoginStart, SessionCompletion } from './session-contract.js'; + +/** A projection only. The invitation code remains exclusively inside its native owner. */ +export type InvitationContinuation = Readonly<{ + csrfToken: string; + state: + | 'signin' + | 'bootstrap' + | 'inspect' + | 'forbidden' + | 'ready' + | 'accepted' + | 'unknown' + | 'unavailable' + | 'expired' + | 'account_change'; + inspection?: NativeInvitationInspection; + disposition?: 'created' | 'already_member'; + sessionAvailable?: boolean; + entryCsrfToken?: string; + sessionCsrfToken?: string; + login?: LoginStart; + completion?: SessionCompletion; + opening?: InvitationOpening; +}>; +export type InvitationOpening = Readonly<{ handle: string; browser: string; csrfToken: string }>; +export type InvitationCommand = + | Readonly<{ kind: 'capture'; csrf: string; code: string }> + | Readonly<{ kind: 'inspect' | 'verify' | 'result' | 'cancel'; csrf: string }> + | Readonly<{ kind: 'accept'; csrf: string; version: string }>; +export type InvitationResponseLease = Readonly<{ + signal: AbortSignal; + cleaned: Promise; + pin: (assert: () => void) => void; +}>; diff --git a/frontend/apps/web/src/server/auth/native-administration-client.ts b/frontend/apps/web/src/server/auth/native-administration-client.ts index e7464b2de..95c5b58a7 100644 --- a/frontend/apps/web/src/server/auth/native-administration-client.ts +++ b/frontend/apps/web/src/server/auth/native-administration-client.ts @@ -7,7 +7,7 @@ import type { NativeOperationResult, } from './native-operation-contract.js'; -/** One explicit dispatcher over the generated SDK's 21 operations; no generic HTTP capability escapes. */ +/** One explicit dispatcher over the generated SDK's finite operations; no generic HTTP capability escapes. */ export function createNativeAdministrationCaller( configuration: NativeApiConfiguration, transport: typeof fetch, @@ -46,6 +46,47 @@ export function createNativeAdministrationCaller( limit: operation.limit, }); break; + case 'listInvitations': + result = await sdk.listInvitations({ + ...read, + cursor: operation.cursor, + limit: operation.limit, + }); + break; + case 'getInvitation': + result = await sdk.getInvitation(operation.id, read); + break; + case 'listInvitableRoles': + result = await sdk.listInvitableRoles(read); + break; + case 'getInvitationPolicy': + result = await sdk.getInvitationPolicy(read); + break; + case 'createInvitation': + result = await sdk.createInvitation(operation.invitation, { + idempotencyKey: operation.idempotencyKey, + signal, + }); + break; + case 'revokeInvitation': + result = await sdk.revokeInvitation( + operation.id, + write(operation.version, operation.idempotencyKey), + ); + break; + case 'reissueInvitation': + result = await sdk.reissueInvitation( + operation.id, + write(operation.version, operation.idempotencyKey), + ); + break; + case 'setInvitationLifetime': + result = await sdk.setInvitationLifetime(operation.days, { + precondition: operation.precondition, + idempotencyKey: operation.idempotencyKey, + signal, + }); + break; case 'getMembership': result = await sdk.getMembership(operation.id, read); break; diff --git a/frontend/apps/web/src/server/auth/native-api-transport.ts b/frontend/apps/web/src/server/auth/native-api-transport.ts index 512aa7b2e..677a7976e 100644 --- a/frontend/apps/web/src/server/auth/native-api-transport.ts +++ b/frontend/apps/web/src/server/auth/native-api-transport.ts @@ -113,7 +113,9 @@ export function createNativeApiTransport( }; const outgoing = new Headers(init.headers); for (const [name] of outgoing) - if (!['accept', 'content-type', 'idempotency-key', 'if-match'].includes(name)) + if ( + !['accept', 'content-type', 'idempotency-key', 'if-match', 'if-none-match'].includes(name) + ) throw new NativeApiError('configuration'); outgoing.set('Accept', 'application/json'); outgoing.set('Authorization', 'Bearer ' + token); @@ -156,6 +158,7 @@ export function createNativeApiTransport( const media = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase(); if ( response.redirected || + response.headers.has('set-cookie') || (response.status < 400 && ![200, 201].includes(response.status)) || media !== (response.status < 400 ? 'application/json' : 'application/problem+json') ) diff --git a/frontend/apps/web/src/server/auth/native-clients.test.ts b/frontend/apps/web/src/server/auth/native-clients.test.ts index 32760ab3e..1a13a8691 100644 --- a/frontend/apps/web/src/server/auth/native-clients.test.ts +++ b/frontend/apps/web/src/server/auth/native-clients.test.ts @@ -476,6 +476,58 @@ const routes: readonly { { operation: { kind: 'listRoles' }, method: 'GET', path: '/identity/roles' }, { operation: { kind: 'getRole', id }, method: 'GET', path: '/identity/roles/' + id }, { operation: { kind: 'getPermissions' }, method: 'GET', path: '/identity/permissions' }, + { operation: { kind: 'listInvitations' }, method: 'GET', path: '/identity/invitations' }, + { operation: { kind: 'getInvitation', id }, method: 'GET', path: '/identity/invitations/' + id }, + { operation: { kind: 'listInvitableRoles' }, method: 'GET', path: '/identity/invitable-roles' }, + { + operation: { kind: 'getInvitationPolicy' }, + method: 'GET', + path: '/tenancy/settings/invitation-policy', + }, + { + operation: { + kind: 'createInvitation', + invitation: { email: 'person@example.test', roleId: id, roleVersion: write.version }, + idempotencyKey: write.idempotencyKey, + }, + method: 'POST', + path: '/identity/invitations', + body: { email: 'person@example.test', roleId: id, roleVersion: write.version }, + }, + { + operation: { kind: 'revokeInvitation', id, ...write }, + method: 'POST', + path: '/identity/invitations/' + id + '/revocations', + body: {}, + }, + { + operation: { kind: 'reissueInvitation', id, ...write }, + method: 'POST', + path: '/identity/invitations/' + id + '/reissues', + body: {}, + }, + { + operation: { + kind: 'setInvitationLifetime', + days: 14, + precondition: { kind: 'absent' }, + idempotencyKey: write.idempotencyKey, + }, + method: 'PUT', + path: '/tenancy/settings/invitation-policy', + body: { days: 14 }, + }, + { + operation: { + kind: 'setInvitationLifetime', + days: 30, + precondition: { kind: 'present', version: write.version }, + idempotencyKey: write.idempotencyKey, + }, + method: 'PUT', + path: '/tenancy/settings/invitation-policy', + body: { days: 30 }, + }, { operation: { kind: 'listOrganizations' }, method: 'GET', path: '/tenancy/organizations' }, { operation: { kind: 'getOrganization', id }, @@ -576,7 +628,15 @@ describe('native closed administration dispatcher', () => { expect(headers.get('X-LearnStack-Native-Tenant-Id')).toBe(tenantId); expect(headers.has('X-LearnStack-Native-Organization-Id')).toBe(false); expect(headers.get('If-Match')).toBe( - 'version' in operation ? '"' + write.version + '"' : null, + 'version' in operation || + (operation.kind === 'setInvitationLifetime' && operation.precondition.kind === 'present') + ? '"' + write.version + '"' + : null, + ); + expect(headers.get('If-None-Match')).toBe( + operation.kind === 'setInvitationLifetime' && operation.precondition.kind === 'absent' + ? '*' + : null, ); expect(headers.get('Idempotency-Key')).toBe( 'idempotencyKey' in operation ? write.idempotencyKey : null, @@ -613,7 +673,7 @@ describe('native closed administration dispatcher', () => { { kind: 'invalid-request' }, ); expect(transport).not.toHaveBeenCalled(); - expect(new Set(routes.map((route) => route.operation.kind)).size).toBe(21); + expect(new Set(routes.map((route) => route.operation.kind)).size).toBe(29); }); it('preserves decimal versions, Location and replay metadata for a deliberate exact retry', async () => { const transport = vi.fn(async () => diff --git a/frontend/apps/web/src/server/auth/native-invitation-client.test.ts b/frontend/apps/web/src/server/auth/native-invitation-client.test.ts new file mode 100644 index 000000000..68b332e68 --- /dev/null +++ b/frontend/apps/web/src/server/auth/native-invitation-client.test.ts @@ -0,0 +1,226 @@ +// @vitest-environment node +import { describe, expect, it, vi } from 'vitest'; + +import { createNativeInvitationApi } from './native-invitation-client'; +import type { InvitationInspection, InvitationInput } from './native-invitation-contract'; + +const invitationId = '019a154b-0000-7000-8000-000000000031'; +const input = { + invitationId, + code: invitationId + '.' + Buffer.alloc(32, 7).toString('base64url'), +}; +const configuration = { + apiOrigin: 'http://127.0.0.1:5100', + secret: 'private-hop-'.padEnd(48, 'x'), + nativeSourceSecret: Buffer.from(Array.from({ length: 32 }, (_, index) => index + 1)).toString( + 'base64url', + ), +}; +const binding = { host: 'institution.example.test:3000', source: '203.0.113.19' }; +const inspection: InvitationInspection = { + invitationId, + version: '9007199254740993', + expiresAt: '2026-11-20T15:30:00.0000000+00:00', + tenantLabel: 'Institution', + organizationLabel: 'Branch', + roleId: '019a154b-0000-7000-8000-000000000032', + roleKey: 'Learner', + roleScope: 'Organization', + verifiedEmail: 'recipient@example.test', +}; +const accepted = { + invitationId, + version: '9007199254740994', + membershipId: '019a154b-0000-7000-8000-000000000033', + disposition: 'already_member', + tenantId: '019a154b-0000-7000-8000-000000000034', + organizationId: '019a154b-0000-7000-8000-000000000035', +}; +const signal = () => new AbortController().signal; +const json = (body: unknown, status = 200, headers: Record = {}) => + new Response(JSON.stringify(body), { + status, + headers: { + 'Content-Type': status < 400 ? 'application/json' : 'application/problem+json', + 'Cache-Control': 'no-store', + ...(status === 200 ? { ETag: '"' + (body as { version: string }).version + '"' } : {}), + ...headers, + }, + }); +const problem = (code: string, status: number) => ({ + type: 'https://errors.learnstack.dev/' + code, + title: 'private-provider-diagnostic', + status, + instance: '/api/v1/identity/invitation-entry/inspections', + code, + messageKey: 'lockey_' + code, + correlationId: 'private-correlation', + errors: { code: [{ key: 'lockey_private_field' }] }, +}); + +describe('native recipient invitation client', () => { + it('uses the three exact SDK POSTs with scope-null source proof and version only on acceptance', async () => { + const transport = vi + .fn() + .mockResolvedValueOnce(json(inspection)) + .mockResolvedValueOnce(json(accepted)) + .mockResolvedValueOnce(json(accepted)); + const api = createNativeInvitationApi(configuration, transport); + const offer = await api.inspect('held-token', binding, input, signal()); + expect(offer).toEqual(inspection); + expect(Object.isFrozen(offer)).toBe(true); + expect( + await api.accept('held-token', binding, { ...input, version: inspection.version }, signal()), + ).toEqual(accepted); + expect(await api.result('held-token', binding, input, signal())).toEqual(accepted); + for (const [index, action] of ['inspections', 'acceptances', 'results'].entries()) { + const [url, init] = transport.mock.calls[index]!; + expect(url).toBe(configuration.apiOrigin + '/api/v1/identity/invitation-entry/' + action); + expect(init).toMatchObject({ + method: 'POST', + cache: 'no-store', + credentials: 'omit', + redirect: 'error', + }); + expect(JSON.parse(init!.body as string)).toEqual(input); + const headers = new Headers(init!.headers); + expect(headers.get('Authorization')).toBe('Bearer held-token'); + expect(headers.get('If-Match')).toBe(index === 1 ? '"' + inspection.version + '"' : null); + for (const name of [ + 'Cookie', + 'Idempotency-Key', + 'If-None-Match', + 'X-LearnStack-Native-Tenant-Id', + 'X-LearnStack-Native-Organization-Id', + ]) + expect(headers.has(name)).toBe(false); + } + }); + + it.each([ + { ...input, code: input.code + '=' }, + { ...input, code: input.code.slice(37) }, + { ...input, invitationId: inspection.roleId }, + { ...input, code: input.code.slice(0, -1) + 'B' }, + { ...input, path: '/other' }, + ])('refuses malformed or expanded input before I/O', async (value) => { + const transport = vi.fn(); + await expect( + createNativeInvitationApi(configuration, transport).inspect( + 'token', + binding, + value as InvitationInput, + signal(), + ), + ).rejects.toMatchObject({ reason: 'configuration' }); + expect(transport).not.toHaveBeenCalled(); + }); + + it.each([ + { ...inspection, version: 9007199254740992 }, + { ...inspection, version: '9223372036854775808' }, + { ...inspection, verifiedEmail: 'Recipient@example.test' }, + { ...inspection, organizationLabel: null }, + { ...inspection, roleKey: 'TenantAdmin' }, + { ...inspection, code: input.code }, + { ...inspection, invitationId: inspection.roleId }, + ])( + 'rejects broadened or contradictory inspection without returning private data', + async (value) => { + await expect( + createNativeInvitationApi(configuration, async () => json(value)).inspect( + 'token', + binding, + input, + signal(), + ), + ).rejects.toMatchObject({ reason: 'response' }); + }, + ); + + const invalidHeaders: Record[] = [ + { ETag: 'W/"9007199254740993"' }, + { 'Cache-Control': 'private' }, + { Location: '/api/v1/identity/invitations/' + invitationId }, + { 'Idempotency-Replayed': 'true' }, + { 'Set-Cookie': 'untrusted=private' }, + ]; + it.each(invalidHeaders)('rejects invalid receipt headers', async (headers) => { + await expect( + createNativeInvitationApi(configuration, async () => json(inspection, 200, headers)).inspect( + 'token', + binding, + input, + signal(), + ), + ).rejects.toMatchObject({ reason: 'response' }); + }); + + it.each([ + ['forbidden', 403], + ['not_found', 404], + ['concurrency_conflict', 409], + ['rate_limited', 429], + ['dependency_unavailable', 503], + ] as const)( + 'retains finite %s recovery without upstream diagnostics or automatic retry', + async (code, status) => { + const transport = vi.fn(async () => + json(problem(code, status), status, { 'Retry-After': '30' }), + ); + let captured: unknown; + try { + await createNativeInvitationApi(configuration, transport).accept( + 'token', + binding, + { ...input, version: inspection.version }, + signal(), + ); + } catch (error) { + captured = error; + } + expect(captured).toMatchObject({ + code, + status, + reason: 'provider_refused', + retryAfterSeconds: status === 429 ? 30 : undefined, + }); + expect(JSON.stringify(captured)).not.toContain('private'); + expect(String(captured)).not.toContain(input.code); + expect(transport).toHaveBeenCalledTimes(1); + }, + ); + + it('maps unknown or status-inconsistent classifications to unknown without forwarding their value', async () => { + for (const [code, status] of [ + ['private-code-canary', 403], + ['forbidden', 503], + ] as const) { + await expect( + createNativeInvitationApi(configuration, async () => + json(problem(code, status), status), + ).inspect('token', binding, input, signal()), + ).rejects.toMatchObject({ code: 'unknown', status }); + } + }); + + it('keeps an abort-insensitive transport owned until it really returns', async () => { + let deliver!: (response: Response) => void; + const pending = new Promise((resolve) => { + deliver = resolve; + }); + const controller = new AbortController(); + let settled = false; + const operation = createNativeInvitationApi(configuration, async () => pending) + .inspect('token', binding, input, controller.signal) + .finally(() => { + settled = true; + }); + const refused = expect(operation).rejects.toMatchObject({ reason: 'cancelled' }); + controller.abort(); + await Promise.resolve(); + expect(settled).toBe(false); + deliver(json(inspection)); + await refused; + }); +}); diff --git a/frontend/apps/web/src/server/auth/native-invitation-client.ts b/frontend/apps/web/src/server/auth/native-invitation-client.ts new file mode 100644 index 000000000..819d6afb7 --- /dev/null +++ b/frontend/apps/web/src/server/auth/native-invitation-client.ts @@ -0,0 +1,106 @@ +import { createInvitationEntryServerSdk } from '@learnstack/sdk/invitation-entry-server'; + +import { createNativeApiTransport, exactFields } from './native-api-transport.js'; +import type { NativeApiConfiguration } from './native-api-transport.js'; +import { NativeApiError } from './native-contract.js'; +import type { NativeBinding } from './native-contract.js'; +import { NativeInvitationError } from './native-invitation-contract.js'; +import type { + InvitationFailureCode, + InvitationInput, + InvitationInspection, + InvitationResult, + NativeInvitationApi, +} from './native-invitation-contract.js'; + +const failures: Readonly> = Object.freeze({ + validation_failed: 400, + unauthorized: 401, + forbidden: 403, + not_found: 404, + concurrency_conflict: 409, + rate_limited: 429, + dependency_unavailable: 503, + audit_unavailable: 503, + payload_too_large: 413, +}); + +/** The SDK owns wire validation; this adapter alone supplies the held credential and source. */ +export function createNativeInvitationApi( + configuration: NativeApiConfiguration, + transport: typeof fetch, +): NativeInvitationApi { + const request = createNativeApiTransport(configuration, transport); + type Action = keyof NativeInvitationApi; + async function call( + action: Action, + token: string, + binding: NativeBinding, + input: InvitationInput & Readonly<{ version?: string }>, + signal: AbortSignal, + ): Promise { + if ( + !exactFields( + input, + action === 'accept' ? ['invitationId', 'code', 'version'] : ['invitationId', 'code'], + ) + ) + throw new NativeApiError('configuration'); + let failure: unknown; + const sdk = createInvitationEntryServerSdk(async (path, init) => { + try { + return await request(token, binding, null, path, init, signal); + } catch (error) { + failure = error; + throw error; + } + }); + // Construct exact input fields; version is only a strong header on acceptance. + const offer = { invitationId: input.invitationId, code: input.code }; + const result = + action === 'inspect' + ? await sdk.inspectInvitation(offer, { signal }) + : action === 'result' + ? await sdk.getOwnInvitationResult(offer, { signal }) + : await sdk.acceptInvitation(offer, { + signal, + version: input.version ?? '', + }); + if (signal.aborted) throw new NativeApiError('cancelled'); + if (result.kind === 'success') return Object.freeze({ ...result.data }); + if (result.kind === 'api-error') { + const code = + Object.hasOwn(failures, result.error.code) && failures[result.error.code] === result.status + ? (result.error.code as InvitationFailureCode) + : 'unknown'; + const delay = result.error.code === 'rate_limited' ? result.error.retryAfter : undefined; + const retry = + result.status === 429 && + delay !== undefined && + Number.isInteger(delay) && + delay >= 1 && + delay <= 86_400 + ? delay + : undefined; + throw new NativeInvitationError(code, result.status, retry); + } + if (failure instanceof NativeApiError) throw failure; + throw new NativeApiError( + result.kind === 'cancelled' + ? 'cancelled' + : result.kind === 'invalid-request' + ? 'configuration' + : result.kind === 'transport-error' + ? 'transport' + : 'response', + ); + } + return Object.freeze({ + inspect: (...args: Parameters) => + call('inspect', ...args) as Promise, + accept: (...args: Parameters) => + call('accept', ...args) as Promise, + result: (...args: Parameters) => + call('result', ...args) as Promise, + }); +} diff --git a/frontend/apps/web/src/server/auth/native-invitation-contract.ts b/frontend/apps/web/src/server/auth/native-invitation-contract.ts new file mode 100644 index 000000000..494ae6289 --- /dev/null +++ b/frontend/apps/web/src/server/auth/native-invitation-contract.ts @@ -0,0 +1,83 @@ +import type { + InvitationAcceptance, + InvitationEntryInput, + InvitationInspection as Inspection, +} from '@learnstack/sdk/invitation-entry-server'; + +import { NativeApiError } from './native-contract.js'; +import type { NativeBinding } from './native-contract.js'; + +export type InvitationInspection = Readonly; +export type InvitationResult = Readonly; +export type NativeInvitationInspection = InvitationInspection; +export type NativeInvitationResult = InvitationResult; +export type InvitationInput = Readonly; +export type NativeInvitationApi = Readonly<{ + inspect( + token: string, + binding: NativeBinding, + input: InvitationInput, + signal: AbortSignal, + ): Promise; + accept( + token: string, + binding: NativeBinding, + input: InvitationInput & Readonly<{ version: string }>, + signal: AbortSignal, + ): Promise; + result( + token: string, + binding: NativeBinding, + input: InvitationInput, + signal: AbortSignal, + ): Promise; +}>; + +export type InvitationFailureCode = + | 'validation_failed' + | 'unauthorized' + | 'forbidden' + | 'not_found' + | 'concurrency_conflict' + | 'rate_limited' + | 'dependency_unavailable' + | 'audit_unavailable' + | 'payload_too_large' + | 'unknown'; + +/** No upstream messages, addresses, bodies or field errors leave the native adapter. */ +export class NativeInvitationError extends NativeApiError { + constructor( + readonly code: InvitationFailureCode, + status: number, + retryAfterSeconds?: number, + ) { + super('provider_refused', status, retryAfterSeconds); + this.name = 'NativeInvitationError'; + } +} + +/** A safe native view, never a custody object or a generic submitted-fields draft. */ +export type InvitationConfirmation = Readonly<{ + state: + | 'signin' + | 'bootstrap' + | 'inspect' + | 'forbidden' + | 'ready' + | 'accepted' + | 'unknown' + | 'unavailable' + | 'expired' + | 'account_change'; + csrfToken: string; + inspection?: InvitationInspection; + disposition?: 'created' | 'already_member'; + sessionAvailable?: boolean; + entryCsrfToken?: string; + sessionCsrfToken?: string; +}>; +export type InvitationPage = + | Readonly<{ kind: 'landing'; locale: string; csrfToken: string; invalid?: boolean }> + | (InvitationConfirmation & + Readonly<{ kind: 'confirm'; locale: string; signinCsrfToken?: string }>); diff --git a/frontend/apps/web/src/server/auth/native-invitation-http.test.ts b/frontend/apps/web/src/server/auth/native-invitation-http.test.ts new file mode 100644 index 000000000..164d80e6f --- /dev/null +++ b/frontend/apps/web/src/server/auth/native-invitation-http.test.ts @@ -0,0 +1,626 @@ +// @vitest-environment node +import { createServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { Readable } from 'node:stream'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { BFF_COOKIES } from './cookies'; +import { createInvitationRenderer } from './invitation-html'; +import { NativeInvitationError } from './native-invitation-contract'; +import type { NativeInvitationApi } from './native-invitation-contract'; +import { createNativeInvitationHandler, nativeInvitationTarget } from './native-invitation-http'; +import { BffSessionError } from './session-contract'; +import { BFF_LIMITS } from './session-store'; +import en from '../../i18n/messages/en/invitation.json'; +import tr from '../../i18n/messages/tr/invitation.json'; +import { bffFixture, bffHost, bffOpaque, bffSource } from '../../test/bff-fixture'; + +const resources: (() => Promise)[] = []; +afterEach(async () => { + for (const close of resources.splice(0).reverse()) await close(); +}); +async function fixture(siteStatus = 200, invitations?: NativeInvitationApi) { + const f = bffFixture({ invitations }); + const site = { + displayName: 'Institution', + defaultLocale: 'tr', + enabledLocales: ['en', 'tr'], + theme: null, + showPlatformAttribution: true, + }; + const transport = vi.fn( + async () => + new Response( + JSON.stringify( + siteStatus === 200 + ? site + : { + type: 'about:blank', + title: 'Hidden', + status: siteStatus, + code: 'not_found', + instance: '/api/v1/public/site', + messageKey: 'lockey_not_found', + correlationId: 'owned-test', + }, + ), + { + status: siteStatus, + headers: { + 'content-type': siteStatus === 200 ? 'application/json' : 'application/problem+json', + }, + }, + ), + ); + const handler = createNativeInvitationHandler({ + configuration: { apiOrigin: 'https://api.example.test', secret: bffOpaque() }, + admission: f.publicAdmission.holder, + store: f.store, + responseLease: f.requestRuntime.nativeResponse, + render: createInvitationRenderer({ en, tr }), + providerOrigin: 'https://identity.example.test', + transport, + }); + const next = vi.fn(); + const signoutResponse = vi.fn(async (_response: Response) => {}); + const server = createServer((request, response) => { + const binding = { + host: bffHost, + peer: bffSource, + method: request.method!, + target: request.url!, + }; + const handle = async () => { + if (nativeInvitationTarget(binding.target)) return handler(binding, request, response); + if (binding.target !== '/api/auth/signout') return next(); + const headers = new Headers(); + for (const [name, value] of Object.entries(request.headers)) + if (value !== undefined) headers.set(name, Array.isArray(value) ? value.join(',') : value); + const result = await f.http.handle({ + host: bffHost, + source: bffSource, + method: request.method!, + target: request.url!, + headers, + body: Readable.toWeb(request) as ReadableStream, + signal: new AbortController().signal, + }); + if (!result) throw new Error('Expected generic signout response'); + await signoutResponse(result); + const outgoing = Object.fromEntries(result.headers); + response.writeHead(result.status, { + ...outgoing, + 'set-cookie': result.headers.getSetCookie(), + }); + response.end(await result.text()); + }; + Promise.resolve( + f.publicAdmission.run(binding, request, response, () => + f.requestRuntime.run(binding, request, response, handle), + ), + ).catch(() => { + if (!response.headersSent) response.writeHead(503); + response.end(); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const url = 'http://127.0.0.1:' + (server.address() as AddressInfo).port; + resources.push(async () => { + server.closeAllConnections(); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + await f.requestRuntime.stop(); + await f.store.stop(); + f.publicAdmission.shutdown(); + }); + return { + ...f, + transport, + next, + signoutResponse, + request: (path: string, init?: RequestInit) => + // eslint-disable-next-line no-restricted-globals -- HTTP requests target only this test's fresh owned listener. + fetch(url + path, { redirect: 'manual', ...init }), + }; +} +function cookie(response: Response) { + return response.headers + .getSetCookie() + .map((value) => value.split(';', 1)[0]) + .join('; '); +} +function csrf(html: string) { + return /name="csrfToken" value="([^"]+)"/.exec(html)![1]!; +} + +describe('native invitation response before Next', () => { + it('admits the actual host before allocating custody, and HEAD/prefetch allocate nothing', async () => { + const f = await fixture(); + for (const init of [ + { method: 'HEAD' }, + { headers: { purpose: 'prefetch' } }, + { headers: { rsc: '1' } }, + ] as RequestInit[]) { + const response = await f.request('/invitations', init); + expect(await response.text()).toBe(''); + expect(response.headers.getSetCookie()).toEqual([]); + expect(f.store.counts().owners).toBe(0); + } + const response = await f.request('/invitations'); + const html = await response.text(); + expect(response.status).toBe(200); + expect(html).toContain('lang="tr"'); + expect(html.indexOf('')); + expect(response.headers.get('cache-control')).toBe('no-store'); + const nonce = /