Skip to content

Commit 889e30d

Browse files
committed
test(sharing,runtime): pin the 410 arm's EXPIRED half, at both sites
The gated 410 arm is reached by two predicates, not one: row.revoked_at || (row.expires_at && Date.parse(row.expires_at) <= Date.now()) Both sites pinned only the revoked half. A pin on that half alone leaves the expired half free to keep answering `410 EXPIRED_OR_REVOKED` on a switched-off object — the same existence oracle, reached by the other predicate, and invisible to every assertion in the file. Both new cases carry the same reverse check the siblings do (with the block ON an expired link is still 410) and the same `expectIndistinguishable` byte-equality assertion against the unknown-token answer. Expiry is stamped on the stored row rather than minted: `createLink` refuses a past `expiresAt` outright with `422 EXPIRY_IN_PAST`, so back-dating the row is the only way to reach an already-expired link — which is what the passage of time does to a live one, and the stamp the file's existing #13608 pins already use. The changeset's "Consumer impact" paragraph named only the password-prompt consequence. The 410 shift is equally consumer-visible — a different sentence in the objectui console, which branches on the refusal STATUS and never on the body's error code — so it is now named too, with the measured consumer and its line range. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
1 parent b0afa4d commit 889e30d

3 files changed

Lines changed: 77 additions & 5 deletions

File tree

.changeset/share-link-probe-policy-gate.md

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,24 @@ viewer still resolves the record. Mint-time behaviour is untouched, no
5656
`sys_share_link` row is written or read differently, and no error code is added
5757
or retired.
5858

59-
**Consumer impact.** A viewer that renders its password prompt off
60-
`401 NEEDS_PASSWORD` shows "link invalid" instead, for links on a switched-off
61-
object only. That is the intended outcome and was accepted with the ruling: a
59+
**Consumer impact.** A viewer that branches on the refusal STATUS sees TWO
60+
changes, for links on a switched-off object only — and the measured consumer
61+
branches on status alone. On the objectui console at `67dadd6`,
62+
`apps/console/src/pages/SharedRecordPage.tsx` lines 70-85 dispatch on
63+
`res.status` and never on the body's error code, so:
64+
65+
- all three 401 arms (`NEEDS_PASSWORD`, `WRONG_PASSWORD`, `SIGN_IN_REQUIRED`)
66+
rendered the password prompt and now render the 404 copy, "This link is
67+
invalid or no longer available.";
68+
- the 410 arm rendered "This link has expired or was revoked." and now renders
69+
that same 404 copy.
70+
71+
Both shifts are the intended outcome and were accepted with the ruling: a
6272
correct password on such a link yields nothing, so prompting for one teaches the
63-
holder to open a door that is bricked up. Links on objects whose block is on are
64-
unaffected, prompt included.
73+
holder to open a door that is bricked up, and "expired or revoked" is a claim
74+
about a token whose existence the caller must not be able to confirm. Links on
75+
objects whose block is on are unaffected — prompt, 410 copy and 200 render
76+
included.
6577

6678
Maintainer ruling 2026-09-03 (decision batch #17, item 1), verbatim 「同意」,
6779
adopting option A over option B (keep the 401 and document the accepted oracle)

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1259,6 +1259,35 @@ describe('[#14637] the route probe reads the standing policy before it answers f
12591259
expectIndistinguishable(await resolve(link.token), await resolve(UNKNOWN_TOKEN));
12601260
});
12611261

1262+
it("the 410 arm's OTHER half — an EXPIRED link falls through too, not just a revoked one", async () => {
1263+
const { driver, service, engine, schemas } = await boot(SHAREABLE);
1264+
const link = await service.createLink(
1265+
{ object: 'article', recordId: 'a_ok', audience: 'public', permission: 'view' },
1266+
CALLER,
1267+
);
1268+
// Back-dated on the stored row, not minted: `createLink` refuses a past
1269+
// `expiresAt` outright (`422 EXPIRY_IN_PAST`), so this is the only way to
1270+
// reach an ALREADY-EXPIRED link — and it is exactly what the passage of
1271+
// time does to a live one. Same stamp the #13608 pins above use.
1272+
await driver.update('sys_share_link', link.id, {
1273+
expires_at: new Date(Date.now() - 60_000).toISOString(),
1274+
});
1275+
const resolve = mountResolveRoute(service, engine);
1276+
1277+
// `revoked_at` and `expires_at` are two predicates reaching ONE arm, so a
1278+
// pin on the revoked half alone leaves the expired half free to keep
1279+
// answering 410 on a switched-off object — the same oracle, reached by the
1280+
// other predicate.
1281+
//
1282+
// Reverse check: with the block ON, an expired link is still 410.
1283+
const on = await resolve(link.token);
1284+
expect(on.status).toBe(410);
1285+
expect(on.body?.error?.code).toBe('EXPIRED_OR_REVOKED');
1286+
1287+
switchOff(schemas);
1288+
expectIndistinguishable(await resolve(link.token), await resolve(UNKNOWN_TOKEN));
1289+
});
1290+
12621291
it('fail-closed: an object whose schema the engine cannot answer for is refused, not probed', async () => {
12631292
const { service, engine, schemas } = await boot(SHAREABLE);
12641293
const link = await service.createLink(

packages/runtime/src/domains/share-links-enforcement-context.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,8 @@ describe('[#14637] the dispatcher probe reads the standing policy before it answ
753753
forgetSchema(): void;
754754
mint(input: { audience?: 'link_only' | 'signed_in'; password?: string }): Promise<string>;
755755
revoke(token: string): Promise<void>;
756+
/** Let the token's own clock run out — the OTHER half of the same 410 arm. */
757+
expire(token: string): void;
756758
}
757759

758760
async function harness(): Promise<Harness> {
@@ -783,6 +785,15 @@ describe('[#14637] the dispatcher probe reads the standing policy before it answ
783785
return link.token;
784786
},
785787
revoke: async (token: string) => { await svc.revokeLink(token, { isSystem: true } as any); },
788+
// Stamped on the row rather than minted: `createLink` refuses a past
789+
// `expiresAt` outright (`422 EXPIRY_IN_PAST`), so back-dating the
790+
// stored row is the only way to reach an ALREADY-EXPIRED link — which
791+
// is exactly what the passage of time does to a live one.
792+
expire: (token: string) => {
793+
const row = (tables.sys_share_link ?? []).find((r) => r.token === token);
794+
if (!row) throw new Error('expire(): no sys_share_link row for that token');
795+
row.expires_at = new Date(Date.now() - 60_000).toISOString();
796+
},
786797
resolve: async (token, opts = {}) => {
787798
const res = await handleShareLinksRequest(
788799
deps,
@@ -874,6 +885,26 @@ describe('[#14637] the dispatcher probe reads the standing policy before it answ
874885
expectIndistinguishable(await h.resolve(token), await h.resolve(UNKNOWN_TOKEN));
875886
});
876887

888+
it("the 410 arm's OTHER half — an EXPIRED link falls through too, not just a revoked one", async () => {
889+
const h = await harness();
890+
const token = await h.mint({});
891+
h.expire(token);
892+
893+
// `revoked_at` and `expires_at` are two predicates reaching ONE arm
894+
// (`share-links.ts`: `row.revoked_at || (row.expires_at && …)`), so a
895+
// pin on the revoked half alone leaves the expired half free to keep
896+
// answering 410 on a switched-off object — the same oracle, reached by
897+
// the other predicate.
898+
//
899+
// Reverse check: with the block ON, an expired link is still 410.
900+
const on = await h.resolve(token);
901+
expect(on.status).toBe(410);
902+
expect(on.body?.error?.code).toBe('EXPIRED_OR_REVOKED');
903+
904+
h.switchOff();
905+
expectIndistinguishable(await h.resolve(token), await h.resolve(UNKNOWN_TOKEN));
906+
});
907+
877908
it('fail-closed: an object whose schema the engine cannot answer for is refused, not probed', async () => {
878909
const h = await harness();
879910
const token = await h.mint({ password: 'hunter2' });

0 commit comments

Comments
 (0)