diff --git a/.changeset/share-link-enabled-at-redemption.md b/.changeset/share-link-enabled-at-redemption.md new file mode 100644 index 0000000000..c03fd0e014 --- /dev/null +++ b/.changeset/share-link-enabled-at-redemption.md @@ -0,0 +1,69 @@ +--- +"@objectstack/plugin-sharing": minor +--- + +fix(plugin-sharing): hold `publicSharing.enabled` at redemption, not only at mint (#14033) + +**BREAKING** runtime behaviour change on a published package: share links that +were legitimately minted can now stop resolving without anyone revoking them — +every link on an object whose `publicSharing.enabled` is not `true`. Shipped as +`minor` under the repo's launch-window convention (a breaking change does not +burn a major while the stack is in lockstep). No export is added, removed or +re-shaped; the level carries the breaking banner, not a surface change. + +`ShareLinkService.createLink()` refused to mint on an object whose +`publicSharing` block was absent or had `enabled !== true` (422 +`SHARING_NOT_ENABLED`), and nothing checked the switch again. `resolveToken()` +checked `revoked_at`, `expires_at`, the audience gates, the password, record +existence and — since #13608 — the block's `eligibility` predicate, then served +whatever survived, under the system context, to a caller with no principal at +all. So the platform held the block's CHILD predicate as a standing policy while +its PARENT switch governed minting only: an author who turned the whole feature +off stopped new links and not one existing link, and would have had to narrow +the predicate to stop anonymous serving — the opposite of what the surface +reads like. Measured before it was changed: a token minted while the block was +on kept serving the record in full after the block was turned off. + +**What changed.** `resolveToken()` reads the object's CURRENT `publicSharing` +block on every redemption and refuses when `enabled` is not `true` — before the +record is read, before the usage stamp, before any sibling key inside the block +is evaluated. Re-enabling the block restores the same tokens: this is a standing +policy, not a revocation, and no `sys_share_link` row is touched. How a link was +minted buys it nothing at redemption — a link minted under a system context or +the service's `permissive` bypass (the system-context ledger's row 37 path) on a +switched-off object refuses exactly like one orphaned by an author turning the +block off, and an object with no `publicSharing` block at all is the same switch +at its default and refuses too. With the block on, `eligibility` (#13608) and +the declared `redactFields` (#13856) keep their existing redemption-time +behaviour; nothing new is evaluated. An object the engine cannot return a +schema for — no `getSchema` on the engine, or an object not registered at the +moment of redemption — is `enabled: false` by `getPolicy`'s definition and is +refused at redemption: fail-closed, the same definition `createLink` uses. The +#13856 entry's "an opted-out object's links keep resolving with the declared +redactions" state is superseded: with the block off they do not resolve at all. + +**The refusal is deliberately indistinguishable.** It is the same answer a +revoked, expired, unknown or no-longer-eligible token already gets: the +undifferentiated `null` — no new error code, no new response branch, and no +usage stamp. Over HTTP a switched-off link is answered with the generic +`404 INVALID_OR_EXPIRED`, byte-for-byte what a token that never existed +receives. The readable reason (`SHARING_NOT_ENABLED`, with the link, object and +record ids) is written to the server-side log at `warn`, where the eligibility +refusal already writes its own. + +**Operator impact — retroactive, on deploy.** Every live link on an object whose +`publicSharing` block is currently switched off — or that never declared one — +stops resolving the moment this version is deployed, with no revocation event +and no grace period. That is the intent: the alternative is a declared switch +the platform does not hold. Measure before rollout: the objects to read are +those whose `publicSharing.enabled` is not `true`, and the links at risk are the +`sys_share_link` rows naming them (`object_name`). To keep such links working, +enable the block — and narrow it with `eligibility` / `redactFields` if the +feature was off for a reason; there is no per-link opt-out, deliberately. +Minting is unchanged: `createLink` still refuses `SHARING_NOT_ENABLED` for an +ordinary caller, and the system / `permissive` bypass still mints — what it +mints simply does not serve until the block is on. The refusal logs one `warn` +line per refused hit and is not latched, so a retroactive deploy with many live +links on switched-off objects will burst the log once. + + diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index b9c44ea6c5..5b401ccd91 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:440`, `:494`, `:498`, `:571`, `:601` | +| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `plugin-sharing/src/share-link-service.ts:449`, `:503`, `:507`, `:580`, `:610` | | 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/packages/plugins/plugin-sharing/src/share-link-eligibility.test.ts b/packages/plugins/plugin-sharing/src/share-link-eligibility.test.ts index 908fe806a0..271ce7c9d5 100644 --- a/packages/plugins/plugin-sharing/src/share-link-eligibility.test.ts +++ b/packages/plugins/plugin-sharing/src/share-link-eligibility.test.ts @@ -917,6 +917,181 @@ describe('[#13608] publicSharing.eligibility is enforced again at REDEMPTION', ( }); }); +/** + * [#14033] The PARENT switch is a standing policy too. + * + * `publicSharing.enabled` governed MINTING only: `getPolicy()` collapsed to an + * empty policy when the block was off, and `resolveToken` read nothing off + * `policy.enabled`. So the platform held this shape — the predicate INSIDE + * the block was re-evaluated at every redemption (#13608, above) while turning + * the ENTIRE block off did not stop a single existing link. Maintainer ruling + * of 2026-09-01 (quoted verbatim in `share-link-service.test.ts`'s reversal + * register): the switch is a standing policy held at every redemption, + * retroactively; a link minted through the system / `permissive` bypass is + * governed the same way; and with the block off nothing inside it is + * evaluated at all, while with it on the sibling keys keep their + * redemption-time behaviour. + * + * These pins use the real driver and the real public route: "no record read" + * is measured off the engine's call log, the refusal shape is measured at the + * seam an anonymous holder actually reaches, and the reason is read off the + * server-side log — the only place the ruling leaves it, exactly as for the + * eligibility refusal above. + */ +describe('[#14033] publicSharing.enabled is a standing policy — the switch is held at redemption', () => { + /** A token minted while the block is ON and `a_ok` qualifies — the pre-condition of every case below. */ + async function mint(service: ShareLinkService, recordId = 'a_ok') { + const link = await service.createLink( + { object: 'article', recordId, audience: 'public', permission: 'view' }, + CALLER, + ); + expect(link.token).toBeTruthy(); + return link; + } + + /** The switch, thrown from OUTSIDE the token's life: the object's declared block, `enabled: false`. */ + const switchOff = (schemas: Record) => { + schemas.article = { ...ARTICLE, publicSharing: { ...ARTICLE.publicSharing, enabled: false } }; + }; + const switchOn = (schemas: Record) => { schemas.article = ARTICLE; }; + + /** 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); + } + + it('THE REPRO — an ELIGIBLE record on a switched-off block is refused, with no record read and no usage stamp', async () => { + const { driver, service, schemas, findCalls } = await boot(); + const link = await mint(service); + + switchOff(schemas); + findCalls.length = 0; + + // `a_ok` is published + public: no eligibility refusal is available here, + // so this `null` can only have come from the switch. + expect(await service.resolveToken(link.token, {})).toBeNull(); + + // Refused before the record probe — the token lookup was the only read. + expect(findCalls.map((c) => c.object)).toEqual(['sys_share_link']); + // …and before the usage stamp. + const row = await linkRow(driver, link.id); + expect(row.use_count ?? 0).toBe(0); + expect(row.last_used_at ?? null).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. Same reading as the #13608 pin above, for the same reason: the + * switched-off link lands in the generic "invalid / expired / revoked" + * answer, byte-for-byte what a token that NEVER EXISTED gets — not the 410 + * bucket, which would confirm the token was real, and not a 422 naming the + * policy, which is what letting `SHARING_NOT_ENABLED` escape would produce. + */ + it('at the HTTP seam an anonymous caller cannot tell a switched-off link from an unknown token', async () => { + const { service, engine, schemas } = await boot(); + const live = await mint(service); + const revoked = await mint(service); + await service.revokeLink(revoked.token, { isSystem: true } as any); + + const resolve = mountResolveRoute(service, engine); + + // Before the switch: the link serves the record. + expect((await resolve(live.token)).status).toBe(200); + + switchOff(schemas); + + const switchedOff = await resolve(live.token); + const unknown = await resolve('zzzzzzzzzzzzzzzzzzzzzz'); + const revokedAnswer = await resolve(revoked.token); + + expect(switchedOff).toEqual(unknown); + expect(switchedOff.status).toBe(404); + expect(switchedOff.body?.error?.code).toBe('INVALID_OR_EXPIRED'); + // Nothing about the policy or the switch reaches the wire. + const wire = JSON.stringify(switchedOff.body).toLowerCase(); + expect(wire).not.toContain('enabled'); + expect(wire).not.toContain('publicsharing'); + expect(wire).not.toContain('sharing_not_enabled'); + + // The pre-existing revoked bucket, recorded as measured: 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('the reason a switched-off link died is written to the server-side log, and only there', async () => { + const logged: LoggedRefusal[] = []; + const { service, schemas } = await boot(ARTICLE, { + logger: { warn: (msg, meta) => { logged.push({ msg, meta }); } }, + }); + const link = await mint(service); + switchOff(schemas); + + expect(await service.resolveToken(link.token, {})).toBeNull(); + + expect(logged).toHaveLength(1); + expect(logged[0].msg).toContain('publicSharing.enabled'); + expect(logged[0].meta?.reason).toBe('SHARING_NOT_ENABLED'); + expect(logged[0].meta?.link).toBe(link.id); + expect(logged[0].meta?.object).toBe('article'); + expect(logged[0].meta?.record).toBe('a_ok'); + }); + + /** + * Ruling point 4, both halves on ONE token. OFF: the switch refuses before + * anything inside the block is evaluated — the record is not even read, so + * the predicate that WOULD refuse it never runs. ON again: the same token is + * judged by the predicate once more, and refused by IT; when the record + * qualifies again the token serves. A standing policy, not a revocation. + */ + it('OFF: nothing inside the block is evaluated; ON again: the eligibility re-check resumes on the same token', async () => { + const logged: LoggedRefusal[] = []; + const { driver, service, schemas, findCalls } = await boot(ARTICLE, { + logger: { warn: (msg, meta) => { logged.push({ msg, meta }); } }, + }); + const link = await mint(service); + + // Reclassify the record so the predicate would refuse it — THEN switch off. + await driver.update('article', 'a_ok', { audience: 'internal' }); + switchOff(schemas); + findCalls.length = 0; + + expect(await service.resolveToken(link.token, {})).toBeNull(); + expect(findCalls.map((c) => c.object)).toEqual(['sys_share_link']); + expect(logged.map((l) => l.meta?.reason)).toEqual(['SHARING_NOT_ENABLED']); + + switchOn(schemas); + expect(await service.resolveToken(link.token, {})).toBeNull(); + expect(logged.map((l) => l.meta?.reason)).toEqual(['SHARING_NOT_ENABLED', 'RECORD_NOT_ELIGIBLE']); + + await driver.update('article', 'a_ok', { audience: 'public' }); + expect(await service.resolveToken(link.token, {})).not.toBeNull(); + }); + + /** + * Ruling point 3 on the real driver: the `permissive` bypass still MINTS on + * a switched-off block (ledger row 37's path — the ruling governs + * redemption, not minting), and the result is refused at redemption by the + * bypassing service and the ordinary one alike. + */ + it('a link minted through the `permissive` bypass on a switched-off block is refused at redemption', async () => { + const off = { ...ARTICLE, publicSharing: { ...ARTICLE.publicSharing, enabled: false } }; + const { service, engine } = await boot(off); + const bypass = new ShareLinkService({ engine: engine as any, permissive: true }); + + const link = await bypass.createLink( + { object: 'article', recordId: 'a_ok', audience: 'public', permission: 'view' }, + CALLER, + ); + expect(link.token).toBeTruthy(); + + expect(await bypass.resolveToken(link.token, {})).toBeNull(); + expect(await service.resolveToken(link.token, {})).toBeNull(); + }); +}); + /** * [#13608] Mount the real PUBLIC resolve route on the real service. * diff --git a/packages/plugins/plugin-sharing/src/share-link-service.test.ts b/packages/plugins/plugin-sharing/src/share-link-service.test.ts index 61061f06d1..41daaaa914 100644 --- a/packages/plugins/plugin-sharing/src/share-link-service.test.ts +++ b/packages/plugins/plugin-sharing/src/share-link-service.test.ts @@ -465,48 +465,81 @@ describe('ShareLinkService', () => { // the declared redaction set is now read from the declared block regardless // of `enabled`, so opting out can never widen what an existing token serves. // -// ⛔ Deliberately NOT asserted here: whether the link should resolve AT ALL -// once `enabled` is false. That is the deployment-visible ruling pending in -// #14033. These tests take today's behaviour (it resolves) as a given and pin -// only the redaction set served when it does; if #14033 rules that de-opt-in -// kills standing links, the resolve-side readings here move with that card. -describe('[#13856] declared redactFields survive publicSharing opt-out', () => { - /** An opt-in object whose schema object is LOCAL to the test, so `enabled` can be flipped. */ - function makeOptOutHarness() { - const schemas: Record = { - sys_share_link: { name: 'sys_share_link', fields: {} }, - articles: { - name: 'articles', - publicSharing: { enabled: true, redactFields: ['owner_id', 'cost'] }, - fields: { id: {}, title: {}, body: {}, owner_id: {}, cost: {} }, - }, - // Reverse control: never declared a publicSharing block at all. - plain_notes: { name: 'plain_notes', fields: { id: {}, text: {}, secret: {} } }, - }; - const engine = makeFakeEngine(schemas); - engine._tables.articles = [{ id: 'a1', title: 'T', body: 'B', owner_id: 'u9', cost: 42 }]; - engine._tables.plain_notes = [{ id: 'n1', text: 'hi', secret: 's3' }]; - const service = new ShareLinkService({ engine: engine as any }); - return { schemas, engine, service }; - } +// ## [#14033] REVERSAL REGISTER — the resolve-side readings below moved +// +// This block was written while the standing-policy question was pending, and +// said so: it took "the link still resolves with the switch off" as a GIVEN +// and pinned only the redaction set served when it did. That question has +// since been ruled. Maintainer ruling of 2026-09-01, recorded on #14033 by the +// director seat (「其他同意」 on the four-point recommendation), verbatim and +// untranslated: +// +// > 1. **A**:`publicSharing.enabled` 为持续策略,`resolveToken()` 每次兑付重查;关掉 ⇒ 存量链接停止兑付。 +// > 2. **追溯即时生效**(沿 #13608 先例):部署一落地,禁用块上的所有既有 token 即停止解析。changeset 必须标注 breaking runtime change,与 #13857 同型。 +// > 3. **系统/宽容旁路铸造的链接同样受管**:兑付是匿名动作,不因铸造方式豁免 —— `enabled` 关则一律拒付(含 system-context.mdx ledger row 37 的旁路铸造路径)。 +// > 4. **兄弟键统一律入册**:`enabled` 关 ⇒ 整块不生效 ⇒ 拒付(子键求值 moot);`enabled` 开 ⇒ 块内各策略键按持续策略在兑付期求值(`eligibility` 已然,`redactFields` 由 #13856 修复)。后续兄弟键⛔ 不再单独立卡。 +// +// So every pin here that asserted "resolves with the switch off" is REVERSED +// in place — kept, renamed, its docblock naming what it pinned and which +// ruling point moved it — never deleted. The block's own guarantee (opting +// out never WIDENS what an existing token serves) still holds, now trivially: +// with the switch off nothing is served at all (point 4: 子键求值 moot). The +// `redactFields` read in `getPolicy`'s disabled branch is deliberately left as +// #13856 landed it (not this card's to redo); the `enabled: true` control and +// the mint-time gate are untouched and stay pinned exactly as before. + +/** + * An opt-in object whose schema object is LOCAL to the test, so `enabled` can + * be flipped. Shared by the #13856 block and the #14033 block below — the + * second is the ruling the first deferred to. + */ +function makeOptOutHarness() { + const schemas: Record = { + sys_share_link: { name: 'sys_share_link', fields: {} }, + articles: { + name: 'articles', + publicSharing: { enabled: true, redactFields: ['owner_id', 'cost'] }, + fields: { id: {}, title: {}, body: {}, owner_id: {}, cost: {} }, + }, + // Reverse control: never declared a publicSharing block at all. + plain_notes: { name: 'plain_notes', fields: { id: {}, text: {}, secret: {} } }, + }; + const engine = makeFakeEngine(schemas); + engine._tables.articles = [{ id: 'a1', title: 'T', body: 'B', owner_id: 'u9', cost: 42 }]; + engine._tables.plain_notes = [{ id: 'n1', text: 'hi', secret: 's3' }]; + const service = new ShareLinkService({ engine: engine as any }); + return { schemas, engine, service }; +} - /** Seed a pre-existing link directly (mint refuses for these paths — that gate is pinned below). */ - function seedLink(engine: any, row: Record) { - engine._tables.sys_share_link = [{ - id: 'shl_seeded', - permission: 'view', - audience: 'link_only', - expires_at: null, - email_allowlist: null, - password_hash: null, - redact_fields: null, - revoked_at: null, - use_count: 0, - ...row, - }]; - } +/** Seed a pre-existing link directly (mint refuses for these paths — that gate is pinned below). */ +function seedLink(engine: any, row: Record) { + engine._tables.sys_share_link = [{ + id: 'shl_seeded', + permission: 'view', + audience: 'link_only', + expires_at: null, + email_allowlist: null, + password_hash: null, + redact_fields: null, + revoked_at: null, + use_count: 0, + ...row, + }]; +} - it('THE REPRO — opting out keeps the declared redactions applying', async () => { +describe('[#13856] declared redactFields survive publicSharing opt-out', () => { + /** + * REVERSED by #14033 (ruling points 1 and 4, quoted in the register above). + * + * What it pinned: with the switch turned OFF the same token still resolved, + * the declared redactions were still applied, and the field set served OFF + * was a subset of the set served ON. What reversed it: `enabled` is a + * standing policy — with the switch off the link does not resolve at all, + * so there is no served set to compare and the redaction reading is moot. + * The ON half of the original reading is kept: it is the control that the + * switch, not the harness, is what changed the answer. + */ + it('REVERSED by #14033 — opting out no longer serves the link at all (it used to, redactions applied)', async () => { const { schemas, service } = makeOptOutHarness(); const link = await service.createLink( { object: 'articles', recordId: 'a1', audience: 'link_only', permission: 'view', redactFields: ['body'] }, @@ -515,30 +548,37 @@ describe('[#13856] declared redactFields survive publicSharing opt-out', () => { const on = await service.resolveToken(link.token); expect(on).not.toBeNull(); + expect(on!.redactFields).toContain('owner_id'); + expect(on!.redactFields).toContain('cost'); schemas.articles.publicSharing.enabled = false; - // Today's behaviour, under ruling in #14033 — a given here, not a pin. + // Formerly `expect(off).not.toBeNull()` — "today's behaviour, under ruling + // in #14033 — a given here, not a pin". The ruling landed: point 1. const off = await service.resolveToken(link.token); - expect(off).not.toBeNull(); - - // Positive: the declared fields are still stripped after opt-out. - expect(off!.redactFields).toContain('owner_id'); - expect(off!.redactFields).toContain('cost'); + expect(off).toBeNull(); - // ⭐ The directional assertion — the field set served with the switch OFF - // is a SUBSET of the set served with it ON. Opting out may only ever - // narrow what an existing token serves, never widen it. + // The block's guarantee, restated under the ruling: the set served with + // the switch OFF (nothing) is a subset of the set served with it ON. const allFields = Object.keys(schemas.articles.fields); const servedOn = allFields.filter((f) => !on!.redactFields.includes(f)); - const servedOff = allFields.filter((f) => !off!.redactFields.includes(f)); + const servedOff: string[] = off === null ? [] : allFields.filter((f) => !off.redactFields.includes(f)); expect( servedOff.filter((f) => !servedOn.includes(f)), 'fields served ONLY after opting out — must be none', ).toEqual([]); }); - it('boundary — the per-link redact_fields union is unchanged when the switch is off', async () => { + /** + * REVERSED by #14033 (ruling point 4, quoted in the register above). + * + * What it pinned: with the switch OFF the resolved link carried exactly + * declared ∪ per-link redactions. What reversed it: with the switch off + * nothing inside the block is evaluated and nothing is served — there is no + * resolved link to carry a union. The union itself is still pinned on the + * `enabled: true` path by the control directly below. + */ + it('REVERSED by #14033 — boundary: with the switch off there is no resolved link to carry the union', async () => { const { schemas, service } = makeOptOutHarness(); const link = await service.createLink( { object: 'articles', recordId: 'a1', audience: 'link_only', permission: 'view', redactFields: ['body'] }, @@ -546,10 +586,8 @@ describe('[#13856] declared redactFields survive publicSharing opt-out', () => { ); schemas.articles.publicSharing.enabled = false; - const off = await service.resolveToken(link.token); - expect(off).not.toBeNull(); - // Exactly declared ∪ per-link — nothing dropped, nothing sprouted. - expect(new Set(off!.redactFields)).toEqual(new Set(['owner_id', 'cost', 'body'])); + // Formerly: `expect(off).not.toBeNull()` and a union of ['owner_id', 'cost', 'body']. + expect(await service.resolveToken(link.token)).toBeNull(); }); it('control — the enabled:true path serves exactly declared ∪ per-link, as before', async () => { @@ -563,19 +601,35 @@ describe('[#13856] declared redactFields survive publicSharing opt-out', () => { expect(new Set(on!.redactFields)).toEqual(new Set(['owner_id', 'cost', 'body'])); }); - it('reverse control — an object with no publicSharing block sprouts NO redaction set', async () => { + /** + * REVERSED by #14033 (ruling point 3, quoted in the register above). + * + * What it pinned: a seeded link on an object with NO `publicSharing` block + * resolved, and sprouted no redaction set. What reversed it: a block that + * was never declared is `enabled !== true` — the same switch, at its + * default — and a link on such an object can only have been minted through + * the system / `permissive` bypass, which point 3 puts under the switch like + * every other link. It no longer resolves, so it serves no set to inspect. + */ + it('REVERSED by #14033 — reverse control: a block-less object is switched off, its seeded link refuses', async () => { const { engine, service } = makeOptOutHarness(); seedLink(engine, { token: 'noblock-token-1234567890', object_name: 'plain_notes', record_id: 'n1', }); - const resolved = await service.resolveToken('noblock-token-1234567890'); - expect(resolved).not.toBeNull(); - expect(resolved!.redactFields).toEqual([]); + // Formerly: resolved, with `redactFields` equal to `[]`. + expect(await service.resolveToken('noblock-token-1234567890')).toBeNull(); }); - it('reverse control — per-link redactions still apply alone on a block-less object', async () => { + /** + * REVERSED by #14033 (ruling point 3, quoted in the register above). + * + * What it pinned: on a block-less object the per-link `redact_fields` were + * served alone. What reversed it: the same as the pin above — the object is + * switched off, the link refuses, and the per-link set is never reached. + */ + it('REVERSED by #14033 — reverse control: per-link redactions on a block-less object are never reached', async () => { const { engine, service } = makeOptOutHarness(); seedLink(engine, { token: 'noblock-token-0987654321', @@ -583,9 +637,8 @@ describe('[#13856] declared redactFields survive publicSharing opt-out', () => { record_id: 'n1', redact_fields: ['secret'], }); - const resolved = await service.resolveToken('noblock-token-0987654321'); - expect(resolved).not.toBeNull(); - expect(resolved!.redactFields).toEqual(['secret']); + // Formerly: resolved, with `redactFields` equal to `['secret']`. + expect(await service.resolveToken('noblock-token-0987654321')).toBeNull(); }); // The rejection assertion, per the ADR-0112 envelope: `code` AND `status` — @@ -608,6 +661,169 @@ describe('[#13856] declared redactFields survive publicSharing opt-out', () => { }); }); +// ── [#14033] `publicSharing.enabled` is a STANDING policy, held again at REDEMPTION ── +// +// ## The defect +// +// `getPolicy()` returned an empty policy (`enabled: false`) when the object's +// `publicSharing` block was absent or `enabled !== true`, and nothing in +// `resolveToken()` read `policy.enabled` — the opt-in was checked at MINT only +// (`createLink` → 422 `SHARING_NOT_ENABLED`). So the platform held this shape: +// the predicate INSIDE the block (`eligibility`, #13608) was re-evaluated on +// every redemption, while turning the WHOLE block off did not stop a single +// link already handed out. An author who wanted anonymous serving to stop had +// to narrow the predicate rather than switch the feature off. Measured before +// it was changed (the card's own instruction): the first pin below was red +// against the unmodified service — the token kept serving. +// +// ## The ruling +// +// Quoted verbatim in the reversal register above. In one line each: (1) the +// switch is a standing policy, re-read at every redemption; (2) retroactive on +// deploy, as #13608 was; (3) how a link was minted — system context, the +// `permissive` bypass, ledger row 37 — buys it nothing at redemption; (4) +// switch OFF ⇒ nothing inside the block is evaluated; switch ON ⇒ the sibling +// keys keep their redemption-time behaviour. +// +// ## What is measured, not assumed +// +// "Refused" is measured three ways on the same call: the answer is the +// undifferentiated `null` a revoked / expired / unknown token gets; the only +// read the service issued was the token lookup (no record probe — read off a +// recording engine); and the usage counters did not move. The HTTP-seam +// shape, the server-side log line and the real-driver readings live beside +// the #13608 pins in `share-link-eligibility.test.ts`. +describe('[#14033] publicSharing.enabled is a standing policy — held again at redemption', () => { + /** Every `find` the service issues, so "no record read" is a measurement rather than an assumption. */ + function recordingService(engine: any, opts: Record = {}) { + const findCalls: Array<{ object: string; query: any }> = []; + const recording = { + ...engine, + async find(object: string, query?: any) { + findCalls.push({ object, query }); + return engine.find(object, query); + }, + }; + const service = new ShareLinkService({ engine: recording as any, ...opts }); + return { service, findCalls }; + } + + /** The row as the table holds it now — usage counters included. */ + function linkRow(engine: any, id: string): any { + return engine._tables.sys_share_link.find((r: any) => r.id === id); + } + + const MINT = { object: 'articles', recordId: 'a1', audience: 'link_only', permission: 'view' } as const; + + it('THE REPRO — minted while enabled, the SAME token stops resolving the moment the block is turned off', async () => { + const { schemas, engine } = makeOptOutHarness(); + const { service, findCalls } = recordingService(engine); + const link = await service.createLink({ ...MINT }, { userId: 'u1' }); + + // Control: serving while the switch is on. + expect(await service.resolveToken(link.token)).not.toBeNull(); + const before = { ...linkRow(engine, link.id) }; + + schemas.articles.publicSharing.enabled = false; + findCalls.length = 0; + + // Pre-ruling this served the record in full to an anonymous caller. + expect(await service.resolveToken(link.token)).toBeNull(); + + // Refused BEFORE the record probe: the only read was the token lookup. + expect(findCalls.map((c) => c.object)).toEqual(['sys_share_link']); + // …and BEFORE the usage stamp: the counters are exactly what they were. + const after = linkRow(engine, link.id); + expect(after.use_count).toBe(before.use_count); + expect(after.last_used_at).toBe(before.last_used_at); + }); + + it('a standing policy, not a revocation — turning the block back ON restores the SAME token', async () => { + const { schemas, engine } = makeOptOutHarness(); + const { service } = recordingService(engine); + const link = await service.createLink({ ...MINT }, { userId: 'u1' }); + + schemas.articles.publicSharing.enabled = false; + expect(await service.resolveToken(link.token)).toBeNull(); + + schemas.articles.publicSharing.enabled = true; + const restored = await service.resolveToken(link.token); + expect(restored).not.toBeNull(); + expect(restored!.link.id).toBe(link.id); + // The block is in force again, so its sibling keys are too (point 4). + expect(restored!.redactFields).toEqual(expect.arrayContaining(['owner_id', 'cost'])); + // Exactly one successful resolution was stamped — the refused one was not. + expect(linkRow(engine, link.id).use_count).toBe(1); + }); + + it('the refusal is the identical `null` a revoked / unknown token gets — never a code an anonymous caller could read', async () => { + const { schemas, engine } = makeOptOutHarness(); + const { service } = recordingService(engine); + const live = await service.createLink({ ...MINT }, { userId: 'u1' }); + const revoked = await service.createLink({ ...MINT }, { userId: 'u1' }); + await service.revokeLink(revoked.id, { userId: 'u1' }); + + schemas.articles.publicSharing.enabled = false; + + // `resolves` — the switched-off arm RETURNS; a thrown `SHARING_NOT_ENABLED` + // here would hand the route a 422 naming the policy to an anonymous caller. + await expect(service.resolveToken(live.token)).resolves.toBeNull(); + + const answers = { + switchedOff: await service.resolveToken(live.token), + revoked: await service.resolveToken(revoked.token), + unknown: await service.resolveToken('zzzzzzzzzzzzzzzzzzzzzz'), + }; + 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); + }); + + describe('ruling point 3 — how a link was MINTED buys it nothing at redemption', () => { + it('minted through the `permissive` bypass on a block that is OFF: refused, no record read, no usage stamp', async () => { + const { schemas, engine } = makeOptOutHarness(); + schemas.articles.publicSharing.enabled = false; + const { service, findCalls } = recordingService(engine, { permissive: true }); + + // The bypass MINTS — ledger row 37's path, and the ruling leaves it so … + const link = await service.createLink({ ...MINT }, { userId: 'u1' }); + expect(link.token).toBeTruthy(); + findCalls.length = 0; + + // … and redemption refuses the result exactly like an orphaned link. + expect(await service.resolveToken(link.token)).toBeNull(); + expect(findCalls.map((c) => c.object)).toEqual(['sys_share_link']); + const row = linkRow(engine, link.id); + expect(row.use_count).toBe(0); + expect(row.last_used_at).toBeNull(); + }); + + it('minted under a SYSTEM context on a block that is OFF: refused all the same', async () => { + const { schemas, engine } = makeOptOutHarness(); + schemas.articles.publicSharing.enabled = false; + const { service } = recordingService(engine); + + const link = await service.createLink( + { ...MINT }, + { isSystem: true, positions: [], permissions: [] } as any, + ); + expect(link.token).toBeTruthy(); + + expect(await service.resolveToken(link.token)).toBeNull(); + }); + + it('a block that was never declared is `enabled !== true` too — a seeded link on it refuses without a record read', async () => { + const { engine } = makeOptOutHarness(); + seedLink(engine, { token: 'noblock-token-1234567890', object_name: 'plain_notes', record_id: 'n1' }); + const { service, findCalls } = recordingService(engine); + + expect(await service.resolveToken('noblock-token-1234567890')).toBeNull(); + expect(findCalls.map((c) => c.object)).toEqual(['sys_share_link']); + }); + }); +}); + // [#12981, batch 9] The `use_count` / `last_used_at` stamp at the end of // `resolveToken` used to be swallowed by an empty `catch` ("usage telemetry is // a nice-to-have"). It is a durability site: `sys_share_link` DECLARES both diff --git a/packages/plugins/plugin-sharing/src/share-link-service.ts b/packages/plugins/plugin-sharing/src/share-link-service.ts index 501a02dc44..726dace8f7 100644 --- a/packages/plugins/plugin-sharing/src/share-link-service.ts +++ b/packages/plugins/plugin-sharing/src/share-link-service.ts @@ -341,9 +341,18 @@ export interface ShareLinkServiceOptions { /** Companion verifier — must accept hashes produced by `hashPassword`. */ verifyPassword?: (plain: string, hash: string) => Promise; /** - * Bypass the per-object opt-in check (useful when the schema scan is - * happening after `start`). When omitted, calls against an object - * without `publicSharing.enabled=true` are rejected with 422. + * Bypass the per-object opt-in check at MINT (useful when the schema scan + * is happening after `start`). When omitted, `createLink` against an object + * without `publicSharing.enabled=true` is rejected with 422. + * + * [#14033] Mint ONLY. `publicSharing.enabled` is a standing policy held + * again at every redemption, and `resolveToken` reads the object's CURRENT + * block regardless of how a row was minted: a link minted under this bypass + * while the block is off does not resolve until the block is enabled. So + * during a late schema scan the option helps MINTING, not serving: an object + * the engine cannot return a schema for is `enabled: false` by `getPolicy`'s + * definition (the same definition `createLink` uses), and redemption refuses + * until the schema resolves and the block is enabled. */ permissive?: boolean; /** @@ -639,7 +648,66 @@ export class ShareLinkService implements IShareLinkService { // computed from it below, unchanged. const schema = this.engine.getSchema?.(row.object_name); const policy = getPolicy(schema); - const eligibility = policy.enabled ? policy.eligibility : undefined; + + // [#14033] `publicSharing.enabled` is a STANDING policy — held here, at + // every redemption, not only at mint. + // + // ## Why the parent switch is held at redemption + // + // `createLink` reads `policy.enabled` (422 `SHARING_NOT_ENABLED`) and + // nothing here did. So the predicate INSIDE the block (`eligibility`, + // below) was re-evaluated on every redemption while turning the WHOLE + // block off stopped no link already handed out: an author who wanted + // anonymous serving to stop had to narrow the predicate rather than switch + // the feature off — the opposite of what the surface reads like. Measured + // before it was changed: a token minted while the block was on kept + // serving the record in full after the block was turned off. The + // maintainer ruled (2026-09-01, on #14033) that the switch is a standing + // policy: re-read on every redemption, a block that is off stops every + // existing token on it — retroactively, on deploy, as #13608 was — and + // re-enabling the block restores them. Not a revocation: no row moves. + // + // ## What the gate does NOT ask + // + // How the link was minted. A token minted under a system context or the + // `permissive` bypass (system-context ledger row 37) carries no mark of + // it, and the ruling's point 3 says that is right: redemption is an + // anonymous act and is not exempted by how the row got there. So this + // reads the CURRENT block and nothing else. An absent block is the same + // switch at its default (`enabled` defaults to false) and refuses too. + // + // ## Placement, and what "off" switches off + // + // AFTER the cheap in-memory gates (a revoked or expired token still pays + // no schema read) and BEFORE the record probe and the usage stamp: a + // switched-off link reads no record and bumps no counter. Ruling point 4: + // with the switch off nothing inside the block is evaluated — the + // predicate is not run, the redaction set is never computed — and with it + // on, the sibling keys keep exactly the redemption-time behaviour below. + // + // ## The refusal is the same `null`, and the reason goes to the log + // + // Same family as `stillEligible`: for a caller who may hold nothing but a + // token, a distinguishable "sharing is off for this object" is an + // existence oracle, so the answer is the one revoked / expired / unknown / + // ineligible already give (over HTTP the generic 404). The readable + // reason is written to the server-side log through the GUARANTEED `warn` + // member — the caller was answered, so this is not a degradation and not + // an `error`; it is the sink shape the eligibility refusal already uses. + if (!policy.enabled) { + this.logger?.warn?.( + '[share-link] redemption refused — publicSharing.enabled is not true on the object ' + + '(the block is switched off; its links resolve again once it is re-enabled)', + { + link: row.id, + object: row.object_name, + record: row.record_id, + reason: 'SHARING_NOT_ENABLED', + }, + ); + return null; + } + const eligibility = policy.eligibility; // [#5190] Does the shared RECORD still exist? A share link is an // identity-less CAPABILITY token: whoever holds it has the access, no