@ultimat3/cli 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "26.1.1",
3
+ "version": "27.1.0",
4
4
  "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -39,33 +39,33 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@babel/core": "^8.0.1",
42
- "@ultimat3/action": "26.1.1",
43
- "@ultimat3/admin": "26.1.1",
44
- "@ultimat3/ai": "26.1.1",
45
- "@ultimat3/auth": "26.1.1",
46
- "@ultimat3/cache": "26.1.1",
47
- "@ultimat3/core": "26.1.1",
48
- "@ultimat3/db": "26.1.1",
49
- "@ultimat3/entity": "26.1.1",
50
- "@ultimat3/flags": "26.1.1",
51
- "@ultimat3/http": "26.1.1",
52
- "@ultimat3/i18n": "26.1.1",
53
- "@ultimat3/jobs": "26.1.1",
54
- "@ultimat3/mail": "26.1.1",
55
- "@ultimat3/manifest": "26.1.1",
56
- "@ultimat3/mcp": "26.1.1",
57
- "@ultimat3/money": "26.1.1",
58
- "@ultimat3/notify": "26.1.1",
59
- "@ultimat3/policy": "26.1.1",
60
- "@ultimat3/pwa": "26.1.1",
61
- "@ultimat3/query": "26.1.1",
62
- "@ultimat3/realtime": "26.1.1",
63
- "@ultimat3/render": "26.1.1",
64
- "@ultimat3/schema": "26.1.1",
65
- "@ultimat3/seo": "26.1.1",
66
- "@ultimat3/storage": "26.1.1",
67
- "@ultimat3/testing": "26.1.1",
68
- "@ultimat3/time": "26.1.1",
42
+ "@ultimat3/action": "27.1.0",
43
+ "@ultimat3/admin": "27.1.0",
44
+ "@ultimat3/ai": "27.1.0",
45
+ "@ultimat3/auth": "27.1.0",
46
+ "@ultimat3/cache": "27.1.0",
47
+ "@ultimat3/core": "27.1.0",
48
+ "@ultimat3/db": "27.1.0",
49
+ "@ultimat3/entity": "27.1.0",
50
+ "@ultimat3/flags": "27.1.0",
51
+ "@ultimat3/http": "27.1.0",
52
+ "@ultimat3/i18n": "27.1.0",
53
+ "@ultimat3/jobs": "27.1.0",
54
+ "@ultimat3/mail": "27.1.0",
55
+ "@ultimat3/manifest": "27.1.0",
56
+ "@ultimat3/mcp": "27.1.0",
57
+ "@ultimat3/money": "27.1.0",
58
+ "@ultimat3/notify": "27.1.0",
59
+ "@ultimat3/policy": "27.1.0",
60
+ "@ultimat3/pwa": "27.1.0",
61
+ "@ultimat3/query": "27.1.0",
62
+ "@ultimat3/realtime": "27.1.0",
63
+ "@ultimat3/render": "27.1.0",
64
+ "@ultimat3/schema": "27.1.0",
65
+ "@ultimat3/seo": "27.1.0",
66
+ "@ultimat3/storage": "27.1.0",
67
+ "@ultimat3/testing": "27.1.0",
68
+ "@ultimat3/time": "27.1.0",
69
69
  "babel-preset-solid": "^1.9.15"
70
70
  }
71
71
  }
package/src/app-load.ts CHANGED
@@ -419,8 +419,13 @@ function reloadRoute(
419
419
 
420
420
  const countSuspense = (source: string): number => source.match(/<Suspense[\s/>]/g)?.length ?? 0;
421
421
 
422
- /** `packages/db/src/errors.ts` → `packages/db`; `apps/web/app/posts/errors.ts` → `apps/web`. */
423
- const workspaceOf = (file: string): string => file.split('/').slice(0, 2).join('/');
422
+ /**
423
+ * `packages/db/src/errors.ts` → `packages/db`; `apps/web/app/posts/errors.ts` → `apps/web` — on
424
+ * either separator. A manifest written on Windows split a `\`-path on `/`, kept the whole file as
425
+ * the "package" (`apps\\admin\\app\\…\\abuse.tsx`) and sorted the codes by it, so the same tree
426
+ * regenerated thousands of changed lines on the next Linux machine and `--check` meant nothing.
427
+ */
428
+ export const workspaceOf = (file: string): string => toPosix(file).split('/').slice(0, 2).join('/');
424
429
 
425
430
  /**
426
431
  * The app's `X_*` codes, from the same walk and the same scanner the `errors` gate step uses.
@@ -16,7 +16,6 @@ import {
16
16
  roleDefinitions,
17
17
  } from '@ultimat3/policy';
18
18
  import { routeEntries } from '@ultimat3/render';
19
- import { definedStorage } from '@ultimat3/storage';
20
19
  import { loadApp } from './app-load';
21
20
  import { borrowedFinding, borrowedPermissions, permissionsFile } from './app-permissions-borrowed';
22
21
  import { siteFile } from './app-permissions-site';
@@ -25,7 +24,6 @@ import { duplicateFinding, findDuplicateInstalls } from './duplicate-packages';
25
24
  import type { Finding } from './output';
26
25
  import { findingFrom } from './output';
27
26
  import { ungrantedFinding, ungrantedRequirements } from './permission-grants';
28
- import { STORAGE_READ_PERMISSION } from './runtime-storage';
29
27
 
30
28
  /** One place a permission is named by a string the framework never checks. */
31
29
  export interface PermissionReference {
@@ -37,8 +35,6 @@ export interface PermissionReference {
37
35
  readonly grant?: true;
38
36
  /** App-root-relative POSIX path of the declaration, or `undefined` when it is not derivable. */
39
37
  readonly at: string | undefined;
40
- /** Who requires it when no app file does — a route the framework mounts. */
41
- readonly requiredBy?: string;
42
38
  }
43
39
 
44
40
  /** What this check needs of a boot — the seam `i18n-registration.ts` already established. */
@@ -76,31 +72,10 @@ export function roleMapFile(root: string): string | undefined {
76
72
  * and the running server cannot be looking at two different sets.
77
73
  */
78
74
  export function requiredReferences(): readonly PermissionReference[] {
79
- const routes = routeEntries().flatMap((entry) => {
75
+ return routeEntries().flatMap((entry) => {
80
76
  const permission = entry.config.policy?.permission;
81
77
  return permission === undefined ? [] : [{ permission, at: entry.file }];
82
78
  });
83
- return [...routes, ...storageReferences()];
84
- }
85
-
86
- /**
87
- * The framework's own `GET /_storage/:disk/*key` and `/media/*key` require `storage:read`
88
- * (`runtime-storage.ts`), and an app whose permission set lacks it got `500 X_PERMISSION_UNKNOWN`
89
- * on the first signed URL it minted — at runtime, from a route it never wrote (#524). Asked only
90
- * of an app that declared its own disks (`defineStorage` in an app module, which is what those
91
- * routes then serve): one that stores nothing mints no URL, and requiring a permission it has no
92
- * use for would be a finding with no reader.
93
- */
94
- function storageReferences(): readonly PermissionReference[] {
95
- return definedStorage() === undefined
96
- ? []
97
- : [
98
- {
99
- permission: STORAGE_READ_PERMISSION,
100
- at: undefined,
101
- requiredBy: 'the mounted GET /_storage/:disk/*key and /media/*key routes',
102
- },
103
- ];
104
79
  }
105
80
 
106
81
  /**
@@ -147,11 +122,7 @@ export function permissionFindings(root: string): readonly Finding[] {
147
122
  );
148
123
  const known = knownPermissions();
149
124
  return references.map((reference) => {
150
- const base = findingFrom(permissionUnknown(reference.permission, known));
151
- const finding =
152
- reference.requiredBy === undefined
153
- ? base
154
- : { ...base, cause: `${base.cause} — required by ${reference.requiredBy}` };
125
+ const finding = findingFrom(permissionUnknown(reference.permission, known));
155
126
  return reference.at === undefined ? finding : { ...finding, at: reference.at };
156
127
  });
157
128
  }
@@ -24,7 +24,7 @@
24
24
  import { resolve } from 'node:path';
25
25
  import { registeredStylesheets } from '@ultimat3/render/server';
26
26
  import { resolvedImports } from './module-imports';
27
- import { hasPathSegment } from './path-segments';
27
+ import { hasPathSegment, isPathUnder } from './path-segments';
28
28
 
29
29
  type Kind = 'script' | 'style' | 'data';
30
30
 
@@ -106,7 +106,7 @@ const hashOf = (text: string | null): bigint | null =>
106
106
  /** The in-app files `source` imports — a package under `node_modules` is not the app's to reload. */
107
107
  const importsOf = (path: string, source: string, root: string): string[] =>
108
108
  resolvedImports(path, source, 'referenced').filter(
109
- (target) => target.startsWith(`${root}/`) && !hasPathSegment(target, 'node_modules'),
109
+ (target) => isPathUnder(root, target) && !hasPathSegment(target, 'node_modules'),
110
110
  );
111
111
 
112
112
  function link(from: string, to: string): void {
@@ -156,7 +156,7 @@ export async function trackStylesheets(root: string): Promise<void> {
156
156
  if (!tracking) return;
157
157
  const base = resolve(root);
158
158
  const inApp = (path: string): boolean =>
159
- path.startsWith(`${base}/`) && !hasPathSegment(path, 'node_modules');
159
+ isPathUnder(base, path) && !hasPathSegment(path, 'node_modules');
160
160
  for (const sheet of registeredStylesheets()) {
161
161
  if (!inApp(sheet.file)) continue;
162
162
  if (!nodes.has(sheet.file)) await track(sheet.file, await readText(sheet.file), base);
package/src/budgets.ts CHANGED
@@ -86,7 +86,9 @@ const jsBudgetOf = (route: RouteFact): number | null => parseByteBudget(route.bu
86
86
  export type RouteFileOf = (url: string) => string | undefined;
87
87
 
88
88
  const declaredFileOf: RouteFileOf = (url) =>
89
- describePages().find((route) => route.path === url && route.mount === undefined)?.file;
89
+ describePages().find(
90
+ (route) => route.path === url && (route.mount === undefined || route.mount.claimed === true),
91
+ )?.file;
90
92
 
91
93
  const KB = 1024;
92
94
 
@@ -323,7 +323,14 @@ async function readSliceErrors(
323
323
  }
324
324
 
325
325
  /** The generators whose output depends on what the slice's `entity.ts` / `repo.ts` declare. */
326
- const READS_SLICE: ReadonlySet<Generator> = new Set(['job', 'task', 'action', 'mutator', 'query']);
326
+ const READS_SLICE: ReadonlySet<Generator> = new Set([
327
+ 'job',
328
+ 'task',
329
+ 'action',
330
+ 'mutator',
331
+ 'query',
332
+ 'backfill',
333
+ ]);
327
334
 
328
335
  /**
329
336
  * The slice's `entity.ts`/`repo.ts` as they stand on disk, absent when the generator's kind reads
@@ -16,7 +16,7 @@ import { join } from 'node:path';
16
16
  import { finiteCount, logger } from '@ultimat3/core';
17
17
  import type { DevIgnore } from './dev-watch';
18
18
  import { devIgnore } from './dev-watch';
19
- import { pathSegments } from './path-segments';
19
+ import { isPathUnder, pathSegments } from './path-segments';
20
20
 
21
21
  /** What `node:fs`'s watcher hands a listener — `filename` is optional in fact, not only in type. */
22
22
  export type WatchListener = (event: string, filename: string | Buffer | null | undefined) => void;
@@ -213,7 +213,9 @@ class WatchDirectoryMap {
213
213
 
214
214
  remove(directory: string): void {
215
215
  for (const held of this.#watchers.keys()) {
216
- if (held !== directory && !held.startsWith(`${directory}/`)) continue;
216
+ // Either separator: on Windows a held directory is `C:\app\apps\web`, and a `/` test
217
+ // closed none of the watchers below a removed directory.
218
+ if (held !== directory && !isPathUnder(directory, held)) continue;
217
219
  this.#watchers.get(held)?.close();
218
220
  this.#watchers.delete(held);
219
221
  }
@@ -231,6 +231,7 @@ export const CLI_OWNED_ERROR_CODES = [
231
231
  'X_DEV_STATE_UNWRITABLE',
232
232
  'X_PACKAGE_TEST_ONLY_SHIPPED',
233
233
  'X_ISLAND_MODULE_DUPLICATED',
234
+ 'X_BACKFILL_SLICE_ENTITY',
234
235
  ] as const;
235
236
 
236
237
  /**
@@ -418,6 +419,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
418
419
  X_DEV_STATE_UNWRITABLE: 'x dev cannot claim its state directory',
419
420
  X_PACKAGE_TEST_ONLY_SHIPPED: 'a test-only module ships in the tarball',
420
421
  X_ISLAND_MODULE_DUPLICATED: 'An island chunk bundles one module more than once',
422
+ X_BACKFILL_SLICE_ENTITY: 'the slice holds no entity a generated backfill can sweep',
421
423
  };
422
424
 
423
425
  // One unconditional call, so a second package claiming one of the CLI's codes throws
@@ -61,7 +61,7 @@ export interface GenerateOptions {
61
61
  */
62
62
  readonly sliceErrors?: string;
63
63
  /**
64
- * `action`, `mutator`, `query`, `job` and `task`: the slice's `entity.ts` as it stands on disk, absent when
64
+ * `action`, `mutator`, `query`, `job`, `task` and `backfill`: the slice's `entity.ts` as it stands on disk, absent when
65
65
  * the feature has no entity yet — and then none is written. Supplied by `run` for `sliceErrors`'s reason — whether the feature is
66
66
  * tenant-scoped is a fact about THIS app, and a template that assumed `tenant: 'orgId'` wrote
67
67
  * `repo.byId`/`repo.list` calls into a feature whose entity names no tenant column. Read at
@@ -166,6 +166,8 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
166
166
  backfillFiles(options.name, {
167
167
  ...target,
168
168
  ...(options.locales === undefined ? {} : { locales: options.locales }),
169
+ ...(options.sliceEntity === undefined ? {} : { sliceEntity: options.sliceEntity }),
170
+ ...(options.sliceRepo === undefined ? {} : { sliceRepo: options.sliceRepo }),
169
171
  }),
170
172
  );
171
173
  case 'entity':
package/src/index.ts CHANGED
@@ -137,6 +137,10 @@ export { startQueue } from './runtime-queue';
137
137
  export { appRoutes, routeDocument } from './runtime-render';
138
138
  export type { RunningServices } from './runtime-services';
139
139
  export { startServices } from './runtime-services';
140
+ // The `/_storage` pair `x dev` and `runRole` mount, for an app's contract test of the real route
141
+ // over HTTP — the way `assetRoutes` already serves `/media` to one.
142
+ export type { StorageRoutesOptions } from './runtime-storage';
143
+ export { STORAGE_READ_PERMISSION, servedStorage, storageRoutes } from './runtime-storage';
140
144
  // The drift a hash cannot see, and the composition both the gate step and `x doctor` read.
141
145
  export { checkMigrationDrift, checkSnapshotDrift } from './schema-drift';
142
146
  export { checkSchemaDump } from './schema-dump-drift';
package/src/mcp-errors.ts CHANGED
@@ -300,6 +300,8 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
300
300
  'bun run x -- verify --only package-shape --json # then rename each listed module to <name>-fixture.ts and update its importers',
301
301
  X_ISLAND_MODULE_DUPLICATED:
302
302
  'gh issue create --repo developerz-ai/ultimate --title "X_ISLAND_MODULE_DUPLICATED" # paste this error\'s --json as the body: the framework bundled one of its own modules twice',
303
+ X_BACKFILL_SLICE_ENTITY:
304
+ 'x errors explain X_BACKFILL_SLICE_ENTITY --json # the refusal itself names the x g job line for this slice',
303
305
  };
304
306
 
305
307
  const isCliCode = (code: string): code is CliErrorCode =>
@@ -12,3 +12,13 @@ export function pathSegments(path: string): readonly string[] {
12
12
  export function hasPathSegment(path: string, segment: string): boolean {
13
13
  return pathSegments(path).includes(segment);
14
14
  }
15
+
16
+ /**
17
+ * Whether `path` lies strictly below `root`, on either separator. Bun resolves paths with a
18
+ * backslash on Windows, so `path.startsWith(`${root}/`)` was false for every file there: the dev
19
+ * reload graph recorded no import and no sheet as the app's own, and a save rendered stale code.
20
+ */
21
+ export function isPathUnder(root: string, path: string): boolean {
22
+ const base = root.replaceAll('\\', '/').replace(/\/+$/, '');
23
+ return path.replaceAll('\\', '/').startsWith(`${base}/`);
24
+ }
@@ -7,7 +7,7 @@ import type { AdminApp, AdminRouteConfig } from '@ultimat3/admin';
7
7
  import type { Route, RouteMeta } from '@ultimat3/http';
8
8
  import { asCtx, html, redirect, routeNotFound } from '@ultimat3/http';
9
9
  import type { RouteEntry } from '@ultimat3/render';
10
- import { compilePattern } from '@ultimat3/render';
10
+ import { compilePattern, routeFor } from '@ultimat3/render';
11
11
  import { renderSsr } from '@ultimat3/render/server';
12
12
  import type { DocumentOptions } from './document-options';
13
13
  import { routeDocument } from './runtime-render';
@@ -120,6 +120,9 @@ const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
120
120
 
121
121
  const METHODS: readonly ('GET' | 'POST')[] = ['GET', 'POST'];
122
122
 
123
+ /** A file route on a mounted path exists only by a claim the mount accepted (`registerRoute`). */
124
+ const claimed = (path: string): boolean => routeFor(path) !== undefined;
125
+
123
126
  /**
124
127
  * Two paths per admin and two methods on each: the base path itself (the dashboard) and one
125
128
  * catch-all under it. The admin's own table is matched INSIDE the handler, against the table as
@@ -131,7 +134,11 @@ const METHODS: readonly ('GET' | 'POST')[] = ['GET', 'POST'];
131
134
  export function adminMountRoutes(options: AdminMountOptions): readonly Route[] {
132
135
  return [...declared().keys()].flatMap((base) =>
133
136
  [base, `${base}/*rest`].flatMap((path) =>
134
- METHODS.map(
137
+ // The dashboard's GET, when an app file claimed it (`claimAdminRoute`): the file's route
138
+ // serves it, and two GETs on one path is X_ROUTE_CONFLICT. A claimed path UNDER the base
139
+ // needs nothing here — a static or param segment outranks the catch-all — and every POST
140
+ // stays the admin's. Only a claim the admin issued can put a file on a mounted path.
141
+ METHODS.filter((method) => !(method === 'GET' && path === base && claimed(base))).map(
135
142
  (method): Route => ({
136
143
  method,
137
144
  path,
@@ -179,7 +179,7 @@ async function mediaResponse(
179
179
  ): Promise<Response> {
180
180
  const requested = request.params['key'] ?? '';
181
181
  authorizeStorageRead({ disk: storage.defaultDisk, key: requested }, ctx);
182
- const key = assertReadableKey(requested, ctx.actor);
182
+ const key = assertReadableKey(storage, storage.defaultDisk, requested, ctx.actor);
183
183
  const query = parseImageQuery(request.url.searchParams);
184
184
  if (query !== null) return transformedVariant(storage, key, query, images);
185
185
  // No transform asked for: the object itself, still under the storage key's own safety checks.
@@ -12,14 +12,22 @@ import { actorOf } from '@ultimat3/core';
12
12
  import type { CacheHint, RequestContext, Route, UltimateRequest } from '@ultimat3/http';
13
13
  import { asCtx, unauthenticated } from '@ultimat3/http';
14
14
  import type { KnownPermission } from '@ultimat3/policy';
15
- import { can, codeOf, evaluate, policyForbidden, reasonOf } from '@ultimat3/policy';
16
- import type { Storage, StorageRead } from '@ultimat3/storage';
15
+ import {
16
+ can,
17
+ codeOf,
18
+ evaluate,
19
+ isKnownPermission,
20
+ policyForbidden,
21
+ reasonOf,
22
+ } from '@ultimat3/policy';
23
+ import type { Storage, StorageObject } from '@ultimat3/storage';
17
24
  import {
18
25
  assertSafeKey,
19
26
  DEFAULT_SIGNED_URL_BASE,
20
27
  definedStorage,
21
28
  isTenantScoped,
22
29
  isWithinOrg,
30
+ keyUnshared,
23
31
  objectNotFound,
24
32
  orgMismatch,
25
33
  } from '@ultimat3/storage';
@@ -30,8 +38,8 @@ import { storedObjectHeaders } from './stored-object-headers';
30
38
  * The one capability that gates reading a stored object, on every disk. A permission and not a
31
39
  * per-disk family: `disk` is in the policy's `input`, so an app that wants a per-disk rule writes
32
40
  * one predicate over it, while a second permission string would be a second thing to grant and to
33
- * forget. An app that declared a permission set without it gets `X_PERMISSION_UNKNOWN` from
34
- * `can()` — naming the exact `definePermissions` edit — rather than a request that quietly worked.
41
+ * forget. An app that declared a permission set without it serves nothing here: every read is a
42
+ * clean 401/403 (27.0.0; it was a 500 `X_PERMISSION_UNKNOWN`).
35
43
  */
36
44
  export const STORAGE_READ_PERMISSION = 'storage:read';
37
45
 
@@ -76,10 +84,22 @@ export interface StorageReadInput {
76
84
  */
77
85
  export function authorizeStorageRead(input: StorageReadInput, ctx: RequestContext): void {
78
86
  const context = asCtx(ctx);
87
+ const actor = actorOf(context);
88
+ // An app that never declared `storage:read` serves nothing through this route: nobody can hold
89
+ // an undeclared permission, so it answers what a role without the grant gets — 401 for nobody,
90
+ // 403 for a known actor. `can()` would throw `X_PERMISSION_UNKNOWN` (500) here, and an app kept
91
+ // the permission declared-but-ungranted only to keep its own refusal a refusal.
92
+ if (!isKnownPermission(STORAGE_READ_PERMISSION)) {
93
+ if (actor === null) throw unauthenticated(ctx.url.pathname);
94
+ throw policyForbidden(
95
+ STORAGE_READ_PERMISSION,
96
+ `this app does not declare ${STORAGE_READ_PERMISSION}, so no role grants it`,
97
+ );
98
+ }
79
99
  // Built per request, not at mount: an app whose permission set lacks `storage:read` must get a
80
100
  // problem document on this route, not a boot that takes every other route down with it.
81
101
  const policy = can<StorageReadInput>(READ_PERMISSION);
82
- const evaluation = evaluate(policy, { input, actor: actorOf(context) });
102
+ const evaluation = evaluate(policy, { input, actor });
83
103
  if (evaluation.allowed) return;
84
104
  // The decision's own code goes on the wire, never a flattened one: `can()` denies "nobody" with
85
105
  // X_UNAUTHENTICATED and a known actor with X_FORBIDDEN, and 401 and 403 are different
@@ -93,26 +113,50 @@ export function authorizeStorageRead(input: StorageReadInput, ctx: RequestContex
93
113
 
94
114
  /**
95
115
  * The key half of the read decision, in the order that discloses least: a key that could escape its
96
- * prefix is refused before any tenant is named, and a key inside another tenant's prefix is 404
97
- * (never 403 — `error-map.ts` maps `X_STORAGE_ORG_MISMATCH` there so a refusal cannot confirm that
98
- * a key exists).
116
+ * prefix is refused before any tenant is named; a key inside another tenant's prefix is 404 (never
117
+ * 403 — `error-map.ts` maps `X_STORAGE_ORG_MISMATCH` there so a refusal cannot confirm that a key
118
+ * exists); and a key inside NO tenant's prefix is served only when the app shared it.
119
+ *
120
+ * That last clause is secure by default (27.0.0). The rule used to be "not another tenant's", which
121
+ * read every key outside `org/` as everybody's: an app's all-tenant export, raw webhook payloads
122
+ * and DSR archives were one GET away for any signed-in reader holding `storage:read`. A key no
123
+ * tenant owns is now `X_STORAGE_KEY_UNSHARED` (404) unless `defineStorage({ shared })` lists its
124
+ * prefix for this disk — decided from the key alone, so it too says nothing about existence.
99
125
  *
100
126
  * Split out of `readStorageObject` because `/media/*key` (`runtime-assets.ts`) has to make the same
101
127
  * decision and made none at all: it passed a client-supplied key straight to `disk().get`, so every
102
128
  * object on the app's only disk was one unauthenticated URL away. A second copy of this test is how
103
129
  * one of the two surfaces would drift back — `storage-surfaces.test.ts` is what holds them level.
104
130
  */
105
- export function assertReadableKey(key: string, actor: Actor): string {
131
+ export function assertReadableKey(
132
+ storage: Storage,
133
+ disk: string,
134
+ key: string,
135
+ actor: Actor,
136
+ ): string {
106
137
  const safe = assertSafeKey(key);
107
138
  // An actor with no org is inside no org, so every tenant-scoped key is somebody else's —
108
139
  // `isWithinOrg` answers exactly that for an empty org, without throwing.
109
140
  const orgId = actor.orgId ?? '';
110
- if (isTenantScoped(safe) && !isWithinOrg(safe, orgId)) {
111
- throw orgMismatch(safe, orgId);
141
+ if (isTenantScoped(safe)) {
142
+ if (!isWithinOrg(safe, orgId)) throw orgMismatch(safe, orgId);
143
+ return safe;
112
144
  }
145
+ if (!storage.isShared(disk, safe)) throw keyUnshared(disk, safe);
113
146
  return safe;
114
147
  }
115
148
 
149
+ /**
150
+ * What the route serves: the object's description and a STREAM of its bytes, never a buffer. The
151
+ * route used `get()`, which holds the whole object in memory and refuses past the disk's
152
+ * `maxGetBytes` — so a large upload was either a pod's worth of heap or `X_STORAGE_TOO_LARGE`.
153
+ */
154
+ export interface ServedObject {
155
+ readonly object: StorageObject;
156
+ /** Opened only once the route knows it will send a body: a 304 and a 416 open nothing. */
157
+ readonly open: () => Promise<ReadableStream<Uint8Array>>;
158
+ }
159
+
116
160
  /**
117
161
  * Everything between the decision and the bytes. An unknown disk is the same 404 the foreign-tenant
118
162
  * case answers, rather than `X_STORAGE_DISK_UNKNOWN`, whose cause lists every configured disk name.
@@ -121,10 +165,13 @@ export async function readStorageObject(
121
165
  storage: Storage,
122
166
  input: StorageReadInput,
123
167
  actor: Actor,
124
- ): Promise<StorageRead> {
125
- const key = assertReadableKey(input.key, actor);
168
+ ): Promise<ServedObject> {
169
+ const key = assertReadableKey(storage, input.disk, input.key, actor);
126
170
  if (!storage.diskNames.includes(input.disk)) throw objectNotFound(input.disk, key);
127
- return storage.disk(input.disk).get(key);
171
+ const driver = storage.disk(input.disk);
172
+ const object = await driver.stat(key);
173
+ if (object === undefined) throw objectNotFound(input.disk, key);
174
+ return { object, open: () => driver.stream(key) };
128
175
  }
129
176
 
130
177
  /** Inclusive, as `Range` and `Content-Range` both are. */
@@ -174,24 +221,53 @@ export function etagMatches(header: string | null, etag: string): boolean {
174
221
  return header.split(',').some((candidate) => candidate.trim().replace(/^W\//, '') === etag);
175
222
  }
176
223
 
224
+ /**
225
+ * The inclusive window `[start, end]` of a stream, as a stream. A disk streams from byte 0 (the
226
+ * driver contract has no ranged read), so the bytes before `start` are read and dropped: bounded
227
+ * memory, at the cost of reading the prefix — the trade a `Range` probe on a video wants over
228
+ * buffering the whole object.
229
+ */
230
+ function windowOf(
231
+ source: ReadableStream<Uint8Array>,
232
+ start: number,
233
+ end: number,
234
+ ): ReadableStream<Uint8Array> {
235
+ let offset = 0;
236
+ return source.pipeThrough(
237
+ new TransformStream<Uint8Array, Uint8Array>({
238
+ transform(chunk, controller) {
239
+ const from = Math.max(start - offset, 0);
240
+ const to = Math.min(end + 1 - offset, chunk.byteLength);
241
+ offset += chunk.byteLength;
242
+ if (to > from) controller.enqueue(chunk.subarray(from, to));
243
+ if (offset > end) controller.terminate();
244
+ },
245
+ }),
246
+ );
247
+ }
248
+
177
249
  /**
178
250
  * The content type is the STORED one — the upload gate sniffed those bytes and `put` recorded the
179
251
  * answer, so an extension in the URL is the only party that can lie. The validator is the driver's
180
252
  * own content hash (local: sha256 of the bytes, S3: the provider's etag), which is the identity
181
253
  * storage already keeps for an object; inventing a cache key here would be a second one.
182
254
  */
183
- export function storageResponse(request: UltimateRequest, read: StorageRead): Response {
184
- const etag = `"${read.object.etag}"`;
255
+ export async function storageResponse(
256
+ request: UltimateRequest,
257
+ served: ServedObject,
258
+ ): Promise<Response> {
259
+ const { object } = served;
260
+ const etag = `"${object.etag}"`;
185
261
  const headers = new Headers({
186
- 'content-type': read.object.contentType,
262
+ 'content-type': object.contentType,
187
263
  etag,
188
264
  'accept-ranges': 'bytes',
189
265
  // A type a browser would RUN (html, svg, xml) is a download, never a page on this origin.
190
- ...storedObjectHeaders(read.object.contentType),
266
+ ...storedObjectHeaders(object.contentType),
191
267
  });
192
268
  // Omitted, never invented: a disk whose provider reported no date has none, and a `Last-Modified`
193
269
  // made up for it is a validator a cache would go on to trust.
194
- const modified = read.object.lastModified;
270
+ const modified = object.lastModified;
195
271
  if (modified !== undefined) headers.set('last-modified', modified.toUTCString());
196
272
  // Revalidation costs a request and no bytes, which is the trade an authorized response wants:
197
273
  // the bytes never change under a key, but the actor's permission to read them can be revoked.
@@ -199,23 +275,24 @@ export function storageResponse(request: UltimateRequest, read: StorageRead): Re
199
275
  return new Response(null, { status: 304, headers });
200
276
  }
201
277
 
202
- const size = read.bytes.byteLength;
278
+ const size = object.size;
203
279
  const range = parseByteRange(request.header('range'), size);
204
280
  if (range === UNSATISFIABLE) {
205
281
  headers.set('content-range', `bytes */${size}`);
206
282
  return new Response(null, { status: 416, headers });
207
283
  }
208
- // Copied at each call, not through a helper, for `runtime-assets.ts`'s reason: a
209
- // `Uint8Array<ArrayBufferLike>` may be backed by a `SharedArrayBuffer`, which `Response` does
210
- // not accept — and a helper's declared return type widens the copy back to the type it refuses.
284
+ // `size` is the stat's: a key's bytes never change under it (an upload mints a new key), so the
285
+ // length declared here is the length the stream delivers.
211
286
  if (range === undefined) {
212
287
  headers.set('content-length', String(size));
213
- return new Response(new Uint8Array(read.bytes), { headers });
288
+ return new Response(await served.open(), { headers });
214
289
  }
215
- const slice = read.bytes.subarray(range.start, range.end + 1);
216
290
  headers.set('content-range', `bytes ${range.start}-${range.end}/${size}`);
217
- headers.set('content-length', String(slice.byteLength));
218
- return new Response(new Uint8Array(slice), { status: 206, headers });
291
+ headers.set('content-length', String(range.end - range.start + 1));
292
+ return new Response(windowOf(await served.open(), range.start, range.end), {
293
+ status: 206,
294
+ headers,
295
+ });
219
296
  }
220
297
 
221
298
  /**
@@ -239,6 +316,7 @@ export function servedStorage(host: Storage): Storage {
239
316
  return current().diskNames;
240
317
  },
241
318
  disk: (name?: string) => current().disk(name),
319
+ isShared: (disk: string, key: string) => current().isShared(disk, key),
242
320
  };
243
321
  }
244
322
 
@@ -2,12 +2,13 @@
2
2
  // primitive, so it inherits .enqueue(), the retry policy, the cancellation and the manifest row.
3
3
  // One live run per name: a second enqueue while the pass is going is the same pass.
4
4
 
5
+ import { stripComments, UltimateError } from '@ultimat3/core';
5
6
  import type { FeatureTarget } from './entity';
6
7
  import { sortedImports } from './imports';
7
8
  import type { GeneratedFile, NameSet } from './naming';
8
9
  import { names } from './naming';
9
10
  import { PLACEHOLDER_DB_MODULE } from './scaffold-db-client';
10
- import { sliceFoundation } from './slice-foundation';
11
+ import { sliceFoundation, sliceTakesScaffoldRow } from './slice-foundation';
11
12
  import { wrapImport } from './wrap';
12
13
 
13
14
  /**
@@ -226,11 +227,54 @@ unitTest('the projection is idempotent: a replayed page changes nothing', () =>
226
227
  export interface BackfillOptions extends FeatureTarget {
227
228
  /** Every locale the planted entity's admin labels ship for. Defaults to `['en']`. */
228
229
  readonly locales?: readonly string[];
230
+ /** The slice's `entity.ts` as it stands on disk, absent when the feature has none yet. */
231
+ readonly sliceEntity?: string;
232
+ /** The slice's `repo.ts` as it stands on disk, absent alongside `sliceEntity`. */
233
+ readonly sliceRepo?: string;
234
+ }
235
+
236
+ /** Every table an `entity.ts` declares, in source order: `entity('court_closures', …)`. */
237
+ const declaredTables = (source: string): readonly string[] =>
238
+ [...stripComments(source).matchAll(/\bentity\(\s*['"]([^'"]+)['"]/g)].map(
239
+ (match) => match[1] ?? '',
240
+ );
241
+
242
+ /**
243
+ * Declared beside its one thrower: `errors.ts` sits at its ceiling. The sweep this generator writes
244
+ * reads the slice's OWN scaffolded table (`db.<plural>`, its `title` and `price`), so a slice whose
245
+ * entities are named otherwise — notificado.co's `deadline`, holding `holidays` and
246
+ * `court_closures` — got `import type { Deadline }`, `db.deadlines` and admin labels for a table
247
+ * that does not exist. Refused before a file is planned; a hand sweep starts from a job.
248
+ */
249
+ export class BackfillSliceEntityError extends UltimateError {
250
+ constructor(input: {
251
+ readonly name: string;
252
+ readonly feature: string;
253
+ readonly tables: readonly string[];
254
+ }) {
255
+ const declared = input.tables.length === 0 ? 'no table' : input.tables.join(', ');
256
+ super({
257
+ code: 'X_BACKFILL_SLICE_ENTITY',
258
+ cause: `x g backfill sweeps the ${input.feature} slice's own scaffolded table, and its entity.ts declares ${declared} instead — the generated files would import a row type and a db handle that do not exist`,
259
+ fix: `x g job ${input.name} --feature ${input.feature} # then declare backfill({ source: db.<one of: ${declared}>, … }) from @ultimat3/jobs in it`,
260
+ meta: { feature: input.feature, tables: [...input.tables] },
261
+ });
262
+ }
229
263
  }
230
264
 
231
265
  export function backfillFiles(rawName: string, target: BackfillOptions): readonly GeneratedFile[] {
232
266
  const name = names(rawName);
233
267
  const feature = names(target.feature);
268
+ if (
269
+ target.sliceEntity !== undefined &&
270
+ !sliceTakesScaffoldRow(target, target.sliceEntity, target.sliceRepo)
271
+ ) {
272
+ throw new BackfillSliceEntityError({
273
+ name: name.kebab,
274
+ feature: target.feature,
275
+ tables: declaredTables(target.sliceEntity),
276
+ });
277
+ }
234
278
  const dir = `${target.surfaceDir}/${target.feature}/backfills`;
235
279
  const dbModule = target.dbModule ?? PLACEHOLDER_DB_MODULE;
236
280
  return [