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
38 changes: 38 additions & 0 deletions .changeset/share-link-usage-stamp-refusal-reported.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
"@objectstack/plugin-sharing": patch
---

fix(plugin-sharing): a refused `use_count` / `last_used_at` stamp in `resolveToken` is reported as a durability degradation, once, instead of being swallowed (#12981, batch 9)

`ShareLinkService.resolveToken` stamps `use_count` and `last_used_at` on
`sys_share_link` after every successful resolution. The stamp's `catch` was
empty ("usage telemetry is a nice-to-have"), so a storage refusal — a
read-only database, a missing table, a broken system-context write path —
left the link resolving normally while both counters silently froze.

Those counters are a persistence CLAIM, not telemetry: `sys_share_link`
declares `use_count` as "Incremented by resolveToken on every successful
resolution" and `last_used_at` as "Stamped by resolveToken; used by the
dashboard to highlight active links", and the shipped `active_links` grid
lists both. After a swallowed refusal an administrator read a count the
system's own declaration defines, wrong, with no signal anywhere — the
AGENTS.md "Degradation log levels" shape (persisted state and runtime state
disagree while nothing looks broken).

**What changed.** The refusal is now reported through the service's existing
`logger` option — the published `{ info?, warn, error? }` shape — at `error`,
falling back to the guaranteed `warn` channel when the host sink declares no
`error`. The line names the consequence (both counters are not being
persisted; links keep resolving; the `active_links` grid under-counts), the
fix (resolve the storage refusal named as the cause; refused stamps are not
replayed), and the cause. It is emitted **once per service instance**, at the
first refusal, never per request — `resolveToken` runs on every public
share-link request, and a line per refused stamp would be the flood the rule
forbids.

**What did NOT change**, and is pinned: the resolution itself (the holder is
still served, `redactFields` is unchanged, `resolveToken` never throws for a
refused stamp); the success path (`use_count` still increments and
`last_used_at` is still stamped on every successful resolution); the public
HTTP projection; and `ShareLinkServiceOptions` — no member is added or
widened, so hosts compile exactly as before.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff 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:434`, `:488`, `:492`, `:565`, `:595` |
| 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` |
| 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
183 changes: 183 additions & 0 deletions packages/plugins/plugin-sharing/src/share-link-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -607,3 +607,186 @@ describe('[#13856] declared redactFields survive publicSharing opt-out', () => {
expect(caught.code).toBe('SHARING_NOT_ENABLED');
});
});

// [#12981, batch 9] The `use_count` / `last_used_at` stamp at the end of
// `resolveToken` used to be swallowed by an empty `catch` ("usage telemetry is
// a nice-to-have"). It is a durability site: `sys_share_link` DECLARES both
// counters as written by `resolveToken`, and the shipped `active_links` grid
// asserts them — so a refused stamp left an admin grid asserting a number the
// system's own declaration defines, wrongly, with no signal. The repair reports
// the refusal through the service's existing `{ info?, warn, error? }` sink at
// `error` (falling back to the guaranteed `warn`), ONCE per instance, and
// leaves the resolution itself untouched.
describe('[#12981] a refused usage stamp is reported ONCE as a durability degradation', () => {
/** A sink that records every call, per level, so counts are exact. */
function makeSink() {
const calls = { error: [] as any[][], warn: [] as any[][], info: [] as any[][] };
return {
calls,
logger: {
info: (...a: any[]) => { calls.info.push(a); },
warn: (...a: any[]) => { calls.warn.push(a); },
error: (...a: any[]) => { calls.error.push(a); },
},
};
}

/**
* An engine whose `sys_share_link` UPDATE is refused with `err` for as long
* as `refusing.on` is true — every other operation (find / insert / the
* record probe) is the plain fake, so the ONLY thing that fails is the stamp.
*/
function makeRefusingEngine(err: unknown) {
const base = makeFakeEngine(SCHEMAS);
base._tables.ai_conversations = [{ id: 'c1', title: 'Demo' }];
const refusing = { on: true };
const engine = {
...base,
async update(object: string, idOrData: any, dataOrOptions?: any) {
if (refusing.on && object === 'sys_share_link') throw err;
return base.update(object, idOrData, dataOrOptions);
},
};
return { base, engine, refusing };
}

async function mint(service: ShareLinkService) {
return service.createLink(
{ object: 'ai_conversations', recordId: 'c1', audience: 'link_only', permission: 'view' },
{ userId: 'u1' },
);
}

const REFUSAL = Object.assign(new Error('SQLITE_READONLY: attempt to write a readonly database'), {
code: 'STORAGE_REFUSED',
});

it('positive — the link still resolves, and the refusal is reported at error naming both counters', async () => {
const { engine, base } = makeRefusingEngine(REFUSAL);
const sink = makeSink();
const service = new ShareLinkService({ engine: engine as any, logger: sink.logger });
const link = await mint(service);

const resolved = await service.resolveToken(link.token);

// The resolution is UNCHANGED by the refusal: the holder is served.
expect(resolved).not.toBeNull();
expect(resolved!.link.id).toBe(link.id);
expect(resolved!.redactFields).toEqual(['metadata']);
// ...and the counters genuinely did not move — the thing being reported.
expect(base._tables.sys_share_link[0].use_count).toBe(0);
expect(base._tables.sys_share_link[0].last_used_at).toBeNull();

// The report: exactly one, at `error`, not degraded to `warn` while
// `error` is available. Consequence and fix in the one line, plus the
// cause, per AGENTS.md → "Degradation log levels".
expect(sink.calls.error).toHaveLength(1);
expect(sink.calls.warn).toHaveLength(0);
expect(sink.calls.info).toHaveLength(0);
const [message, meta] = sink.calls.error[0];
expect(message).toContain('use_count');
expect(message).toContain('last_used_at');
expect(message).toContain('sys_share_link');
expect(message).toContain('active_links');
expect(message).toContain('Fix:');
expect(message).toContain('SQLITE_READONLY: attempt to write a readonly database');
expect(meta).toMatchObject({
link: link.id,
object: 'ai_conversations',
record: 'c1',
reason: 'STORAGE_REFUSED',
});
});

// ⭐ The "say it ONCE" pin. `resolveToken` runs on every public request, so a
// line per refused stamp is the flood the rule forbids. N = 5 ≥ 3.
it('say it ONCE — five consecutive refused stamps produce exactly one report', async () => {
const { engine } = makeRefusingEngine(REFUSAL);
const sink = makeSink();
const service = new ShareLinkService({ engine: engine as any, logger: sink.logger });
const link = await mint(service);

for (let i = 0; i < 5; i++) {
// Every resolution still serves — the degradation never leaks to the holder.
expect(await service.resolveToken(link.token), `resolution #${i + 1}`).not.toBeNull();
}

expect(sink.calls.error).toHaveLength(1);
expect(sink.calls.warn).toHaveLength(0);
expect(sink.calls.error[0][0]).toContain('Reported ONCE');
});

// Reverse control. Without it, "once" and "never" are indistinguishable: a
// reporter that never fires also passes the pin above only through the
// positive test, so the control pins the OTHER direction — a stamp that
// lands produces nothing at any level.
it('reverse control — five stamps that LAND produce zero output at every level', async () => {
const { engine, refusing, base } = makeRefusingEngine(REFUSAL);
refusing.on = false;
const sink = makeSink();
const service = new ShareLinkService({ engine: engine as any, logger: sink.logger });
const link = await mint(service);

for (let i = 0; i < 5; i++) {
expect(await service.resolveToken(link.token)).not.toBeNull();
}

expect(sink.calls.error).toHaveLength(0);
expect(sink.calls.warn).toHaveLength(0);
expect(sink.calls.info).toHaveLength(0);
// Invariance of the success path: the declared semantics hold verbatim —
// `use_count` "incremented on every successful resolution", `last_used_at` stamped.
expect(base._tables.sys_share_link[0].use_count).toBe(5);
expect(typeof base._tables.sys_share_link[0].last_used_at).toBe('string');
expect(Number.isNaN(Date.parse(base._tables.sys_share_link[0].last_used_at))).toBe(false);
});

// "At the FIRST degradation" is not "on the first call": storage that starts
// refusing after a healthy run is reported at the moment it turns, once.
it('the first degradation after healthy stamps is reported, once, and later refusals stay silent', async () => {
const { engine, refusing, base } = makeRefusingEngine(REFUSAL);
refusing.on = false;
const sink = makeSink();
const service = new ShareLinkService({ engine: engine as any, logger: sink.logger });
const link = await mint(service);

await service.resolveToken(link.token);
await service.resolveToken(link.token);
expect(sink.calls.error).toHaveLength(0);
expect(base._tables.sys_share_link[0].use_count).toBe(2);

refusing.on = true;
for (let i = 0; i < 3; i++) expect(await service.resolveToken(link.token)).not.toBeNull();

expect(sink.calls.error).toHaveLength(1);
expect(sink.calls.warn).toHaveLength(0);
// The counters froze at the last landed value — exactly the drift the line reports.
expect(base._tables.sys_share_link[0].use_count).toBe(2);
});

// The sink's `error` is optional by contract (#9754: hosts inject reduced
// sinks); `warn` is the guaranteed channel. A `{ warn }`-only host must still
// hear the report — a conditional `error?.(…)` call would have emitted nothing.
it('falls back to the guaranteed warn channel when the host sink declares no error', async () => {
const { engine } = makeRefusingEngine(REFUSAL);
const warns: any[][] = [];
const service = new ShareLinkService({
engine: engine as any,
logger: { warn: (...a: any[]) => { warns.push(a); } },
});
const link = await mint(service);

for (let i = 0; i < 3; i++) expect(await service.resolveToken(link.token)).not.toBeNull();

expect(warns).toHaveLength(1);
expect(warns[0][0]).toContain('use_count');
expect(warns[0][1]).toMatchObject({ link: link.id, reason: 'STORAGE_REFUSED' });
});

it('a host with no logger at all is served exactly as before — the resolution never throws', async () => {
const { engine } = makeRefusingEngine(REFUSAL);
const service = new ShareLinkService({ engine: engine as any });
const link = await mint(service);
await expect(service.resolveToken(link.token)).resolves.not.toBeNull();
});
});
82 changes: 79 additions & 3 deletions packages/plugins/plugin-sharing/src/share-link-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,12 @@ export class ShareLinkService implements IShareLinkService {
context: ExecutionContext,
) => Promise<boolean>;
private readonly logger?: ShareLinkServiceOptions['logger'];
/**
* [#12981] Latched by the FIRST refused usage stamp on this instance and
* never reset; `reportUsageStampRefusal` reads it so the durability report
* is made once, not once per refused write (the rule's own words).
*/
private usageStampRefusalReported = false;

constructor(opts: ShareLinkServiceOptions) {
this.engine = opts.engine;
Expand Down Expand Up @@ -698,7 +704,10 @@ export class ShareLinkService implements IShareLinkService {
new Set<string>([...(policy.redactFields ?? []), ...((row.redact_fields as string[]) ?? [])]),
);

// Stamp usage. Errors here MUST NOT block the read — log-and-continue.
// Stamp usage. A refusal here MUST NOT block the read — by this line the
// token, the record and the policy have all answered and the holder is
// owed the record — but it is a DURABILITY degradation, not telemetry to
// drop on the floor: see `reportUsageStampRefusal` (#12981).
try {
await this.engine.update(
'sys_share_link',
Expand All @@ -709,13 +718,80 @@ export class ShareLinkService implements IShareLinkService {
},
{ context: SYSTEM_CTX },
);
} catch {
// best-effort — usage telemetry is a nice-to-have
} catch (err) {
this.reportUsageStampRefusal(row, err);
}

return { link: row, redactFields };
}

/**
* [#12981] Storage refused the `use_count` / `last_used_at` stamp that
* `resolveToken` issues on a successful resolution. Report it as a
* durability degradation — ONCE per service instance.
*
* ## Why this is a durability site and not "usage telemetry"
*
* The catch this reporter replaced said "best-effort — usage telemetry is a
* nice-to-have". The persistence CLAIM, though, is not made by
* `resolveToken`'s response (which carries neither counter; its two public
* HTTP callers project a nine-field whitelist that excludes both). It is
* made by the declarations: `sys_share_link` declares `use_count` as
* "Incremented by resolveToken on every successful resolution" and
* `last_used_at` as "Stamped by resolveToken; used by the dashboard to
* highlight active links", both `readonly: true` — which is exactly why this
* write goes out under `SYSTEM_CTX` (`isSystem` exempts statically readonly
* fields). And the shipped `active_links` grid lists both columns. So after
* a swallowed refusal: HTTP 200 to the holder, and an admin grid asserting a
* count the system's own declaration defines — now wrong, with no signal
* anywhere. AGENTS.md → "Degradation log levels", in its own words:
* persisted state and runtime state disagree while nothing looks broken.
* ⇒ `error`, not `warn`. Neither legal alternative applies: the failure is
* handed to no caller, and a write was genuinely issued.
*
* ## Why ONCE, per instance, never reset
*
* `resolveToken` runs on EVERY public share-link request. A line per refused
* stamp is the mirror-image failure the rule names — "say it once, at the
* first degradation, not once per failed write" — a flood nobody reads,
* which is what made the founding incident's `warn` unreadable. The latch is
* per service instance (the plugin builds one) and deliberately does not
* reset on a later successful stamp: a latch that reset would print on every
* other request under flapping storage, i.e. the per-request flood again.
* Later refusals are silent BY DESIGN, and the one line says so.
*
* ## The sink, and why `error` is reachable here (#13398 class ruling)
*
* `ShareLinkServiceOptions['logger']` is the `{ info?, warn, error? }` shape
* — `error` optional, `warn` required and guaranteed (#9754 / #10556) — the
* ruling's own option-C terminal shape, and already published. What the
* ruling forbids is raising a site to `error` when that means GROWING
* `error?` onto a published sink that lacks it (its option B); this sink
* declares it, so nothing is widened. Spelled the `outbox-sweep.ts` way: a
* conditional `error?.(…)` call against a host sink without `error` emits
* nothing, so the `warn` fallback is an explicit branch.
*/
private reportUsageStampRefusal(row: ShareLink, err: unknown): void {
if (this.usageStampRefusalReported) return;
this.usageStampRefusalReported = true;
const cause = (err as { message?: unknown } | null | undefined)?.message ?? err;
const message =
'[share-link] usage stamp REFUSED — `use_count` / `last_used_at` on `sys_share_link` are NOT being '
+ 'persisted. Links keep resolving normally (the holder is still served the record), so nothing looks '
+ 'broken, but the `active_links` grid and every "how often was this link used" audit now under-count. '
+ 'Fix: resolve the storage refusal named as the cause (the `sys_share_link` table, the driver, or the '
+ 'system-context write path); stamps refused meanwhile are NOT replayed. Reported ONCE per service '
+ `instance — later refusals are silent. Cause: ${String(cause)}`;
const meta = {
link: row.id,
object: row.object_name,
record: row.record_id,
reason: (err as { code?: unknown } | null | undefined)?.code ?? 'UNKNOWN',
};
if (this.logger?.error) this.logger.error(message, meta);
else this.logger?.warn?.(message, meta);
}

/**
* [#5190 / #13608] Read the shared record at redemption time: the existence
* probe, and — when the object declares an eligibility predicate — the row
Expand Down
Loading
Loading