Repository navigation
Commit 0c03ce2
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
File tree
- .changeset
- packages
- platform-objects/src
- apps/translations
- identity
- plugins/plugin-auth/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
848 | 848 | | |
849 | 849 | | |
850 | 850 | | |
851 | | - | |
| 851 | + | |
852 | 852 | | |
853 | 853 | | |
854 | 854 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
848 | 848 | | |
849 | 849 | | |
850 | 850 | | |
851 | | - | |
| 851 | + | |
852 | 852 | | |
853 | 853 | | |
854 | 854 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
848 | 848 | | |
849 | 849 | | |
850 | 850 | | |
851 | | - | |
| 851 | + | |
852 | 852 | | |
853 | 853 | | |
854 | 854 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
848 | 848 | | |
849 | 849 | | |
850 | 850 | | |
851 | | - | |
| 851 | + | |
852 | 852 | | |
853 | 853 | | |
854 | 854 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
145 | 145 | | |
146 | 146 | | |
147 | 147 | | |
148 | | - | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
149 | 154 | | |
150 | 155 | | |
151 | 156 | | |
| |||
170 | 175 | | |
171 | 176 | | |
172 | 177 | | |
173 | | - | |
| 178 | + | |
174 | 179 | | |
175 | 180 | | |
176 | 181 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
45 | 45 | | |
46 | 46 | | |
47 | 47 | | |
| 48 | + | |
48 | 49 | | |
49 | 50 | | |
50 | 51 | | |
| |||
234 | 235 | | |
235 | 236 | | |
236 | 237 | | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
237 | 245 | | |
238 | 246 | | |
239 | 247 | | |
| |||
| 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 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
114 | 114 | | |
115 | 115 | | |
116 | 116 | | |
117 | | - | |
| 117 | + | |
118 | 118 | | |
119 | 119 | | |
120 | 120 | | |
| |||
0 commit comments