Skip to content

Commit 0c03ce2

Browse files
fix(plugin-auth, platform-objects): resending an expired invitation replaces it, leaving one pending invitation (#28605)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XC7EVd3zBgDP3FhGA6C2k7Q)_ Fixes #28540 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:管理员对一条已过期的邀请点「重新发送邀请」时,系统照旧发出一份新邀请,但现在会把同一个人在同一组织里已过期、仍显示「待处理」的旧邀请标为「已过期」,所以「待处理」列表里每人只剩一行。直接再次邀请同一邮箱也一样。确认文案改为在两种情况下都成立:「有效期重新计算;已过期的邀请会被一份新邀请取代」(中、日、西语同步)。 - **为什么改**:卡 #28540(来自 #28384 开发报告的实测发现):better-auth 找待处理邀请时会跳过已过期的行,于是重发生成了新邀请,旧行一直挂在「待处理」,原文案「其有效期重新开始」也不对。 - **风险与回滚**:只在 invite-member 成功之后动作;被拒的请求什么都不改。未过期的邀请重发照旧续期(同一个 id)。别人的邀请、其他组织的邀请不受影响。改状态失败只记 warn,不影响新邀请。复制链接的门禁(#28529)未改动。回滚即 revert。 - **先看**:`packages/plugins/plugin-auth/src/invitation-expired-supersede.ts`(新的退役步骤);`packages/plugins/plugin-auth/src/auth-manager-after-hook.ts`(接入点);`packages/plugins/plugin-auth/src/invitation-resend-expired.test.ts`。 - **两分钟验证**:`pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 src/invitation-resend-expired.test.ts` → 15/15 通过(其中 5 条是本卡的)。 - **需要你做的**:无,席位落地。 ## 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 #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](https://claude.ai/code/session_01FEkQ5mL9b8QSVNXp9PcY6T)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 882fcdb commit 0c03ce2

10 files changed

Lines changed: 363 additions & 7 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
'@objectstack/platform-objects': patch
4+
---
5+
6+
fix(plugin-auth, platform-objects): resending an expired invitation replaces it, leaving one pending invitation for the person
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
- **`@objectstack/plugin-auth`**: when `POST /organization/invite-member` issues an invitation, the person's other pending invitations in that organization whose expiry has passed are now marked `expired`. A resend on an invitation that has already expired issues a new invitation, as before, and the expired one now moves to the Expired / Canceled list instead of staying in Pending beside the new one. A plain invite to an address whose earlier invitation expired does the same. A resend on an invitation that has not expired still renews that invitation (same id). A refused call changes nothing.
12+
- **`@objectstack/platform-objects`**: the Resend Invitation confirmation on `sys_invitation` now reads "Resend the invitation to {{ record.email }}? The expiry date starts over, and an invitation that has already expired is replaced by a new one.", which is true for both rows the action is offered on. The zh-CN, ja-JP and es-ES translations say the same.

‎packages/platform-objects/src/apps/translations/en.objects.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -848,7 +848,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
848848
},
849849
resend_invitation: {
850850
label: "Resend Invitation",
851-
description: "Resend the invitation to {{ record.email }}? Its expiry date starts over.",
851+
description: "Resend the invitation to {{ record.email }}? The expiry date starts over, and an invitation that has already expired is replaced by a new one.",
852852
successMessage: "Invitation resent",
853853
outcomeMessages: {
854854
sent: "Invitation resent to {{ result.email }}",

‎packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -848,7 +848,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
848848
},
849849
resend_invitation: {
850850
label: "Reenviar invitación",
851-
description: "¿Reenviar la invitación a {{ record.email }}? Su plazo de vencimiento vuelve a empezar.",
851+
description: "¿Reenviar la invitación a {{ record.email }}? El plazo de vencimiento vuelve a empezar, y una invitación que ya ha vencido se sustituye por una nueva.",
852852
successMessage: "Invitación reenviada",
853853
outcomeMessages: {
854854
sent: "Invitación reenviada a {{ result.email }}",

‎packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -848,7 +848,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
848848
},
849849
resend_invitation: {
850850
label: "招待を再送",
851-
description: "{{ record.email }} に招待を再送しますか?有効期限は改めて始まります。",
851+
description: "{{ record.email }} に招待を再送しますか?有効期限は改めて始まります。すでに期限切れの招待は新しい招待に置き換えられます。",
852852
successMessage: "招待を再送しました",
853853
outcomeMessages: {
854854
sent: "{{ result.email }} に招待を再送しました",

‎packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -848,7 +848,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
848848
},
849849
resend_invitation: {
850850
label: "重新发送邀请",
851-
description: "要重新发送邀请给 {{ record.email }} 吗?邀请的有效期将重新计算。",
851+
description: "要重新发送邀请给 {{ record.email }} 吗?有效期将重新计算;已过期的邀请会被一份新邀请取代。",
852852
successMessage: "邀请已重新发送",
853853
outcomeMessages: {
854854
sent: "已向 {{ result.email }} 重新发送邀请",

‎packages/platform-objects/src/identity/sys-invitation.object.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,12 @@ export const SysInvitation = ObjectSchema.create({
145145
// carried over from the row. invite-member renews a pending invitation
146146
// with its stored role whatever role is sent, and another email would
147147
// invite a different person, so neither is the user's to edit here.
148-
description: 'Resend the invitation to {{ record.email }}? Its expiry date starts over.',
148+
// [#28540] True for both rows Resend is offered on: an unexpired
149+
// invitation is renewed, and an expired one is replaced by a new
150+
// invitation while the old row moves to `expired` (plugin-auth's
151+
// `invitation-expired-supersede.ts`), so one pending row remains.
152+
description:
153+
'Resend the invitation to {{ record.email }}? The expiry date starts over, and an invitation that has already expired is replaced by a new one.',
149154
icon: 'send',
150155
variant: 'secondary',
151156
locations: ['list_item'],
@@ -170,7 +175,7 @@ export const SysInvitation = ObjectSchema.create({
170175
ai: {
171176
exposed: true,
172177
description:
173-
'Send a pending invitation (the record) again (params: its email and role, copied from the invitation; the stored role is kept). Its expiry date starts over. Use when the invitee lost the invitation or never received it.',
178+
'Send a pending invitation (the record) again (params: its email and role, copied from the invitation; the stored role is kept). The expiry date starts over, and an invitation that has already expired is replaced by a new one. Use when the invitee lost the invitation or never received it.',
174179
},
175180
},
176181
{

‎packages/plugins/plugin-auth/src/auth-manager-after-hook.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import { SIGN_IN_EMAIL_PATH } from './auth-session-audit.js';
4545
import { narrowDirectoryForExternalCaller } from './external-directory-door.js';
4646
import { rotateCallerBearerOnImpersonation } from './impersonation-bearer-rotation.js';
4747
import { stampInvitationDeliveryOutcome } from './invitation-delivery-outcome.js';
48+
import { retireExpiredInvitationsOnReissue } from './invitation-expired-supersede.js';
4849
import { codeInviteRoleNotFound } from './invite-role-not-found-code.js';
4950
import { readRequestClientAddress } from './request-client-address.js';
5051
import { stampRoleChangeGrantedSet } from './role-change-granted-set.js';
@@ -234,6 +235,13 @@ export async function globalAfterHook(this: AfterHookHost, ctx: any) {
234235
// `invitation-delivery-outcome.ts`). A refusal passes untouched.
235236
await stampInvitationDeliveryOutcome(ctx, () => this.isEmailDeliveryLive());
236237

238+
// ── #28540: a new invitation retires the person's expired pending one ──
239+
// The vendor skips an expired row when it looks for one to renew, so a
240+
// resend (or an invite) on it issues a new invitation; the expired row is
241+
// moved to `expired` here, so the Pending list holds one row per person
242+
// (see `invitation-expired-supersede.ts`). A refusal retires nothing.
243+
await retireExpiredInvitationsOnReissue(ctx);
244+
237245
// ── #27862: a role change names the permission set it granted ──
238246
// The before hook read the member's org-admin grants; a success that
239247
// left a new one held answers `outcome: 'permission_set_granted'` and
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #28540 — an invitation issued for a person retires that person's expired
5+
* pending invitations in the same organization.
6+
*
7+
* better-auth's `invite-member` looks for the person's pending invitation with
8+
* `findPendingInvitation`, which drops every row whose `expiresAt` has passed.
9+
* So a resend (`resend: true`, the `sys_invitation.resend_invitation` action)
10+
* on an invitation that has already expired finds nothing to renew and issues
11+
* a NEW invitation, and so does a plain invite. The expired row keeps reading
12+
* `pending`: nothing in the vendor or in the platform ever moves it, and the
13+
* Pending list then shows two rows for one person, the older one admitting
14+
* no one.
15+
*
16+
* This runs in the global `after` hook, only on a successful invite-member
17+
* answer, so it acts only once the vendor has admitted the caller and written
18+
* the answered row; a refusal retires nothing. It moves each of the person's
19+
* other pending rows in that organization whose `expires_at` has passed to
20+
* `expired` (the ObjectStack half of `InvitationStatus`; better-auth has no
21+
* such value), so the row moves to the Expired / Canceled list. A resend that
22+
* renewed an unexpired row answers that same row, which is excluded by id;
23+
* any older expired row beside it is retired too.
24+
*
25+
* An expiry that does not read as a date counts as expired, as the vendor's
26+
* own check and the copy-link gate read it. The write names `status:
27+
* 'pending'` beside the id, so a row that left `pending` meanwhile (accepted,
28+
* declined, canceled) is not touched.
29+
*
30+
* Failure-isolated: the invitation is already issued and its mail handed off
31+
* when this runs, so a read or write that fails leaves the old row as it was
32+
* (the state before this existed), says so once at `warn`, and never turns
33+
* the issued invitation into an error answer.
34+
*/
35+
36+
import { INVITE_MEMBER_PATH } from './invite-role-not-found-code.js';
37+
import { isBeforeExpiry } from './invitation-link-action.js';
38+
39+
/** The better-auth model name of `sys_invitation`. */
40+
const INVITATION_MODEL = 'invitation';
41+
42+
/** The status a retired row takes. */
43+
const RETIRED_STATUS = 'expired';
44+
45+
/** One `where` clause of the better-auth adapter. */
46+
interface AdapterWhere {
47+
field: string;
48+
value: unknown;
49+
}
50+
51+
/**
52+
* The two better-auth adapter calls this reads and writes through
53+
* (`ctx.context.adapter`): keyword-object calls on a vendor model name, the
54+
* same ones the vendor's own invitation routes make, not the data engine's
55+
* `(object, data, options)` door.
56+
*/
57+
interface InvitationRowAdapter {
58+
findMany(args: { model: string; where: AdapterWhere[] }): Promise<any[]>;
59+
update(args: { model: string; where: AdapterWhere[]; update: Record<string, unknown> }): Promise<unknown>;
60+
}
61+
62+
/**
63+
* Retire the answered person's expired pending invitations after a successful
64+
* invite-member. A no-op on every other path, on a refusal, and on an answer
65+
* that does not carry an invitation's id, email and organization. Never throws.
66+
*/
67+
export async function retireExpiredInvitationsOnReissue(ctx: any): Promise<void> {
68+
if (ctx?.path !== INVITE_MEMBER_PATH) return;
69+
const returned = ctx?.context?.returned;
70+
if (!returned || typeof returned !== 'object' || Array.isArray(returned)) return;
71+
try {
72+
const { isAPIError } = await import('better-auth/api');
73+
if (isAPIError(returned)) return;
74+
} catch {
75+
if (returned instanceof Error) return;
76+
}
77+
const answeredId = typeof returned.id === 'string' ? returned.id : undefined;
78+
const email = typeof returned.email === 'string' ? returned.email.toLowerCase() : undefined;
79+
const organizationId = typeof returned.organizationId === 'string' ? returned.organizationId : undefined;
80+
const adapter: InvitationRowAdapter | undefined = ctx?.context?.adapter;
81+
if (!answeredId || !email || !organizationId || !adapter) return;
82+
83+
try {
84+
const pending = await adapter.findMany({
85+
model: INVITATION_MODEL,
86+
where: [
87+
{ field: 'email', value: email },
88+
{ field: 'organizationId', value: organizationId },
89+
{ field: 'status', value: 'pending' },
90+
],
91+
});
92+
for (const row of pending ?? []) {
93+
if (!row || row.id === answeredId || isBeforeExpiry(row.expiresAt)) continue;
94+
await adapter.update({
95+
model: INVITATION_MODEL,
96+
where: [
97+
{ field: 'id', value: row.id },
98+
{ field: 'status', value: 'pending' },
99+
],
100+
update: { status: RETIRED_STATUS },
101+
});
102+
}
103+
} catch (err: any) {
104+
console.warn(
105+
'[auth] The new invitation was issued, but an earlier expired invitation for the same person could '
106+
+ 'not be marked expired, so it still reads pending in the invitation list (it admits no one): '
107+
+ `${err?.message ?? String(err)}`,
108+
);
109+
}
110+
}

‎packages/plugins/plugin-auth/src/invitation-link-action.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ function nonEmptyString(value: unknown): string | undefined {
114114
* Anything that does not read as a date answers `false`, as the vendor's own
115115
* `new Date(expiresAt) > new Date()` check does.
116116
*/
117-
function isBeforeExpiry(expiresAt: unknown): boolean {
117+
export function isBeforeExpiry(expiresAt: unknown): boolean {
118118
if (!(expiresAt instanceof Date) && typeof expiresAt !== 'string' && typeof expiresAt !== 'number') return false;
119119
const ms = new Date(expiresAt).getTime();
120120
return Number.isFinite(ms) && ms > Date.now();

0 commit comments

Comments
 (0)