From 807ee84815fd465a67c744408c7005a9e7fb21cf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 02:45:50 +0000 Subject: [PATCH 1/4] fix(service-storage)!: the upload commit, chunked-completion and progress doors act only for the uploader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One ownership rule, `isFileUploader` in a new `upload-ownership.ts`, declared once and consulted by the three upload doors that act on an id the caller names. Each door calls it through `requireUploader` right after its by-id read and before any write or disclosure: the caller's user id must equal the file's `owner_id`; an upload session reaches its uploader through `file_id`; an empty owner or a missing file row is refused; no administrator exception. The refusal is one body, `403 PERMISSION_DENIED`, whoever is refused. Open mode (no session resolver wired) is unchanged: there is no caller identity to compare. The download doors, the start doors, the tenant stamp and scope, and the not-found answers are unchanged. Pins per door: the uploader passes; a same-organization non-uploader and a caller acting in another organization get the identical refusal with the rows untouched; the rule precedes the expiry write. Three route fixtures in the tenant-audit suite gain the uploader their doors now require, and the envelope conformance suite gains the new refusal. Clause-②: no (narrowing) Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude --- .../22046-upload-doors-uploader-only.md | 18 + .../src/error-envelope.conformance.test.ts | 20 + .../service-storage/src/storage-routes.ts | 52 ++- ...t-audit-update-delete-half-repairs.test.ts | 8 +- .../src/upload-door-ownership.test.ts | 403 ++++++++++++++++++ .../service-storage/src/upload-ownership.ts | 61 +++ 6 files changed, 559 insertions(+), 3 deletions(-) create mode 100644 .changeset/22046-upload-doors-uploader-only.md create mode 100644 packages/services/service-storage/src/upload-door-ownership.test.ts create mode 100644 packages/services/service-storage/src/upload-ownership.ts diff --git a/.changeset/22046-upload-doors-uploader-only.md b/.changeset/22046-upload-doors-uploader-only.md new file mode 100644 index 00000000000..87419941eb8 --- /dev/null +++ b/.changeset/22046-upload-doors-uploader-only.md @@ -0,0 +1,18 @@ +--- +'@objectstack/service-storage': minor +--- + +fix(service-storage)!: the upload commit, chunked-completion and progress doors act only for the user who started the upload + +Clause-②: no (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +`POST /api/v1/storage/upload/complete`, `POST /api/v1/storage/upload/chunked/:uploadId/complete` and `GET /api/v1/storage/upload/chunked/:uploadId/progress` now act only for the user who started the upload. The rule is declared once and each of the three doors consults it: the signed-in user must be the file's `owner_id`, which both upload-start doors already stamp from the session. A chunked upload reaches its uploader through its file. + +- **Refused now:** every other caller, with `403 PERMISSION_DENIED`. The answer is the same whichever organization the caller acts in, and it comes before anything is written or returned. There is no administrator exception. A file with no recorded uploader, or a chunked upload whose file is gone, is refused the same way rather than given a guessed owner. +- **Unchanged:** the uploader's own calls, who may start an upload, who may download a file (the download doors' own authorization), the organization the doors stamp and scope by, and the not-found answers. A deployment with no `auth` service, whose upload routes run without a session resolver, keeps them open as before. + +What changes for you: complete and poll an upload as the same signed-in user who started it. An upload left pending with no recorded uploader cannot be committed; start it again. diff --git a/packages/services/service-storage/src/error-envelope.conformance.test.ts b/packages/services/service-storage/src/error-envelope.conformance.test.ts index 9a21bc69322..4559a01b5ed 100644 --- a/packages/services/service-storage/src/error-envelope.conformance.test.ts +++ b/packages/services/service-storage/src/error-envelope.conformance.test.ts @@ -202,6 +202,26 @@ describe('storage error envelope (#3675)', () => { }); }, }, + { + // #22046: the upload ownership rule's one refusal. + name: 'committing an upload the caller did not start', + status: 403, + code: 'PERMISSION_DENIED', + run: async () => { + const store = new StorageMetadataStore(null); + await store.createFile({ + id: 'f-owned', + key: 'user/f-owned.bin', + name: 'f.bin', + status: 'pending', + owner_id: 'u-uploader', + }); + const routes = mount(await tmpAdapter(), store, { + resolveSession: async () => ({ userId: 'u-someone-else' }), + }); + return drive(routes, 'POST', `${BASE}/upload/complete`, { body: { fileId: 'f-owned' } }); + }, + }, { name: 'anonymous upload when a session resolver is wired', status: 401, diff --git a/packages/services/service-storage/src/storage-routes.ts b/packages/services/service-storage/src/storage-routes.ts index 44ecc8e64ae..3ea485cd37f 100644 --- a/packages/services/service-storage/src/storage-routes.ts +++ b/packages/services/service-storage/src/storage-routes.ts @@ -17,6 +17,15 @@ import type { StorageWriteContext, } from './metadata-store.js'; import type { LocalStorageAdapter } from './local-storage-adapter.js'; +// [#22046] The ONE upload ownership rule. Declared in its own module and only +// CALLED here — the three doors below share it rather than each carrying a +// copy of the comparison. +import { + isFileUploader, + NOT_UPLOADER_CODE, + NOT_UPLOADER_MESSAGE, + NOT_UPLOADER_STATUS, +} from './upload-ownership.js'; // Type only. The PREDICATE is never re-implemented in this file (#10246): it // arrives through `opts.resolveFileHolder`, which the plugin binds to the reap // guard's own `findFileHolder`. @@ -67,8 +76,11 @@ export interface StorageRoutesOptions { * presigned/complete/chunked upload routes reject anonymous requests with * 401 `AUTH_REQUIRED`, and new sys_file rows are stamped with * `owner_id = session.userId` and — since #12745 — with the session's active - * `organizationId`. When absent (bare kernels, tests), the - * routes stay open — back-compat, logged once. Download routes are NOT + * `organizationId`. Since #22046 the commit, chunked-completion and + * progress doors also refuse a caller who is not the file's uploader + * (`isFileUploader`, `403 PERMISSION_DENIED`). When absent (bare kernels, + * tests), the routes stay open — back-compat, logged once — and there is no + * caller identity for the ownership rule to compare. Download routes are NOT * gated here (capability URLs embedded in /; gating them * is a tracked follow-up needing cookie sessions or signed links). */ @@ -305,6 +317,32 @@ export function registerStorageRoutes( return session; }; + // ── Upload ownership gate (#22046, ruling B) ───────────────────────── + // The commit, chunked-completion and progress doors act on an id the + // caller NAMES. Each calls this right after its by-id read and before any + // write or disclosure; `false` ⇒ the refusal was already sent and the + // handler must stop. The rule itself is `isFileUploader` + // (`upload-ownership.ts`) — the caller must be the file's uploader, an + // empty owner and a missing file row are refused, no administrator + // exception — and the answer is the same body whoever is refused. + // + // `authSession === null` is the OPEN mode `requireUploadSession` returns + // when no session resolver is wired (bare kernels, tests): there is no + // caller identity to compare, every upload in that mode lands with no + // owner, and the routes stay open exactly as `resolveSession` declares. + // Any wired resolver yields a session with a user id, and the rule then + // applies without exception. + const requireUploader = ( + authSession: StorageUploadSession | null, + file: FileRecord | null, + res: IHttpResponse, + ): boolean => { + if (authSession === null) return true; + if (isFileUploader(authSession.userId, file)) return true; + sendError(res, NOT_UPLOADER_STATUS, NOT_UPLOADER_CODE, NOT_UPLOADER_MESSAGE); + return false; + }; + // ── Terminal upload-session statuses (#7667) ───────────────────────────── // `sys_upload_session.status` declares `failed` and `expired`, the retention // backstop reaps on them (`onlyWhen: { status: { $in: ['completed', @@ -468,6 +506,9 @@ export function registerStorageRoutes( sendError(res, 404, 'FILE_NOT_FOUND', 'File not found'); return; } + // [#22046] Only the uploader commits — before the write below and + // before anything about the row is answered. + if (!requireUploader(session, file, res)) return; const updated = await store.updateFile( fileId, @@ -690,6 +731,10 @@ export function registerStorageRoutes( sendError(res, 404, 'UPLOAD_SESSION_NOT_FOUND', 'Upload session not found'); return; } + // [#22046] The session row names no user; it reaches its uploader + // through `file_id`. Asked before the expiry stamp, the status writes + // and the completion answer below. + if (!requireUploader(authSession, await store.getFile(session.file_id), res)) return; const live = await expireIfPastDeadline(session, writeContext); if (live.status === 'expired') { @@ -762,6 +807,9 @@ export function registerStorageRoutes( sendError(res, 404, 'UPLOAD_SESSION_NOT_FOUND', 'Upload session not found'); return; } + // [#22046] Same rule, same place: after the by-id read, before the + // expiry write below and before the progress answer discloses the row. + if (!requireUploader(authSession, await store.getFile(stored.file_id), res)) return; // Progress REPORTS the expiry rather than refusing it: `expired` is a // declared member of `UploadProgressSchema.status`, and a resuming client diff --git a/packages/services/service-storage/src/tenant-audit-update-delete-half-repairs.test.ts b/packages/services/service-storage/src/tenant-audit-update-delete-half-repairs.test.ts index 63ba4fb1b10..e0ec95d3355 100644 --- a/packages/services/service-storage/src/tenant-audit-update-delete-half-repairs.test.ts +++ b/packages/services/service-storage/src/tenant-audit-update-delete-half-repairs.test.ts @@ -346,7 +346,11 @@ async function withRoutes( } } -const committedFile = (id: string): FileRecord => ({ ...fileRec(id), status: 'pending' }); +// [#22046] The rows these doors act on carry their uploader: the commit and +// progress doors now refuse a caller who is not the file's `owner_id` before +// any write, so a seed with no owner would measure that refusal instead of +// the write context these cases pin. `u1` is the session `withRoutes` hands in. +const committedFile = (id: string): FileRecord => ({ ...fileRec(id), status: 'pending', owner_id: 'u1' }); describe('[#13178] the routes: the upload doors pass the session organization on their writes', () => { it('POST /upload/complete scopes the sys_file commit — the session it used to discard', async () => { @@ -443,6 +447,8 @@ describe('[#13178] the routes: the upload doors pass the session organization on // be missed when threading a write context by hand. data: { ...sessionRec('s1'), expires_at: new Date(Date.now() - 60_000).toISOString() }, }, + // [#22046] The session reaches its uploader through `file_id`. + { object: 'sys_file', data: committedFile('f_s1') }, ], async (server) => { const res = createMockRes(); diff --git a/packages/services/service-storage/src/upload-door-ownership.test.ts b/packages/services/service-storage/src/upload-door-ownership.test.ts new file mode 100644 index 00000000000..dc272e8a882 --- /dev/null +++ b/packages/services/service-storage/src/upload-door-ownership.test.ts @@ -0,0 +1,403 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #22046 (ruling B) — the three upload doors that act on an id the caller +// names consult ONE ownership rule, `isFileUploader` (`upload-ownership.ts`), +// right after their by-id read and before any write or disclosure. +// +// Per door this file carries: +// - a POSITIVE pin: the uploader passes; +// - a SAME-ORGANIZATION negative pin: a caller who is not the uploader gets +// `403 PERMISSION_DENIED`, and nothing about the row is written or shown; +// - a CROSS-ORGANIZATION negative pin at this altitude: a caller acting in +// another organization gets the identical body. (The same pin on a booted +// organization wall is a separate measurement; see the PR.) +// +// Plus the ruled edges: an empty `owner_id` is refused, a session whose file +// row is gone is refused, there is no administrator exception, and the +// not-found answers the doors gave before are unchanged. +// +// The store here is the engine-absent stand-in, whose writes are NOT tenant +// scoped — so a door that skipped the rule would visibly change the row, which +// is what makes every "row unchanged" assertion below able to fail. + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import type { IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; +import { LocalStorageAdapter } from './local-storage-adapter.js'; +import { StorageMetadataStore } from './metadata-store.js'; +import { registerStorageRoutes } from './storage-routes.js'; +import type { StorageUploadSession } from './storage-routes.js'; +import { isFileUploader } from './upload-ownership.js'; + +const BASE = '/api/v1/storage'; +const COMMIT = `${BASE}/upload/complete`; +const CHUNKED_COMPLETE = `${BASE}/upload/chunked/:uploadId/complete`; +const PROGRESS = `${BASE}/upload/chunked/:uploadId/progress`; + +/** The uploader. */ +const ALICE: StorageUploadSession = { userId: 'u_alice', organizationId: 'org_a' }; +/** Same organization as the uploader, not the uploader. */ +const BOB: StorageUploadSession = { userId: 'u_bob', organizationId: 'org_a' }; +/** Another organization entirely. */ +const CAROL: StorageUploadSession = { userId: 'u_carol', organizationId: 'org_b' }; +/** A caller holding an administrator role — the rule has no exception for one. */ +const ADMIN: StorageUploadSession = { userId: 'u_admin', organizationId: 'org_a' }; + +const SESSIONS: Record = { + alice: ALICE, + bob: BOB, + carol: CAROL, + admin: ADMIN, +}; +type Who = keyof typeof SESSIONS; + +interface Answer { + status: number; + body: any; +} + +function mount( + adapter: LocalStorageAdapter, + store: StorageMetadataStore, + opts: { open?: boolean } = {}, +): Map { + const routes = new Map(); + const http = { + get: (p: string, h: RouteHandler) => { routes.set(`GET:${p}`, h); }, + post: (p: string, h: RouteHandler) => { routes.set(`POST:${p}`, h); }, + put: (p: string, h: RouteHandler) => { routes.set(`PUT:${p}`, h); }, + delete: () => {}, + patch: () => {}, + use: () => {}, + listen: async () => {}, + close: async () => {}, + }; + registerStorageRoutes(http as any, adapter, store, { + basePath: BASE, + ...(opts.open + ? {} + : { + resolveSession: async (req: IHttpRequest) => + SESSIONS[String(req.headers['x-test-user'] ?? '')] ?? null, + }), + }); + return routes; +} + +async function call( + routes: Map, + method: string, + path: string, + who: Who | null, + req: Partial = {}, +): Promise { + const handler = routes.get(`${method}:${path}`); + if (!handler) throw new Error(`no handler for ${method} ${path}`); + const answer: Answer = { status: 200, body: undefined }; + const res: any = { + json(data: any) { answer.body = data; }, + send() {}, + status(code: number) { answer.status = code; return res; }, + header() { return res; }, + }; + await handler( + { + params: {}, + query: {}, + body: undefined, + method, + path, + ...req, + headers: { ...(who ? { 'x-test-user': who } : {}), ...(req.headers ?? {}) }, + } as IHttpRequest, + res as IHttpResponse, + ); + return answer; +} + +/** The refusal every non-uploader gets — code and status, never the prose. */ +function expectNotUploader(answer: Answer, label: string): void { + expect(answer.status, label).toBe(403); + expect(answer.body?.success, label).toBe(false); + expect(answer.body?.error?.code, label).toBe('PERMISSION_DENIED'); +} + +/** Nothing about the row the caller named reached the refusal body. */ +function expectDisclosesNothing(answer: Answer, secrets: string[], label: string): void { + expect(answer.body?.data, label).toBeUndefined(); + const text = JSON.stringify(answer.body); + for (const s of secrets) expect(text.includes(s), `${label}: ${s}`).toBe(false); +} + +let rootDir: string; +let adapter: LocalStorageAdapter; +let store: StorageMetadataStore; +let routes: Map; + +beforeEach(async () => { + rootDir = join(tmpdir(), `os-22046-${Date.now()}-${Math.random().toString(36).slice(2)}`); + await fs.mkdir(rootDir, { recursive: true }); + adapter = new LocalStorageAdapter({ rootDir, signingSecret: 'test-secret' }); + store = new StorageMetadataStore(null); + routes = mount(adapter, store); +}); + +afterEach(async () => { + await fs.rm(rootDir, { recursive: true, force: true }); +}); + +/** Alice starts a presigned upload through the real start door. */ +async function alicePresigns(filename = 'alice-plan.txt'): Promise { + const res = await call(routes, 'POST', `${BASE}/upload/presigned`, 'alice', { + body: { filename, mimeType: 'text/plain', size: 5 }, + }); + expect(res.status).toBe(200); + return res.body.data.fileId as string; +} + +/** Alice starts a chunked upload through the real start door. */ +async function aliceStartsChunked( + filename = 'alice-chunked.bin', +): Promise<{ uploadId: string; fileId: string }> { + const res = await call(routes, 'POST', `${BASE}/upload/chunked`, 'alice', { + body: { filename, mimeType: 'application/octet-stream', totalSize: 10 }, + }); + expect(res.status).toBe(200); + return { uploadId: res.body.data.uploadId, fileId: res.body.data.fileId }; +} + +// --------------------------------------------------------------------------- +// The predicate +// --------------------------------------------------------------------------- + +describe('isFileUploader — the one rule', () => { + it('allows the uploader', () => { + expect(isFileUploader('u_alice', { owner_id: 'u_alice' })).toBe(true); + }); + + it('refuses anyone else', () => { + expect(isFileUploader('u_bob', { owner_id: 'u_alice' })).toBe(false); + }); + + it('refuses an empty owner — it never guesses one', () => { + expect(isFileUploader('u_alice', {})).toBe(false); + expect(isFileUploader('u_alice', { owner_id: undefined })).toBe(false); + expect(isFileUploader('u_alice', { owner_id: '' })).toBe(false); + expect(isFileUploader('u_alice', { owner_id: null as unknown as string })).toBe(false); + }); + + it('refuses a missing file row, and a caller with no user id', () => { + expect(isFileUploader('u_alice', null)).toBe(false); + expect(isFileUploader('u_alice', undefined)).toBe(false); + expect(isFileUploader(undefined, { owner_id: 'u_alice' })).toBe(false); + expect(isFileUploader('', { owner_id: '' })).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// The commit door +// --------------------------------------------------------------------------- + +describe('the commit door (POST /upload/complete)', () => { + it('POSITIVE: the uploader commits their own upload', async () => { + const fileId = await alicePresigns(); + const res = await call(routes, 'POST', COMMIT, 'alice', { body: { fileId, eTag: 'etag-alice' } }); + expect(res.status).toBe(200); + expect(res.body.data.fileId).toBe(fileId); + const row = await store.getFile(fileId); + expect(row?.status).toBe('committed'); + expect(row?.etag).toBe('etag-alice'); + }); + + it('SAME ORGANIZATION: a non-uploader is refused 403 PERMISSION_DENIED, and the row is untouched', async () => { + const fileId = await alicePresigns(); + const res = await call(routes, 'POST', COMMIT, 'bob', { body: { fileId, eTag: 'etag-bob' } }); + expectNotUploader(res, 'bob'); + expectDisclosesNothing(res, ['alice-plan', 'u_alice', 'org_a', fileId], 'bob'); + const row = await store.getFile(fileId); + expect(row?.status).toBe('pending'); + expect(row?.etag).toBeUndefined(); + }); + + it('ANOTHER ORGANIZATION: the identical refusal, and the row is untouched', async () => { + const fileId = await alicePresigns(); + const same = await call(routes, 'POST', COMMIT, 'bob', { body: { fileId, eTag: 'etag-bob' } }); + const cross = await call(routes, 'POST', COMMIT, 'carol', { body: { fileId, eTag: 'etag-carol' } }); + expectNotUploader(cross, 'carol'); + expect(cross).toEqual(same); + const row = await store.getFile(fileId); + expect(row?.status).toBe('pending'); + expect(row?.etag).toBeUndefined(); + }); + + it('no administrator exception', async () => { + const fileId = await alicePresigns(); + expectNotUploader(await call(routes, 'POST', COMMIT, 'admin', { body: { fileId } }), 'admin'); + expect((await store.getFile(fileId))?.status).toBe('pending'); + }); + + it('an empty owner_id is refused — even to a caller who would otherwise pass', async () => { + for (const owner of [undefined, '']) { + const id = `f_ownerless_${owner === undefined ? 'unset' : 'empty'}`; + await store.createFile({ id, key: `user/${id}.txt`, name: `${id}.txt`, status: 'pending', owner_id: owner }); + expectNotUploader(await call(routes, 'POST', COMMIT, 'alice', { body: { fileId: id } }), id); + expect((await store.getFile(id))?.status).toBe('pending'); + } + }); + + it('the not-found answer is unchanged', async () => { + const res = await call(routes, 'POST', COMMIT, 'alice', { body: { fileId: 'f_no_such_file' } }); + expect(res.status).toBe(404); + expect(res.body?.error?.code).toBe('FILE_NOT_FOUND'); + }); +}); + +// --------------------------------------------------------------------------- +// The chunked-completion door +// --------------------------------------------------------------------------- + +describe('the chunked-completion door (POST /upload/chunked/:uploadId/complete)', () => { + it('POSITIVE: the uploader completes their own upload', async () => { + const { uploadId, fileId } = await aliceStartsChunked(); + const res = await call(routes, 'POST', CHUNKED_COMPLETE, 'alice', { + params: { uploadId }, + body: { parts: [] }, + }); + expect(res.status).toBe(200); + expect(res.body.data.fileId).toBe(fileId); + expect((await store.getSession(uploadId))?.status).toBe('completed'); + expect((await store.getFile(fileId))?.status).toBe('committed'); + }); + + it('SAME ORGANIZATION: a non-uploader is refused 403 PERMISSION_DENIED, and neither row moves', async () => { + const { uploadId, fileId } = await aliceStartsChunked(); + const res = await call(routes, 'POST', CHUNKED_COMPLETE, 'bob', { + params: { uploadId }, + body: { parts: [] }, + }); + expectNotUploader(res, 'bob'); + expectDisclosesNothing(res, ['alice-chunked', 'u_alice', 'org_a', fileId], 'bob'); + expect((await store.getSession(uploadId))?.status).toBe('in_progress'); + expect((await store.getFile(fileId))?.status).toBe('pending'); + }); + + it('ANOTHER ORGANIZATION: the identical refusal, and neither row moves', async () => { + const { uploadId, fileId } = await aliceStartsChunked(); + const same = await call(routes, 'POST', CHUNKED_COMPLETE, 'bob', { params: { uploadId }, body: { parts: [] } }); + const cross = await call(routes, 'POST', CHUNKED_COMPLETE, 'carol', { params: { uploadId }, body: { parts: [] } }); + expectNotUploader(cross, 'carol'); + expect(cross).toEqual(same); + expect((await store.getSession(uploadId))?.status).toBe('in_progress'); + expect((await store.getFile(fileId))?.status).toBe('pending'); + }); + + it('the rule runs BEFORE the expiry check: a non-uploader neither learns of nor stamps the expiry', async () => { + const { uploadId } = await aliceStartsChunked(); + const past = new Date(Date.now() - 60_000).toISOString(); + await store.updateSession(uploadId, { expires_at: past }); + const res = await call(routes, 'POST', CHUNKED_COMPLETE, 'bob', { params: { uploadId }, body: { parts: [] } }); + expectNotUploader(res, 'bob'); + expect((await store.getSession(uploadId))?.status).toBe('in_progress'); + // Control: the uploader reaches the expiry branch, which does write. + const own = await call(routes, 'POST', CHUNKED_COMPLETE, 'alice', { params: { uploadId }, body: { parts: [] } }); + expect(own.status).toBe(410); + expect((await store.getSession(uploadId))?.status).toBe('expired'); + }); + + it('a session whose file row is gone is refused — no uploader to match, none is guessed', async () => { + const { uploadId, fileId } = await aliceStartsChunked(); + await store.deleteFile(fileId); + expectNotUploader( + await call(routes, 'POST', CHUNKED_COMPLETE, 'alice', { params: { uploadId }, body: { parts: [] } }), + 'alice', + ); + expect((await store.getSession(uploadId))?.status).toBe('in_progress'); + }); + + it('the not-found answer is unchanged', async () => { + const res = await call(routes, 'POST', CHUNKED_COMPLETE, 'alice', { params: { uploadId: 'no_such_upload' } }); + expect(res.status).toBe(404); + expect(res.body?.error?.code).toBe('UPLOAD_SESSION_NOT_FOUND'); + }); +}); + +// --------------------------------------------------------------------------- +// The progress door +// --------------------------------------------------------------------------- + +describe('the progress door (GET /upload/chunked/:uploadId/progress)', () => { + it('POSITIVE: the uploader reads their own progress', async () => { + const { uploadId, fileId } = await aliceStartsChunked(); + const res = await call(routes, 'GET', PROGRESS, 'alice', { params: { uploadId } }); + expect(res.status).toBe(200); + expect(res.body.data.fileId).toBe(fileId); + expect(res.body.data.filename).toBe('alice-chunked.bin'); + }); + + it('SAME ORGANIZATION: a non-uploader is refused 403 PERMISSION_DENIED and shown nothing', async () => { + const { uploadId, fileId } = await aliceStartsChunked(); + const res = await call(routes, 'GET', PROGRESS, 'bob', { params: { uploadId } }); + expectNotUploader(res, 'bob'); + expectDisclosesNothing(res, ['alice-chunked', 'u_alice', 'org_a', fileId], 'bob'); + }); + + it('ANOTHER ORGANIZATION: the identical refusal, shown nothing', async () => { + const { uploadId, fileId } = await aliceStartsChunked(); + const same = await call(routes, 'GET', PROGRESS, 'bob', { params: { uploadId } }); + const cross = await call(routes, 'GET', PROGRESS, 'carol', { params: { uploadId } }); + expectNotUploader(cross, 'carol'); + expect(cross).toEqual(same); + expectDisclosesNothing(cross, ['alice-chunked', 'u_alice', 'org_a', fileId], 'carol'); + }); + + it('the rule runs BEFORE the expiry write: a non-uploader cannot stamp the row expired', async () => { + const { uploadId } = await aliceStartsChunked(); + const past = new Date(Date.now() - 60_000).toISOString(); + await store.updateSession(uploadId, { expires_at: past }); + expectNotUploader(await call(routes, 'GET', PROGRESS, 'bob', { params: { uploadId } }), 'bob'); + expect((await store.getSession(uploadId))?.status).toBe('in_progress'); + // Control: the same read by the uploader does write the expiry. + const own = await call(routes, 'GET', PROGRESS, 'alice', { params: { uploadId } }); + expect(own.status).toBe(200); + expect(own.body.data.status).toBe('expired'); + expect((await store.getSession(uploadId))?.status).toBe('expired'); + }); + + it('no administrator exception', async () => { + const { uploadId } = await aliceStartsChunked(); + expectNotUploader(await call(routes, 'GET', PROGRESS, 'admin', { params: { uploadId } }), 'admin'); + }); + + it('the not-found answer is unchanged', async () => { + const res = await call(routes, 'GET', PROGRESS, 'alice', { params: { uploadId: 'no_such_upload' } }); + expect(res.status).toBe(404); + expect(res.body?.error?.code).toBe('UPLOAD_SESSION_NOT_FOUND'); + }); +}); + +// --------------------------------------------------------------------------- +// Open mode — no session resolver wired (bare kernels): no identity to compare +// --------------------------------------------------------------------------- + +describe('open mode (no session resolver) is unchanged', () => { + it('all three doors still answer an unauthenticated caller, as the open mode declares', async () => { + const open = mount(adapter, store, { open: true }); + const presign = await call(open, 'POST', `${BASE}/upload/presigned`, null, { + body: { filename: 'open.txt', mimeType: 'text/plain', size: 5 }, + }); + expect(presign.status).toBe(200); + expect((await call(open, 'POST', COMMIT, null, { body: { fileId: presign.body.data.fileId } })).status).toBe(200); + + const start = await call(open, 'POST', `${BASE}/upload/chunked`, null, { + body: { filename: 'open.bin', mimeType: 'application/octet-stream', totalSize: 10 }, + }); + expect(start.status).toBe(200); + const uploadId = start.body.data.uploadId; + expect((await call(open, 'GET', PROGRESS, null, { params: { uploadId } })).status).toBe(200); + expect( + (await call(open, 'POST', CHUNKED_COMPLETE, null, { params: { uploadId }, body: { parts: [] } })).status, + ).toBe(200); + }); +}); diff --git a/packages/services/service-storage/src/upload-ownership.ts b/packages/services/service-storage/src/upload-ownership.ts new file mode 100644 index 00000000000..8c1e8a26caa --- /dev/null +++ b/packages/services/service-storage/src/upload-ownership.ts @@ -0,0 +1,61 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { StandardErrorCode } from '@objectstack/spec/api'; +import type { FileRecord } from './metadata-store.js'; + +/** + * The upload ownership rule (#22046, ruling B) — declared ONCE, here. + * + * Three upload doors act on an id the caller names rather than one they were + * just handed: the commit door (`POST …/upload/complete`), the + * chunked-completion door (`POST …/upload/chunked/:uploadId/complete`) and the + * progress door (`GET …/upload/chunked/:uploadId/progress`). Each authenticated + * the session and then acted on whatever row the id resolved to. Each now asks + * this predicate, right after its by-id read and before any write or any + * disclosure, and refuses with {@link NOT_UPLOADER_STATUS} / + * {@link NOT_UPLOADER_CODE} when it answers `false`. + * + * The rule, as ruled: + * - the caller's user id must EQUAL the file's `owner_id` — the uploader both + * upload-start doors already stamp from the session, and which no door read + * before this rule; + * - an upload session carries no user column: it reaches its uploader + * through `file_id`, so the chunked doors read the file row and ask this + * same question of it — there is no second rule for sessions; + * - an EMPTY `owner_id` is refused, and so is a file row that cannot be + * found: a door that meets either refuses rather than guessing an owner; + * - there is NO administrator exception — the rule reads "the uploader only". + * An exception, if a real need appears, is additive and decided on its own. + * + * What it does not decide: who may read a file's bytes (the download doors' + * `authorizeDownload` stands), who may start an upload, and the organization + * the doors stamp and scope by. It is a pure function of the two values it is + * handed, so a new door that acts on a file or upload id inherits it by + * calling it — never by re-deriving the comparison. + * + * ⛔ Every refusal is the same answer: one status, one code, one message, with + * nothing in it about the row. A caller who is not the uploader learns nothing + * from the refusal about whose file it is or which organization holds it. + */ +export function isFileUploader( + callerUserId: string | undefined, + file: Pick | null | undefined, +): boolean { + if (typeof callerUserId !== 'string' || callerUserId.length === 0) return false; + const ownerId = file?.owner_id; + if (typeof ownerId !== 'string' || ownerId.length === 0) return false; + return ownerId === callerUserId; +} + +/** The status a door answers when {@link isFileUploader} refuses. */ +export const NOT_UPLOADER_STATUS = 403; + +/** The code a door answers when {@link isFileUploader} refuses (ADR-0112 vocabulary). */ +export const NOT_UPLOADER_CODE: StandardErrorCode = 'PERMISSION_DENIED'; + +/** + * The message a door answers when {@link isFileUploader} refuses — constant on + * purpose, so the refusal of a caller in the uploader's organization and of a + * caller in another one are the same body. + */ +export const NOT_UPLOADER_MESSAGE = 'Only the user who started this upload can act on it'; From d9b23e8429ed8e557fa622caf0c11b869af3e7b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 03:00:25 +0000 Subject: [PATCH 2/4] test(organizations): the storage upload doors' ownership rule on a booted organization wall The ruling's addendum asks, per upload door, for a caller acting in another organization to be refused with the same body as a caller in the uploader's own organization, measured on a booted wall. The wall is raised by this package's OrganizationsPlugin, which no other workspace package may declare (ADR-0132), so the measurement lives here: the smallest package that boots it. `bootStack` with this package's OrganizationsPlugin and the real StorageServicePlugin, the isolated posture requested and read back off the tenancy service; a colleague joins the uploader's organization through the invitation doors and an outsider creates an organization of their own. Per door (commit, chunked completion, progress): a positive pin, and the colleague and the outsider answered the identical `403 PERMISSION_DENIED` with the rows read back whole and unchanged. The two new devDependencies (`@objectstack/service-storage`, `@objectstack/verify`) are aliased to source, as `check:test-source-alias` prescribes. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude --- packages/plugins/organizations/package.json | 2 + ...storage-upload-door-ownership.wall.test.ts | 284 ++++++++++++++++++ .../plugins/organizations/vitest.config.ts | 15 +- pnpm-lock.yaml | 6 + 4 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 packages/plugins/organizations/src/storage-upload-door-ownership.wall.test.ts diff --git a/packages/plugins/organizations/package.json b/packages/plugins/organizations/package.json index 40010e257bf..457c19d56e1 100644 --- a/packages/plugins/organizations/package.json +++ b/packages/plugins/organizations/package.json @@ -31,6 +31,8 @@ "@objectstack/objectql": "workspace:*", "@objectstack/plugin-security": "workspace:*", "@objectstack/rest": "workspace:*", + "@objectstack/service-storage": "workspace:*", + "@objectstack/verify": "workspace:*", "@types/node": "^26.6.3", "tsx": "^4.23.15", "typescript": "^6.0.3", diff --git a/packages/plugins/organizations/src/storage-upload-door-ownership.wall.test.ts b/packages/plugins/organizations/src/storage-upload-door-ownership.wall.test.ts new file mode 100644 index 00000000000..4a34dbd2457 --- /dev/null +++ b/packages/plugins/organizations/src/storage-upload-door-ownership.wall.test.ts @@ -0,0 +1,284 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22046 (ruling B and its addendum) — the storage upload doors' ownership + * rule, measured on a BOOTED organization wall. + * + * `@objectstack/service-storage` declares one ownership rule (`isFileUploader`) + * that three upload doors consult after their by-id read and before any write + * or disclosure: the commit door, the chunked-completion door and the progress + * door. Its own suite pins the rule per door. What that suite cannot boot is + * the organization wall, and the ruling's addendum asks for exactly that: per + * door, a caller acting in ANOTHER organization, naming an upload it did not + * start, is answered `403 PERMISSION_DENIED` with the same body as a caller in + * the uploader's own organization — and the by-id read that precedes the rule + * discloses nothing and writes nothing across the wall. + * + * ## Why this file lives in THIS package + * + * The wall is raised by this package's `OrganizationsPlugin` (the `org-scoping` + * registrar the `tenancy` service probes). ADR-0132's entitlement boundary + * forbids every other workspace package from declaring + * `@objectstack/organizations` (`no-framework-dependents.pin.test.ts`), so the + * storage package — and the dogfood package, whose multi-organization blocks + * skip for the same reason — cannot mount it. The boundary is asymmetric: this + * package may depend on framework packages freely, so the measurement comes + * here rather than the plugin going there. It is the smallest package that + * boots the wall. + * + * ## What is real + * + * `bootStack` (`@objectstack/verify`): an `ObjectKernel` over in-process + * SQLite, the Hono app every request below is injected through, the real + * `AuthPlugin` (better-auth sessions, organizations, invitations) and + * `SecurityPlugin`. THIS package's `OrganizationsPlugin` and the real + * `StorageServicePlugin` are mounted the way a host mounts them. The walled + * posture is REQUESTED through `OS_TENANCY_POSTURE=isolated` and read back off + * the `tenancy` service the boot resolved — not assumed. The three callers are + * made through the product's own doors: the seeded platform owner, a colleague + * invited into the owner's organization who accepted, and an outsider who + * created an organization of their own. Nothing is stubbed. + * + * The rows each door names are read back through the engine as SYSTEM, whole, + * before and after every refused call, and compared column for column — that is + * "writes nothing". "Discloses nothing" is the refusal body: one code, one + * status, and nothing about the row. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { StorageServicePlugin } from '@objectstack/service-storage'; +import { OrganizationsPlugin } from './organizations-plugin.js'; + +const APP = { + manifest: { + id: 'com.example.uploadwall', + namespace: 'uploadwall', + version: '0.0.1', + type: 'app', + name: 'Upload Door Wall Fixture', + }, + objects: [], +}; + +const SYS = { context: { isSystem: true } } as const; + +const COLLEAGUE_EMAIL = 'upload-wall-colleague@verify.test'; +const OUTSIDER_EMAIL = 'upload-wall-outsider@verify.test'; + +interface Answer { + status: number; + body: any; +} + +describe('the upload doors on a booted organization wall (#22046)', () => { + const restoreEnv: Array<[string, string | undefined]> = []; + let rootDir: string; + let stack: VerifyStack; + let ql: any; + /** The seeded platform owner — every upload below is theirs. */ + let uploader: string; + /** Invited into the uploader's organization; not the uploader. */ + let colleague: string; + /** Acting in an organization of their own. */ + let outsider: string; + let uploaderOrg: string; + let outsiderOrg: string; + + const answer = async (res: Response): Promise => ({ + status: res.status, + body: await res.json().catch(() => undefined), + }); + const activeOrganization = async (token: string): Promise => { + const res = await stack.apiAs(token, 'GET', '/auth/get-session'); + return ((await res.json()) as any)?.session?.activeOrganizationId ?? null; + }; + const stored = (object: string, id: string) => ql.findOne(object, { where: { id } }, SYS); + + const presign = async (): Promise => { + const res = await answer( + await stack.apiAs(uploader, 'POST', '/storage/upload/presigned', { + filename: 'wall-plan.txt', + mimeType: 'text/plain', + size: 5, + }), + ); + expect(res.status).toBe(200); + return res.body.data.fileId; + }; + const startChunked = async (): Promise<{ uploadId: string; fileId: string }> => { + const res = await answer( + await stack.apiAs(uploader, 'POST', '/storage/upload/chunked', { + filename: 'wall-chunked.bin', + mimeType: 'application/octet-stream', + totalSize: 10, + }), + ); + expect(res.status).toBe(200); + return { uploadId: res.body.data.uploadId, fileId: res.body.data.fileId }; + }; + + /** The one refusal: code and status, and nothing about the row. */ + const expectRefused = (res: Answer, secrets: string[], label: string) => { + expect(res.status, label).toBe(403); + expect(res.body?.success, label).toBe(false); + expect(res.body?.error?.code, label).toBe('PERMISSION_DENIED'); + expect(res.body?.data, label).toBeUndefined(); + const text = JSON.stringify(res.body); + for (const s of secrets) expect(text.includes(s), `${label}: ${s}`).toBe(false); + }; + + beforeAll(async () => { + for (const [name, value] of [ + ['OS_TENANCY_POSTURE', 'isolated'], + // A walled deployment must declare what a new user joins, or this + // package's own boot gate refuses it. + ['OS_AUTH_MEMBERSHIP_POLICY', 'invite-only'], + ] as const) { + restoreEnv.push([name, process.env[name]]); + process.env[name] = value; + } + rootDir = mkdtempSync(join(tmpdir(), 'upload-wall-')); + stack = await bootStack(APP as never, { + extraPlugins: [ + new OrganizationsPlugin(), + new StorageServicePlugin({ adapter: 'local', local: { rootDir }, bindToSettings: false }), + ], + }); + ql = await stack.kernel.getServiceAsync('objectql'); + + uploader = await stack.signIn(); + uploaderOrg = (await activeOrganization(uploader))!; + + colleague = await stack.signUp(COLLEAGUE_EMAIL); + const invited = await answer( + await stack.apiAs(uploader, 'POST', '/auth/organization/invite-member', { + email: COLLEAGUE_EMAIL, + role: 'member', + organizationId: uploaderOrg, + }), + ); + expect(invited.status).toBe(200); + const accepted = await stack.apiAs(colleague, 'POST', '/auth/organization/accept-invitation', { + invitationId: invited.body.id, + }); + expect(accepted.status).toBe(200); + expect((await stack.apiAs(colleague, 'POST', '/auth/organization/set-active', { organizationId: uploaderOrg })).status).toBe(200); + + outsider = await stack.signUp(OUTSIDER_EMAIL); + const created = await answer( + await stack.apiAs(outsider, 'POST', '/auth/organization/create', { name: 'Upload Wall Other', slug: 'upload-wall-other' }), + ); + expect(created.status).toBe(200); + outsiderOrg = created.body.id; + }, 120_000); + + afterAll(async () => { + await stack?.stop(); + if (rootDir) rmSync(rootDir, { recursive: true, force: true }); + for (const [name, value] of restoreEnv.reverse()) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + }); + + it('PREMISE: the wall is up, and the three callers sit where the pins say they do', async () => { + const tenancy: any = await stack.kernel.getServiceAsync('tenancy'); + expect(tenancy.posture).toBe('isolated'); + expect(tenancy.isolationActive).toBe(true); + expect(tenancy.degraded).toBe(false); + + expect(uploaderOrg).toBeTruthy(); + expect(await activeOrganization(colleague)).toBe(uploaderOrg); + expect(await activeOrganization(outsider)).toBe(outsiderOrg); + expect(outsiderOrg).not.toBe(uploaderOrg); + + // The rows the doors act on are stamped into the uploader's organization — + // the wall the outsider is on the far side of. + const fileId = await presign(); + const row = await stored('sys_file', fileId); + expect(row.organization_id).toBe(uploaderOrg); + expect(row.owner_id).toBeTruthy(); + const { uploadId } = await startChunked(); + expect((await stored('sys_upload_session', uploadId)).organization_id).toBe(uploaderOrg); + }); + + describe('the commit door', () => { + it('a colleague and an outsider get the same 403 PERMISSION_DENIED, and the row is unchanged', async () => { + const fileId = await presign(); + const before = await stored('sys_file', fileId); + + const same = await answer(await stack.apiAs(colleague, 'POST', '/storage/upload/complete', { fileId, eTag: 'etag-colleague' })); + const cross = await answer(await stack.apiAs(outsider, 'POST', '/storage/upload/complete', { fileId, eTag: 'etag-outsider' })); + + expectRefused(same, ['wall-plan', fileId, uploaderOrg], 'colleague'); + expectRefused(cross, ['wall-plan', fileId, uploaderOrg], 'outsider'); + expect(cross).toEqual(same); + expect(await stored('sys_file', fileId)).toEqual(before); + }); + + it('POSITIVE: the uploader commits', async () => { + const fileId = await presign(); + const own = await answer(await stack.apiAs(uploader, 'POST', '/storage/upload/complete', { fileId, eTag: 'etag-uploader' })); + expect(own.status).toBe(200); + expect(own.body.data.fileId).toBe(fileId); + const row = await stored('sys_file', fileId); + expect(row.status).toBe('committed'); + expect(row.etag).toBe('etag-uploader'); + }); + }); + + describe('the chunked-completion door', () => { + it('a colleague and an outsider get the same 403 PERMISSION_DENIED, and neither row moves', async () => { + const { uploadId, fileId } = await startChunked(); + const sessionBefore = await stored('sys_upload_session', uploadId); + const fileBefore = await stored('sys_file', fileId); + + const path = `/storage/upload/chunked/${uploadId}/complete`; + const same = await answer(await stack.apiAs(colleague, 'POST', path, { parts: [] })); + const cross = await answer(await stack.apiAs(outsider, 'POST', path, { parts: [] })); + + expectRefused(same, ['wall-chunked', fileId, uploaderOrg], 'colleague'); + expectRefused(cross, ['wall-chunked', fileId, uploaderOrg], 'outsider'); + expect(cross).toEqual(same); + expect(await stored('sys_upload_session', uploadId)).toEqual(sessionBefore); + expect(await stored('sys_file', fileId)).toEqual(fileBefore); + }); + + it('POSITIVE: the uploader completes', async () => { + const { uploadId, fileId } = await startChunked(); + const own = await answer(await stack.apiAs(uploader, 'POST', `/storage/upload/chunked/${uploadId}/complete`, { parts: [] })); + expect(own.status).toBe(200); + expect(own.body.data.fileId).toBe(fileId); + expect((await stored('sys_upload_session', uploadId)).status).toBe('completed'); + expect((await stored('sys_file', fileId)).status).toBe('committed'); + }); + }); + + describe('the progress door', () => { + it('a colleague and an outsider get the same 403 PERMISSION_DENIED, are shown nothing, and the row is unchanged', async () => { + const { uploadId, fileId } = await startChunked(); + const before = await stored('sys_upload_session', uploadId); + + const path = `/storage/upload/chunked/${uploadId}/progress`; + const same = await answer(await stack.apiAs(colleague, 'GET', path)); + const cross = await answer(await stack.apiAs(outsider, 'GET', path)); + + expectRefused(same, ['wall-chunked', fileId, uploaderOrg], 'colleague'); + expectRefused(cross, ['wall-chunked', fileId, uploaderOrg], 'outsider'); + expect(cross).toEqual(same); + expect(await stored('sys_upload_session', uploadId)).toEqual(before); + }); + + it('POSITIVE: the uploader reads its progress', async () => { + const { uploadId, fileId } = await startChunked(); + const own = await answer(await stack.apiAs(uploader, 'GET', `/storage/upload/chunked/${uploadId}/progress`)); + expect(own.status).toBe(200); + expect(own.body.data.fileId).toBe(fileId); + expect(own.body.data.filename).toBe('wall-chunked.bin'); + }); + }); +}); diff --git a/packages/plugins/organizations/vitest.config.ts b/packages/plugins/organizations/vitest.config.ts index 58ae5d89953..63c40627ba4 100644 --- a/packages/plugins/organizations/vitest.config.ts +++ b/packages/plugins/organizations/vitest.config.ts @@ -10,7 +10,7 @@ export default defineConfig({ // fix (#7668/#7778); registering the package in that gate's unaliased // ledger is explicitly NOT — the ledger is shrink-only. // - // The three entries, and why each one is a VALUE reach rather than a type + // Some of the entries, and why each one is a VALUE reach rather than a type // reach (a `import type` is erased before anything resolves and needs no // alias): // - `@objectstack/plugin-auth` — `organizations-plugin.ts` imports @@ -27,6 +27,11 @@ export default defineConfig({ // `walled-default-org-self-registrant.pin.test.ts`, whose subject is // exactly which principal the default-org bootstrap treats as the // declared owner. + // - `@objectstack/service-storage` and `@objectstack/verify` — the + // `StorageServicePlugin` and `bootStack` that + // `storage-upload-door-ownership.wall.test.ts` boots the walled stack + // with (#22046). The storage upload doors ARE that file's subject, so a + // `dist/` copy behind the source would measure an older door. // // ANCHORED regex, array form, deliberately: a bare string `find` matches by // PREFIX, so with a FILE replacement it would also swallow a subpath and @@ -88,6 +93,14 @@ export default defineConfig({ find: /^@objectstack\/driver-sqlite-wasm$/, replacement: path.resolve(__dirname, '../../drivers/driver-sqlite-wasm/src/index.ts'), }, + { + find: /^@objectstack\/service-storage$/, + replacement: path.resolve(__dirname, '../../services/service-storage/src/index.ts'), + }, + { + find: /^@objectstack\/verify$/, + replacement: path.resolve(__dirname, '../../verify/src/index.ts'), + }, ], }, test: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dac884e5176..eeac8f8d720 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1554,6 +1554,12 @@ importers: '@objectstack/rest': specifier: workspace:* version: link:../../rest + '@objectstack/service-storage': + specifier: workspace:* + version: link:../../services/service-storage + '@objectstack/verify': + specifier: workspace:* + version: link:../../verify '@types/node': specifier: ^26.6.3 version: 26.6.3 From 89b93c624b3ddc1e577604e6023edd88d1511f17 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 03:04:24 +0000 Subject: [PATCH 3/4] test(organizations): one pin per organization per upload door on the wall The same-organization and the cross-organization refusals become separate pins, so each one fails on its own assertion when its door's ownership call is removed. The cross-organization pin asserts the outsider's answer and the unchanged rows first, then that the body equals the colleague's. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude --- ...storage-upload-door-ownership.wall.test.ts | 58 ++++++++++++------- 1 file changed, 38 insertions(+), 20 deletions(-) diff --git a/packages/plugins/organizations/src/storage-upload-door-ownership.wall.test.ts b/packages/plugins/organizations/src/storage-upload-door-ownership.wall.test.ts index 4a34dbd2457..c1c532c4d6f 100644 --- a/packages/plugins/organizations/src/storage-upload-door-ownership.wall.test.ts +++ b/packages/plugins/organizations/src/storage-upload-door-ownership.wall.test.ts @@ -207,17 +207,23 @@ describe('the upload doors on a booted organization wall (#22046)', () => { }); describe('the commit door', () => { - it('a colleague and an outsider get the same 403 PERMISSION_DENIED, and the row is unchanged', async () => { + const commit = (token: string, fileId: string, eTag: string) => + stack.apiAs(token, 'POST', '/storage/upload/complete', { fileId, eTag }); + + it('SAME ORGANIZATION: a colleague is refused 403 PERMISSION_DENIED, and the row is unchanged', async () => { const fileId = await presign(); const before = await stored('sys_file', fileId); + expectRefused(await answer(await commit(colleague, fileId, 'etag-colleague')), ['wall-plan', fileId, uploaderOrg], 'colleague'); + expect(await stored('sys_file', fileId)).toEqual(before); + }); - const same = await answer(await stack.apiAs(colleague, 'POST', '/storage/upload/complete', { fileId, eTag: 'etag-colleague' })); - const cross = await answer(await stack.apiAs(outsider, 'POST', '/storage/upload/complete', { fileId, eTag: 'etag-outsider' })); - - expectRefused(same, ['wall-plan', fileId, uploaderOrg], 'colleague'); + it('ANOTHER ORGANIZATION: an outsider gets the colleague’s body, and the row is unchanged', async () => { + const fileId = await presign(); + const before = await stored('sys_file', fileId); + const cross = await answer(await commit(outsider, fileId, 'etag-outsider')); expectRefused(cross, ['wall-plan', fileId, uploaderOrg], 'outsider'); - expect(cross).toEqual(same); expect(await stored('sys_file', fileId)).toEqual(before); + expect(cross).toEqual(await answer(await commit(colleague, fileId, 'etag-colleague'))); }); it('POSITIVE: the uploader commits', async () => { @@ -232,20 +238,27 @@ describe('the upload doors on a booted organization wall (#22046)', () => { }); describe('the chunked-completion door', () => { - it('a colleague and an outsider get the same 403 PERMISSION_DENIED, and neither row moves', async () => { + const complete = (token: string, uploadId: string) => + stack.apiAs(token, 'POST', `/storage/upload/chunked/${uploadId}/complete`, { parts: [] }); + + it('SAME ORGANIZATION: a colleague is refused 403 PERMISSION_DENIED, and neither row moves', async () => { const { uploadId, fileId } = await startChunked(); const sessionBefore = await stored('sys_upload_session', uploadId); const fileBefore = await stored('sys_file', fileId); + expectRefused(await answer(await complete(colleague, uploadId)), ['wall-chunked', fileId, uploaderOrg], 'colleague'); + expect(await stored('sys_upload_session', uploadId)).toEqual(sessionBefore); + expect(await stored('sys_file', fileId)).toEqual(fileBefore); + }); - const path = `/storage/upload/chunked/${uploadId}/complete`; - const same = await answer(await stack.apiAs(colleague, 'POST', path, { parts: [] })); - const cross = await answer(await stack.apiAs(outsider, 'POST', path, { parts: [] })); - - expectRefused(same, ['wall-chunked', fileId, uploaderOrg], 'colleague'); + it('ANOTHER ORGANIZATION: an outsider gets the colleague’s body, and neither row moves', async () => { + const { uploadId, fileId } = await startChunked(); + const sessionBefore = await stored('sys_upload_session', uploadId); + const fileBefore = await stored('sys_file', fileId); + const cross = await answer(await complete(outsider, uploadId)); expectRefused(cross, ['wall-chunked', fileId, uploaderOrg], 'outsider'); - expect(cross).toEqual(same); expect(await stored('sys_upload_session', uploadId)).toEqual(sessionBefore); expect(await stored('sys_file', fileId)).toEqual(fileBefore); + expect(cross).toEqual(await answer(await complete(colleague, uploadId))); }); it('POSITIVE: the uploader completes', async () => { @@ -259,18 +272,23 @@ describe('the upload doors on a booted organization wall (#22046)', () => { }); describe('the progress door', () => { - it('a colleague and an outsider get the same 403 PERMISSION_DENIED, are shown nothing, and the row is unchanged', async () => { + const progress = (token: string, uploadId: string) => + stack.apiAs(token, 'GET', `/storage/upload/chunked/${uploadId}/progress`); + + it('SAME ORGANIZATION: a colleague is refused 403 PERMISSION_DENIED, shown nothing, and the row is unchanged', async () => { const { uploadId, fileId } = await startChunked(); const before = await stored('sys_upload_session', uploadId); + expectRefused(await answer(await progress(colleague, uploadId)), ['wall-chunked', fileId, uploaderOrg], 'colleague'); + expect(await stored('sys_upload_session', uploadId)).toEqual(before); + }); - const path = `/storage/upload/chunked/${uploadId}/progress`; - const same = await answer(await stack.apiAs(colleague, 'GET', path)); - const cross = await answer(await stack.apiAs(outsider, 'GET', path)); - - expectRefused(same, ['wall-chunked', fileId, uploaderOrg], 'colleague'); + it('ANOTHER ORGANIZATION: an outsider gets the colleague’s body, shown nothing, and the row is unchanged', async () => { + const { uploadId, fileId } = await startChunked(); + const before = await stored('sys_upload_session', uploadId); + const cross = await answer(await progress(outsider, uploadId)); expectRefused(cross, ['wall-chunked', fileId, uploaderOrg], 'outsider'); - expect(cross).toEqual(same); expect(await stored('sys_upload_session', uploadId)).toEqual(before); + expect(cross).toEqual(await answer(await progress(colleague, uploadId))); }); it('POSITIVE: the uploader reads its progress', async () => { From 67e5a1ef13508de4c99e390f18e0fb9908d727b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 03:29:34 +0000 Subject: [PATCH 4/4] test(organizations): declare OS_REGISTRY_LOG for the suite that now boots a stack `check:registry-log-declared` requires every suite that boots a stack through `bootStack` to quiet the registry's per-item registration chatter through the engine's own seam; the upload-door wall test made this package one. Claude-Session: https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ Co-authored-by: Claude --- packages/plugins/organizations/vitest.config.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/plugins/organizations/vitest.config.ts b/packages/plugins/organizations/vitest.config.ts index 63c40627ba4..94172981d65 100644 --- a/packages/plugins/organizations/vitest.config.ts +++ b/packages/plugins/organizations/vitest.config.ts @@ -113,5 +113,11 @@ export default defineConfig({ disableConsoleIntercept: true, environment: 'node', include: ['src/**/*.test.ts'], + // #13517: quiet the registry's per-item registration chatter — the + // engine's own `OS_REGISTRY_LOG` seam, not a change to its shipped + // default. Enforced by scripts/check-registry-log-declared.mjs, since + // `storage-upload-door-ownership.wall.test.ts` boots a stack through + // `@objectstack/verify`'s `bootStack`. + env: { OS_REGISTRY_LOG: 'warn' }, }, });