Repository navigation
Commit 5e725b8
feat(service-storage): the file-attachments service, a server-side verb that stores bytes and attaches them to a record (#23386)
Fixes #23306
Clause-②: yes
A server-side producer (inbound mail first, then imports and generated
output) can now store bytes and attach them to a record.
`StorageServicePlugin` registers the kernel service `file-attachments`
(`FILE_ATTACHMENT_SERVICE`), declared in `@objectstack/spec/contracts`
as `IFileAttachmentService`. Its one verb, `attachFile({ parentObject,
parentId, organizationId, filename, mimeType, data })`, writes the
bytes, one `sys_file` row and one `sys_attachment` row linking it to the
parent, and answers `{ fileId, attachmentId, size }`. The widening is
additive: four new exports from `@objectstack/spec/contracts`
(`IFileAttachmentService`, `AttachFileInput`, `AttachedFile`,
`FILE_ATTACHMENT_SERVICE`) and one more service in
`StorageServicePlugin.providesServices`. Nothing is removed or renamed.
## One writer: each check, and where it comes from
The triage direction is "one writer, no second copy". Each check the
verb runs is the code every other attachment of the parent already
meets. Measured on a real `ObjectQL` over sqlite `:memory:` with the
plugin's own hooks and `plugin-audit`'s real writers installed:
| check | where it comes from | under the verb's system context |
| --- | --- | --- |
| upload size limit | `uploadSizeRefusal` in `storage-limits.ts`: the
verdict the REST upload doors ask, against the same
`resolveStorageLimits` over the `storage` service's Limits snapshot |
runs, `413 PAYLOAD_TOO_LARGE`, before anything is stored |
| the `sys_file` row and its organization stamp |
`StorageMetadataStore.createFile` / `updateFile` / `deleteFile`, the
upload doors' writer, through the same `StorageWriteContext` channel |
runs |
| `enable.files` opt-in | `plugin-audit`'s `enforceFilesCapability`, a
`beforeInsert` hook on `sys_attachment` | runs: `403 FILES_DISABLED`
(measured, and pinned with the real gate) |
| parent EDIT (`installAttachmentAccessHooks`) | the `sys_attachment`
`beforeInsert` hook in this package | returns at its system-context line
by its own declared design; `checkEdit` was called 0 times (measured). A
server-side producer has no caller whose edit access could be asked. |
| attachment lifecycle | `installAttachmentLifecycleHooks` `afterInsert`
| runs (a pending file is not a tombstone, so it changes nothing) |
| the parent's organization | the parent read by id as the system; its
organization from `createWallOrganizationResolver`, the package's one
answer to "which column is this record walled by" | a parent walled into
another organization than `organizationId` is `404 RECORD_NOT_FOUND`,
the same answer as a missing parent |
Both rows are written in the parent's organization when it has one,
otherwise in the caller's. `organizationId` is required and nullable:
acting in no organization is stated, never a left-out field.
### Mechanism hypotheses, measured
- **H1 holds, with one correction.** The upload door's "attach" is a
data-API insert of `sys_attachment` judged by `beforeInsert` hooks, so
the one writer is an engine insert, not a function in
`storage-routes.ts`. Under the system context the parent-EDIT gate does
not run (above). The three refusals the card names do run.
- **H2 holds, and "quota" is the per-upload limit.** No per-organization
aggregate storage quota exists in open code (`git grep -i` for a storage
quota concept: 0 hits; the only `quota` code is SMS, lifecycle row
counts and AI). The byte-acceptance limit is `max_upload_mb`. It was
enforced by `requireWithinUploadLimit`, a closure inside
`registerStorageFamily` (`storage-routes.ts`) with the refusal constants
beside it. **The extraction is a move:** the status, the code, the
sentence and the comparison now live in `storage-limits.ts`
(`uploadSizeRefusal`, `UPLOAD_TOO_LARGE_STATUS`,
`UPLOAD_TOO_LARGE_CODE`), beside the limit; the doors call it, and so
does the verb. The doors answer byte-for-byte as before (their 18 limit
pins stay green). `main` was merged before this PR opened (PR #23346
landed in the same file; its hunks and these are disjoint).
- **H3: a sibling contract, not a member of `IStorageService`.**
`IStorageService` is the blob store an adapter implements
(`LocalStorageAdapter`, `S3StorageAdapter`, third parties), and the
`storage` slot's occupant can be a bare adapter with no data engine. A
row-writing verb there would be an optional member most implementors
could never honour. The sibling follows the existing non-core slot
convention (`SEED_SETTLEMENT_SERVICE`, `SHARE_LINK_SERVICE`): an
interface plus a slot constant, in `storage-service.ts` beside
`IFileAccessDelegate`.
- **H4: no refusal leaves a committed `sys_file`, by write order.** The
order is the presigned door's: the `sys_file` row first, `pending`, then
the bytes, so the row is always the pointer the pending-upload retention
and the reap guard reclaim the bytes by, even if the process dies
mid-call. The row turns `committed` only in the same
`engine.transaction` as the `sys_attachment` insert. A refused call then
removes the bytes and the pending row before it rethrows. On an engine
with no transaction, or inside a caller's transaction the verb joined,
the commit never runs after a refused insert, and the verb removes an
attachment row it did insert.
`attachment-refused-attach-tombstone.test.ts` was read first: its
tombstoner is for a user's committed upload; this verb never leaves one,
so it does not need it.
The verb is armed at `kernel:bootstrapped`, after every plugin's
`kernel:ready` handler has installed its gates (`AuditPlugin` installs
the `enable.files` gate from its own `kernel:ready`). Before that, or
with no data engine, it refuses `503 SERVICE_UNAVAILABLE` rather than
write a row a gate has not judged.
## Tests
`packages/services/service-storage/src/file-attachment-service.test.ts`,
17 tests, through the plugin's own wiring: `init`, `start`,
`kernel:ready`, `kernel:bootstrapped`, the service resolved by
`FILE_ATTACHMENT_SERVICE`, a settings namespace carrying
`max_upload_mb`, the plugin's local adapter in a temp directory, and
`plugin-audit`'s real gate. `@objectstack/plugin-audit` is a new
devDependency, aliased to source in `vitest.config.ts` like `core` and
`driver-sql`.
- Success: one committed `sys_file` (attachments scope, private, key
`attachments/` plus the file id plus the extension, bytes behind it) and
one `sys_attachment`, both in the parent's organization.
- The three refusals the card lists, each asserting `code` and `status`
and that NOTHING is left (no `sys_file` committed or pending, no
`sys_attachment`, no stored key), each beside its control (the same call
without the refused condition succeeds):
- over the limit: `413 PAYLOAD_TOO_LARGE`; control: exactly at the
limit;
- `enable.files` off: `403 FILES_DISABLED`; control: an opted-in object;
- parent in another organization: `404 RECORD_NOT_FOUND`; control: the
same parent from its own organization. A missing parent answers the
same.
- Organization stamping: `null` takes the parent's; a global parent
takes the caller's.
- A refused commit after the insert rolls the attachment back; on an
engine with no transaction the verb removes the row itself; an upload
failure removes the pending row and propagates unchanged.
- `503` before `kernel:bootstrapped` and with no engine; `400
VALIDATION_ERROR` for seven malformed inputs.
`packages/spec/src/contracts/storage-service.test.ts`: the slot name and
the contract's shape.
### Ablations (one-off, each through `scripts/ablation-replace.mjs` in
WRAP mode)
Every leg: the anchor hit once and moved 1 to 0, the blob changed on
disk, and the restore was proven (blob equals HEAD, `git diff HEAD`
empty). The suite imports the mutated modules from source (relative
imports, and `plugin-audit` through its source alias), so no rebuild
sits between mutation and measurement. Run at `00e7f3217`; the five
mutated files are byte-identical at the final head `ac4fcfdaf` (`git
diff` empty across the merge).
| leg | mutation | prediction | observed |
| --- | --- | --- | --- |
| A1 | the verb ignores the size verdict | 413 pin red | 1 red (413 pin)
|
| A2 | a refused call cleans nothing up | the leave-nothing pins red | 4
red (FILES_DISABLED, commit rollback, no-transaction, upload failure) |
| A2b | the row is written `committed` up front | green: the cleanup
still removes it | 17 green, as predicted. The crash-window half of
pending-first (process death mid-call) is not pinned. |
| A3 | no organization wall | 404 pin red | 1 red |
| A4 | no transaction, claimed owned | rollback pins red | 2 red |
| A5 | no compensation outside an owned transaction | no-transaction pin
red | 1 red |
| A6 | armed at `kernel:ready` | the 503-until-bootstrapped pin red | 1
red |
| A7 | `organizationId` not validated | validation pin red | 1 red |
| A8 | the shared `uploadSizeRefusal` admits everything | the verb's pin
AND the doors' pins red | verb 1 red; `storage-limits.test.ts` 12 red:
one verdict for both |
| A10 | `plugin-audit`'s `enable.files` gate never refuses |
FILES_DISABLED pin red | 1 red: the verb does not bypass the gate |
The first attempts at A2 and A6 were refused by the tool as no-ops (the
replacement contained the anchor, so the anchor count did not drop) and
were re-anchored; the observed rows above are the re-anchored runs.
### Readings (final head `ac4fcfdaf`, after merging `main`)
- `pnpm --filter @objectstack/service-storage exec vitest run
--maxWorkers=2`: 55 files, 956 tests passed.
- `pnpm --filter @objectstack/service-storage typecheck`: exit 0; the
test program compiles all 55 test files, 0 debt.
- `pnpm --filter @objectstack/spec exec vitest run --project local
--maxWorkers=2 src/contracts`: 48 files, 453 tests passed. `pnpm
--filter @objectstack/spec typecheck`: exit 0, test-typecheck ledger
unchanged.
- `pnpm --filter @objectstack/spec check:generated`: 14 of 14 artifacts
current, built from this tree. The `contracts` shards of `api-surface/`
and `export-origins/` gained exactly the four exports.
- Narrowing, declared: the full `@objectstack/spec` local suite ran past
the 10-minute foreground cap and was not measured locally;
`src/contracts` (the changed directory) was. `turbo ls --affected` names
82 packages (the package graph only: it is blind to tests that read
another package's source from disk, and root-level suites are not on
it). The spec change is additive (0 removed exports), no package
re-exports `@objectstack/spec/contracts` wholesale, and the four names
have 0 other declarations in the tree, so no downstream verdict can move
through it. CI runs the farm.
- ESLint, narrowed and measured: `eslint --no-inline-config --format
json` over the 8 changed `.ts` files gives 8 files, 0 errors, 0
warnings, 0 fatal. Population: `eslint.config.mjs` `files:
'**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'`; the rest of the diff is md,
mdx, json and yaml. Invariance: the config enables no type-aware linting
(no `parserOptions.project`), so no untouched file's verdict can move.
- Built output: `require` of `dist/index.cjs` and `import` of
`dist/index.js` both load and list the new service.
- Gates: `node scripts/pm/dispatch-gates.mjs --commands` (no paths)
derives 124 families at `ac4fcfdaf`; 120 ran with exit 0, and `--ran`
reconciles 124 of 124 with 0 UNRUN. NOT MEASURED, 4:
`check:skill-examples`, `check:dual-build-cjs-loads` and `check:i18n`
refused with their own PREREQUISITE NOT MET (exit 3: packages this diff
does not touch are not built here; the changed package's built CJS and
ESM entries were probed directly, above); `check:type-check-debt` was
not run (its `--re-measure` is a repo-wide tsc sweep; this diff adds no
package and moves no ledger entry). `check:tenant-audit-census` is green
on the re-derived census.
## Paths beyond the claimed surface (each forced by a named gate or by
the tests)
- `content/docs/permissions/tenant-audit-census.mdx` and
`docs/audits/2026-08-tenant-audit-write-call-sites.counts.md`:
`check:tenant-audit-census` re-derives the write-call-site census; the
verb adds two `sys_attachment` writes, both carrying a tenant context.
Regenerated with `node scripts/tenant-audit-census.mjs --write`, with
the page's enforced prose figures moved with it (on the merged tree: 226
to 228).
- `packages/services/service-storage/package.json`, `vitest.config.ts`
and `pnpm-lock.yaml`: `@objectstack/plugin-audit` as a test-only
dependency. The lockfile change is the three-line importer entry,
accepted by `pnpm install --frozen-lockfile`; a plain `pnpm install`
also re-resolved an unrelated vitest peer variant, which is not in this
diff.
## Acceptance notes
- No README or docs page names the new service yet. The contract
docblock is its documentation; the consumer card #23307 reads it.
- A system call with `organizationId: null` against a global parent on a
walled deployment is refused by the engine's own system-insert
organization rule, surfaced as the store's `StorageMetadataStoreError`
(whose `Cause:` names it), not as a `4xx`. That is the upload doors'
behaviour for a session with no active organization too.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01YRp5z4kK6vgYkrdH97ztsy)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent eeb95f7 commit 5e725b8
15 files changed
Lines changed: 1053 additions & 66 deletions
File tree
- .changeset
- content/docs/permissions
- docs/audits
- packages
- services/service-storage
- src
- spec
- api-surface
- export-origins
- src/contracts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
83 | 83 | | |
84 | 84 | | |
85 | 85 | | |
86 | | - | |
| 86 | + | |
87 | 87 | | |
88 | 88 | | |
89 | 89 | | |
| |||
122 | 122 | | |
123 | 123 | | |
124 | 124 | | |
125 | | - | |
| 125 | + | |
126 | 126 | | |
127 | 127 | | |
128 | 128 | | |
| |||
187 | 187 | | |
188 | 188 | | |
189 | 189 | | |
190 | | - | |
| 190 | + | |
191 | 191 | | |
192 | | - | |
193 | | - | |
| 192 | + | |
| 193 | + | |
194 | 194 | | |
195 | 195 | | |
196 | 196 | | |
| |||
200 | 200 | | |
201 | 201 | | |
202 | 202 | | |
203 | | - | |
| 203 | + | |
204 | 204 | | |
205 | 205 | | |
206 | 206 | | |
207 | 207 | | |
208 | 208 | | |
209 | 209 | | |
210 | | - | |
| 210 | + | |
211 | 211 | | |
212 | 212 | | |
213 | 213 | | |
214 | | - | |
| 214 | + | |
215 | 215 | | |
216 | 216 | | |
217 | 217 | | |
| |||
223 | 223 | | |
224 | 224 | | |
225 | 225 | | |
226 | | - | |
227 | | - | |
| 226 | + | |
| 227 | + | |
228 | 228 | | |
229 | | - | |
| 229 | + | |
230 | 230 | | |
231 | | - | |
| 231 | + | |
232 | 232 | | |
233 | 233 | | |
234 | 234 | | |
235 | 235 | | |
236 | 236 | | |
237 | 237 | | |
238 | | - | |
| 238 | + | |
239 | 239 | | |
240 | 240 | | |
241 | 241 | | |
242 | | - | |
243 | | - | |
| 242 | + | |
| 243 | + | |
244 | 244 | | |
245 | 245 | | |
246 | 246 | | |
247 | | - | |
| 247 | + | |
248 | 248 | | |
249 | 249 | | |
250 | 250 | | |
| |||
297 | 297 | | |
298 | 298 | | |
299 | 299 | | |
300 | | - | |
| 300 | + | |
301 | 301 | | |
302 | 302 | | |
303 | 303 | | |
304 | | - | |
305 | | - | |
| 304 | + | |
| 305 | + | |
306 | 306 | | |
307 | | - | |
| 307 | + | |
308 | 308 | | |
309 | 309 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
33 | 33 | | |
34 | 34 | | |
35 | 35 | | |
36 | | - | |
37 | | - | |
| 36 | + | |
| 37 | + | |
38 | 38 | | |
39 | | - | |
| 39 | + | |
40 | 40 | | |
41 | | - | |
| 41 | + | |
42 | 42 | | |
43 | 43 | | |
44 | 44 | | |
45 | 45 | | |
46 | 46 | | |
47 | 47 | | |
48 | | - | |
| 48 | + | |
49 | 49 | | |
50 | 50 | | |
51 | 51 | | |
| |||
90 | 90 | | |
91 | 91 | | |
92 | 92 | | |
93 | | - | |
| 93 | + | |
94 | 94 | | |
95 | 95 | | |
96 | 96 | | |
97 | | - | |
98 | | - | |
| 97 | + | |
| 98 | + | |
99 | 99 | | |
100 | | - | |
| 100 | + | |
101 | 101 | | |
102 | 102 | | |
103 | 103 | | |
| |||
238 | 238 | | |
239 | 239 | | |
240 | 240 | | |
| 241 | + | |
| 242 | + | |
241 | 243 | | |
242 | 244 | | |
243 | 245 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
46 | 46 | | |
47 | 47 | | |
48 | 48 | | |
| 49 | + | |
49 | 50 | | |
50 | 51 | | |
51 | 52 | | |
| |||
0 commit comments