@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 +28 -28
- package/src/app-load.ts +7 -2
- package/src/app-permissions.ts +2 -31
- package/src/app-reload-graph.ts +3 -3
- package/src/budgets.ts +3 -1
- package/src/cmd-generate.ts +8 -1
- package/src/dev-watch-tree.ts +4 -2
- package/src/error-codes.ts +2 -0
- package/src/generate-files.ts +3 -1
- package/src/index.ts +4 -0
- package/src/mcp-errors.ts +2 -0
- package/src/path-segments.ts +10 -0
- package/src/runtime-admin.ts +9 -2
- package/src/runtime-assets.ts +1 -1
- package/src/runtime-storage.ts +105 -27
- package/src/templates/backfill.ts +45 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "
|
|
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": "
|
|
43
|
-
"@ultimat3/admin": "
|
|
44
|
-
"@ultimat3/ai": "
|
|
45
|
-
"@ultimat3/auth": "
|
|
46
|
-
"@ultimat3/cache": "
|
|
47
|
-
"@ultimat3/core": "
|
|
48
|
-
"@ultimat3/db": "
|
|
49
|
-
"@ultimat3/entity": "
|
|
50
|
-
"@ultimat3/flags": "
|
|
51
|
-
"@ultimat3/http": "
|
|
52
|
-
"@ultimat3/i18n": "
|
|
53
|
-
"@ultimat3/jobs": "
|
|
54
|
-
"@ultimat3/mail": "
|
|
55
|
-
"@ultimat3/manifest": "
|
|
56
|
-
"@ultimat3/mcp": "
|
|
57
|
-
"@ultimat3/money": "
|
|
58
|
-
"@ultimat3/notify": "
|
|
59
|
-
"@ultimat3/policy": "
|
|
60
|
-
"@ultimat3/pwa": "
|
|
61
|
-
"@ultimat3/query": "
|
|
62
|
-
"@ultimat3/realtime": "
|
|
63
|
-
"@ultimat3/render": "
|
|
64
|
-
"@ultimat3/schema": "
|
|
65
|
-
"@ultimat3/seo": "
|
|
66
|
-
"@ultimat3/storage": "
|
|
67
|
-
"@ultimat3/testing": "
|
|
68
|
-
"@ultimat3/time": "
|
|
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
|
-
/**
|
|
423
|
-
|
|
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.
|
package/src/app-permissions.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
}
|
package/src/app-reload-graph.ts
CHANGED
|
@@ -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) =>
|
|
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
|
-
|
|
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(
|
|
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
|
|
package/src/cmd-generate.ts
CHANGED
|
@@ -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([
|
|
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
|
package/src/dev-watch-tree.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/error-codes.ts
CHANGED
|
@@ -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
|
package/src/generate-files.ts
CHANGED
|
@@ -61,7 +61,7 @@ export interface GenerateOptions {
|
|
|
61
61
|
*/
|
|
62
62
|
readonly sliceErrors?: string;
|
|
63
63
|
/**
|
|
64
|
-
* `action`, `mutator`, `query`, `job` and `
|
|
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 =>
|
package/src/path-segments.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/runtime-admin.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/src/runtime-assets.ts
CHANGED
|
@@ -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.
|
package/src/runtime-storage.ts
CHANGED
|
@@ -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 {
|
|
16
|
-
|
|
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
|
|
34
|
-
*
|
|
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
|
|
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
|
|
97
|
-
*
|
|
98
|
-
* a key
|
|
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(
|
|
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)
|
|
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<
|
|
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
|
-
|
|
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(
|
|
184
|
-
|
|
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':
|
|
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(
|
|
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 =
|
|
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 =
|
|
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
|
-
//
|
|
209
|
-
//
|
|
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(
|
|
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(
|
|
218
|
-
return new Response(
|
|
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 [
|