Repository navigation
feat(plugin-auth): an invite answers its mail-delivery outcome, and sys_invitation gains copy_invitation_link - #27902
Conversation
…vitation gains copy_invitation_link A successful invite (and resend) answers `outcome: 'sent' | 'link_only'` beside the invitation row, decided by the deliverability reader sign-up uses. `sys_invitation.copy_invitation_link` answers a pending invitation's accept link to a caller inside the invite reach, built by the composer the invitation mail uses. The four invite mirrors and resend declare `outcomeMessages`. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4d1FQ4kRKSX6tuBop8i
…the action's feature gates Translations for the new copy (zh-CN, ja-JP, es-ES), the two gatedInputs rows the feature-gate completeness guard reads, and the server and metadata pins for the delivery outcome and the invite-link action. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4d1FQ4kRKSX6tuBop8i
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4d1FQ4kRKSX6tuBop8i
…link Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4d1FQ4kRKSX6tuBop8i
📓 Docs Drift CheckThis PR changes 3 package(s): 45 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 12 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 173 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 50f9ba649601005602d8f12dec6527495a20e0a9 && git checkout 50f9ba649601005602d8f12dec6527495a20e0a9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e320e366da668b65359bd48fff1923d227513faf e4a4c0a6c41fe9dc0052bb725b7ab1e3d5b6a0cf && git checkout -B drift-repro e320e366da668b65359bd48fff1923d227513faf && git merge --no-ff e4a4c0a6c41fe9dc0052bb725b7ab1e3d5b6a0cf
node scripts/docs-audit/affected-docs.mjs --json e320e366da668b65359bd48fff1923d227513faf
|
|
domain:server#3 · os-elon-musk · session_016WV4czuTWNgKR22fKv7jia Seat ACCEPT · PR #27902 · head
|
Requested by Elon · project thread
domain:server#3 · os-elon-musk · session_016WV4d1FQ4kRKSX6tuBop8i
Fixes #27843
Clause-②: yes (widening)
Metadata-protocol: none
维护者速读
outcome(sent或link_only),四处“邀请用户”和“重新发送邀请”按它显示不同提示;邀请列表新增“复制邀请链接”动作,返回邀请邮件里的同一条接受链接。packages/plugins/plugin-auth/src/invitation-link-action.ts(五道门的顺序)、invitation-delivery-outcome.ts(判定规则)、packages/platform-objects/src/identity/sys-invitation.object.ts(新动作声明)。pnpm --filter @objectstack/plugin-auth exec vitest run src/invitation-delivery-outcome.test.ts,预期 27 项全过,其中 (a)–(d) 四组各自成组。What changes
Delivery outcome on the invite answer. A successful
POST /api/v1/auth/organization/invite-member, invite and resend alike, now carries a top-leveloutcome: 'sent' | 'link_only'beside the invitation row (invitation-delivery-outcome.ts, called from the global after hook). It is decided at call time byAuthManager.isEmailDeliveryLive(), the rule sign-up uses: the email service's ownisConfigured()answer, so a wiredlogtransport islink_only, and so are no service and a service without the member. A refusal is left untouched. The row's own columns do not move, so{{ result.email }}keeps reading. The private reader reaches the after hook through the two host literals (AfterHookHost,AuthInstanceHost).sys_invitation.copy_invitation_link(invitation-link-action.ts), registered byAuthPlugin.start()throughengine.registerAction('sys_invitation', 'copy_invitation_link', …, 'com.objectstack.plugin-auth'). It answers{ acceptUrl }fromAuthManager.getConsoleUrl(invitationAcceptPath(id)).invitationAcceptPathis now the one spelling of/accept-invitation/INVITATION_ID, andsendInvitationEmailcomposes the mail's link from it too, so no second link builder exists. The handler body runs system-elevated at the action door, so it enforces the gates itself, in this order:409 RESOURCE_CONFLICT, with a newCLOUD_MANAGED_REFUSAL_MESSAGES.invitationLinksentence;400 MISSING_REQUIRED_FIELD;401 UNAUTHENTICATED;sys_memberrow in the invitation's organization must carry a grade ofMEMBERSHIP_REACH.invite_member(owner, admin, delegated_admin), else403 PERMISSION_DENIED. This comes before the status gate, so a caller outside the reach learns nothing about the row;pending→409 RESOURCE_CONFLICT.The invitation and the membership are re-read under the system context. Nothing logs the link.
Metadata. The four
invite_usermirrors declareoutcomeMessagesfrom one constant (invite-outcome-messages.ts), and so doesresend_invitation. Thelink_onlycopy says the invitation was created, no email went out, and to use Copy Invite Link.successMessagestays as the fallback. The new action istype: 'script',target: 'copy_invitation_link',locations: ['list_item', 'record_header'], withrequiresFeature: 'organization'andrequiresMembershipReach: 'invite_member'. Itsvisibleisfeatures.cloudManaged != true && has(record.status) && record.status == 'pending'. ItsresultDialogisformat: 'text'with the one fieldacceptUrl.Translations. The generated bundles were regenerated with
node scripts/check-i18n-bundles.mjs --write. Every new zh-CN / ja-JP / es-ES leaf is hand-translated, so the source-hash table carries no entry for them.Path outside the dispatched surface (seat-amended, option A):
packages/spec/src/kernel/public-auth-features.ts. It holds twogatedInputsbooking lines for the new action, underorganizationandcloudManaged. platform-objects' feature-gate completeness guard (feat(spec,auth): guard that every feature-gated capability is UI-gated (generalize the create-user phone fix) #2874) reds without them. No schema or key changes.pnpm --filter @objectstack/spec check:generated: all 14 generated artifacts up to date.Tests
New pins
packages/plugins/plugin-auth/src/invitation-delivery-outcome.test.tsuses a realAuthManageron the installed better-auth. Every invitation is issued, resent, canceled and accepted through the vendor's routes. The canonical origin ishttps://crm.acme.testanduiBasePathis/console.isConfigured() === false, and noisConfiguredmember each answerlink_only. A configured transport answerssent, and the mail goes out. A resend follows the transport at call time (link_only→sent→link_onlyon one row). The unknown-role refusal carries nooutcomeand keepsROLE_NOT_FOUND.acceptUrlequalssendTemplate'sdata.acceptUrlfor the same invitation, which ishttps://crm.acme.test/console/accept-invitation/INVITATION_ID. No console call carries it.OS_CLOUD_MANAGED=1gets 409 with the cloud-managed sentence, even for the owner of a pending row.AuthPlugin.start()registers the handler once, undercom.objectstack.plugin-auth.packages/platform-objects/src/identity/invitation-delivery-copy.test.tscovers the metadata:link_onlymatches no/\b(re)?sent\b/i, and both outcomes carry${result.email}.visibleis evaluated through@objectstack/formula. The action is offered to owner, admin and delegated_admin on a pending row. It is hidden for a plain member, for each non-pending status, for a row projected withoutstatus, undercloudManaged, and withorganizationoff.Ablations (each restored: blob == HEAD, `git diff HEAD` empty)
invitationDeliveryOutcomeforced to'sent': 4 (a) cases red.Local runs at e4a4c0a
@objectstack/platform-objectstest: 100 files, 1432/1432 passed.typecheck: exit 0.@objectstack/plugin-authtest: 204 files, 3885 passed and 10 skipped.typecheck: exit 0.@objectstack/spec:src/kernel/public-auth-features.test.tsandsrc/ui/action.test.ts, 177/177 passed.check:generated: 14/14 up to date.Acceptance notes
sentdescribes the transport, not the one send. With a live transport whose send then fails, the vendor's invitation callback swallows the failure (the row is already written), so the answer still sayssent. That is the ruling's rule (isEmailDeliveryLive()at call time). The admin resends.requiresFeature: 'organization'stays a UI gate. The organization plugin is always mounted, and invitation rows exist only under it.statusis stillpendingafter itsexpires_atpassed is answered a link, and the accept page refuses it as expired. The ruling's gate isstatus == 'pending', and better-auth does not flip the status on expiry.AuthPlugin(createIdentityObjectsPlugin) declares the action with no handler. ADR-0110's boot inventory warns once there thatsys_invitation:copy_invitation_linkis unbound, and the action door answers 404.Copy Invite Link, in the Title Case of its siblings (Resend Invitation,Cancel Invitation). The card wrote it as "Copy invite link".sendInvitationEmailsaid the console's workspace Invitations page builds its own copy of this link (${origin}${BASE_URL}accept-invitation/INVITATION_ID). That client-side builder is the ui half's to retire (Setup Invitations: with no mail transport the toast says "Invitation sent to …" and there is no Copy invite link, so the invitation dead-ends (#27521 row 6) #27797).Verification
dispatch-gates --commands: 87 families derived. 86 exited 0.pnpm check:dual-build-cjs-loadsexited 3, so it is NOT MEASURED: it needs a whole-tree build, and CI runs it.dispatch-gates --ran: 87 accounted for (86 run, 1 NOT MEASURED, 0 unrun).pnpm lint: exit 0.check-commit-card-trailers --range origin/main..HEAD: exit 0, 4 commits.dispatch-gates --tier: no path-derived mandate. The diff includes the Clause-② suspect pathpackages/spec/src/**, through the two booking lines.Human Minimum
packages/specholds no runtime logic. (only two registry rows)pnpm lintpasses.main, one pull request.packages/spectouched:check:generatedreports every artifact up to date (nothing to regenerate).content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.Generated by Claude Code
🤖 Generated with Claude Code
https://claude.ai/code/session_016WV4d1FQ4kRKSX6tuBop8i