Repository navigation
Commit aa46d14
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
- .changeset
- packages
- platform-objects/src/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 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
12 | 12 | | |
13 | 13 | | |
14 | 14 | | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
15 | 18 | | |
16 | 19 | | |
17 | 20 | | |
| |||
134 | 137 | | |
135 | 138 | | |
136 | 139 | | |
137 | | - | |
138 | | - | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
139 | 147 | | |
140 | 148 | | |
141 | 149 | | |
| |||
145 | 153 | | |
146 | 154 | | |
147 | 155 | | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
148 | 161 | | |
149 | | - | |
| 162 | + | |
150 | 163 | | |
151 | 164 | | |
152 | 165 | | |
| |||
168 | 181 | | |
169 | 182 | | |
170 | 183 | | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
171 | 198 | | |
172 | 199 | | |
173 | 200 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
190 | 190 | | |
191 | 191 | | |
192 | 192 | | |
193 | | - | |
194 | | - | |
195 | | - | |
196 | | - | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
197 | 200 | | |
198 | 201 | | |
199 | 202 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
17 | 17 | | |
18 | 18 | | |
19 | 19 | | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
20 | 23 | | |
21 | 24 | | |
22 | 25 | | |
| |||
356 | 359 | | |
357 | 360 | | |
358 | 361 | | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
359 | 417 | | |
360 | 418 | | |
361 | 419 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
43 | 43 | | |
44 | 44 | | |
45 | 45 | | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
46 | 53 | | |
47 | 54 | | |
48 | 55 | | |
| |||
101 | 108 | | |
102 | 109 | | |
103 | 110 | | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
104 | 123 | | |
105 | 124 | | |
106 | 125 | | |
| |||
128 | 147 | | |
129 | 148 | | |
130 | 149 | | |
131 | | - | |
| 150 | + | |
132 | 151 | | |
133 | 152 | | |
134 | 153 | | |
| |||
160 | 179 | | |
161 | 180 | | |
162 | 181 | | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
163 | 191 | | |
164 | 192 | | |
165 | 193 | | |
| |||
0 commit comments