From 2ad0b3138070a45dad81a7a326252e9de64f2aa1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 14:09:08 +0000 Subject: [PATCH 1/3] fix(plugin-sharing,spec): re-evaluate publicSharing.eligibility at redemption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs --- .../share-link-eligibility-at-redemption.md | 61 +++ content/docs/protocol/objectql/security.mdx | 24 ++ content/docs/references/data/object.mdx | 2 +- .../src/share-link-eligibility.test.ts | 398 +++++++++++++++++- .../plugin-sharing/src/share-link-service.ts | 148 ++++++- .../spec/src/contracts/share-link-service.ts | 27 +- packages/spec/src/data/object.zod.ts | 30 +- 7 files changed, 665 insertions(+), 25 deletions(-) create mode 100644 .changeset/share-link-eligibility-at-redemption.md diff --git a/.changeset/share-link-eligibility-at-redemption.md b/.changeset/share-link-eligibility-at-redemption.md new file mode 100644 index 0000000000..31b30da671 --- /dev/null +++ b/.changeset/share-link-eligibility-at-redemption.md @@ -0,0 +1,61 @@ +--- +"@objectstack/plugin-sharing": minor +"@objectstack/spec": minor +--- + +fix(plugin-sharing,spec): hold `publicSharing.eligibility` at redemption, not only at mint (#13608) + +**BREAKING** runtime behaviour change on a published package: share links that +were legitimately minted can now stop resolving without anyone revoking them. +Shipped as `minor` under the repo's launch-window convention. + +`ShareLinkService.createLink()` evaluated the object's declared +`publicSharing.eligibility` predicate before writing a `sys_share_link` row, and +nothing evaluated it again. `resolveToken()` checked `revoked_at`, `expires_at`, +the audience gates, the password and record EXISTENCE — then served whatever +survived, under the system context, to a caller with no principal at all. So the +declaration read as a standing policy about which records may be reached +anonymously, while the platform held it at exactly one instant in a link's life. + +The state the predicate reads is the state an editor changes. Publish an article +`published` + `public`, mint a link, then flip `audience` to `internal` or +`status` back to `draft`: the object's own policy now says the record is not +eligible for link sharing, and the old token kept resolving and kept serving the +record in full. The remedy was to revoke every link on the record by hand, which +first requires knowing they exist. + +It also sat oddly beside its neighbour. In that same `resolveToken()`, the +record-existence probe is deliberately fail-CLOSED (an unanswered probe denies), +so a **deleted** record stopped being served immediately while a +**reclassified** one did not — two failure directions in one door. + +**What changed.** `resolveToken()` re-evaluates the predicate against the record +it is about to serve, through the same `assertEligible` the mint path calls, so +the two points cannot drift on strictness, on the declared-field binding, or on +which faults refuse. It is one read either way: when a predicate is declared the +existence probe's projection widens from `['id']` to the whole row instead of a +second query being issued, so an object with no `eligibility` key keeps the +exact probe it always had. Fail-closed, matching mint: a predicate that will not +compile, faults on the record, or answers anything other than `true` refuses. + +**The refusal is deliberately indistinguishable.** For a caller who may hold +nothing but a token, telling "does not exist" apart from "revoked" apart from +"no longer eligible" is an existence oracle, so the redemption refusal is the +same undifferentiated `null` a revoked, expired or unknown token already gets — +no new error code, no new response branch, and no usage stamp. Over HTTP an +ineligible link is answered with the generic `404 INVALID_OR_EXPIRED`, byte-for- +byte what a token that never existed receives. The readable reason a link died +is written to the server-side log instead. + +**Operator impact.** Deployments upgrading across this change can feel it +immediately: any live link whose record has since moved out of its object's +`eligibility` predicate stops resolving, with no revocation event and no grace +period. That is the intent — the alternative is a declared policy the platform +does not hold — but it is worth measuring before rollout: an object's +`eligibility` predicate is the thing to read, and the links at risk are those on +records that no longer satisfy it. An operator who needs such links to keep +working must widen the predicate; there is no per-link opt-out, deliberately. +`redactFields` behaviour, the audience/password gates and objects that declare +no `eligibility` key are all untouched and pinned. + + diff --git a/content/docs/protocol/objectql/security.mdx b/content/docs/protocol/objectql/security.mdx index 0984480418..3d3231d6de 100644 --- a/content/docs/protocol/objectql/security.mdx +++ b/content/docs/protocol/objectql/security.mdx @@ -433,6 +433,7 @@ publicSharing: allowedPermissions: [view] redactFields: [internal_notes] maxExpiryDays: 30 + eligibility: "record.status == 'published'" ``` **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* `canManageShares`), or system context: a link someone else minted on your record is your record's exposure to kill, not only its creator's. +**When `eligibility` is enforced** (#13608). The optional `eligibility` CEL +predicate is a **standing policy about which records may be reached +anonymously**, not a mint-time check. The platform evaluates it when a link is +minted **and again on every redemption**, against the record it is about to +serve. So flipping a record out of the policy — `audience` to internal, `status` +back to draft — stops every token minted while it was in, immediately, with no +revocation step to remember. Fail-**closed** at both points: a predicate that +will not compile, or that faults on the record, refuses rather than assuming +consent. What an anonymous holder sees is the ordinary "invalid or expired" +answer, identical to the one an unknown token gets; the readable reason a link +died is written to the server log, never to the response. + + +**Upgrade note.** Before this, `eligibility` was enforced only at mint, so a +record reclassified after a link was issued kept being served in full to +anyone holding the URL. Deployments upgrading across that change can feel it: +links minted while a record qualified stop resolving as soon as it stops +qualifying. That is the intent — the alternative was a declared policy the +platform did not hold — but if you depend on links surviving a +reclassification, widen the predicate rather than relying on the old +behaviour. + + --- ## 6. Field-Level Encryption diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 11ff6a09a3..de99c850a2 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -431,7 +431,7 @@ const result = ApiMethod.parse(data); | **allowedPermissions** | `Enum<'view' \| 'comment' \| 'edit'>[]` | optional | Permission levels selectable on the share dialog | | **maxExpiryDays** | `integer` | optional | Reject links with expiry beyond this many days | | **redactFields** | `string[]` | optional | Field names removed from records served via a share token | -| **eligibility** | `string` | optional | CEL expression that must evaluate to true on the target record | +| **eligibility** | `string` | optional | CEL expression that must evaluate to true on the target record. Enforced at BOTH mint and every redemption (#13608): a record that stops qualifying stops being served through links already minted for it. Fail-closed — a predicate that will not compile or faults on the record refuses. | ### Nested Shape: `Object.actions[number]` diff --git a/packages/plugins/plugin-sharing/src/share-link-eligibility.test.ts b/packages/plugins/plugin-sharing/src/share-link-eligibility.test.ts index f78ad1fc82..5c1d2a6f5c 100644 --- a/packages/plugins/plugin-sharing/src/share-link-eligibility.test.ts +++ b/packages/plugins/plugin-sharing/src/share-link-eligibility.test.ts @@ -37,6 +37,27 @@ * `maxExpiryDays`) had no pin in this package before. That set is what makes * the ablation meaningful: with the new predicate removed, the eligibility * cases must flip red and these five must stay green. + * + * ## [#13608] The second seam: the same policy, held again at REDEMPTION + * + * Enforcing at mint alone left the adjacent half open, and the state the + * predicate reads is exactly the state an editor changes: publish an article + * `public`, mint a link, then flip `audience` to `internal` or `status` back to + * `draft`, and the token minted before the flip kept resolving — serving the + * record in full to a caller with no principal. The maintainer ruled + * (2026-08-31) that `resolveToken` re-evaluates the predicate before serving, + * fail-closed, and that the refusal reuses the undifferentiated `null` a + * revoked link already gets rather than inventing a distinguishable "no longer + * eligible" answer for an anonymous caller — that distinction is an existence + * oracle. + * + * So the redemption block below pins three things the mint block cannot: the + * reclassification repro on BOTH flips, the refusal's SHAPE (measured at the + * service seam and again at the HTTP seam an anonymous holder actually + * reaches), and the fail-closed arm asserted by its REASON rather than by its + * outcome — a predicate that cannot compile can never be merely false, and the + * reason it refuses is read off the server-side log, which is the only place + * the ruling leaves it. */ import { describe, it, expect, afterEach } from 'vitest'; @@ -46,7 +67,12 @@ import type { DriverQuery } from '@objectstack/spec/contracts'; // with this cannot accept a call the real engine refuses // (`check:engine-double-contract`). import { assertEngineUpdateDispatch, assertEngineFindOnePredicate } from '@objectstack/objectql'; +import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; import { ShareLinkService } from './share-link-service.js'; +// [#13608] The PUBLIC seam an anonymous holder actually reaches. The refusal's +// shape is a claim about what that caller can observe, so it is measured there +// and not only on the service's return value. +import { registerShareLinkRoutes } from './share-link-routes.js'; import { SysShareLink } from './objects/sys-share-link.object.js'; /** @@ -94,6 +120,9 @@ afterEach(async () => { } }); +/** A refusal line the service wrote to its server-side log. */ +interface LoggedRefusal { msg: string; meta?: Record } + interface BootOptions { /** * The object definition the DRIVER is initialised from, when it must differ @@ -109,6 +138,12 @@ interface BootOptions { * own-key-`undefined` block for what is reproduced and why it is real. */ shapeRow?: (row: any) => any; + /** + * [#13608] Collect the service's server-side log. The redemption refusal is + * deliberately silent on the wire, so this is where the REASON it refused + * becomes assertable — and where the ruling says the reason belongs. + */ + logger?: { warn: (msg: string, meta?: Record) => void }; } /** @@ -129,9 +164,16 @@ async function boot(article: any = ARTICLE, options: BootOptions = {}) { for (const row of ROWS) await driver.create('article', row); const schemas: Record = { article, sys_share_link: SysShareLink }; + /** + * [#13608] Every read the service issues, in order. Two claims are measured + * off it: the `id`-only probe is UNCHANGED for an object with no predicate, + * and the eligibility path widens that same read rather than adding a second. + */ + const findCalls: Array<{ object: string; query: any }> = []; const engine = { getSchema: (name: string) => schemas[name], find: async (object: string, query: any) => { + findCalls.push({ object, query }); const rows = await driver.find(object, query); if (!options.shapeRow || object !== 'article' || !Array.isArray(rows)) return rows; return rows.map(options.shapeRow); @@ -148,8 +190,11 @@ async function boot(article: any = ARTICLE, options: BootOptions = {}) { return driver.update(object, dispatch.id as string, data); }, }; - const service = new ShareLinkService({ engine: engine as any }); - return { driver, service }; + const service = new ShareLinkService({ + engine: engine as any, + ...(options.logger ? { logger: options.logger } : {}), + }); + return { driver, service, engine, schemas, findCalls }; } /** Every row currently in the share-link table — the thing that must stay empty. */ @@ -547,3 +592,352 @@ describe('[#7861] publicSharing.eligibility is enforced at createLink', () => { }); }); }); + + +/** + * [#13608] The redemption seam. + * + * `resolveToken` checked `revoked_at`, `expires_at`, the audience gates, the + * password and record EXISTENCE — and served whatever survived, under + * `SYSTEM_CTX`, to a caller with no principal. The object's declared + * eligibility policy was consulted at mint and never again, so a record could + * be reclassified out of the policy and keep being published by a token minted + * while it was in. + */ +describe('[#13608] publicSharing.eligibility is enforced again at REDEMPTION', () => { + /** A token minted while `a_ok` still qualifies — the pre-condition of every case below. */ + async function mintOn(service: ShareLinkService, recordId = 'a_ok', extra: Record = {}) { + const link = await service.createLink( + { object: 'article', recordId, audience: 'public', permission: 'view', ...extra }, + CALLER, + ); + expect(link.token).toBeTruthy(); + return link; + } + + /** The row as the table holds it now — usage counters included. */ + async function linkRow(driver: SqlDriver, id: string): Promise { + const rows = await driver.find('sys_share_link', {} as DriverQuery); + return rows.find((r: any) => r.id === id); + } + + describe('THE REPRO — a record reclassified after mint stops being served', () => { + it('`audience` flipped to internal: the same token resolves, then does not', async () => { + const { driver, service } = await boot(); + const link = await mintOn(service); + + // Minted while eligible, and serving. + expect(await service.resolveToken(link.token, {})).not.toBeNull(); + + // The editor's flip — the exact step the card measured. + await driver.update('article', 'a_ok', { audience: 'internal' }); + + // Pre-fix this still returned the record in full to an anonymous caller. + expect(await service.resolveToken(link.token, {})).toBeNull(); + }); + + it('`status` reverted to draft: the same token resolves, then does not', async () => { + const { driver, service } = await boot(); + const link = await mintOn(service); + expect(await service.resolveToken(link.token, {})).not.toBeNull(); + + await driver.update('article', 'a_ok', { status: 'draft' }); + + expect(await service.resolveToken(link.token, {})).toBeNull(); + }); + }); + + /** + * Ruling item 2 (maintainer, 2026-08-31): the refusal REUSES the answer a + * revoked / expired link already gets. Distinguishing "does not exist" from + * "revoked" from "no longer eligible" for a caller with no principal is an + * existence oracle, judged the way this repo's `RESOURCE_NOT_FOUND` pins + * judge one. + * + * "It refuses" does not cover that, so the shape itself is asserted — twice, + * because the claim is about two different observers. + */ + describe('the refusal is the answer revoked / expired / unknown already give', () => { + it('at the service seam it is `null` — the identical value, not a lookalike', async () => { + const { driver, service } = await boot(); + const live = await mintOn(service); + const revoked = await mintOn(service); + const expired = await mintOn(service); + const gone = await mintOn(service, 'a_ok'); + + await service.revokeLink(revoked.token, { isSystem: true } as any); + await driver.update('sys_share_link', expired.id, { + expires_at: new Date(Date.now() - 60_000).toISOString(), + }); + + // The reclassification, applied after every token above was minted. + await driver.update('article', 'a_ok', { audience: 'internal' }); + // …and one record removed outright, for the #5190 arm of the same door. + await driver.delete('article', 'a_ok'); + + const answers = { + reclassified: await service.resolveToken(live.token, {}), + revoked: await service.resolveToken(revoked.token, {}), + expired: await service.resolveToken(expired.token, {}), + recordGone: await service.resolveToken(gone.token, {}), + unknown: await service.resolveToken('zzzzzzzzzzzzzzzzzzzzzz', {}), + }; + + // Identity, not deep equality: every arm returns the SAME value, so + // there is nothing on the wire to tell them apart. + for (const [name, answer] of Object.entries(answers)) { + expect(answer, `${name} must answer with the shared refusal`).toBeNull(); + } + expect(Object.values(answers).every((a) => a === answers.revoked)).toBe(true); + }); + + it('the ineligible arm returns — it never throws a code an anonymous caller could read', async () => { + const { driver, service } = await boot(); + const link = await mintOn(service); + await driver.update('article', 'a_ok', { audience: 'internal' }); + + // `assertEligible` throws `RECORD_NOT_ELIGIBLE`; letting that escape here + // would hand the route a 422 with the policy's own text in it. + await expect(service.resolveToken(link.token, {})).resolves.toBeNull(); + }); + + /** + * The HTTP seam, driven end-to-end on the real service through the real + * route, with the route's SECURE default context — every request below is + * anonymous. + * + * What this measures, and why it is the honest reading of ruling item 2: + * the reclassified link lands in the route's generic "invalid / expired / + * revoked" answer, byte-for-byte the same one a token that NEVER EXISTED + * gets. It does not land in the 410 `EXPIRED_OR_REVOKED` branch, and it + * must not: the route picks that branch off `revoked_at` / `expires_at` on + * the row itself, so reaching it would require the service to hand the + * route a distinguishable "ineligible" answer — exactly what the ruling + * forbids. The bucket it does land in is the strictly LESS informative of + * the two: 410 would confirm to a holder that the token was real. + */ + it('at the HTTP seam an anonymous caller cannot tell it from an unknown token', async () => { + const { driver, service, engine } = await boot(); + const live = await mintOn(service); + const revoked = await mintOn(service); + await service.revokeLink(revoked.token, { isSystem: true } as any); + + const resolve = mountResolveRoute(service, engine); + + // Before the flip: the link serves the record. + const served = await resolve(live.token); + expect(served.status).toBe(200); + + await driver.update('article', 'a_ok', { audience: 'internal' }); + + const reclassified = await resolve(live.token); + const unknown = await resolve('zzzzzzzzzzzzzzzzzzzzzz'); + const revokedAnswer = await resolve(revoked.token); + + // The equality that IS ruling item 2 at this seam. + expect(reclassified).toEqual(unknown); + expect(reclassified.status).toBe(404); + expect(reclassified.body?.error?.code).toBe('INVALID_OR_EXPIRED'); + // Nothing about the policy, the predicate or the record reaches the wire. + expect(JSON.stringify(reclassified.body).toLowerCase()).not.toContain('eligib'); + expect(JSON.stringify(reclassified.body).toLowerCase()).not.toContain('audience'); + + // The pre-existing revoked bucket, recorded as measured rather than + // assumed: it is a DIFFERENT status, and this change does not move it. + expect(revokedAnswer.status).toBe(410); + expect(revokedAnswer.body?.error?.code).toBe('EXPIRED_OR_REVOKED'); + }); + + it('a refused redemption stamps no usage — exactly like a revoked one', async () => { + const { driver, service } = await boot(); + const link = await mintOn(service); + await driver.update('article', 'a_ok', { audience: 'internal' }); + + expect(await service.resolveToken(link.token, {})).toBeNull(); + + const row = await linkRow(driver, link.id); + expect(row.use_count ?? 0).toBe(0); + expect(row.last_used_at ?? null).toBeNull(); + }); + }); + + /** + * The fail-closed arm, asserted by the REASON it refuses. + * + * A case whose predicate is false anyway would pass with the whole + * fail-closed branch deleted, so each case here uses a record that WOULD + * qualify under the predicate it was minted with, and a predicate that + * cannot be false at all — it can only fault. The reason is read off the + * server-side log, which is the only place the ruling leaves it. + */ + describe('fail-closed: an unanswered predicate refuses, and says why only in the log', () => { + /** Mint under the working policy, then swap the object's predicate underneath the token. */ + async function mintThenBreakPredicate(eligibility: string) { + const logged: LoggedRefusal[] = []; + const { driver, service, schemas } = await boot(ARTICLE, { + logger: { warn: (msg, meta) => { logged.push({ msg, meta }); } }, + }); + const link = await mintOn(service); + + // The control: this token, this record, resolves under the declared + // policy. So a later refusal is about the SWAP, not about the record. + expect(await service.resolveToken(link.token, {})).not.toBeNull(); + + schemas.article = { ...ARTICLE, publicSharing: { ...ARTICLE.publicSharing, eligibility } }; + return { driver, service, link, logged }; + } + + it('a predicate that no longer compiles refuses a record that still qualifies', async () => { + const { service, link, logged } = await mintThenBreakPredicate('record.status ===== '); + + expect(await service.resolveToken(link.token, {})).toBeNull(); + + // The reason, and the only place it exists. `a_ok` is `published` + + // `public`, so no false verdict is available here — this refusal can + // only have come from the unevaluable arm. + expect(logged).toHaveLength(1); + expect(logged[0].meta?.reason).toBe('ELIGIBILITY_UNEVALUABLE'); + expect(logged[0].meta?.link).toBe(link.id); + expect(logged[0].meta?.record).toBe('a_ok'); + }); + + it('a predicate naming an UNDECLARED key refuses as a fault, not as a verdict', async () => { + const { service, link, logged } = await mintThenBreakPredicate("record.nope == 'x'"); + + expect(await service.resolveToken(link.token, {})).toBeNull(); + expect(logged[0].meta?.reason).toBe('ELIGIBILITY_UNEVALUABLE'); + }); + + it('a predicate resolving to a non-boolean has not consented', async () => { + const { service, link, logged } = await mintThenBreakPredicate('record.title'); + + expect(await service.resolveToken(link.token, {})).toBeNull(); + expect(logged[0].meta?.reason).toBe('RECORD_NOT_ELIGIBLE'); + }); + + it('the refusal reason names the two things an operator needs, and stays server-side', async () => { + const { service, link, logged } = await mintThenBreakPredicate('record.status ===== '); + await service.resolveToken(link.token, {}); + + expect(logged[0].msg).toContain('publicSharing.eligibility'); + expect(logged[0].meta?.detail).toContain('record.status ===== '); + }); + }); + + /** + * The other half of the guarantee: everything that was serving before must + * still serve, and the read must not have grown a second query. + */ + describe('nothing else moved', () => { + it('a record that stays eligible resolves exactly as before, redaction included', async () => { + const { driver, service } = await boot(); + const link = await mintOn(service, 'a_ok', { redactFields: ['title'] }); + + // A write that does NOT cross the policy — the record still qualifies. + await driver.update('article', 'a_ok', { title: 'Renamed, still published + public' }); + + const resolved = await service.resolveToken(link.token, {}); + expect(resolved).not.toBeNull(); + expect(resolved!.link.record_id).toBe('a_ok'); + // object default ∪ per-link, unchanged by this card. + expect(resolved!.redactFields).toEqual(['owner_id', 'title']); + + const row = await linkRow(driver, link.id); + expect(row.use_count).toBe(1); + expect(row.last_used_at).toBeTruthy(); + }); + + it('an object with NO eligibility key is untouched by a reclassification', async () => { + const noEligibility = { ...ARTICLE, publicSharing: { ...ARTICLE.publicSharing, eligibility: undefined } }; + const { driver, service } = await boot(noEligibility); + const link = await mintOn(service); + + await driver.update('article', 'a_ok', { audience: 'internal', status: 'draft' }); + + // No declared policy, so nothing to hold: the link keeps serving. + expect(await service.resolveToken(link.token, {})).not.toBeNull(); + }); + + it('the existence probe keeps its `id`-only projection when no predicate is declared', async () => { + const noEligibility = { ...ARTICLE, publicSharing: { ...ARTICLE.publicSharing, eligibility: undefined } }; + const { service, findCalls } = await boot(noEligibility); + const link = await mintOn(service); + + findCalls.length = 0; + expect(await service.resolveToken(link.token, {})).not.toBeNull(); + + const articleReads = findCalls.filter((c) => c.object === 'article'); + expect(articleReads).toHaveLength(1); + expect(articleReads[0].query.fields).toEqual(['id']); + }); + + it('the eligibility read WIDENS the same probe rather than adding a second query', async () => { + const { service, findCalls } = await boot(); + const link = await mintOn(service); + + findCalls.length = 0; + expect(await service.resolveToken(link.token, {})).not.toBeNull(); + + const articleReads = findCalls.filter((c) => c.object === 'article'); + expect(articleReads).toHaveLength(1); + expect(articleReads[0].query.fields).toBeUndefined(); + }); + + it('[#5190] a deleted record still refuses — the probe survived the refactor', async () => { + const { driver, service } = await boot(); + const link = await mintOn(service); + await driver.delete('article', 'a_ok'); + + expect(await service.resolveToken(link.token, {})).toBeNull(); + }); + + it('[#5190] a deleted record refuses even with NO predicate declared', async () => { + const noEligibility = { ...ARTICLE, publicSharing: { ...ARTICLE.publicSharing, eligibility: undefined } }; + const { driver, service } = await boot(noEligibility); + const link = await mintOn(service); + await driver.delete('article', 'a_ok'); + + expect(await service.resolveToken(link.token, {})).toBeNull(); + }); + }); +}); + +/** + * [#13608] Mount the real PUBLIC resolve route on the real service. + * + * Only the verbs `registerShareLinkRoutes` calls are implemented, and the + * SECURE default `contextFromRequest` is deliberately left in place: it reads + * no identity header, so every request driven through the returned function is + * anonymous — the caller the refusal shape is a claim about. + */ +function mountResolveRoute(service: ShareLinkService, engine: unknown) { + const routes = new Map(); + const http: any = { + get: (path: string, h: RouteHandler) => { routes.set(`GET ${path}`, h); return http; }, + post: (path: string, h: RouteHandler) => { routes.set(`POST ${path}`, h); return http; }, + put: (path: string, h: RouteHandler) => { routes.set(`PUT ${path}`, h); return http; }, + delete: (path: string, h: RouteHandler) => { routes.set(`DELETE ${path}`, h); return http; }, + patch: (path: string, h: RouteHandler) => { routes.set(`PATCH ${path}`, h); return http; }, + use: () => http, + listen: async () => undefined, + close: async () => undefined, + getInstance: () => null, + }; + registerShareLinkRoutes(http as IHttpServer, service, engine as any); + + const handler = routes.get('GET /api/v1/share-links/:token/resolve'); + if (!handler) throw new Error('the public resolve route was not mounted'); + + return async (token: string): Promise<{ status: number; body: any }> => { + const captured: { status: number; body: any } = { status: 200, body: undefined }; + const res: any = { + status: (code: number) => { captured.status = code; return res; }, + json: (data: any) => { captured.body = data; return res; }, + send: () => res, + header: () => res, + }; + const req: any = { params: { token }, query: {}, headers: {}, method: 'GET', path: '/' }; + await handler(req as IHttpRequest, res as IHttpResponse); + return captured; + }; +} diff --git a/packages/plugins/plugin-sharing/src/share-link-service.ts b/packages/plugins/plugin-sharing/src/share-link-service.ts index c003345a9e..3909d07fea 100644 --- a/packages/plugins/plugin-sharing/src/share-link-service.ts +++ b/packages/plugins/plugin-sharing/src/share-link-service.ts @@ -231,12 +231,22 @@ async function defaultVerifyPassword(password: string, hash: string): Promise([...(policy.redactFields ?? []), ...((row.redact_fields as string[]) ?? [])]), ); @@ -653,32 +706,99 @@ export class ShareLinkService implements IShareLinkService { } /** - * [#5190] Is `(object, recordId)` still there? Read under the SYSTEM context - * on purpose: the question is EXISTENCE, not the holder's visibility — the - * token is the authorisation, and an anonymous holder has no context to read - * under in the first place. + * [#5190 / #13608] Read the shared record at redemption time: the existence + * probe, and — when the object declares an eligibility predicate — the row + * that predicate is judged on. `null` means "do not serve". + * + * Read under the SYSTEM context on purpose: the questions are EXISTENCE and + * the object's own POLICY, not the holder's visibility — the token is the + * authorisation, and an anonymous holder has no context to read under in the + * first place. * * Fails CLOSED. A probe that throws (driver blip, unregistered object) is an * unanswered question, and an unanswered question must not authorise: the - * caller treats `false` exactly like revoked. Note this is the OPPOSITE + * caller treats `null` exactly like revoked. Note this is the OPPOSITE * direction from the orphan sweep, which leaves rows alone when its probe * fails — and for the same principle. Neither acts on an unanswered question; * for a grant the safe direction is "deny", for a deletion it is "keep". + * + * [#13608] `withRecord` widens the projection from `['id']` to the whole row + * rather than issuing a second query, and it widens ONLY when a predicate is + * there to read it: an object with no `eligibility` key keeps the exact + * `id`-only probe it has always had. */ - private async recordStillExists( + private async loadRecordForServing( object: string | null | undefined, recordId: string | null | undefined, - ): Promise { - if (!object || !recordId) return false; + withRecord: boolean, + ): Promise | null> { + if (!object || !recordId) return null; try { const rows = await this.engine.find(String(object), { where: { id: recordId }, - fields: ['id'], + ...(withRecord ? {} : { fields: ['id'] }), limit: 1, context: SYSTEM_CTX, } as any); - return Array.isArray(rows) && rows.length > 0; + const found = Array.isArray(rows) ? rows[0] : undefined; + return (found as Record | undefined) ?? null; } catch { + return null; + } + } + + /** + * [#13608] The redemption half of the eligibility gate. + * + * ## It is the MINT gate, called again + * + * The verdict comes from `assertEligible` itself, not from a second + * evaluation written to match it. Mint and redemption therefore cannot drift + * on strictness (`true`, never merely truthy), on the declared-field binding, + * or on which faults refuse: there is one implementation and both seams call + * it. + * + * ## Why the answer collapses to a boolean here (ruling item 2, 2026-08-31) + * + * `assertEligible` throws two distinct codes on purpose — + * `RECORD_NOT_ELIGIBLE` (this record does not qualify) and + * `ELIGIBILITY_UNEVALUABLE` (the predicate is broken for every record until + * its author fixes it) — because at MINT they reach a caller who has already + * proved they can see the record, and the distinction tells them which thing + * to fix. + * + * At REDEMPTION the caller may hold nothing but the token. Telling such a + * caller "does not exist" apart from "revoked" apart from "no longer + * eligible" is an existence oracle, judged here the same way the + * `RESOURCE_NOT_FOUND` family is. So every refusal collapses into the one + * answer revoked, expired and record-gone already give: `null` — no code, no + * distinct error, nothing an anonymous holder can read a policy out of. + * + * The human-readable "why this link died" is not discarded; it goes to the + * server-side log, which is where the ruling puts it. + */ + private stillEligible( + eligibility: string, + record: Record, + schema: any, + row: ShareLink, + ): boolean { + try { + assertEligible(eligibility, record, schema, String(row.object_name)); + return true; + } catch (err: any) { + this.logger?.warn?.( + '[share-link] redemption refused — the record is no longer eligible under publicSharing.eligibility', + { + link: row.id, + object: row.object_name, + record: row.record_id, + // The distinction the anonymous response deliberately withholds. It + // is readable HERE, and only here. + reason: err?.code ?? 'UNKNOWN', + detail: err?.message, + }, + ); return false; } } diff --git a/packages/spec/src/contracts/share-link-service.ts b/packages/spec/src/contracts/share-link-service.ts index 53ae37ee73..d2bc3a85b7 100644 --- a/packages/spec/src/contracts/share-link-service.ts +++ b/packages/spec/src/contracts/share-link-service.ts @@ -36,6 +36,16 @@ * whole `resolveAuthzContext` envelope, not a per-site subset. See * {@link ShareLinkExecutionContext} for the boundary this draws and * why (#6206 / #6430). + * + * 6. **`publicSharing.eligibility` is a STANDING policy, not a mint-time + * check (#13608).** Implementations evaluate the object's declared + * predicate when the link is minted AND again on every `resolveToken`, + * against the record about to be served. A record that stops qualifying + * stops being served through tokens minted while it did qualify — no + * revocation step, no grace period. Fail-closed at both points: a + * predicate that cannot be evaluated refuses. The redemption refusal is + * the undifferentiated `null` documented on {@link + * IShareLinkService.resolveToken}. */ import type { ExecutionContext } from '../kernel/execution-context.zod.js'; @@ -217,9 +227,20 @@ export interface IShareLinkService { listLinks(filter: ListShareLinksFilter, context: ExecutionContext): Promise; /** - * Resolve a token at request-handling time. Returns null when the - * token does not exist, is revoked, expired, or fails the audience - * check. Increments `use_count` / `last_used_at` as a side effect. + * Resolve a token at request-handling time. Increments `use_count` / + * `last_used_at` as a side effect of a SUCCESSFUL resolution only. + * + * Returns `null` when the token does not exist, is revoked, is expired, + * fails the audience or password gate, names a record that no longer exists + * (#5190), or names a record that no longer satisfies the object's + * `publicSharing.eligibility` predicate (#13608). + * + * ⛔ That single `null` is the contract, not an implementation detail. The + * caller of this method may hold nothing but a token, and distinguishing + * "does not exist" from "revoked" from "no longer eligible" for such a + * caller is an existence oracle. Implementations MUST NOT return a + * distinguishable answer per reason, and MUST NOT throw one either; the + * readable reason belongs in the server-side log. * * @param token raw token from the URL / cookie * @param probe contextual gates the caller has already evaluated diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index f0e6512b72..f30ae1127e 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -2250,12 +2250,32 @@ const ObjectSchemaBase = strictObject( */ redactFields: z.array(z.string()).optional().describe('Field names removed from records served via a share token'), /** - * Optional CEL/JSONLogic predicate evaluated against the candidate - * record when a link is created. When the predicate returns false, - * the create call fails with 422 (e.g. "draft records cannot be - * shared"). Evaluator is the same one used by sharing rules. + * Optional CEL predicate over the candidate record. It is a STANDING + * policy about which records may be reached anonymously, and the platform + * holds it at BOTH points in a link's life (#13608): + * + * - **at mint** — `createLink` refuses with 422 when the predicate is + * false (e.g. "draft records cannot be shared") and writes no link row; + * - **at redemption** — every `resolveToken` re-evaluates it against the + * record it is about to serve. A record that STOPS qualifying (an + * `audience` flipped to internal, a `status` reverted to draft) stops + * being served through links minted while it did qualify. + * + * ⚠️ Tightening this policy therefore cuts off already-minted links, on + * purpose — no revocation step, no grace period. That is the point of a + * standing policy, and it is a behaviour change for deployments that + * shipped before #13608. + * + * Fail-CLOSED at both points: a predicate that does not compile, that + * faults on the record, or that answers anything other than `true` refuses + * rather than assuming consent. The redemption refusal is deliberately + * INDISTINGUISHABLE from a revoked, expired or unknown token — an + * anonymous holder is told nothing about why a link died; that reason goes + * to the server-side log. + * + * Evaluator is the same one used by sharing rules. */ - eligibility: z.string().optional().describe('CEL expression that must evaluate to true on the target record'), + eligibility: z.string().optional().describe('CEL expression that must evaluate to true on the target record. Enforced at BOTH mint and every redemption (#13608): a record that stops qualifying stops being served through links already minted for it. Fail-closed — a predicate that will not compile or faults on the record refuses.'), }).optional().describe('Public share-link policy (Notion/Figma-style link sharing)'), // [ADR-0085] The former `detail: { … }.passthrough()` UI-hints block is From 285ac141f26faaac054bedd3dee2afbd0136efc3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 14:31:53 +0000 Subject: [PATCH 2/3] docs(spec): drop the internal issue id from the eligibility describe string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs --- content/docs/permissions/system-context.mdx | 2 +- content/docs/references/data/object.mdx | 2 +- packages/spec/src/data/object.zod.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 2242da20b7..7d479f22ff 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -135,7 +135,7 @@ The largest single consumer — **20 of the 109 sites**. | 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`) | | 35 | `listShares()` skips the management gate | Get: full enumeration of who can see a record | `plugin-sharing/src/sharing-service.ts:1338` | | 36 | `sys_record_share` reads are **not** self-scoped | Get: tenant-wide share listing without `manage_sharing` | `sharing-plugin.ts:1077` | -| 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` | +| 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` | | 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` | | 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` | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index de99c850a2..1eb73bda3f 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -431,7 +431,7 @@ const result = ApiMethod.parse(data); | **allowedPermissions** | `Enum<'view' \| 'comment' \| 'edit'>[]` | optional | Permission levels selectable on the share dialog | | **maxExpiryDays** | `integer` | optional | Reject links with expiry beyond this many days | | **redactFields** | `string[]` | optional | Field names removed from records served via a share token | -| **eligibility** | `string` | optional | CEL expression that must evaluate to true on the target record. Enforced at BOTH mint and every redemption (#13608): a record that stops qualifying stops being served through links already minted for it. Fail-closed — a predicate that will not compile or faults on the record refuses. | +| **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. | ### Nested Shape: `Object.actions[number]` diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index f30ae1127e..71a34b4517 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -2275,7 +2275,7 @@ const ObjectSchemaBase = strictObject( * * Evaluator is the same one used by sharing rules. */ - eligibility: z.string().optional().describe('CEL expression that must evaluate to true on the target record. Enforced at BOTH mint and every redemption (#13608): a record that stops qualifying stops being served through links already minted for it. Fail-closed — a predicate that will not compile or faults on the record refuses.'), + eligibility: z.string().optional().describe('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.'), }).optional().describe('Public share-link policy (Notion/Figma-style link sharing)'), // [ADR-0085] The former `detail: { … }.passthrough()` UI-hints block is From 926c1af6d7bd2bb19195aeffc8354e4860ef8c77 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 14:36:56 +0000 Subject: [PATCH 3/3] test(plugin-sharing): make the refusal-shape pin discriminate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs --- .../src/share-link-eligibility.test.ts | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/plugins/plugin-sharing/src/share-link-eligibility.test.ts b/packages/plugins/plugin-sharing/src/share-link-eligibility.test.ts index 5c1d2a6f5c..908fe806a0 100644 --- a/packages/plugins/plugin-sharing/src/share-link-eligibility.test.ts +++ b/packages/plugins/plugin-sharing/src/share-link-eligibility.test.ts @@ -660,20 +660,35 @@ describe('[#13608] publicSharing.eligibility is enforced again at REDEMPTION', ( describe('the refusal is the answer revoked / expired / unknown already give', () => { it('at the service seam it is `null` — the identical value, not a lookalike', async () => { const { driver, service } = await boot(); + + // ⚠️ The record-gone arm gets its OWN record. Deleting the reclassified + // one would satisfy this case through the #5190 probe instead, and the + // reclassification assertion would then pass with the whole eligibility + // gate ablated — measured, on the first run of this ablation. + await driver.create('article', { + id: 'a_second', + title: 'Another published + public', + status: 'published', + audience: 'public', + owner_id: 'u1', + }); + const live = await mintOn(service); const revoked = await mintOn(service); const expired = await mintOn(service); - const gone = await mintOn(service, 'a_ok'); + const gone = await mintOn(service, 'a_second'); await service.revokeLink(revoked.token, { isSystem: true } as any); await driver.update('sys_share_link', expired.id, { expires_at: new Date(Date.now() - 60_000).toISOString(), }); - // The reclassification, applied after every token above was minted. + // The reclassification, applied after every token above was minted. The + // record STAYS in the table — it is ineligible, not gone. await driver.update('article', 'a_ok', { audience: 'internal' }); - // …and one record removed outright, for the #5190 arm of the same door. - await driver.delete('article', 'a_ok'); + // …and the other record is removed outright, for the #5190 arm of the + // same door. + await driver.delete('article', 'a_second'); const answers = { reclassified: await service.resolveToken(live.token, {}),