Skip to content

Commit dd986d8

Browse files
fix(service-storage)!: downloading a file with no attachments scope and no field owner requires a signed-in caller (#22439)
Fixes #22431 Clause-②: no (narrowing) Executes ruling [`6074960686`](#22146 (comment)) item 3 (#22146), as triage routed it: a storage download of a file with no scope and no field owner requires a signed-in caller, and a file declared `acl: 'public_read'` stays anonymous (ADR-0104). Function level only, as the card requires. ## What changes - **`packages/services/service-storage/src/storage-routes.ts`, `authorizeDownload`** (the one gate both download routes call). A file with neither an attachments scope nor a field owner (an upload no record has claimed) now needs a session from the existing `resolveSession`; a caller with none is refused `401 AUTH_REQUIRED`, the pair the upload gate and the attachments gate already answer. No new code, no spec change. `acl: 'public_read'` is checked first and stays anonymous for every class. Attachments-scope and field-owned files keep their `authorizeFileRead` verdicts, untouched. A resolver that throws, or a session with no user, fails closed. - **Bare kernel unchanged.** With no `resolveSession` wired, these downloads stay open as before, and the module now says so once (the existing one-time notice names only the upload routes). - **Docblocks made true.** The `resolveSession` docblock no longer calls download gating "a tracked follow-up", and the `authorizeFileRead` docblock no longer names an organization logo as anonymous. - **`content/docs/permissions/attachments-access.mdx`** said that avatars, image-field thumbnails and organization logos keep an anonymous capability URL. This change makes the avatar and logo half false, and the image-field half has been false since field-owned files were gated. The paragraph and two table rows now state the three classes. This file is outside the dispatch's file fence; see Acceptance notes. - **One changeset** for `@objectstack/service-storage`, `minor`, with the BREAKING banner, the remedy (sign in, or mark the file `public_read`) and an ADR-0087 disposition `not-required (no-migration-prescription)`. ## Measured before building (the card's stop condition) Measured on `origin/main` `b9222dc701` on a booted showcase (`objectstack dev --fresh`, `single` posture). The readings stay in the seat's container. Classes only here: - **Reproduction.** An anonymous download of an unclaimed upload was served at both download routes, bytes included. Controls: an attachments-scope file and a field-owned file were refused `401 AUTH_REQUIRED` to the same caller, and an anonymous upload was refused `401 AUTH_REQUIRED`. - **Producers of unclaimed files.** In this repository only the two upload routes create a `sys_file` row (`StorageMetadataStore.createFile`). The copy-on-claim copy is claimed by construction. No seed, branding, theme or import path creates one, and the showcase seeds none. The rows that stay unclaimed come from what clients do with an upload: the console writes an uploaded avatar into the user's `image` URL field and an uploaded organization logo into the organization's `logo` URL field. Neither is a file-class field, so neither is ever claimed. A picked file stays unclaimed until its record is saved, an abandoned upload stays unclaimed, and so does a file whose owner released it. Nothing in the repository produces a `public_read` file. - **Readers of such a file, and when they render.** The console renders avatars and organization logos (header, user menu, profile, members, organizations, organization settings) and pre-save upload previews. Every one of them is behind sign-in. The surfaces that render before sign-in read nothing in this class. The sign-in pages draw their logo from operator configuration, never from an upload. The invitation page draws no logo or avatar. A public form cannot upload anonymously. The share page renders no stored file. No in-repo email template renders an avatar or a logo. - **How a signed-in browser reaches the routes.** The console signs in through the better-auth client, which sends credentials by default, so the browser holds the HttpOnly, SameSite=Lax session cookie the sign-in sets, beside the bearer token. In a real Chromium session signed in that way, image tags pointing at the routes loaded the already-gated classes (attachments-scope and field-owned). A session with no cookie (a bearer-only client) failed them. The routes' `resolveSession` reads the cookie the same as a bearer header. - **Verdict:** no surface the repository ships stops rendering, for a signed-out or a signed-in viewer. Built. ## Tests - **Unit, `storage-routes.test.ts`:** a new block for the unclaimed class. It covers the refusal at both doors (code, status, envelope, no URL minted, authorizer not consulted), parity with the upload gate's anonymous answer, fail-closed on a throwing resolver and on a user-less session, and a signed-in caller served as before (302, and the presigned TTL read back out of the minted URL). It also covers `public_read` anonymous without a session read, the parent-governed verdicts unchanged and the resolver not consulted, a missing file 404 before the session is asked, and a bare kernel open with one notice. Against the unfixed code: **4 failed / 40 passed** (the refusal pins and the notice red; the controls green). With the fix: green. - **Conformance, `error-envelope.conformance.test.ts`:** the new refusal joins the driven error branches. - **Dogfood, `storage-unclaimed-download.dogfood.test.ts`** (one file, booted showcase with the storage plugin): the anonymous refusal at both doors, a bearer caller served, a **cookie-only** caller served (the transport an image tag uses), `public_read` anonymous and back, and controls (anonymous upload, attachments-scope download). Against main's `dist`: **2 failed / 4 passed**. With the fix: **6 / 6**. - **Ablation** (committed fix first, mutation through `scripts/ablation-replace.mjs`, restore trap held): deleting the session-gate call in `authorizeDownload` landed (anchor 1 → 0, blob changed). After a rebuild, `ablation-dist-preflight --absent` showed the call gone from all 4 built files. Unit + conformance went **5 failed / 57 passed**, and the dogfood file went **2 failed / 4 passed**. Restore: blob equal to HEAD, `git diff HEAD` empty, `git status --porcelain` empty. The rebuild preflight showed the call present in `dist/index.js` and `dist/index.cjs`, then **62 / 62** and **6 / 6**. (The mutated build's DTS step failed on the now-unused helper, `TS6133`. ESM and CJS were rebuilt, and those are what the suites import.) - **Full `@objectstack/service-storage` suite:** 46 files, **782 passed**; `typecheck` exit 0 (with `check:test-typecheck`). - **Every dogfood file that uploads, downloads or touches `sys_file`** (18 files) at `42fe8d498c`: **175 passed, 1 skipped** (the pre-existing `skipIf(!organizationsAvailable)` block), exit 0. `@objectstack/dogfood` `typecheck` exit 0, with the new file in the program. ## Gates (at `42fe8d498c`, after merging `origin/main` `05c7c3fa3b`) - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) derived **98** commands from the six changed paths. All 98 were run, and all exited 0. `--ran` reconciliation: `98 derived famil(ies) accounted for — 98 run, 0 NOT-MEASURED (a DERIVED zero — all 98 recorded an exit code and none of them is 3)`. - `pnpm check:error-status-conformance` (also by hand, per the dispatch): `✓ every derivable runtime status is documented, and every documented status is reachable.` - Changeset, `check-changeset-no-major` with this body as the event: `✓ This diff introduces no major bump.` and `✓ LEVEL AXIS: this PR declares clause-② no (narrowing), and no package whose packages/**/src/** it moves is graded patch.` `check-adr-0087-registration`: `✓ 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition` (`BREAKING+bang+clause-②-narrowing`, `not-required (no-migration-prescription)`). - `check:nul-bytes`: `OK (... no raw ASCII control bytes)`. `check:route-envelope`, `check:doc-authoring`, `check:cross-package-test-inputs` and `check:test-source-alias` all exited 0. - ESLint, narrowed to the four changed TypeScript files and proven: each is in the config's population (`--print-config` resolves for every one), the JSON output counts 4 files with 0 errors and 0 warnings, and `eslint.config.mjs` never enables type-aware linting, so this diff cannot move a verdict on an untouched file. The repo-wide `pnpm lint` is CI's run. ## Acceptance notes - **File fence.** The dispatch fenced `storage-routes.ts` and its tests, one dogfood file and one changeset. This PR also corrects `content/docs/permissions/attachments-access.mdx`, because the change makes its statement false. The standing dev rules require a published statement this change falsifies to be fixed in the same change. The conflict is named here rather than settled silently. Drop the commit if the seat rules otherwise. - **TTL kept.** A signed-in download of an unclaimed file still mints its URL with the presigned TTL, not the short gated-download TTL. "Served as today" was the instruction. - **Order kept.** The routes look the file up before judging the caller, so a missing file answers 404 before the session is asked, exactly as the parent-governed classes already do. - **Where a signed-in page would still go dark** (not a shipped shape, noted): a console served cross-site from its API (a SameSite=Lax cookie is not sent on a cross-site image request), and a session restored from a bearer token alone. Field-owned images already fail the same way there today. - **Boot wording elsewhere.** `mountStorageRoutes`' unbound-gates warning and the `StorageRoutesMountReport.sessionResolver` docstring (in `storage-service-plugin.ts`, which PR #22396 edits and this PR does not touch) still describe the resolver as gating uploads. Both are still true and now incomplete. Carrier: none. - `.changeset` grading: `check-changeset-no-major` and `check-adr-0087-registration` verdicts are under Gates. --- _Generated by [Claude Code](https://claude.ai/code/session_01WkL6Eijt432S1Y7ekb6ovQ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bf492c8 commit dd986d8

6 files changed

Lines changed: 452 additions & 27 deletions

File tree

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)!: downloading a file with no attachments scope and no field owner requires a signed-in caller
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) A runtime authorization narrowing at the two storage download routes, 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 routes serve for one file class: a caller with no session is now refused a file that has neither an attachments scope nor a field owner, while a signed-in caller is served as before. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers these routes 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+
The storage download routes, both the one that answers a signed URL and the stable one that redirects to the bytes, now require a signed-in caller for a file that has neither an attachments scope nor a field owner: an upload no record has claimed. That is the class an avatar or an organization logo stored as a URL belongs to, and so is a picked file not yet saved to its record. ADR-0104 made the anonymous capability URL an opt-in, `acl: 'public_read'`; this was the one class still served anonymously by default.
14+
15+
- **Refused now:** a caller with no session, with `401 AUTH_REQUIRED`, the answer the upload routes and the attachments gate already give an unauthenticated caller. No signed URL is minted for the refused caller.
16+
- **Unchanged:** a signed-in caller is served exactly as before, including the signed URL's lifetime. A browser's `<img src>` and `<a href>` send the session cookie the sign-in set, so a signed-in page keeps rendering these files. A file marked `acl: 'public_read'` stays anonymous. Attachments-scope and field-owned files keep their parent-record verdicts. A deployment with no `auth` service, whose storage routes run without a session resolver, keeps these downloads open as before and says so once in its log.
17+
18+
What changes for you. Before this release, anyone holding such a file's id could download it; now, sign in first. A file that must render before sign-in (on a sign-in page, in an email, on a public page) needs `acl: 'public_read'` on its `sys_file` row.

‎content/docs/permissions/attachments-access.mdx‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ record, and issue a **short-lived signed URL**:
111111

112112
| Code | Status | When |
113113
| --- | --- | --- |
114-
| `AUTH_REQUIRED` | 401 | Anonymous download of an attachments-scope file, or a credential the organization wall refuses (see below) |
114+
| `AUTH_REQUIRED` | 401 | Anonymous download of any file not marked `acl: 'public_read'` (see below), or a credential the organization wall refuses |
115115
| `ATTACHMENT_DOWNLOAD_DENIED` | 403 | The caller is neither the file's owner nor able to read any record it is attached to |
116116

117117
**The 401 is the generic "unauthenticated" answer, and it has always covered
@@ -132,10 +132,15 @@ anonymous case does: the response is byte-identical to sending no credential at
132132
all, and the reason is written to the server log instead. The check itself lives
133133
in the shared API-key admission path, not in the attachments gate.
134134

135-
The gate is scoped to attachments files on purpose: **non-attachments files**
136-
(avatars, `Field.image` thumbnails, org logos) keep their stable, anonymous
137-
capability URL, because they are embedded in `<img src>` which cannot carry a
138-
bearer token. Their discovery is already gated by access to the owning record.
135+
The parent-record check applies to files that have a parent: attachments-scope
136+
files, and files a record's `file` / `image` field owns, which are judged against
137+
that one record (`FILE_DOWNLOAD_DENIED`, 403, when it cannot be read). A file with
138+
**neither** — an upload no record has claimed, such as an avatar or an
139+
organization logo stored as a URL — has no parent to check, so its download
140+
requires only a signed-in caller (`AUTH_REQUIRED`, 401, otherwise). A browser's
141+
`<img src>` sends the session cookie set at sign-in, so a signed-in page keeps
142+
rendering these files. Only a file marked `acl: 'public_read'` is served to a
143+
caller with no session: mark a file that way when it must render before sign-in.
139144

140145
The upload entry points (presigned / chunked) likewise require a session when
141146
an auth service is wired, and stamp `owner_id` on the new `sys_file`.
@@ -174,7 +179,7 @@ can be shared across records). Reclamation is handled by the platform LifecycleS
174179
| Attach (create) | can edit the parent record | `ATTACHMENT_PARENT_ACCESS` (403) |
175180
| List / read | inherits parent read visibility | *(filtered out)* |
176181
| Delete | uploader or parent editor (+ RBAC delete grant) | `ATTACHMENT_DELETE_DENIED` (403); `PERMISSION_DENIED` (403) when the parent is not readable or no delete grant is held |
177-
| Download | session + owner-or-parent-read (attachments scope) | `AUTH_REQUIRED` (401) / `ATTACHMENT_DOWNLOAD_DENIED` (403) |
182+
| Download | session + owner-or-parent-read (attachments scope); session only for a file with no parent; none for `acl: 'public_read'` | `AUTH_REQUIRED` (401) / `ATTACHMENT_DOWNLOAD_DENIED` (403) |
178183

179184
## See also
180185

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#22431] A file with neither an attachments scope nor a field owner — an
4+
// upload no record has claimed, which is how an avatar or an organization logo
5+
// stored as a URL lives — needs a signed-in caller at both download doors,
6+
// over a REAL showcase boot. `acl: 'public_read'` stays the one anonymous
7+
// download (ADR-0104).
8+
//
9+
// The package suite (`service-storage/src/storage-routes.test.ts`) pins the
10+
// gate over a hand-wired resolver. This file is where the composed one runs:
11+
// the plugin's own `kernel:ready` mount binds the kernel's `auth` service as
12+
// the resolver, so the answer a caller gets here is the deployment's answer.
13+
//
14+
// The half that decides whether the change is safe to ship is the COOKIE case.
15+
// A browser renders these files through `<img src>` / `<a href>`, which can
16+
// carry no bearer header — only the session cookie the sign-in set. So the
17+
// signed-in reader is asserted twice: once with the bearer a script sends,
18+
// once with nothing but that cookie, and both must reach the bytes.
19+
//
20+
// Not eligible for the shared showcase project: it boots its own plugins.
21+
22+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
23+
import { mkdtempSync, promises as fs } from 'node:fs';
24+
import { join } from 'node:path';
25+
import { tmpdir } from 'node:os';
26+
import showcaseStack from '@objectstack/example-showcase';
27+
import { bootStack, type VerifyStack } from '@objectstack/verify';
28+
import { StorageServicePlugin } from '@objectstack/service-storage';
29+
import { showcaseAppDefaultSecurity } from './showcase-security.js';
30+
31+
const SYS = { isSystem: true } as const;
32+
const BYTES = 'unclaimed';
33+
34+
/** Strip the origin off an absolute adapter URL so it can be re-injected. */
35+
const toPath = (url: string): string => url.replace(/^https?:\/\/[^/]+/, '');
36+
37+
describe('[#22431] a download of a file with no attachments scope and no field owner needs a signed-in caller', () => {
38+
let stack: VerifyStack;
39+
let rootDir: string;
40+
let ql: any;
41+
let token: string;
42+
let cookie: string;
43+
/** Uploaded with no scope named — the shape the console's upload adapter sends. */
44+
let unclaimed: string;
45+
let attached: string;
46+
47+
const bearer = () => ({ Authorization: `Bearer ${token}` });
48+
49+
/** The real three-step presigned upload; `scope` omitted unless named. */
50+
const upload = async (name: string, scope?: string): Promise<string> => {
51+
const presign = await stack.api('/storage/upload/presigned', {
52+
method: 'POST',
53+
headers: { 'Content-Type': 'application/json', ...bearer() },
54+
body: JSON.stringify({ filename: name, mimeType: 'text/plain', size: BYTES.length, ...(scope ? { scope } : {}) }),
55+
});
56+
expect(presign.status, 'presign').toBe(200);
57+
const { data } = (await presign.json()) as any;
58+
const put = await stack.raw(toPath(String(data.uploadUrl)), {
59+
method: 'PUT',
60+
headers: data.headers ?? { 'content-type': 'text/plain' },
61+
body: BYTES,
62+
});
63+
expect(put.status, 'raw PUT').toBeLessThan(300);
64+
const complete = await stack.api('/storage/upload/complete', {
65+
method: 'POST',
66+
headers: { 'Content-Type': 'application/json', ...bearer() },
67+
body: JSON.stringify({ fileId: data.fileId }),
68+
});
69+
expect(complete.status, 'complete').toBe(200);
70+
return String(data.fileId);
71+
};
72+
73+
/** Both doors for one caller: the JSON door and the redirect door. */
74+
const doors = async (fileId: string, headers: Record<string, string> = {}) => ({
75+
url: await stack.api(`/storage/files/${fileId}/url`, { headers }),
76+
redirect: await stack.api(`/storage/files/${fileId}`, { headers, redirect: 'manual' } as RequestInit),
77+
});
78+
79+
const expectRefused = async (res: Response, label: string) => {
80+
expect(res.status, label).toBe(401);
81+
const body = (await res.json()) as any;
82+
expect(body.success, label).toBe(false);
83+
expect(body.error?.code, label).toBe('AUTH_REQUIRED');
84+
};
85+
86+
/** A 302 whose target, followed with NO credential, serves the uploaded bytes. */
87+
const expectBytesBehindRedirect = async (res: Response, label: string) => {
88+
expect(res.status, label).toBe(302);
89+
const location = res.headers.get('location');
90+
expect(location, `${label}: a 302 with no Location`).toBeTruthy();
91+
const bytes = await stack.raw(toPath(String(location)));
92+
expect(bytes.status, label).toBe(200);
93+
expect(await bytes.text(), label).toBe(BYTES);
94+
};
95+
96+
beforeAll(async () => {
97+
rootDir = mkdtempSync(join(tmpdir(), 'unclaimed-download-'));
98+
stack = await bootStack(showcaseStack, {
99+
security: showcaseAppDefaultSecurity(),
100+
extraPlugins: [new StorageServicePlugin({ adapter: 'local', local: { rootDir }, bindToSettings: false })],
101+
});
102+
ql = await stack.kernel.getServiceAsync('objectql');
103+
token = await stack.signIn();
104+
105+
// The browser transport: the session cookie the sign-in response sets.
106+
const signIn = await stack.api('/auth/sign-in/email', {
107+
method: 'POST',
108+
headers: { 'Content-Type': 'application/json' },
109+
body: JSON.stringify({ email: 'admin@objectos.ai', password: 'admin123' }),
110+
});
111+
expect(signIn.status).toBe(200);
112+
cookie = signIn.headers
113+
.getSetCookie()
114+
.map((c) => c.split(';')[0])
115+
.filter((pair) => pair.includes('session_token='))
116+
.join('; ');
117+
expect(cookie, 'the sign-in sets a session cookie').toContain('session_token=');
118+
119+
unclaimed = await upload('unclaimed.txt');
120+
attached = await upload('attached.txt', 'attachments');
121+
}, 120_000);
122+
123+
afterAll(async () => {
124+
await stack?.stop();
125+
if (rootDir) await fs.rm(rootDir, { recursive: true, force: true });
126+
});
127+
128+
it('the file under test is unclaimed: no attachments scope, no field owner, not public_read', async () => {
129+
const row = await ql.findOne('sys_file', { where: { id: unclaimed }, context: SYS });
130+
expect(row?.scope).not.toBe('attachments');
131+
expect(row?.ref_object ?? null).toBeNull();
132+
expect(row?.acl ?? 'private').not.toBe('public_read');
133+
});
134+
135+
it('an anonymous caller is refused 401 AUTH_REQUIRED at both download doors', async () => {
136+
const { url, redirect } = await doors(unclaimed);
137+
await expectRefused(url, 'the URL door');
138+
await expectRefused(redirect, 'the redirect door');
139+
expect(redirect.headers.get('location'), 'no capability URL leaks on the refusal').toBeNull();
140+
});
141+
142+
it('a signed-in caller with a bearer token is served as before', async () => {
143+
const { url, redirect } = await doors(unclaimed, bearer());
144+
expect(url.status).toBe(200);
145+
const body = (await url.json()) as any;
146+
const bytes = await stack.raw(toPath(String(body.data.url)));
147+
expect(await bytes.text()).toBe(BYTES);
148+
await expectBytesBehindRedirect(redirect, 'bearer, redirect door');
149+
});
150+
151+
it('a signed-in browser is served through its session cookie alone — what <img src> carries', async () => {
152+
const { url, redirect } = await doors(unclaimed, { cookie });
153+
expect(url.status, 'the URL door, cookie only').toBe(200);
154+
await expectBytesBehindRedirect(redirect, 'cookie only, redirect door');
155+
});
156+
157+
it("acl: 'public_read' keeps the file anonymous, and only that declaration does", async () => {
158+
await ql.update('sys_file', { acl: 'public_read' }, { where: { id: unclaimed }, context: SYS });
159+
try {
160+
const { url, redirect } = await doors(unclaimed);
161+
expect(url.status, 'public_read, anonymous URL door').toBe(200);
162+
await expectBytesBehindRedirect(redirect, 'public_read, anonymous redirect door');
163+
} finally {
164+
await ql.update('sys_file', { acl: 'private' }, { where: { id: unclaimed }, context: SYS });
165+
}
166+
await expectRefused((await doors(unclaimed)).redirect, 'back to private');
167+
});
168+
169+
it('controls: an anonymous upload and an anonymous attachments-scope download stay refused', async () => {
170+
const presign = await stack.api('/storage/upload/presigned', {
171+
method: 'POST',
172+
headers: { 'Content-Type': 'application/json' },
173+
body: JSON.stringify({ filename: 'anon.txt', mimeType: 'text/plain', size: 1 }),
174+
});
175+
await expectRefused(presign, 'anonymous upload');
176+
const { url, redirect } = await doors(attached);
177+
await expectRefused(url, 'attachments-scope, URL door');
178+
await expectRefused(redirect, 'attachments-scope, redirect door');
179+
});
180+
});

‎packages/services/service-storage/src/error-envelope.conformance.test.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,19 @@ describe('storage error envelope (#3675)', () => {
314314
return drive(routes, 'GET', `${BASE}/files/:fileId/url`, { params: { fileId: 'a2' } });
315315
},
316316
},
317+
{
318+
// #22431: a file with neither an attachments scope nor a field owner
319+
// needs a signed-in caller — the same pair as the two 401s above.
320+
name: 'anonymous download of a file with neither an attachments scope nor a field owner',
321+
status: 401,
322+
code: 'AUTH_REQUIRED',
323+
run: async () => {
324+
const store = new StorageMetadataStore(null);
325+
await committedAttachment(store, 'u1', { scope: 'user', key: 'user/u1.png' });
326+
const routes = mount(await tmpAdapter(), store, { resolveSession: async () => null });
327+
return drive(routes, 'GET', `${BASE}/files/:fileId`, { params: { fileId: 'u1' } });
328+
},
329+
},
317330
{
318331
name: 'raw upload against an adapter with no token support',
319332
status: 501,

0 commit comments

Comments
 (0)