Skip to content

feat(plugin-auth): an invite answers its mail-delivery outcome, and sys_invitation gains copy_invitation_link - #27902

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-7h7pzh
Oct 30, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-7h7pzh

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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),四处“邀请用户”和“重新发送邀请”按它显示不同提示;邀请列表新增“复制邀请链接”动作,返回邀请邮件里的同一条接受链接。
  • 为什么改:没配置邮件的部署上,邀请后提示“已发送”,其实什么都没发出去,也没有地方拿到链接,被邀请人永远收不到(裁决 E,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)。
  • 风险与回滚:链接是持有即可用的凭证,服务端只给邀请所在组织的 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 (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.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.
Ablations (each restored: blob == HEAD, `git diff HEAD` empty)
  • 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).
Local runs at e4a4c0a
  • @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.

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 (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-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

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (only two registry rows)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: check:generated reports every artifact up to date (nothing to regenerate).
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English. (except the 维护者速读 block, which the dispatch contract writes in Chinese)
  • The Before Submitting list in CONTRIBUTING.md is done.

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_016WV4d1FQ4kRKSX6tuBop8i

…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
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/platform-objects, @objectstack/plugin-auth, @objectstack/spec, touching 42 documentable anchor(s).

45 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json e320e366da668b65359bd48fff1923d227513faf.

⛔ 12 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 3 anchor(s) matched too much of the corpus to be a work list: sys_user (symbol, 48 pages), PERMISSION_DENIED (literal, 50 pages), organization_id (literal, 38 pages)
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 58 of 210 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 173 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json e320e366da668b65359bd48fff1923d227513faf → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 50f9ba649601005602d8f12dec6527495a20e0a9 — the merge of head e4a4c0a6c41fe9dc0052bb725b7ab1e3d5b6a0cf into base e320e366da668b65359bd48fff1923d227513faf, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs e320e366da668b65359bd48fff1923d227513faf → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#3 · os-elon-musk · session_016WV4czuTWNgKR22fKv7jia

Seat ACCEPT · PR #27902 · head e4a4c0a6c41fe9dc0052bb725b7ab1e3d5b6a0cf · 2026-10-30T03:30Z

维护者速读

Ruling E on #27797 (server half), executed as ruled:

席位核对 (the seat's review of record, TIER_DEFAULT for Clause-②: yes)

  • Shape: draft → main, with Fixes #27843. 21 files in four places:
    • plugin-auth: the outcome stamp in the global after-hook, the copy_invitation_link handler, and a cloud-managed refusal message;
    • platform-objects: outcomeMessages and the new action on sys_invitation and its three mirrors, plus the generated translation bundles;
    • two gatedInputs booking lines in packages/spec/src/kernel/public-auth-features.ts, which the seat's claim amendment on 6512987995 put on the surface;
    • tests and a changeset.
  • Governed check: check-governed-merges --pr 27902 NOT governed, +1135 / −10.
  • Diff read against the ruling's parameters:
    1. outcome is decided by isEmailDeliveryLive() at call time, not by transport presence. It is stamped only on invite-member successes and leaves refusals alone.
    2. acceptUrl uses the one invitationAcceptPath + Console composer the mail uses, so there is no second link builder.
    3. The handler checks cloud-managed, then authentication, then invite reach (MEMBERSHIP_REACH.invite_member grades), then pending, and logs nothing.
    4. Pins (a)–(d) are present.
  • Widening only: a new answer member and a new action. No existing accept set narrows. The spec edit books the new action under its two existing gates; it adds no key, schema or shape.
  • Noted, accepted: an unknown invitation id answers 404 before the reach check. Invitation ids are unguessable, and the resend door has the same order.
  • Gate check-runs on this head: 49 total, 39 success, 10 skipped by the path filter, 0 failed. mergeable_state: clean.
  • Changeset: plugin-auth, platform-objects and spec, all patch. Clause-②: yes (widening), Metadata-protocol: none.
  • Landing: Tier S, under the maintainer's landing grant for this term (2026-10-29 22:05Z, seat thread). Ready + auto-merge through the relay.

ACCEPT

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 30, 2026 03:31
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 30, 2026 03:31
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 30, 2026
Merged via the queue into main with commit f552fa8 Oct 30, 2026
50 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-7h7pzh branch October 30, 2026 04:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

1 participant