Skip to content

Commit 29678f2

Browse files
fix(service-storage)!: the upload commit, chunked-completion and progress doors act only for the uploader (#22170)
Fixes #22046 Clause-②: no (narrowing) Ruling B on the card, with its addendum: one ownership rule, declared once and checked at the three storage upload doors that act on an id the caller names. Classes, positions and functions only. ## What changed - **`packages/services/service-storage/src/upload-ownership.ts` (new).** `isFileUploader(callerUserId, file)` is the one rule. The caller's user id must equal the file's `owner_id`. An empty `owner_id` and a missing file row both answer `false`, so a door never guesses an owner. There is no administrator exception. The module also declares the refusal: `403`, `PERMISSION_DENIED`, and one constant message. - **`storage-routes.ts`.** `requireUploader(authSession, file, res)` calls the predicate and sends the refusal. Each door calls it right after its by-id read and before any write or disclosure: - **commit door** (`POST …/upload/complete`): after `getFile`, before `updateFile`; - **chunked-completion door** (`POST …/upload/chunked/:uploadId/complete`): after `getSession` it reads the session's file by `file_id`, because the session row has no user column, then checks. This comes before the expiry stamp, the status writes and the answer; - **progress door** (`GET …/upload/chunked/:uploadId/progress`): the same, before the expiry write and the progress answer. - **Unchanged:** the download doors' `authorizeDownload`, the two start doors, the tenant stamp and the tenant scope, the not-found answers, and the chunk door with its resume-token check. - **Open mode** (no session resolver wired, which means a kernel with no `auth` service): `requireUploader` passes. There is no caller identity to compare, and the routes stay open as the `resolveSession` option documents. See the acceptance notes below. - **Changeset** `.changeset/22046-upload-doors-uploader-only.md`: `@objectstack/service-storage` `minor`. It is an accept-set narrowing, marked **BREAKING** under the launch-window convention, with ADR-0087 disposition `not-required (no-migration-prescription)`. ## Pins **`service-storage` · `src/upload-door-ownership.test.ts`** (23 cases). Per door: - **positive:** the uploader passes; - **same organization:** a non-uploader gets `403 PERMISSION_DENIED`, the rows are unchanged, and the body names nothing about the row; - **another organization:** a caller gets the identical body. It also covers: an empty owner is refused; a session whose file row is gone is refused; there is no administrator exception; the rule runs before the expiry write and before the expiry disclosure; the not-found answers are unchanged; open mode is unchanged; and the predicate's own cases. **Booted organization wall · `packages/plugins/organizations/src/storage-upload-door-ownership.wall.test.ts`** (10 cases). **Cross-lane files, declared here:** - this test; - `packages/plugins/organizations/package.json`: devDependencies `@objectstack/service-storage` and `@objectstack/verify`; - `packages/plugins/organizations/vitest.config.ts`: both aliased to source, as `check:test-source-alias` prescribes; - `pnpm-lock.yaml`: 6 lines. The wall is raised by `OrganizationsPlugin`. ADR-0132's boundary (`no-framework-dependents.pin.test.ts`) lets no other workspace package declare it, which is why the dogfood multi-organization blocks skip. That makes this package the smallest one that boots the wall. The ADR-0132 pin allows the dependency in this direction. What the test runs: - the stack: `bootStack` with this package's `OrganizationsPlugin` and the real `StorageServicePlugin`, `OS_TENANCY_POSTURE=isolated` and `OS_AUTH_MEMBERSHIP_POLICY=invite-only`; - the premise, read back from the `tenancy` service: posture `isolated`, `isolationActive: true`, `degraded: false`; - the callers, made through the product's own doors: - the seeded platform owner is the uploader; - a colleague joins the owner's organization through invite and accept; - an outsider creates an organization of their own. Per door: - a **positive** pin; - a **same-organization** pin; - a **cross-organization** pin: `403 PERMISSION_DENIED` with a body equal to the colleague's. The rows the door names are read back whole, as system, before and after each refused call, and must be equal. **Fixture triage.** The route block in `tenant-audit-update-delete-half-repairs.test.ts` seeded rows with no uploader. The commit and progress doors now refuse such rows before the write that block pins, so its seeds gain `owner_id` (the session's user) and the progress case gains its file row. **Envelope conformance** gains the new refusal's case. ## Evidence (head `67e5a1ef`) - **Red before the fix:** the new service-storage pin file against the unmodified doors (base `033e5c53` plus the pin file) gave `Tests 12 failed | 11 passed (23)`. Every negative pin was red and every positive or unchanged pin was green. - **Green:** `@objectstack/service-storage` vitest `Test Files 43 passed (43) · Tests 683 passed (683)`. `@objectstack/organizations` vitest `Test Files 10 passed (10) · Tests 141 passed (141)`. The dogfood suites that drive these doors on the wire (`attachments-permission-matrix`, `field-file-collection`, `predicate-write-unreadable-not-matched`, `write-door-unreadable-is-not-found`) gave `Tests 56 passed | 1 skipped (57)`; the skip is the existing multi-organization block, which skips in this repository. Typecheck is green for both packages, and both test-layer programs list the new files (`--listFiles`). - **Ablation, per door** (`scripts/ablation-replace.mjs --delete`, the door's `requireUploader` call removed; each leg restored to the HEAD blob with `git diff HEAD` empty): - **service-storage pins:** at each door that door's 4 negative pins went red and the other 19 stayed green. That is commit 4 red · completion 4 red · progress 4 red. - **wall pins:** at each door that door's same-organization pin and cross-organization pin each went red on their own assertion (status not 403), and the other 8 stayed green. - No rebuild leg was needed: both suites resolve `@objectstack/service-storage` to source (the relative import in the storage suite, the vitest alias in the wall suite). - **Lint:** `eslint --no-inline-config --format json` on the 7 touched lintable files: 7 files, 0 errors, 0 warnings. The population is every touched `.ts` file, and eslint reports none as ignored. This repo's config enables no type-aware rules, so the diff cannot move a verdict on an untouched file. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `67e5a1ef` derived 79 gate commands. All 79 were run on that head and each exited 0. `--ran` with the recorded exit codes reports `79 run, 0 NOT-MEASURED (a DERIVED zero)`. One gate was red on the first pass and is fixed in this diff: `check:registry-log-declared` required the organizations suite, now booting a stack, to declare `OS_REGISTRY_LOG`. ## Acceptance notes (boundary readings, not changed here) - **Not-found vs refused.** An unknown id still answers `404` (`FILE_NOT_FOUND` / `UPLOAD_SESSION_NOT_FOUND`). An id that exists but belongs to someone else answers `403`. The pair therefore tells a caller whether an id they hold exists. The ruling fixes `403` and leaves the not-found answer alone, so neither is touched. Before this change the same distinction existed through the door's success answer. - **Open mode** is a kernel with no session resolver: no identity, routes documented open. The rule needs a caller identity and does not apply there. Every upload in that mode lands with no owner. - **The chunk door** (`PUT …/chunk/:chunkIndex`) is outside the ruling. It keeps its resume-token check. - **CI cost:** `@objectstack/organizations` tests now depend on `@objectstack/verify` and its closure (test only; nothing ships). --- _Generated by [Claude Code](https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6ed0c0f commit 29678f2

10 files changed

Lines changed: 889 additions & 4 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/service-storage': minor
3+
---
4+
5+
fix(service-storage)!: the upload commit, chunked-completion and progress doors act only for the user who started the upload
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) A runtime authorization narrowing at three storage upload doors, not a metadata change: no spec key, export, option, response field or stored shape is removed, renamed or re-shaped, so there is no tombstone and nothing for `objectstack migrate meta` to rewrite. What narrows is which callers those doors act for: a caller who is not the file's uploader is now refused, while the uploader is answered as before. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers these doors and this diff adds none (not registered / already-registered); and no published interface or type changes (not runtime-interface-only / type-surface-only). -->
10+
11+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
`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.
14+
15+
- **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.
16+
- **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.
17+
18+
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.

‎packages/plugins/organizations/package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
"@objectstack/objectql": "workspace:*",
3232
"@objectstack/plugin-security": "workspace:*",
3333
"@objectstack/rest": "workspace:*",
34+
"@objectstack/service-storage": "workspace:*",
35+
"@objectstack/verify": "workspace:*",
3436
"@types/node": "^26.6.3",
3537
"tsx": "^4.23.15",
3638
"typescript": "^6.0.3",
Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #22046 (ruling B and its addendum) — the storage upload doors' ownership
5+
* rule, measured on a BOOTED organization wall.
6+
*
7+
* `@objectstack/service-storage` declares one ownership rule (`isFileUploader`)
8+
* that three upload doors consult after their by-id read and before any write
9+
* or disclosure: the commit door, the chunked-completion door and the progress
10+
* door. Its own suite pins the rule per door. What that suite cannot boot is
11+
* the organization wall, and the ruling's addendum asks for exactly that: per
12+
* door, a caller acting in ANOTHER organization, naming an upload it did not
13+
* start, is answered `403 PERMISSION_DENIED` with the same body as a caller in
14+
* the uploader's own organization — and the by-id read that precedes the rule
15+
* discloses nothing and writes nothing across the wall.
16+
*
17+
* ## Why this file lives in THIS package
18+
*
19+
* The wall is raised by this package's `OrganizationsPlugin` (the `org-scoping`
20+
* registrar the `tenancy` service probes). ADR-0132's entitlement boundary
21+
* forbids every other workspace package from declaring
22+
* `@objectstack/organizations` (`no-framework-dependents.pin.test.ts`), so the
23+
* storage package — and the dogfood package, whose multi-organization blocks
24+
* skip for the same reason — cannot mount it. The boundary is asymmetric: this
25+
* package may depend on framework packages freely, so the measurement comes
26+
* here rather than the plugin going there. It is the smallest package that
27+
* boots the wall.
28+
*
29+
* ## What is real
30+
*
31+
* `bootStack` (`@objectstack/verify`): an `ObjectKernel` over in-process
32+
* SQLite, the Hono app every request below is injected through, the real
33+
* `AuthPlugin` (better-auth sessions, organizations, invitations) and
34+
* `SecurityPlugin`. THIS package's `OrganizationsPlugin` and the real
35+
* `StorageServicePlugin` are mounted the way a host mounts them. The walled
36+
* posture is REQUESTED through `OS_TENANCY_POSTURE=isolated` and read back off
37+
* the `tenancy` service the boot resolved — not assumed. The three callers are
38+
* made through the product's own doors: the seeded platform owner, a colleague
39+
* invited into the owner's organization who accepted, and an outsider who
40+
* created an organization of their own. Nothing is stubbed.
41+
*
42+
* The rows each door names are read back through the engine as SYSTEM, whole,
43+
* before and after every refused call, and compared column for column — that is
44+
* "writes nothing". "Discloses nothing" is the refusal body: one code, one
45+
* status, and nothing about the row.
46+
*/
47+
48+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
49+
import { mkdtempSync, rmSync } from 'node:fs';
50+
import { tmpdir } from 'node:os';
51+
import { join } from 'node:path';
52+
import { bootStack, type VerifyStack } from '@objectstack/verify';
53+
import { StorageServicePlugin } from '@objectstack/service-storage';
54+
import { OrganizationsPlugin } from './organizations-plugin.js';
55+
56+
const APP = {
57+
manifest: {
58+
id: 'com.example.uploadwall',
59+
namespace: 'uploadwall',
60+
version: '0.0.1',
61+
type: 'app',
62+
name: 'Upload Door Wall Fixture',
63+
},
64+
objects: [],
65+
};
66+
67+
const SYS = { context: { isSystem: true } } as const;
68+
69+
const COLLEAGUE_EMAIL = 'upload-wall-colleague@verify.test';
70+
const OUTSIDER_EMAIL = 'upload-wall-outsider@verify.test';
71+
72+
interface Answer {
73+
status: number;
74+
body: any;
75+
}
76+
77+
describe('the upload doors on a booted organization wall (#22046)', () => {
78+
const restoreEnv: Array<[string, string | undefined]> = [];
79+
let rootDir: string;
80+
let stack: VerifyStack;
81+
let ql: any;
82+
/** The seeded platform owner — every upload below is theirs. */
83+
let uploader: string;
84+
/** Invited into the uploader's organization; not the uploader. */
85+
let colleague: string;
86+
/** Acting in an organization of their own. */
87+
let outsider: string;
88+
let uploaderOrg: string;
89+
let outsiderOrg: string;
90+
91+
const answer = async (res: Response): Promise<Answer> => ({
92+
status: res.status,
93+
body: await res.json().catch(() => undefined),
94+
});
95+
const activeOrganization = async (token: string): Promise<string | null> => {
96+
const res = await stack.apiAs(token, 'GET', '/auth/get-session');
97+
return ((await res.json()) as any)?.session?.activeOrganizationId ?? null;
98+
};
99+
const stored = (object: string, id: string) => ql.findOne(object, { where: { id } }, SYS);
100+
101+
const presign = async (): Promise<string> => {
102+
const res = await answer(
103+
await stack.apiAs(uploader, 'POST', '/storage/upload/presigned', {
104+
filename: 'wall-plan.txt',
105+
mimeType: 'text/plain',
106+
size: 5,
107+
}),
108+
);
109+
expect(res.status).toBe(200);
110+
return res.body.data.fileId;
111+
};
112+
const startChunked = async (): Promise<{ uploadId: string; fileId: string }> => {
113+
const res = await answer(
114+
await stack.apiAs(uploader, 'POST', '/storage/upload/chunked', {
115+
filename: 'wall-chunked.bin',
116+
mimeType: 'application/octet-stream',
117+
totalSize: 10,
118+
}),
119+
);
120+
expect(res.status).toBe(200);
121+
return { uploadId: res.body.data.uploadId, fileId: res.body.data.fileId };
122+
};
123+
124+
/** The one refusal: code and status, and nothing about the row. */
125+
const expectRefused = (res: Answer, secrets: string[], label: string) => {
126+
expect(res.status, label).toBe(403);
127+
expect(res.body?.success, label).toBe(false);
128+
expect(res.body?.error?.code, label).toBe('PERMISSION_DENIED');
129+
expect(res.body?.data, label).toBeUndefined();
130+
const text = JSON.stringify(res.body);
131+
for (const s of secrets) expect(text.includes(s), `${label}: ${s}`).toBe(false);
132+
};
133+
134+
beforeAll(async () => {
135+
for (const [name, value] of [
136+
['OS_TENANCY_POSTURE', 'isolated'],
137+
// A walled deployment must declare what a new user joins, or this
138+
// package's own boot gate refuses it.
139+
['OS_AUTH_MEMBERSHIP_POLICY', 'invite-only'],
140+
] as const) {
141+
restoreEnv.push([name, process.env[name]]);
142+
process.env[name] = value;
143+
}
144+
rootDir = mkdtempSync(join(tmpdir(), 'upload-wall-'));
145+
stack = await bootStack(APP as never, {
146+
extraPlugins: [
147+
new OrganizationsPlugin(),
148+
new StorageServicePlugin({ adapter: 'local', local: { rootDir }, bindToSettings: false }),
149+
],
150+
});
151+
ql = await stack.kernel.getServiceAsync('objectql');
152+
153+
uploader = await stack.signIn();
154+
uploaderOrg = (await activeOrganization(uploader))!;
155+
156+
colleague = await stack.signUp(COLLEAGUE_EMAIL);
157+
const invited = await answer(
158+
await stack.apiAs(uploader, 'POST', '/auth/organization/invite-member', {
159+
email: COLLEAGUE_EMAIL,
160+
role: 'member',
161+
organizationId: uploaderOrg,
162+
}),
163+
);
164+
expect(invited.status).toBe(200);
165+
const accepted = await stack.apiAs(colleague, 'POST', '/auth/organization/accept-invitation', {
166+
invitationId: invited.body.id,
167+
});
168+
expect(accepted.status).toBe(200);
169+
expect((await stack.apiAs(colleague, 'POST', '/auth/organization/set-active', { organizationId: uploaderOrg })).status).toBe(200);
170+
171+
outsider = await stack.signUp(OUTSIDER_EMAIL);
172+
const created = await answer(
173+
await stack.apiAs(outsider, 'POST', '/auth/organization/create', { name: 'Upload Wall Other', slug: 'upload-wall-other' }),
174+
);
175+
expect(created.status).toBe(200);
176+
outsiderOrg = created.body.id;
177+
}, 120_000);
178+
179+
afterAll(async () => {
180+
await stack?.stop();
181+
if (rootDir) rmSync(rootDir, { recursive: true, force: true });
182+
for (const [name, value] of restoreEnv.reverse()) {
183+
if (value === undefined) delete process.env[name];
184+
else process.env[name] = value;
185+
}
186+
});
187+
188+
it('PREMISE: the wall is up, and the three callers sit where the pins say they do', async () => {
189+
const tenancy: any = await stack.kernel.getServiceAsync('tenancy');
190+
expect(tenancy.posture).toBe('isolated');
191+
expect(tenancy.isolationActive).toBe(true);
192+
expect(tenancy.degraded).toBe(false);
193+
194+
expect(uploaderOrg).toBeTruthy();
195+
expect(await activeOrganization(colleague)).toBe(uploaderOrg);
196+
expect(await activeOrganization(outsider)).toBe(outsiderOrg);
197+
expect(outsiderOrg).not.toBe(uploaderOrg);
198+
199+
// The rows the doors act on are stamped into the uploader's organization —
200+
// the wall the outsider is on the far side of.
201+
const fileId = await presign();
202+
const row = await stored('sys_file', fileId);
203+
expect(row.organization_id).toBe(uploaderOrg);
204+
expect(row.owner_id).toBeTruthy();
205+
const { uploadId } = await startChunked();
206+
expect((await stored('sys_upload_session', uploadId)).organization_id).toBe(uploaderOrg);
207+
});
208+
209+
describe('the commit door', () => {
210+
const commit = (token: string, fileId: string, eTag: string) =>
211+
stack.apiAs(token, 'POST', '/storage/upload/complete', { fileId, eTag });
212+
213+
it('SAME ORGANIZATION: a colleague is refused 403 PERMISSION_DENIED, and the row is unchanged', async () => {
214+
const fileId = await presign();
215+
const before = await stored('sys_file', fileId);
216+
expectRefused(await answer(await commit(colleague, fileId, 'etag-colleague')), ['wall-plan', fileId, uploaderOrg], 'colleague');
217+
expect(await stored('sys_file', fileId)).toEqual(before);
218+
});
219+
220+
it('ANOTHER ORGANIZATION: an outsider gets the colleague’s body, and the row is unchanged', async () => {
221+
const fileId = await presign();
222+
const before = await stored('sys_file', fileId);
223+
const cross = await answer(await commit(outsider, fileId, 'etag-outsider'));
224+
expectRefused(cross, ['wall-plan', fileId, uploaderOrg], 'outsider');
225+
expect(await stored('sys_file', fileId)).toEqual(before);
226+
expect(cross).toEqual(await answer(await commit(colleague, fileId, 'etag-colleague')));
227+
});
228+
229+
it('POSITIVE: the uploader commits', async () => {
230+
const fileId = await presign();
231+
const own = await answer(await stack.apiAs(uploader, 'POST', '/storage/upload/complete', { fileId, eTag: 'etag-uploader' }));
232+
expect(own.status).toBe(200);
233+
expect(own.body.data.fileId).toBe(fileId);
234+
const row = await stored('sys_file', fileId);
235+
expect(row.status).toBe('committed');
236+
expect(row.etag).toBe('etag-uploader');
237+
});
238+
});
239+
240+
describe('the chunked-completion door', () => {
241+
const complete = (token: string, uploadId: string) =>
242+
stack.apiAs(token, 'POST', `/storage/upload/chunked/${uploadId}/complete`, { parts: [] });
243+
244+
it('SAME ORGANIZATION: a colleague is refused 403 PERMISSION_DENIED, and neither row moves', async () => {
245+
const { uploadId, fileId } = await startChunked();
246+
const sessionBefore = await stored('sys_upload_session', uploadId);
247+
const fileBefore = await stored('sys_file', fileId);
248+
expectRefused(await answer(await complete(colleague, uploadId)), ['wall-chunked', fileId, uploaderOrg], 'colleague');
249+
expect(await stored('sys_upload_session', uploadId)).toEqual(sessionBefore);
250+
expect(await stored('sys_file', fileId)).toEqual(fileBefore);
251+
});
252+
253+
it('ANOTHER ORGANIZATION: an outsider gets the colleague’s body, and neither row moves', async () => {
254+
const { uploadId, fileId } = await startChunked();
255+
const sessionBefore = await stored('sys_upload_session', uploadId);
256+
const fileBefore = await stored('sys_file', fileId);
257+
const cross = await answer(await complete(outsider, uploadId));
258+
expectRefused(cross, ['wall-chunked', fileId, uploaderOrg], 'outsider');
259+
expect(await stored('sys_upload_session', uploadId)).toEqual(sessionBefore);
260+
expect(await stored('sys_file', fileId)).toEqual(fileBefore);
261+
expect(cross).toEqual(await answer(await complete(colleague, uploadId)));
262+
});
263+
264+
it('POSITIVE: the uploader completes', async () => {
265+
const { uploadId, fileId } = await startChunked();
266+
const own = await answer(await stack.apiAs(uploader, 'POST', `/storage/upload/chunked/${uploadId}/complete`, { parts: [] }));
267+
expect(own.status).toBe(200);
268+
expect(own.body.data.fileId).toBe(fileId);
269+
expect((await stored('sys_upload_session', uploadId)).status).toBe('completed');
270+
expect((await stored('sys_file', fileId)).status).toBe('committed');
271+
});
272+
});
273+
274+
describe('the progress door', () => {
275+
const progress = (token: string, uploadId: string) =>
276+
stack.apiAs(token, 'GET', `/storage/upload/chunked/${uploadId}/progress`);
277+
278+
it('SAME ORGANIZATION: a colleague is refused 403 PERMISSION_DENIED, shown nothing, and the row is unchanged', async () => {
279+
const { uploadId, fileId } = await startChunked();
280+
const before = await stored('sys_upload_session', uploadId);
281+
expectRefused(await answer(await progress(colleague, uploadId)), ['wall-chunked', fileId, uploaderOrg], 'colleague');
282+
expect(await stored('sys_upload_session', uploadId)).toEqual(before);
283+
});
284+
285+
it('ANOTHER ORGANIZATION: an outsider gets the colleague’s body, shown nothing, and the row is unchanged', async () => {
286+
const { uploadId, fileId } = await startChunked();
287+
const before = await stored('sys_upload_session', uploadId);
288+
const cross = await answer(await progress(outsider, uploadId));
289+
expectRefused(cross, ['wall-chunked', fileId, uploaderOrg], 'outsider');
290+
expect(await stored('sys_upload_session', uploadId)).toEqual(before);
291+
expect(cross).toEqual(await answer(await progress(colleague, uploadId)));
292+
});
293+
294+
it('POSITIVE: the uploader reads its progress', async () => {
295+
const { uploadId, fileId } = await startChunked();
296+
const own = await answer(await stack.apiAs(uploader, 'GET', `/storage/upload/chunked/${uploadId}/progress`));
297+
expect(own.status).toBe(200);
298+
expect(own.body.data.fileId).toBe(fileId);
299+
expect(own.body.data.filename).toBe('wall-chunked.bin');
300+
});
301+
});
302+
});

‎packages/plugins/organizations/vitest.config.ts‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export default defineConfig({
1010
// fix (#7668/#7778); registering the package in that gate's unaliased
1111
// ledger is explicitly NOT — the ledger is shrink-only.
1212
//
13-
// The three entries, and why each one is a VALUE reach rather than a type
13+
// Some of the entries, and why each one is a VALUE reach rather than a type
1414
// reach (a `import type` is erased before anything resolves and needs no
1515
// alias):
1616
// - `@objectstack/plugin-auth` — `organizations-plugin.ts` imports
@@ -27,6 +27,11 @@ export default defineConfig({
2727
// `walled-default-org-self-registrant.pin.test.ts`, whose subject is
2828
// exactly which principal the default-org bootstrap treats as the
2929
// declared owner.
30+
// - `@objectstack/service-storage` and `@objectstack/verify` — the
31+
// `StorageServicePlugin` and `bootStack` that
32+
// `storage-upload-door-ownership.wall.test.ts` boots the walled stack
33+
// with (#22046). The storage upload doors ARE that file's subject, so a
34+
// `dist/` copy behind the source would measure an older door.
3035
//
3136
// ANCHORED regex, array form, deliberately: a bare string `find` matches by
3237
// PREFIX, so with a FILE replacement it would also swallow a subpath and
@@ -88,6 +93,14 @@ export default defineConfig({
8893
find: /^@objectstack\/driver-sqlite-wasm$/,
8994
replacement: path.resolve(__dirname, '../../drivers/driver-sqlite-wasm/src/index.ts'),
9095
},
96+
{
97+
find: /^@objectstack\/service-storage$/,
98+
replacement: path.resolve(__dirname, '../../services/service-storage/src/index.ts'),
99+
},
100+
{
101+
find: /^@objectstack\/verify$/,
102+
replacement: path.resolve(__dirname, '../../verify/src/index.ts'),
103+
},
91104
],
92105
},
93106
test: {
@@ -100,5 +113,11 @@ export default defineConfig({
100113
disableConsoleIntercept: true,
101114
environment: 'node',
102115
include: ['src/**/*.test.ts'],
116+
// #13517: quiet the registry's per-item registration chatter — the
117+
// engine's own `OS_REGISTRY_LOG` seam, not a change to its shipped
118+
// default. Enforced by scripts/check-registry-log-declared.mjs, since
119+
// `storage-upload-door-ownership.wall.test.ts` boots a stack through
120+
// `@objectstack/verify`'s `bootStack`.
121+
env: { OS_REGISTRY_LOG: 'warn' },
103122
},
104123
});

0 commit comments

Comments
 (0)