@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 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": "26.1.1",
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": "26.1.1"
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
@@ -77,6 +77,7 @@ export {
77
77
  getTooLarge,
78
78
  isStorageError,
79
79
  keyConflict,
80
+ keyUnshared,
80
81
  listFailed,
81
82
  objectNotFound,
82
83
  orgMismatch,
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;