Skip to content

Commit aa46d14

Browse files
fix(plugin-auth)!: Copy Invite Link is no longer offered or answered for a pending invitation past its expiry (#28529)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XFRN2q4SMrFcvRtmNhBFXJG)_ Fixes #28384 Clause-②: no (narrowing) Metadata-protocol: none ## 维护者速读 - 改了什么:邀请的「复制邀请链接」现在除了看状态是否为 pending,还看 `expires_at`。邀请已过期但状态仍是 pending 时,行菜单和记录页头不再显示这个操作;直接调用也会被拒,返回 409 `RESOURCE_CONFLICT`,文案说明邀请已过期、请重新发送。 - 为什么改:卡 #28384 与分诊 6541933328:过期邀请没有任务把状态改成 expired,管理员仍能复制并发出一个对方打开即被拒的链接。 - 风险与回滚:这是对一个平台操作的收紧(changeset 已标 BREAKING,`Clause-②: no (narrowing)`)。未过期的 pending 邀请照旧拿到链接;「重新发送邀请」不受影响,过期邀请上仍然显示,是补救办法。回滚即 revert 本 PR。 - 先看:`packages/plugins/plugin-auth/src/invitation-link-action.ts` 的新过期门(排在 pending 门之后)与 `isBeforeExpiry`;`packages/platform-objects/src/identity/sys-invitation.object.ts` 中 `copy_invitation_link` 的 `visible`;`invitation-delivery-outcome.test.ts` 末尾新增的 describe 块。 - 两分钟验证:`pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 src/invitation-delivery-outcome.test.ts`,预期 32 条全绿,其中过期 pending 邀请得 409。 - 需要你做的:无,席位落地。 ## What changes **The rule.** An expired invitation's link is no longer offered or answered. A `sys_invitation` row can still read `pending` after its `expires_at` has passed, because nothing moves it to `expired`. - **Handler** (`packages/plugins/plugin-auth/src/invitation-link-action.ts`). `createCopyInvitationLinkHandler` now reads `expires_at` with the row and adds a sixth gate after the pending gate, in the order the docblock lists: a row whose `expires_at` is not in the future is refused `409 RESOURCE_CONFLICT` with "This invitation has expired, so its link no longer lets anyone join. Resend the invitation to give the person a link that works." A missing or unreadable `expires_at` counts as expired, the way the vendor's own check reads it. The value is read whether the driver returns a `Date`, ISO text or epoch milliseconds. Reach is still judged first, so a caller outside the invite reach learns nothing about an expired row. - **Visibility** (`packages/platform-objects/src/identity/sys-invitation.object.ts`). `copy_invitation_link`'s `visible` adds `has(record.expires_at) && record.expires_at != null && datetime(record.expires_at) > now()`. `has()` opens the operand for the sparse `list_item` face, as `status` is opened, and the `!= null` half is the one an ordering comparison needs (the rule in `materializeDeclaredFields`, `packages/objectql/src/declared-fields.ts`). The grid already puts every `record.FIELD` an action predicate names into the row `$select` (`collectPredicateFieldRefs`), so no list view needed a new column. - **Resend is unchanged.** Measured before touching it: `resend_invitation` does not share the copy-link check (its `visible` is the lowered feature and reach gates only), and it is the remedy, so it stays offered on an expired row and gets no expiry gate. ## Tests - `packages/plugins/plugin-auth/src/invitation-delivery-outcome.test.ts`, new block `#28384 (e)`, on a real `AuthManager` over the installed better-auth: an expired pending row is refused 409 `RESOURCE_CONFLICT` and the message's first sentence is pinned; a row with an unreadable `expires_at` is refused the same way; controls: a pending row before expiry still answers its link for a `Date`, ISO text and epoch-milliseconds value; a plain member on an expired row still gets 403 (reach before expiry); Resend still answers 200 for the invitee and its answer has a link to copy. - `packages/platform-objects/src/identity/invitation-delivery-copy.test.ts`: the evaluated `visible` is false on a pending row past `expires_at`, and on a pending row projected without, or holding no, `expires_at`; Resend's evaluated `visible` stays true on that expired row. The existing "offered on a pending row" fixtures now carry a future `expires_at`. ## Acceptance notes - **`Clause-②` measured as `no (narrowing)`.** The claim expected `yes (narrowing)`. Measured: the diff narrows one surface (the handler refuses a row it answered, and `visible` follows) and widens none, so the value is `no`, with the narrowing arm. - **`Metadata-protocol: none`.** No key of any schema moves and no authorable value is refused: the `visible` value of one platform action changes, not its shape. - **Noted, not filed (seat to judge):** measured on the vendor route in this PR's harness, a resend of an expired pending invitation does not renew that row. better-auth 1.7.12 finds pending invitations with an expiry filter, so it issues a new invitation (a new id) and leaves the expired row reading `pending`. Resend's confirmation text ("Its expiry date starts over") is therefore not what happens on an expired row, and the Pending list then shows both rows for the same person. Out of this card's scope. ## Verification All readings on head 52e7cd7 (branch `claude/project-thread-rypvzz`, base `origin/main` 1f1a369). <details><summary>Red first: production sources at base, new tests kept</summary> `git restore --source=1f1a369b` on the two production files, tests at head (both suites import `src/` directly): - platform-objects `invitation-delivery-copy.test.ts`: 2 failed / 29 passed. Failed: "is not offered on a row still pending after its expires_at", "is not offered on a pending row projected without, or holding no, expires_at". - plugin-auth `invitation-delivery-outcome.test.ts`: 2 failed / 30 passed. Failed: "a row still pending after expires_at is refused 409", "a pending row with no readable expires_at is refused the same way". The controls (future row, reach before expiry, Resend) passed on base. - Restored with `git checkout HEAD --`; `git diff HEAD` empty. </details> <details><summary>Ablation: the handler gate alone</summary> `node scripts/ablation-replace.mjs` on `invitation-link-action.ts`, anchor ` if (!isBeforeExpiry(` → ` if (["ABL28384"].length < 0 && !isBeforeExpiry(` (anchor x1 → x0, marker x0 → x1, blob df97aac → c0980674), `visible` left at head: plugin-auth suite 2 failed / 30 passed, the same two handler cases. Restore: blob after restore df97aac = blob at HEAD; marker count 0. The `visible` change alone does not pass the handler cases, since `visible` is courtesy. </details> <details><summary>Package suites, typecheck, lint</summary> - `pnpm --filter @objectstack/platform-objects test`: exit 0, 104 files, 1477 passed. - `pnpm --filter @objectstack/plugin-auth test`: exit 0, 207 files, 3943 passed, 10 skipped. - `pnpm --filter @objectstack/platform-objects typecheck`: exit 0. `pnpm --filter @objectstack/plugin-auth typecheck`: exit 0. - Scoped lint `pnpm exec eslint --no-inline-config packages/platform-objects/src/identity packages/plugins/plugin-auth/src/invitation-link-action.ts packages/plugins/plugin-auth/src/invitation-delivery-outcome.test.ts`: exit 0. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 62 families; `--ran` reconciliation: 61 run with exit 0, 1 NOT MEASURED (`pnpm check:dual-build-cjs-loads`, exit 3, PREREQUISITE NOT MET: it needs every package's `dist`, only the dogfood closure was built; CI runs it). `check-plugin-teardown-shape --self-test` first answered exit 3 on the shallow clone and exit 0 after fetching its pinned control commit. Also `check-commit-card-trailers --range origin/main..HEAD` exit 0 and `check-platform-checklist --changed-from origin/main` exit 0. </details> <details><summary>Dogfood battery</summary> Head 52e7cd7, `pnpm --filter '@objectstack/dogfood^...' build` (exit 0), then under the lock `pnpm --filter @objectstack/dogfood test` with no shard and no path: `os-verify-lock: VERDICT command-exit 0`, Test Files 363 passed / 3 skipped (366), Tests 2932 passed / 28 skipped (2960), no failure. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5mvxN1Jh9iwvcsEv1R --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5mvxN1Jh9iwvcsEv1R)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 258fb1e commit aa46d14

5 files changed

Lines changed: 143 additions & 8 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
'@objectstack/platform-objects': patch
4+
---
5+
6+
fix(plugin-auth)!: Copy Invite Link is no longer offered or answered for a pending invitation whose expiry has passed
7+
8+
Clause-②: no (narrowing) — the `sys_invitation.copy_invitation_link` handler refuses, `409 RESOURCE_CONFLICT`, an invitation that still reads `pending` after its `expires_at`, a call it answered before; the action's `visible` predicate hides it on such a row. Nothing is widened.
9+
Metadata-protocol: none
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no metadata an author writes changes meaning: one platform action's handler narrows which rows it answers, and that action's own visible predicate follows it. The refusal is the handler's existing 409 RESOURCE_CONFLICT with a sentence naming expiry, so there is nothing for a migration to rewrite and no tombstone. -->
12+
13+
**BREAKING** row narrowing on `sys_invitation.copy_invitation_link` (`@objectstack/plugin-auth`, `@objectstack/platform-objects`).
14+
15+
- **The rule.** An expired invitation's link is no longer offered or answered. A row can still read `pending` after its `expires_at` has passed, because nothing moves it to `expired`; the handler now refuses it with `409 RESOURCE_CONFLICT`, as it refuses a non-pending row, and the row menu and record header no longer offer the action there. A missing or unreadable `expires_at` counts as expired.
16+
- **Unchanged.** The other gates and their order (cloud-managed, the record, the caller, invite reach, pending), the link a pending invitation before its expiry answers, and Resend Invitation, which stays offered on an expired row.
17+
- **Who sees it.** An organization admin who opens the row menu of an invitation past its expiry.
18+
19+
**The remedy.** Resend the invitation, then copy the link of the invitation Resend answers.

‎packages/platform-objects/src/identity/invitation-delivery-copy.test.ts‎

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
// `target`, its answer shown in a copyable text result dialog, offered on a
1313
// pending row to the grades that reach invite-member and hidden while the
1414
// environment is cloud-managed;
15+
// - #28384: it is not offered on a row that still reads `pending` after its
16+
// `expires_at` has passed (the handler refuses it too, plugin-auth
17+
// `invitation-delivery-outcome.test.ts`), while Resend stays offered there;
1518
// - the translated bundles carry a translation of every new leaf, never the
1619
// English source `--fill=default` seeds a new key with.
1720
//
@@ -134,8 +137,13 @@ const DELEGATE = user(['delegated_admin']);
134137
const MEMBER = user(['org_member']);
135138
const ON = { organization: true, cloudManaged: false };
136139

137-
function offered(record: Record<string, unknown>, current: unknown, features: Record<string, unknown>): boolean | string {
138-
const raw = action(SysInvitation, 'copy_invitation_link').visible;
140+
function offered(
141+
record: Record<string, unknown>,
142+
current: unknown,
143+
features: Record<string, unknown>,
144+
name = 'copy_invitation_link',
145+
): boolean | string {
146+
const raw = action(SysInvitation, name).visible;
139147
const source = typeof raw === 'string' ? raw : raw?.source;
140148
const r = celEngine.evaluate(
141149
{ dialect: 'cel', source },
@@ -145,8 +153,13 @@ function offered(record: Record<string, unknown>, current: unknown, features: Re
145153
return typeof r.value === 'boolean' ? r.value : `NON-BOOLEAN ${JSON.stringify(r.value)}`;
146154
}
147155

156+
/** ISO text a day from now, or a day ago: what a list row carries for a datetime. */
157+
const DAY_MS = 24 * 60 * 60 * 1000;
158+
const inADay = () => new Date(Date.now() + DAY_MS).toISOString();
159+
const aDayAgo = () => new Date(Date.now() - DAY_MS).toISOString();
160+
148161
describe('sys_invitation.copy_invitation_link — who is offered it', () => {
149-
const pending = { id: 'inv_1', status: 'pending' };
162+
const pending = { id: 'inv_1', status: 'pending', expires_at: inADay() };
150163

151164
it.each([
152165
['owner', OWNER],
@@ -168,6 +181,20 @@ describe('sys_invitation.copy_invitation_link — who is offered it', () => {
168181
expect(offered({ id: 'inv_1' }, OWNER, ON)).toBe(false);
169182
});
170183

184+
it('#28384 — is not offered on a row still pending after its expires_at', () => {
185+
expect(offered({ id: 'inv_1', status: 'pending', expires_at: aDayAgo() }, OWNER, ON)).toBe(false);
186+
});
187+
188+
it('#28384 — is not offered on a pending row projected without, or holding no, expires_at', () => {
189+
expect(offered({ id: 'inv_1', status: 'pending' }, OWNER, ON)).toBe(false);
190+
expect(offered({ id: 'inv_1', status: 'pending', expires_at: null }, OWNER, ON)).toBe(false);
191+
});
192+
193+
it('#28384 — Resend stays offered on a pending row past its expires_at: it is the remedy', () => {
194+
const expired = { id: 'inv_1', status: 'pending', expires_at: aDayAgo(), email: 'b@example.com', role: 'member' };
195+
expect(offered(expired, OWNER, ON, 'resend_invitation')).toBe(true);
196+
});
197+
171198
it('is hidden while the environment is cloud-managed', () => {
172199
expect(offered(pending, OWNER, { organization: true, cloudManaged: true })).toBe(false);
173200
});

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

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -190,10 +190,13 @@ export const SysInvitation = ObjectSchema.create({
190190
target: 'copy_invitation_link',
191191
// The gates of `resend_invitation` (the organization capability and the
192192
// grades that reach invite-member), hidden while cloud-managed as the
193-
// invite mirrors are, and offered on a pending row only. `has()` opens
194-
// the status operand for the sparse `list_item` face (#8990), as on the
195-
// recipient-side actions below.
196-
visible: "features.cloudManaged != true && has(record.status) && record.status == 'pending'",
193+
// invite mirrors are, and offered on a pending row only, before its
194+
// `expires_at`: a row can still read `pending` after it expires, and
195+
// its link admits no one (the handler refuses it too). `has()` opens
196+
// each operand for the sparse `list_item` face (#8990), as on the
197+
// recipient-side actions below; `expires_at` is compared by ordering,
198+
// so it also needs its `!= null` half (`materializeDeclaredFields`).
199+
visible: "features.cloudManaged != true && has(record.status) && record.status == 'pending' && has(record.expires_at) && record.expires_at != null && datetime(record.expires_at) > now()",
197200
requiresFeature: 'organization',
198201
requiresMembershipReach: 'invite_member',
199202
resultDialog: {

‎packages/plugins/plugin-auth/src/invitation-delivery-outcome.test.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717
// non-pending row.
1818
// (d) the action is refused while the environment is cloud-managed (the
1919
// declaration hides it there too, pinned in platform-objects).
20+
// (e) #28384: a row that still reads `pending` after its `expires_at` has
21+
// passed is refused 409 (the declaration hides it there too, pinned in
22+
// platform-objects), while Resend still answers for it.
2023
//
2124
// Every invitation is issued, resent, canceled and accepted through the
2225
// vendor's own routes on a real `AuthManager` over the installed better-auth,
@@ -356,6 +359,61 @@ describe('#27843 (c) — copy_invitation_link is answered only inside the invite
356359
});
357360
});
358361

362+
describe('#28384 (e) — copy_invitation_link refuses a pending invitation past its expires_at', () => {
363+
/** Move a pending row's `expires_at` into the past, leaving its status as it is. */
364+
const expire = (row: Record<string, any>) => {
365+
row.expires_at = new Date(Date.now() - 60_000);
366+
};
367+
368+
it('a row still pending after expires_at is refused 409, and is not handed a link', async () => {
369+
const { invite, owner, copyLink, invitationRow } = await stage();
370+
const id = String((await invite('bob.builder@acme.test')).body.id);
371+
expire(invitationRow(id));
372+
expect(invitationRow(id).status).toBe('pending');
373+
374+
const v = await refusalOf(copyLink(id, owner.userId));
375+
expect([v.status, v.code]).toEqual([409, 'RESOURCE_CONFLICT']);
376+
expect(v.message.split('. ')[0]).toBe('This invitation has expired, so its link no longer lets anyone join');
377+
});
378+
379+
it('a pending row with no readable expires_at is refused the same way', async () => {
380+
const { invite, owner, copyLink, invitationRow } = await stage();
381+
const id = String((await invite('bob.builder@acme.test')).body.id);
382+
invitationRow(id).expires_at = 'not a date';
383+
const v = await refusalOf(copyLink(id, owner.userId));
384+
expect([v.status, v.code]).toEqual([409, 'RESOURCE_CONFLICT']);
385+
});
386+
387+
it('a pending row before expires_at still answers its link, whatever shape the driver returns', async () => {
388+
const { invite, owner, copyLink, invitationRow } = await stage();
389+
const id = String((await invite('bob.builder@acme.test')).body.id);
390+
const later = Date.now() + 60 * 60 * 1000;
391+
for (const shape of [new Date(later), new Date(later).toISOString(), later]) {
392+
invitationRow(id).expires_at = shape;
393+
expect((await copyLink(id, owner.userId)).acceptUrl).toContain(`/accept-invitation/${id}`);
394+
}
395+
});
396+
397+
it('reach is judged before expiry: a plain member learns nothing about an expired row', async () => {
398+
const { invite, member, copyLink, invitationRow } = await stage();
399+
const id = String((await invite('bob.builder@acme.test')).body.id);
400+
expire(invitationRow(id));
401+
const v = await refusalOf(copyLink(id, member.userId));
402+
expect([v.status, v.code]).toEqual([403, 'PERMISSION_DENIED']);
403+
});
404+
405+
it('Resend still answers for the invitee, and its answer has a link to copy', async () => {
406+
const { invite, owner, copyLink, invitationRow } = await stage();
407+
const id = String((await invite('bob.builder@acme.test')).body.id);
408+
expire(invitationRow(id));
409+
410+
const resent = await invite('bob.builder@acme.test', { resend: true });
411+
expect(resent.status, resent.text).toBe(200);
412+
const answered = String(resent.body.id);
413+
expect((await copyLink(answered, owner.userId)).acceptUrl).toContain(`/accept-invitation/${answered}`);
414+
});
415+
});
416+
359417
describe('#27843 (d) — copy_invitation_link on a cloud-managed environment', () => {
360418
it('is refused 409 with the cloud-managed sentence, even for the owner of a pending row', async () => {
361419
const { invite, owner, copyLink } = await stage();

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

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,13 @@
4343
* `membership-reach-table.test.ts`. Before the status gate, so a caller
4444
* outside the reach learns nothing about the row.
4545
* 5. **Pending** — any other status has no link that admits anyone.
46+
* 6. **Not expired** — a row can still read `pending` after its `expires_at`
47+
* has passed (nothing moves it to `expired`), and its link admits no one:
48+
* the vendor's accept route refuses an invitation past `expires_at`. Refused
49+
* `409 RESOURCE_CONFLICT`, as a non-pending row is, so the admin is never
50+
* handed a link that fails when the invitee opens it. A missing or
51+
* unreadable `expires_at` is read as expired, the way the vendor reads it.
52+
* `resend_invitation` is not gated on expiry: it is the remedy.
4653
*
4754
* The invitation and the membership are re-read from the store under the
4855
* system context, never taken from the record the door projected for the
@@ -101,6 +108,18 @@ function nonEmptyString(value: unknown): string | undefined {
101108
return typeof value === 'string' && value.length > 0 ? value : undefined;
102109
}
103110

111+
/**
112+
* Is now still before the invitation's `expires_at`? The stored value may come
113+
* back as a `Date`, ISO text or epoch milliseconds, depending on the driver.
114+
* Anything that does not read as a date answers `false`, as the vendor's own
115+
* `new Date(expiresAt) > new Date()` check does.
116+
*/
117+
function isBeforeExpiry(expiresAt: unknown): boolean {
118+
if (!(expiresAt instanceof Date) && typeof expiresAt !== 'string' && typeof expiresAt !== 'number') return false;
119+
const ms = new Date(expiresAt).getTime();
120+
return Number.isFinite(ms) && ms > Date.now();
121+
}
122+
104123
/** The grades that reach `/organization/invite-member`. */
105124
const INVITE_REACH_GRADES: readonly string[] = MEMBERSHIP_REACH.invite_member.grades;
106125

@@ -128,7 +147,7 @@ export function createCopyInvitationLinkHandler(deps: CopyInvitationLinkDeps) {
128147

129148
const invitation: any = await sys.findOne(INVITATION_OBJECT, {
130149
where: { id: invitationId },
131-
fields: ['id', 'organization_id', 'status'],
150+
fields: ['id', 'organization_id', 'status', 'expires_at'],
132151
});
133152
if (!invitation) {
134153
throw refusal(404, 'RECORD_NOT_FOUND', `No invitation '${invitationId}' exists.`);
@@ -160,6 +179,15 @@ export function createCopyInvitationLinkHandler(deps: CopyInvitationLinkDeps) {
160179
);
161180
}
162181

182+
if (!isBeforeExpiry(invitation.expires_at)) {
183+
throw refusal(
184+
409,
185+
'RESOURCE_CONFLICT',
186+
'This invitation has expired, so its link no longer lets anyone join. '
187+
+ 'Resend the invitation to give the person a link that works.',
188+
);
189+
}
190+
163191
return { acceptUrl: deps.consoleUrl(invitationAcceptPath(String(invitation.id))) };
164192
};
165193
}

0 commit comments

Comments
 (0)