Skip to content
Merged
13 changes: 13 additions & 0 deletions .changeset/storage-routes-host-mount.md
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 28 additions & 0 deletions packages/services/service-storage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions packages/services/service-storage/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Loading
Loading