@ultimat3/cli 22.1.0 → 22.2.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": "22.1.0",
3
+ "version": "22.2.0",
4
4
  "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -38,33 +38,33 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@babel/core": "^8.0.1",
41
- "@ultimat3/action": "22.1.0",
42
- "@ultimat3/admin": "22.1.0",
43
- "@ultimat3/ai": "22.1.0",
44
- "@ultimat3/auth": "22.1.0",
45
- "@ultimat3/cache": "22.1.0",
46
- "@ultimat3/core": "22.1.0",
47
- "@ultimat3/db": "22.1.0",
48
- "@ultimat3/entity": "22.1.0",
49
- "@ultimat3/flags": "22.1.0",
50
- "@ultimat3/http": "22.1.0",
51
- "@ultimat3/i18n": "22.1.0",
52
- "@ultimat3/jobs": "22.1.0",
53
- "@ultimat3/mail": "22.1.0",
54
- "@ultimat3/manifest": "22.1.0",
55
- "@ultimat3/mcp": "22.1.0",
56
- "@ultimat3/money": "22.1.0",
57
- "@ultimat3/notify": "22.1.0",
58
- "@ultimat3/policy": "22.1.0",
59
- "@ultimat3/pwa": "22.1.0",
60
- "@ultimat3/query": "22.1.0",
61
- "@ultimat3/realtime": "22.1.0",
62
- "@ultimat3/render": "22.1.0",
63
- "@ultimat3/schema": "22.1.0",
64
- "@ultimat3/seo": "22.1.0",
65
- "@ultimat3/storage": "22.1.0",
66
- "@ultimat3/testing": "22.1.0",
67
- "@ultimat3/time": "22.1.0",
41
+ "@ultimat3/action": "22.2.0",
42
+ "@ultimat3/admin": "22.2.0",
43
+ "@ultimat3/ai": "22.2.0",
44
+ "@ultimat3/auth": "22.2.0",
45
+ "@ultimat3/cache": "22.2.0",
46
+ "@ultimat3/core": "22.2.0",
47
+ "@ultimat3/db": "22.2.0",
48
+ "@ultimat3/entity": "22.2.0",
49
+ "@ultimat3/flags": "22.2.0",
50
+ "@ultimat3/http": "22.2.0",
51
+ "@ultimat3/i18n": "22.2.0",
52
+ "@ultimat3/jobs": "22.2.0",
53
+ "@ultimat3/mail": "22.2.0",
54
+ "@ultimat3/manifest": "22.2.0",
55
+ "@ultimat3/mcp": "22.2.0",
56
+ "@ultimat3/money": "22.2.0",
57
+ "@ultimat3/notify": "22.2.0",
58
+ "@ultimat3/policy": "22.2.0",
59
+ "@ultimat3/pwa": "22.2.0",
60
+ "@ultimat3/query": "22.2.0",
61
+ "@ultimat3/realtime": "22.2.0",
62
+ "@ultimat3/render": "22.2.0",
63
+ "@ultimat3/schema": "22.2.0",
64
+ "@ultimat3/seo": "22.2.0",
65
+ "@ultimat3/storage": "22.2.0",
66
+ "@ultimat3/testing": "22.2.0",
67
+ "@ultimat3/time": "22.2.0",
68
68
  "babel-preset-solid": "^1.9.15"
69
69
  }
70
70
  }
Binary file
@@ -18,7 +18,7 @@ import { pageSync } from './page-sync';
18
18
  import { loadPwaArtifacts } from './pwa-artifacts';
19
19
  import { assetRoutes } from './runtime-assets';
20
20
  import { appRoutes } from './runtime-render';
21
- import { storageRoutes } from './runtime-storage';
21
+ import { servedStorage, storageRoutes } from './runtime-storage';
22
22
  import { styleBundle } from './style-bundle';
23
23
  import { styleRoutes } from './style-routes';
24
24
  import { serviceWorkerArtifacts } from './sw-artifacts';
@@ -72,6 +72,8 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
72
72
  // call `runRole` makes — `POST /mcp` answered 404 in every process the framework booted until
73
73
  // one of them asked. Warned once here when `expose` is true and nothing can be mounted.
74
74
  const mcpMount = await mountAppMcp(input.root);
75
+ // `serve-boot.ts`'s answer: the app's declared disks when it has any, read per request.
76
+ const served = servedStorage(input.storage);
75
77
  const routes: readonly Route[] = [
76
78
  ...devDashboardRoutes(input.dashboard),
77
79
  // The same API table the container serves: a read that answers here and 404s in production
@@ -83,10 +85,10 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
83
85
  // never shadow `/icons` or `/media`.
84
86
  ...assetRoutes({
85
87
  root: input.root,
86
- storage: input.storage,
88
+ storage: served,
87
89
  ...(pwa === undefined ? {} : { pwa }),
88
90
  }),
89
- ...storageRoutes({ storage: input.storage }),
91
+ ...storageRoutes({ storage: served }),
90
92
  // The chunks the documents below name. Mounted before the app's routes for the reason
91
93
  // `/icons` and `/media` are: a page route must not be able to shadow an asset URL.
92
94
  ...islandRoutes(() => input.islands()),
@@ -19,8 +19,13 @@ import { posix } from 'node:path';
19
19
  import { clientScopeOf } from '@ultimat3/auth';
20
20
  import type { Ctx } from '@ultimat3/core';
21
21
  import { CLIENT_SCOPE_HEADER } from '@ultimat3/core';
22
- import type { RouteMeta as HttpRouteMeta, Route, RouteParams } from '@ultimat3/http';
23
- import { asCtx, html, stream } from '@ultimat3/http';
22
+ import type {
23
+ RouteMeta as HttpRouteMeta,
24
+ RedirectIntent,
25
+ Route,
26
+ RouteParams,
27
+ } from '@ultimat3/http';
28
+ import { asCtx, html, NO_STORE, redirect, stream, takeRedirect } from '@ultimat3/http';
24
29
  import { currentLocale } from '@ultimat3/i18n';
25
30
  import type {
26
31
  ClientSyncHead,
@@ -289,14 +294,12 @@ async function documentFrom(
289
294
  async function resultFor(
290
295
  entry: RouteEntry,
291
296
  request: DevRouteData,
297
+ data: RouteData,
292
298
  options: DevRenderOptions,
293
299
  isr: IsrController,
294
300
  ctx: Ctx,
295
301
  ): Promise<RenderResult> {
296
302
  const url = new URL(request.url);
297
- // ONCE per request, before the mode is chosen. Every branch below reads this same object, so a
298
- // route's `load` runs exactly once however its mode splits head from body.
299
- const data = await routeDataFor(entry.config, request);
300
303
  // The status the loader answered through `withStatus`, 200 when it said nothing. Read once,
301
304
  // here, and handed to every mode: this file mints the `Response`, render owns the seam.
302
305
  const status = routeStatusOf(data);
@@ -380,6 +383,18 @@ const withScope = (result: RenderResult, scope: string | undefined): RenderResul
380
383
  ? result
381
384
  : { ...result, headers: { ...result.headers, [CLIENT_SCOPE_HEADER]: scope } };
382
385
 
386
+ /**
387
+ * A loader's redirect. No declared `cache` means `private, no-store`: the loader decided it for
388
+ * THIS request (it recorded an open, it checked a token), and the mode's default would offer that
389
+ * decision to a CDN, which would then answer every later request without running the loader. A
390
+ * declared one is the pipeline's to apply, off `meta.cache`, exactly as for any handler.
391
+ */
392
+ function loadRedirect(entry: RouteEntry, to: RedirectIntent): Response {
393
+ const response = redirect(to.location, to.status);
394
+ if (entry.config.cache === undefined) response.headers.set('cache-control', 'private, no-store');
395
+ return response;
396
+ }
397
+
383
398
  const responseOf = (result: RenderResult): Response =>
384
399
  typeof result.body === 'string'
385
400
  ? html(result.body, { status: result.status, headers: result.headers })
@@ -395,6 +410,12 @@ const metaOf = (entry: RouteEntry): HttpRouteMeta => ({
395
410
  render: entry.config.render,
396
411
  tags: [entry.surface],
397
412
  ...(entry.config.policy === undefined ? {} : { policy: entry.config.policy.permission }),
413
+ // Projected so the router screens its ages at mount (`assertRouteCache`) and the `cache-headers`
414
+ // stage applies it to a response that wrote none — a loader's redirect. The rendered document
415
+ // carries its own header, from `ssrHeaders`, which the stage reviews rather than replaces.
416
+ ...(entry.config.cache === undefined
417
+ ? {}
418
+ : { cache: entry.config.cache === 'no-store' ? NO_STORE : entry.config.cache }),
398
419
  });
399
420
 
400
421
  /**
@@ -424,7 +445,15 @@ export function appRoutes(options: DevRenderOptions): readonly Route[] {
424
445
  handler: async (request, ctx): Promise<Response> => {
425
446
  const entry = routeFor(registered.path) ?? registered;
426
447
  const data: DevRouteData = { url: request.url.href, params: ctx.params };
427
- return responseOf(await resultFor(entry, data, options, isr, asCtx(ctx)));
448
+ // ONCE per request, before the mode is chosen: every branch of `resultFor` reads this same
449
+ // object, so a route's `load` runs exactly once however its mode splits head from body.
450
+ const loaded = await routeDataFor(entry.config, data);
451
+ // `setRedirect()` inside `load` — the same slot an action's handler fills — answers here,
452
+ // before a document is rendered for a page the visitor is leaving. `withStatus` refuses a
453
+ // 3xx because a rendered document has no `Location`; this is the path that has one.
454
+ const to = takeRedirect(ctx);
455
+ if (to !== undefined) return loadRedirect(entry, to);
456
+ return responseOf(await resultFor(entry, data, loaded, options, isr, asCtx(ctx)));
428
457
  },
429
458
  }));
430
459
  }
@@ -0,0 +1,100 @@
1
+ // Single responsibility: the upload half of `/_storage` — the `PUT` a `grantUpload` URL names.
2
+ // `@ultimat3/storage` owns every accept decision (`acceptSignedUpload`); this file is where those
3
+ // decisions meet a `Route`, beside the GET in `runtime-storage.ts`, so `x dev` and `runRole` mount
4
+ // one write path through the one `storageRoutes()` both tables already call (#523).
5
+
6
+ import { finiteCount, readWithinLimit } from '@ultimat3/core';
7
+ import type { RequestContext, Route, UltimateRequest } from '@ultimat3/http';
8
+ import { json, NO_STORE } from '@ultimat3/http';
9
+ import type { Storage } from '@ultimat3/storage';
10
+ import {
11
+ acceptSignedUpload,
12
+ DEFAULT_MAX_UPLOAD_BYTES,
13
+ DEFAULT_SIGNED_URL_BASE,
14
+ objectNotFound,
15
+ signedUploadConstraints,
16
+ tooLarge,
17
+ uploadPolicy,
18
+ } from '@ultimat3/storage';
19
+
20
+ /**
21
+ * The route's URL is the capability, so the one thing read off the request before it is verified
22
+ * is the URL itself — path and query exactly as the grant signed them.
23
+ */
24
+ const signedUrlOf = (request: UltimateRequest): string =>
25
+ `${request.url.pathname}${request.url.search}`;
26
+
27
+ /**
28
+ * The GRANT is the policy. `grantUpload` refused a type and a size the app's `uploadPolicy` did not
29
+ * allow before it signed, and the signature carries both — so the accept validates against what
30
+ * was signed, never against `uploadPolicy()`'s image-only default, which would refuse every PDF an
31
+ * app granted on purpose. The magic-byte sniff still runs: a signature bounds the declared type,
32
+ * the bytes still have to be it.
33
+ */
34
+ async function acceptUpload(
35
+ storage: Storage,
36
+ request: UltimateRequest,
37
+ ctx: RequestContext,
38
+ ): Promise<Response> {
39
+ const diskName = request.params['disk'] ?? '';
40
+ const key = request.params['key'] ?? '';
41
+ // The GET's answer for a disk this app does not have, for the GET's reason: a refusal that lists
42
+ // the configured disks tells a caller which names exist.
43
+ if (!storage.diskNames.includes(diskName)) throw objectNotFound(diskName, key);
44
+ const disk = storage.disk(diskName);
45
+ const url = signedUrlOf(request);
46
+ // The actor's org, never one off the request: `acceptSignedUpload` holds the verified key to it.
47
+ const orgId = ctx.actor.orgId ?? '';
48
+
49
+ // Verified BEFORE the body is read, so the signed ceiling caps the read — not `bodyLimitBytes`,
50
+ // which is an RPC body's limit and would refuse every upload over a megabyte the app granted,
51
+ // and never an unverified `?max=` a forger could raise to make this process hold gigabytes.
52
+ const constraints = await signedUploadConstraints({ url, disk, orgId });
53
+ // Screened as every defaulted count is: a signed ceiling is verified, but `NaN` would still read
54
+ // as "no cap" to the counting reader below.
55
+ const ceiling = finiteCount(
56
+ 'storageUploadRoute',
57
+ 'maxBytes',
58
+ constraints.maxBytes === undefined ? DEFAULT_MAX_UPLOAD_BYTES : constraints.maxBytes,
59
+ 1,
60
+ );
61
+ const read = await readWithinLimit(request.raw.body, ceiling);
62
+ if ('over' in read) throw tooLarge(constraints.key, read.over, ceiling);
63
+
64
+ const stored = await acceptSignedUpload({
65
+ url,
66
+ disk,
67
+ orgId,
68
+ bytes: read.bytes,
69
+ declaredContentType: request.header('content-type') ?? undefined,
70
+ policy: uploadPolicy({
71
+ maxBytes: ceiling,
72
+ ...(constraints.contentType === undefined
73
+ ? {}
74
+ : { allowedContentTypes: [constraints.contentType] }),
75
+ }),
76
+ });
77
+ return json({ key: stored.key }, { status: 201 });
78
+ }
79
+
80
+ /**
81
+ * `auth: 'required'` and `enforcedBy: 'handler'`, the GET's posture and for its reasons. No
82
+ * permission: the grant is the authorization, decided by the action that minted it under its own
83
+ * policy, and this route re-proves the three things a leaked URL must not buy — the signature,
84
+ * the expiry, and the actor's tenant. `no-store`: a 201 for one actor's write is nothing to cache.
85
+ */
86
+ export function storageUploadRoute(storage: Storage): Route {
87
+ return {
88
+ method: 'PUT',
89
+ path: `${DEFAULT_SIGNED_URL_BASE}/:disk/*key`,
90
+ meta: {
91
+ name: 'storage.upload',
92
+ auth: 'required',
93
+ enforcedBy: 'handler',
94
+ cache: NO_STORE,
95
+ tags: ['storage'],
96
+ },
97
+ handler: (request: UltimateRequest, ctx: RequestContext): Promise<Response> =>
98
+ acceptUpload(storage, request, ctx),
99
+ };
100
+ }
@@ -17,11 +17,13 @@ import type { Storage, StorageRead } from '@ultimat3/storage';
17
17
  import {
18
18
  assertSafeKey,
19
19
  DEFAULT_SIGNED_URL_BASE,
20
+ definedStorage,
20
21
  isTenantScoped,
21
22
  isWithinOrg,
22
23
  objectNotFound,
23
24
  orgMismatch,
24
25
  } from '@ultimat3/storage';
26
+ import { storageUploadRoute } from './runtime-storage-upload';
25
27
 
26
28
  /**
27
29
  * The one capability that gates reading a stored object, on every disk. A permission and not a
@@ -211,6 +213,30 @@ export function storageResponse(request: UltimateRequest, read: StorageRead): Re
211
213
  return new Response(new Uint8Array(slice), { status: 206, headers });
212
214
  }
213
215
 
216
+ /**
217
+ * The `Storage` the framework's own routes serve: the process's ONE registry when there is one —
218
+ * the last `defineStorage()`, which is the app's whenever an app module declares its disks — and
219
+ * the host's env-selected disk only when nothing did. Resolved per call, never captured at mount:
220
+ * the boot builds its disk before the app's modules load, and `x dev` re-imports them on a save.
221
+ *
222
+ * Why not the host's disk alone (#524): an app that declared `defineStorage({ disks: { uploads,
223
+ * evidence } })` wrote through `disk('evidence')` while `/_storage` and `/media` read the host's
224
+ * `local`/`object`, so every URL the app's own disks signed answered 404. The app's code and the
225
+ * framework's routes must be reading one registry, and `defineStorage` says there is only one.
226
+ */
227
+ export function servedStorage(host: Storage): Storage {
228
+ const current = (): Storage => definedStorage() ?? host;
229
+ return {
230
+ get defaultDisk(): string {
231
+ return current().defaultDisk;
232
+ },
233
+ get diskNames(): readonly string[] {
234
+ return current().diskNames;
235
+ },
236
+ disk: (name?: string) => current().disk(name),
237
+ };
238
+ }
239
+
214
240
  export interface StorageRoutesOptions {
215
241
  /** The configured disks. The driver seam only — this route never assumes a filesystem. */
216
242
  readonly storage: Storage;
@@ -249,5 +275,8 @@ export function storageRoutes(options: StorageRoutesOptions): readonly Route[] {
249
275
  return storageResponse(request, await readStorageObject(options.storage, input, ctx.actor));
250
276
  },
251
277
  },
278
+ // The upload half: the `PUT` every `grantUpload` URL names (#523). One call mounts both, so
279
+ // no route table can serve the reads of a disk it cannot write to.
280
+ storageUploadRoute(options.storage),
252
281
  ];
253
282
  }
package/src/serve-boot.ts CHANGED
@@ -24,7 +24,7 @@ import { assetRoutes } from './runtime-assets';
24
24
  import { appRoutes } from './runtime-render';
25
25
  import { replicaOverrides } from './runtime-replica';
26
26
  import type { RunningServices } from './runtime-services';
27
- import { storageRoutes } from './runtime-storage';
27
+ import { servedStorage, storageRoutes } from './runtime-storage';
28
28
  import { loadDrainConfig } from './serve-drain';
29
29
  import { configureReporting, containerBinding, metricsPortFor, portFromEnv } from './serve-env';
30
30
  import type { ServedApp, ServeOptions } from './serve-types';
@@ -156,17 +156,19 @@ async function webSurface(
156
156
  });
157
157
  // The app's own MCP endpoint, through the same call `x dev` makes — see `app-mcp.ts`.
158
158
  const mcpMount = await mountAppMcp(options.root);
159
+ // The app's own disks when it declared any (`defineStorage` in an app module), else this boot's.
160
+ const served = servedStorage(runtime.storage);
159
161
  const routes: readonly Route[] = [
160
162
  ...apiRoutes(),
161
163
  ...mcpMount.routes,
162
164
  ...(serviceWorker === undefined ? [] : serviceWorkerRoutes(serviceWorker)),
163
165
  ...assetRoutes({
164
166
  root: options.root,
165
- storage: runtime.storage,
167
+ storage: served,
166
168
  ...(options.runtime?.images === undefined ? {} : { images: options.runtime.images }),
167
169
  ...(pwa === undefined ? {} : { pwa }),
168
170
  }),
169
- ...storageRoutes({ storage: runtime.storage }),
171
+ ...storageRoutes({ storage: served }),
170
172
  ...islandRoutes(() => islands),
171
173
  // The surface stylesheets the documents link. Built from the registry the `loadApp` above
172
174
  // filled, so this process serves exactly the CSS it renders against.