@ultimat3/storage 26.1.1 → 27.1.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/README.md +5 -0
- package/package.json +2 -2
- package/src/errors.ts +17 -0
- package/src/index.ts +1 -0
- package/src/storage.ts +54 -0
package/README.md
CHANGED
|
@@ -11,6 +11,7 @@ defineStorage({
|
|
|
11
11
|
media: s3Driver({ bucket: 'media', endpoint: process.env.S3_ENDPOINT, forcePathStyle: true }),
|
|
12
12
|
},
|
|
13
13
|
default: 'uploads', // omit and the first disk wins
|
|
14
|
+
shared: { media: ['brand/'] }, // the only un-scoped keys /_storage and /media serve (27.0.0)
|
|
14
15
|
});
|
|
15
16
|
|
|
16
17
|
await disk('media').put(scopedKey(orgId, 'avatars', 'a.png'), bytes, { contentType: 'image/png' });
|
|
@@ -18,6 +19,10 @@ await disk('media').put(scopedKey(orgId, 'avatars', 'a.png'), bytes, { contentTy
|
|
|
18
19
|
|
|
19
20
|
Swapping `local` for `s3` in `app.config.ts` changes no call site. `x dev` needs no S3 server.
|
|
20
21
|
|
|
22
|
+
`shared` is per disk, whole segments ending in `/`, never inside `org/` (`X_CONFIG_INVALID` at
|
|
23
|
+
boot). A key outside every `org/<id>/` prefix and every shared prefix is refused by the served
|
|
24
|
+
read routes with `X_STORAGE_KEY_UNSHARED` (404); `storage.isShared(disk, key)` is the question.
|
|
25
|
+
|
|
21
26
|
## Drivers
|
|
22
27
|
|
|
23
28
|
| Driver | Backing | For | Signed URLs |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/storage",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "27.1.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",
|
|
@@ -32,6 +32,6 @@
|
|
|
32
32
|
"test": "bun test"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@ultimat3/core": "
|
|
35
|
+
"@ultimat3/core": "27.1.0"
|
|
36
36
|
}
|
|
37
37
|
}
|
package/src/errors.ts
CHANGED
|
@@ -30,6 +30,7 @@ export const STORAGE_OWNED_ERROR_CODES = [
|
|
|
30
30
|
'X_STORAGE_PUT_FAILED',
|
|
31
31
|
'X_STORAGE_READ_FAILED',
|
|
32
32
|
'X_STORAGE_OBJECT_LOCKED',
|
|
33
|
+
'X_STORAGE_KEY_UNSHARED',
|
|
33
34
|
] as const;
|
|
34
35
|
|
|
35
36
|
/**
|
|
@@ -70,6 +71,7 @@ export const STORAGE_ERROR_TITLES: Readonly<Record<StorageOwnedErrorCode, string
|
|
|
70
71
|
X_STORAGE_PUT_FAILED: 'the object could not be written',
|
|
71
72
|
X_STORAGE_READ_FAILED: 'the object could not be read',
|
|
72
73
|
X_STORAGE_OBJECT_LOCKED: 'the object is under retention or a legal hold',
|
|
74
|
+
X_STORAGE_KEY_UNSHARED: 'object key is outside every tenant and every shared prefix',
|
|
73
75
|
};
|
|
74
76
|
|
|
75
77
|
// One unconditional call, so a second package claiming one of storage's codes throws
|
|
@@ -429,6 +431,21 @@ export const orgMismatch = (key: string, orgId: string): StorageError =>
|
|
|
429
431
|
meta: { key, orgId },
|
|
430
432
|
});
|
|
431
433
|
|
|
434
|
+
/**
|
|
435
|
+
* A served read of a key no tenant owns and no `defineStorage({ shared })` prefix covers. 404 for
|
|
436
|
+
* the reason `orgMismatch` is: the refusal is decided from the key alone, before any disk is read,
|
|
437
|
+
* so it says nothing about whether an object is there.
|
|
438
|
+
*/
|
|
439
|
+
export const keyUnshared = (disk: string, key: string): StorageError =>
|
|
440
|
+
new StorageError({
|
|
441
|
+
code: 'X_STORAGE_KEY_UNSHARED',
|
|
442
|
+
cause: `key "${key}" on disk "${disk}" is outside every org/<id>/ prefix and every prefix defineStorage({ shared }) lists for "${disk}"`,
|
|
443
|
+
// Never derived from the refused key: its own top segment is often the very data the refusal
|
|
444
|
+
// protects (`db-export/`), and a fix line is pasted as written.
|
|
445
|
+
fix: `scopedKey(orgId, ...parts) # a tenant's object; for one EVERY reader may have, list a prefix you choose: defineStorage({ disks, shared: { ${disk}: ['brand/'] } })`,
|
|
446
|
+
meta: { disk, key },
|
|
447
|
+
});
|
|
448
|
+
|
|
432
449
|
/** The client half: the disk answered the presigned PUT with something other than 2xx. */
|
|
433
450
|
export const uploadFailed = (path: string, status: number, detail: string): StorageError =>
|
|
434
451
|
new StorageError({
|
package/src/index.ts
CHANGED
package/src/storage.ts
CHANGED
|
@@ -7,17 +7,66 @@
|
|
|
7
7
|
import { ConfigInvalidError } from '@ultimat3/core';
|
|
8
8
|
import type { StorageDriver } from './driver';
|
|
9
9
|
import { diskUnknown } from './errors';
|
|
10
|
+
import { isSafeKey, isTenantScoped } from './path';
|
|
10
11
|
|
|
11
12
|
export interface StorageConfig {
|
|
12
13
|
readonly disks: Readonly<Record<string, StorageDriver>>;
|
|
13
14
|
/** Disk used when `disk()` is called with no name. Defaults to the first declared disk. */
|
|
14
15
|
readonly default?: string | undefined;
|
|
16
|
+
/**
|
|
17
|
+
* Key prefixes, per disk, that hold objects NO tenant owns and every reader may have — a brand
|
|
18
|
+
* logo, a shared asset. The only way a key outside `org/<id>/` is served by `/_storage` and
|
|
19
|
+
* `/media` (still to a signed-in actor holding `storage:read`); everything else outside a
|
|
20
|
+
* tenant prefix — exports, raw payloads, DSR archives — is refused `X_STORAGE_KEY_UNSHARED`.
|
|
21
|
+
* Each prefix ends in `/` and names a whole segment: `{ uploads: ['brand/'] }`.
|
|
22
|
+
*/
|
|
23
|
+
readonly shared?: Readonly<Record<string, readonly string[]>> | undefined;
|
|
15
24
|
}
|
|
16
25
|
|
|
17
26
|
export interface Storage {
|
|
18
27
|
readonly defaultDisk: string;
|
|
19
28
|
readonly diskNames: readonly string[];
|
|
20
29
|
disk(name?: string): StorageDriver;
|
|
30
|
+
/**
|
|
31
|
+
* Whether `key` on `disk` lies under a prefix `StorageConfig.shared` declared for that disk. A
|
|
32
|
+
* tenant-scoped key is never shared, and an unknown disk shares nothing.
|
|
33
|
+
*/
|
|
34
|
+
isShared(disk: string, key: string): boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The declared prefixes, refused at boot when one could never mean what it says: a disk that is
|
|
39
|
+
* not declared, a prefix that is not a whole segment, one that would share the whole disk, or one
|
|
40
|
+
* inside the tenant namespace — sharing `org/` would undo the boundary the prefix exists beside.
|
|
41
|
+
*/
|
|
42
|
+
function sharedPrefixes(
|
|
43
|
+
shared: StorageConfig['shared'],
|
|
44
|
+
names: readonly string[],
|
|
45
|
+
): ReadonlyMap<string, readonly string[]> {
|
|
46
|
+
const out = new Map<string, readonly string[]>();
|
|
47
|
+
for (const [diskName, prefixes] of Object.entries(shared ?? {})) {
|
|
48
|
+
if (!names.includes(diskName)) {
|
|
49
|
+
throw new ConfigInvalidError({
|
|
50
|
+
cause: `storage.shared names disk "${diskName}" but the configured disks are: ${names.join(', ')}`,
|
|
51
|
+
fix: `defineStorage({ disks, shared: { ${names[0] ?? '<disk>'}: ['<prefix>/'] } }) # key it by one of: ${names.join(', ')}`,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
for (const prefix of prefixes) {
|
|
55
|
+
const shareable =
|
|
56
|
+
typeof prefix === 'string' &&
|
|
57
|
+
prefix.endsWith('/') &&
|
|
58
|
+
isSafeKey(prefix.slice(0, -1)) &&
|
|
59
|
+
!isTenantScoped(prefix);
|
|
60
|
+
if (!shareable) {
|
|
61
|
+
throw new ConfigInvalidError({
|
|
62
|
+
cause: `storage.shared.${diskName} lists ${JSON.stringify(prefix)}, which is not a shareable prefix: it must be a relative key ending in "/" and outside "org/"`,
|
|
63
|
+
fix: `defineStorage({ disks, shared: { ${diskName}: ['brand/'] } }) # whole segments ending in "/", outside org/ — a tenant's object is read by its own org, never shared`,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
out.set(diskName, Object.freeze([...prefixes]));
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
21
70
|
}
|
|
22
71
|
|
|
23
72
|
let current: Storage | undefined;
|
|
@@ -63,6 +112,7 @@ export function defineStorage(config: StorageConfig): Storage {
|
|
|
63
112
|
registered.set(driver, diskName);
|
|
64
113
|
driver.registerAs?.(diskName);
|
|
65
114
|
}
|
|
115
|
+
const shared = sharedPrefixes(config.shared, names);
|
|
66
116
|
const storageInstance: Storage = {
|
|
67
117
|
defaultDisk,
|
|
68
118
|
diskNames: Object.freeze([...names]),
|
|
@@ -72,6 +122,10 @@ export function defineStorage(config: StorageConfig): Storage {
|
|
|
72
122
|
if (driver === undefined) throw diskUnknown(wanted, names);
|
|
73
123
|
return driver;
|
|
74
124
|
},
|
|
125
|
+
isShared(diskName: string, key: string): boolean {
|
|
126
|
+
if (!isSafeKey(key) || isTenantScoped(key)) return false;
|
|
127
|
+
return (shared.get(diskName) ?? []).some((prefix) => key.startsWith(prefix));
|
|
128
|
+
},
|
|
75
129
|
};
|
|
76
130
|
current = storageInstance;
|
|
77
131
|
return storageInstance;
|