From 1796fca90d504a9907c3fb14208b47987ff7c5e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 02:20:05 +0000 Subject: [PATCH 1/5] wip(storage): mountStorageRoutes host door (#15169) --- .changeset/storage-routes-host-mount.md | 13 + packages/services/service-storage/README.md | 28 ++ .../services/service-storage/src/index.ts | 17 + .../src/mount-storage-routes.test.ts | 467 ++++++++++++++++++ .../src/mount-storage-routes.ts | 161 ++++++ .../src/storage-service-plugin.ts | 215 ++++++-- 6 files changed, 869 insertions(+), 32 deletions(-) create mode 100644 .changeset/storage-routes-host-mount.md create mode 100644 packages/services/service-storage/src/mount-storage-routes.test.ts create mode 100644 packages/services/service-storage/src/mount-storage-routes.ts diff --git a/.changeset/storage-routes-host-mount.md b/.changeset/storage-routes-host-mount.md new file mode 100644 index 0000000000..449e045a0f --- /dev/null +++ b/.changeset/storage-routes-host-mount.md @@ -0,0 +1,13 @@ +--- +"@objectstack/service-storage": minor +--- + +feat(storage): `mountStorageRoutes` — mount the storage routes on a host-owned HTTP surface, composed from a kernel that has no `http-server` service (#15169) + +`StorageServicePlugin` mounts `/api/v1/storage/*` itself, at `kernel:ready`, on the kernel's `http-server` service. A hosted per-environment tenant kernel registers no such service, so the storage service, `sys_file`, the lifecycle hooks and the reap guards were all present while every `/api/v1/storage/*` request answered 404 — an app with an attachment field could not upload. The settings service already had a working host bridge because `registerSettingsRoutes` and everything it needs are public; storage could not be bridged the same way because `registerStorageRoutes` needs three package-internal seams: the upload session resolver, the ADR-0104 D3 download authorization gate, and the tombstone holder predicate. + +**New export: `mountStorageRoutes(http, kernel, options?)`** (with `MountStorageRoutesOptions`, `StorageRouteKernel`, `StorageRoutesMountReport`). One entry point that takes the host's `IHttpServer`-shaped surface and the environment kernel, binds the three seams from that kernel's own `auth` service and data engine, and registers the full route table — the composition the plugin's own mount now calls too, so a host's storage door and the plugin's are one code path. The options carry wire knobs only (`basePath`, `presignedTtl`, `sessionTtl`, `downloadTtl`, `logger`): the three gate seams are not accepted in any form, so a consumer cannot substitute, omit or bypass the download gate, and the platform keeps exactly one definition of it. The return value reports which gates bound, as booleans. A kernel with no `storage` service throws naming the remedy; a kernel with no `auth` service or no data engine mounts with the matching gate off and warns — the plugin's existing bare-kernel behaviour, said out loud. + +Deliberately NOT published: `buildAuthSessionResolver`, `buildFileReadAuthorizer` and `findFileHolder` stay package-internal. The narrower surface serves the one consumer that exists (a host mounting the door) and is easier to walk back than three loose functions. + +Nothing existing changes shape or behaviour: `registerStorageRoutes` and `StorageRoutesOptions` are untouched, and `StorageServicePlugin` mounts exactly what it mounted before. diff --git a/packages/services/service-storage/README.md b/packages/services/service-storage/README.md index 29747aa4c0..7c969e2ea6 100644 --- a/packages/services/service-storage/README.md +++ b/packages/services/service-storage/README.md @@ -88,6 +88,34 @@ All routes are mounted at `/api/v1/storage` (configurable via `basePath`). | PUT | `/_local/raw/:token` | Local raw upload (presigned) | | GET | `/_local/raw/:token` | Local raw download (presigned) | +### Mounting the routes from a host (kernels with no `http-server` service) + +`StorageServicePlugin` mounts the table above itself, at `kernel:ready`, on the +kernel's `http-server` service. A kernel that registers no such service — a +hosted per-environment tenant kernel — keeps the storage service, `sys_file`, +the lifecycle hooks and the reap guards, but has no HTTP door. A host that +owns the HTTP surface mounts the same routes with `mountStorageRoutes`: + +```typescript +import { mountStorageRoutes } from '@objectstack/service-storage'; + +// `http` is whatever the host registers routes on — an `IHttpServer` adapter, +// or the host's own route-collecting shim that later dispatches into this +// kernel. `kernel` is the environment kernel, AFTER it has bootstrapped. +const report = mountStorageRoutes(http, kernel, { basePath: '/api/v1/storage' }); +// report: { basePath, sessionResolver, downloadAuthorizer, tombstoneHolderResolver, metadataStore } +``` + +The door composes the upload session resolver, the download authorization +gate (ADR-0104 D3) and the tombstone holder predicate from the kernel's own +`auth` service and data engine — through the same composition the plugin's +own mount uses. The options carry wire knobs only (`basePath`, the TTLs, a +logger): none of the three gates can be supplied, replaced or omitted by the +host, so the platform keeps exactly one definition of who may download a +file. A kernel with no `storage` service throws; a kernel with no `auth` +service or no data engine mounts with the matching gate off and says so at +`warn`, exactly the bare-kernel behaviour the plugin has. + ## Client SDK Usage ```typescript diff --git a/packages/services/service-storage/src/index.ts b/packages/services/service-storage/src/index.ts index bb06c0a235..26c4b09dd7 100644 --- a/packages/services/service-storage/src/index.ts +++ b/packages/services/service-storage/src/index.ts @@ -20,6 +20,19 @@ export type { FileReadVerdict, StorageUploadSession, } from './storage-routes.js'; +// [#15169] The host door: the storage routes composed from a kernel and +// mounted on an HTTP surface the host owns — for kernels with no `http-server` +// service (cloud's per-environment tenant kernels). Published as ONE entry +// point rather than as the three gate builders it wires +// (`buildAuthSessionResolver` / `buildFileReadAuthorizer` / `findFileHolder`, +// which stay internal): the consumer gets the door, never a handle on the +// ADR-0104 D3 download gate, so the platform keeps one definition of it. +export { mountStorageRoutes } from './mount-storage-routes.js'; +export type { + MountStorageRoutesOptions, + StorageRouteKernel, + StorageRoutesMountReport, +} from './mount-storage-routes.js'; export { SystemFile, SystemUploadSession } from './objects/index.js'; export { installAttachmentLifecycleHooks, @@ -33,6 +46,10 @@ export type { AttachmentLifecycleEngine, AttachmentLifecycleLogger } from './att // this project does not owe (implementation-first) — and the narrower the // ownership predicate's blast radius, the fewer places can drift weaker than // the guard. Export them the day a consumer exists. +// [#15169] A consumer DID arrive — a host mounting the routes on a kernel with +// no `http-server` — and it is served by `mountStorageRoutes` above, which +// binds the predicate inside the package. The consumer needs the door, not the +// predicate, so this declaration stands: the blast radius did not widen. export { inventoryStrandedFileOrphans, formatStrandedOrphanInventory, diff --git a/packages/services/service-storage/src/mount-storage-routes.test.ts b/packages/services/service-storage/src/mount-storage-routes.test.ts new file mode 100644 index 0000000000..d1489c83fe --- /dev/null +++ b/packages/services/service-storage/src/mount-storage-routes.test.ts @@ -0,0 +1,467 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#15169] The host door — `mountStorageRoutes` — pinned on the property that + * justified publishing it: a host mounting storage on a kernel that has no + * `http-server` service gets the framework's own gates, and gets NO handle on + * them. + * + * ## Why these pins, and why in this order + * + * 1. **§1 the door serves and refuses.** Controls in both directions before + * any property pin: a member who can read the file's parent record gets a + * signed URL (and the adapter MINTED it); an anonymous caller is refused + * 401 with no capability minted; an admitted member whose parent record is + * unreachable gets the reachability 403, not the admission 401. Without + * the third arm a property pin below could pass on the wrong reason. The + * same two directions on the UPLOAD gate. + * 2. **§2 one definition.** The gate a host gets IS the gate the plugin + * binds: the plugin's own `kernel:ready` mount and the host door register + * the identical route table through one composition, and the host door + * threads the kernel's async registry into the ADR-0104 D3 authorizer's + * tenancy-posture read (#15352) exactly as the plugin path does — an + * unreadable posture store answers the declared 503, never a verdict. + * 3. **§3 no substitution.** The option type has none of `registerStorageRoutes`' + * three gate seams (a type-level pin, compiled by `tsconfig.test.json`), + * AND a widened options object smuggling one in is ignored (a runtime pin): + * the anonymous caller is still refused after a consumer "supplied" an + * always-allow authorizer. This is the arm an ablation of the door turns + * red — pass the options bag through to `registerStorageRoutes` and the + * smuggled authorizer wins. + * 4. **§4 absence is loud.** No `storage` service throws naming the remedy; + * no `auth` / no engine mounts with the gates off, REPORTS them off, and + * warns — and the report carries booleans only, never a function. + * + * Every fixture kernel is a REAL `ObjectKernel`: the registry classification + * the posture read depends on (branded "never registered" vs unbranded + * "failed to construct", #13906) is the registry's, and a double imitating it + * would be asserting about itself. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { promises as fs } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { ObjectKernel } from '@objectstack/core'; +import type { PluginContext } from '@objectstack/core'; +import type { IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; +import { LocalStorageAdapter } from './local-storage-adapter.js'; +import { mountStorageRoutes, type MountStorageRoutesOptions } from './mount-storage-routes.js'; +import { StorageServicePlugin } from './storage-service-plugin.js'; + +const BASE = '/api/v1/storage'; + +/** Field-owned, private, parked on a parent record `u_member` can read. */ +const FILE_OPEN = 'file_15169_open'; +/** Same shape, parent record readable by nobody — the reachability control. */ +const FILE_CLOSED = 'file_15169_closed'; + +/** The nine routes `registerStorageRoutes` mounts, at the default base. */ +const LEDGERED_ROUTES = [ + `POST:${BASE}/upload/presigned`, + `POST:${BASE}/upload/complete`, + `POST:${BASE}/upload/chunked`, + `PUT:${BASE}/upload/chunked/:uploadId/chunk/:chunkIndex`, + `POST:${BASE}/upload/chunked/:uploadId/complete`, + `GET:${BASE}/upload/chunked/:uploadId/progress`, + `GET:${BASE}/files/:fileId/url`, + `GET:${BASE}/files/:fileId`, + `PUT:${BASE}/_local/raw/:token`, + `GET:${BASE}/_local/raw/:token`, +]; + +// --------------------------------------------------------------------------- +// Fixture: the data engine, the auth service, the host's route collector +// --------------------------------------------------------------------------- + +function matchesWhere(row: Record, where: unknown): boolean { + for (const [field, cond] of Object.entries((where ?? {}) as Record)) { + if (field.startsWith('$')) throw new Error(`fixture where-matcher: unsupported combinator '${field}'`); + if (cond !== null && typeof cond === 'object') { + const inList = (cond as { $in?: unknown }).$in; + if (!Array.isArray(inList)) throw new Error(`fixture where-matcher: unsupported operator on '${field}'`); + if (!inList.includes(row[field])) return false; + continue; + } + if (row[field] !== cond) return false; + } + return true; +} + +/** + * `sys_file` plus the parent object the field-owned files hang off, with row + * visibility as an explicit allow-list — the stand-in for RLS. `owner_id` is a + * user nobody authenticates as, so the authorizer's "uploader may always + * download" shortcut is never what an arm travels through. + */ +function makeEngine() { + const tables: Record>> = { + sys_file: [ + { + id: FILE_OPEN, key: 'files/open.pdf', name: 'open.pdf', mime_type: 'application/pdf', size: 12, + scope: 'record', status: 'committed', acl: 'private', owner_id: 'u_uploader', + ref_object: 'contract', ref_id: 'rec_open', ref_field: 'attachment', + }, + { + id: FILE_CLOSED, key: 'files/closed.pdf', name: 'closed.pdf', mime_type: 'application/pdf', size: 12, + scope: 'record', status: 'committed', acl: 'private', owner_id: 'u_uploader', + ref_object: 'contract', ref_id: 'rec_closed', ref_field: 'attachment', + }, + ], + sys_upload_session: [], + sys_attachment: [], + }; + const contractVisibility: Record = { rec_open: ['u_member'], rec_closed: [] }; + const rowsOf = (object: string) => { + const rows = tables[object]; + if (!rows) throw new Error(`fixture engine: unknown object '${object}'`); + return rows; + }; + return { + tables, + find: async (object: string, q: Record = {}) => { + if (object === 'contract') { + const id = String(((q.where ?? {}) as { id?: unknown }).id ?? ''); + const userId = ((q.context ?? {}) as { userId?: string }).userId; + return userId && (contractVisibility[id] ?? []).includes(userId) ? [{ id }] : []; + } + const rows = rowsOf(object).filter((row) => matchesWhere(row, q.where)); + return typeof q.limit === 'number' ? rows.slice(0, q.limit) : rows; + }, + findOne: async (object: string, q: Record = {}) => + rowsOf(object).find((row) => matchesWhere(row, q.where)) ?? null, + insert: async (object: string, row: Record) => { + rowsOf(object).push({ ...row }); + return row; + }, + update: async (object: string, patch: Record, q: Record = {}) => { + for (const row of rowsOf(object)) if (matchesWhere(row, q.where)) Object.assign(row, patch); + }, + delete: async () => {}, + }; +} + +/** A session per `x-test-user` header — the host's auth, reduced to what the gates read. */ +function makeAuth() { + return { + api: { + getSession: async ({ headers }: { headers: Headers }) => { + const userId = headers.get('x-test-user'); + return userId ? { user: { id: userId }, session: {} } : undefined; + }, + }, + }; +} + +/** The cloud shape: a route table the host later dispatches into. */ +function makeRouteCollector() { + const routes = new Map(); + const http = { + get: (path: string, handler: RouteHandler) => { routes.set(`GET:${path}`, handler); }, + post: (path: string, handler: RouteHandler) => { routes.set(`POST:${path}`, handler); }, + put: (path: string, handler: RouteHandler) => { routes.set(`PUT:${path}`, handler); }, + delete: () => {}, + patch: () => {}, + use: () => {}, + listen: async () => {}, + close: async () => {}, + }; + return { http, routes }; +} + +interface MockResponse { + status: number; + json: Record | undefined; + headers: Record; +} + +function makeRes(): IHttpResponse & MockResponse { + const res: Record = { status: 200, json: undefined, headers: {} }; + const api = { + json(data: Record) { (res as { json?: unknown }).json = data; return api; }, + send() { return api; }, + status(code: number) { (res as { status: number }).status = code; return api; }, + header(name: string, value: string) { + ((res as { headers: Record }).headers)[name] = value; + return api; + }, + }; + return Object.assign(res, api) as unknown as IHttpResponse & MockResponse; +} + +async function call( + routes: Map, + method: 'GET' | 'POST', + path: string, + init: { params?: Record; body?: unknown; user?: string } = {}, +): Promise { + const handler = routes.get(`${method}:${path}`); + if (!handler) throw new Error(`fixture: no handler registered for ${method} ${path}`); + const req = { + params: init.params ?? {}, + query: {}, + body: init.body, + headers: init.user ? { 'x-test-user': init.user } : {}, + method, + path, + } as unknown as IHttpRequest; + const res = makeRes(); + await handler(req, res); + return { status: res.status, json: res.json, headers: res.headers }; +} + +const errorCode = (res: MockResponse) => (res.json?.error as { code?: string } | undefined)?.code; + +let rootDirs: string[] = []; + +async function makeStorage() { + const rootDir = join(tmpdir(), `os-15169-${Date.now()}-${Math.random().toString(36).slice(2)}`); + await fs.mkdir(rootDir, { recursive: true }); + rootDirs.push(rootDir); + return new LocalStorageAdapter({ rootDir, signingSecret: 'test-secret-15169' }); +} + +/** A real kernel carrying the named services — `gracefulShutdown: false` so it never hooks the runner's signals. */ +function makeKernel(services: Record): ObjectKernel { + const kernel = new ObjectKernel({ skipSystemValidation: true, gracefulShutdown: false } as never); + for (const [name, svc] of Object.entries(services)) kernel.registerService(name, svc); + return kernel; +} + +interface Host { + routes: Map; + minted: () => number; + warns: string[]; + report: ReturnType; + engine: ReturnType; +} + +async function mountHost( + wire: { auth?: boolean; engine?: boolean; tenancy?: 'single' | 'factory-throws' } = {}, + opts: MountStorageRoutesOptions = { basePath: BASE }, + accessor?: (kernel: ObjectKernel) => Parameters[1], +): Promise { + const storage = await makeStorage(); + const engine = makeEngine(); + const services: Record = { storage }; + if (wire.engine !== false) services.objectql = engine; + if (wire.auth !== false) services.auth = makeAuth(); + if (wire.tenancy === 'single') services.tenancy = { posture: 'single' }; + const kernel = makeKernel(services); + if (wire.tenancy === 'factory-throws') { + kernel.registerServiceFactory('tenancy', () => { throw new Error('tenancy backend unavailable'); }); + } + const { http, routes } = makeRouteCollector(); + const warns: string[] = []; + const mintSpy = vi.spyOn(storage, 'getPresignedDownload'); + const report = mountStorageRoutes(http, accessor ? accessor(kernel) : kernel, { + ...opts, + logger: { info: () => {}, warn: (m: string) => { warns.push(m); } }, + }); + return { routes, minted: () => mintSpy.mock.calls.length, warns, report, engine }; +} + +afterEach(async () => { + vi.restoreAllMocks(); + for (const dir of rootDirs) await fs.rm(dir, { recursive: true, force: true }); + rootDirs = []; +}); + +// --------------------------------------------------------------------------- +// §1 — the door serves and refuses (controls in both directions) +// --------------------------------------------------------------------------- + +describe('[#15169] §1 · the host door serves, and the host door refuses', () => { + it('mounts the full ledgered route table on the host surface and reports every gate bound', async () => { + const h = await mountHost(); + expect([...h.routes.keys()].sort()).toEqual([...LEDGERED_ROUTES].sort()); + expect(h.report).toEqual({ + basePath: BASE, + sessionResolver: true, + downloadAuthorizer: true, + tombstoneHolderResolver: true, + metadataStore: 'engine', + }); + expect(h.warns).toEqual([]); + }); + + it('CONTROL · SERVES: a member who can read the parent record gets a signed URL, and the adapter MINTED it', async () => { + const h = await mountHost(); + const res = await call(h.routes, 'GET', `${BASE}/files/:fileId/url`, { params: { fileId: FILE_OPEN }, user: 'u_member' }); + expect(res.status).toBe(200); + expect(String((res.json?.data as { url?: string } | undefined)?.url)).toContain('/_local/raw/'); + expect(h.minted()).toBe(1); + }); + + it('CONTROL · REFUSES at the door: anonymous is 401 AUTH_REQUIRED and no capability is minted', async () => { + const h = await mountHost(); + const res = await call(h.routes, 'GET', `${BASE}/files/:fileId/url`, { params: { fileId: FILE_OPEN } }); + expect(res.status).toBe(401); + expect(errorCode(res)).toBe('AUTH_REQUIRED'); + expect(h.minted()).toBe(0); + }); + + it('CONTROL · REFUSES by reachability: an admitted member whose parent record is unreachable is 403, not 401', async () => { + const h = await mountHost(); + const res = await call(h.routes, 'GET', `${BASE}/files/:fileId/url`, { params: { fileId: FILE_CLOSED }, user: 'u_member' }); + expect(res.status).toBe(403); + expect(errorCode(res)).toBe('FILE_DOWNLOAD_DENIED'); + expect(h.minted()).toBe(0); + }); + + it('UPLOAD gate · anonymous is 401 AUTH_REQUIRED and no sys_file row lands', async () => { + const h = await mountHost(); + const before = h.engine.tables.sys_file.length; + const res = await call(h.routes, 'POST', `${BASE}/upload/presigned`, { + body: { filename: 'photo.jpg', mimeType: 'image/jpeg', size: 1024, scope: 'user' }, + }); + expect(res.status).toBe(401); + expect(errorCode(res)).toBe('AUTH_REQUIRED'); + expect(h.engine.tables.sys_file.length).toBe(before); + }); + + it('UPLOAD gate · a session gets a presigned upload and the sys_file row is stamped with its user', async () => { + const h = await mountHost(); + const res = await call(h.routes, 'POST', `${BASE}/upload/presigned`, { + body: { filename: 'photo.jpg', mimeType: 'image/jpeg', size: 1024, scope: 'user' }, + user: 'u_member', + }); + expect(res.status).toBe(200); + const fileId = (res.json?.data as { fileId?: string } | undefined)?.fileId; + expect(fileId).toBeTruthy(); + const row = h.engine.tables.sys_file.find((r) => r.id === fileId); + expect(row?.owner_id).toBe('u_member'); + }); +}); + +// --------------------------------------------------------------------------- +// §2 — one definition: the host's gate is the plugin's gate +// --------------------------------------------------------------------------- + +describe('[#15169] §2 · one definition — the plugin mount and the host door are the same composition', () => { + it('the plugin\'s own kernel:ready mount registers byte-for-byte the same route table', async () => { + // The plugin path, on the fake context every plugin suite here mounts, + // with an `http-server` in the registry so its own branch runs. + const { http, routes: pluginRoutes } = makeRouteCollector(); + const services = new Map([['http-server', http]]); + const readyHooks: Array<() => Promise | void> = []; + const ctx = { + logger: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }, + registerService: (name: string, svc: unknown) => { services.set(name, svc); }, + getService: (name: string) => { + const s = services.get(name); + if (!s) throw new Error(`service '${name}' not registered`); + return s; + }, + hook: (event: string, fn: () => Promise | void) => { if (event === 'kernel:ready') readyHooks.push(fn); }, + } as unknown as PluginContext; + const rootDir = join(tmpdir(), `os-15169-plugin-${Date.now()}`); + rootDirs.push(rootDir); + const plugin = new StorageServicePlugin({ + adapter: 'local', basePath: BASE, local: { rootDir, signingSecret: 's' }, bindToSettings: false, + }); + await plugin.init(ctx); + await plugin.start(ctx); + for (const hook of readyHooks) await hook(); + + const host = await mountHost(); + expect([...pluginRoutes.keys()].sort()).toEqual([...host.routes.keys()].sort()); + }); + + it('threads the kernel\'s async registry into the download gate: an unreadable tenancy posture is the declared 503, never a verdict', async () => { + // Subject: the posture read (#15352) runs on the host door, off the REAL + // registry's classification — a registered-and-failing `tenancy` factory + // is an OUTAGE, relayed as 503 SERVICE_UNAVAILABLE (#15999). + const outage = await mountHost({ tenancy: 'factory-throws' }); + const res = await call(outage.routes, 'GET', `${BASE}/files/:fileId/url`, { params: { fileId: FILE_OPEN } }); + expect(res.status).toBe(503); + expect(errorCode(res)).toBe('SERVICE_UNAVAILABLE'); + expect(outage.minted()).toBe(0); + + // Control: the same door with a readable posture reaches the admission + // verdict — anonymous is 401. Same kernel shape, only the posture differs. + const readable = await mountHost({ tenancy: 'single' }); + const ctrl = await call(readable.routes, 'GET', `${BASE}/files/:fileId/url`, { params: { fileId: FILE_OPEN } }); + expect(ctrl.status).toBe(401); + }); + + it('a `getService`-only accessor (LiteKernel shape) keeps the posture read quiet and the gate bound', async () => { + const h = await mountHost({}, { basePath: BASE }, (kernel) => ({ + getService: (name: string): T => kernel.getService(name), + })); + expect(h.report.downloadAuthorizer).toBe(true); + const res = await call(h.routes, 'GET', `${BASE}/files/:fileId/url`, { params: { fileId: FILE_OPEN } }); + expect(res.status).toBe(401); + expect(errorCode(res)).toBe('AUTH_REQUIRED'); + }); +}); + +// --------------------------------------------------------------------------- +// §3 — no substitution: a consumer cannot hand the door its own gate +// --------------------------------------------------------------------------- + +describe('[#15169] §3 · the consumer cannot substitute or bypass the download gate', () => { + it('TYPE · the option type carries none of the three gate seams', () => { + // Compiled by `tsconfig.test.json` (and the build program, which does not + // exclude tests): each line is a type error the day the seam is admitted. + // @ts-expect-error — `authorizeFileRead` is not a host-mount option + const a: MountStorageRoutesOptions = { basePath: BASE, authorizeFileRead: async () => 'allow' }; + // @ts-expect-error — `resolveSession` is not a host-mount option + const b: MountStorageRoutesOptions = { basePath: BASE, resolveSession: async () => ({ userId: 'u' }) }; + // @ts-expect-error — `resolveFileHolder` is not a host-mount option + const c: MountStorageRoutesOptions = { basePath: BASE, resolveFileHolder: async () => null }; + expect([a, b, c].length).toBe(3); + }); + + it('RUNTIME · a widened options object smuggling an always-allow authorizer changes nothing: anonymous is still refused', async () => { + const smuggled = { + basePath: BASE, + authorizeFileRead: async () => 'allow' as const, + resolveSession: async () => ({ userId: 'u_member' }), + resolveFileHolder: async () => 'attachment' as const, + } as MountStorageRoutesOptions; + const h = await mountHost({}, smuggled); + const download = await call(h.routes, 'GET', `${BASE}/files/:fileId/url`, { params: { fileId: FILE_OPEN } }); + expect(download.status).toBe(401); + expect(errorCode(download)).toBe('AUTH_REQUIRED'); + expect(h.minted()).toBe(0); + const upload = await call(h.routes, 'POST', `${BASE}/upload/presigned`, { + body: { filename: 'photo.jpg', mimeType: 'image/jpeg', size: 1024, scope: 'user' }, + }); + expect(upload.status).toBe(401); + expect(errorCode(upload)).toBe('AUTH_REQUIRED'); + }); +}); + +// --------------------------------------------------------------------------- +// §4 — absence is loud +// --------------------------------------------------------------------------- + +describe('[#15169] §4 · absence is loud', () => { + it('a kernel with no `storage` service throws, naming the service and the plugin that registers it', async () => { + const kernel = makeKernel({ objectql: makeEngine(), auth: makeAuth() }); + const { http } = makeRouteCollector(); + expect(() => mountStorageRoutes(http, kernel, { basePath: BASE })).toThrow(/`storage`[\s\S]*StorageServicePlugin/); + }); + + it('a kernel with no `auth` and no engine mounts with the gates OFF, reports them off, and warns once', async () => { + const h = await mountHost({ auth: false, engine: false }); + expect(h.report).toEqual({ + basePath: BASE, + sessionResolver: false, + downloadAuthorizer: false, + tombstoneHolderResolver: false, + metadataStore: 'memory', + }); + expect(h.warns).toHaveLength(1); + // The report is booleans and strings only — never a function a caller + // could invoke around the door. + expect(Object.values(h.report).every((v) => typeof v !== 'function')).toBe(true); + // And "off" is real, not merely reported: the declared bare-kernel posture + // accepts an anonymous upload. + const res = await call(h.routes, 'POST', `${BASE}/upload/presigned`, { + body: { filename: 'photo.jpg', mimeType: 'image/jpeg', size: 1024, scope: 'user' }, + }); + expect(res.status).toBe(200); + }); +}); diff --git a/packages/services/service-storage/src/mount-storage-routes.ts b/packages/services/service-storage/src/mount-storage-routes.ts new file mode 100644 index 0000000000..aa9fa828f3 --- /dev/null +++ b/packages/services/service-storage/src/mount-storage-routes.ts @@ -0,0 +1,161 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#15169] The host door: mount the framework's storage routes on an HTTP + * surface the HOST owns, over a kernel that has no `http-server` service. + * + * ## Why this exists + * + * `StorageServicePlugin` mounts `/api/v1/storage/*` itself, at `kernel:ready`, + * on the kernel's `http-server` service. A hosted per-environment tenant kernel + * (cloud) registers no such service, so that branch logs "no HTTP server + * available" and the storage service is up with no HTTP door: `sys_file`, + * the lifecycle hooks and the reap guards are all present, and every + * `/api/v1/storage/*` request answers 404 — an app with an attachment field + * cannot upload. The settings service already has a working answer to the + * same shape: the host mounts `registerSettingsRoutes` on its raw app and + * dispatches into the environment kernel's route table. Storage could not be + * bridged the same way, because `registerStorageRoutes` needs three seams — + * the upload session resolver, the ADR-0104 D3 download authorizer and the + * tombstone holder predicate — that are, deliberately, package-internal. + * + * ## The shape, and what it exposes + * + * ONE entry point that takes the host's `IHttpServer`-shaped surface plus the + * kernel to compose from, and binds the three seams inside the package — + * the narrow half of #15169 option A, preferred over publishing the three + * builders because a narrower public surface is easier to walk back. What a + * consumer gets is the door; what it never gets is a handle on any gate: + * + * - {@link MountStorageRoutesOptions} carries the wire knobs only (`basePath`, + * the three TTLs, a logger). It has no `resolveSession`, no + * `authorizeFileRead`, no `resolveFileHolder` — the three option keys of + * `registerStorageRoutes` this door exists to keep off the host's side. + * An object literal naming one of them is a type error, and a widened + * object carrying one is ignored: the composition reads named fields, never + * the options bag through. + * - The gates are built from the kernel the host hands over — its `auth` + * service and its data engine — by the same `composeStorageRoutes` the + * plugin's own `kernel:ready` mount calls. One composition, two callers, so + * the platform keeps exactly one definition of the download gate, which is + * the property that made the settings bridge safe and the property option C + * (a consumer re-implementing the authorizer) would have broken. + * - The return value is a {@link StorageRoutesMountReport}: booleans saying + * which gates bound, for the host's boot log. Never the functions. + * + * ## Preconditions, loudly + * + * Call it once per kernel, after that kernel has bootstrapped: the gates + * resolve `auth` / `objectql` at mount time, exactly as the plugin does at + * `kernel:ready`, so a kernel still filling its registry would bind a gate + * against an absence the boot later contradicts. A kernel with no `storage` + * service THROWS (nothing to mount — mount `StorageServicePlugin` first). + * A kernel with no `auth` service or no data engine mounts with the matching + * gate off — the declared bare-kernel behaviour — and says so at `warn`, + * naming what stays open and the composition that closes it. + */ + +import type { IHttpServer, IDataEngine, IStorageService } from '@objectstack/spec/contracts'; +import { + composeStorageRoutes, + toGateRegistry, + type StorageRouteKernel, + type StorageRoutesMountReport, +} from './storage-service-plugin.js'; + +export type { StorageRouteKernel, StorageRoutesMountReport } from './storage-service-plugin.js'; + +/** + * The wire knobs of a host mount. Deliberately NOT `StorageRoutesOptions`: the + * three gate seams that type carries are bound by the package (see the module + * header), and are not accepted here in any form. + */ +export interface MountStorageRoutesOptions { + /** Wire prefix. @default '/api/v1/storage' */ + basePath?: string; + /** Default presigned upload URL TTL in seconds. @default 3600 */ + presignedTtl?: number; + /** Default chunked upload session TTL in seconds. @default 86400 */ + sessionTtl?: number; + /** TTL of the signed URL minted on a GATED download. @default 300 */ + downloadTtl?: number; + /** Receives the door's one-time notices (open upload mode, unbound gates). */ + logger?: { info(msg: string): void; warn(msg: string): void }; +} + +/** + * Mount `/api/v1/storage/*` on `http`, composed from `kernel`. + * + * `http` is whatever the host registers routes on — a real `IHttpServer` + * adapter, or the host's own route-collecting shim that later dispatches into + * the kernel this was composed from (the settings bridge's shape). Only the + * registration half (`get` / `post` / `put`) is called. + * + * `kernel` is the environment kernel — an `ObjectKernel`, a `LiteKernel`, or a + * `PluginContext` on one; see {@link StorageRouteKernel}. Its `storage` service + * is what the routes serve, its `objectql` engine is where `sys_file` lives, + * and its `auth` service is what the upload and download gates authenticate + * against. + * + * @throws when `kernel` has no `storage` service — there is nothing to mount. + */ +export function mountStorageRoutes( + http: IHttpServer, + kernel: StorageRouteKernel, + opts: MountStorageRoutesOptions = {}, +): StorageRoutesMountReport { + let storage: IStorageService | undefined; + try { + storage = kernel.getService('storage'); + } catch { + storage = undefined; + } + if (!storage) { + throw new Error( + 'mountStorageRoutes: the kernel has no `storage` service, so there are no storage routes to ' + + 'mount. Mount `StorageServicePlugin` on that kernel (it registers `storage` in init()) and ' + + 'call mountStorageRoutes after the kernel has bootstrapped.', + ); + } + + let engine: IDataEngine | null = null; + try { + engine = kernel.getService('objectql'); + } catch { + // No data engine: `sys_file` metadata is held in memory and the two + // parent-governed gates cannot be built — reported below, not hidden. + engine = null; + } + + const report = composeStorageRoutes(http, toGateRegistry(kernel), { + storage, + engine, + basePath: opts.basePath, + presignedTtl: opts.presignedTtl, + sessionTtl: opts.sessionTtl, + downloadTtl: opts.downloadTtl, + logger: opts.logger, + }); + + const unbound: string[] = []; + if (!report.sessionResolver) { + unbound.push('upload routes accept anonymous requests (no `auth` service on the kernel)'); + } + if (!report.downloadAuthorizer) { + unbound.push( + 'parent-governed downloads are NOT authorized (the kernel lacks an `auth` service or a data engine)', + ); + } + if (report.metadataStore === 'memory') { + unbound.push('`sys_file` metadata is in-memory (no `objectql` engine on the kernel) and is lost on restart'); + } + if (unbound.length > 0) { + opts.logger?.warn( + `mountStorageRoutes: storage routes mounted at ${report.basePath} with gates unbound — ` + + unbound.join('; ') + + '. This is the declared bare-kernel posture; on a hosted kernel, register `auth` and the ' + + 'data engine on it BEFORE mounting so the same gates the plugin binds are bound here.', + ); + } + return report; +} diff --git a/packages/services/service-storage/src/storage-service-plugin.ts b/packages/services/service-storage/src/storage-service-plugin.ts index bd69d9623c..d3cfb014ff 100644 --- a/packages/services/service-storage/src/storage-service-plugin.ts +++ b/packages/services/service-storage/src/storage-service-plugin.ts @@ -183,7 +183,6 @@ export class StorageServicePlugin implements Plugin { private readonly options: StorageServicePluginOptions; private storage: SwappableStorageService | null = null; - private store: StorageMetadataStore | null = null; private metrics: MetricsRegistry = new NoopMetricsRegistry(); /** * What the CURRENTLY installed adapter points at (#4096). Set beside every @@ -435,24 +434,16 @@ export class StorageServicePlugin implements Plugin { } if (httpServer && this.storage) { - this.store = new StorageMetadataStore(engine); - - registerStorageRoutes(httpServer, this.storage, this.store, { + // [#15169] ONE composition, shared with the host door + // (`mountStorageRoutes`): the gates are bound in + // `composeStorageRoutes`, from the kernel, and nowhere else — so the + // plugin's own mount and a host's cannot drift apart. + composeStorageRoutes(httpServer, toGateRegistry(ctx), { + storage: this.storage, + engine, basePath: this.options.basePath ?? '/api/v1/storage', presignedTtl: this.options.presignedTtl, sessionTtl: this.options.sessionTtl, - resolveSession: buildAuthSessionResolver(ctx), - authorizeFileRead: buildFileReadAuthorizer(ctx, engine), - // "Is anything still holding this tombstone?" on the READ side - // (#10246) — the reap guard's own `findFileHolder`, handed over - // rather than re-derived. One definition of "still held", asked by - // the sweep before it reaps and by the download path before it - // refuses, so the two cannot answer differently. No engine (bare - // kernel) leaves it undefined and tombstones stay refused. - resolveFileHolder: - engine && typeof (engine as any).find === 'function' - ? (file: FileRecord) => findFileHolder(engine as any, file.id, file as any) - : undefined, logger: ctx.logger, }); @@ -762,10 +753,10 @@ function toWebHeaders(req: { headers?: unknown }): any | null { } /** A `getSession(headers)` bound to the kernel's `auth` service, or null. */ -function buildGetSession(ctx: PluginContext): ((headers: any) => Promise) | null { +function buildGetSession(registry: StorageGateRegistry): ((headers: any) => Promise) | null { let authService: any; try { - authService = ctx.getService('auth'); + authService = registry.getService('auth'); } catch { return null; } @@ -778,6 +769,163 @@ function buildGetSession(ctx: PluginContext): ((headers: any) => Promise) | }; } +/** The default wire prefix of the storage door. */ +const DEFAULT_STORAGE_BASE_PATH = '/api/v1/storage'; + +/** + * [#15169] The slice of a kernel the storage door is composed FROM — the + * registry accessors its gates read, and nothing else. + * + * Structurally satisfied by every shape a caller holds today: an + * `ObjectKernel` (`getService` + `getServiceAsync`), a `LiteKernel` + * (`getService` only), and a `PluginContext` (`getService`, with the async + * registry one hop away through `getKernel()`). {@link toGateRegistry} + * normalises whichever arrives into the {@link StorageGateRegistry} the gate + * builders take, so the tenancy-posture read (#15352) runs identically for a + * host that hands over a kernel and for the plugin's own `kernel:ready` mount. + */ +export interface StorageRouteKernel { + /** Sync registry lookup; throws when the slot is absent. */ + getService(name: string): T; + /** + * Async registry lookup carrying the branded "never registered" rejection + * (`ObjectKernel`). Absent on hosts without one — a `LiteKernel` — and the + * posture read stays quiet there (family-wide behaviour, #15997). + */ + getServiceAsync?(name: string, scopeId?: string): Promise; + /** A `PluginContext` reaches the async registry through its kernel. */ + getKernel?(): { getServiceAsync?(name: string, scopeId?: string): Promise } | undefined; +} + +/** + * What the gate builders READ — package-internal. A sync registry, plus the + * async one when the host has it. Never a `PluginContext` any more: the + * builders used to take one and read `getKernel?.()` themselves, which meant a + * host holding a bare kernel (no context at all) could not build the gates + * without impersonating a context. See {@link toGateRegistry}. + */ +export interface StorageGateRegistry { + getService(name: string): T; + getServiceAsync?(name: string, scopeId?: string): Promise; +} + +/** + * Normalise whatever registry-shaped thing the caller holds into the slice the + * gate builders read. The async accessor is taken from the value itself when + * it has one (a kernel), else from `getKernel()` (a plugin context on a real + * kernel), else left absent (a `LiteKernel`, or a test context with neither) — + * the exact three-way reading `resolveAdmissionTenancyPosture` used to perform + * inline, now performed once so both mount paths share it. + */ +export function toGateRegistry(kernel: StorageRouteKernel): StorageGateRegistry { + const registry: StorageGateRegistry = { + getService: (name: string): T => kernel.getService(name), + }; + const asyncSource = typeof kernel.getServiceAsync === 'function' ? kernel : kernel.getKernel?.(); + const getServiceAsync = asyncSource?.getServiceAsync; + if (asyncSource && typeof getServiceAsync === 'function') { + registry.getServiceAsync = (name: string, scopeId?: string): Promise => + getServiceAsync.call(asyncSource, name, scopeId) as Promise; + } + return registry; +} + +/** The inputs {@link composeStorageRoutes} binds the door over. Package-internal. */ +export interface StorageRoutesComposition { + /** The `storage` service the routes serve bytes through. */ + storage: IStorageService; + /** The data engine `sys_file` / `sys_upload_session` live in; `null` ⇒ in-memory metadata (bare kernel). */ + engine: IDataEngine | null; + basePath?: string; + presignedTtl?: number; + sessionTtl?: number; + downloadTtl?: number; + logger?: { info(msg: string): void; warn(msg: string): void }; +} + +/** + * What a mount bound — booleans, never the gate functions themselves. A host + * reads it to say at boot what its storage door enforces (Route & surface + * ownership §3: absence must be loud), and a test pins it without being handed + * anything it could call around the door. + */ +export interface StorageRoutesMountReport { + /** The wire prefix the routes were registered under. */ + basePath: string; + /** Upload routes require a session (the kernel's `auth` service was present). */ + sessionResolver: boolean; + /** + * Parent-governed downloads consult the ADR-0104 D3 authorization gate (the + * kernel had BOTH an `auth` service and a data engine). `false` ⇒ downloads + * of gated files stay OPEN — the declared bare-kernel behaviour. + */ + downloadAuthorizer: boolean; + /** Tombstoned rows are re-judged through the reap guard's `findFileHolder` (#10246). */ + tombstoneHolderResolver: boolean; + /** Where `sys_file` metadata lives for this mount. */ + metadataStore: 'engine' | 'memory'; +} + +/** + * [#15169] Compose the storage door: bind the three gates FROM the registry and + * register the routes over them. Package-internal; the two callers are the + * plugin's own `kernel:ready` mount and the public host door + * `mountStorageRoutes` (`mount-storage-routes.ts`). + * + * This is the ONE place `resolveSession` / `authorizeFileRead` / + * `resolveFileHolder` are wired, and the reason the three builders are not + * on the package's public surface: a host that needs storage routes on a + * kernel without an `http-server` service (cloud's per-environment tenant + * kernels) gets the composition, not its parts. It cannot substitute the + * download gate — `buildFileReadAuthorizer` IS the ADR-0104 D3 download + * authorization — because nothing on the public option types names it; it + * cannot omit it either, because it is bound here from the kernel the host + * hands over. A consumer re-implementing the authorizer in its own code is + * how a security decision acquires a second, divergent definition, and it + * was refused (#15169 option C). + * + * `resolveFileHolder` is bound to the reap guard's own `findFileHolder` + * (#10246): one definition of "still held", asked by the sweep before it + * reaps and by the download path before it refuses, so the two cannot answer + * differently. No engine (bare kernel) leaves it undefined and tombstones stay + * refused — the same reason the predicate itself stays unexported (see + * `index.ts`). + */ +export function composeStorageRoutes( + http: IHttpServer, + registry: StorageGateRegistry, + composition: StorageRoutesComposition, +): StorageRoutesMountReport { + const basePath = composition.basePath ?? DEFAULT_STORAGE_BASE_PATH; + const engine = composition.engine; + const store = new StorageMetadataStore(engine); + const resolveSession = buildAuthSessionResolver(registry); + const authorizeFileRead = buildFileReadAuthorizer(registry, engine); + const resolveFileHolder = + engine && typeof (engine as any).find === 'function' + ? (file: FileRecord) => findFileHolder(engine as any, file.id, file as any) + : undefined; + + registerStorageRoutes(http, composition.storage, store, { + basePath, + presignedTtl: composition.presignedTtl, + sessionTtl: composition.sessionTtl, + downloadTtl: composition.downloadTtl, + resolveSession, + authorizeFileRead, + resolveFileHolder, + logger: composition.logger, + }); + + return { + basePath, + sessionResolver: resolveSession !== undefined, + downloadAuthorizer: authorizeFileRead !== undefined, + tombstoneHolderResolver: resolveFileHolder !== undefined, + metadataStore: engine ? 'engine' : 'memory', + }; +} + /** * Bridge the kernel's `auth` service (better-auth) into the storage routes' * upload gate (#2755). Returns `undefined` when no auth service is present — @@ -800,10 +948,10 @@ function buildGetSession(ctx: PluginContext): ((headers: any) => Promise) | * organization therefore means no stamp — the pre-#12745 behaviour, reported * by the backfill rather than invented here. */ -function buildAuthSessionResolver( - ctx: PluginContext, +export function buildAuthSessionResolver( + registry: StorageGateRegistry, ): ((req: { headers?: unknown }) => Promise) | undefined { - const getSession = buildGetSession(ctx); + const getSession = buildGetSession(registry); if (!getSession) return undefined; return async (req) => { try { @@ -881,6 +1029,12 @@ function buildAuthSessionResolver( * * ## Why `getServiceAsync`, and why its ABSENCE stays quiet ON THIS DOOR * + * Since #15169 the accessor arrives already normalised: {@link toGateRegistry} + * takes `getServiceAsync` off a kernel directly, or off `getKernel()` for a + * plugin context, or leaves it absent — so this function reads one slice + * whichever mount path built it (the plugin's own, or a host's + * `mountStorageRoutes`). The three-way reading below is unchanged in effect. + * * ⚠️ The brand exists only on the ASYNC resolution path: `PluginContext.getService` * — the accessor every other lookup in this file uses — throws two UNBRANDED * plain `Error`s (`… not found` and `… is async - use await`), so a synchronous @@ -917,14 +1071,11 @@ function buildAuthSessionResolver( * extraction is worth doing — once, as its own card, after they land. */ async function resolveAdmissionTenancyPosture( - ctx: PluginContext, + registry: StorageGateRegistry, ): Promise { - const kernel = ctx.getKernel?.() as - | { getServiceAsync?: (name: string, scopeId?: string) => Promise } - | undefined; - if (!kernel || typeof kernel.getServiceAsync !== 'function') return undefined; + if (typeof registry.getServiceAsync !== 'function') return undefined; try { - return effectiveTenancyPosture(await kernel.getServiceAsync('tenancy')); + return effectiveTenancyPosture(await registry.getServiceAsync('tenancy')); } catch (err) { if (!isServiceNotRegisteredError(err)) { throw new AuthzStoreUnavailableError('tenancy', err); @@ -949,11 +1100,11 @@ async function resolveAdmissionTenancyPosture( * more. A shared model would have had to union field references too, silently * widening access whenever one file id was copied into a more public record. */ -function buildFileReadAuthorizer( - ctx: PluginContext, +export function buildFileReadAuthorizer( + registry: StorageGateRegistry, engine: IDataEngine | null, ): ((file: FileRecord, req: { headers?: unknown }) => Promise) | undefined { - const getSession = buildGetSession(ctx); + const getSession = buildGetSession(registry); if (!getSession || !engine || typeof (engine as any).find !== 'function') return undefined; return async (file, req) => { @@ -967,7 +1118,7 @@ function buildFileReadAuthorizer( // why a quiet `catch` at this seam would be the defect rather than the // fix. Raised INSIDE this `try`, so an outage takes the #13279 relay in // the `catch` below rather than a new net. - const tenancyPosture = await resolveAdmissionTenancyPosture(ctx); + const tenancyPosture = await resolveAdmissionTenancyPosture(registry); const authz = await resolveAuthzContext({ ql: engine, headers, getSession, tenancyPosture }); if (!authz.userId) return 'unauthenticated'; @@ -990,7 +1141,7 @@ function buildFileReadAuthorizer( const delegateName = (engine as any).getObject?.(ownerObject)?.fileAccessDelegate; if (typeof delegateName === 'string' && delegateName) { try { - const delegate = ctx.getService(delegateName); + const delegate = registry.getService(delegateName); if (!delegate || typeof delegate.authorizeFileRead !== 'function') return 'deny'; return (await delegate.authorizeFileRead(ownerId, authz)) ? 'allow' : 'deny'; } catch { From 7dda68f07f05387b2e6e0d58e336e68496dadafe Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 02:22:19 +0000 Subject: [PATCH 2/5] wip(storage): fixture reads answer empty for undeclared tables (#15169) --- .../src/mount-storage-routes.test.ts | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/services/service-storage/src/mount-storage-routes.test.ts b/packages/services/service-storage/src/mount-storage-routes.test.ts index d1489c83fe..390843b913 100644 --- a/packages/services/service-storage/src/mount-storage-routes.test.ts +++ b/packages/services/service-storage/src/mount-storage-routes.test.ts @@ -112,9 +112,18 @@ function makeEngine() { sys_attachment: [], }; const contractVisibility: Record = { rec_open: ['u_member'], rec_closed: [] }; - const rowsOf = (object: string) => { + /** + * READS of an undeclared object answer empty: `resolveAuthzContext` reads the + * permission store (roles, permission sets, memberships) for a session + * principal, and a fixture that refused those reads would relay every + * admitted caller as the 503 outage instead of reaching the verdict. WRITES + * stay strict — a row landing in a table this fixture never declared is a + * fixture bug, not a behaviour. + */ + const readRows = (object: string) => tables[object] ?? []; + const writeRows = (object: string) => { const rows = tables[object]; - if (!rows) throw new Error(`fixture engine: unknown object '${object}'`); + if (!rows) throw new Error(`fixture engine: write to undeclared object '${object}'`); return rows; }; return { @@ -125,17 +134,17 @@ function makeEngine() { const userId = ((q.context ?? {}) as { userId?: string }).userId; return userId && (contractVisibility[id] ?? []).includes(userId) ? [{ id }] : []; } - const rows = rowsOf(object).filter((row) => matchesWhere(row, q.where)); + const rows = readRows(object).filter((row) => matchesWhere(row, q.where)); return typeof q.limit === 'number' ? rows.slice(0, q.limit) : rows; }, findOne: async (object: string, q: Record = {}) => - rowsOf(object).find((row) => matchesWhere(row, q.where)) ?? null, + readRows(object).find((row) => matchesWhere(row, q.where)) ?? null, insert: async (object: string, row: Record) => { - rowsOf(object).push({ ...row }); + writeRows(object).push({ ...row }); return row; }, update: async (object: string, patch: Record, q: Record = {}) => { - for (const row of rowsOf(object)) if (matchesWhere(row, q.where)) Object.assign(row, patch); + for (const row of writeRows(object)) if (matchesWhere(row, q.where)) Object.assign(row, patch); }, delete: async () => {}, }; From 5a0b6efa6b6b223a1c98a63e424f73071d9f0d65 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 02:45:37 +0000 Subject: [PATCH 3/5] test(storage): pin the host door's engine double to ObjectQL's write dispatch (#15169) The `mountStorageRoutes` fixture engine answered `delete` / `update` / `findOne` more loosely than `ObjectQL` does, which is how a dead route ships with its suite green (#4434 / #5619). Route the three verbs through the producer's own predicates (`assertEngineDeleteDispatch`, `assertEngineUpdateDispatch`, `assertEngineFindOnePredicate`) and register the three (file, verb) pairs in the gate's ledger via its `--write`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- .../src/mount-storage-routes.test.ts | 25 ++++++++++++++++--- scripts/engine-double-contract.pinned.json | 15 +++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/packages/services/service-storage/src/mount-storage-routes.test.ts b/packages/services/service-storage/src/mount-storage-routes.test.ts index 390843b913..54c5bb9e88 100644 --- a/packages/services/service-storage/src/mount-storage-routes.test.ts +++ b/packages/services/service-storage/src/mount-storage-routes.test.ts @@ -44,6 +44,15 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { ObjectKernel } from '@objectstack/core'; import type { PluginContext } from '@objectstack/core'; +// The producer's own dispatch predicates open every scanned verb of the engine +// double below (#4434 / #5619): a fake looser than `ObjectQL` is how a dead +// route ships with its suite green. Resolved through the package `exports` to +// `dist/` on purpose (`KNOWN_UNALIASED_TEST_IMPORTS`). +import { + assertEngineDeleteDispatch, + assertEngineFindOnePredicate, + assertEngineUpdateDispatch, +} from '@objectstack/objectql'; import type { IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; import { LocalStorageAdapter } from './local-storage-adapter.js'; import { mountStorageRoutes, type MountStorageRoutesOptions } from './mount-storage-routes.js'; @@ -137,16 +146,26 @@ function makeEngine() { const rows = readRows(object).filter((row) => matchesWhere(row, q.where)); return typeof q.limit === 'number' ? rows.slice(0, q.limit) : rows; }, - findOne: async (object: string, q: Record = {}) => - readRows(object).find((row) => matchesWhere(row, q.where)) ?? null, + findOne: async (object: string, q: Record = {}) => { + assertEngineFindOnePredicate(object, q); + return readRows(object).find((row) => matchesWhere(row, q.where)) ?? null; + }, insert: async (object: string, row: Record) => { writeRows(object).push({ ...row }); return row; }, update: async (object: string, patch: Record, q: Record = {}) => { + assertEngineUpdateDispatch(patch, q); for (const row of writeRows(object)) if (matchesWhere(row, q.where)) Object.assign(row, patch); }, - delete: async () => {}, + delete: async (object: string, q: Record = {}) => { + assertEngineDeleteDispatch(q); + const rows = writeRows(object); + const keep = rows.filter((row) => !matchesWhere(row, q.where)); + const removed = rows.length - keep.length; + rows.splice(0, rows.length, ...keep); + return removed; + }, }; } diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 5b6bc99505..9f167b8c35 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3711,6 +3711,21 @@ "verb": "findOne", "pinned": 1 }, + { + "file": "packages/services/service-storage/src/mount-storage-routes.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/services/service-storage/src/mount-storage-routes.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/services/service-storage/src/mount-storage-routes.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/services/service-storage/src/storage-routes.metadata-outage.test.ts", "verb": "delete", From 5eb7f3e32ab20ef8d6fe9c533f1afc46ab6f8286 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 02:45:37 +0000 Subject: [PATCH 4/5] refactor(storage): the two gate builders stay module-private (#15169) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `buildAuthSessionResolver` and `buildFileReadAuthorizer` were given an `export` keyword by the composition refactor and nothing imports them: measured as import EDGES (an `import`/`export … from` clause naming the symbol, multi-line aware), both are 0, against firing positive controls `findFileHolder` 4, `mountStorageRoutes` 2, `StorageServicePlugin` 15. `buildFileReadAuthorizer` IS the ADR-0104 D3 download-authorization gate, so the keyword is not free: dropping it makes "a consumer gets no handle on the gate" hold at the module level too, not only because `index.ts` declines to re-export and the package's `exports` map publishes `"."` alone. `composeStorageRoutes` and `toGateRegistry` keep their exports — `mount-storage-routes.ts` imports them, and they hand back a report of booleans, never a gate. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 --- .../service-storage/src/storage-service-plugin.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/services/service-storage/src/storage-service-plugin.ts b/packages/services/service-storage/src/storage-service-plugin.ts index d3cfb014ff..c1f2cab61d 100644 --- a/packages/services/service-storage/src/storage-service-plugin.ts +++ b/packages/services/service-storage/src/storage-service-plugin.ts @@ -884,6 +884,11 @@ export interface StorageRoutesMountReport { * how a security decision acquires a second, divergent definition, and it * was refused (#15169 option C). * + * The three builders stay MODULE-private, not merely absent from `index.ts`: + * the package's `exports` map publishes `"."` alone, so no deep import reaches + * this file, and this file hands no caller a gate — only this composition, + * which binds them and returns booleans. + * * `resolveFileHolder` is bound to the reap guard's own `findFileHolder` * (#10246): one definition of "still held", asked by the sweep before it * reaps and by the download path before it refuses, so the two cannot answer @@ -948,7 +953,7 @@ export function composeStorageRoutes( * organization therefore means no stamp — the pre-#12745 behaviour, reported * by the backfill rather than invented here. */ -export function buildAuthSessionResolver( +function buildAuthSessionResolver( registry: StorageGateRegistry, ): ((req: { headers?: unknown }) => Promise) | undefined { const getSession = buildGetSession(registry); @@ -1100,7 +1105,7 @@ async function resolveAdmissionTenancyPosture( * more. A shared model would have had to union field references too, silently * widening access whenever one file id was copied into a more public record. */ -export function buildFileReadAuthorizer( +function buildFileReadAuthorizer( registry: StorageGateRegistry, engine: IDataEngine | null, ): ((file: FileRecord, req: { headers?: unknown }) => Promise) | undefined { From a697366715e8656e4f75fce8ed4b7bba6f67a734 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 05:19:05 +0000 Subject: [PATCH 5/5] ci(route-envelope): declare mount-storage-routes.ts (#15169) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mount-storage-routes.ts` matches the `*-routes.ts` discovery convention, so `check:route-envelope` found it and refused it as NOT DECLARED — undeclared is an error, never a default. It writes no response body of its own: it binds the three package-internal seams and hands the surface to `registerStorageRoutes`, so every `/api/v1/storage/*` body is still written by `storage-routes.ts` through the shared sendOk/sendError pair. Declared `{ responses: 0, ok: 0, err: 0 }` with a note in the neighbours' shape. No ratchet, no vendorWire, no other entry touched. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TezFG8ZMrNH6n5VTNpPpdH --- scripts/check-route-envelope.mjs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/scripts/check-route-envelope.mjs b/scripts/check-route-envelope.mjs index 0a444a2510..f39ceb78a5 100644 --- a/scripts/check-route-envelope.mjs +++ b/scripts/check-route-envelope.mjs @@ -254,6 +254,18 @@ const MODULES = { // (`runtime/src/domains/share-links.ts`) had always returned. 'packages/plugins/plugin-sharing/src/share-link-routes.ts': { responses: 0, ok: 0, err: 0 }, + // [#15169] The host door. `mountStorageRoutes` composes `/api/v1/storage/*` + // onto an HTTP surface the HOST owns, for a hosted kernel that registers no + // `http-server` service. It matches the `*-routes.ts` convention and so is + // discovered, but it answers nothing itself: it binds the three package-internal + // seams and hands the surface to `registerStorageRoutes`, so every body on that + // prefix is still written by `storage-routes.ts` above, through the shared pair. + // Zero is therefore structural rather than measured-and-hoped: a write site + // appearing here would mean the door started building bodies of its own, which is + // exactly the review this number exists to force. Declared in the same PR that + // created the module so the gap never exists. + 'packages/services/service-storage/src/mount-storage-routes.ts': { responses: 0, ok: 0, err: 0 }, + // ── Exempt ────────────────────────────────────────────────────────────── // A dev-only SSE endpoint (`GET|POST /api/v1/dev/metadata-events`) that closes