diff --git a/.changeset/21839-share-link-password-handling.md b/.changeset/21839-share-link-password-handling.md new file mode 100644 index 00000000000..4a2f94b3f95 --- /dev/null +++ b/.changeset/21839-share-link-password-handling.md @@ -0,0 +1,15 @@ +--- +'@objectstack/plugin-sharing': patch +'@objectstack/plugin-hono-server': patch +'@objectstack/hono': patch +'@objectstack/runtime': patch +--- + +Share-link passwords follow the platform's credential rules (#21839). + +- **The stored hash never leaves the server.** The share-link mint response (`POST /api/v1/share-links`, and `ShareLinkService.createLink`'s return value) no longer carries `password_hash`. The list and the redemption result are projected the same way. A client that reads a link's password state keeps reading it from the redemption route's `NEEDS_PASSWORD` answer, as before. +- **The stored form is the platform's slow password hash.** New passwords are hashed with scrypt at the parameters account passwords use, instead of one salted SHA-256. Links minted before this release keep working: a stored password in a legacy form still verifies, and it is re-hashed into the new form on its first successful redemption. Every comparison is constant-time. A deployment that injects its own `hashPassword` / `verifyPassword` pair is unaffected, and its stored forms are left alone. +- **The password travels in a header.** Both public share-link routes (`GET /api/v1/share-links/:token/resolve` and `/:token/messages`) accept the `x-share-password` request header, the preferred form, because a header is not part of the request URL. The `?password=` query parameter is still accepted for compatibility, so current consoles keep working until they move to the header. `/messages` accepted only the query parameter on this mount before. +- **Cross-origin clients can send the header.** `X-Share-Password` is in the default CORS preflight allow-list (`DEFAULT_CORS_ALLOW_HEADERS` in `@objectstack/plugin-hono-server`, which the `@objectstack/hono` adapter also applies). A deployment that passes its own `allowHeaders` is unchanged; add the header to that list to let a cross-origin client use it. +- **Public share-link answers are not cached.** Both public routes answer with `Cache-Control: no-store` and `Vary: X-Share-Password` on every outcome, on both mounts (the sharing plugin's routes and the runtime dispatcher's `/share-links` domain). The authenticated create, list and revoke routes are unchanged. +- **Hashing works in WebContainer.** On StackBlitz WebContainer, where `node:crypto.scrypt` is incomplete, the password is hashed with the pure-JS scrypt from `@noble/hashes` (now a dependency of `@objectstack/plugin-sharing`, as it already is of `@objectstack/plugin-auth`), at the same parameters and in the same stored form. A hash made on either runtime verifies on the other. diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index ab081f68f9c..01a3aad9f6c 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way. The same holds twice over for the context. An options argument spelled as a literal can be read; one spelled `options`, `{ ...opts }`, or handed through a -forwarding shim cannot, and **67 of the 232 sites are spelled that way**. A +forwarding shim cannot, and **67 of the 233 sites are spelled that way**. A context resolved from an inline literal or a local `const` can be tested for `isSystem`; one arriving from a helper call cannot. @@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | -| 175 write call sites | quoted in the merged changeset | **232** | +| 175 write call sites | quoted in the merged changeset | **233** | | 24 carrying no tenant context | quoted in the merged changeset | **9** provable and tenancy-enabled; **34** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **154 of 232** decidable, **78** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 113 decidably elevated, 0 decidably not, 102 undecidable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **155 of 233** decidable, **78** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 114 decidably elevated, 0 decidably not, 102 undecidable | | 141 and 132, two independent re-derivations | the card that filed this work | — | **The differences are not reconciled, and deliberately so.** The old census's @@ -207,11 +207,11 @@ would report a smaller number and would not say so. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -113 of 232 (49%) as decidably elevated, with 102 more whose elevation is a +114 of 233 (49%) as decidably elevated, with 102 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `9 / 232`, and say what it is**: the sites whose options argument was +⇒ **Cite `9 / 233`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites without tenant context" — **34 further sites** have an options argument this @@ -223,28 +223,28 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **232** | -| …whose object name is statically decidable | 154 | +| write call sites on the application surface | **233** | +| …whose object name is statically decidable | 155 | | …whose object name is chosen at run time | 78 | -| …against an object with tenancy ENABLED | 153 | +| …against an object with tenancy ENABLED | 154 | | …against an object that declares tenancy off | 1 | -| threading a tenant context | 148 | +| threading a tenant context | 149 | | PROVABLY carrying none (options read, no context key) | **17** | | …of those, against a decidably tenancy-enabled object | **9** | | options argument UNREADABLE — may or may not carry one | 67 | | …of those, against a decidably tenancy-enabled object | 34 | -| threading a decidably ELEVATED (`isSystem`) context | 113 | +| threading a decidably ELEVATED (`isSystem`) context | 114 | | threading a context that is decidably NOT elevated | 0 | | threading a context whose elevation is a run-time fact | 102 | | how the instrument reached the site | count | | :--- | ---: | -| receiver carried a readable engine type | 184 | +| receiver carried a readable engine type | 185 | | receiver erased, placed by the object NAME | 28 | | receiver erased, placed by an `object: string` PARAMETER | 15 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | -| object name spelled inline | 103 | +| object name spelled inline | 104 | | object name spelled through a `const` | 51 | | object name is an `object: string` parameter | 17 | | object name is some other run-time expression | 61 | @@ -297,13 +297,13 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-05 at `34782539c`. +Measured on 2026-10-05 at `3d34c6efd`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 605 | -| engine-shaped types recognised | 68 | +| tracked non-test sources scanned | 607 | +| engine-shaped types recognised | 69 | | declared objects in the registry | 116 | -| same-named calls subtracted as non-engine | 155 | +| same-named calls subtracted as non-engine | 158 | {/* END GENERATED: tenant-audit-census */} diff --git a/content/docs/protocol/kernel/http-protocol.mdx b/content/docs/protocol/kernel/http-protocol.mdx index ef5c7324590..ec6751b48d4 100644 --- a/content/docs/protocol/kernel/http-protocol.mdx +++ b/content/docs/protocol/kernel/http-protocol.mdx @@ -999,18 +999,19 @@ ObjectStack sends CORS headers automatically: ```http Access-Control-Allow-Origin: https://app.acme.com Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS -Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, X-Tenant-ID, X-Environment-Id, If-Match +Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, X-Tenant-ID, X-Environment-Id, If-Match, X-Share-Password Access-Control-Expose-Headers: set-auth-token, x-objectstack-dropped-fields Access-Control-Max-Age: 86400 ``` -Three of the allowed request headers are easy to overlook, and each one disables +Four of the allowed request headers are easy to overlook, and each one disables a feature if an intermediate proxy strips it: | Header | Why it is allowed | | --- | --- | | `X-Tenant-ID` / `X-Environment-Id` | Route the request to its environment on a multi-tenant host. | | `If-Match` | Carries the OCC token on record `PATCH`es. Without it, a cross-origin save fails in the browser with "Failed to fetch". | +| `X-Share-Password` | Carries a share-link password to the public `/share-links/:token/resolve` and `/messages` routes. It is the preferred form because a header stays out of URLs; without it, a cross-origin client can only send the password as a query parameter. | The two **exposed** response headers matter to browser clients specifically: `set-auth-token` delivers a rotated session token (without it a cross-origin diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index 5bfd9bfd4ab..0f8e8e087e1 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 232 | -| Object name statically decidable | 154 | +| Write call sites | 233 | +| Object name statically decidable | 155 | | Object name chosen at run time | 78 | -| Against a tenancy-enabled object | 153 | +| Against a tenancy-enabled object | 154 | | Against an object declaring tenancy off | 1 | -| Threading a tenant context | 148 | +| Threading a tenant context | 149 | | Provably carrying none | 17 | | …and decidably tenancy-enabled | 9 | | Options argument unreadable | 67 | | …and decidably tenancy-enabled | 34 | -| Threading a decidably elevated context | 113 | +| Threading a decidably elevated context | 114 | | Threading a decidably non-elevated context | 0 | | Threading a context of undecidable elevation | 102 | @@ -90,14 +90,14 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-05 at `34782539c`. +Measured on 2026-10-05 at `3d34c6efd`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 605 | -| engine-shaped types recognised | 68 | +| tracked non-test sources scanned | 607 | +| engine-shaped types recognised | 69 | | declared objects in the registry | 116 | -| same-named calls subtracted as non-engine | 155 | +| same-named calls subtracted as non-engine | 158 | ## Every site @@ -183,7 +183,7 @@ Measured on 2026-10-05 at `34782539c`. | `packages/plugins/plugin-sharing/src/primary-bu-projection.ts` | `update` | `sys_user` | enabled | elevated | 2 | | `packages/plugins/plugin-sharing/src/record-orphan-cleanup.ts` | `delete` | `table` | undecidable | options unreadable | 2 | | `packages/plugins/plugin-sharing/src/share-link-service.ts` | `insert` | `sys_share_link` | enabled | elevated | 1 | -| `packages/plugins/plugin-sharing/src/share-link-service.ts` | `update` | `sys_share_link` | enabled | elevated | 2 | +| `packages/plugins/plugin-sharing/src/share-link-service.ts` | `update` | `sys_share_link` | enabled | elevated | 3 | | `packages/plugins/plugin-sharing/src/sharing-plugin.ts` | `update` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-sharing/src/sharing-rule-service.ts` | `delete` | `sys_record_share` | enabled | options unreadable | 3 | | `packages/plugins/plugin-sharing/src/sharing-rule-service.ts` | `delete` | `sys_sharing_rule` | enabled | options unreadable | 1 | diff --git a/packages/plugins/plugin-hono-server/src/adapter.ts b/packages/plugins/plugin-hono-server/src/adapter.ts index 6a0c10f7628..5b951a50478 100644 --- a/packages/plugins/plugin-hono-server/src/adapter.ts +++ b/packages/plugins/plugin-hono-server/src/adapter.ts @@ -64,6 +64,10 @@ import { * `If-Match` carries the OCC token on record PATCHes (objectui's inline edit, * REST `update` with `ifMatch`) — without it in the preflight allow-list every * cross-origin save fails in the browser with "Failed to fetch" (objectui#2572). + * `X-Share-Password` carries a share-link password to the public + * `/share-links/:token/resolve` and `/messages` routes — the preferred form, + * since a header stays out of URLs (#21839); without it here a cross-origin + * client could only use the query-parameter form. */ export const DEFAULT_CORS_ALLOW_HEADERS: readonly string[] = Object.freeze([ 'Content-Type', @@ -72,6 +76,7 @@ export const DEFAULT_CORS_ALLOW_HEADERS: readonly string[] = Object.freeze([ 'X-Tenant-ID', 'X-Environment-Id', 'If-Match', + 'X-Share-Password', ]); /** diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts index 4cb54155c5a..b4a89dc4e4c 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.test.ts @@ -313,6 +313,17 @@ describe('HonoServerPlugin', () => { expect(corsConfigCapture.last.allowHeaders).toContain('If-Match'); }); + it('should allow X-Share-Password by default (share-link password header, #21839)', async () => { + corsConfigCapture.last = undefined; + + const plugin = new HonoServerPlugin(); + await plugin.init(context as PluginContext); + + // The header form keeps the password out of URLs; a preflight that + // does not allow it leaves a cross-origin client only the query form. + expect(corsConfigCapture.last.allowHeaders).toContain('X-Share-Password'); + }); + it('should merge user-supplied exposeHeaders with set-auth-token default', async () => { corsConfigCapture.last = undefined; diff --git a/packages/plugins/plugin-sharing/package.json b/packages/plugins/plugin-sharing/package.json index e6f145a1bd3..1e2ab500803 100644 --- a/packages/plugins/plugin-sharing/package.json +++ b/packages/plugins/plugin-sharing/package.json @@ -20,6 +20,7 @@ "gen:test-typecheck-debt": "tsx ../../../scripts/check-test-typecheck.mts --update --package packages/plugins/plugin-sharing --project tsconfig.test.json" }, "dependencies": { + "@noble/hashes": "^2.4.0", "@objectstack/core": "workspace:*", "@objectstack/formula": "workspace:*", "@objectstack/metadata-core": "workspace:*", diff --git a/packages/plugins/plugin-sharing/src/share-link-password-webcontainer.test.ts b/packages/plugins/plugin-sharing/src/share-link-password-webcontainer.test.ts new file mode 100644 index 00000000000..6fd50aa8ab5 --- /dev/null +++ b/packages/plugins/plugin-sharing/src/share-link-password-webcontainer.test.ts @@ -0,0 +1,107 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21839] On WebContainer the share-link password is hashed by the pure-JS + * scrypt (`@noble/hashes`), because that host's `node:crypto.scrypt` is + * incomplete; everywhere else by `node:crypto`. The two must be one hash. + * + * Pinned: + * - with a WebContainer signal set, `node:crypto.scrypt` is never called — + * the pure-JS path really ran; + * - without one, it is — the native path really ran; + * - a hash minted on either path verifies on the other, and a wrong password + * is refused on both; + * - the two paths derive byte-identical keys for the same password and salt + * (compared directly against `node:crypto`). + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import * as nodeCrypto from 'node:crypto'; +import { hashShareLinkPassword, verifyShareLinkPassword } from './share-link-password.js'; + +vi.mock('node:crypto', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, scrypt: vi.fn(actual.scrypt) }; +}); + +const PASSWORD = 'Ünïcödé pass 21839'; +const nodeScrypt = vi.mocked(nodeCrypto.scrypt); + +function onWebContainer(on: boolean) { + vi.stubEnv('STACKBLITZ', on ? '1' : ''); + vi.stubEnv('SHELL', on ? '/bin/jsh' : '/bin/bash'); +} + +afterEach(() => { + vi.unstubAllEnvs(); + nodeScrypt.mockClear(); +}); + +describe('[#21839] share-link password: node:crypto and pure-JS scrypt are interchangeable', () => { + it('the WebContainer path does not touch node:crypto.scrypt; the native path does', async () => { + onWebContainer(true); + await hashShareLinkPassword(PASSWORD); + expect(nodeScrypt).not.toHaveBeenCalled(); + + onWebContainer(false); + await hashShareLinkPassword(PASSWORD); + expect(nodeScrypt).toHaveBeenCalledTimes(1); + }); + + it('a hash minted on WebContainer verifies natively, and the reverse', async () => { + onWebContainer(true); + const pureJsHash = await hashShareLinkPassword(PASSWORD); + onWebContainer(false); + const nativeHash = await hashShareLinkPassword(PASSWORD); + + expect(pureJsHash).toMatch(/^scrypt\$[0-9a-f]{32}\$[0-9a-f]{128}$/); + expect(nativeHash).toMatch(/^scrypt\$[0-9a-f]{32}\$[0-9a-f]{128}$/); + + onWebContainer(false); + expect(await verifyShareLinkPassword(PASSWORD, pureJsHash)).toBe(true); + expect(await verifyShareLinkPassword('wrong 21839', pureJsHash)).toBe(false); + expect(nodeScrypt).toHaveBeenCalled(); + + onWebContainer(true); + nodeScrypt.mockClear(); + expect(await verifyShareLinkPassword(PASSWORD, nativeHash)).toBe(true); + expect(await verifyShareLinkPassword('wrong 21839', nativeHash)).toBe(false); + expect(nodeScrypt).not.toHaveBeenCalled(); + }); + + it('a password whose NFKC form differs from its input is one password on both paths', async () => { + // A decomposed e + combining acute, and a fullwidth A: NFKC rewrites both. + const raw = 'cafe\u0301 \uFF21 21839'; + const normalised = 'caf\u00e9 A 21839'; + expect(raw).not.toBe(normalised); + expect(raw.normalize('NFKC')).toBe(normalised); + + // pure-JS mints, native verifies — either spelling of the same password. + onWebContainer(true); + const pureJsHash = await hashShareLinkPassword(raw); + onWebContainer(false); + expect(await verifyShareLinkPassword(raw, pureJsHash)).toBe(true); + expect(await verifyShareLinkPassword(normalised, pureJsHash)).toBe(true); + expect(await verifyShareLinkPassword('cafe A 21839', pureJsHash)).toBe(false); + + // native mints, pure-JS verifies — either spelling of the same password. + onWebContainer(false); + const nativeHash = await hashShareLinkPassword(raw); + onWebContainer(true); + nodeScrypt.mockClear(); + expect(await verifyShareLinkPassword(raw, nativeHash)).toBe(true); + expect(await verifyShareLinkPassword(normalised, nativeHash)).toBe(true); + expect(await verifyShareLinkPassword('cafe A 21839', nativeHash)).toBe(false); + expect(nodeScrypt).not.toHaveBeenCalled(); + }); + + it('the pure-JS key is byte-identical to node:crypto for the same password and salt', async () => { + onWebContainer(true); + const hash = await hashShareLinkPassword(PASSWORD); + const [, saltHex, keyHex] = hash.split('$'); + const expected = nodeCrypto + .scryptSync(PASSWORD.normalize('NFKC'), saltHex!, 64, { N: 16384, r: 16, p: 1, maxmem: 128 * 16384 * 16 * 2 }) + .toString('hex'); + expect(keyHex).toBe(expected); + }); +}); diff --git a/packages/plugins/plugin-sharing/src/share-link-password.test.ts b/packages/plugins/plugin-sharing/src/share-link-password.test.ts new file mode 100644 index 00000000000..17dff588d01 --- /dev/null +++ b/packages/plugins/plugin-sharing/src/share-link-password.test.ts @@ -0,0 +1,517 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21839] A share link's password: what leaves the server, how it is stored, + * how it travels in. + * + * Run on a REAL `ObjectQL` over `@objectstack/driver-sql` + better-sqlite3, so + * the engine's strip of the `internal` hash column is live and every exit below + * is the one production answers with. + * + * Pinned: + * - no exit carries the stored hash or any part of it — the mint response + * (service and route), the creator's list, the redemption result (service + * and route); + * - the stored form is the platform's slow password hash, and holds no + * plaintext; + * - a row in either legacy stored form still verifies, a wrong password is + * refused and leaves it alone, and the right password re-hashes it into the + * current form — once every later gate has passed; + * - a refused upgrade does not block the read and is reported once, naming + * the link and never the password; + * - the `x-share-password` header is accepted on both public routes, the + * `?password=` query parameter still is, and a wrong password is refused + * through either form; + * - no log line carries the presented password; + * - both public routes answer `Cache-Control: no-store` and + * `Vary: X-Share-Password` on every outcome, and the authenticated routes + * do not; + * - the pure-JS scrypt the WebContainer path uses and `node:crypto`'s produce + * interchangeable hashes. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { createHash } from 'node:crypto'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; +import { SysShareLink } from './objects/sys-share-link.object.js'; +import { ShareLinkService } from './share-link-service.js'; +import { registerShareLinkRoutes } from './share-link-routes.js'; +import { + hashShareLinkPassword, + verifyShareLinkPassword, + isLegacyShareLinkPasswordHash, +} from './share-link-password.js'; +import type { SharingEngine } from './sharing-service.js'; + +type Row = Record; + +const TARGET = { + name: 'pin_doc', + label: 'Pinned Doc', + publicSharing: { enabled: true, allowedAudiences: ['link_only'], allowedPermissions: ['view'] }, + fields: { + id: { name: 'id', label: 'ID', type: 'text', primaryKey: true }, + title: { name: 'title', label: 'Title', type: 'text' }, + }, +}; + +const CONVERSATIONS = { + name: 'ai_conversations', + label: 'Conversation', + publicSharing: { enabled: true, allowedAudiences: ['link_only'], allowedPermissions: ['view'] }, + fields: { + id: { name: 'id', label: 'ID', type: 'text', primaryKey: true }, + title: { name: 'title', label: 'Title', type: 'text' }, + }, +}; + +const MESSAGES = { + name: 'ai_messages', + label: 'Message', + fields: { + id: { name: 'id', label: 'ID', type: 'text', primaryKey: true }, + conversation_id: { name: 'conversation_id', label: 'Conversation', type: 'text' }, + content: { name: 'content', label: 'Content', type: 'text' }, + created_at: { name: 'created_at', label: 'Created', type: 'text' }, + }, +}; + +class MockHttp implements IHttpServer { + routes = new Map(); + private add(method: string, path: string, handler: RouteHandler) { + this.routes.set(`${method} ${path}`, handler); + } + get(path: string, h: RouteHandler) { this.add('GET', path, h); return this as any; } + post(path: string, h: RouteHandler) { this.add('POST', path, h); return this as any; } + put(path: string, h: RouteHandler) { this.add('PUT', path, h); return this as any; } + delete(path: string, h: RouteHandler) { this.add('DELETE', path, h); return this as any; } + patch(path: string, h: RouteHandler) { this.add('PATCH', path, h); return this as any; } + use() { return this as any; } + listen() { return Promise.resolve(); } + close() { return Promise.resolve(); } + getInstance() { return null; } +} + +async function drive( + http: MockHttp, + key: string, + opts: { + params?: Record; + query?: Record; + headers?: Record; + body?: unknown; + } = {}, +): Promise<{ status: number; body: any; headers: Record }> { + const handler = http.routes.get(key); + if (!handler) throw new Error(`no handler for ${key}`); + const captured = { status: 200, body: undefined as any, headers: {} as Record }; + const res: IHttpResponse = { + json: vi.fn((data: any) => { captured.body = data; }) as any, + send: vi.fn() as any, + status: vi.fn((code: number) => { captured.status = code; return res; }) as any, + header: vi.fn((name: string, value: string | string[]) => { captured.headers[name] = value; return res; }) as any, + }; + const req: IHttpRequest = { + params: opts.params ?? {}, + query: opts.query ?? {}, + body: opts.body, + headers: opts.headers ?? {}, + method: key.split(' ')[0]!, + path: '/', + }; + await handler(req, res); + return captured; +} + +const B = '/api/v1/share-links'; +const CREATOR = { userId: 'usr_creator' }; +const PASSWORD = 'correct horse 21839 $ battery'; + +const engines: ObjectQL[] = []; +afterEach(async () => { + while (engines.length) { + try { + await (engines.pop() as unknown as { destroy?(): Promise })?.destroy?.(); + } catch { + /* noop */ + } + } +}); + +function makeLogger() { + return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; +} + +/** Every argument any logger member received, serialised. */ +function loggedText(logger: ReturnType): string { + const calls = [ + ...logger.info.mock.calls, + ...logger.warn.mock.calls, + ...logger.error.mock.calls, + ...logger.debug.mock.calls, + ]; + return JSON.stringify(calls); +} + +async function boot( + opts: { + refuseHashWrites?: boolean; + /** An engine whose `sys_share_link` reads do NOT strip the hash column. */ + leakyReads?: boolean; + serviceOptions?: Partial[0]>; + } = {}, +) { + const engine = new ObjectQL(); + engines.push(engine); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + true, + ); + await engine.init(); + for (const o of [SysShareLink, TARGET, CONVERSATIONS, MESSAGES]) { + engine.registry.registerObject(o as never, '@objectstack/plugin-sharing'); + } + await engine.syncSchemas(); + const sys = { context: { isSystem: true } }; + await (engine as any).insert('pin_doc', { id: 'doc_1', title: 'Shared doc' }, sys); + await (engine as any).insert('ai_conversations', { id: 'conv_1', title: 'Chat' }, sys); + await (engine as any).insert( + 'ai_messages', + { id: 'msg_1', conversation_id: 'conv_1', content: 'hello', created_at: '2026-01-01T00:00:00Z' }, + sys, + ); + + // The engine the service is handed: the real one, optionally refusing any + // write of the password hash column (the upgrade write), or optionally + // handing the hash column back on every `sys_share_link` read (an engine + // without the `internal` strip — the case the service's own exit projection + // exists for). + const served: any = opts.refuseHashWrites || opts.leakyReads + ? new Proxy(engine as any, { + get(target, prop, receiver) { + if (prop === 'find' && opts.leakyReads) { + return async (object: string, q: unknown) => { + const rows = await target.find(object, q); + if (object !== 'sys_share_link' || !Array.isArray(rows)) return rows; + return Promise.all( + rows.map(async (r: Row) => ({ ...r, password_hash: await storedHash(target, String(r.id)) })), + ); + }; + } + if (prop === 'update' && opts.refuseHashWrites) { + return async (object: string, data: Row, o: unknown) => { + if (object === 'sys_share_link' && 'password_hash' in (data ?? {})) { + const err: any = new Error('storage refused the write'); + err.code = 'STORAGE_REFUSED'; + throw err; + } + return target.update(object, data, o); + }; + } + const v = Reflect.get(target, prop, receiver); + return typeof v === 'function' ? v.bind(target) : v; + }, + }) + : engine; + + const logger = makeLogger(); + const service = new ShareLinkService({ + engine: served as SharingEngine, + logger, + ...(opts.serviceOptions ?? {}), + }); + const http = new MockHttp(); + registerShareLinkRoutes(http, service, served as SharingEngine, { + contextFromRequest: () => ({ ...CREATOR }), + }); + return { engine: engine as any, service, http, logger }; +} + +async function storedHash(engine: any, id: string): Promise { + const found = await engine.getDriver('sys_share_link').find('sys_share_link', { where: { id } }); + const rows = (Array.isArray(found) ? found : [found]).filter(Boolean) as Row[]; + const v = rows[0]?.password_hash; + return typeof v === 'string' ? v : null; +} + +async function setStoredHash(engine: any, id: string, hash: string): Promise { + await engine.getDriver('sys_share_link').update('sys_share_link', id, { password_hash: hash }); +} + +function legacySha256(password: string, salt = 'legacySalt123456'): string { + return `sha256$${salt}$${createHash('sha256').update(`${salt}:${password}`, 'utf8').digest('hex')}`; +} + +function legacyPlaintext(password: string, salt = 'legacySalt123456'): string { + return `weak$${salt}$${password}`; +} + +/** Assert a serialised exit carries neither the hash key nor any piece of the stored hash. */ +function expectNoHash(value: unknown, hash: string, label: string): void { + const text = JSON.stringify(value); + expect(text, `${label}: no password_hash key`).not.toContain('password_hash'); + for (const piece of hash.split('$').filter((p) => p.length >= 16)) { + expect(text, `${label}: no part of the stored hash`).not.toContain(piece); + } +} + +describe('[#21839] the stored hash never leaves the server', () => { + it('the mint response, through the service and through the route, carries no hash', async () => { + const { engine, service, http } = await boot(); + + const minted = await service.createLink({ object: 'pin_doc', recordId: 'doc_1', password: PASSWORD }, CREATOR); + const stored = await storedHash(engine, minted.id); + expect(stored, 'a hash is stored').toBeTruthy(); + expect(minted.token, 'the creator still gets the token').toBeTruthy(); + expect(minted).not.toHaveProperty('password_hash'); + expectNoHash(minted, stored!, 'service createLink'); + + const res = await drive(http, `POST ${B}`, { body: { object: 'pin_doc', recordId: 'doc_1', password: PASSWORD } }); + expect(res.status).toBe(201); + expect(res.body?.data?.token).toBeTruthy(); + expect(res.body?.data).not.toHaveProperty('password_hash'); + expectNoHash(res.body, (await storedHash(engine, res.body.data.id))!, 'POST route'); + }); + + it('the list and the redemption result, through the service and through the route, carry no hash', async () => { + const { engine, service, http } = await boot(); + const minted = await service.createLink({ object: 'pin_doc', recordId: 'doc_1', password: PASSWORD }, CREATOR); + const stored = (await storedHash(engine, minted.id))!; + + const listed = await service.listLinks({ createdBy: CREATOR.userId }, CREATOR); + expect(listed).toHaveLength(1); + expectNoHash(listed, stored, 'service listLinks'); + expectNoHash((await drive(http, `GET ${B}`)).body, stored, 'GET list route'); + + const resolved = await service.resolveToken(minted.token, { providedPassword: PASSWORD }); + expect(resolved).not.toBeNull(); + expectNoHash(resolved, stored, 'service resolveToken'); + const viaRoute = await drive(http, `GET ${B}/:token/resolve`, { + params: { token: minted.token }, + headers: { 'x-share-password': PASSWORD }, + }); + expect(viaRoute.status).toBe(200); + expectNoHash(viaRoute.body, stored, 'GET resolve route'); + }); + + it('the list and the redemption result carry no hash even from an engine that does not strip it', async () => { + const { engine, service } = await boot({ leakyReads: true }); + const minted = await service.createLink({ object: 'pin_doc', recordId: 'doc_1', password: PASSWORD }, CREATOR); + const stored = (await storedHash(engine, minted.id))!; + + // Control: the wired engine really hands the hash back, so the absence + // below is the service's projection and not the engine's strip. + const raw = await (service as unknown as { engine: SharingEngine }).engine.find('sys_share_link', { + where: { id: minted.id }, + context: { isSystem: true }, + } as never); + expect((raw as Row[])[0]?.password_hash, 'the leaky engine returns the hash').toBe(stored); + + expectNoHash(await service.listLinks({ createdBy: CREATOR.userId }, CREATOR), stored, 'service listLinks'); + const resolved = await service.resolveToken(minted.token, { providedPassword: PASSWORD }); + expect(resolved, 'the password still verifies through the leaky read').not.toBeNull(); + expectNoHash(resolved, stored, 'service resolveToken'); + }); +}); + +describe('[#21839] the stored form is the platform slow password hash', () => { + it('a new link stores scrypt, with no plaintext, and two hashes of one password differ', async () => { + const { engine, service } = await boot(); + const a = await service.createLink({ object: 'pin_doc', recordId: 'doc_1', password: PASSWORD }, CREATOR); + const b = await service.createLink({ object: 'pin_doc', recordId: 'doc_1', password: PASSWORD }, CREATOR); + const ha = (await storedHash(engine, a.id))!; + const hb = (await storedHash(engine, b.id))!; + expect(ha).toMatch(/^scrypt\$[0-9a-f]{32}\$[0-9a-f]{128}$/); + expect(ha).not.toContain(PASSWORD); + expect(isLegacyShareLinkPasswordHash(ha)).toBe(false); + expect(ha, 'a per-row salt').not.toBe(hb); + }); + + it('the verifier accepts the right password and refuses a wrong one, an empty one and an unknown form', async () => { + const hash = await hashShareLinkPassword(PASSWORD); + expect(await verifyShareLinkPassword(PASSWORD, hash)).toBe(true); + expect(await verifyShareLinkPassword(`${PASSWORD} `, hash)).toBe(false); + expect(await verifyShareLinkPassword('', hash)).toBe(false); + expect(await verifyShareLinkPassword(PASSWORD, `argon2$${hash.slice('scrypt$'.length)}`)).toBe(false); + expect(await verifyShareLinkPassword(PASSWORD, 'scrypt$nosalt')).toBe(false); + }); +}); + +describe.each([ + ['salted SHA-256', legacySha256], + ['plaintext (no SubtleCrypto)', legacyPlaintext], +] as const)('[#21839] a legacy %s row', (_name, legacy) => { + it('still verifies, refuses a wrong password without touching the row, and is upgraded by the right one', async () => { + const { engine, service } = await boot(); + const link = await service.createLink({ object: 'pin_doc', recordId: 'doc_1', password: 'placeholder' }, CREATOR); + const legacyHash = legacy(PASSWORD); + await setStoredHash(engine, link.id, legacyHash); + expect(isLegacyShareLinkPasswordHash(legacyHash)).toBe(true); + + expect(await service.resolveToken(link.token, { providedPassword: 'not it' }), 'wrong password').toBeNull(); + expect(await service.resolveToken(link.token), 'no password').toBeNull(); + expect(await storedHash(engine, link.id), 'a refused attempt writes nothing').toBe(legacyHash); + + const opened = await service.resolveToken(link.token, { providedPassword: PASSWORD }); + expect(opened, 'the legacy row still opens with its password').not.toBeNull(); + + const upgraded = (await storedHash(engine, link.id))!; + expect(upgraded, 'upgraded into the current form').toMatch(/^scrypt\$/); + expect(await verifyShareLinkPassword(PASSWORD, upgraded), 'the upgraded hash verifies the same password').toBe(true); + expect(await service.resolveToken(link.token, { providedPassword: PASSWORD }), 'and the link keeps working').not.toBeNull(); + expect(await service.resolveToken(link.token, { providedPassword: 'not it' }), 'still refuses a wrong one').toBeNull(); + }); +}); + +describe('[#21839] the legacy upgrade', () => { + it('is not attempted for a switched-off object, whose redemption is refused', async () => { + const { engine, service } = await boot(); + const link = await service.createLink({ object: 'pin_doc', recordId: 'doc_1', password: 'placeholder' }, CREATOR); + const legacyHash = legacySha256(PASSWORD); + await setStoredHash(engine, link.id, legacyHash); + const schema = engine.getSchema('pin_doc'); + const original = schema.publicSharing; + schema.publicSharing = { ...original, enabled: false }; + try { + expect(await service.resolveToken(link.token, { providedPassword: PASSWORD })).toBeNull(); + } finally { + schema.publicSharing = original; + } + expect(await storedHash(engine, link.id), 'no write on a refused redemption').toBe(legacyHash); + }); + + it('a refused upgrade write does not block the read, leaves the legacy form verifying, and is reported once without the password', async () => { + const { engine, service, logger } = await boot({ refuseHashWrites: true }); + const link = await service.createLink({ object: 'pin_doc', recordId: 'doc_1', password: 'placeholder' }, CREATOR); + const legacyHash = legacySha256(PASSWORD); + await setStoredHash(engine, link.id, legacyHash); + + expect(await service.resolveToken(link.token, { providedPassword: PASSWORD })).not.toBeNull(); + expect(await service.resolveToken(link.token, { providedPassword: PASSWORD })).not.toBeNull(); + expect(await storedHash(engine, link.id)).toBe(legacyHash); + + const reports = logger.error.mock.calls.filter((c) => String(c[0]).includes('legacy password hash upgrade REFUSED')); + expect(reports, 'reported once per service instance').toHaveLength(1); + expect(reports[0]![1]).toMatchObject({ link: link.id, reason: 'STORAGE_REFUSED' }); + expect(loggedText(logger)).not.toContain(PASSWORD); + expect(loggedText(logger)).not.toContain(legacyHash.split('$')[2]); + }); + + it('is not attempted when the deployment injects its own hasher pair', async () => { + const { engine, service } = await boot({ + serviceOptions: { + hashPassword: async (p: string) => `custom$${p.length}`, + verifyPassword: async (p: string, h: string) => h === legacySha256(PASSWORD) && p === PASSWORD, + }, + }); + const link = await service.createLink({ object: 'pin_doc', recordId: 'doc_1', password: 'placeholder' }, CREATOR); + const legacyHash = legacySha256(PASSWORD); + await setStoredHash(engine, link.id, legacyHash); + expect(await service.resolveToken(link.token, { providedPassword: PASSWORD })).not.toBeNull(); + expect(await storedHash(engine, link.id), 'an injected pair owns its stored forms').toBe(legacyHash); + }); +}); + +describe('[#21839] how the password travels in', () => { + async function protectedConversation() { + const booted = await boot(); + const link = await booted.service.createLink( + { object: 'ai_conversations', recordId: 'conv_1', password: PASSWORD }, + CREATOR, + ); + return { ...booted, link }; + } + + it.each(['resolve', 'messages'] as const)('/%s accepts the x-share-password header', async (route) => { + const { http, link } = await protectedConversation(); + const res = await drive(http, `GET ${B}/:token/${route}`, { + params: { token: link.token }, + headers: { 'x-share-password': PASSWORD }, + }); + expect(res.status).toBe(200); + expect(res.body?.success).toBe(true); + if (route === 'messages') expect(res.body?.data?.map((m: Row) => m.id)).toEqual(['msg_1']); + else expect(res.body?.data?.record?.id).toBe('conv_1'); + }); + + it.each(['resolve', 'messages'] as const)('/%s still accepts the ?password= query parameter', async (route) => { + const { http, link } = await protectedConversation(); + const res = await drive(http, `GET ${B}/:token/${route}`, { + params: { token: link.token }, + query: { password: PASSWORD }, + }); + expect(res.status).toBe(200); + expect(res.body?.success).toBe(true); + }); + + it.each([ + ['resolve', 'header', 401, 'WRONG_PASSWORD'], + ['resolve', 'query', 401, 'WRONG_PASSWORD'], + ['messages', 'header', 404, 'NOT_FOUND'], + ['messages', 'query', 404, 'NOT_FOUND'], + ] as const)('/%s refuses a wrong password sent as a %s', async (route, form, status, code) => { + const { http, link, logger } = await protectedConversation(); + const wrong = 'not the password 21839'; + const res = await drive(http, `GET ${B}/:token/${route}`, { + params: { token: link.token }, + ...(form === 'header' ? { headers: { 'x-share-password': wrong } } : { query: { password: wrong } }), + }); + expect(res.status).toBe(status); + expect(res.body?.success).toBe(false); + expect(res.body?.error?.code).toBe(code); + expect(JSON.stringify(res.body)).not.toContain(wrong); + expect(loggedText(logger)).not.toContain(wrong); + }); + + it('no log line carries the presented password, on any outcome', async () => { + const { http, link, logger } = await protectedConversation(); + for (const pw of [PASSWORD, 'wrong one 21839']) { + for (const route of ['resolve', 'messages']) { + await drive(http, `GET ${B}/:token/${route}`, { params: { token: link.token }, headers: { 'x-share-password': pw } }); + await drive(http, `GET ${B}/:token/${route}`, { params: { token: link.token }, query: { password: pw } }); + } + } + expect(loggedText(logger)).not.toContain(PASSWORD); + expect(loggedText(logger)).not.toContain('wrong one 21839'); + }); +}); + +describe('[#21839] the public routes are never cached', () => { + function expectNoStore(res: { headers: Record }, label: string) { + expect(res.headers['Cache-Control'], label).toBe('no-store'); + expect(res.headers.Vary, label).toBe('X-Share-Password'); + } + + it.each(['resolve', 'messages'] as const)('/%s sends no-store + Vary on success and on every refusal', async (route) => { + const booted = await boot(); + const link = await booted.service.createLink( + { object: 'ai_conversations', recordId: 'conv_1', password: PASSWORD }, + CREATOR, + ); + const key = `GET ${B}/:token/${route}`; + const ok = await drive(booted.http, key, { params: { token: link.token }, headers: { 'x-share-password': PASSWORD } }); + expect(ok.status).toBe(200); + expectNoStore(ok, 'success'); + + const bare = await drive(booted.http, key, { params: { token: link.token } }); + expect(bare.status).not.toBe(200); + expectNoStore(bare, 'no password'); + + const wrong = await drive(booted.http, key, { params: { token: link.token }, query: { password: 'nope 21839' } }); + expect(wrong.status).not.toBe(200); + expectNoStore(wrong, 'wrong password'); + + const unknown = await drive(booted.http, key, { params: { token: 'no-such-token-21839' } }); + expect(unknown.status).toBe(404); + expectNoStore(unknown, 'unknown token'); + }); + + it('the authenticated list route is not given the public headers', async () => { + const { http } = await boot(); + const res = await drive(http, `GET ${B}`); + expect(res.headers['Cache-Control']).toBeUndefined(); + expect(res.headers.Vary).toBeUndefined(); + }); +}); diff --git a/packages/plugins/plugin-sharing/src/share-link-password.ts b/packages/plugins/plugin-sharing/src/share-link-password.ts new file mode 100644 index 00000000000..de6486d6c29 --- /dev/null +++ b/packages/plugins/plugin-sharing/src/share-link-password.ts @@ -0,0 +1,171 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The share-link password's stored form — hashing, verification, and the + * legacy forms that still verify until their next successful redemption. + * + * ## The current form is the platform's password hash + * + * `scrypt$SALT_HEX$KEY_HEX`, with the parameters the platform's account + * passwords use (better-auth's hasher, and the pure-JS twin `plugin-auth` + * carries for WebContainer): N=16384, r=16, p=1, a 64-byte key, a 16-byte + * random salt passed as its hex string, and the password NFKC-normalised. + * Same algorithm and parameters, so a share-link password is exactly as + * expensive to brute-force from a database dump as a sign-in password. + * + * ## Two implementations, one hash + * + * On Node the key is derived by `node:crypto`'s scrypt. WebContainer + * (StackBlitz) reports itself as Node but polyfills `node:crypto.scrypt` + * incompletely, so there — detected exactly as `plugin-auth`'s + * `isWebContainerRuntime()` detects it — the key is derived by + * `@noble/hashes/scrypt` instead, the same pure-JS scrypt `plugin-auth` swaps + * in for account passwords on that host. Same parameters, same salt input, + * same output bytes: a hash minted by either verifies under the other, so a + * link created in a WebContainer keeps working when the app is deployed. The + * pure-JS module is loaded only on that host; elsewhere it is never imported. + * + * ## The legacy forms, and why they are upgraded on read rather than migrated + * + * Rows minted before this module stored either `sha256$SALT$HEX` (one salted + * SHA-256 — fast to brute-force) or, on a runtime without SubtleCrypto, + * `weak$SALT$PLAINTEXT`. Neither can be converted without the password, which + * the server only sees when a holder presents it. So both still VERIFY, and + * `ShareLinkService.resolveToken` re-hashes into the current form on the first + * successful redemption ({@link isLegacyShareLinkPasswordHash}); nobody's link + * breaks, and a protected link stops carrying the weak form the first time it + * is used. + * + * ## Every comparison is constant-time + * + * `timingSafeEqual` over equal-length buffers. The plaintext legacy form is + * compared through a SHA-256 of both sides first, so neither its content nor + * its length is read off the comparison's timing. + */ + +import { createHash, randomBytes, scrypt, timingSafeEqual } from 'node:crypto'; + +/** The platform's account-password scrypt parameters (better-auth's). */ +const SCRYPT_N = 16384; +const SCRYPT_R = 16; +const SCRYPT_P = 1; +const SCRYPT_KEY_BYTES = 64; +const SCRYPT_SALT_BYTES = 16; +/** Headroom over the 128·N·r = 32 MiB the parameters need (node's default cap is 32 MiB). */ +const SCRYPT_MAXMEM = 128 * SCRYPT_N * SCRYPT_R * 2; + +const CURRENT_PREFIX = 'scrypt$'; +const LEGACY_SHA256_PREFIX = 'sha256$'; +const LEGACY_PLAINTEXT_PREFIX = 'weak$'; + +/** + * WebContainer (StackBlitz) detection — the same three signals `plugin-auth`'s + * `isWebContainerRuntime()` and `service-settings`' local crypto provider read. + */ +function isWebContainerRuntime(): boolean { + const proc = (globalThis as { process?: { versions?: Record; env?: Record } }) + .process; + return ( + Boolean(proc?.versions?.webcontainer) || + (typeof proc?.env?.SHELL === 'string' && proc.env.SHELL.includes('jsh')) || + Boolean(proc?.env?.STACKBLITZ) + ); +} + +/** The pure-JS scrypt, with exactly the parameters {@link deriveKeyNode} passes. */ +async function deriveKeyPureJs(password: string, saltHex: string): Promise { + const { scryptAsync } = await import('@noble/hashes/scrypt.js'); + const key = await scryptAsync(password.normalize('NFKC'), saltHex, { + N: SCRYPT_N, + r: SCRYPT_R, + p: SCRYPT_P, + dkLen: SCRYPT_KEY_BYTES, + maxmem: SCRYPT_MAXMEM, + }); + return Buffer.from(key); +} + +function deriveKey(password: string, saltHex: string): Promise { + return isWebContainerRuntime() ? deriveKeyPureJs(password, saltHex) : deriveKeyNode(password, saltHex); +} + +function deriveKeyNode(password: string, saltHex: string): Promise { + return new Promise((resolve, reject) => { + scrypt( + password.normalize('NFKC'), + saltHex, + SCRYPT_KEY_BYTES, + { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P, maxmem: SCRYPT_MAXMEM }, + (err, key) => (err ? reject(err) : resolve(key)), + ); + }); +} + +/** Constant-time equality of two hex strings of the expected byte length. */ +function hexEqual(actualHex: string, expectedHex: string, bytes: number): boolean { + if (!/^[0-9a-f]+$/i.test(expectedHex) || expectedHex.length !== bytes * 2) return false; + const a = Buffer.from(actualHex, 'hex'); + const b = Buffer.from(expectedHex, 'hex'); + return a.length === b.length && timingSafeEqual(a, b); +} + +/** Split `PREFIX` + `SALT$REST`, where REST may itself contain `$`. */ +function splitSaltAndRest(hash: string, prefix: string): [string, string] | null { + const body = hash.slice(prefix.length); + const cut = body.indexOf('$'); + if (cut <= 0) return null; + return [body.slice(0, cut), body.slice(cut + 1)]; +} + +/** Hash a share-link password into the current stored form. */ +export async function hashShareLinkPassword(password: string): Promise { + const saltHex = randomBytes(SCRYPT_SALT_BYTES).toString('hex'); + const key = await deriveKey(password, saltHex); + return `${CURRENT_PREFIX}${saltHex}$${key.toString('hex')}`; +} + +/** + * Verify a presented password against a stored hash in the current form or + * either legacy form. An unrecognised form verifies nothing. + */ +export async function verifyShareLinkPassword(password: string, hash: string): Promise { + if (typeof password !== 'string' || typeof hash !== 'string') return false; + + if (hash.startsWith(CURRENT_PREFIX)) { + const parts = splitSaltAndRest(hash, CURRENT_PREFIX); + if (!parts) return false; + const [saltHex, keyHex] = parts; + const key = await deriveKey(password, saltHex); + return hexEqual(key.toString('hex'), keyHex, SCRYPT_KEY_BYTES); + } + + if (hash.startsWith(LEGACY_SHA256_PREFIX)) { + const parts = splitSaltAndRest(hash, LEGACY_SHA256_PREFIX); + if (!parts) return false; + const [salt, expectedHex] = parts; + const actualHex = createHash('sha256').update(`${salt}:${password}`, 'utf8').digest('hex'); + return hexEqual(actualHex, expectedHex, 32); + } + + if (hash.startsWith(LEGACY_PLAINTEXT_PREFIX)) { + const parts = splitSaltAndRest(hash, LEGACY_PLAINTEXT_PREFIX); + if (!parts) return false; + const [, stored] = parts; + const a = createHash('sha256').update(password, 'utf8').digest(); + const b = createHash('sha256').update(stored, 'utf8').digest(); + return timingSafeEqual(a, b); + } + + return false; +} + +/** + * True when a stored hash is in a legacy form this module still verifies but + * no longer writes — the cue to re-hash after a successful verification. + */ +export function isLegacyShareLinkPasswordHash(hash: unknown): boolean { + return ( + typeof hash === 'string' && + (hash.startsWith(LEGACY_SHA256_PREFIX) || hash.startsWith(LEGACY_PLAINTEXT_PREFIX)) + ); +} diff --git a/packages/plugins/plugin-sharing/src/share-link-routes.ts b/packages/plugins/plugin-sharing/src/share-link-routes.ts index ff74c32f77f..700da3cacf0 100644 --- a/packages/plugins/plugin-sharing/src/share-link-routes.ts +++ b/packages/plugins/plugin-sharing/src/share-link-routes.ts @@ -30,7 +30,7 @@ * and renders the response read-only. */ -import type { IHttpServer, IHttpRequest, RouteHandler } from '@objectstack/spec/contracts'; +import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; // The declared envelope is written in ONE place for the whole platform (#3973). import { sendOk, sendError } from '@objectstack/types'; import type { ShareLinkExecutionContext } from '@objectstack/spec/contracts'; @@ -124,6 +124,52 @@ function isAuthenticated(ctx: ShareLinkExecutionContext): boolean { * fleet. Prime Directive #12: the shim goes once the producer agrees. */ +/** + * [#21839] The password a share-link holder presented, read the ONE way both + * public routes (`/resolve` and `/messages`) read it. + * + * The `x-share-password` request header is the preferred form: a header is + * not part of the request URL, so it stays out of browser history, referrers + * and any access log that records URLs. The `?password=` query parameter is + * still accepted for compatibility with clients that send it today, and is + * read first when present, exactly as the dispatcher twin + * (`runtime/src/domains/share-links.ts`) reads it — two mounts of the same + * routes must not answer the same request differently. `/messages` read the + * query parameter alone until this helper; it now takes the header too, as + * its twin always has. + * + * Nothing on this path logs either form: the routes write no log line, and + * the service's log lines name the link, never the presented password. + */ +function presentedPassword(req: IHttpRequest): string | undefined { + const q: any = req.query ?? {}; + if (typeof q.password === 'string') return q.password; + const v = req.headers?.['x-share-password']; + const header = Array.isArray(v) ? v[0] : v; + return typeof header === 'string' ? header : undefined; +} + +/** + * [#21839] Response headers both public routes (`/resolve` and `/messages`) + * answer with, on every outcome. + * + * `Cache-Control: no-store` — the body is a record released by a capability + * token (and, for a protected link, by a password); no browser or shared cache + * may keep a copy of it, nor of a refusal that would be replayed after the + * link changes. `Vary: X-Share-Password` — the answer depends on that request + * header, so any cache that does not honour `no-store` must at least never + * serve one presenter's answer to another. The dispatcher twin + * (`runtime/src/domains/share-links.ts`) sends the same pair. + */ +const SHARE_LINK_PUBLIC_RESPONSE_HEADERS: Readonly> = Object.freeze({ + 'Cache-Control': 'no-store', + Vary: 'X-Share-Password', +}); + +function setPublicResponseHeaders(res: IHttpResponse): void { + for (const [name, value] of Object.entries(SHARE_LINK_PUBLIC_RESPONSE_HEADERS)) res.header(name, value); +} + /** Strip `redactFields` from a record (also removes from nested arrays of objects). */ function applyRedaction(record: any, redactFields: string[]): any { if (!record || typeof record !== 'object' || redactFields.length === 0) return record; @@ -223,6 +269,7 @@ export function registerShareLinkRoutes( // No `ctxOf` here — the token IS the authorisation. We still allow // probes from a signed-in user so audience=signed_in is satisfiable. http.get(`${base}/:token/resolve`, (async (req, res) => { + setPublicResponseHeaders(res); try { const q = req.query ?? {}; // [Finding-2] The `audience: 'signed_in'` gate must key off the VERIFIED @@ -230,13 +277,7 @@ export function registerShareLinkRoutes( // the "must be signed in" check by inventing a user id. const signedInUserId = (await ctxOf(req)).userId; const recipientEmail = typeof q.email === 'string' ? q.email : undefined; - const providedPassword = - typeof q.password === 'string' - ? q.password - : (() => { - const v = req.headers?.['x-share-password']; - return Array.isArray(v) ? v[0] : v; - })(); + const providedPassword = presentedPassword(req); const resolved = await service.resolveToken(req.params.token, { signedInUserId, @@ -362,10 +403,11 @@ export function registerShareLinkRoutes( // following the same pattern. // ────────────────────────────────────────────────────────────── http.get(`${base}/:token/messages`, (async (req, res) => { + setPublicResponseHeaders(res); try { - const password = - typeof req.query?.password === 'string' ? (req.query.password as string) : undefined; - const resolved = await service.resolveToken(req.params.token, { providedPassword: password }); + const resolved = await service.resolveToken(req.params.token, { + providedPassword: presentedPassword(req), + }); if (!resolved) { sendError(res, 404, 'NOT_FOUND', 'Share link not found'); return; diff --git a/packages/plugins/plugin-sharing/src/share-link-service.ts b/packages/plugins/plugin-sharing/src/share-link-service.ts index 288e11c2eb5..6b680a6cb8b 100644 --- a/packages/plugins/plugin-sharing/src/share-link-service.ts +++ b/packages/plugins/plugin-sharing/src/share-link-service.ts @@ -45,6 +45,11 @@ import { materializeDeclaredFields, readInternalColumn } from '@objectstack/obje // — the same definition `getPolicy` below has always used. import { isPublicSharingEnabled } from '@objectstack/spec/data'; import type { SharingEngine } from './sharing-service.js'; +import { + hashShareLinkPassword, + verifyShareLinkPassword, + isLegacyShareLinkPasswordHash, +} from './share-link-password.js'; import { deleteRowsForDeletedRecords, sweepOrphanedRowsByRecordExistence, @@ -197,50 +202,20 @@ function normaliseExpiresAt(input: string | null | undefined, maxDays: number): } /** - * Weak password hash. Production deployments should swap in argon2 / - * bcrypt via dependency injection (see `ShareLinkServiceOptions.hashPassword`). - * The default uses SubtleCrypto SHA-256 with a per-row salt — strong - * enough to keep the hash useless to a casual observer and to deflate - * the cost of a database leak, but NOT a substitute for argon2 against - * a determined attacker. The platform deliberately surfaces this in the - * plugin docs so deployments can decide. + * [#21839] The one exit projection for a share-link row: every copy that + * leaves this service drops `password_hash`. The stored hash is needed by + * exactly one reader — the verification inside `resolveToken`, which recovers + * it into a local — and by no caller: not the creator (who chose the password), + * not the list, not the redemption result. `createLink` built its return value + * from the row it inserted, so the hash went back to the creator in the mint + * response; the list and the redemption result already came from engine reads + * that strip the `internal` column, and pass through here too so the + * guarantee does not rest on which engine is wired. */ -async function defaultHashPassword(password: string): Promise { - const g: any = globalThis as any; - const subtle = g.crypto?.subtle; - const salt = generateToken(16); - if (!subtle) { - // Synthetic fallback — no SubtleCrypto means we're in a stripped - // runtime; emit a clearly-marked placeholder so the deployment is - // forced to wire in a real hasher rather than ship a weak one. - return `weak$${salt}$${password}`; - } - const enc = new TextEncoder(); - const buf = await subtle.digest('SHA-256', enc.encode(salt + ':' + password)); - const hex = Array.from(new Uint8Array(buf)) - .map((b) => b.toString(16).padStart(2, '0')) - .join(''); - return `sha256$${salt}$${hex}`; -} - -async function defaultVerifyPassword(password: string, hash: string): Promise { - if (hash.startsWith('weak$')) { - const [, , stored] = hash.split('$'); - return stored === password; - } - if (hash.startsWith('sha256$')) { - const [, salt, expected] = hash.split('$'); - const g: any = globalThis as any; - const subtle = g.crypto?.subtle; - if (!subtle) return false; - const enc = new TextEncoder(); - const buf = await subtle.digest('SHA-256', enc.encode(salt + ':' + password)); - const hex = Array.from(new Uint8Array(buf)) - .map((b) => b.toString(16).padStart(2, '0')) - .join(''); - return hex === expected; - } - return false; +function withoutPasswordHash(link: T): T { + if (!link || typeof link !== 'object' || !('password_hash' in link)) return link; + const { password_hash: _omitted, ...rest } = link; + return rest as T; } /** @@ -396,9 +371,19 @@ function isLinkCreator( export interface ShareLinkServiceOptions { engine: SharingEngine; - /** Override the default SHA-256 hasher with argon2 / bcrypt for production. */ + /** + * Override the default password hasher. The default is the platform's + * password hash — scrypt with the account-password parameters + * (`share-link-password.ts`) — so production needs no override. + */ hashPassword?: (plain: string) => Promise; - /** Companion verifier — must accept hashes produced by `hashPassword`. */ + /** + * Companion verifier — must accept hashes produced by `hashPassword`. + * + * [#21839] Legacy stored forms are re-hashed on a successful redemption + * only while BOTH members are the defaults: an injected pair owns its own + * stored forms, and this service cannot tell which of them are legacy. + */ verifyPassword?: (plain: string, hash: string) => Promise; /** * Bypass the per-object opt-in check at MINT (useful when the schema scan @@ -503,12 +488,17 @@ export class ShareLinkService implements IShareLinkService { * is made once, not once per refused write (the rule's own words). */ private usageStampRefusalReported = false; + /** [#21839] Both password members are the defaults — legacy forms may be upgraded. */ + private readonly upgradesLegacyPasswordHashes: boolean; + /** [#21839] Latched by the first refused legacy-hash upgrade; reported once. */ + private passwordUpgradeRefusalReported = false; constructor(opts: ShareLinkServiceOptions) { this.engine = opts.engine; this.permissive = opts.permissive ?? false; - this.hashPassword = opts.hashPassword ?? defaultHashPassword; - this.verifyPassword = opts.verifyPassword ?? defaultVerifyPassword; + this.hashPassword = opts.hashPassword ?? hashShareLinkPassword; + this.verifyPassword = opts.verifyPassword ?? verifyShareLinkPassword; + this.upgradesLegacyPasswordHashes = !opts.hashPassword && !opts.verifyPassword; this.canManageShares = opts.canManageShares; this.canMintWithoutVisibility = opts.canMintWithoutVisibility; this.logger = opts.logger; @@ -673,7 +663,8 @@ export class ShareLinkService implements IShareLinkService { }; await this.engine.insert('sys_share_link', row, { context: SYSTEM_CTX }); - return row; + // [#21839] The row as stored carries the hash; the mint response does not. + return withoutPasswordHash(row); } async revokeLink(idOrToken: string, context: ExecutionContext): Promise { @@ -776,7 +767,9 @@ export class ShareLinkService implements IShareLinkService { links.forEach((link, i) => { if (typeof tokens[i] === 'string') link.token = tokens[i] as string; }); - return links; + // [#21839] The engine strips the hash already; the exit projection holds it + // whichever engine is wired. + return links.map(withoutPasswordHash); } async resolveToken( @@ -820,10 +813,17 @@ export class ShareLinkService implements IShareLinkService { [row as unknown as Record], 'password_hash', ); + // [#21839] A legacy stored form that just verified is re-hashed into the + // current one — but only once every later gate has passed (below), so a + // switched-off, gone or ineligible link takes no write. + let upgradePasswordFrom: string | undefined; if (passwordHash) { if (!probe.providedPassword) return null; const ok = await this.verifyPassword(probe.providedPassword, String(passwordHash)); if (!ok) return null; + if (this.upgradesLegacyPasswordHashes && isLegacyShareLinkPasswordHash(passwordHash)) { + upgradePasswordFrom = probe.providedPassword; + } } // [commit fc9ba76a5] The object's policy is read HERE, before the record probe, @@ -955,6 +955,10 @@ export class ShareLinkService implements IShareLinkService { new Set([...(policy.redactFields ?? []), ...((row.redact_fields as string[]) ?? [])]), ); + if (upgradePasswordFrom !== undefined) { + await this.upgradeLegacyPasswordHash(row, upgradePasswordFrom); + } + // 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 @@ -973,7 +977,50 @@ export class ShareLinkService implements IShareLinkService { this.reportUsageStampRefusal(row, err); } - return { link: row, redactFields }; + return { link: withoutPasswordHash(row), redactFields }; + } + + /** + * [#21839] Re-hash a link's password from a legacy stored form into the + * current one, after `resolveToken` verified the presented password against + * it. The plaintext is the presented one, so the new hash verifies the same + * password the old one did — the link keeps working, with the weak form gone. + * + * A refusal does not block the read (the holder proved the password and is + * owed the record), and the legacy hash it leaves behind still verifies. But + * the upgrade is a claimed persistence — the stored form was meant to stop + * being the weak one — so a refusal is reported at `error`, once per service + * instance, the way `reportUsageStampRefusal` reports the usage stamp. The + * report names the link, never the password or either hash. + */ + private async upgradeLegacyPasswordHash(row: ShareLink, password: string): Promise { + try { + const upgraded = await this.hashPassword(password); + await this.engine.update( + 'sys_share_link', + { id: row.id, password_hash: upgraded }, + { context: SYSTEM_CTX }, + ); + } catch (err) { + if (this.passwordUpgradeRefusalReported) return; + this.passwordUpgradeRefusalReported = true; + const cause = (err as { message?: unknown } | null | undefined)?.message ?? err; + const message = + '[share-link] legacy password hash upgrade REFUSED — a protected link still stores its password in the ' + + 'legacy fast-hash form. The link keeps resolving (the legacy form still verifies), so nothing looks ' + + 'broken, but the stored form is not the slow hash it should now be. Fix: resolve the storage refusal ' + + 'named as the cause (the `sys_share_link` table, the driver, or the system-context write path); the ' + + 'upgrade is retried on the link\'s next successful redemption. 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); + } } /** diff --git a/packages/runtime/src/domains/share-links-public-cache-headers.test.ts b/packages/runtime/src/domains/share-links-public-cache-headers.test.ts new file mode 100644 index 00000000000..5b9ac80d276 --- /dev/null +++ b/packages/runtime/src/domains/share-links-public-cache-headers.test.ts @@ -0,0 +1,173 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21839] The dispatcher's two PUBLIC share-link routes answer with + * `Cache-Control: no-store` and `Vary: X-Share-Password` on every outcome, and + * the authenticated routes are not touched. + * + * Driven over a REAL `ObjectQL` + `@objectstack/driver-sql` + better-sqlite3, + * the same harness `share-links-internal-hash-probe.test.ts` uses, so every + * answer below is the one production builds. The plugin-sharing mount pins the + * same pair in `plugin-sharing/src/share-link-password.test.ts`. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { SHARE_LINK_SERVICE } from '@objectstack/spec/contracts'; +import { ShareLinkService, SysShareLink } from '@objectstack/plugin-sharing'; +import { apiErrorResponse } from '../error-envelope.js'; +import { HttpDispatcher } from '../http-dispatcher.js'; +import type { DomainHandlerDeps } from '../domain-handler-registry.js'; +import type { HttpProtocolContext } from '../http-dispatcher.js'; +import { handleShareLinksRequest } from './share-links.js'; + +const SHARED = 'pin_doc'; +const RECORD = 'doc_1'; +const PASSWORD = 'no store 21839'; + +const TARGET = { + name: SHARED, + label: 'Pinned Doc', + publicSharing: { enabled: true, allowedAudiences: ['link_only'], allowedPermissions: ['view'] }, + fields: { + id: { name: 'id', label: 'ID', type: 'text', primaryKey: true }, + title: { name: 'title', label: 'Title', type: 'text' }, + }, +}; + +const realErrorFromThrown = (() => { + const dispatcher: any = new HttpDispatcher({ context: { getService: () => null } } as any); + return (e: any, fallbackStatus?: number) => dispatcher.errorFromThrown(e, fallbackStatus); +})(); + +function makeDeps(engine: any, svc: any): DomainHandlerDeps { + const deps: any = { + resolveService: async (_c: any, name: string) => + name === SHARE_LINK_SERVICE ? svc : name === 'objectql' ? engine : undefined, + getRequestKernelService: async (_c: any, name: string) => (name === 'objectql' ? engine : undefined), + success: (data: any, meta?: any) => ({ status: 200, body: { success: true, data, ...(meta ? { meta } : {}) } }), + error: (message: string, httpStatus = 500, details?: any) => apiErrorResponse({ message, httpStatus, details }), + routeNotFound: (route: string) => apiErrorResponse({ message: `Route not found: ${route}`, httpStatus: 404 }), + errorFromThrown: realErrorFromThrown, + }; + return deps as DomainHandlerDeps; +} + +const engines: ObjectQL[] = []; +afterEach(async () => { + while (engines.length) { + try { + await engines.pop()!.destroy(); + } catch { + /* noop */ + } + } +}); + +type Answer = { status: number; body?: any; headers?: Record }; + +async function harness() { + const engine = new ObjectQL(); + engines.push(engine); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }) as any, + true, + ); + await engine.init(); + for (const o of [SysShareLink, TARGET]) engine.registry.registerObject(o as any, '@objectstack/runtime-test'); + await engine.syncSchemas(); + await engine.insert(SHARED, { id: RECORD, title: 'Shared doc' }, { context: { isSystem: true } } as any); + const svc = new ShareLinkService({ engine: engine as any }); + const deps = makeDeps(engine, svc); + const call = async ( + subPath: string, + method: string, + query: Record = {}, + executionContext?: Record, + ): Promise => { + const res = await handleShareLinksRequest(deps, subPath, method, undefined, query, { + executionContext, + } as unknown as HttpProtocolContext); + if (!res.handled || !res.response) throw new Error(`${method} ${subPath} was not handled`); + return res.response as Answer; + }; + const link = await svc.createLink( + { object: SHARED, recordId: RECORD, password: PASSWORD }, + { isSystem: true, userId: 'usr_creator' } as any, + ); + return { call, link }; +} + +function expectNoStore(answer: Answer, label: string) { + expect(answer.headers?.['Cache-Control'], label).toBe('no-store'); + expect(answer.headers?.Vary, label).toBe('X-Share-Password'); +} + +describe('[#21839] dispatcher public share-link routes are never cached', () => { + it('/resolve: success, every refusal, and an unknown token', async () => { + const { call, link } = await harness(); + + const right = await call(`/${link.token}/resolve`, 'GET', { password: PASSWORD }); + expect(right.status).toBe(200); + expectNoStore(right, 'resolved'); + + const bare = await call(`/${link.token}/resolve`, 'GET'); + expect(bare.body?.error?.code).toBe('NEEDS_PASSWORD'); + expectNoStore(bare, 'needs password'); + + const wrong = await call(`/${link.token}/resolve`, 'GET', { password: 'not it' }); + expect(wrong.body?.error?.code).toBe('WRONG_PASSWORD'); + expectNoStore(wrong, 'wrong password'); + + const unknown = await call('/no-such-token-21839/resolve', 'GET'); + expect(unknown.status).toBe(404); + expectNoStore(unknown, 'unknown token'); + }); + + it('/messages: a refusal and an unsupported object both carry the headers', async () => { + const { call, link } = await harness(); + + const refused = await call(`/${link.token}/messages`, 'GET'); + expect(refused.status).toBe(404); + expectNoStore(refused, 'refused'); + + const unsupported = await call(`/${link.token}/messages`, 'GET', { password: PASSWORD }); + expect(unsupported.status).toBe(400); + expect(unsupported.body?.error?.code).toBe('UNSUPPORTED'); + expectNoStore(unsupported, 'unsupported'); + }); + + it('the authenticated routes are not given the public headers', async () => { + const { call } = await harness(); + const list = await call('', 'GET', {}, { userId: 'usr_creator', isSystem: true }); + expect(list.headers?.['Cache-Control']).toBeUndefined(); + expect(list.headers?.Vary).toBeUndefined(); + }); +}); + +describe('[#21839] a throw outside the body try still carries the public headers', () => { + const throwingDeps = (): DomainHandlerDeps => + ({ + ...makeDeps(undefined, undefined), + resolveService: async () => { + throw Object.assign(new Error('service registry unavailable'), { status: 503 }); + }, + }) as unknown as DomainHandlerDeps; + + it('resolveService throwing on /resolve and /messages answers the mapped error with the headers', async () => { + for (const route of ['/tok_21839/resolve', '/tok_21839/messages']) { + const res = await handleShareLinksRequest(throwingDeps(), route, 'GET', undefined, {}, {} as HttpProtocolContext); + expect(res.handled, route).toBe(true); + const answer = res.response as Answer; + expect(answer.status, route).toBe(503); + expectNoStore(answer, route); + } + }); + + it('an authenticated route still propagates the throw unchanged', async () => { + await expect( + handleShareLinksRequest(throwingDeps(), '', 'GET', undefined, {}, {} as HttpProtocolContext), + ).rejects.toThrow('service registry unavailable'); + }); +}); diff --git a/packages/runtime/src/domains/share-links.ts b/packages/runtime/src/domains/share-links.ts index 23a1e261586..74298831652 100644 --- a/packages/runtime/src/domains/share-links.ts +++ b/packages/runtime/src/domains/share-links.ts @@ -67,6 +67,29 @@ export function createShareLinksDomain(deps: DomainHandlerDeps): DomainRoute { }; } +/** + * [#21839] Response headers the two public routes (`/:token/resolve` and + * `/:token/messages`) answer with, on every outcome — success, refusal, or a + * thrown error. `Cache-Control: no-store` keeps the token-released record (and + * any refusal) out of every browser and shared cache; `Vary: X-Share-Password` + * marks the answer as depending on that request header for any cache that does + * not honour `no-store`. The plugin-sharing mount + * (`plugin-sharing/src/share-link-routes.ts`) sends the same pair. + */ +const PUBLIC_RESPONSE_HEADERS: Readonly> = Object.freeze({ + 'Cache-Control': 'no-store', + Vary: 'X-Share-Password', +}); + +function isPublicShareLinkRoute(subPath: string, method: string): boolean { + const parts = subPath.replace(/^\/+/, '').split('/').filter(Boolean); + return ( + method.toUpperCase() === 'GET' && + parts.length === 2 && + (parts[1] === 'resolve' || parts[1] === 'messages') + ); +} + /** Body kept signature-compatible with the legacy `HttpDispatcher.handleShareLinks`. */ export async function handleShareLinksRequest( deps: DomainHandlerDeps, @@ -75,6 +98,32 @@ export async function handleShareLinksRequest( body: any, query: any, context: HttpProtocolContext, +): Promise { + const isPublic = isPublicShareLinkRoute(subPath, method); + let result: HttpDispatcherResult; + try { + result = await handleShareLinksRequestBody(deps, subPath, method, body, query, context); + } catch (err: unknown) { + // A throw from OUTSIDE the body's own try (service resolution, engine + // lookup) would otherwise leave a public route without the headers + // above. Authenticated routes keep their existing propagation. + if (!isPublic) throw err; + result = { handled: true, response: deps.errorFromThrown(err, 500) }; + } + if (!result.response || !isPublic) return result; + return { + ...result, + response: { ...result.response, headers: { ...result.response.headers, ...PUBLIC_RESPONSE_HEADERS } }, + }; +} + +async function handleShareLinksRequestBody( + deps: DomainHandlerDeps, + subPath: string, + method: string, + body: any, + query: any, + context: HttpProtocolContext, ): Promise { // [#4127 batch 3] `plugin-sharing` registers `ShareLinkService`, which // declares `implements IShareLinkService`; the four methods called below diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a72e6e19fea..b383960ab3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1941,6 +1941,9 @@ importers: packages/plugins/plugin-sharing: dependencies: + '@noble/hashes': + specifier: ^2.4.0 + version: 2.4.0 '@objectstack/core': specifier: workspace:* version: link:../../core