Skip to content

Commit fc9ba76

Browse files
claude[bot]claude
andauthored
Hold publicSharing.eligibility at redemption, not only at mint (#13857)
* fix(plugin-sharing,spec): re-evaluate publicSharing.eligibility at redemption `ShareLinkService.resolveToken()` now re-evaluates the object's declared `publicSharing.eligibility` predicate against the record it is about to serve, through the same `assertEligible` the mint path calls. A record reclassified out of the policy after a link was minted stops being served through that link; an unevaluable predicate refuses (fail-closed), matching mint. The refusal reuses the undifferentiated `null` a revoked, expired or unknown token already gets — no new error code, no new response branch, no usage stamp — because distinguishing those cases for a caller with no principal is an existence oracle. The readable reason goes to the server-side log. Docs: the semantics are now stated beside the `publicSharing.eligibility` declaration key, on `IShareLinkService.resolveToken`, and in the hand-written sharing security page with an upgrade note. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs * docs(spec): drop the internal issue id from the eligibility describe string `check:doc-authoring` refuses issue-id citations in customer-facing strings (maintainer ruling 2026-08-12) — `.describe()` prose projects into content/docs/references/** and the generated skill artifacts, where `#NNNN` resolves to nothing. The sentence is rephrased rather than truncated; the reference stays in the adjacent TSDoc, which only an internal reader sees. Also re-anchors the five system-context census citations this branch's line shifts moved (`check:system-context-census --fix`). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs * test(plugin-sharing): make the refusal-shape pin discriminate The first ablation run measured this case GREEN with the redemption gate removed: it deleted the reclassified record to exercise the #5190 arm, so the record-existence probe answered `null` for the reclassified token too and the assertion held for a reason that has nothing to do with eligibility. The record-gone arm now gets its own record, and the reclassified one stays in the table — ineligible, not gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 479ace6 commit fc9ba76

8 files changed

Lines changed: 681 additions & 26 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
---
2+
"@objectstack/plugin-sharing": minor
3+
"@objectstack/spec": minor
4+
---
5+
6+
fix(plugin-sharing,spec): hold `publicSharing.eligibility` at redemption, not only at mint (#13608)
7+
8+
**BREAKING** runtime behaviour change on a published package: share links that
9+
were legitimately minted can now stop resolving without anyone revoking them.
10+
Shipped as `minor` under the repo's launch-window convention.
11+
12+
`ShareLinkService.createLink()` evaluated the object's declared
13+
`publicSharing.eligibility` predicate before writing a `sys_share_link` row, and
14+
nothing evaluated it again. `resolveToken()` checked `revoked_at`, `expires_at`,
15+
the audience gates, the password and record EXISTENCE — then served whatever
16+
survived, under the system context, to a caller with no principal at all. So the
17+
declaration read as a standing policy about which records may be reached
18+
anonymously, while the platform held it at exactly one instant in a link's life.
19+
20+
The state the predicate reads is the state an editor changes. Publish an article
21+
`published` + `public`, mint a link, then flip `audience` to `internal` or
22+
`status` back to `draft`: the object's own policy now says the record is not
23+
eligible for link sharing, and the old token kept resolving and kept serving the
24+
record in full. The remedy was to revoke every link on the record by hand, which
25+
first requires knowing they exist.
26+
27+
It also sat oddly beside its neighbour. In that same `resolveToken()`, the
28+
record-existence probe is deliberately fail-CLOSED (an unanswered probe denies),
29+
so a **deleted** record stopped being served immediately while a
30+
**reclassified** one did not — two failure directions in one door.
31+
32+
**What changed.** `resolveToken()` re-evaluates the predicate against the record
33+
it is about to serve, through the same `assertEligible` the mint path calls, so
34+
the two points cannot drift on strictness, on the declared-field binding, or on
35+
which faults refuse. It is one read either way: when a predicate is declared the
36+
existence probe's projection widens from `['id']` to the whole row instead of a
37+
second query being issued, so an object with no `eligibility` key keeps the
38+
exact probe it always had. Fail-closed, matching mint: a predicate that will not
39+
compile, faults on the record, or answers anything other than `true` refuses.
40+
41+
**The refusal is deliberately indistinguishable.** For a caller who may hold
42+
nothing but a token, telling "does not exist" apart from "revoked" apart from
43+
"no longer eligible" is an existence oracle, so the redemption refusal is the
44+
same undifferentiated `null` a revoked, expired or unknown token already gets —
45+
no new error code, no new response branch, and no usage stamp. Over HTTP an
46+
ineligible link is answered with the generic `404 INVALID_OR_EXPIRED`, byte-for-
47+
byte what a token that never existed receives. The readable reason a link died
48+
is written to the server-side log instead.
49+
50+
**Operator impact.** Deployments upgrading across this change can feel it
51+
immediately: any live link whose record has since moved out of its object's
52+
`eligibility` predicate stops resolving, with no revocation event and no grace
53+
period. That is the intent — the alternative is a declared policy the platform
54+
does not hold — but it is worth measuring before rollout: an object's
55+
`eligibility` predicate is the thing to read, and the links at risk are those on
56+
records that no longer satisfy it. An operator who needs such links to keep
57+
working must widen the predicate; there is no per-link opt-out, deliberately.
58+
`redactFields` behaviour, the audience/password gates and objects that declare
59+
no `eligibility` key are all untouched and pinned.
60+
61+
<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is removed, renamed or re-shaped: `publicSharing.eligibility` keeps its name, its type and its accept-set, and the change is WHEN the platform evaluates it. There is therefore no tombstone for `objectstack migrate meta` to carry and no mechanical rewrite it could perform — a deployment whose links stop resolving must decide whether its own predicate is still the policy it wants, which is an authoring decision no ledger entry can make on its behalf. -->

‎content/docs/permissions/system-context.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ The largest single consumer — **20 of the 109 sites**.
135135
| 34 | `revoke()` deletes directly, **before** the non-manual-source guard | Get: the evaluator can revoke its own grants. Lose: the `CONFLICT` guard that warns a rule-materialised grant will be silently re-granted on the next reconcile | `plugin-sharing/src/sharing-service.ts:1286` (guard at `:1311`) |
136136
| 35 | `listShares()` skips the management gate | Get: full enumeration of who can see a record | `plugin-sharing/src/sharing-service.ts:1338` |
137137
| 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1077` |
138-
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link creation/resolution while the policy is off | `plugin-sharing/src/share-link-service.ts:413`, `:467`, `:471`, `:544`, `:574` |
138+
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link creation/resolution while the policy is off | `plugin-sharing/src/share-link-service.ts:423`, `:477`, `:481`, `:554`, `:584` |
139139
| 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` |
140140
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:157`, `:382` |
141141

‎content/docs/protocol/objectql/security.mdx‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,7 @@ publicSharing:
433433
allowedPermissions: [view]
434434
redactFields: [internal_notes]
435435
maxExpiryDays: 30
436+
eligibility: "record.status == 'published'"
436437
```
437438

438439
**Who may mint and revoke** (ADR-0111 D8). Minting a link requires the object's
@@ -443,6 +444,29 @@ Revoking a link is allowed for the link's **creator**, a **record share-manager*
443444
`canManageShares`), or system context: a link someone else minted on your record
444445
is your record's exposure to kill, not only its creator's.
445446

447+
**When `eligibility` is enforced** (#13608). The optional `eligibility` CEL
448+
predicate is a **standing policy about which records may be reached
449+
anonymously**, not a mint-time check. The platform evaluates it when a link is
450+
minted **and again on every redemption**, against the record it is about to
451+
serve. So flipping a record out of the policy — `audience` to internal, `status`
452+
back to draft — stops every token minted while it was in, immediately, with no
453+
revocation step to remember. Fail-**closed** at both points: a predicate that
454+
will not compile, or that faults on the record, refuses rather than assuming
455+
consent. What an anonymous holder sees is the ordinary "invalid or expired"
456+
answer, identical to the one an unknown token gets; the readable reason a link
457+
died is written to the server log, never to the response.
458+
459+
<Callout type="warn">
460+
**Upgrade note.** Before this, `eligibility` was enforced only at mint, so a
461+
record reclassified after a link was issued kept being served in full to
462+
anyone holding the URL. Deployments upgrading across that change can feel it:
463+
links minted while a record qualified stop resolving as soon as it stops
464+
qualifying. That is the intent — the alternative was a declared policy the
465+
platform did not hold — but if you depend on links surviving a
466+
reclassification, widen the predicate rather than relying on the old
467+
behaviour.
468+
</Callout>
469+
446470
---
447471

448472
## 6. Field-Level Encryption

‎content/docs/references/data/object.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,7 @@ const result = ApiMethod.parse(data);
432432
| **allowedPermissions** | `Enum<'view' \| 'comment' \| 'edit'>[]` | optional | Permission levels selectable on the share dialog |
433433
| **maxExpiryDays** | `integer` | optional | Reject links with expiry beyond this many days |
434434
| **redactFields** | `string[]` | optional | Field names removed from records served via a share token |
435-
| **eligibility** | `string` | optional | CEL expression that must evaluate to true on the target record |
435+
| **eligibility** | `string` | optional | CEL expression that must evaluate to true on the target record. Enforced at BOTH points in a link's life: when the link is minted, and again on every redemption — a record that stops qualifying stops being served through links already minted for it. Fail-closed: a predicate that will not compile, or that faults on the record, refuses. |
436436

437437
### Nested Shape: `Object.actions[number]`
438438

0 commit comments

Comments
 (0)