@ultimat3/storage 21.0.0 → 22.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +28 -72
- package/README.md +13 -3
- package/package.json +2 -2
- package/src/attachment.ts +9 -3
- package/src/driver-local.ts +5 -2
- package/src/errors.ts +15 -0
package/CLAUDE.md
CHANGED
|
@@ -15,31 +15,16 @@ Tier 1. Object storage: named disks, safe keys, signed URLs, sniffed uploads.
|
|
|
15
15
|
wrap it in its own `action()` with its own policy. See `docs/architecture/17-uploads.md`.
|
|
16
16
|
|
|
17
17
|
- **Every byte ceiling and every TTL is screened where it is DECLARED, through core's
|
|
18
|
-
`finiteCount` and nothing else
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
stay true.
|
|
25
|
-
Measured: `uploadPolicy({ maxBytes: Number.NaN })` accepted a 5,000,016-byte PNG through
|
|
26
|
-
`validateUpload`, because `size > NaN` is false — the one number deciding how much a caller may
|
|
27
|
-
store stopped deciding anything. Variant `quality` is core's `assertFiniteImageQuality` and NOT a
|
|
28
|
-
second screen: `variantKey` never reaches the encoder, so a copy that disagreed would mint
|
|
29
|
-
`q150` keys for bytes `transformImageBytes` then refuses.
|
|
30
|
-
- **The default is taken on `undefined` and on nothing else, `As of 2026-08-26`** —
|
|
31
|
-
`options.x === undefined ? D : options.x`, never `options.x ?? D`. `??` coalesces on `null` too,
|
|
32
|
-
so an explicitly blanked key in a decoded JSON config took the default BEFORE the screen above
|
|
33
|
-
could refuse it: the mirror of the `NaN` half, where one value slips past the guard and the other
|
|
34
|
-
past the default, and both end in a bound nobody chose. Every site above, `quality` included.
|
|
18
|
+
`finiteCount` and nothing else** — `uploadPolicy({ maxBytes })`, both drivers' `maxPutBytes`,
|
|
19
|
+
`createUploadGrant({ expiresInMs })`, `buildSignedUrl` and the s3 presign TTL. No private copy:
|
|
20
|
+
`size > NaN` is false, so an unscreened `NaN` ceiling stops deciding anything. Variant `quality`
|
|
21
|
+
is core's `assertFiniteImageQuality`, not a second screen.
|
|
22
|
+
- **The default is taken on `undefined` and on nothing else** — `x === undefined ? D : x`, never
|
|
23
|
+
`x ?? D`: `??` defaults a blanked `null` before the screen can refuse it.
|
|
35
24
|
|
|
36
|
-
- **`MAX_KEY_LENGTH` is BYTES,
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
never MORE than the UTF-8 byte count, so a non-ASCII key over the real limit passed this guard
|
|
40
|
-
and was refused by the store instead: 400 CJK characters is 400 units and 1,200 bytes. Keeping
|
|
41
|
-
local and remote disks interchangeable is the whole reason the ceiling exists, so it has to be
|
|
42
|
-
the store's ceiling. Same defect and same fix as `@ultimat3/cache`'s surrogate-key guard.
|
|
25
|
+
- **`MAX_KEY_LENGTH` is UTF-8 BYTES, measured in bytes** — S3's limit. A UTF-16 `length` passed
|
|
26
|
+
400 CJK characters (1,200 bytes) that the store then refused. Same fix as `@ultimat3/cache`'s
|
|
27
|
+
surrogate-key guard.
|
|
43
28
|
|
|
44
29
|
| Rule | |
|
|
45
30
|
|---|---|
|
|
@@ -78,16 +63,9 @@ Gotchas:
|
|
|
78
63
|
in a single `test()` per claim, so neither disk can move alone. Where the two genuinely cannot
|
|
79
64
|
agree it pins the DIVERGENCE, with the reason — that is the honest form, and it still fails the
|
|
80
65
|
day either half changes.
|
|
81
|
-
- **The local `list()` globs with `dot: true`, and the `META_DIR` skip is the
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
legal — was missing from the listing while `put`/`get`/`exists` handled it normally and
|
|
85
|
-
`s3Driver.list()` returned it: the key space and the listing disagreed by construction.
|
|
86
|
-
`sweepOrphans` pages through `list()`, so those objects were reported as erased while still on
|
|
87
|
-
disk — a false erasure report by OMISSION, the same lie a swallowed listing error tells. The
|
|
88
|
-
`.meta/` skip one line below was unreachable until this landed and this file called it "a second
|
|
89
|
-
line of defence"; it is now the only thing keeping the sidecar tree out of the object namespace,
|
|
90
|
-
and it folds case for `isSafeKey`'s reason. Pinned in `driver-parity.test.ts`.
|
|
66
|
+
- **The local `list()` globs with `dot: true`, and the `META_DIR` skip is the real filter.**
|
|
67
|
+
Without `dot`, dot-prefixed keys were missing from the listing while `put`/`get` handled them —
|
|
68
|
+
and `sweepOrphans` pages through `list()`. Pinned in `driver-parity.test.ts`.
|
|
91
69
|
- **A `list({ limit })` is a positive integer or a refusal** — `resolveListLimit` at the
|
|
92
70
|
`ListOptions` seam, so both disks answer one way. `limit: 0` used to slice `[0, 0)` on the local
|
|
93
71
|
disk and then drop its own `truncated` flag (`truncated && last !== undefined`, with an empty
|
|
@@ -112,14 +90,9 @@ Gotchas:
|
|
|
112
90
|
for the http error map to render but a 500. `sweepOrphans` walks `list()`, so the local swallow
|
|
113
91
|
was a false-erasure report a layer up. `ENOENT` (a root nobody has written to) is still an empty
|
|
114
92
|
page; everything else is `X_STORAGE_LIST_FAILED`, whose `fix` the DRIVER supplies.
|
|
115
|
-
- **`head()` NEVER
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
sequentially, and `copy()` inherited it, so a copy documented as never routing bytes through the
|
|
119
|
-
heap buffered the entire source. A listing that cannot know an etag reports `''`, which is what
|
|
120
|
-
the s3 listing already answers. `get()` hashes out of bytes it already holds; `copy()` passes
|
|
121
|
-
`hash: true`, because the sidecar it writes at the destination would otherwise carry `etag: ''`
|
|
122
|
-
as a durable lie.
|
|
93
|
+
- **`head()` and `list()` NEVER read an object's bytes.** A listing that cannot know an etag
|
|
94
|
+
reports `''`, as the s3 listing does. `get()` hashes bytes it already holds; `copy()` passes
|
|
95
|
+
`hash: true` so the destination sidecar never records `etag: ''`.
|
|
123
96
|
- **`put({ metadata })` / `put({ cacheControl })` is the one `PutOptions` pair the disks disagree
|
|
124
97
|
about, and it is pinned rather than resolved.** Bun's `S3File.write` exposes `type`, `acl` and
|
|
125
98
|
`storageClass` and no header hook for `x-amz-meta-*` or `Cache-Control`, so `s3Driver` refuses
|
|
@@ -181,19 +154,10 @@ Gotchas:
|
|
|
181
154
|
may be `NODE_ENV`'s, never a variable the process did not set. `usesDevStorageSecret()` is the
|
|
182
155
|
`x doctor` predicate, mirroring core's `usesDevCursorSecret()`; it reads the env var, so a disk
|
|
183
156
|
handed an explicit `signingSecret` is outside its question.
|
|
184
|
-
- **`localDriver({ env })` and `usesDevStorageSecret({ env })` are ONE question about ONE table
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
environment its refusal names. An embedding caller whose env is not the process's (`serveApp({ env })`,
|
|
189
|
-
a test fixture) got the verdict from one table and the behaviour from another, in the dangerous
|
|
190
|
-
direction: a production boot with no secret, launched from a development shell that has one,
|
|
191
|
-
signing every grant with the published literal. All three reads now come off `options.env ??
|
|
192
|
-
process.env`, so a bare `localDriver({ root })` is unchanged and additive.
|
|
193
|
-
`driver-local-boot.test.ts` pins it by mutation — reverting any one read to `process.env` fails.
|
|
194
|
-
That file is the CONSTRUCTION half, split off `driver-local.test.ts` at the line ceiling along
|
|
195
|
-
the seam the guard already draws: nothing in it writes a byte, so it needs no temporary
|
|
196
|
-
directory, and `driver-local.test.ts` keeps everything the disk actually does with an object.
|
|
157
|
+
- **`localDriver({ env })` and `usesDevStorageSecret({ env })` are ONE question about ONE table**:
|
|
158
|
+
the secret, `isLocal()` and the refusal's environment all read `options.env ?? process.env`, so
|
|
159
|
+
the boot's guard and the constructor it guards cannot answer from two tables.
|
|
160
|
+
`driver-local-boot.test.ts` pins it by mutation.
|
|
197
161
|
- **The mounted read half is `@ultimat3/cli`'s `dev-storage.ts`, not this package.** `GET
|
|
198
162
|
/_storage/:disk/*key` gates on `@ultimat3/policy`'s `evaluate()` (`storage:read`), which is tier
|
|
199
163
|
2 and unreachable from here — so a "serve this object" helper in this package could only ever be
|
|
@@ -212,19 +176,11 @@ Gotchas:
|
|
|
212
176
|
Its image failures (`X_IMAGE_UNSUPPORTED`, `X_IMAGE_DECODE_FAILED`) surface unwrapped —
|
|
213
177
|
wrapping them in a `StorageError` would give one failure two codes. `variantKey` now RAISES
|
|
214
178
|
core's `imageUnsupported()` as well as passing them through, for that same reason.
|
|
215
|
-
- **`VARIANT_FORMATS` is this package's format vocabulary, and `IMAGE_FORMATS` is core's
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
`photos/hero@full.undefined`, a well-formed writable key naming a file nothing can serve. The set
|
|
221
|
-
is now a strict subset **by the compiler**: `as const satisfies readonly ImageFormat[]`, so a
|
|
222
|
-
member core cannot name is a build error here. `isVariantFormat` takes `string` on purpose, so
|
|
223
|
-
`probeImage(bytes).format` narrows through it with no cast. `avif` is in the set and `gif`/`svg`
|
|
224
|
-
are not because the question is what a variant KEY can carry, never what core can transform —
|
|
225
|
-
core decodes `gif` perfectly well, and naming this set `TRANSFORMABLE_FORMATS` would have been
|
|
226
|
-
the same lie one rename later. `scripts/render-modes.ts` holds the `IMAGE_FORMATS` row (`by:
|
|
227
|
-
'name'`) and `image.test.ts` fails the day either core name reappears in `src/index.ts`.
|
|
179
|
+
- **`VARIANT_FORMATS` is this package's format vocabulary, and `IMAGE_FORMATS` is core's** (what
|
|
180
|
+
core can PROBE). `as const satisfies readonly ImageFormat[]` makes it a strict subset by the
|
|
181
|
+
compiler; `isVariantFormat` takes `string` so `probeImage(bytes).format` narrows with no cast.
|
|
182
|
+
`scripts/render-modes.ts` holds the `IMAGE_FORMATS` row and `image.test.ts` fails if either core
|
|
183
|
+
name reappears in `src/index.ts`.
|
|
228
184
|
- `transformImage()` must encode at exactly `fitDimensions()`'s size, and passes `format`
|
|
229
185
|
explicitly (`?? 'webp'`, which then rejects): bytes that disagree with the `variantKey`
|
|
230
186
|
extension, or with the `width`/`height` `@ultimat3/seo` inlined, are the layout shift the
|
|
@@ -281,8 +237,8 @@ Gotchas:
|
|
|
281
237
|
default header, and `decodeError` keeps a refusal `X_STORAGE_UPLOAD_FAILED` with the disk's
|
|
282
238
|
status. A request with no response at all is the transport's `X_CLIENT_TRANSPORT_FAILED` — it was a
|
|
283
239
|
bare `TypeError` before. This file calls no `fetch`; the XHR above is the one exemption.
|
|
284
|
-
- **Bundle
|
|
285
|
-
|
|
286
|
-
core's transport graph, paid only where the chunk did not already carry it — an island that
|
|
287
|
-
also calls an action or a query already does.
|
|
240
|
+
- **Bundle** (`bun build --target=browser --minify`, `uploadFile` only, `As of 2026-09-22`):
|
|
241
|
+
**17,767 B** on `clientTransport`; the +5.9 kB over 20.2.1 is core's transport graph.
|
|
288
242
|
- `exactOptionalPropertyTypes` is on — declare optional fields as `x?: T | undefined`.
|
|
243
|
+
|
|
244
|
+
Why each rule above is shaped the way it is: [`docs/history/storage.md`](../../docs/history/storage.md).
|
package/README.md
CHANGED
|
@@ -106,7 +106,7 @@ The HMAC covers the **constraints**, not just the key —
|
|
|
106
106
|
A client that edits `?x-max=` invalidates the signature — it cannot widen what it was granted.
|
|
107
107
|
|
|
108
108
|
The HMAC key is `signingSecret`, else `STORAGE_SIGNING_SECRET`, else the shipped
|
|
109
|
-
`DEV_SIGNING_SECRET` — and **only in `development` or `test
|
|
109
|
+
`DEV_SIGNING_SECRET` — and **only in `development` or `test`**, read with a fallback of `production`: a process that names no environment at all (`ULTIMATE_ENV` and `NODE_ENV` both unset) is refused like a production one, `As of 2026-09-23`. Anywhere else `localDriver` refuses
|
|
110
110
|
to construct (`X_ENV_MISSING`) unless one of those two is set to something that is not the shipped
|
|
111
111
|
literal: the dev literal is published in this repo, so signing with it lets anyone mint a `PUT` for
|
|
112
112
|
any key with a `maxBytes` and `contentType` of their choosing, which `acceptSignedUpload` then
|
|
@@ -189,9 +189,9 @@ An upload happens **before** the row it belongs to exists, so it lands at
|
|
|
189
189
|
| `pendingKey(orgId, uploadName(id, filename))` | `org/o1/pending/u-1.png` |
|
|
190
190
|
| `quarantineKey(orgId, name)` | `org/o1/pending/quarantine/u-1.png` |
|
|
191
191
|
| `attachmentKey(orgId, { entity, id, field }, name)` | `org/o1/post/p-1/cover/u-1.png` |
|
|
192
|
-
| `promoteAttachment({ disk, key, orgId, target })` | `copy` then `delete` — never the reverse |
|
|
192
|
+
| `promoteAttachment({ disk, key, orgId, target })` | `copy` then `delete` — never the reverse. The key must be a **pending** one (`isPendingKey`): an attached key a client sent back is another row's file, and moving it would delete the victim's copy (`X_STORAGE_NOT_PENDING`) |
|
|
193
193
|
| `releaseQuarantine({ disk, key, orgId })` | quarantine → pending; returns the released key |
|
|
194
|
-
| `sweepOrphans({ disk, orgId, olderThanMs })` | `{ deleted, failed }` for stale `pending/` keys |
|
|
194
|
+
| `sweepOrphans({ disk, orgId, olderThanMs })` | `{ deleted, failed }` for stale `pending/` keys. `olderThanMs` is a whole number of 0 or more, refused otherwise (`X_INVARIANT`) — `NaN` read as "everything is old enough" and deleted an upload made a moment ago |
|
|
195
195
|
|
|
196
196
|
The filename contributes nothing but its extension, and only if it matches `.[a-z0-9]{1,12}`.
|
|
197
197
|
`sweepOrphans` can only reach the `pending/` prefix of one org — a sweep that could touch an
|
|
@@ -239,11 +239,21 @@ Inside `pending/` deliberately: an upload nobody ever scanned is still an orphan
|
|
|
239
239
|
| `X_STORAGE_DELETE_FAILED` | the disk REFUSED a delete — denied `s3:DeleteObject`, a throttle, an expired credential, a read-only mount. An **absent** key is still not an error |
|
|
240
240
|
| `X_STORAGE_LIST_FAILED` | the disk REFUSED a listing — denied `s3:ListBucket`, a throttle, an unreadable root. An **empty** disk is still not an error |
|
|
241
241
|
| `X_STORAGE_QUARANTINED` | `promoteAttachment` on a key nothing has released from `pending/quarantine/` |
|
|
242
|
+
| `X_STORAGE_NOT_PENDING` | `promoteAttachment` on a key outside the org's `pending/` prefix — most often another row's attached key |
|
|
242
243
|
| `X_NOT_IMPLEMENTED` | S3 user metadata / cache-control; `serverSideEncryption` on either driver |
|
|
243
244
|
| `X_ENV_MISSING` | core's: S3 credential env vars, or a `localDriver` built outside development where neither `signingSecret` nor `STORAGE_SIGNING_SECRET` holds a secret other than the published `DEV_SIGNING_SECRET` |
|
|
244
245
|
| `X_IMAGE_UNSUPPORTED` | core's: an `avif` encode, a source no built-in decoder reads, or a `variantKey` format no variant can carry |
|
|
245
246
|
| `X_IMAGE_DECODE_FAILED` | core's: truncated or corrupt image bytes |
|
|
246
247
|
|
|
248
|
+
### Error classes
|
|
249
|
+
|
|
250
|
+
Every error class `src/index.ts` exports, for `instanceof` inside one process. Across a wire or
|
|
251
|
+
a job boundary the class is gone and the `code` is what survives — match on that.
|
|
252
|
+
|
|
253
|
+
| Class | Code | Declared in |
|
|
254
|
+
|---|---|---|
|
|
255
|
+
| `StorageError` | any `StorageErrorCode` — `STORAGE_ERROR_CODES` | `src/errors.ts` |
|
|
256
|
+
|
|
247
257
|
## Images
|
|
248
258
|
|
|
249
259
|
`variantKey()`, `srcsetDescriptors()`, `fitDimensions()` are pure — `@ultimat3/seo` builds
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/storage",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "22.0.0",
|
|
4
4
|
"description": "Named disks over Bun.file and Bun.s3: safe keys, signed URLs, sniffed uploads",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -31,6 +31,6 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "
|
|
34
|
+
"@ultimat3/core": "22.0.0"
|
|
35
35
|
}
|
|
36
36
|
}
|
package/src/attachment.ts
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
// so the tenant prefix is a construction, not a check somebody remembered to write.
|
|
6
6
|
|
|
7
7
|
import type { Clock } from '@ultimat3/core';
|
|
8
|
-
import { renderThrowable, systemClock } from '@ultimat3/core';
|
|
8
|
+
import { finiteCount, renderThrowable, systemClock } from '@ultimat3/core';
|
|
9
9
|
import type { ListPage, StorageDriver, StorageListEntry, StorageObject } from './driver';
|
|
10
|
-
import { orgMismatch, quarantined } from './errors';
|
|
10
|
+
import { notPending, orgMismatch, quarantined } from './errors';
|
|
11
11
|
import { isWithinOrg, orgPrefix, scopedKey } from './path';
|
|
12
12
|
|
|
13
13
|
/** The one segment an unattached upload lives under. `sweepOrphans` reads only this prefix. */
|
|
@@ -137,6 +137,9 @@ export interface PromoteAttachmentInput {
|
|
|
137
137
|
*/
|
|
138
138
|
export async function promoteAttachment(input: PromoteAttachmentInput): Promise<StorageObject> {
|
|
139
139
|
if (!isWithinOrg(input.key, input.orgId)) throw orgMismatch(input.key, input.orgId);
|
|
140
|
+
// Pending, not merely in-org: an attached key a client sent back belongs to another row, and the
|
|
141
|
+
// copy-then-delete below would move that row's file here and delete theirs.
|
|
142
|
+
if (!isPendingKey(input.key, input.orgId)) throw notPending(input.key, input.orgId);
|
|
140
143
|
if (isQuarantinedKey(input.key, input.orgId)) throw quarantined(input.key, input.orgId);
|
|
141
144
|
const name = input.key.slice(input.key.lastIndexOf('/') + 1);
|
|
142
145
|
const attached = attachmentKey(input.orgId, input.target, name);
|
|
@@ -187,7 +190,10 @@ export interface SweepResult {
|
|
|
187
190
|
*/
|
|
188
191
|
export async function sweepOrphans(input: SweepOrphansInput): Promise<SweepResult> {
|
|
189
192
|
const clock = input.clock ?? systemClock;
|
|
190
|
-
|
|
193
|
+
// Screened first: `now - NaN` is NaN and `lastModified > NaN` is false — the "old enough"
|
|
194
|
+
// answer — so `olderThanMs: NaN` deleted an upload created a moment ago.
|
|
195
|
+
const olderThanMs = finiteCount('sweepOrphans', 'olderThanMs', input.olderThanMs, 0);
|
|
196
|
+
const cutoff = clock.now().getTime() - olderThanMs;
|
|
191
197
|
const prefix = pendingPrefix(input.orgId);
|
|
192
198
|
const deleted: string[] = [];
|
|
193
199
|
const failed: SweepFailure[] = [];
|
package/src/driver-local.ts
CHANGED
|
@@ -190,8 +190,11 @@ export function localDriver(options: LocalDriverOptions): StorageDriver {
|
|
|
190
190
|
supplied === undefined || supplied === '' || supplied === DEV_SIGNING_SECRET
|
|
191
191
|
? undefined
|
|
192
192
|
: supplied;
|
|
193
|
-
|
|
194
|
-
|
|
193
|
+
// FAILS CLOSED: a process that names no environment resolves as `production` here, the answer
|
|
194
|
+
// core's `assertNoDevSecretsOutsideLocal` gives. `isLocal`'s own fallback is `development`, so the
|
|
195
|
+
// process that forgot to say signed with the published key — exactly the one that must not.
|
|
196
|
+
if (configured === undefined && !isLocal({ env, fallback: 'production' }))
|
|
197
|
+
throw signingSecretMissing(resolveEnvironment({ env, fallback: 'production' }));
|
|
195
198
|
const secret = configured ?? DEV_SIGNING_SECRET;
|
|
196
199
|
|
|
197
200
|
const filePath = (key: string): string => `${root}/${key}`;
|
package/src/errors.ts
CHANGED
|
@@ -20,6 +20,7 @@ export const STORAGE_OWNED_ERROR_CODES = [
|
|
|
20
20
|
'X_STORAGE_DELETE_FAILED',
|
|
21
21
|
'X_STORAGE_LIST_FAILED',
|
|
22
22
|
'X_STORAGE_QUARANTINED',
|
|
23
|
+
'X_STORAGE_NOT_PENDING',
|
|
23
24
|
] as const;
|
|
24
25
|
|
|
25
26
|
/**
|
|
@@ -55,6 +56,7 @@ export const STORAGE_ERROR_TITLES: Readonly<Record<StorageOwnedErrorCode, string
|
|
|
55
56
|
X_STORAGE_DELETE_FAILED: 'the object could not be deleted',
|
|
56
57
|
X_STORAGE_LIST_FAILED: 'the objects could not be listed',
|
|
57
58
|
X_STORAGE_QUARANTINED: 'the object is still in quarantine',
|
|
59
|
+
X_STORAGE_NOT_PENDING: 'the key is not a pending upload',
|
|
58
60
|
};
|
|
59
61
|
|
|
60
62
|
// One unconditional call, so a second package claiming one of storage's codes throws
|
|
@@ -295,6 +297,19 @@ export const quarantined = (key: string, orgId: string): StorageError =>
|
|
|
295
297
|
meta: { key, orgId },
|
|
296
298
|
});
|
|
297
299
|
|
|
300
|
+
/**
|
|
301
|
+
* A key inside the org and outside its `pending/` prefix — most often another row's attached key
|
|
302
|
+
* a client sent back. Promotion copies then deletes, so accepting it moved the victim's file onto
|
|
303
|
+
* this row and removed it from theirs.
|
|
304
|
+
*/
|
|
305
|
+
export const notPending = (key: string, orgId: string): StorageError =>
|
|
306
|
+
new StorageError({
|
|
307
|
+
code: 'X_STORAGE_NOT_PENDING',
|
|
308
|
+
cause: `"${key}" is not under org "${orgId}"'s pending/ prefix, so it is not an upload waiting for a row — it may already belong to one`,
|
|
309
|
+
fix: 'promote the key grantUpload returned with no target: promoteAttachment({ disk, key: pendingKey(orgId, name), orgId, target })',
|
|
310
|
+
meta: { key, orgId },
|
|
311
|
+
});
|
|
312
|
+
|
|
298
313
|
/** The key is well-formed and unforged, and still belongs to somebody else. */
|
|
299
314
|
export const orgMismatch = (key: string, orgId: string): StorageError =>
|
|
300
315
|
new StorageError({
|