Skip to content

Commit c852449

Browse files
committed
fix(runtime): a throw before the share-link body try still answers a public route with no-store
The public-route header wrapper now maps a throw from outside the body's own try (service resolution) through errorFromThrown before adding the headers; authenticated routes keep propagating. Adds an NFKC-differing password case to the cross-implementation scrypt test, both directions, and lists @objectstack/hono in the changeset frontmatter its text already names. Claude-Session: https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6 Co-authored-by: Claude <noreply@anthropic.com>
1 parent f592f64 commit c852449

4 files changed

Lines changed: 65 additions & 2 deletions

File tree

‎.changeset/21839-share-link-password-handling.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
---
22
'@objectstack/plugin-sharing': patch
33
'@objectstack/plugin-hono-server': patch
4+
'@objectstack/hono': patch
45
'@objectstack/runtime': patch
56
---
67

‎packages/plugins/plugin-sharing/src/share-link-password-webcontainer.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,32 @@ describe('[#21839] share-link password: node:crypto and pure-JS scrypt are inter
6969
expect(nodeScrypt).not.toHaveBeenCalled();
7070
});
7171

72+
it('a password whose NFKC form differs from its input is one password on both paths', async () => {
73+
// A decomposed e + combining acute, and a fullwidth A: NFKC rewrites both.
74+
const raw = 'cafe\u0301 \uFF21 21839';
75+
const normalised = 'caf\u00e9 A 21839';
76+
expect(raw).not.toBe(normalised);
77+
expect(raw.normalize('NFKC')).toBe(normalised);
78+
79+
// pure-JS mints, native verifies — either spelling of the same password.
80+
onWebContainer(true);
81+
const pureJsHash = await hashShareLinkPassword(raw);
82+
onWebContainer(false);
83+
expect(await verifyShareLinkPassword(raw, pureJsHash)).toBe(true);
84+
expect(await verifyShareLinkPassword(normalised, pureJsHash)).toBe(true);
85+
expect(await verifyShareLinkPassword('cafe A 21839', pureJsHash)).toBe(false);
86+
87+
// native mints, pure-JS verifies — either spelling of the same password.
88+
onWebContainer(false);
89+
const nativeHash = await hashShareLinkPassword(raw);
90+
onWebContainer(true);
91+
nodeScrypt.mockClear();
92+
expect(await verifyShareLinkPassword(raw, nativeHash)).toBe(true);
93+
expect(await verifyShareLinkPassword(normalised, nativeHash)).toBe(true);
94+
expect(await verifyShareLinkPassword('cafe A 21839', nativeHash)).toBe(false);
95+
expect(nodeScrypt).not.toHaveBeenCalled();
96+
});
97+
7298
it('the pure-JS key is byte-identical to node:crypto for the same password and salt', async () => {
7399
onWebContainer(true);
74100
const hash = await hashShareLinkPassword(PASSWORD);

‎packages/runtime/src/domains/share-links-public-cache-headers.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,3 +145,29 @@ describe('[#21839] dispatcher public share-link routes are never cached', () =>
145145
expect(list.headers?.Vary).toBeUndefined();
146146
});
147147
});
148+
149+
describe('[#21839] a throw outside the body try still carries the public headers', () => {
150+
const throwingDeps = (): DomainHandlerDeps =>
151+
({
152+
...makeDeps(undefined, undefined),
153+
resolveService: async () => {
154+
throw Object.assign(new Error('service registry unavailable'), { status: 503 });
155+
},
156+
}) as unknown as DomainHandlerDeps;
157+
158+
it('resolveService throwing on /resolve and /messages answers the mapped error with the headers', async () => {
159+
for (const route of ['/tok_21839/resolve', '/tok_21839/messages']) {
160+
const res = await handleShareLinksRequest(throwingDeps(), route, 'GET', undefined, {}, {} as HttpProtocolContext);
161+
expect(res.handled, route).toBe(true);
162+
const answer = res.response as Answer;
163+
expect(answer.status, route).toBe(503);
164+
expectNoStore(answer, route);
165+
}
166+
});
167+
168+
it('an authenticated route still propagates the throw unchanged', async () => {
169+
await expect(
170+
handleShareLinksRequest(throwingDeps(), '', 'GET', undefined, {}, {} as HttpProtocolContext),
171+
).rejects.toThrow('service registry unavailable');
172+
});
173+
});

‎packages/runtime/src/domains/share-links.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,18 @@ export async function handleShareLinksRequest(
9999
query: any,
100100
context: HttpProtocolContext,
101101
): Promise<HttpDispatcherResult> {
102-
const result = await handleShareLinksRequestBody(deps, subPath, method, body, query, context);
103-
if (!result.response || !isPublicShareLinkRoute(subPath, method)) return result;
102+
const isPublic = isPublicShareLinkRoute(subPath, method);
103+
let result: HttpDispatcherResult;
104+
try {
105+
result = await handleShareLinksRequestBody(deps, subPath, method, body, query, context);
106+
} catch (err: unknown) {
107+
// A throw from OUTSIDE the body's own try (service resolution, engine
108+
// lookup) would otherwise leave a public route without the headers
109+
// above. Authenticated routes keep their existing propagation.
110+
if (!isPublic) throw err;
111+
result = { handled: true, response: deps.errorFromThrown(err, 500) };
112+
}
113+
if (!result.response || !isPublic) return result;
104114
return {
105115
...result,
106116
response: { ...result.response, headers: { ...result.response.headers, ...PUBLIC_RESPONSE_HEADERS } },

0 commit comments

Comments
 (0)