Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/share-link-redactfields-survive-optout.md
Original file line numberDiff line numberDiff line change
@@ -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.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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` |

Expand Down
154 changes: 154 additions & 0 deletions packages/plugins/plugin-sharing/src/share-link-service.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, any> = {
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<string, any>) {
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');
});
});
13 changes: 12 additions & 1 deletion packages/plugins/plugin-sharing/src/share-link-service.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/share-link-redactfields-survive-optout.md
Original file line numberDiff line numberDiff line change
@@ -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.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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` |

Expand Down
154 changes: 154 additions & 0 deletions packages/plugins/plugin-sharing/src/share-link-service.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, any> = {
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<string, any>) {
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');
});
});
13 changes: 12 additions & 1 deletion packages/plugins/plugin-sharing/src/share-link-service.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/share-link-redactfields-survive-optout.md
Original file line numberDiff line numberDiff line change
@@ -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.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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` |

Expand Down
154 changes: 154 additions & 0 deletions packages/plugins/plugin-sharing/src/share-link-service.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, any> = {
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<string, any>) {
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');
});
});
13 changes: 12 additions & 1 deletion packages/plugins/plugin-sharing/src/share-link-service.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/share-link-redactfields-survive-optout.md
Original file line numberDiff line numberDiff line change
@@ -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.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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` |

Expand Down
154 changes: 154 additions & 0 deletions packages/plugins/plugin-sharing/src/share-link-service.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, any> = {
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<string, any>) {
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');
});
});
13 changes: 12 additions & 1 deletion packages/plugins/plugin-sharing/src/share-link-service.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/share-link-redactfields-survive-optout.md
Original file line numberDiff line numberDiff line change
@@ -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.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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` |

Expand Down
154 changes: 154 additions & 0 deletions packages/plugins/plugin-sharing/src/share-link-service.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, any> = {
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<string, any>) {
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');
});
});
13 changes: 12 additions & 1 deletion packages/plugins/plugin-sharing/src/share-link-service.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/share-link-redactfields-survive-optout.md
Original file line numberDiff line numberDiff line change
@@ -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.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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` |

Expand Down
154 changes: 154 additions & 0 deletions packages/plugins/plugin-sharing/src/share-link-service.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, any> = {
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<string, any>) {
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');
});
});
13 changes: 12 additions & 1 deletion packages/plugins/plugin-sharing/src/share-link-service.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/share-link-redactfields-survive-optout.md
Original file line numberDiff line numberDiff line change
@@ -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.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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` |

Expand Down
154 changes: 154 additions & 0 deletions packages/plugins/plugin-sharing/src/share-link-service.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, any> = {
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<string, any>) {
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');
});
});
13 changes: 12 additions & 1 deletion packages/plugins/plugin-sharing/src/share-link-service.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/share-link-redactfields-survive-optout.md
Original file line numberDiff line numberDiff line change
@@ -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.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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` |

Expand Down
154 changes: 154 additions & 0 deletions packages/plugins/plugin-sharing/src/share-link-service.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, any> = {
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<string, any>) {
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');
});
});
13 changes: 12 additions & 1 deletion packages/plugins/plugin-sharing/src/share-link-service.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand Down
Loading