Skip to content

fix(plugin-auth, platform-objects): resending an expired invitation replaces it, leaving one pending invitation - #28605

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-hprs5h
Nov 1, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-hprs5h

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Fixes #28540

Clause-②: no
Metadata-protocol: none

维护者速读

What changes

The retire step (packages/plugins/plugin-auth/src/invitation-expired-supersede.ts, new). better-auth 1.7.12's invite-member looks up the person's pending invitation with findPendingInvitation, which drops every row whose expiresAt has passed (organization/adapter.mjs). So a resend (resend: true, the resend_invitation row action) on an invitation that has already expired finds nothing to renew and issues a new invitation, and the expired row kept reading pending forever. retireExpiredInvitationsOnReissue(ctx) runs in the global after hook, right after the delivery-outcome stamp, and only on a successful invite-member answer. It reads the answered person's pending rows in that organization through the vendor's own adapter (ctx.context.adapter.findMany on model invitation), skips the answered row by id and every row still before its expiry, and moves the rest to expired with a write that names status: 'pending' beside the id (a row that was accepted, declined or canceled meanwhile is not touched).

  • Status expired, not canceled: the row did expire, expired is the ObjectStack half of InvitationStatus (better-auth has no such value), and the row now shows in the Expired / Canceled list.
  • Expiry predicate is the copy-link gate's own isBeforeExpiry (invitation-link-action.ts), now exported for reuse; the gate itself is unchanged. An expiry that does not read as a date counts as expired, as the vendor and the gate read it.
  • Failure-isolated. The new invitation is already written and its mail handed off when this runs, so a read or write that fails leaves the old row as it was, says so once at warn, and never turns the issued invitation into an error.
  • The adapter is typed with a two-method local interface (findMany / update with keyword objects), so check:tenant-audit-census reads the receiver as the better-auth adapter rather than an unplaced engine write; no ledger row is added.

The confirmation (packages/platform-objects/src/identity/sys-invitation.object.ts). resend_invitation's description now reads "Resend the invitation to (the record.email hole)? The expiry date starts over, and an invitation that has already expired is replaced by a new one." It is true on both rows Resend is offered on. Its ai.description says the same. The en bundle is regenerated by node scripts/check-i18n-bundles.mjs --write; the zh-CN, ja-JP and es-ES leaves are re-translated by hand (a stale translation is not a gap, so the merge keeps it otherwise).

Not changed: the copy-link gate from #28529 (its visible and handler), Resend's visible (still offered on an expired row: resend is the remedy), the invite-member answer's shape, and the vendor's renewal of an unexpired row.

Tests

packages/plugins/plugin-auth/src/invitation-resend-expired.test.ts (new): a real AuthManager over the installed better-auth and the memory engine, every invitation issued and resent through the vendor's own route (the harness of invitation-delivery-outcome.test.ts, cut to two people).

  • A resend on an expired pending row: 200, a new id, exactly one pending row for the person (the new one), the old row reads expired.
  • A plain invite after the earlier invitation expired: the same.
  • Control: a resend on an unexpired pending row keeps one row, the same id, and a later expiry.
  • Control: an invite for a new address leaves another person's expired pending row and the same person's expired row in another organization reading pending.
  • Control: a refused resend (a plain member, 403) retires nothing.

Readings:

  • Red first (prod sources at origin/main a52191f, test at head): 2 failed / 13 passed, the two expired cases; every control green on base.
  • Ablation via node scripts/ablation-replace.mjs, retire step only (anchor if (!row || row.id === answeredId || isBeforeExpiry(row.expiresAt)) continue; prefixed with an always-true ["ABL28540"].length > 0 ||): anchor 1 → 0, blob 25bc42e6c78c → 78d9bfb5c76e; 2 failed / 13 passed, the two expired cases only; restore blob equals HEAD, git diff HEAD empty. The suite imports src/ directly, so no build sits between the mutation and the run.
  • Green on head 1a454b7: the new file and invitation-delivery-outcome.test.ts 47/47.
  • Package suites on df41142 (after the merge and a rebuild): pnpm --filter @objectstack/plugin-auth test Test Files 208 passed (208), Tests 3958 passed, 10 skipped; pnpm --filter @objectstack/platform-objects test Test Files 106 passed (106), Tests 1488 passed; both typecheck exit 0.

Acceptance notes

  • Seam. The claim pointed at the before hook. The retire step sits in the after hook instead: before the vendor runs, the caller is not yet admitted, and a status write there would happen for refused and unauthenticated calls too. The claim's file surface did not list auth-manager-after-hook.ts, invitation-link-action.ts (one export keyword) or the new module; the seat may amend it.
  • Plain invite included. A plain invite to an address whose invitation expired had the same two-row defect through the same vendor lookup; the one seam covers both, and it is pinned.
  • Test placement. The cases live in a new file rather than in invitation-delivery-outcome.test.ts, whose tests already take about 10 s of wall. Like 33 other plugin-auth test files, the new file imports createMemoryEngine from impersonation-bearer-rotation.test.ts, which also runs that file's 10 cases inside it (observed, not filed).
  • Overlap. Open PRs re-read before pr_create: none touches auth-manager-before-hook.ts, auth-manager-after-hook.ts, invitation-link-action.ts or sys-invitation.object.ts. PR fix(platform-objects): the Users identity source reads "Local Account" instead of "Env-Native" #28543 edits the same four *.objects.generated.ts bundles at line 108 (env_native), this PR at line 851: separate hunks.
  • Security. The old invitation's link stays dead (it was already refused once past its expiry); nothing revives an expired invitation.

Verification

All readings on head df41142 (1a454b7 merged with origin/main cd065d3, which brought in PR #28543's edits to the same bundles; the merge was clean). Nothing landed on this branch after these runs.

  • Derived gate families: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack → 63 families, each run with its exit code captured before any pipe, all exit 0 on df41142. --ran reconciliation: ✓ dispatch-gates --ran: 63 derived famil(ies) accounted for — 63 run, 0 NOT-MEASURED (a DERIVED zero — all 63 recorded an exit code and none of them is 3). First-run reds on an earlier head and their fixes: check-tenant-audit-census exit 1 (an untyped adapter.update() receiver) → the adapter typed, exit 0; check-plugin-teardown-shape.mjs --self-test exit 3 on the shallow clone → git fetch --depth=1 of its control commit, exit 0; check:dual-build-cjs-loads exit 3 (no dist/ yet) → exit 0 after the i18n gate's build closure (115 entry points across 70 packages load).
  • Hand gates, each exit 0 on df41142: node scripts/check-platform-checklist.mjs --changed-from origin/main, node scripts/check-commit-card-trailers.mjs --range origin/main..HEAD, node scripts/check-durability-degradation-log-level.mjs, and pnpm exec eslint --no-inline-config over the five touched source files (neither package has a lint script; the repo-wide pnpm lint is CI's).
  • Dogfood: not owed (no ! in the title, no narrowing arm).
  • CI families are read off this PR's check-runs by the seat.

Generated by Claude Code

…ng invitation

better-auth's findPendingInvitation skips a row past its expiresAt, so a
resend (or an invite) on an expired invitation issues a new one and the
expired row kept reading pending. The global after hook now moves the
person's other expired pending rows in that organization to expired after
a successful invite-member, so the Pending list holds one row per person.
A refusal retires nothing; a resend that renews an unexpired row keeps it.

Claude-Session: https://claude.ai/code/session_01FEkQ5mL9b8QSVNXp9PcY6T
Co-authored-by: Claude <noreply@anthropic.com>
…n expired invitation too

Claude-Session: https://claude.ai/code/session_01FEkQ5mL9b8QSVNXp9PcY6T
Co-authored-by: Claude <noreply@anthropic.com>
…send Invitation's confirmation; changeset

Claude-Session: https://claude.ai/code/session_01FEkQ5mL9b8QSVNXp9PcY6T
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx (via organizationId (literal, a string literal in retireExpiredInvitationsOnReissue))
  • content/docs/automation/hooks.mdx (via sys_invitation (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects), organizationId (literal, a string literal in retireExpiredInvitationsOnReissue))
  • content/docs/data-modeling/seed-data.mdx (via organizationId (literal, a string literal in retireExpiredInvitationsOnReissue))
  • content/docs/deployment/cloud-connection.mdx (via organizationId (literal, a string literal in retireExpiredInvitationsOnReissue))
  • content/docs/deployment/seed-tenancy-repair.mdx (via organizationId (literal, a string literal in retireExpiredInvitationsOnReissue))
  • content/docs/deployment/self-hosting.mdx (via sys_invitation (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/kernel/contracts/auth-service.mdx (via organizationId (literal, a string literal in retireExpiredInvitationsOnReissue))
  • content/docs/kernel/events.mdx (via organizationId (literal, a string literal in retireExpiredInvitationsOnReissue))
  • content/docs/kernel/runtime-services/audit-service.mdx (via organizationId (literal, a string literal in retireExpiredInvitationsOnReissue))
  • content/docs/kernel/runtime-services/email-service.mdx (via organizationId (literal, a string literal in retireExpiredInvitationsOnReissue))
  • content/docs/kernel/runtime-services/sharing-service.mdx (via organizationId (literal, a string literal in retireExpiredInvitationsOnReissue))
  • content/docs/permissions/authentication.mdx (via organizationId (literal, a string literal in retireExpiredInvitationsOnReissue))
  • content/docs/permissions/system-context.mdx (via sys_invitation (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects), organizationId (literal, a string literal in retireExpiredInvitationsOnReissue))
  • content/docs/protocol/kernel/config-resolution.mdx (via organizationId (literal, a string literal in retireExpiredInvitationsOnReissue))
  • content/docs/ui/actions.mdx (via sys_invitation (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects), organizationId (literal, a string literal in retireExpiredInvitationsOnReissue))

⛔ 6 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/index.mdx (via organizationId (literal, a string literal in retireExpiredInvitationsOnReissue))
  • content/docs/releases/v16.mdx (via organizationId (literal, a string literal in retireExpiredInvitationsOnReissue))
  • content/docs/releases/v17/17-3.mdx (via sys_invitation (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/releases/v17/17-5.mdx (via organizationId (literal, a string literal in retireExpiredInvitationsOnReissue))
  • content/docs/releases/v18/18-1.mdx (via sys_invitation (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/releases/v18/18-2.mdx (via sys_invitation (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects), organizationId (literal, a string literal in retireExpiredInvitationsOnReissue))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 213 client-bound route-ledger rows — the other 153 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 153: 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; 98 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 — 23 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 ce001f97abbe460d9a6cc9a4bde6c6e38c9d37f8 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from e55c40c4d3ec563e4e5499218a2a50dc0d0e4e27 — the merge of head df411420a635f079f8ee2c2ae50b9df734b1e37f into base ce001f97abbe460d9a6cc9a4bde6c6e38c9d37f8, 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 e55c40c4d3ec563e4e5499218a2a50dc0d0e4e27 && git checkout e55c40c4d3ec563e4e5499218a2a50dc0d0e4e27
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ce001f97abbe460d9a6cc9a4bde6c6e38c9d37f8 df411420a635f079f8ee2c2ae50b9df734b1e37f && git checkout -B drift-repro ce001f97abbe460d9a6cc9a4bde6c6e38c9d37f8 && git merge --no-ff df411420a635f079f8ee2c2ae50b9df734b1e37f

node scripts/docs-audit/affected-docs.mjs --json ce001f97abbe460d9a6cc9a4bde6c6e38c9d37f8

⚠️ 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 ce001f97abbe460d9a6cc9a4bde6c6e38c9d37f8 → 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#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo

席位核对

ACCEPT · domain:server seat 2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo · 2026-11-01T08:09Z · head df411420 · #28540

The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.

  1. Shape:
    • A draft against main with no assignees, Fixes #28540, and patch changesets for @objectstack/plugin-auth and @objectstack/platform-objects.
    • check-governed-merges --pr 28605 reads NOT governed, 370 changed lines (most of them the regenerated translation bundles). Landing tier S.
    • The claim (6554252035, with its file surface amended in 6555143897 to name the after hook) names this PR's branch, claude/project-thread-hprs5h.
  2. Against the order:
    • When invite-member issues an invitation, plugin-auth's after hook moves the person's other pending invitations in that organization whose expiry has passed to expired. A Resend on an expired invitation now leaves one pending row, the new one. The old row moves to Expired / Canceled.
    • A Resend on an unexpired invitation still renews it (same id). A refused call changes nothing.
    • The expiry reading is shared with the copy-link gate (isBeforeExpiry, now exported) rather than spelled twice. The copy-link gate is unchanged.
    • Resend's confirmation and AI description are reworded to be true for both rows. The bundles are regenerated and translated through the generator.
    • Clause-②: no, Metadata-protocol: none. expired is already a sys_invitation.status value, and the response shape doesn't change.
  3. Contract review: not owed. record-recognisers reads no review face off these paths, and the changeset's Clause-②: no carries no narrowing arm.
  4. Gates: CI on head df411420 shows 35 success, 13 skipped and 0 red. The state is clean. The dev reports 63 of 63 derived gates exit 0.
  5. Overlap: no conflict against main d94dd3a6.
  6. Landing: pr_ready + automerge_enable through the relay.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 1, 2026 09:10
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 1, 2026 09:10
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 1, 2026
Merged via the queue into main with commit 0c03ce2 Nov 1, 2026
49 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-hprs5h branch November 1, 2026 09:38
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/m tests tooling

Projects

None yet

1 participant