From 80aa1308a8b4a5753e844610ebc30cb443458161 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 04:46:44 +0000 Subject: [PATCH 1/3] wip(plugin-sharing): preserve in-progress #13856 work (PM custodial commit) The dispatched dev agent was terminated mid-task by a session rate limit (HTTP 429) before it could commit or push. This commit preserves what was on disk so the container's reclamation cannot lose it. NOT reviewed and NOT verified: no suite was run against this state, and the work is incomplete by the agent's own account (it was still re-acquiring the shared verify lock for its reproduction leg). Treat as a starting point to amend, never as a finished change. Deliberately excluded: repro-13856.scratch.test.ts, which the agent named as scratch and plainly did not intend to ship. --- .../share-link-redactfields-survive-optout.md | 30 ++++ .../src/share-link-service.test.ts | 154 ++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 .changeset/share-link-redactfields-survive-optout.md diff --git a/.changeset/share-link-redactfields-survive-optout.md b/.changeset/share-link-redactfields-survive-optout.md new file mode 100644 index 0000000000..86f7699043 --- /dev/null +++ b/.changeset/share-link-redactfields-survive-optout.md @@ -0,0 +1,30 @@ +--- +"@objectstack/plugin-sharing": patch +--- + +fix(plugin-sharing): an object's declared `publicSharing.redactFields` keep applying to share-link redemption after the object opts out (#13856) + +`ShareLinkService.getPolicy()` collapsed to an EMPTY policy whenever the +object's `publicSharing` block had `enabled !== true` — `redactFields: []` +included. A link minted while the object was opted IN and redeemed after it +was opted OUT therefore kept resolving and started serving the very fields the +object declares redacted: turning the feature OFF made the anonymous endpoint +serve MORE data than it did while the feature was ON. Fail-open in the wrong +direction, and wrong under either answer to the standing-policy question. + +The declared redaction set is now read from the object's declared +`publicSharing` block regardless of `enabled`, so opting out can never widen +what an existing token serves. Anonymous redemptions on opted-out objects that +previously received the declared-redacted fields stop receiving them — that +narrowing is this fix's intent, declared here rather than smoothed over. + +Unchanged, deliberately: + +- the `enabled: true` path (declared ∪ per-link union, byte-identical); +- an object with no `publicSharing` block at all (no redaction set sprouts); +- the per-link `redact_fields` half of the union; +- the mint-time opt-in gate (`SHARING_NOT_ENABLED`, 422) and the #13608 + redemption-time eligibility gate; +- whether an already-minted link should still RESOLVE at all once + `enabled` is false — that ruling is pending in #14033 and is not + implemented here in either direction. 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 b909a7ccb7..e7ec7a57eb 100644 --- a/packages/plugins/plugin-sharing/src/share-link-service.test.ts +++ b/packages/plugins/plugin-sharing/src/share-link-service.test.ts @@ -453,3 +453,157 @@ describe('ShareLinkService', () => { }); }); }); + +// ── [#13856] declared `redactFields` survive the object opting OUT ────────── +// +// `getPolicy()` used to collapse to an EMPTY policy whenever +// `publicSharing.enabled !== true` — `redactFields: []` included. So a link +// minted while the object was opted IN, redeemed after it was opted OUT, kept +// resolving and started serving MORE fields than it did while the feature was +// on: turning the switch OFF widened what the anonymous endpoint serves. +// Fail-open, and wrong under either answer to the standing-policy question: +// 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 }; + } + + /** 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 () => { + const { schemas, service } = makeOptOutHarness(); + const link = await service.createLink( + { object: 'articles', recordId: 'a1', audience: 'link_only', permission: 'view', redactFields: ['body'] }, + { userId: 'u1' }, + ); + + const on = await service.resolveToken(link.token); + expect(on).not.toBeNull(); + + schemas.articles.publicSharing.enabled = false; + + // Today's behaviour, under ruling in #14033 — a given here, not a pin. + 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'); + + // ⭐ 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. + 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)); + 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 () => { + const { schemas, service } = makeOptOutHarness(); + const link = await service.createLink( + { object: 'articles', recordId: 'a1', audience: 'link_only', permission: 'view', redactFields: ['body'] }, + { userId: 'u1' }, + ); + 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'])); + }); + + it('control — the enabled:true path serves exactly declared ∪ per-link, as before', async () => { + const { service } = makeOptOutHarness(); + const link = await service.createLink( + { object: 'articles', recordId: 'a1', audience: 'link_only', permission: 'view', redactFields: ['body'] }, + { userId: 'u1' }, + ); + const on = await service.resolveToken(link.token); + expect(on).not.toBeNull(); + 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 () => { + 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([]); + }); + + it('reverse control — per-link redactions still apply alone on a block-less object', async () => { + const { engine, service } = makeOptOutHarness(); + seedLink(engine, { + token: 'noblock-token-0987654321', + object_name: 'plain_notes', + record_id: 'n1', + redact_fields: ['secret'], + }); + const resolved = await service.resolveToken('noblock-token-0987654321'); + expect(resolved).not.toBeNull(); + expect(resolved!.redactFields).toEqual(['secret']); + }); + + // The rejection assertion, per the ADR-0112 envelope: `code` AND `status` — + // a bare `.toThrow()` could pass on a refusal for the wrong reason entirely. + it('the mint-time opt-in gate is untouched — enabled:false still refuses SHARING_NOT_ENABLED', async () => { + const { schemas, service } = makeOptOutHarness(); + schemas.articles.publicSharing.enabled = false; + let caught: any; + try { + await service.createLink( + { object: 'articles', recordId: 'a1', audience: 'link_only', permission: 'view' }, + { userId: 'u1' }, + ); + } catch (err) { + caught = err; + } + expect(caught, 'expected a refusal, but the mint resolved').toBeDefined(); + expect(caught.status).toBe(422); + expect(caught.code).toBe('SHARING_NOT_ENABLED'); + }); +}); From 85fb05e55b26cbfa30b096e6a3a2a9a05f12ddb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 07:02:52 +0000 Subject: [PATCH 2/3] fix(plugin-sharing): declared publicSharing.redactFields survive the object opting out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getPolicy() collapsed to an empty policy whenever the object's publicSharing block had enabled !== true — redactFields: [] included — so a link minted while the object was opted in, redeemed after it was opted out, kept resolving and started serving the very fields the object declares redacted. 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. The mint-time gate, the redemption-time eligibility gate, the per-link union, and the no-block path are unchanged; whether standing links should resolve at all after opt-out is a separate pending ruling and is deliberately not implemented here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs --- .../plugin-sharing/src/share-link-service.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/plugins/plugin-sharing/src/share-link-service.ts b/packages/plugins/plugin-sharing/src/share-link-service.ts index 3909d07fea..32a9e69888 100644 --- a/packages/plugins/plugin-sharing/src/share-link-service.ts +++ b/packages/plugins/plugin-sharing/src/share-link-service.ts @@ -100,7 +100,18 @@ function getPolicy(schema: any): { enabled: false, allowedAudiences: [], allowedPermissions: [], - redactFields: [], + // [#13856] The declared redaction set is read REGARDLESS of `enabled`. + // This branch used to return `redactFields: []`, so a link minted while + // the object was opted IN and redeemed after it was opted OUT kept + // resolving AND started serving the very fields the object declares + // redacted — turning the feature off WIDENED what the anonymous + // endpoint serves. Opting out gates MINTING (`createLink`'s 422 reads + // `enabled`, not this list) and whatever #14033 rules for standing + // links; it must never strip the object's declared redactions from + // tokens that still serve. An object with no `publicSharing` block at + // all keeps `[]` — nothing declared, nothing redacted — exactly as + // before. + redactFields: Array.isArray(raw?.redactFields) ? (raw.redactFields as string[]) : [], }; } return { From ab98ef31008845fb99874950211013ba5f0b5838 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 10:19:02 +0000 Subject: [PATCH 3/3] fix(docs): re-anchor system-context row 37 after the share-link hunk shifted it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check-system-context-census` failed on this branch: 10 problems over 145 anchors and 109 census sites — 5 `[anchor-is-not-a-read-site]` plus the 5 `[site-without-a-row]` reads they had rotted off. Cause: this PR's single hunk in `getPolicy`'s disabled branch (the `redactFields` ternary and its explaining comment, +12/-1 at line ~103) pushed every `isSystem` read site below it down by exactly 11 lines, so row 37's citations in `content/docs/permissions/system-context.mdx` no longer resolved. Pure line rot, not a population change — the read-site count is unchanged at 109, each stale anchor pairs 1:1 with an orphaned site at a uniform delta of +11, and the old and new lines are byte-identical: :423 -> :434 :477 -> :488 :481 -> :492 :554 -> :565 :584 -> :595 Repaired with `node scripts/check-system-context-census.mjs --fix`, which rewrote 5 anchors and REFUSED nothing. No anchor was hand-edited; no source or test file is touched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs --- content/docs/permissions/system-context.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index fa4e087fe9..b6532671fd 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:423`, `:477`, `:481`, `:554`, `:584` | +| 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:434`, `:488`, `:492`, `:565`, `:595` | | 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` |