|
| 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. --> |
0 commit comments