Repository navigation
Commit f552fa8
feat(plugin-auth): an invite answers its mail-delivery outcome, and sys_invitation gains copy_invitation_link (#27902)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} -->
_Requested by **Elon** · [project
thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DFLBEddJnGLwHChNQUrFM1f)_
domain:server#3 · os-elon-musk · session_016WV4d1FQ4kRKSX6tuBop8i
Fixes #27843
Clause-②: yes (widening)
Metadata-protocol: none
## 维护者速读
- 改了什么:邀请接口在成功应答里多带一个 `outcome`(`sent` 或
`link_only`),四处“邀请用户”和“重新发送邀请”按它显示不同提示;邀请列表新增“复制邀请链接”动作,返回邀请邮件里的同一条接受链接。
- 为什么改:没配置邮件的部署上,邀请后提示“已发送”,其实什么都没发出去,也没有地方拿到链接,被邀请人永远收不到(裁决 E,#27797)。
- 风险与回滚:链接是持有即可用的凭证,服务端只给邀请所在组织的
owner/admin/delegated_admin、只给待处理邀请、云托管环境一律拒绝,且从不写日志;回滚即回退本
PR,应答多出的字段对旧前端无影响。
-
先看:`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-level `outcome: 'sent' | 'link_only'` beside the
invitation row (`invitation-delivery-outcome.ts`, called from the global
after hook). It is decided at call time by
`AuthManager.isEmailDeliveryLive()`, the rule sign-up uses: the email
service's own `isConfigured()` answer, so a wired `log` transport is
`link_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 by `AuthPlugin.start()`
through `engine.registerAction('sys_invitation', 'copy_invitation_link',
…, 'com.objectstack.plugin-auth')`. It answers `{ acceptUrl }` from
`AuthManager.getConsoleUrl(invitationAcceptPath(id))`.
`invitationAcceptPath` is now the one spelling of
`/accept-invitation/INVITATION_ID`, and `sendInvitationEmail` composes
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:
1. cloud-managed → `409 RESOURCE_CONFLICT`, with a new
`CLOUD_MANAGED_REFUSAL_MESSAGES.invitationLink` sentence;
2. no record id → `400 MISSING_REQUIRED_FIELD`;
3. no session user → `401 UNAUTHENTICATED`;
4. the caller's `sys_member` row in the invitation's organization must
carry a grade of `MEMBERSHIP_REACH.invite_member` (owner, admin,
delegated_admin), else `403 PERMISSION_DENIED`. This comes before the
status gate, so a caller outside the reach learns nothing about the row;
5. status other than `pending` → `409 RESOURCE_CONFLICT`.
The invitation and the membership are re-read under the system context.
Nothing logs the link.
- **Metadata.** The four `invite_user` mirrors declare `outcomeMessages`
from one constant (`invite-outcome-messages.ts`), and so does
`resend_invitation`. The `link_only` copy says the invitation was
created, no email went out, and to use Copy Invite Link.
`successMessage` stays as the fallback. The new action is `type:
'script'`, `target: 'copy_invitation_link'`, `locations: ['list_item',
'record_header']`, with `requiresFeature: 'organization'` and
`requiresMembershipReach: 'invite_member'`. Its `visible` is
`features.cloudManaged != true && has(record.status) && record.status ==
'pending'`. Its `resultDialog` is `format: 'text'` with the one field
`acceptUrl`.
- **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 two
`gatedInputs` booking lines for the new action, under `organization` and
`cloudManaged`. platform-objects' feature-gate completeness guard
(#2874) reds without them. No schema or key changes. `pnpm --filter
@objectstack/spec check:generated`: all 14 generated artifacts up to
date.
## Tests
<details><summary>New pins</summary>
`packages/plugins/plugin-auth/src/invitation-delivery-outcome.test.ts`
uses a real `AuthManager` on the installed better-auth. Every invitation
is issued, resent, canceled and accepted through the vendor's routes.
The canonical origin is `https://crm.acme.test` and `uiBasePath` is
`/console`.
- (a) No email service, `isConfigured() === false`, and no
`isConfigured` member each answer `link_only`. A configured transport
answers `sent`, and the mail goes out. A resend follows the transport at
call time (`link_only` → `sent` → `link_only` on one row). The
unknown-role refusal carries no `outcome` and keeps `ROLE_NOT_FOUND`.
- (b) `acceptUrl` equals `sendTemplate`'s `data.acceptUrl` for the same
invitation, which is
`https://crm.acme.test/console/accept-invitation/INVITATION_ID`. No
console call carries it.
- (c) The owner and a delegated admin are admitted. A plain member is
refused 403. The owner of another organization is refused 403. No
session is refused 401. Canceled and accepted rows are refused 409. A
plain member on a canceled row gets 403 (reach before status). An
unknown id gets 404.
- (d) `OS_CLOUD_MANAGED=1` gets 409 with the cloud-managed sentence,
even for the owner of a pending row.
- `AuthPlugin.start()` registers the handler once, under
`com.objectstack.plugin-auth`.
`packages/platform-objects/src/identity/invitation-delivery-copy.test.ts`
covers the metadata:
- The mirrors and resend declare the outcome copy. `link_only` matches
no `/\b(re)?sent\b/i`, and both outcomes carry `${result.email}`.
- The action declaration is pinned.
- The parsed `visible` is 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 without `status`, under `cloudManaged`, and with
`organization` off.
- Every new leaf is translated in each locale and is not the English
source.
</details>
<details><summary>Ablations (each restored: blob == HEAD, `git diff
HEAD` empty)</summary>
- `invitationDeliveryOutcome` forced to `'sent'`: 4 (a) cases red.
- The reach check forced open: 3 (c) cases red (plain member, other-org
owner, reach-before-status).
</details>
<details><summary>Local runs at e4a4c0a</summary>
- `@objectstack/platform-objects` `test`: 100 files, 1432/1432 passed.
`typecheck`: exit 0.
- `@objectstack/plugin-auth` `test`: 204 files, 3885 passed and 10
skipped. `typecheck`: exit 0.
- `@objectstack/spec`: `src/kernel/public-auth-features.test.ts` and
`src/ui/action.test.ts`, 177/177 passed. `check:generated`: 14/14 up to
date.
</details>
## Acceptance notes
- `sent` describes 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
says `sent`. That is the ruling's rule (`isEmailDeliveryLive()` at call
time). The admin resends.
- The handler enforces the invite reach, the pending status and
cloud-managed. `requiresFeature: 'organization'` stays a UI gate. The
organization plugin is always mounted, and invitation rows exist only
under it.
- A row whose `status` is still `pending` after its `expires_at` passed
is answered a link, and the accept page refuses it as expired. The
ruling's gate is `status == 'pending'`, and better-auth does not flip
the status on expiry.
- A composition that mounts the identity objects without `AuthPlugin`
(`createIdentityObjectsPlugin`) declares the action with no handler.
ADR-0110's boot inventory warns once there that
`sys_invitation:copy_invitation_link` is unbound, and the action door
answers 404.
- The label is spelled `Copy Invite Link`, in the Title Case of its
siblings (`Resend Invitation`, `Cancel Invitation`). The card wrote it
as "Copy invite link".
- The removed comment in `sendInvitationEmail` said 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 (#27797).
## Verification
- `dispatch-gates --commands`: 87 families derived. 86 exited 0. `pnpm
check:dual-build-cjs-loads` exited 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 path `packages/spec/src/**`, through the two
booking lines.
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
(only two registry rows)
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes.
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: `check:generated` reports every
artifact up to date (nothing to regenerate).
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English. (except the 维护者速读
block, which the dispatch contract writes in Chinese)
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.
---
_Generated by [Claude
Code](https://claude.ai/code/session_016WV4d1FQ4kRKSX6tuBop8i)_
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_016WV4d1FQ4kRKSX6tuBop8i
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent fdf844c commit f552fa8
21 files changed
Lines changed: 1135 additions & 10 deletions
File tree
- .changeset
- packages
- platform-objects/src
- apps/translations
- identity
- plugins/plugin-auth/src
- spec/src/kernel
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
140 | 140 | | |
141 | 141 | | |
142 | 142 | | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
143 | 147 | | |
144 | 148 | | |
145 | 149 | | |
| |||
577 | 581 | | |
578 | 582 | | |
579 | 583 | | |
| 584 | + | |
| 585 | + | |
| 586 | + | |
| 587 | + | |
580 | 588 | | |
581 | 589 | | |
582 | 590 | | |
| |||
664 | 672 | | |
665 | 673 | | |
666 | 674 | | |
| 675 | + | |
| 676 | + | |
| 677 | + | |
| 678 | + | |
667 | 679 | | |
668 | 680 | | |
669 | 681 | | |
| |||
794 | 806 | | |
795 | 807 | | |
796 | 808 | | |
| 809 | + | |
| 810 | + | |
| 811 | + | |
| 812 | + | |
797 | 813 | | |
798 | 814 | | |
799 | 815 | | |
| |||
813 | 829 | | |
814 | 830 | | |
815 | 831 | | |
816 | | - | |
| 832 | + | |
| 833 | + | |
| 834 | + | |
| 835 | + | |
| 836 | + | |
| 837 | + | |
| 838 | + | |
| 839 | + | |
| 840 | + | |
| 841 | + | |
| 842 | + | |
| 843 | + | |
| 844 | + | |
| 845 | + | |
| 846 | + | |
| 847 | + | |
817 | 848 | | |
818 | 849 | | |
819 | 850 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
140 | 140 | | |
141 | 141 | | |
142 | 142 | | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
143 | 147 | | |
144 | 148 | | |
145 | 149 | | |
| |||
577 | 581 | | |
578 | 582 | | |
579 | 583 | | |
| 584 | + | |
| 585 | + | |
| 586 | + | |
| 587 | + | |
580 | 588 | | |
581 | 589 | | |
582 | 590 | | |
| |||
664 | 672 | | |
665 | 673 | | |
666 | 674 | | |
| 675 | + | |
| 676 | + | |
| 677 | + | |
| 678 | + | |
667 | 679 | | |
668 | 680 | | |
669 | 681 | | |
| |||
794 | 806 | | |
795 | 807 | | |
796 | 808 | | |
| 809 | + | |
| 810 | + | |
| 811 | + | |
| 812 | + | |
797 | 813 | | |
798 | 814 | | |
799 | 815 | | |
| |||
813 | 829 | | |
814 | 830 | | |
815 | 831 | | |
816 | | - | |
| 832 | + | |
| 833 | + | |
| 834 | + | |
| 835 | + | |
| 836 | + | |
| 837 | + | |
| 838 | + | |
| 839 | + | |
| 840 | + | |
| 841 | + | |
| 842 | + | |
| 843 | + | |
| 844 | + | |
| 845 | + | |
| 846 | + | |
| 847 | + | |
817 | 848 | | |
818 | 849 | | |
819 | 850 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
140 | 140 | | |
141 | 141 | | |
142 | 142 | | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
143 | 147 | | |
144 | 148 | | |
145 | 149 | | |
| |||
577 | 581 | | |
578 | 582 | | |
579 | 583 | | |
| 584 | + | |
| 585 | + | |
| 586 | + | |
| 587 | + | |
580 | 588 | | |
581 | 589 | | |
582 | 590 | | |
| |||
664 | 672 | | |
665 | 673 | | |
666 | 674 | | |
| 675 | + | |
| 676 | + | |
| 677 | + | |
| 678 | + | |
667 | 679 | | |
668 | 680 | | |
669 | 681 | | |
| |||
794 | 806 | | |
795 | 807 | | |
796 | 808 | | |
| 809 | + | |
| 810 | + | |
| 811 | + | |
| 812 | + | |
797 | 813 | | |
798 | 814 | | |
799 | 815 | | |
| |||
813 | 829 | | |
814 | 830 | | |
815 | 831 | | |
816 | | - | |
| 832 | + | |
| 833 | + | |
| 834 | + | |
| 835 | + | |
| 836 | + | |
| 837 | + | |
| 838 | + | |
| 839 | + | |
| 840 | + | |
| 841 | + | |
| 842 | + | |
| 843 | + | |
| 844 | + | |
| 845 | + | |
| 846 | + | |
| 847 | + | |
817 | 848 | | |
818 | 849 | | |
819 | 850 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
140 | 140 | | |
141 | 141 | | |
142 | 142 | | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
143 | 147 | | |
144 | 148 | | |
145 | 149 | | |
| |||
577 | 581 | | |
578 | 582 | | |
579 | 583 | | |
| 584 | + | |
| 585 | + | |
| 586 | + | |
| 587 | + | |
580 | 588 | | |
581 | 589 | | |
582 | 590 | | |
| |||
664 | 672 | | |
665 | 673 | | |
666 | 674 | | |
| 675 | + | |
| 676 | + | |
| 677 | + | |
| 678 | + | |
667 | 679 | | |
668 | 680 | | |
669 | 681 | | |
| |||
794 | 806 | | |
795 | 807 | | |
796 | 808 | | |
| 809 | + | |
| 810 | + | |
| 811 | + | |
| 812 | + | |
797 | 813 | | |
798 | 814 | | |
799 | 815 | | |
| |||
813 | 829 | | |
814 | 830 | | |
815 | 831 | | |
816 | | - | |
| 832 | + | |
| 833 | + | |
| 834 | + | |
| 835 | + | |
| 836 | + | |
| 837 | + | |
| 838 | + | |
| 839 | + | |
| 840 | + | |
| 841 | + | |
| 842 | + | |
| 843 | + | |
| 844 | + | |
| 845 | + | |
| 846 | + | |
| 847 | + | |
817 | 848 | | |
818 | 849 | | |
819 | 850 | | |
| |||
0 commit comments