Skip to content

Commit 67192ce

Browse files
os-steveclaude
andauthored
fix(plugin-sharing): declared publicSharing.redactFields survive the object opting out (#14171)
* 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. * fix(plugin-sharing): declared publicSharing.redactFields survive the object opting out 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs * fix(docs): re-anchor system-context row 37 after the share-link hunk shifted it `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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 83be460 commit 67192ce

4 files changed

Lines changed: 197 additions & 2 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/plugin-sharing": patch
3+
---
4+
5+
fix(plugin-sharing): an object's declared `publicSharing.redactFields` keep applying to share-link redemption after the object opts out (#13856)
6+
7+
`ShareLinkService.getPolicy()` collapsed to an EMPTY policy whenever the
8+
object's `publicSharing` block had `enabled !== true``redactFields: []`
9+
included. A link minted while the object was opted IN and redeemed after it
10+
was opted OUT therefore kept resolving and started serving the very fields the
11+
object declares redacted: turning the feature OFF made the anonymous endpoint
12+
serve MORE data than it did while the feature was ON. Fail-open in the wrong
13+
direction, and wrong under either answer to the standing-policy question.
14+
15+
The declared redaction set is now read from the object's declared
16+
`publicSharing` block regardless of `enabled`, so opting out can never widen
17+
what an existing token serves. Anonymous redemptions on opted-out objects that
18+
previously received the declared-redacted fields stop receiving them — that
19+
narrowing is this fix's intent, declared here rather than smoothed over.
20+
21+
Unchanged, deliberately:
22+
23+
- the `enabled: true` path (declared ∪ per-link union, byte-identical);
24+
- an object with no `publicSharing` block at all (no redaction set sprouts);
25+
- the per-link `redact_fields` half of the union;
26+
- the mint-time opt-in gate (`SHARING_NOT_ENABLED`, 422) and the #13608
27+
redemption-time eligibility gate;
28+
- whether an already-minted link should still RESOLVE at all once
29+
`enabled` is false — that ruling is pending in #14033 and is not
30+
implemented here in either direction.

content/docs/permissions/system-context.mdx

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

packages/plugins/plugin-sharing/src/share-link-service.test.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,3 +453,157 @@ describe('ShareLinkService', () => {
453453
});
454454
});
455455
});
456+
457+
// ── [#13856] declared `redactFields` survive the object opting OUT ──────────
458+
//
459+
// `getPolicy()` used to collapse to an EMPTY policy whenever
460+
// `publicSharing.enabled !== true` — `redactFields: []` included. So a link
461+
// minted while the object was opted IN, redeemed after it was opted OUT, kept
462+
// resolving and started serving MORE fields than it did while the feature was
463+
// on: turning the switch OFF widened what the anonymous endpoint serves.
464+
// Fail-open, and wrong under either answer to the standing-policy question:
465+
// the declared redaction set is now read from the declared block regardless
466+
// of `enabled`, so opting out can never widen what an existing token serves.
467+
//
468+
// ⛔ Deliberately NOT asserted here: whether the link should resolve AT ALL
469+
// once `enabled` is false. That is the deployment-visible ruling pending in
470+
// #14033. These tests take today's behaviour (it resolves) as a given and pin
471+
// only the redaction set served when it does; if #14033 rules that de-opt-in
472+
// kills standing links, the resolve-side readings here move with that card.
473+
describe('[#13856] declared redactFields survive publicSharing opt-out', () => {
474+
/** An opt-in object whose schema object is LOCAL to the test, so `enabled` can be flipped. */
475+
function makeOptOutHarness() {
476+
const schemas: Record<string, any> = {
477+
sys_share_link: { name: 'sys_share_link', fields: {} },
478+
articles: {
479+
name: 'articles',
480+
publicSharing: { enabled: true, redactFields: ['owner_id', 'cost'] },
481+
fields: { id: {}, title: {}, body: {}, owner_id: {}, cost: {} },
482+
},
483+
// Reverse control: never declared a publicSharing block at all.
484+
plain_notes: { name: 'plain_notes', fields: { id: {}, text: {}, secret: {} } },
485+
};
486+
const engine = makeFakeEngine(schemas);
487+
engine._tables.articles = [{ id: 'a1', title: 'T', body: 'B', owner_id: 'u9', cost: 42 }];
488+
engine._tables.plain_notes = [{ id: 'n1', text: 'hi', secret: 's3' }];
489+
const service = new ShareLinkService({ engine: engine as any });
490+
return { schemas, engine, service };
491+
}
492+
493+
/** Seed a pre-existing link directly (mint refuses for these paths — that gate is pinned below). */
494+
function seedLink(engine: any, row: Record<string, any>) {
495+
engine._tables.sys_share_link = [{
496+
id: 'shl_seeded',
497+
permission: 'view',
498+
audience: 'link_only',
499+
expires_at: null,
500+
email_allowlist: null,
501+
password_hash: null,
502+
redact_fields: null,
503+
revoked_at: null,
504+
use_count: 0,
505+
...row,
506+
}];
507+
}
508+
509+
it('THE REPRO — opting out keeps the declared redactions applying', async () => {
510+
const { schemas, service } = makeOptOutHarness();
511+
const link = await service.createLink(
512+
{ object: 'articles', recordId: 'a1', audience: 'link_only', permission: 'view', redactFields: ['body'] },
513+
{ userId: 'u1' },
514+
);
515+
516+
const on = await service.resolveToken(link.token);
517+
expect(on).not.toBeNull();
518+
519+
schemas.articles.publicSharing.enabled = false;
520+
521+
// Today's behaviour, under ruling in #14033 — a given here, not a pin.
522+
const off = await service.resolveToken(link.token);
523+
expect(off).not.toBeNull();
524+
525+
// Positive: the declared fields are still stripped after opt-out.
526+
expect(off!.redactFields).toContain('owner_id');
527+
expect(off!.redactFields).toContain('cost');
528+
529+
// ⭐ The directional assertion — the field set served with the switch OFF
530+
// is a SUBSET of the set served with it ON. Opting out may only ever
531+
// narrow what an existing token serves, never widen it.
532+
const allFields = Object.keys(schemas.articles.fields);
533+
const servedOn = allFields.filter((f) => !on!.redactFields.includes(f));
534+
const servedOff = allFields.filter((f) => !off!.redactFields.includes(f));
535+
expect(
536+
servedOff.filter((f) => !servedOn.includes(f)),
537+
'fields served ONLY after opting out — must be none',
538+
).toEqual([]);
539+
});
540+
541+
it('boundary — the per-link redact_fields union is unchanged when the switch is off', async () => {
542+
const { schemas, service } = makeOptOutHarness();
543+
const link = await service.createLink(
544+
{ object: 'articles', recordId: 'a1', audience: 'link_only', permission: 'view', redactFields: ['body'] },
545+
{ userId: 'u1' },
546+
);
547+
schemas.articles.publicSharing.enabled = false;
548+
549+
const off = await service.resolveToken(link.token);
550+
expect(off).not.toBeNull();
551+
// Exactly declared ∪ per-link — nothing dropped, nothing sprouted.
552+
expect(new Set(off!.redactFields)).toEqual(new Set(['owner_id', 'cost', 'body']));
553+
});
554+
555+
it('control — the enabled:true path serves exactly declared ∪ per-link, as before', async () => {
556+
const { service } = makeOptOutHarness();
557+
const link = await service.createLink(
558+
{ object: 'articles', recordId: 'a1', audience: 'link_only', permission: 'view', redactFields: ['body'] },
559+
{ userId: 'u1' },
560+
);
561+
const on = await service.resolveToken(link.token);
562+
expect(on).not.toBeNull();
563+
expect(new Set(on!.redactFields)).toEqual(new Set(['owner_id', 'cost', 'body']));
564+
});
565+
566+
it('reverse control — an object with no publicSharing block sprouts NO redaction set', async () => {
567+
const { engine, service } = makeOptOutHarness();
568+
seedLink(engine, {
569+
token: 'noblock-token-1234567890',
570+
object_name: 'plain_notes',
571+
record_id: 'n1',
572+
});
573+
const resolved = await service.resolveToken('noblock-token-1234567890');
574+
expect(resolved).not.toBeNull();
575+
expect(resolved!.redactFields).toEqual([]);
576+
});
577+
578+
it('reverse control — per-link redactions still apply alone on a block-less object', async () => {
579+
const { engine, service } = makeOptOutHarness();
580+
seedLink(engine, {
581+
token: 'noblock-token-0987654321',
582+
object_name: 'plain_notes',
583+
record_id: 'n1',
584+
redact_fields: ['secret'],
585+
});
586+
const resolved = await service.resolveToken('noblock-token-0987654321');
587+
expect(resolved).not.toBeNull();
588+
expect(resolved!.redactFields).toEqual(['secret']);
589+
});
590+
591+
// The rejection assertion, per the ADR-0112 envelope: `code` AND `status` —
592+
// a bare `.toThrow()` could pass on a refusal for the wrong reason entirely.
593+
it('the mint-time opt-in gate is untouched — enabled:false still refuses SHARING_NOT_ENABLED', async () => {
594+
const { schemas, service } = makeOptOutHarness();
595+
schemas.articles.publicSharing.enabled = false;
596+
let caught: any;
597+
try {
598+
await service.createLink(
599+
{ object: 'articles', recordId: 'a1', audience: 'link_only', permission: 'view' },
600+
{ userId: 'u1' },
601+
);
602+
} catch (err) {
603+
caught = err;
604+
}
605+
expect(caught, 'expected a refusal, but the mint resolved').toBeDefined();
606+
expect(caught.status).toBe(422);
607+
expect(caught.code).toBe('SHARING_NOT_ENABLED');
608+
});
609+
});

packages/plugins/plugin-sharing/src/share-link-service.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,18 @@ function getPolicy(schema: any): {
100100
enabled: false,
101101
allowedAudiences: [],
102102
allowedPermissions: [],
103-
redactFields: [],
103+
// [#13856] The declared redaction set is read REGARDLESS of `enabled`.
104+
// This branch used to return `redactFields: []`, so a link minted while
105+
// the object was opted IN and redeemed after it was opted OUT kept
106+
// resolving AND started serving the very fields the object declares
107+
// redacted — turning the feature off WIDENED what the anonymous
108+
// endpoint serves. Opting out gates MINTING (`createLink`'s 422 reads
109+
// `enabled`, not this list) and whatever #14033 rules for standing
110+
// links; it must never strip the object's declared redactions from
111+
// tokens that still serve. An object with no `publicSharing` block at
112+
// all keeps `[]` — nothing declared, nothing redacted — exactly as
113+
// before.
114+
redactFields: Array.isArray(raw?.redactFields) ? (raw.redactFields as string[]) : [],
104115
};
105116
}
106117
return {

0 commit comments

Comments
 (0)