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
69 changes: 69 additions & 0 deletions .changeset/share-link-enabled-at-redemption.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
---
"@objectstack/plugin-sharing": minor
---

fix(plugin-sharing): hold `publicSharing.enabled` at redemption, not only at mint (#14033)

**BREAKING** runtime behaviour change on a published package: share links that
were legitimately minted can now stop resolving without anyone revoking them —
every link on an object whose `publicSharing.enabled` is not `true`. Shipped as
`minor` under the repo's launch-window convention (a breaking change does not
burn a major while the stack is in lockstep). No export is added, removed or
re-shaped; the level carries the breaking banner, not a surface change.

`ShareLinkService.createLink()` refused to mint on an object whose
`publicSharing` block was absent or had `enabled !== true` (422
`SHARING_NOT_ENABLED`), and nothing checked the switch again. `resolveToken()`
checked `revoked_at`, `expires_at`, the audience gates, the password, record
existence and — since #13608 — the block's `eligibility` predicate, then served
whatever survived, under the system context, to a caller with no principal at
all. So the platform held the block's CHILD predicate as a standing policy while
its PARENT switch governed minting only: an author who turned the whole feature
off stopped new links and not one existing link, and would have had to narrow
the predicate to stop anonymous serving — the opposite of what the surface
reads like. Measured before it was changed: a token minted while the block was
on kept serving the record in full after the block was turned off.

**What changed.** `resolveToken()` reads the object's CURRENT `publicSharing`
block on every redemption and refuses when `enabled` is not `true` — before the
record is read, before the usage stamp, before any sibling key inside the block
is evaluated. Re-enabling the block restores the same tokens: this is a standing
policy, not a revocation, and no `sys_share_link` row is touched. How a link was
minted buys it nothing at redemption — a link minted under a system context or
the service's `permissive` bypass (the system-context ledger's row 37 path) on a
switched-off object refuses exactly like one orphaned by an author turning the
block off, and an object with no `publicSharing` block at all is the same switch
at its default and refuses too. With the block on, `eligibility` (#13608) and
the declared `redactFields` (#13856) keep their existing redemption-time
behaviour; nothing new is evaluated. An object the engine cannot return a
schema for — no `getSchema` on the engine, or an object not registered at the
moment of redemption — is `enabled: false` by `getPolicy`'s definition and is
refused at redemption: fail-closed, the same definition `createLink` uses. The
#13856 entry's "an opted-out object's links keep resolving with the declared
redactions" state is superseded: with the block off they do not resolve at all.

**The refusal is deliberately indistinguishable.** It is the same answer a
revoked, expired, unknown or no-longer-eligible token already gets: the
undifferentiated `null` — no new error code, no new response branch, and no
usage stamp. Over HTTP a switched-off link is answered with the generic
`404 INVALID_OR_EXPIRED`, byte-for-byte what a token that never existed
receives. The readable reason (`SHARING_NOT_ENABLED`, with the link, object and
record ids) is written to the server-side log at `warn`, where the eligibility
refusal already writes its own.

**Operator impact — retroactive, on deploy.** Every live link on an object whose
`publicSharing` block is currently switched off — or that never declared one —
stops resolving the moment this version is deployed, with no revocation event
and no grace period. That is the intent: the alternative is a declared switch
the platform does not hold. Measure before rollout: the objects to read are
those whose `publicSharing.enabled` is not `true`, and the links at risk are the
`sys_share_link` rows naming them (`object_name`). To keep such links working,
enable the block — and narrow it with `eligibility` / `redactFields` if the
feature was off for a reason; there is no per-link opt-out, deliberately.
Minting is unchanged: `createLink` still refuses `SHARING_NOT_ENABLED` for an
ordinary caller, and the system / `permissive` bypass still mints — what it
mints simply does not serve until the block is on. The refusal logs one `warn`
line per refused hit and is not latched, so a retroactive deploy with many live
links on switched-off objects will burst the log once.

<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is removed, renamed or re-shaped: `publicSharing.enabled` keeps its name, its type, its default and its accept-set, and the change is WHEN the platform holds it. There is therefore no tombstone for `objectstack migrate meta` to carry and no mechanical rewrite it could perform — a deployment whose links stop resolving must decide whether the block should be on at all, which is an authoring decision no ledger entry can make on its behalf. -->
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:440`, `:494`, `:498`, `:571`, `:601` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `plugin-sharing/src/share-link-service.ts:449`, `:503`, `:507`, `:580`, `:610` |
| 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` |
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:157`, `:382` |

Expand Down
175 changes: 175 additions & 0 deletions packages/plugins/plugin-sharing/src/share-link-eligibility.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -917,6 +917,181 @@ describe('[#13608] publicSharing.eligibility is enforced again at REDEMPTION', (
});
});

/**
* [#14033] The PARENT switch is a standing policy too.
*
* `publicSharing.enabled` governed MINTING only: `getPolicy()` collapsed to an
* empty policy when the block was off, and `resolveToken` read nothing off
* `policy.enabled`. So the platform held this shape — the predicate INSIDE
* the block was re-evaluated at every redemption (#13608, above) while turning
* the ENTIRE block off did not stop a single existing link. Maintainer ruling
* of 2026-09-01 (quoted verbatim in `share-link-service.test.ts`'s reversal
* register): the switch is a standing policy held at every redemption,
* retroactively; a link minted through the system / `permissive` bypass is
* governed the same way; and with the block off nothing inside it is
* evaluated at all, while with it on the sibling keys keep their
* redemption-time behaviour.
*
* These pins use the real driver and the real public route: "no record read"
* is measured off the engine's call log, the refusal shape is measured at the
* seam an anonymous holder actually reaches, and the reason is read off the
* server-side log — the only place the ruling leaves it, exactly as for the
* eligibility refusal above.
*/
describe('[#14033] publicSharing.enabled is a standing policy — the switch is held at redemption', () => {
/** A token minted while the block is ON and `a_ok` qualifies — the pre-condition of every case below. */
async function mint(service: ShareLinkService, recordId = 'a_ok') {
const link = await service.createLink(
{ object: 'article', recordId, audience: 'public', permission: 'view' },
CALLER,
);
expect(link.token).toBeTruthy();
return link;
}

/** The switch, thrown from OUTSIDE the token's life: the object's declared block, `enabled: false`. */
const switchOff = (schemas: Record<string, any>) => {
schemas.article = { ...ARTICLE, publicSharing: { ...ARTICLE.publicSharing, enabled: false } };
};
const switchOn = (schemas: Record<string, any>) => { schemas.article = ARTICLE; };

/** The row as the table holds it now — usage counters included. */
async function linkRow(driver: SqlDriver, id: string): Promise<any> {
const rows = await driver.find('sys_share_link', {} as DriverQuery);
return rows.find((r: any) => r.id === id);
}

it('THE REPRO — an ELIGIBLE record on a switched-off block is refused, with no record read and no usage stamp', async () => {
const { driver, service, schemas, findCalls } = await boot();
const link = await mint(service);

switchOff(schemas);
findCalls.length = 0;

// `a_ok` is published + public: no eligibility refusal is available here,
// so this `null` can only have come from the switch.
expect(await service.resolveToken(link.token, {})).toBeNull();

// Refused before the record probe — the token lookup was the only read.
expect(findCalls.map((c) => c.object)).toEqual(['sys_share_link']);
// …and before the usage stamp.
const row = await linkRow(driver, link.id);
expect(row.use_count ?? 0).toBe(0);
expect(row.last_used_at ?? null).toBeNull();
});

/**
* The HTTP seam, driven end-to-end on the real service through the real
* route, with the route's SECURE default context — every request below is
* anonymous. Same reading as the #13608 pin above, for the same reason: the
* switched-off link lands in the generic "invalid / expired / revoked"
* answer, byte-for-byte what a token that NEVER EXISTED gets — not the 410
* bucket, which would confirm the token was real, and not a 422 naming the
* policy, which is what letting `SHARING_NOT_ENABLED` escape would produce.
*/
it('at the HTTP seam an anonymous caller cannot tell a switched-off link from an unknown token', async () => {
const { service, engine, schemas } = await boot();
const live = await mint(service);
const revoked = await mint(service);
await service.revokeLink(revoked.token, { isSystem: true } as any);

const resolve = mountResolveRoute(service, engine);

// Before the switch: the link serves the record.
expect((await resolve(live.token)).status).toBe(200);

switchOff(schemas);

const switchedOff = await resolve(live.token);
const unknown = await resolve('zzzzzzzzzzzzzzzzzzzzzz');
const revokedAnswer = await resolve(revoked.token);

expect(switchedOff).toEqual(unknown);
expect(switchedOff.status).toBe(404);
expect(switchedOff.body?.error?.code).toBe('INVALID_OR_EXPIRED');
// Nothing about the policy or the switch reaches the wire.
const wire = JSON.stringify(switchedOff.body).toLowerCase();
expect(wire).not.toContain('enabled');
expect(wire).not.toContain('publicsharing');
expect(wire).not.toContain('sharing_not_enabled');

// The pre-existing revoked bucket, recorded as measured: a DIFFERENT
// status, and this change does not move it.
expect(revokedAnswer.status).toBe(410);
expect(revokedAnswer.body?.error?.code).toBe('EXPIRED_OR_REVOKED');
});

it('the reason a switched-off link died is written to the server-side log, and only there', async () => {
const logged: LoggedRefusal[] = [];
const { service, schemas } = await boot(ARTICLE, {
logger: { warn: (msg, meta) => { logged.push({ msg, meta }); } },
});
const link = await mint(service);
switchOff(schemas);

expect(await service.resolveToken(link.token, {})).toBeNull();

expect(logged).toHaveLength(1);
expect(logged[0].msg).toContain('publicSharing.enabled');
expect(logged[0].meta?.reason).toBe('SHARING_NOT_ENABLED');
expect(logged[0].meta?.link).toBe(link.id);
expect(logged[0].meta?.object).toBe('article');
expect(logged[0].meta?.record).toBe('a_ok');
});

/**
* Ruling point 4, both halves on ONE token. OFF: the switch refuses before
* anything inside the block is evaluated — the record is not even read, so
* the predicate that WOULD refuse it never runs. ON again: the same token is
* judged by the predicate once more, and refused by IT; when the record
* qualifies again the token serves. A standing policy, not a revocation.
*/
it('OFF: nothing inside the block is evaluated; ON again: the eligibility re-check resumes on the same token', async () => {
const logged: LoggedRefusal[] = [];
const { driver, service, schemas, findCalls } = await boot(ARTICLE, {
logger: { warn: (msg, meta) => { logged.push({ msg, meta }); } },
});
const link = await mint(service);

// Reclassify the record so the predicate would refuse it — THEN switch off.
await driver.update('article', 'a_ok', { audience: 'internal' });
switchOff(schemas);
findCalls.length = 0;

expect(await service.resolveToken(link.token, {})).toBeNull();
expect(findCalls.map((c) => c.object)).toEqual(['sys_share_link']);
expect(logged.map((l) => l.meta?.reason)).toEqual(['SHARING_NOT_ENABLED']);

switchOn(schemas);
expect(await service.resolveToken(link.token, {})).toBeNull();
expect(logged.map((l) => l.meta?.reason)).toEqual(['SHARING_NOT_ENABLED', 'RECORD_NOT_ELIGIBLE']);

await driver.update('article', 'a_ok', { audience: 'public' });
expect(await service.resolveToken(link.token, {})).not.toBeNull();
});

/**
* Ruling point 3 on the real driver: the `permissive` bypass still MINTS on
* a switched-off block (ledger row 37's path — the ruling governs
* redemption, not minting), and the result is refused at redemption by the
* bypassing service and the ordinary one alike.
*/
it('a link minted through the `permissive` bypass on a switched-off block is refused at redemption', async () => {
const off = { ...ARTICLE, publicSharing: { ...ARTICLE.publicSharing, enabled: false } };
const { service, engine } = await boot(off);
const bypass = new ShareLinkService({ engine: engine as any, permissive: true });

const link = await bypass.createLink(
{ object: 'article', recordId: 'a_ok', audience: 'public', permission: 'view' },
CALLER,
);
expect(link.token).toBeTruthy();

expect(await bypass.resolveToken(link.token, {})).toBeNull();
expect(await service.resolveToken(link.token, {})).toBeNull();
});
});

/**
* [#13608] Mount the real PUBLIC resolve route on the real service.
*
Expand Down
Loading
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
69 changes: 69 additions & 0 deletions .changeset/share-link-enabled-at-redemption.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
---
"@objectstack/plugin-sharing": minor
---

fix(plugin-sharing): hold `publicSharing.enabled` at redemption, not only at mint (#14033)

**BREAKING** runtime behaviour change on a published package: share links that
were legitimately minted can now stop resolving without anyone revoking them —
every link on an object whose `publicSharing.enabled` is not `true`. Shipped as
`minor` under the repo's launch-window convention (a breaking change does not
burn a major while the stack is in lockstep). No export is added, removed or
re-shaped; the level carries the breaking banner, not a surface change.

`ShareLinkService.createLink()` refused to mint on an object whose
`publicSharing` block was absent or had `enabled !== true` (422
`SHARING_NOT_ENABLED`), and nothing checked the switch again. `resolveToken()`
checked `revoked_at`, `expires_at`, the audience gates, the password, record
existence and — since #13608 — the block's `eligibility` predicate, then served
whatever survived, under the system context, to a caller with no principal at
all. So the platform held the block's CHILD predicate as a standing policy while
its PARENT switch governed minting only: an author who turned the whole feature
off stopped new links and not one existing link, and would have had to narrow
the predicate to stop anonymous serving — the opposite of what the surface
reads like. Measured before it was changed: a token minted while the block was
on kept serving the record in full after the block was turned off.

**What changed.** `resolveToken()` reads the object's CURRENT `publicSharing`
block on every redemption and refuses when `enabled` is not `true` — before the
record is read, before the usage stamp, before any sibling key inside the block
is evaluated. Re-enabling the block restores the same tokens: this is a standing
policy, not a revocation, and no `sys_share_link` row is touched. How a link was
minted buys it nothing at redemption — a link minted under a system context or
the service's `permissive` bypass (the system-context ledger's row 37 path) on a
switched-off object refuses exactly like one orphaned by an author turning the
block off, and an object with no `publicSharing` block at all is the same switch
at its default and refuses too. With the block on, `eligibility` (#13608) and
the declared `redactFields` (#13856) keep their existing redemption-time
behaviour; nothing new is evaluated. An object the engine cannot return a
schema for — no `getSchema` on the engine, or an object not registered at the
moment of redemption — is `enabled: false` by `getPolicy`'s definition and is
refused at redemption: fail-closed, the same definition `createLink` uses. The
#13856 entry's "an opted-out object's links keep resolving with the declared
redactions" state is superseded: with the block off they do not resolve at all.

**The refusal is deliberately indistinguishable.** It is the same answer a
revoked, expired, unknown or no-longer-eligible token already gets: the
undifferentiated `null` — no new error code, no new response branch, and no
usage stamp. Over HTTP a switched-off link is answered with the generic
`404 INVALID_OR_EXPIRED`, byte-for-byte what a token that never existed
receives. The readable reason (`SHARING_NOT_ENABLED`, with the link, object and
record ids) is written to the server-side log at `warn`, where the eligibility
refusal already writes its own.

**Operator impact — retroactive, on deploy.** Every live link on an object whose
`publicSharing` block is currently switched off — or that never declared one —
stops resolving the moment this version is deployed, with no revocation event
and no grace period. That is the intent: the alternative is a declared switch
the platform does not hold. Measure before rollout: the objects to read are
those whose `publicSharing.enabled` is not `true`, and the links at risk are the
`sys_share_link` rows naming them (`object_name`). To keep such links working,
enable the block — and narrow it with `eligibility` / `redactFields` if the
feature was off for a reason; there is no per-link opt-out, deliberately.
Minting is unchanged: `createLink` still refuses `SHARING_NOT_ENABLED` for an
ordinary caller, and the system / `permissive` bypass still mints — what it
mints simply does not serve until the block is on. The refusal logs one `warn`
line per refused hit and is not latched, so a retroactive deploy with many live
links on switched-off objects will burst the log once.

<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is removed, renamed or re-shaped: `publicSharing.enabled` keeps its name, its type, its default and its accept-set, and the change is WHEN the platform holds it. There is therefore no tombstone for `objectstack migrate meta` to carry and no mechanical rewrite it could perform — a deployment whose links stop resolving must decide whether the block should be on at all, which is an authoring decision no ledger entry can make on its behalf. -->
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:440`, `:494`, `:498`, `:571`, `:601` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `plugin-sharing/src/share-link-service.ts:449`, `:503`, `:507`, `:580`, `:610` |
| 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` |
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:157`, `:382` |

Expand Down
175 changes: 175 additions & 0 deletions packages/plugins/plugin-sharing/src/share-link-eligibility.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -917,6 +917,181 @@ describe('[#13608] publicSharing.eligibility is enforced again at REDEMPTION', (
});
});

/**
* [#14033] The PARENT switch is a standing policy too.
*
* `publicSharing.enabled` governed MINTING only: `getPolicy()` collapsed to an
* empty policy when the block was off, and `resolveToken` read nothing off
* `policy.enabled`. So the platform held this shape — the predicate INSIDE
* the block was re-evaluated at every redemption (#13608, above) while turning
* the ENTIRE block off did not stop a single existing link. Maintainer ruling
* of 2026-09-01 (quoted verbatim in `share-link-service.test.ts`'s reversal
* register): the switch is a standing policy held at every redemption,
* retroactively; a link minted through the system / `permissive` bypass is
* governed the same way; and with the block off nothing inside it is
* evaluated at all, while with it on the sibling keys keep their
* redemption-time behaviour.
*
* These pins use the real driver and the real public route: "no record read"
* is measured off the engine's call log, the refusal shape is measured at the
* seam an anonymous holder actually reaches, and the reason is read off the
* server-side log — the only place the ruling leaves it, exactly as for the
* eligibility refusal above.
*/
describe('[#14033] publicSharing.enabled is a standing policy — the switch is held at redemption', () => {
/** A token minted while the block is ON and `a_ok` qualifies — the pre-condition of every case below. */
async function mint(service: ShareLinkService, recordId = 'a_ok') {
const link = await service.createLink(
{ object: 'article', recordId, audience: 'public', permission: 'view' },
CALLER,
);
expect(link.token).toBeTruthy();
return link;
}

/** The switch, thrown from OUTSIDE the token's life: the object's declared block, `enabled: false`. */
const switchOff = (schemas: Record<string, any>) => {
schemas.article = { ...ARTICLE, publicSharing: { ...ARTICLE.publicSharing, enabled: false } };
};
const switchOn = (schemas: Record<string, any>) => { schemas.article = ARTICLE; };

/** The row as the table holds it now — usage counters included. */
async function linkRow(driver: SqlDriver, id: string): Promise<any> {
const rows = await driver.find('sys_share_link', {} as DriverQuery);
return rows.find((r: any) => r.id === id);
}

it('THE REPRO — an ELIGIBLE record on a switched-off block is refused, with no record read and no usage stamp', async () => {
const { driver, service, schemas, findCalls } = await boot();
const link = await mint(service);

switchOff(schemas);
findCalls.length = 0;

// `a_ok` is published + public: no eligibility refusal is available here,
// so this `null` can only have come from the switch.
expect(await service.resolveToken(link.token, {})).toBeNull();

// Refused before the record probe — the token lookup was the only read.
expect(findCalls.map((c) => c.object)).toEqual(['sys_share_link']);
// …and before the usage stamp.
const row = await linkRow(driver, link.id);
expect(row.use_count ?? 0).toBe(0);
expect(row.last_used_at ?? null).toBeNull();
});

/**
* The HTTP seam, driven end-to-end on the real service through the real
* route, with the route's SECURE default context — every request below is
* anonymous. Same reading as the #13608 pin above, for the same reason: the
* switched-off link lands in the generic "invalid / expired / revoked"
* answer, byte-for-byte what a token that NEVER EXISTED gets — not the 410
* bucket, which would confirm the token was real, and not a 422 naming the
* policy, which is what letting `SHARING_NOT_ENABLED` escape would produce.
*/
it('at the HTTP seam an anonymous caller cannot tell a switched-off link from an unknown token', async () => {
const { service, engine, schemas } = await boot();
const live = await mint(service);
const revoked = await mint(service);
await service.revokeLink(revoked.token, { isSystem: true } as any);

const resolve = mountResolveRoute(service, engine);

// Before the switch: the link serves the record.
expect((await resolve(live.token)).status).toBe(200);

switchOff(schemas);

const switchedOff = await resolve(live.token);
const unknown = await resolve('zzzzzzzzzzzzzzzzzzzzzz');
const revokedAnswer = await resolve(revoked.token);

expect(switchedOff).toEqual(unknown);
expect(switchedOff.status).toBe(404);
expect(switchedOff.body?.error?.code).toBe('INVALID_OR_EXPIRED');
// Nothing about the policy or the switch reaches the wire.
const wire = JSON.stringify(switchedOff.body).toLowerCase();
expect(wire).not.toContain('enabled');
expect(wire).not.toContain('publicsharing');
expect(wire).not.toContain('sharing_not_enabled');

// The pre-existing revoked bucket, recorded as measured: a DIFFERENT
// status, and this change does not move it.
expect(revokedAnswer.status).toBe(410);
expect(revokedAnswer.body?.error?.code).toBe('EXPIRED_OR_REVOKED');
});

it('the reason a switched-off link died is written to the server-side log, and only there', async () => {
const logged: LoggedRefusal[] = [];
const { service, schemas } = await boot(ARTICLE, {
logger: { warn: (msg, meta) => { logged.push({ msg, meta }); } },
});
const link = await mint(service);
switchOff(schemas);

expect(await service.resolveToken(link.token, {})).toBeNull();

expect(logged).toHaveLength(1);
expect(logged[0].msg).toContain('publicSharing.enabled');
expect(logged[0].meta?.reason).toBe('SHARING_NOT_ENABLED');
expect(logged[0].meta?.link).toBe(link.id);
expect(logged[0].meta?.object).toBe('article');
expect(logged[0].meta?.record).toBe('a_ok');
});

/**
* Ruling point 4, both halves on ONE token. OFF: the switch refuses before
* anything inside the block is evaluated — the record is not even read, so
* the predicate that WOULD refuse it never runs. ON again: the same token is
* judged by the predicate once more, and refused by IT; when the record
* qualifies again the token serves. A standing policy, not a revocation.
*/
it('OFF: nothing inside the block is evaluated; ON again: the eligibility re-check resumes on the same token', async () => {
const logged: LoggedRefusal[] = [];
const { driver, service, schemas, findCalls } = await boot(ARTICLE, {
logger: { warn: (msg, meta) => { logged.push({ msg, meta }); } },
});
const link = await mint(service);

// Reclassify the record so the predicate would refuse it — THEN switch off.
await driver.update('article', 'a_ok', { audience: 'internal' });
switchOff(schemas);
findCalls.length = 0;

expect(await service.resolveToken(link.token, {})).toBeNull();
expect(findCalls.map((c) => c.object)).toEqual(['sys_share_link']);
expect(logged.map((l) => l.meta?.reason)).toEqual(['SHARING_NOT_ENABLED']);

switchOn(schemas);
expect(await service.resolveToken(link.token, {})).toBeNull();
expect(logged.map((l) => l.meta?.reason)).toEqual(['SHARING_NOT_ENABLED', 'RECORD_NOT_ELIGIBLE']);

await driver.update('article', 'a_ok', { audience: 'public' });
expect(await service.resolveToken(link.token, {})).not.toBeNull();
});

/**
* Ruling point 3 on the real driver: the `permissive` bypass still MINTS on
* a switched-off block (ledger row 37's path — the ruling governs
* redemption, not minting), and the result is refused at redemption by the
* bypassing service and the ordinary one alike.
*/
it('a link minted through the `permissive` bypass on a switched-off block is refused at redemption', async () => {
const off = { ...ARTICLE, publicSharing: { ...ARTICLE.publicSharing, enabled: false } };
const { service, engine } = await boot(off);
const bypass = new ShareLinkService({ engine: engine as any, permissive: true });

const link = await bypass.createLink(
{ object: 'article', recordId: 'a_ok', audience: 'public', permission: 'view' },
CALLER,
);
expect(link.token).toBeTruthy();

expect(await bypass.resolveToken(link.token, {})).toBeNull();
expect(await service.resolveToken(link.token, {})).toBeNull();
});
});

/**
* [#13608] Mount the real PUBLIC resolve route on the real service.
*
Expand Down
Loading
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
69 changes: 69 additions & 0 deletions .changeset/share-link-enabled-at-redemption.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
---
"@objectstack/plugin-sharing": minor
---

fix(plugin-sharing): hold `publicSharing.enabled` at redemption, not only at mint (#14033)

**BREAKING** runtime behaviour change on a published package: share links that
were legitimately minted can now stop resolving without anyone revoking them —
every link on an object whose `publicSharing.enabled` is not `true`. Shipped as
`minor` under the repo's launch-window convention (a breaking change does not
burn a major while the stack is in lockstep). No export is added, removed or
re-shaped; the level carries the breaking banner, not a surface change.

`ShareLinkService.createLink()` refused to mint on an object whose
`publicSharing` block was absent or had `enabled !== true` (422
`SHARING_NOT_ENABLED`), and nothing checked the switch again. `resolveToken()`
checked `revoked_at`, `expires_at`, the audience gates, the password, record
existence and — since #13608 — the block's `eligibility` predicate, then served
whatever survived, under the system context, to a caller with no principal at
all. So the platform held the block's CHILD predicate as a standing policy while
its PARENT switch governed minting only: an author who turned the whole feature
off stopped new links and not one existing link, and would have had to narrow
the predicate to stop anonymous serving — the opposite of what the surface
reads like. Measured before it was changed: a token minted while the block was
on kept serving the record in full after the block was turned off.

**What changed.** `resolveToken()` reads the object's CURRENT `publicSharing`
block on every redemption and refuses when `enabled` is not `true` — before the
record is read, before the usage stamp, before any sibling key inside the block
is evaluated. Re-enabling the block restores the same tokens: this is a standing
policy, not a revocation, and no `sys_share_link` row is touched. How a link was
minted buys it nothing at redemption — a link minted under a system context or
the service's `permissive` bypass (the system-context ledger's row 37 path) on a
switched-off object refuses exactly like one orphaned by an author turning the
block off, and an object with no `publicSharing` block at all is the same switch
at its default and refuses too. With the block on, `eligibility` (#13608) and
the declared `redactFields` (#13856) keep their existing redemption-time
behaviour; nothing new is evaluated. An object the engine cannot return a
schema for — no `getSchema` on the engine, or an object not registered at the
moment of redemption — is `enabled: false` by `getPolicy`'s definition and is
refused at redemption: fail-closed, the same definition `createLink` uses. The
#13856 entry's "an opted-out object's links keep resolving with the declared
redactions" state is superseded: with the block off they do not resolve at all.

**The refusal is deliberately indistinguishable.** It is the same answer a
revoked, expired, unknown or no-longer-eligible token already gets: the
undifferentiated `null` — no new error code, no new response branch, and no
usage stamp. Over HTTP a switched-off link is answered with the generic
`404 INVALID_OR_EXPIRED`, byte-for-byte what a token that never existed
receives. The readable reason (`SHARING_NOT_ENABLED`, with the link, object and
record ids) is written to the server-side log at `warn`, where the eligibility
refusal already writes its own.

**Operator impact — retroactive, on deploy.** Every live link on an object whose
`publicSharing` block is currently switched off — or that never declared one —
stops resolving the moment this version is deployed, with no revocation event
and no grace period. That is the intent: the alternative is a declared switch
the platform does not hold. Measure before rollout: the objects to read are
those whose `publicSharing.enabled` is not `true`, and the links at risk are the
`sys_share_link` rows naming them (`object_name`). To keep such links working,
enable the block — and narrow it with `eligibility` / `redactFields` if the
feature was off for a reason; there is no per-link opt-out, deliberately.
Minting is unchanged: `createLink` still refuses `SHARING_NOT_ENABLED` for an
ordinary caller, and the system / `permissive` bypass still mints — what it
mints simply does not serve until the block is on. The refusal logs one `warn`
line per refused hit and is not latched, so a retroactive deploy with many live
links on switched-off objects will burst the log once.

<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is removed, renamed or re-shaped: `publicSharing.enabled` keeps its name, its type, its default and its accept-set, and the change is WHEN the platform holds it. There is therefore no tombstone for `objectstack migrate meta` to carry and no mechanical rewrite it could perform — a deployment whose links stop resolving must decide whether the block should be on at all, which is an authoring decision no ledger entry can make on its behalf. -->
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:440`, `:494`, `:498`, `:571`, `:601` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `plugin-sharing/src/share-link-service.ts:449`, `:503`, `:507`, `:580`, `:610` |
| 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` |
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:157`, `:382` |

Expand Down
175 changes: 175 additions & 0 deletions packages/plugins/plugin-sharing/src/share-link-eligibility.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -917,6 +917,181 @@ describe('[#13608] publicSharing.eligibility is enforced again at REDEMPTION', (
});
});

/**
* [#14033] The PARENT switch is a standing policy too.
*
* `publicSharing.enabled` governed MINTING only: `getPolicy()` collapsed to an
* empty policy when the block was off, and `resolveToken` read nothing off
* `policy.enabled`. So the platform held this shape — the predicate INSIDE
* the block was re-evaluated at every redemption (#13608, above) while turning
* the ENTIRE block off did not stop a single existing link. Maintainer ruling
* of 2026-09-01 (quoted verbatim in `share-link-service.test.ts`'s reversal
* register): the switch is a standing policy held at every redemption,
* retroactively; a link minted through the system / `permissive` bypass is
* governed the same way; and with the block off nothing inside it is
* evaluated at all, while with it on the sibling keys keep their
* redemption-time behaviour.
*
* These pins use the real driver and the real public route: "no record read"
* is measured off the engine's call log, the refusal shape is measured at the
* seam an anonymous holder actually reaches, and the reason is read off the
* server-side log — the only place the ruling leaves it, exactly as for the
* eligibility refusal above.
*/
describe('[#14033] publicSharing.enabled is a standing policy — the switch is held at redemption', () => {
/** A token minted while the block is ON and `a_ok` qualifies — the pre-condition of every case below. */
async function mint(service: ShareLinkService, recordId = 'a_ok') {
const link = await service.createLink(
{ object: 'article', recordId, audience: 'public', permission: 'view' },
CALLER,
);
expect(link.token).toBeTruthy();
return link;
}

/** The switch, thrown from OUTSIDE the token's life: the object's declared block, `enabled: false`. */
const switchOff = (schemas: Record<string, any>) => {
schemas.article = { ...ARTICLE, publicSharing: { ...ARTICLE.publicSharing, enabled: false } };
};
const switchOn = (schemas: Record<string, any>) => { schemas.article = ARTICLE; };

/** The row as the table holds it now — usage counters included. */
async function linkRow(driver: SqlDriver, id: string): Promise<any> {
const rows = await driver.find('sys_share_link', {} as DriverQuery);
return rows.find((r: any) => r.id === id);
}

it('THE REPRO — an ELIGIBLE record on a switched-off block is refused, with no record read and no usage stamp', async () => {
const { driver, service, schemas, findCalls } = await boot();
const link = await mint(service);

switchOff(schemas);
findCalls.length = 0;

// `a_ok` is published + public: no eligibility refusal is available here,
// so this `null` can only have come from the switch.
expect(await service.resolveToken(link.token, {})).toBeNull();

// Refused before the record probe — the token lookup was the only read.
expect(findCalls.map((c) => c.object)).toEqual(['sys_share_link']);
// …and before the usage stamp.
const row = await linkRow(driver, link.id);
expect(row.use_count ?? 0).toBe(0);
expect(row.last_used_at ?? null).toBeNull();
});

/**
* The HTTP seam, driven end-to-end on the real service through the real
* route, with the route's SECURE default context — every request below is
* anonymous. Same reading as the #13608 pin above, for the same reason: the
* switched-off link lands in the generic "invalid / expired / revoked"
* answer, byte-for-byte what a token that NEVER EXISTED gets — not the 410
* bucket, which would confirm the token was real, and not a 422 naming the
* policy, which is what letting `SHARING_NOT_ENABLED` escape would produce.
*/
it('at the HTTP seam an anonymous caller cannot tell a switched-off link from an unknown token', async () => {
const { service, engine, schemas } = await boot();
const live = await mint(service);
const revoked = await mint(service);
await service.revokeLink(revoked.token, { isSystem: true } as any);

const resolve = mountResolveRoute(service, engine);

// Before the switch: the link serves the record.
expect((await resolve(live.token)).status).toBe(200);

switchOff(schemas);

const switchedOff = await resolve(live.token);
const unknown = await resolve('zzzzzzzzzzzzzzzzzzzzzz');
const revokedAnswer = await resolve(revoked.token);

expect(switchedOff).toEqual(unknown);
expect(switchedOff.status).toBe(404);
expect(switchedOff.body?.error?.code).toBe('INVALID_OR_EXPIRED');
// Nothing about the policy or the switch reaches the wire.
const wire = JSON.stringify(switchedOff.body).toLowerCase();
expect(wire).not.toContain('enabled');
expect(wire).not.toContain('publicsharing');
expect(wire).not.toContain('sharing_not_enabled');

// The pre-existing revoked bucket, recorded as measured: a DIFFERENT
// status, and this change does not move it.
expect(revokedAnswer.status).toBe(410);
expect(revokedAnswer.body?.error?.code).toBe('EXPIRED_OR_REVOKED');
});

it('the reason a switched-off link died is written to the server-side log, and only there', async () => {
const logged: LoggedRefusal[] = [];
const { service, schemas } = await boot(ARTICLE, {
logger: { warn: (msg, meta) => { logged.push({ msg, meta }); } },
});
const link = await mint(service);
switchOff(schemas);

expect(await service.resolveToken(link.token, {})).toBeNull();

expect(logged).toHaveLength(1);
expect(logged[0].msg).toContain('publicSharing.enabled');
expect(logged[0].meta?.reason).toBe('SHARING_NOT_ENABLED');
expect(logged[0].meta?.link).toBe(link.id);
expect(logged[0].meta?.object).toBe('article');
expect(logged[0].meta?.record).toBe('a_ok');
});

/**
* Ruling point 4, both halves on ONE token. OFF: the switch refuses before
* anything inside the block is evaluated — the record is not even read, so
* the predicate that WOULD refuse it never runs. ON again: the same token is
* judged by the predicate once more, and refused by IT; when the record
* qualifies again the token serves. A standing policy, not a revocation.
*/
it('OFF: nothing inside the block is evaluated; ON again: the eligibility re-check resumes on the same token', async () => {
const logged: LoggedRefusal[] = [];
const { driver, service, schemas, findCalls } = await boot(ARTICLE, {
logger: { warn: (msg, meta) => { logged.push({ msg, meta }); } },
});
const link = await mint(service);

// Reclassify the record so the predicate would refuse it — THEN switch off.
await driver.update('article', 'a_ok', { audience: 'internal' });
switchOff(schemas);
findCalls.length = 0;

expect(await service.resolveToken(link.token, {})).toBeNull();
expect(findCalls.map((c) => c.object)).toEqual(['sys_share_link']);
expect(logged.map((l) => l.meta?.reason)).toEqual(['SHARING_NOT_ENABLED']);

switchOn(schemas);
expect(await service.resolveToken(link.token, {})).toBeNull();
expect(logged.map((l) => l.meta?.reason)).toEqual(['SHARING_NOT_ENABLED', 'RECORD_NOT_ELIGIBLE']);

await driver.update('article', 'a_ok', { audience: 'public' });
expect(await service.resolveToken(link.token, {})).not.toBeNull();
});

/**
* Ruling point 3 on the real driver: the `permissive` bypass still MINTS on
* a switched-off block (ledger row 37's path — the ruling governs
* redemption, not minting), and the result is refused at redemption by the
* bypassing service and the ordinary one alike.
*/
it('a link minted through the `permissive` bypass on a switched-off block is refused at redemption', async () => {
const off = { ...ARTICLE, publicSharing: { ...ARTICLE.publicSharing, enabled: false } };
const { service, engine } = await boot(off);
const bypass = new ShareLinkService({ engine: engine as any, permissive: true });

const link = await bypass.createLink(
{ object: 'article', recordId: 'a_ok', audience: 'public', permission: 'view' },
CALLER,
);
expect(link.token).toBeTruthy();

expect(await bypass.resolveToken(link.token, {})).toBeNull();
expect(await service.resolveToken(link.token, {})).toBeNull();
});
});

/**
* [#13608] Mount the real PUBLIC resolve route on the real service.
*
Expand Down
Loading
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
69 changes: 69 additions & 0 deletions .changeset/share-link-enabled-at-redemption.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
---
"@objectstack/plugin-sharing": minor
---

fix(plugin-sharing): hold `publicSharing.enabled` at redemption, not only at mint (#14033)

**BREAKING** runtime behaviour change on a published package: share links that
were legitimately minted can now stop resolving without anyone revoking them —
every link on an object whose `publicSharing.enabled` is not `true`. Shipped as
`minor` under the repo's launch-window convention (a breaking change does not
burn a major while the stack is in lockstep). No export is added, removed or
re-shaped; the level carries the breaking banner, not a surface change.

`ShareLinkService.createLink()` refused to mint on an object whose
`publicSharing` block was absent or had `enabled !== true` (422
`SHARING_NOT_ENABLED`), and nothing checked the switch again. `resolveToken()`
checked `revoked_at`, `expires_at`, the audience gates, the password, record
existence and — since #13608 — the block's `eligibility` predicate, then served
whatever survived, under the system context, to a caller with no principal at
all. So the platform held the block's CHILD predicate as a standing policy while
its PARENT switch governed minting only: an author who turned the whole feature
off stopped new links and not one existing link, and would have had to narrow
the predicate to stop anonymous serving — the opposite of what the surface
reads like. Measured before it was changed: a token minted while the block was
on kept serving the record in full after the block was turned off.

**What changed.** `resolveToken()` reads the object's CURRENT `publicSharing`
block on every redemption and refuses when `enabled` is not `true` — before the
record is read, before the usage stamp, before any sibling key inside the block
is evaluated. Re-enabling the block restores the same tokens: this is a standing
policy, not a revocation, and no `sys_share_link` row is touched. How a link was
minted buys it nothing at redemption — a link minted under a system context or
the service's `permissive` bypass (the system-context ledger's row 37 path) on a
switched-off object refuses exactly like one orphaned by an author turning the
block off, and an object with no `publicSharing` block at all is the same switch
at its default and refuses too. With the block on, `eligibility` (#13608) and
the declared `redactFields` (#13856) keep their existing redemption-time
behaviour; nothing new is evaluated. An object the engine cannot return a
schema for — no `getSchema` on the engine, or an object not registered at the
moment of redemption — is `enabled: false` by `getPolicy`'s definition and is
refused at redemption: fail-closed, the same definition `createLink` uses. The
#13856 entry's "an opted-out object's links keep resolving with the declared
redactions" state is superseded: with the block off they do not resolve at all.

**The refusal is deliberately indistinguishable.** It is the same answer a
revoked, expired, unknown or no-longer-eligible token already gets: the
undifferentiated `null` — no new error code, no new response branch, and no
usage stamp. Over HTTP a switched-off link is answered with the generic
`404 INVALID_OR_EXPIRED`, byte-for-byte what a token that never existed
receives. The readable reason (`SHARING_NOT_ENABLED`, with the link, object and
record ids) is written to the server-side log at `warn`, where the eligibility
refusal already writes its own.

**Operator impact — retroactive, on deploy.** Every live link on an object whose
`publicSharing` block is currently switched off — or that never declared one —
stops resolving the moment this version is deployed, with no revocation event
and no grace period. That is the intent: the alternative is a declared switch
the platform does not hold. Measure before rollout: the objects to read are
those whose `publicSharing.enabled` is not `true`, and the links at risk are the
`sys_share_link` rows naming them (`object_name`). To keep such links working,
enable the block — and narrow it with `eligibility` / `redactFields` if the
feature was off for a reason; there is no per-link opt-out, deliberately.
Minting is unchanged: `createLink` still refuses `SHARING_NOT_ENABLED` for an
ordinary caller, and the system / `permissive` bypass still mints — what it
mints simply does not serve until the block is on. The refusal logs one `warn`
line per refused hit and is not latched, so a retroactive deploy with many live
links on switched-off objects will burst the log once.

<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is removed, renamed or re-shaped: `publicSharing.enabled` keeps its name, its type, its default and its accept-set, and the change is WHEN the platform holds it. There is therefore no tombstone for `objectstack migrate meta` to carry and no mechanical rewrite it could perform — a deployment whose links stop resolving must decide whether the block should be on at all, which is an authoring decision no ledger entry can make on its behalf. -->
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:440`, `:494`, `:498`, `:571`, `:601` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `plugin-sharing/src/share-link-service.ts:449`, `:503`, `:507`, `:580`, `:610` |
| 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` |
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:157`, `:382` |

Expand Down
175 changes: 175 additions & 0 deletions packages/plugins/plugin-sharing/src/share-link-eligibility.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -917,6 +917,181 @@ describe('[#13608] publicSharing.eligibility is enforced again at REDEMPTION', (
});
});

/**
* [#14033] The PARENT switch is a standing policy too.
*
* `publicSharing.enabled` governed MINTING only: `getPolicy()` collapsed to an
* empty policy when the block was off, and `resolveToken` read nothing off
* `policy.enabled`. So the platform held this shape — the predicate INSIDE
* the block was re-evaluated at every redemption (#13608, above) while turning
* the ENTIRE block off did not stop a single existing link. Maintainer ruling
* of 2026-09-01 (quoted verbatim in `share-link-service.test.ts`'s reversal
* register): the switch is a standing policy held at every redemption,
* retroactively; a link minted through the system / `permissive` bypass is
* governed the same way; and with the block off nothing inside it is
* evaluated at all, while with it on the sibling keys keep their
* redemption-time behaviour.
*
* These pins use the real driver and the real public route: "no record read"
* is measured off the engine's call log, the refusal shape is measured at the
* seam an anonymous holder actually reaches, and the reason is read off the
* server-side log — the only place the ruling leaves it, exactly as for the
* eligibility refusal above.
*/
describe('[#14033] publicSharing.enabled is a standing policy — the switch is held at redemption', () => {
/** A token minted while the block is ON and `a_ok` qualifies — the pre-condition of every case below. */
async function mint(service: ShareLinkService, recordId = 'a_ok') {
const link = await service.createLink(
{ object: 'article', recordId, audience: 'public', permission: 'view' },
CALLER,
);
expect(link.token).toBeTruthy();
return link;
}

/** The switch, thrown from OUTSIDE the token's life: the object's declared block, `enabled: false`. */
const switchOff = (schemas: Record<string, any>) => {
schemas.article = { ...ARTICLE, publicSharing: { ...ARTICLE.publicSharing, enabled: false } };
};
const switchOn = (schemas: Record<string, any>) => { schemas.article = ARTICLE; };

/** The row as the table holds it now — usage counters included. */
async function linkRow(driver: SqlDriver, id: string): Promise<any> {
const rows = await driver.find('sys_share_link', {} as DriverQuery);
return rows.find((r: any) => r.id === id);
}

it('THE REPRO — an ELIGIBLE record on a switched-off block is refused, with no record read and no usage stamp', async () => {
const { driver, service, schemas, findCalls } = await boot();
const link = await mint(service);

switchOff(schemas);
findCalls.length = 0;

// `a_ok` is published + public: no eligibility refusal is available here,
// so this `null` can only have come from the switch.
expect(await service.resolveToken(link.token, {})).toBeNull();

// Refused before the record probe — the token lookup was the only read.
expect(findCalls.map((c) => c.object)).toEqual(['sys_share_link']);
// …and before the usage stamp.
const row = await linkRow(driver, link.id);
expect(row.use_count ?? 0).toBe(0);
expect(row.last_used_at ?? null).toBeNull();
});

/**
* The HTTP seam, driven end-to-end on the real service through the real
* route, with the route's SECURE default context — every request below is
* anonymous. Same reading as the #13608 pin above, for the same reason: the
* switched-off link lands in the generic "invalid / expired / revoked"
* answer, byte-for-byte what a token that NEVER EXISTED gets — not the 410
* bucket, which would confirm the token was real, and not a 422 naming the
* policy, which is what letting `SHARING_NOT_ENABLED` escape would produce.
*/
it('at the HTTP seam an anonymous caller cannot tell a switched-off link from an unknown token', async () => {
const { service, engine, schemas } = await boot();
const live = await mint(service);
const revoked = await mint(service);
await service.revokeLink(revoked.token, { isSystem: true } as any);

const resolve = mountResolveRoute(service, engine);

// Before the switch: the link serves the record.
expect((await resolve(live.token)).status).toBe(200);

switchOff(schemas);

const switchedOff = await resolve(live.token);
const unknown = await resolve('zzzzzzzzzzzzzzzzzzzzzz');
const revokedAnswer = await resolve(revoked.token);

expect(switchedOff).toEqual(unknown);
expect(switchedOff.status).toBe(404);
expect(switchedOff.body?.error?.code).toBe('INVALID_OR_EXPIRED');
// Nothing about the policy or the switch reaches the wire.
const wire = JSON.stringify(switchedOff.body).toLowerCase();
expect(wire).not.toContain('enabled');
expect(wire).not.toContain('publicsharing');
expect(wire).not.toContain('sharing_not_enabled');

// The pre-existing revoked bucket, recorded as measured: a DIFFERENT
// status, and this change does not move it.
expect(revokedAnswer.status).toBe(410);
expect(revokedAnswer.body?.error?.code).toBe('EXPIRED_OR_REVOKED');
});

it('the reason a switched-off link died is written to the server-side log, and only there', async () => {
const logged: LoggedRefusal[] = [];
const { service, schemas } = await boot(ARTICLE, {
logger: { warn: (msg, meta) => { logged.push({ msg, meta }); } },
});
const link = await mint(service);
switchOff(schemas);

expect(await service.resolveToken(link.token, {})).toBeNull();

expect(logged).toHaveLength(1);
expect(logged[0].msg).toContain('publicSharing.enabled');
expect(logged[0].meta?.reason).toBe('SHARING_NOT_ENABLED');
expect(logged[0].meta?.link).toBe(link.id);
expect(logged[0].meta?.object).toBe('article');
expect(logged[0].meta?.record).toBe('a_ok');
});

/**
* Ruling point 4, both halves on ONE token. OFF: the switch refuses before
* anything inside the block is evaluated — the record is not even read, so
* the predicate that WOULD refuse it never runs. ON again: the same token is
* judged by the predicate once more, and refused by IT; when the record
* qualifies again the token serves. A standing policy, not a revocation.
*/
it('OFF: nothing inside the block is evaluated; ON again: the eligibility re-check resumes on the same token', async () => {
const logged: LoggedRefusal[] = [];
const { driver, service, schemas, findCalls } = await boot(ARTICLE, {
logger: { warn: (msg, meta) => { logged.push({ msg, meta }); } },
});
const link = await mint(service);

// Reclassify the record so the predicate would refuse it — THEN switch off.
await driver.update('article', 'a_ok', { audience: 'internal' });
switchOff(schemas);
findCalls.length = 0;

expect(await service.resolveToken(link.token, {})).toBeNull();
expect(findCalls.map((c) => c.object)).toEqual(['sys_share_link']);
expect(logged.map((l) => l.meta?.reason)).toEqual(['SHARING_NOT_ENABLED']);

switchOn(schemas);
expect(await service.resolveToken(link.token, {})).toBeNull();
expect(logged.map((l) => l.meta?.reason)).toEqual(['SHARING_NOT_ENABLED', 'RECORD_NOT_ELIGIBLE']);

await driver.update('article', 'a_ok', { audience: 'public' });
expect(await service.resolveToken(link.token, {})).not.toBeNull();
});

/**
* Ruling point 3 on the real driver: the `permissive` bypass still MINTS on
* a switched-off block (ledger row 37's path — the ruling governs
* redemption, not minting), and the result is refused at redemption by the
* bypassing service and the ordinary one alike.
*/
it('a link minted through the `permissive` bypass on a switched-off block is refused at redemption', async () => {
const off = { ...ARTICLE, publicSharing: { ...ARTICLE.publicSharing, enabled: false } };
const { service, engine } = await boot(off);
const bypass = new ShareLinkService({ engine: engine as any, permissive: true });

const link = await bypass.createLink(
{ object: 'article', recordId: 'a_ok', audience: 'public', permission: 'view' },
CALLER,
);
expect(link.token).toBeTruthy();

expect(await bypass.resolveToken(link.token, {})).toBeNull();
expect(await service.resolveToken(link.token, {})).toBeNull();
});
});

/**
* [#13608] Mount the real PUBLIC resolve route on the real service.
*
Expand Down
Loading
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
69 changes: 69 additions & 0 deletions .changeset/share-link-enabled-at-redemption.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
---
"@objectstack/plugin-sharing": minor
---

fix(plugin-sharing): hold `publicSharing.enabled` at redemption, not only at mint (#14033)

**BREAKING** runtime behaviour change on a published package: share links that
were legitimately minted can now stop resolving without anyone revoking them —
every link on an object whose `publicSharing.enabled` is not `true`. Shipped as
`minor` under the repo's launch-window convention (a breaking change does not
burn a major while the stack is in lockstep). No export is added, removed or
re-shaped; the level carries the breaking banner, not a surface change.

`ShareLinkService.createLink()` refused to mint on an object whose
`publicSharing` block was absent or had `enabled !== true` (422
`SHARING_NOT_ENABLED`), and nothing checked the switch again. `resolveToken()`
checked `revoked_at`, `expires_at`, the audience gates, the password, record
existence and — since #13608 — the block's `eligibility` predicate, then served
whatever survived, under the system context, to a caller with no principal at
all. So the platform held the block's CHILD predicate as a standing policy while
its PARENT switch governed minting only: an author who turned the whole feature
off stopped new links and not one existing link, and would have had to narrow
the predicate to stop anonymous serving — the opposite of what the surface
reads like. Measured before it was changed: a token minted while the block was
on kept serving the record in full after the block was turned off.

**What changed.** `resolveToken()` reads the object's CURRENT `publicSharing`
block on every redemption and refuses when `enabled` is not `true` — before the
record is read, before the usage stamp, before any sibling key inside the block
is evaluated. Re-enabling the block restores the same tokens: this is a standing
policy, not a revocation, and no `sys_share_link` row is touched. How a link was
minted buys it nothing at redemption — a link minted under a system context or
the service's `permissive` bypass (the system-context ledger's row 37 path) on a
switched-off object refuses exactly like one orphaned by an author turning the
block off, and an object with no `publicSharing` block at all is the same switch
at its default and refuses too. With the block on, `eligibility` (#13608) and
the declared `redactFields` (#13856) keep their existing redemption-time
behaviour; nothing new is evaluated. An object the engine cannot return a
schema for — no `getSchema` on the engine, or an object not registered at the
moment of redemption — is `enabled: false` by `getPolicy`'s definition and is
refused at redemption: fail-closed, the same definition `createLink` uses. The
#13856 entry's "an opted-out object's links keep resolving with the declared
redactions" state is superseded: with the block off they do not resolve at all.

**The refusal is deliberately indistinguishable.** It is the same answer a
revoked, expired, unknown or no-longer-eligible token already gets: the
undifferentiated `null` — no new error code, no new response branch, and no
usage stamp. Over HTTP a switched-off link is answered with the generic
`404 INVALID_OR_EXPIRED`, byte-for-byte what a token that never existed
receives. The readable reason (`SHARING_NOT_ENABLED`, with the link, object and
record ids) is written to the server-side log at `warn`, where the eligibility
refusal already writes its own.

**Operator impact — retroactive, on deploy.** Every live link on an object whose
`publicSharing` block is currently switched off — or that never declared one —
stops resolving the moment this version is deployed, with no revocation event
and no grace period. That is the intent: the alternative is a declared switch
the platform does not hold. Measure before rollout: the objects to read are
those whose `publicSharing.enabled` is not `true`, and the links at risk are the
`sys_share_link` rows naming them (`object_name`). To keep such links working,
enable the block — and narrow it with `eligibility` / `redactFields` if the
feature was off for a reason; there is no per-link opt-out, deliberately.
Minting is unchanged: `createLink` still refuses `SHARING_NOT_ENABLED` for an
ordinary caller, and the system / `permissive` bypass still mints — what it
mints simply does not serve until the block is on. The refusal logs one `warn`
line per refused hit and is not latched, so a retroactive deploy with many live
links on switched-off objects will burst the log once.

<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is removed, renamed or re-shaped: `publicSharing.enabled` keeps its name, its type, its default and its accept-set, and the change is WHEN the platform holds it. There is therefore no tombstone for `objectstack migrate meta` to carry and no mechanical rewrite it could perform — a deployment whose links stop resolving must decide whether the block should be on at all, which is an authoring decision no ledger entry can make on its behalf. -->
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:440`, `:494`, `:498`, `:571`, `:601` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `plugin-sharing/src/share-link-service.ts:449`, `:503`, `:507`, `:580`, `:610` |
| 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` |
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:157`, `:382` |

Expand Down
175 changes: 175 additions & 0 deletions packages/plugins/plugin-sharing/src/share-link-eligibility.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -917,6 +917,181 @@ describe('[#13608] publicSharing.eligibility is enforced again at REDEMPTION', (
});
});

/**
* [#14033] The PARENT switch is a standing policy too.
*
* `publicSharing.enabled` governed MINTING only: `getPolicy()` collapsed to an
* empty policy when the block was off, and `resolveToken` read nothing off
* `policy.enabled`. So the platform held this shape — the predicate INSIDE
* the block was re-evaluated at every redemption (#13608, above) while turning
* the ENTIRE block off did not stop a single existing link. Maintainer ruling
* of 2026-09-01 (quoted verbatim in `share-link-service.test.ts`'s reversal
* register): the switch is a standing policy held at every redemption,
* retroactively; a link minted through the system / `permissive` bypass is
* governed the same way; and with the block off nothing inside it is
* evaluated at all, while with it on the sibling keys keep their
* redemption-time behaviour.
*
* These pins use the real driver and the real public route: "no record read"
* is measured off the engine's call log, the refusal shape is measured at the
* seam an anonymous holder actually reaches, and the reason is read off the
* server-side log — the only place the ruling leaves it, exactly as for the
* eligibility refusal above.
*/
describe('[#14033] publicSharing.enabled is a standing policy — the switch is held at redemption', () => {
/** A token minted while the block is ON and `a_ok` qualifies — the pre-condition of every case below. */
async function mint(service: ShareLinkService, recordId = 'a_ok') {
const link = await service.createLink(
{ object: 'article', recordId, audience: 'public', permission: 'view' },
CALLER,
);
expect(link.token).toBeTruthy();
return link;
}

/** The switch, thrown from OUTSIDE the token's life: the object's declared block, `enabled: false`. */
const switchOff = (schemas: Record<string, any>) => {
schemas.article = { ...ARTICLE, publicSharing: { ...ARTICLE.publicSharing, enabled: false } };
};
const switchOn = (schemas: Record<string, any>) => { schemas.article = ARTICLE; };

/** The row as the table holds it now — usage counters included. */
async function linkRow(driver: SqlDriver, id: string): Promise<any> {
const rows = await driver.find('sys_share_link', {} as DriverQuery);
return rows.find((r: any) => r.id === id);
}

it('THE REPRO — an ELIGIBLE record on a switched-off block is refused, with no record read and no usage stamp', async () => {
const { driver, service, schemas, findCalls } = await boot();
const link = await mint(service);

switchOff(schemas);
findCalls.length = 0;

// `a_ok` is published + public: no eligibility refusal is available here,
// so this `null` can only have come from the switch.
expect(await service.resolveToken(link.token, {})).toBeNull();

// Refused before the record probe — the token lookup was the only read.
expect(findCalls.map((c) => c.object)).toEqual(['sys_share_link']);
// …and before the usage stamp.
const row = await linkRow(driver, link.id);
expect(row.use_count ?? 0).toBe(0);
expect(row.last_used_at ?? null).toBeNull();
});

/**
* The HTTP seam, driven end-to-end on the real service through the real
* route, with the route's SECURE default context — every request below is
* anonymous. Same reading as the #13608 pin above, for the same reason: the
* switched-off link lands in the generic "invalid / expired / revoked"
* answer, byte-for-byte what a token that NEVER EXISTED gets — not the 410
* bucket, which would confirm the token was real, and not a 422 naming the
* policy, which is what letting `SHARING_NOT_ENABLED` escape would produce.
*/
it('at the HTTP seam an anonymous caller cannot tell a switched-off link from an unknown token', async () => {
const { service, engine, schemas } = await boot();
const live = await mint(service);
const revoked = await mint(service);
await service.revokeLink(revoked.token, { isSystem: true } as any);

const resolve = mountResolveRoute(service, engine);

// Before the switch: the link serves the record.
expect((await resolve(live.token)).status).toBe(200);

switchOff(schemas);

const switchedOff = await resolve(live.token);
const unknown = await resolve('zzzzzzzzzzzzzzzzzzzzzz');
const revokedAnswer = await resolve(revoked.token);

expect(switchedOff).toEqual(unknown);
expect(switchedOff.status).toBe(404);
expect(switchedOff.body?.error?.code).toBe('INVALID_OR_EXPIRED');
// Nothing about the policy or the switch reaches the wire.
const wire = JSON.stringify(switchedOff.body).toLowerCase();
expect(wire).not.toContain('enabled');
expect(wire).not.toContain('publicsharing');
expect(wire).not.toContain('sharing_not_enabled');

// The pre-existing revoked bucket, recorded as measured: a DIFFERENT
// status, and this change does not move it.
expect(revokedAnswer.status).toBe(410);
expect(revokedAnswer.body?.error?.code).toBe('EXPIRED_OR_REVOKED');
});

it('the reason a switched-off link died is written to the server-side log, and only there', async () => {
const logged: LoggedRefusal[] = [];
const { service, schemas } = await boot(ARTICLE, {
logger: { warn: (msg, meta) => { logged.push({ msg, meta }); } },
});
const link = await mint(service);
switchOff(schemas);

expect(await service.resolveToken(link.token, {})).toBeNull();

expect(logged).toHaveLength(1);
expect(logged[0].msg).toContain('publicSharing.enabled');
expect(logged[0].meta?.reason).toBe('SHARING_NOT_ENABLED');
expect(logged[0].meta?.link).toBe(link.id);
expect(logged[0].meta?.object).toBe('article');
expect(logged[0].meta?.record).toBe('a_ok');
});

/**
* Ruling point 4, both halves on ONE token. OFF: the switch refuses before
* anything inside the block is evaluated — the record is not even read, so
* the predicate that WOULD refuse it never runs. ON again: the same token is
* judged by the predicate once more, and refused by IT; when the record
* qualifies again the token serves. A standing policy, not a revocation.
*/
it('OFF: nothing inside the block is evaluated; ON again: the eligibility re-check resumes on the same token', async () => {
const logged: LoggedRefusal[] = [];
const { driver, service, schemas, findCalls } = await boot(ARTICLE, {
logger: { warn: (msg, meta) => { logged.push({ msg, meta }); } },
});
const link = await mint(service);

// Reclassify the record so the predicate would refuse it — THEN switch off.
await driver.update('article', 'a_ok', { audience: 'internal' });
switchOff(schemas);
findCalls.length = 0;

expect(await service.resolveToken(link.token, {})).toBeNull();
expect(findCalls.map((c) => c.object)).toEqual(['sys_share_link']);
expect(logged.map((l) => l.meta?.reason)).toEqual(['SHARING_NOT_ENABLED']);

switchOn(schemas);
expect(await service.resolveToken(link.token, {})).toBeNull();
expect(logged.map((l) => l.meta?.reason)).toEqual(['SHARING_NOT_ENABLED', 'RECORD_NOT_ELIGIBLE']);

await driver.update('article', 'a_ok', { audience: 'public' });
expect(await service.resolveToken(link.token, {})).not.toBeNull();
});

/**
* Ruling point 3 on the real driver: the `permissive` bypass still MINTS on
* a switched-off block (ledger row 37's path — the ruling governs
* redemption, not minting), and the result is refused at redemption by the
* bypassing service and the ordinary one alike.
*/
it('a link minted through the `permissive` bypass on a switched-off block is refused at redemption', async () => {
const off = { ...ARTICLE, publicSharing: { ...ARTICLE.publicSharing, enabled: false } };
const { service, engine } = await boot(off);
const bypass = new ShareLinkService({ engine: engine as any, permissive: true });

const link = await bypass.createLink(
{ object: 'article', recordId: 'a_ok', audience: 'public', permission: 'view' },
CALLER,
);
expect(link.token).toBeTruthy();

expect(await bypass.resolveToken(link.token, {})).toBeNull();
expect(await service.resolveToken(link.token, {})).toBeNull();
});
});

/**
* [#13608] Mount the real PUBLIC resolve route on the real service.
*
Expand Down
Loading
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
69 changes: 69 additions & 0 deletions .changeset/share-link-enabled-at-redemption.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
---
"@objectstack/plugin-sharing": minor
---

fix(plugin-sharing): hold `publicSharing.enabled` at redemption, not only at mint (#14033)

**BREAKING** runtime behaviour change on a published package: share links that
were legitimately minted can now stop resolving without anyone revoking them —
every link on an object whose `publicSharing.enabled` is not `true`. Shipped as
`minor` under the repo's launch-window convention (a breaking change does not
burn a major while the stack is in lockstep). No export is added, removed or
re-shaped; the level carries the breaking banner, not a surface change.

`ShareLinkService.createLink()` refused to mint on an object whose
`publicSharing` block was absent or had `enabled !== true` (422
`SHARING_NOT_ENABLED`), and nothing checked the switch again. `resolveToken()`
checked `revoked_at`, `expires_at`, the audience gates, the password, record
existence and — since #13608 — the block's `eligibility` predicate, then served
whatever survived, under the system context, to a caller with no principal at
all. So the platform held the block's CHILD predicate as a standing policy while
its PARENT switch governed minting only: an author who turned the whole feature
off stopped new links and not one existing link, and would have had to narrow
the predicate to stop anonymous serving — the opposite of what the surface
reads like. Measured before it was changed: a token minted while the block was
on kept serving the record in full after the block was turned off.

**What changed.** `resolveToken()` reads the object's CURRENT `publicSharing`
block on every redemption and refuses when `enabled` is not `true` — before the
record is read, before the usage stamp, before any sibling key inside the block
is evaluated. Re-enabling the block restores the same tokens: this is a standing
policy, not a revocation, and no `sys_share_link` row is touched. How a link was
minted buys it nothing at redemption — a link minted under a system context or
the service's `permissive` bypass (the system-context ledger's row 37 path) on a
switched-off object refuses exactly like one orphaned by an author turning the
block off, and an object with no `publicSharing` block at all is the same switch
at its default and refuses too. With the block on, `eligibility` (#13608) and
the declared `redactFields` (#13856) keep their existing redemption-time
behaviour; nothing new is evaluated. An object the engine cannot return a
schema for — no `getSchema` on the engine, or an object not registered at the
moment of redemption — is `enabled: false` by `getPolicy`'s definition and is
refused at redemption: fail-closed, the same definition `createLink` uses. The
#13856 entry's "an opted-out object's links keep resolving with the declared
redactions" state is superseded: with the block off they do not resolve at all.

**The refusal is deliberately indistinguishable.** It is the same answer a
revoked, expired, unknown or no-longer-eligible token already gets: the
undifferentiated `null` — no new error code, no new response branch, and no
usage stamp. Over HTTP a switched-off link is answered with the generic
`404 INVALID_OR_EXPIRED`, byte-for-byte what a token that never existed
receives. The readable reason (`SHARING_NOT_ENABLED`, with the link, object and
record ids) is written to the server-side log at `warn`, where the eligibility
refusal already writes its own.

**Operator impact — retroactive, on deploy.** Every live link on an object whose
`publicSharing` block is currently switched off — or that never declared one —
stops resolving the moment this version is deployed, with no revocation event
and no grace period. That is the intent: the alternative is a declared switch
the platform does not hold. Measure before rollout: the objects to read are
those whose `publicSharing.enabled` is not `true`, and the links at risk are the
`sys_share_link` rows naming them (`object_name`). To keep such links working,
enable the block — and narrow it with `eligibility` / `redactFields` if the
feature was off for a reason; there is no per-link opt-out, deliberately.
Minting is unchanged: `createLink` still refuses `SHARING_NOT_ENABLED` for an
ordinary caller, and the system / `permissive` bypass still mints — what it
mints simply does not serve until the block is on. The refusal logs one `warn`
line per refused hit and is not latched, so a retroactive deploy with many live
links on switched-off objects will burst the log once.

<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is removed, renamed or re-shaped: `publicSharing.enabled` keeps its name, its type, its default and its accept-set, and the change is WHEN the platform holds it. There is therefore no tombstone for `objectstack migrate meta` to carry and no mechanical rewrite it could perform — a deployment whose links stop resolving must decide whether the block should be on at all, which is an authoring decision no ledger entry can make on its behalf. -->
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:440`, `:494`, `:498`, `:571`, `:601` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `plugin-sharing/src/share-link-service.ts:449`, `:503`, `:507`, `:580`, `:610` |
| 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` |
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:157`, `:382` |

Expand Down
175 changes: 175 additions & 0 deletions packages/plugins/plugin-sharing/src/share-link-eligibility.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -917,6 +917,181 @@ describe('[#13608] publicSharing.eligibility is enforced again at REDEMPTION', (
});
});

/**
* [#14033] The PARENT switch is a standing policy too.
*
* `publicSharing.enabled` governed MINTING only: `getPolicy()` collapsed to an
* empty policy when the block was off, and `resolveToken` read nothing off
* `policy.enabled`. So the platform held this shape — the predicate INSIDE
* the block was re-evaluated at every redemption (#13608, above) while turning
* the ENTIRE block off did not stop a single existing link. Maintainer ruling
* of 2026-09-01 (quoted verbatim in `share-link-service.test.ts`'s reversal
* register): the switch is a standing policy held at every redemption,
* retroactively; a link minted through the system / `permissive` bypass is
* governed the same way; and with the block off nothing inside it is
* evaluated at all, while with it on the sibling keys keep their
* redemption-time behaviour.
*
* These pins use the real driver and the real public route: "no record read"
* is measured off the engine's call log, the refusal shape is measured at the
* seam an anonymous holder actually reaches, and the reason is read off the
* server-side log — the only place the ruling leaves it, exactly as for the
* eligibility refusal above.
*/
describe('[#14033] publicSharing.enabled is a standing policy — the switch is held at redemption', () => {
/** A token minted while the block is ON and `a_ok` qualifies — the pre-condition of every case below. */
async function mint(service: ShareLinkService, recordId = 'a_ok') {
const link = await service.createLink(
{ object: 'article', recordId, audience: 'public', permission: 'view' },
CALLER,
);
expect(link.token).toBeTruthy();
return link;
}

/** The switch, thrown from OUTSIDE the token's life: the object's declared block, `enabled: false`. */
const switchOff = (schemas: Record<string, any>) => {
schemas.article = { ...ARTICLE, publicSharing: { ...ARTICLE.publicSharing, enabled: false } };
};
const switchOn = (schemas: Record<string, any>) => { schemas.article = ARTICLE; };

/** The row as the table holds it now — usage counters included. */
async function linkRow(driver: SqlDriver, id: string): Promise<any> {
const rows = await driver.find('sys_share_link', {} as DriverQuery);
return rows.find((r: any) => r.id === id);
}

it('THE REPRO — an ELIGIBLE record on a switched-off block is refused, with no record read and no usage stamp', async () => {
const { driver, service, schemas, findCalls } = await boot();
const link = await mint(service);

switchOff(schemas);
findCalls.length = 0;

// `a_ok` is published + public: no eligibility refusal is available here,
// so this `null` can only have come from the switch.
expect(await service.resolveToken(link.token, {})).toBeNull();

// Refused before the record probe — the token lookup was the only read.
expect(findCalls.map((c) => c.object)).toEqual(['sys_share_link']);
// …and before the usage stamp.
const row = await linkRow(driver, link.id);
expect(row.use_count ?? 0).toBe(0);
expect(row.last_used_at ?? null).toBeNull();
});

/**
* The HTTP seam, driven end-to-end on the real service through the real
* route, with the route's SECURE default context — every request below is
* anonymous. Same reading as the #13608 pin above, for the same reason: the
* switched-off link lands in the generic "invalid / expired / revoked"
* answer, byte-for-byte what a token that NEVER EXISTED gets — not the 410
* bucket, which would confirm the token was real, and not a 422 naming the
* policy, which is what letting `SHARING_NOT_ENABLED` escape would produce.
*/
it('at the HTTP seam an anonymous caller cannot tell a switched-off link from an unknown token', async () => {
const { service, engine, schemas } = await boot();
const live = await mint(service);
const revoked = await mint(service);
await service.revokeLink(revoked.token, { isSystem: true } as any);

const resolve = mountResolveRoute(service, engine);

// Before the switch: the link serves the record.
expect((await resolve(live.token)).status).toBe(200);

switchOff(schemas);

const switchedOff = await resolve(live.token);
const unknown = await resolve('zzzzzzzzzzzzzzzzzzzzzz');
const revokedAnswer = await resolve(revoked.token);

expect(switchedOff).toEqual(unknown);
expect(switchedOff.status).toBe(404);
expect(switchedOff.body?.error?.code).toBe('INVALID_OR_EXPIRED');
// Nothing about the policy or the switch reaches the wire.
const wire = JSON.stringify(switchedOff.body).toLowerCase();
expect(wire).not.toContain('enabled');
expect(wire).not.toContain('publicsharing');
expect(wire).not.toContain('sharing_not_enabled');

// The pre-existing revoked bucket, recorded as measured: a DIFFERENT
// status, and this change does not move it.
expect(revokedAnswer.status).toBe(410);
expect(revokedAnswer.body?.error?.code).toBe('EXPIRED_OR_REVOKED');
});

it('the reason a switched-off link died is written to the server-side log, and only there', async () => {
const logged: LoggedRefusal[] = [];
const { service, schemas } = await boot(ARTICLE, {
logger: { warn: (msg, meta) => { logged.push({ msg, meta }); } },
});
const link = await mint(service);
switchOff(schemas);

expect(await service.resolveToken(link.token, {})).toBeNull();

expect(logged).toHaveLength(1);
expect(logged[0].msg).toContain('publicSharing.enabled');
expect(logged[0].meta?.reason).toBe('SHARING_NOT_ENABLED');
expect(logged[0].meta?.link).toBe(link.id);
expect(logged[0].meta?.object).toBe('article');
expect(logged[0].meta?.record).toBe('a_ok');
});

/**
* Ruling point 4, both halves on ONE token. OFF: the switch refuses before
* anything inside the block is evaluated — the record is not even read, so
* the predicate that WOULD refuse it never runs. ON again: the same token is
* judged by the predicate once more, and refused by IT; when the record
* qualifies again the token serves. A standing policy, not a revocation.
*/
it('OFF: nothing inside the block is evaluated; ON again: the eligibility re-check resumes on the same token', async () => {
const logged: LoggedRefusal[] = [];
const { driver, service, schemas, findCalls } = await boot(ARTICLE, {
logger: { warn: (msg, meta) => { logged.push({ msg, meta }); } },
});
const link = await mint(service);

// Reclassify the record so the predicate would refuse it — THEN switch off.
await driver.update('article', 'a_ok', { audience: 'internal' });
switchOff(schemas);
findCalls.length = 0;

expect(await service.resolveToken(link.token, {})).toBeNull();
expect(findCalls.map((c) => c.object)).toEqual(['sys_share_link']);
expect(logged.map((l) => l.meta?.reason)).toEqual(['SHARING_NOT_ENABLED']);

switchOn(schemas);
expect(await service.resolveToken(link.token, {})).toBeNull();
expect(logged.map((l) => l.meta?.reason)).toEqual(['SHARING_NOT_ENABLED', 'RECORD_NOT_ELIGIBLE']);

await driver.update('article', 'a_ok', { audience: 'public' });
expect(await service.resolveToken(link.token, {})).not.toBeNull();
});

/**
* Ruling point 3 on the real driver: the `permissive` bypass still MINTS on
* a switched-off block (ledger row 37's path — the ruling governs
* redemption, not minting), and the result is refused at redemption by the
* bypassing service and the ordinary one alike.
*/
it('a link minted through the `permissive` bypass on a switched-off block is refused at redemption', async () => {
const off = { ...ARTICLE, publicSharing: { ...ARTICLE.publicSharing, enabled: false } };
const { service, engine } = await boot(off);
const bypass = new ShareLinkService({ engine: engine as any, permissive: true });

const link = await bypass.createLink(
{ object: 'article', recordId: 'a_ok', audience: 'public', permission: 'view' },
CALLER,
);
expect(link.token).toBeTruthy();

expect(await bypass.resolveToken(link.token, {})).toBeNull();
expect(await service.resolveToken(link.token, {})).toBeNull();
});
});

/**
* [#13608] Mount the real PUBLIC resolve route on the real service.
*
Expand Down
Loading
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
69 changes: 69 additions & 0 deletions .changeset/share-link-enabled-at-redemption.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
---
"@objectstack/plugin-sharing": minor
---

fix(plugin-sharing): hold `publicSharing.enabled` at redemption, not only at mint (#14033)

**BREAKING** runtime behaviour change on a published package: share links that
were legitimately minted can now stop resolving without anyone revoking them —
every link on an object whose `publicSharing.enabled` is not `true`. Shipped as
`minor` under the repo's launch-window convention (a breaking change does not
burn a major while the stack is in lockstep). No export is added, removed or
re-shaped; the level carries the breaking banner, not a surface change.

`ShareLinkService.createLink()` refused to mint on an object whose
`publicSharing` block was absent or had `enabled !== true` (422
`SHARING_NOT_ENABLED`), and nothing checked the switch again. `resolveToken()`
checked `revoked_at`, `expires_at`, the audience gates, the password, record
existence and — since #13608 — the block's `eligibility` predicate, then served
whatever survived, under the system context, to a caller with no principal at
all. So the platform held the block's CHILD predicate as a standing policy while
its PARENT switch governed minting only: an author who turned the whole feature
off stopped new links and not one existing link, and would have had to narrow
the predicate to stop anonymous serving — the opposite of what the surface
reads like. Measured before it was changed: a token minted while the block was
on kept serving the record in full after the block was turned off.

**What changed.** `resolveToken()` reads the object's CURRENT `publicSharing`
block on every redemption and refuses when `enabled` is not `true` — before the
record is read, before the usage stamp, before any sibling key inside the block
is evaluated. Re-enabling the block restores the same tokens: this is a standing
policy, not a revocation, and no `sys_share_link` row is touched. How a link was
minted buys it nothing at redemption — a link minted under a system context or
the service's `permissive` bypass (the system-context ledger's row 37 path) on a
switched-off object refuses exactly like one orphaned by an author turning the
block off, and an object with no `publicSharing` block at all is the same switch
at its default and refuses too. With the block on, `eligibility` (#13608) and
the declared `redactFields` (#13856) keep their existing redemption-time
behaviour; nothing new is evaluated. An object the engine cannot return a
schema for — no `getSchema` on the engine, or an object not registered at the
moment of redemption — is `enabled: false` by `getPolicy`'s definition and is
refused at redemption: fail-closed, the same definition `createLink` uses. The
#13856 entry's "an opted-out object's links keep resolving with the declared
redactions" state is superseded: with the block off they do not resolve at all.

**The refusal is deliberately indistinguishable.** It is the same answer a
revoked, expired, unknown or no-longer-eligible token already gets: the
undifferentiated `null` — no new error code, no new response branch, and no
usage stamp. Over HTTP a switched-off link is answered with the generic
`404 INVALID_OR_EXPIRED`, byte-for-byte what a token that never existed
receives. The readable reason (`SHARING_NOT_ENABLED`, with the link, object and
record ids) is written to the server-side log at `warn`, where the eligibility
refusal already writes its own.

**Operator impact — retroactive, on deploy.** Every live link on an object whose
`publicSharing` block is currently switched off — or that never declared one —
stops resolving the moment this version is deployed, with no revocation event
and no grace period. That is the intent: the alternative is a declared switch
the platform does not hold. Measure before rollout: the objects to read are
those whose `publicSharing.enabled` is not `true`, and the links at risk are the
`sys_share_link` rows naming them (`object_name`). To keep such links working,
enable the block — and narrow it with `eligibility` / `redactFields` if the
feature was off for a reason; there is no per-link opt-out, deliberately.
Minting is unchanged: `createLink` still refuses `SHARING_NOT_ENABLED` for an
ordinary caller, and the system / `permissive` bypass still mints — what it
mints simply does not serve until the block is on. The refusal logs one `warn`
line per refused hit and is not latched, so a retroactive deploy with many live
links on switched-off objects will burst the log once.

<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is removed, renamed or re-shaped: `publicSharing.enabled` keeps its name, its type, its default and its accept-set, and the change is WHEN the platform holds it. There is therefore no tombstone for `objectstack migrate meta` to carry and no mechanical rewrite it could perform — a deployment whose links stop resolving must decide whether the block should be on at all, which is an authoring decision no ledger entry can make on its behalf. -->
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:440`, `:494`, `:498`, `:571`, `:601` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `plugin-sharing/src/share-link-service.ts:449`, `:503`, `:507`, `:580`, `:610` |
| 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` |
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:157`, `:382` |

Expand Down
175 changes: 175 additions & 0 deletions packages/plugins/plugin-sharing/src/share-link-eligibility.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -917,6 +917,181 @@ describe('[#13608] publicSharing.eligibility is enforced again at REDEMPTION', (
});
});

/**
* [#14033] The PARENT switch is a standing policy too.
*
* `publicSharing.enabled` governed MINTING only: `getPolicy()` collapsed to an
* empty policy when the block was off, and `resolveToken` read nothing off
* `policy.enabled`. So the platform held this shape — the predicate INSIDE
* the block was re-evaluated at every redemption (#13608, above) while turning
* the ENTIRE block off did not stop a single existing link. Maintainer ruling
* of 2026-09-01 (quoted verbatim in `share-link-service.test.ts`'s reversal
* register): the switch is a standing policy held at every redemption,
* retroactively; a link minted through the system / `permissive` bypass is
* governed the same way; and with the block off nothing inside it is
* evaluated at all, while with it on the sibling keys keep their
* redemption-time behaviour.
*
* These pins use the real driver and the real public route: "no record read"
* is measured off the engine's call log, the refusal shape is measured at the
* seam an anonymous holder actually reaches, and the reason is read off the
* server-side log — the only place the ruling leaves it, exactly as for the
* eligibility refusal above.
*/
describe('[#14033] publicSharing.enabled is a standing policy — the switch is held at redemption', () => {
/** A token minted while the block is ON and `a_ok` qualifies — the pre-condition of every case below. */
async function mint(service: ShareLinkService, recordId = 'a_ok') {
const link = await service.createLink(
{ object: 'article', recordId, audience: 'public', permission: 'view' },
CALLER,
);
expect(link.token).toBeTruthy();
return link;
}

/** The switch, thrown from OUTSIDE the token's life: the object's declared block, `enabled: false`. */
const switchOff = (schemas: Record<string, any>) => {
schemas.article = { ...ARTICLE, publicSharing: { ...ARTICLE.publicSharing, enabled: false } };
};
const switchOn = (schemas: Record<string, any>) => { schemas.article = ARTICLE; };

/** The row as the table holds it now — usage counters included. */
async function linkRow(driver: SqlDriver, id: string): Promise<any> {
const rows = await driver.find('sys_share_link', {} as DriverQuery);
return rows.find((r: any) => r.id === id);
}

it('THE REPRO — an ELIGIBLE record on a switched-off block is refused, with no record read and no usage stamp', async () => {
const { driver, service, schemas, findCalls } = await boot();
const link = await mint(service);

switchOff(schemas);
findCalls.length = 0;

// `a_ok` is published + public: no eligibility refusal is available here,
// so this `null` can only have come from the switch.
expect(await service.resolveToken(link.token, {})).toBeNull();

// Refused before the record probe — the token lookup was the only read.
expect(findCalls.map((c) => c.object)).toEqual(['sys_share_link']);
// …and before the usage stamp.
const row = await linkRow(driver, link.id);
expect(row.use_count ?? 0).toBe(0);
expect(row.last_used_at ?? null).toBeNull();
});

/**
* The HTTP seam, driven end-to-end on the real service through the real
* route, with the route's SECURE default context — every request below is
* anonymous. Same reading as the #13608 pin above, for the same reason: the
* switched-off link lands in the generic "invalid / expired / revoked"
* answer, byte-for-byte what a token that NEVER EXISTED gets — not the 410
* bucket, which would confirm the token was real, and not a 422 naming the
* policy, which is what letting `SHARING_NOT_ENABLED` escape would produce.
*/
it('at the HTTP seam an anonymous caller cannot tell a switched-off link from an unknown token', async () => {
const { service, engine, schemas } = await boot();
const live = await mint(service);
const revoked = await mint(service);
await service.revokeLink(revoked.token, { isSystem: true } as any);

const resolve = mountResolveRoute(service, engine);

// Before the switch: the link serves the record.
expect((await resolve(live.token)).status).toBe(200);

switchOff(schemas);

const switchedOff = await resolve(live.token);
const unknown = await resolve('zzzzzzzzzzzzzzzzzzzzzz');
const revokedAnswer = await resolve(revoked.token);

expect(switchedOff).toEqual(unknown);
expect(switchedOff.status).toBe(404);
expect(switchedOff.body?.error?.code).toBe('INVALID_OR_EXPIRED');
// Nothing about the policy or the switch reaches the wire.
const wire = JSON.stringify(switchedOff.body).toLowerCase();
expect(wire).not.toContain('enabled');
expect(wire).not.toContain('publicsharing');
expect(wire).not.toContain('sharing_not_enabled');

// The pre-existing revoked bucket, recorded as measured: a DIFFERENT
// status, and this change does not move it.
expect(revokedAnswer.status).toBe(410);
expect(revokedAnswer.body?.error?.code).toBe('EXPIRED_OR_REVOKED');
});

it('the reason a switched-off link died is written to the server-side log, and only there', async () => {
const logged: LoggedRefusal[] = [];
const { service, schemas } = await boot(ARTICLE, {
logger: { warn: (msg, meta) => { logged.push({ msg, meta }); } },
});
const link = await mint(service);
switchOff(schemas);

expect(await service.resolveToken(link.token, {})).toBeNull();

expect(logged).toHaveLength(1);
expect(logged[0].msg).toContain('publicSharing.enabled');
expect(logged[0].meta?.reason).toBe('SHARING_NOT_ENABLED');
expect(logged[0].meta?.link).toBe(link.id);
expect(logged[0].meta?.object).toBe('article');
expect(logged[0].meta?.record).toBe('a_ok');
});

/**
* Ruling point 4, both halves on ONE token. OFF: the switch refuses before
* anything inside the block is evaluated — the record is not even read, so
* the predicate that WOULD refuse it never runs. ON again: the same token is
* judged by the predicate once more, and refused by IT; when the record
* qualifies again the token serves. A standing policy, not a revocation.
*/
it('OFF: nothing inside the block is evaluated; ON again: the eligibility re-check resumes on the same token', async () => {
const logged: LoggedRefusal[] = [];
const { driver, service, schemas, findCalls } = await boot(ARTICLE, {
logger: { warn: (msg, meta) => { logged.push({ msg, meta }); } },
});
const link = await mint(service);

// Reclassify the record so the predicate would refuse it — THEN switch off.
await driver.update('article', 'a_ok', { audience: 'internal' });
switchOff(schemas);
findCalls.length = 0;

expect(await service.resolveToken(link.token, {})).toBeNull();
expect(findCalls.map((c) => c.object)).toEqual(['sys_share_link']);
expect(logged.map((l) => l.meta?.reason)).toEqual(['SHARING_NOT_ENABLED']);

switchOn(schemas);
expect(await service.resolveToken(link.token, {})).toBeNull();
expect(logged.map((l) => l.meta?.reason)).toEqual(['SHARING_NOT_ENABLED', 'RECORD_NOT_ELIGIBLE']);

await driver.update('article', 'a_ok', { audience: 'public' });
expect(await service.resolveToken(link.token, {})).not.toBeNull();
});

/**
* Ruling point 3 on the real driver: the `permissive` bypass still MINTS on
* a switched-off block (ledger row 37's path — the ruling governs
* redemption, not minting), and the result is refused at redemption by the
* bypassing service and the ordinary one alike.
*/
it('a link minted through the `permissive` bypass on a switched-off block is refused at redemption', async () => {
const off = { ...ARTICLE, publicSharing: { ...ARTICLE.publicSharing, enabled: false } };
const { service, engine } = await boot(off);
const bypass = new ShareLinkService({ engine: engine as any, permissive: true });

const link = await bypass.createLink(
{ object: 'article', recordId: 'a_ok', audience: 'public', permission: 'view' },
CALLER,
);
expect(link.token).toBeTruthy();

expect(await bypass.resolveToken(link.token, {})).toBeNull();
expect(await service.resolveToken(link.token, {})).toBeNull();
});
});

/**
* [#13608] Mount the real PUBLIC resolve route on the real service.
*
Expand Down
Loading
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
69 changes: 69 additions & 0 deletions .changeset/share-link-enabled-at-redemption.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
---
"@objectstack/plugin-sharing": minor
---

fix(plugin-sharing): hold `publicSharing.enabled` at redemption, not only at mint (#14033)

**BREAKING** runtime behaviour change on a published package: share links that
were legitimately minted can now stop resolving without anyone revoking them —
every link on an object whose `publicSharing.enabled` is not `true`. Shipped as
`minor` under the repo's launch-window convention (a breaking change does not
burn a major while the stack is in lockstep). No export is added, removed or
re-shaped; the level carries the breaking banner, not a surface change.

`ShareLinkService.createLink()` refused to mint on an object whose
`publicSharing` block was absent or had `enabled !== true` (422
`SHARING_NOT_ENABLED`), and nothing checked the switch again. `resolveToken()`
checked `revoked_at`, `expires_at`, the audience gates, the password, record
existence and — since #13608 — the block's `eligibility` predicate, then served
whatever survived, under the system context, to a caller with no principal at
all. So the platform held the block's CHILD predicate as a standing policy while
its PARENT switch governed minting only: an author who turned the whole feature
off stopped new links and not one existing link, and would have had to narrow
the predicate to stop anonymous serving — the opposite of what the surface
reads like. Measured before it was changed: a token minted while the block was
on kept serving the record in full after the block was turned off.

**What changed.** `resolveToken()` reads the object's CURRENT `publicSharing`
block on every redemption and refuses when `enabled` is not `true` — before the
record is read, before the usage stamp, before any sibling key inside the block
is evaluated. Re-enabling the block restores the same tokens: this is a standing
policy, not a revocation, and no `sys_share_link` row is touched. How a link was
minted buys it nothing at redemption — a link minted under a system context or
the service's `permissive` bypass (the system-context ledger's row 37 path) on a
switched-off object refuses exactly like one orphaned by an author turning the
block off, and an object with no `publicSharing` block at all is the same switch
at its default and refuses too. With the block on, `eligibility` (#13608) and
the declared `redactFields` (#13856) keep their existing redemption-time
behaviour; nothing new is evaluated. An object the engine cannot return a
schema for — no `getSchema` on the engine, or an object not registered at the
moment of redemption — is `enabled: false` by `getPolicy`'s definition and is
refused at redemption: fail-closed, the same definition `createLink` uses. The
#13856 entry's "an opted-out object's links keep resolving with the declared
redactions" state is superseded: with the block off they do not resolve at all.

**The refusal is deliberately indistinguishable.** It is the same answer a
revoked, expired, unknown or no-longer-eligible token already gets: the
undifferentiated `null` — no new error code, no new response branch, and no
usage stamp. Over HTTP a switched-off link is answered with the generic
`404 INVALID_OR_EXPIRED`, byte-for-byte what a token that never existed
receives. The readable reason (`SHARING_NOT_ENABLED`, with the link, object and
record ids) is written to the server-side log at `warn`, where the eligibility
refusal already writes its own.

**Operator impact — retroactive, on deploy.** Every live link on an object whose
`publicSharing` block is currently switched off — or that never declared one —
stops resolving the moment this version is deployed, with no revocation event
and no grace period. That is the intent: the alternative is a declared switch
the platform does not hold. Measure before rollout: the objects to read are
those whose `publicSharing.enabled` is not `true`, and the links at risk are the
`sys_share_link` rows naming them (`object_name`). To keep such links working,
enable the block — and narrow it with `eligibility` / `redactFields` if the
feature was off for a reason; there is no per-link opt-out, deliberately.
Minting is unchanged: `createLink` still refuses `SHARING_NOT_ENABLED` for an
ordinary caller, and the system / `permissive` bypass still mints — what it
mints simply does not serve until the block is on. The refusal logs one `warn`
line per refused hit and is not latched, so a retroactive deploy with many live
links on switched-off objects will burst the log once.

<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is removed, renamed or re-shaped: `publicSharing.enabled` keeps its name, its type, its default and its accept-set, and the change is WHEN the platform holds it. There is therefore no tombstone for `objectstack migrate meta` to carry and no mechanical rewrite it could perform — a deployment whose links stop resolving must decide whether the block should be on at all, which is an authoring decision no ledger entry can make on its behalf. -->
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:440`, `:494`, `:498`, `:571`, `:601` |
| 37 | Share-link policy `enabled` check bypassed; system callers re-enter under a system context | Get: link **creation** while the policy is off — resolution is **not** bypassed since #14033 (`publicSharing.enabled` is a standing policy held at every redemption): a link minted this way does not resolve until the block is enabled | `plugin-sharing/src/share-link-service.ts:449`, `:503`, `:507`, `:580`, `:610` |
| 38 | Sharing-rule provenance stamp skipped | Lose: the row is not marked as an admin customization — seeder / `defineRule` / boot reconcilers are "the package door" | `sharing-rule-provenance.ts:47` |
| 39 | Sharing-rule service write + delete paths return early | Lose: the manage-rules gate on the service surface, and the platform-global-rule delete guard | `sharing-rule-service.ts:157`, `:382` |

Expand Down
175 changes: 175 additions & 0 deletions packages/plugins/plugin-sharing/src/share-link-eligibility.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -917,6 +917,181 @@ describe('[#13608] publicSharing.eligibility is enforced again at REDEMPTION', (
});
});

/**
* [#14033] The PARENT switch is a standing policy too.
*
* `publicSharing.enabled` governed MINTING only: `getPolicy()` collapsed to an
* empty policy when the block was off, and `resolveToken` read nothing off
* `policy.enabled`. So the platform held this shape — the predicate INSIDE
* the block was re-evaluated at every redemption (#13608, above) while turning
* the ENTIRE block off did not stop a single existing link. Maintainer ruling
* of 2026-09-01 (quoted verbatim in `share-link-service.test.ts`'s reversal
* register): the switch is a standing policy held at every redemption,
* retroactively; a link minted through the system / `permissive` bypass is
* governed the same way; and with the block off nothing inside it is
* evaluated at all, while with it on the sibling keys keep their
* redemption-time behaviour.
*
* These pins use the real driver and the real public route: "no record read"
* is measured off the engine's call log, the refusal shape is measured at the
* seam an anonymous holder actually reaches, and the reason is read off the
* server-side log — the only place the ruling leaves it, exactly as for the
* eligibility refusal above.
*/
describe('[#14033] publicSharing.enabled is a standing policy — the switch is held at redemption', () => {
/** A token minted while the block is ON and `a_ok` qualifies — the pre-condition of every case below. */
async function mint(service: ShareLinkService, recordId = 'a_ok') {
const link = await service.createLink(
{ object: 'article', recordId, audience: 'public', permission: 'view' },
CALLER,
);
expect(link.token).toBeTruthy();
return link;
}

/** The switch, thrown from OUTSIDE the token's life: the object's declared block, `enabled: false`. */
const switchOff = (schemas: Record<string, any>) => {
schemas.article = { ...ARTICLE, publicSharing: { ...ARTICLE.publicSharing, enabled: false } };
};
const switchOn = (schemas: Record<string, any>) => { schemas.article = ARTICLE; };

/** The row as the table holds it now — usage counters included. */
async function linkRow(driver: SqlDriver, id: string): Promise<any> {
const rows = await driver.find('sys_share_link', {} as DriverQuery);
return rows.find((r: any) => r.id === id);
}

it('THE REPRO — an ELIGIBLE record on a switched-off block is refused, with no record read and no usage stamp', async () => {
const { driver, service, schemas, findCalls } = await boot();
const link = await mint(service);

switchOff(schemas);
findCalls.length = 0;

// `a_ok` is published + public: no eligibility refusal is available here,
// so this `null` can only have come from the switch.
expect(await service.resolveToken(link.token, {})).toBeNull();

// Refused before the record probe — the token lookup was the only read.
expect(findCalls.map((c) => c.object)).toEqual(['sys_share_link']);
// …and before the usage stamp.
const row = await linkRow(driver, link.id);
expect(row.use_count ?? 0).toBe(0);
expect(row.last_used_at ?? null).toBeNull();
});

/**
* The HTTP seam, driven end-to-end on the real service through the real
* route, with the route's SECURE default context — every request below is
* anonymous. Same reading as the #13608 pin above, for the same reason: the
* switched-off link lands in the generic "invalid / expired / revoked"
* answer, byte-for-byte what a token that NEVER EXISTED gets — not the 410
* bucket, which would confirm the token was real, and not a 422 naming the
* policy, which is what letting `SHARING_NOT_ENABLED` escape would produce.
*/
it('at the HTTP seam an anonymous caller cannot tell a switched-off link from an unknown token', async () => {
const { service, engine, schemas } = await boot();
const live = await mint(service);
const revoked = await mint(service);
await service.revokeLink(revoked.token, { isSystem: true } as any);

const resolve = mountResolveRoute(service, engine);

// Before the switch: the link serves the record.
expect((await resolve(live.token)).status).toBe(200);

switchOff(schemas);

const switchedOff = await resolve(live.token);
const unknown = await resolve('zzzzzzzzzzzzzzzzzzzzzz');
const revokedAnswer = await resolve(revoked.token);

expect(switchedOff).toEqual(unknown);
expect(switchedOff.status).toBe(404);
expect(switchedOff.body?.error?.code).toBe('INVALID_OR_EXPIRED');
// Nothing about the policy or the switch reaches the wire.
const wire = JSON.stringify(switchedOff.body).toLowerCase();
expect(wire).not.toContain('enabled');
expect(wire).not.toContain('publicsharing');
expect(wire).not.toContain('sharing_not_enabled');

// The pre-existing revoked bucket, recorded as measured: a DIFFERENT
// status, and this change does not move it.
expect(revokedAnswer.status).toBe(410);
expect(revokedAnswer.body?.error?.code).toBe('EXPIRED_OR_REVOKED');
});

it('the reason a switched-off link died is written to the server-side log, and only there', async () => {
const logged: LoggedRefusal[] = [];
const { service, schemas } = await boot(ARTICLE, {
logger: { warn: (msg, meta) => { logged.push({ msg, meta }); } },
});
const link = await mint(service);
switchOff(schemas);

expect(await service.resolveToken(link.token, {})).toBeNull();

expect(logged).toHaveLength(1);
expect(logged[0].msg).toContain('publicSharing.enabled');
expect(logged[0].meta?.reason).toBe('SHARING_NOT_ENABLED');
expect(logged[0].meta?.link).toBe(link.id);
expect(logged[0].meta?.object).toBe('article');
expect(logged[0].meta?.record).toBe('a_ok');
});

/**
* Ruling point 4, both halves on ONE token. OFF: the switch refuses before
* anything inside the block is evaluated — the record is not even read, so
* the predicate that WOULD refuse it never runs. ON again: the same token is
* judged by the predicate once more, and refused by IT; when the record
* qualifies again the token serves. A standing policy, not a revocation.
*/
it('OFF: nothing inside the block is evaluated; ON again: the eligibility re-check resumes on the same token', async () => {
const logged: LoggedRefusal[] = [];
const { driver, service, schemas, findCalls } = await boot(ARTICLE, {
logger: { warn: (msg, meta) => { logged.push({ msg, meta }); } },
});
const link = await mint(service);

// Reclassify the record so the predicate would refuse it — THEN switch off.
await driver.update('article', 'a_ok', { audience: 'internal' });
switchOff(schemas);
findCalls.length = 0;

expect(await service.resolveToken(link.token, {})).toBeNull();
expect(findCalls.map((c) => c.object)).toEqual(['sys_share_link']);
expect(logged.map((l) => l.meta?.reason)).toEqual(['SHARING_NOT_ENABLED']);

switchOn(schemas);
expect(await service.resolveToken(link.token, {})).toBeNull();
expect(logged.map((l) => l.meta?.reason)).toEqual(['SHARING_NOT_ENABLED', 'RECORD_NOT_ELIGIBLE']);

await driver.update('article', 'a_ok', { audience: 'public' });
expect(await service.resolveToken(link.token, {})).not.toBeNull();
});

/**
* Ruling point 3 on the real driver: the `permissive` bypass still MINTS on
* a switched-off block (ledger row 37's path — the ruling governs
* redemption, not minting), and the result is refused at redemption by the
* bypassing service and the ordinary one alike.
*/
it('a link minted through the `permissive` bypass on a switched-off block is refused at redemption', async () => {
const off = { ...ARTICLE, publicSharing: { ...ARTICLE.publicSharing, enabled: false } };
const { service, engine } = await boot(off);
const bypass = new ShareLinkService({ engine: engine as any, permissive: true });

const link = await bypass.createLink(
{ object: 'article', recordId: 'a_ok', audience: 'public', permission: 'view' },
CALLER,
);
expect(link.token).toBeTruthy();

expect(await bypass.resolveToken(link.token, {})).toBeNull();
expect(await service.resolveToken(link.token, {})).toBeNull();
});
});

/**
* [#13608] Mount the real PUBLIC resolve route on the real service.
*
Expand Down
Loading
Loading