Skip to content

Commit dd2184a

Browse files
os-trumpclaude
andauthored
feat(service-storage): mountStorageRoutes — one host door for kernels with no http-server service (#16741)
* wip(storage): mountStorageRoutes host door (#15169) * wip(storage): fixture reads answer empty for undeclared tables (#15169) * 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * refactor(storage): the two gate builders stay module-private (#15169) `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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37 * ci(route-envelope): declare mount-storage-routes.ts (#15169) `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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TezFG8ZMrNH6n5VTNpPpdH --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 68f8f77 commit dd2184a

8 files changed

Lines changed: 927 additions & 30 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/service-storage": minor
3+
---
4+
5+
feat(storage): `mountStorageRoutes` — mount the storage routes on a host-owned HTTP surface, composed from a kernel that has no `http-server` service (#15169)
6+
7+
`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.
8+
9+
**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.
10+
11+
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.
12+
13+
Nothing existing changes shape or behaviour: `registerStorageRoutes` and `StorageRoutesOptions` are untouched, and `StorageServicePlugin` mounts exactly what it mounted before.

packages/services/service-storage/README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,34 @@ All routes are mounted at `/api/v1/storage` (configurable via `basePath`).
8888
| PUT | `/_local/raw/:token` | Local raw upload (presigned) |
8989
| GET | `/_local/raw/:token` | Local raw download (presigned) |
9090

91+
### Mounting the routes from a host (kernels with no `http-server` service)
92+
93+
`StorageServicePlugin` mounts the table above itself, at `kernel:ready`, on the
94+
kernel's `http-server` service. A kernel that registers no such service — a
95+
hosted per-environment tenant kernel — keeps the storage service, `sys_file`,
96+
the lifecycle hooks and the reap guards, but has no HTTP door. A host that
97+
owns the HTTP surface mounts the same routes with `mountStorageRoutes`:
98+
99+
```typescript
100+
import { mountStorageRoutes } from '@objectstack/service-storage';
101+
102+
// `http` is whatever the host registers routes on — an `IHttpServer` adapter,
103+
// or the host's own route-collecting shim that later dispatches into this
104+
// kernel. `kernel` is the environment kernel, AFTER it has bootstrapped.
105+
const report = mountStorageRoutes(http, kernel, { basePath: '/api/v1/storage' });
106+
// report: { basePath, sessionResolver, downloadAuthorizer, tombstoneHolderResolver, metadataStore }
107+
```
108+
109+
The door composes the upload session resolver, the download authorization
110+
gate (ADR-0104 D3) and the tombstone holder predicate from the kernel's own
111+
`auth` service and data engine — through the same composition the plugin's
112+
own mount uses. The options carry wire knobs only (`basePath`, the TTLs, a
113+
logger): none of the three gates can be supplied, replaced or omitted by the
114+
host, so the platform keeps exactly one definition of who may download a
115+
file. A kernel with no `storage` service throws; a kernel with no `auth`
116+
service or no data engine mounts with the matching gate off and says so at
117+
`warn`, exactly the bare-kernel behaviour the plugin has.
118+
91119
## Client SDK Usage
92120

93121
```typescript

packages/services/service-storage/src/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,19 @@ export type {
2020
FileReadVerdict,
2121
StorageUploadSession,
2222
} from './storage-routes.js';
23+
// [#15169] The host door: the storage routes composed from a kernel and
24+
// mounted on an HTTP surface the host owns — for kernels with no `http-server`
25+
// service (cloud's per-environment tenant kernels). Published as ONE entry
26+
// point rather than as the three gate builders it wires
27+
// (`buildAuthSessionResolver` / `buildFileReadAuthorizer` / `findFileHolder`,
28+
// which stay internal): the consumer gets the door, never a handle on the
29+
// ADR-0104 D3 download gate, so the platform keeps one definition of it.
30+
export { mountStorageRoutes } from './mount-storage-routes.js';
31+
export type {
32+
MountStorageRoutesOptions,
33+
StorageRouteKernel,
34+
StorageRoutesMountReport,
35+
} from './mount-storage-routes.js';
2336
export { SystemFile, SystemUploadSession } from './objects/index.js';
2437
export {
2538
installAttachmentLifecycleHooks,
@@ -33,6 +46,10 @@ export type { AttachmentLifecycleEngine, AttachmentLifecycleLogger } from './att
3346
// this project does not owe (implementation-first) — and the narrower the
3447
// ownership predicate's blast radius, the fewer places can drift weaker than
3548
// the guard. Export them the day a consumer exists.
49+
// [#15169] A consumer DID arrive — a host mounting the routes on a kernel with
50+
// no `http-server` — and it is served by `mountStorageRoutes` above, which
51+
// binds the predicate inside the package. The consumer needs the door, not the
52+
// predicate, so this declaration stands: the blast radius did not widen.
3653
export {
3754
inventoryStrandedFileOrphans,
3855
formatStrandedOrphanInventory,

0 commit comments

Comments
 (0)