Skip to content

Commit 13bf05d

Browse files
claude[bot]claude
andauthored
fix(plugin-sharing): hold publicSharing.enabled at redemption, not only at mint (#14033) (#14580)
* fix(plugin-sharing): hold publicSharing.enabled at redemption, not only at mint `ShareLinkService.resolveToken()` now reads the object's CURRENT `publicSharing` block on every redemption and refuses when `enabled` is not `true` — before the record probe, before the usage stamp, before any sibling key inside the block is evaluated. Re-enabling the block restores the same tokens (a standing policy, not a revocation). How a link was minted buys it nothing at redemption: rows minted under a system context or the `permissive` bypass on a switched-off object refuse the same way, and an object with no block at all is the same switch at its default. The refusal reuses the undifferentiated `null` a revoked / expired / unknown / ineligible token already gets — no new error code, no new response branch, no usage stamp (over HTTP the generic 404). The readable reason goes to the server-side log at `warn`, where the eligibility refusal already writes its own. Tests: the standing-policy pins (fake engine and real driver), the HTTP-seam shape, the log line, the bypass-minted path, and the reversal register for the #13856 pins that asserted serving on a disabled block. Changeset marked as a breaking runtime change in PR #13857's form. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 * docs(permissions): re-anchor system-context row 37 after the share-link hunk shifted it `check:system-context-census --fix` — pure line rot: the `permissive` option's TSDoc grew five lines above the five `isSystem` read sites row 37 anchors in `share-link-service.ts`, so every anchor moved by exactly +5. No site arrived or vanished (109 read sites, 145 anchors, unchanged). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 * docs(permissions): regenerate system-context census after merging origin/main `pnpm gen:system-context-census` on the merged tree (os-regen-merge.sh step 4): main's side of the page was taken at merge, then row 37's anchors re-derived from the merged `share-link-service.ts` (+5, the `permissive` TSDoc). No read site arrived or vanished. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 * docs(permissions): row 37 — the share-link bypass buys creation only; resolution is a standing policy The "What you get" cell of the system-context ledger's row 37 still said "link creation/resolution while the policy is off". Since the redemption gate on `publicSharing.enabled`, a link minted under the system / `permissive` bypass while the block is off does not resolve until the block is enabled. Prose only; the five anchors are untouched (seat-approved one-cell widening of the claim's file surface, 14033#issuecomment-5511082583). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 * docs(permissions): regenerate system-context census after merging origin/main (round 0) os-regen-merge.sh step 4 on the merged tree: main's side of the page was taken at merge, the row-37 "What you get" cell re-applied (a hand edit no generator reproduces), then `pnpm gen:system-context-census`. Anchors unchanged; no read site arrived or vanished. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 * docs(plugin-sharing): contract-review round 1 — three changeset lines, one permissive TSDoc clause, row 37 re-anchored Prose only; the gate, the tests and the changeset level are untouched. Changeset: the superseded #13856 reading is named; an object the engine cannot return a schema for is `enabled: false` by `getPolicy`'s definition and refuses at redemption (fail-closed, as `createLink`); the refusal logs one `warn` per refused hit, not latched. `permissive` TSDoc: during a late schema scan the option helps minting, not serving. The four added TSDoc lines moved the five row-37 anchors, re-anchored by the census gate's own `--fix`; the row-37 "What you get" cell is unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 * docs(permissions): regenerate system-context census after merging origin/main (round 1) os-regen-merge.sh step 4 on the merged tree: main's side of the page taken at merge, the row-37 "What you get" cell re-applied (a hand edit no generator reproduces), then `pnpm gen:system-context-census`. No read site arrived or vanished. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fb531df commit 13bf05d

5 files changed

Lines changed: 596 additions & 68 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
---
2+
"@objectstack/plugin-sharing": minor
3+
---
4+
5+
fix(plugin-sharing): hold `publicSharing.enabled` at redemption, not only at mint (#14033)
6+
7+
**BREAKING** runtime behaviour change on a published package: share links that
8+
were legitimately minted can now stop resolving without anyone revoking them —
9+
every link on an object whose `publicSharing.enabled` is not `true`. Shipped as
10+
`minor` under the repo's launch-window convention (a breaking change does not
11+
burn a major while the stack is in lockstep). No export is added, removed or
12+
re-shaped; the level carries the breaking banner, not a surface change.
13+
14+
`ShareLinkService.createLink()` refused to mint on an object whose
15+
`publicSharing` block was absent or had `enabled !== true` (422
16+
`SHARING_NOT_ENABLED`), and nothing checked the switch again. `resolveToken()`
17+
checked `revoked_at`, `expires_at`, the audience gates, the password, record
18+
existence and — since #13608 — the block's `eligibility` predicate, then served
19+
whatever survived, under the system context, to a caller with no principal at
20+
all. So the platform held the block's CHILD predicate as a standing policy while
21+
its PARENT switch governed minting only: an author who turned the whole feature
22+
off stopped new links and not one existing link, and would have had to narrow
23+
the predicate to stop anonymous serving — the opposite of what the surface
24+
reads like. Measured before it was changed: a token minted while the block was
25+
on kept serving the record in full after the block was turned off.
26+
27+
**What changed.** `resolveToken()` reads the object's CURRENT `publicSharing`
28+
block on every redemption and refuses when `enabled` is not `true` — before the
29+
record is read, before the usage stamp, before any sibling key inside the block
30+
is evaluated. Re-enabling the block restores the same tokens: this is a standing
31+
policy, not a revocation, and no `sys_share_link` row is touched. How a link was
32+
minted buys it nothing at redemption — a link minted under a system context or
33+
the service's `permissive` bypass (the system-context ledger's row 37 path) on a
34+
switched-off object refuses exactly like one orphaned by an author turning the
35+
block off, and an object with no `publicSharing` block at all is the same switch
36+
at its default and refuses too. With the block on, `eligibility` (#13608) and
37+
the declared `redactFields` (#13856) keep their existing redemption-time
38+
behaviour; nothing new is evaluated. An object the engine cannot return a
39+
schema for — no `getSchema` on the engine, or an object not registered at the
40+
moment of redemption — is `enabled: false` by `getPolicy`'s definition and is
41+
refused at redemption: fail-closed, the same definition `createLink` uses. The
42+
#13856 entry's "an opted-out object's links keep resolving with the declared
43+
redactions" state is superseded: with the block off they do not resolve at all.
44+
45+
**The refusal is deliberately indistinguishable.** It is the same answer a
46+
revoked, expired, unknown or no-longer-eligible token already gets: the
47+
undifferentiated `null` — no new error code, no new response branch, and no
48+
usage stamp. Over HTTP a switched-off link is answered with the generic
49+
`404 INVALID_OR_EXPIRED`, byte-for-byte what a token that never existed
50+
receives. The readable reason (`SHARING_NOT_ENABLED`, with the link, object and
51+
record ids) is written to the server-side log at `warn`, where the eligibility
52+
refusal already writes its own.
53+
54+
**Operator impact — retroactive, on deploy.** Every live link on an object whose
55+
`publicSharing` block is currently switched off — or that never declared one —
56+
stops resolving the moment this version is deployed, with no revocation event
57+
and no grace period. That is the intent: the alternative is a declared switch
58+
the platform does not hold. Measure before rollout: the objects to read are
59+
those whose `publicSharing.enabled` is not `true`, and the links at risk are the
60+
`sys_share_link` rows naming them (`object_name`). To keep such links working,
61+
enable the block — and narrow it with `eligibility` / `redactFields` if the
62+
feature was off for a reason; there is no per-link opt-out, deliberately.
63+
Minting is unchanged: `createLink` still refuses `SHARING_NOT_ENABLED` for an
64+
ordinary caller, and the system / `permissive` bypass still mints — what it
65+
mints simply does not serve until the block is on. The refusal logs one `warn`
66+
line per refused hit and is not latched, so a retroactive deploy with many live
67+
links on switched-off objects will burst the log once.
68+
69+
<!-- 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. -->

content/docs/permissions/system-context.mdx

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

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

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -917,6 +917,181 @@ describe('[#13608] publicSharing.eligibility is enforced again at REDEMPTION', (
917917
});
918918
});
919919

920+
/**
921+
* [#14033] The PARENT switch is a standing policy too.
922+
*
923+
* `publicSharing.enabled` governed MINTING only: `getPolicy()` collapsed to an
924+
* empty policy when the block was off, and `resolveToken` read nothing off
925+
* `policy.enabled`. So the platform held this shape — the predicate INSIDE
926+
* the block was re-evaluated at every redemption (#13608, above) while turning
927+
* the ENTIRE block off did not stop a single existing link. Maintainer ruling
928+
* of 2026-09-01 (quoted verbatim in `share-link-service.test.ts`'s reversal
929+
* register): the switch is a standing policy held at every redemption,
930+
* retroactively; a link minted through the system / `permissive` bypass is
931+
* governed the same way; and with the block off nothing inside it is
932+
* evaluated at all, while with it on the sibling keys keep their
933+
* redemption-time behaviour.
934+
*
935+
* These pins use the real driver and the real public route: "no record read"
936+
* is measured off the engine's call log, the refusal shape is measured at the
937+
* seam an anonymous holder actually reaches, and the reason is read off the
938+
* server-side log — the only place the ruling leaves it, exactly as for the
939+
* eligibility refusal above.
940+
*/
941+
describe('[#14033] publicSharing.enabled is a standing policy — the switch is held at redemption', () => {
942+
/** A token minted while the block is ON and `a_ok` qualifies — the pre-condition of every case below. */
943+
async function mint(service: ShareLinkService, recordId = 'a_ok') {
944+
const link = await service.createLink(
945+
{ object: 'article', recordId, audience: 'public', permission: 'view' },
946+
CALLER,
947+
);
948+
expect(link.token).toBeTruthy();
949+
return link;
950+
}
951+
952+
/** The switch, thrown from OUTSIDE the token's life: the object's declared block, `enabled: false`. */
953+
const switchOff = (schemas: Record<string, any>) => {
954+
schemas.article = { ...ARTICLE, publicSharing: { ...ARTICLE.publicSharing, enabled: false } };
955+
};
956+
const switchOn = (schemas: Record<string, any>) => { schemas.article = ARTICLE; };
957+
958+
/** The row as the table holds it now — usage counters included. */
959+
async function linkRow(driver: SqlDriver, id: string): Promise<any> {
960+
const rows = await driver.find('sys_share_link', {} as DriverQuery);
961+
return rows.find((r: any) => r.id === id);
962+
}
963+
964+
it('THE REPRO — an ELIGIBLE record on a switched-off block is refused, with no record read and no usage stamp', async () => {
965+
const { driver, service, schemas, findCalls } = await boot();
966+
const link = await mint(service);
967+
968+
switchOff(schemas);
969+
findCalls.length = 0;
970+
971+
// `a_ok` is published + public: no eligibility refusal is available here,
972+
// so this `null` can only have come from the switch.
973+
expect(await service.resolveToken(link.token, {})).toBeNull();
974+
975+
// Refused before the record probe — the token lookup was the only read.
976+
expect(findCalls.map((c) => c.object)).toEqual(['sys_share_link']);
977+
// …and before the usage stamp.
978+
const row = await linkRow(driver, link.id);
979+
expect(row.use_count ?? 0).toBe(0);
980+
expect(row.last_used_at ?? null).toBeNull();
981+
});
982+
983+
/**
984+
* The HTTP seam, driven end-to-end on the real service through the real
985+
* route, with the route's SECURE default context — every request below is
986+
* anonymous. Same reading as the #13608 pin above, for the same reason: the
987+
* switched-off link lands in the generic "invalid / expired / revoked"
988+
* answer, byte-for-byte what a token that NEVER EXISTED gets — not the 410
989+
* bucket, which would confirm the token was real, and not a 422 naming the
990+
* policy, which is what letting `SHARING_NOT_ENABLED` escape would produce.
991+
*/
992+
it('at the HTTP seam an anonymous caller cannot tell a switched-off link from an unknown token', async () => {
993+
const { service, engine, schemas } = await boot();
994+
const live = await mint(service);
995+
const revoked = await mint(service);
996+
await service.revokeLink(revoked.token, { isSystem: true } as any);
997+
998+
const resolve = mountResolveRoute(service, engine);
999+
1000+
// Before the switch: the link serves the record.
1001+
expect((await resolve(live.token)).status).toBe(200);
1002+
1003+
switchOff(schemas);
1004+
1005+
const switchedOff = await resolve(live.token);
1006+
const unknown = await resolve('zzzzzzzzzzzzzzzzzzzzzz');
1007+
const revokedAnswer = await resolve(revoked.token);
1008+
1009+
expect(switchedOff).toEqual(unknown);
1010+
expect(switchedOff.status).toBe(404);
1011+
expect(switchedOff.body?.error?.code).toBe('INVALID_OR_EXPIRED');
1012+
// Nothing about the policy or the switch reaches the wire.
1013+
const wire = JSON.stringify(switchedOff.body).toLowerCase();
1014+
expect(wire).not.toContain('enabled');
1015+
expect(wire).not.toContain('publicsharing');
1016+
expect(wire).not.toContain('sharing_not_enabled');
1017+
1018+
// The pre-existing revoked bucket, recorded as measured: a DIFFERENT
1019+
// status, and this change does not move it.
1020+
expect(revokedAnswer.status).toBe(410);
1021+
expect(revokedAnswer.body?.error?.code).toBe('EXPIRED_OR_REVOKED');
1022+
});
1023+
1024+
it('the reason a switched-off link died is written to the server-side log, and only there', async () => {
1025+
const logged: LoggedRefusal[] = [];
1026+
const { service, schemas } = await boot(ARTICLE, {
1027+
logger: { warn: (msg, meta) => { logged.push({ msg, meta }); } },
1028+
});
1029+
const link = await mint(service);
1030+
switchOff(schemas);
1031+
1032+
expect(await service.resolveToken(link.token, {})).toBeNull();
1033+
1034+
expect(logged).toHaveLength(1);
1035+
expect(logged[0].msg).toContain('publicSharing.enabled');
1036+
expect(logged[0].meta?.reason).toBe('SHARING_NOT_ENABLED');
1037+
expect(logged[0].meta?.link).toBe(link.id);
1038+
expect(logged[0].meta?.object).toBe('article');
1039+
expect(logged[0].meta?.record).toBe('a_ok');
1040+
});
1041+
1042+
/**
1043+
* Ruling point 4, both halves on ONE token. OFF: the switch refuses before
1044+
* anything inside the block is evaluated — the record is not even read, so
1045+
* the predicate that WOULD refuse it never runs. ON again: the same token is
1046+
* judged by the predicate once more, and refused by IT; when the record
1047+
* qualifies again the token serves. A standing policy, not a revocation.
1048+
*/
1049+
it('OFF: nothing inside the block is evaluated; ON again: the eligibility re-check resumes on the same token', async () => {
1050+
const logged: LoggedRefusal[] = [];
1051+
const { driver, service, schemas, findCalls } = await boot(ARTICLE, {
1052+
logger: { warn: (msg, meta) => { logged.push({ msg, meta }); } },
1053+
});
1054+
const link = await mint(service);
1055+
1056+
// Reclassify the record so the predicate would refuse it — THEN switch off.
1057+
await driver.update('article', 'a_ok', { audience: 'internal' });
1058+
switchOff(schemas);
1059+
findCalls.length = 0;
1060+
1061+
expect(await service.resolveToken(link.token, {})).toBeNull();
1062+
expect(findCalls.map((c) => c.object)).toEqual(['sys_share_link']);
1063+
expect(logged.map((l) => l.meta?.reason)).toEqual(['SHARING_NOT_ENABLED']);
1064+
1065+
switchOn(schemas);
1066+
expect(await service.resolveToken(link.token, {})).toBeNull();
1067+
expect(logged.map((l) => l.meta?.reason)).toEqual(['SHARING_NOT_ENABLED', 'RECORD_NOT_ELIGIBLE']);
1068+
1069+
await driver.update('article', 'a_ok', { audience: 'public' });
1070+
expect(await service.resolveToken(link.token, {})).not.toBeNull();
1071+
});
1072+
1073+
/**
1074+
* Ruling point 3 on the real driver: the `permissive` bypass still MINTS on
1075+
* a switched-off block (ledger row 37's path — the ruling governs
1076+
* redemption, not minting), and the result is refused at redemption by the
1077+
* bypassing service and the ordinary one alike.
1078+
*/
1079+
it('a link minted through the `permissive` bypass on a switched-off block is refused at redemption', async () => {
1080+
const off = { ...ARTICLE, publicSharing: { ...ARTICLE.publicSharing, enabled: false } };
1081+
const { service, engine } = await boot(off);
1082+
const bypass = new ShareLinkService({ engine: engine as any, permissive: true });
1083+
1084+
const link = await bypass.createLink(
1085+
{ object: 'article', recordId: 'a_ok', audience: 'public', permission: 'view' },
1086+
CALLER,
1087+
);
1088+
expect(link.token).toBeTruthy();
1089+
1090+
expect(await bypass.resolveToken(link.token, {})).toBeNull();
1091+
expect(await service.resolveToken(link.token, {})).toBeNull();
1092+
});
1093+
});
1094+
9201095
/**
9211096
* [#13608] Mount the real PUBLIC resolve route on the real service.
9221097
*

0 commit comments

Comments
 (0)