@ultimat3/cli 22.0.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 +28 -28
- package/src/app-permissions.ts +0 -0
- package/src/dev-route-table.ts +5 -3
- package/src/mcp-ui.ts +4 -2
- package/src/role-sync.ts +4 -0
- package/src/runtime-render.ts +35 -6
- package/src/runtime-storage-upload.ts +100 -0
- package/src/runtime-storage.ts +29 -0
- package/src/serve-boot.ts +5 -3
- package/src/sync-url.ts +21 -1
- package/src/templates/scaffold-container-compose.ts +1 -1
- package/src/templates/scaffold-env.ts +6 -0
- package/src/templates/scaffold-helm.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "22.
|
|
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.
|
|
42
|
-
"@ultimat3/admin": "22.
|
|
43
|
-
"@ultimat3/ai": "22.
|
|
44
|
-
"@ultimat3/auth": "22.
|
|
45
|
-
"@ultimat3/cache": "22.
|
|
46
|
-
"@ultimat3/core": "22.
|
|
47
|
-
"@ultimat3/db": "22.
|
|
48
|
-
"@ultimat3/entity": "22.
|
|
49
|
-
"@ultimat3/flags": "22.
|
|
50
|
-
"@ultimat3/http": "22.
|
|
51
|
-
"@ultimat3/i18n": "22.
|
|
52
|
-
"@ultimat3/jobs": "22.
|
|
53
|
-
"@ultimat3/mail": "22.
|
|
54
|
-
"@ultimat3/manifest": "22.
|
|
55
|
-
"@ultimat3/mcp": "22.
|
|
56
|
-
"@ultimat3/money": "22.
|
|
57
|
-
"@ultimat3/notify": "22.
|
|
58
|
-
"@ultimat3/policy": "22.
|
|
59
|
-
"@ultimat3/pwa": "22.
|
|
60
|
-
"@ultimat3/query": "22.
|
|
61
|
-
"@ultimat3/realtime": "22.
|
|
62
|
-
"@ultimat3/render": "22.
|
|
63
|
-
"@ultimat3/schema": "22.
|
|
64
|
-
"@ultimat3/seo": "22.
|
|
65
|
-
"@ultimat3/storage": "22.
|
|
66
|
-
"@ultimat3/testing": "22.
|
|
67
|
-
"@ultimat3/time": "22.
|
|
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
|
}
|
package/src/app-permissions.ts
CHANGED
|
Binary file
|
package/src/dev-route-table.ts
CHANGED
|
@@ -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:
|
|
88
|
+
storage: served,
|
|
87
89
|
...(pwa === undefined ? {} : { pwa }),
|
|
88
90
|
}),
|
|
89
|
-
...storageRoutes({ 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()),
|
package/src/mcp-ui.ts
CHANGED
|
@@ -95,6 +95,8 @@ export interface UiHostInput {
|
|
|
95
95
|
readonly driver?: ((viewport: UiShotInput['viewport']) => Promise<ShotDriver>) | undefined;
|
|
96
96
|
/** Injected by a test: the route table, in place of the registry. */
|
|
97
97
|
readonly routes?: (() => readonly DeclaredRoute[]) | undefined;
|
|
98
|
+
/** Injected by a test: the settle wait, in place of `DEFAULT_SETTLE_MS` (a real sleep per call). */
|
|
99
|
+
readonly settleMs?: number | undefined;
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
export interface UiCapabilities {
|
|
@@ -163,7 +165,7 @@ export function uiCapabilities(input: UiHostInput): UiCapabilities {
|
|
|
163
165
|
outDir,
|
|
164
166
|
driver,
|
|
165
167
|
boot,
|
|
166
|
-
settleMs: DEFAULT_SETTLE_MS,
|
|
168
|
+
settleMs: input.settleMs ?? DEFAULT_SETTLE_MS,
|
|
167
169
|
timeoutMs: DEFAULT_PAGE_TIMEOUT_MS,
|
|
168
170
|
fullPage: shot.fullPage,
|
|
169
171
|
colorScheme: shot.colorScheme,
|
|
@@ -197,7 +199,7 @@ export function uiCapabilities(input: UiHostInput): UiCapabilities {
|
|
|
197
199
|
root,
|
|
198
200
|
island: island.island,
|
|
199
201
|
...(island.state === undefined ? {} : { state: island.state }),
|
|
200
|
-
settleMs: DEFAULT_SETTLE_MS,
|
|
202
|
+
settleMs: input.settleMs ?? DEFAULT_SETTLE_MS,
|
|
201
203
|
timeoutMs: DEFAULT_PAGE_TIMEOUT_MS,
|
|
202
204
|
...(executablePath === undefined ? {} : { executablePath }),
|
|
203
205
|
...(cdpUrl === undefined ? {} : { cdpUrl }),
|
package/src/role-sync.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { neighbouringPort, PORT_RANGE, portPairAfter } from './flag-number';
|
|
|
20
20
|
import { portFree } from './port-probe';
|
|
21
21
|
import type { StartRolesOptions } from './role-start';
|
|
22
22
|
import { syncAuthenticator } from './sync-authenticator';
|
|
23
|
+
import { syncOriginsFrom } from './sync-url';
|
|
23
24
|
import { DEV_BINDING } from './web-binding';
|
|
24
25
|
|
|
25
26
|
/**
|
|
@@ -201,6 +202,9 @@ export async function prepareSync(options: StartRolesOptions): Promise<PreparedS
|
|
|
201
202
|
buildId: options.buildId,
|
|
202
203
|
sockets,
|
|
203
204
|
...(authenticate === undefined ? {} : { authenticate }),
|
|
205
|
+
// The page's origin when it is served on another host than this node; the node's own host is
|
|
206
|
+
// admitted without it. Anything else is refused before `authenticate` (X_SOCKET_ORIGIN_REFUSED).
|
|
207
|
+
allowedOrigins: syncOriginsFrom(options.env),
|
|
204
208
|
// Tier 1 is presence, and without a registry the node answers a topic subscribe with no member
|
|
205
209
|
// list at all — the KV bucket the transport just created would hold nothing and every `sync`
|
|
206
210
|
// container would run a presence-less protocol. It reads and writes `transport.shared`, so it
|
package/src/runtime-render.ts
CHANGED
|
@@ -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 {
|
|
23
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/runtime-storage.ts
CHANGED
|
@@ -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:
|
|
167
|
+
storage: served,
|
|
166
168
|
...(options.runtime?.images === undefined ? {} : { images: options.runtime.images }),
|
|
167
169
|
...(pwa === undefined ? {} : { pwa }),
|
|
168
170
|
}),
|
|
169
|
-
...storageRoutes({ 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.
|
package/src/sync-url.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Where a page's one socket dials — the framework's answer, so no app owns a `sync-url.ts`. Read
|
|
2
|
-
// once at boot from the deployment's env and handed to every document as `ultimate-sync
|
|
2
|
+
// once at boot from the deployment's env and handed to every document as `ultimate-sync` — and
|
|
3
|
+
// the page origin the node admits, which is the same question asked from the other end.
|
|
3
4
|
|
|
4
5
|
import { ConfigInvalidError } from '@ultimat3/core';
|
|
5
6
|
|
|
@@ -29,3 +30,22 @@ export function syncUrlFrom(env: Readonly<Record<string, string | undefined>>):
|
|
|
29
30
|
}
|
|
30
31
|
return declared;
|
|
31
32
|
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The page origins a sync node admits besides its own host name: `APP_URL`'s, when set. Only a
|
|
36
|
+
* deployment that serves the page on another host than the node needs it; an unparsable value is
|
|
37
|
+
* refused rather than read as "no page origin", which would refuse every socket in silence.
|
|
38
|
+
*/
|
|
39
|
+
export function syncOriginsFrom(env: Readonly<Record<string, string | undefined>>): string[] {
|
|
40
|
+
const declared = env['APP_URL']?.trim() ?? '';
|
|
41
|
+
if (declared === '') return [];
|
|
42
|
+
const parsed = URL.parse(declared);
|
|
43
|
+
if (parsed === null || (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')) {
|
|
44
|
+
throw new ConfigInvalidError({
|
|
45
|
+
cause: 'APP_URL is set but is not an http:// or https:// URL, so no page origin can match it',
|
|
46
|
+
fix: 'export APP_URL="https://www.example.com" # the origin your pages are served on',
|
|
47
|
+
meta: { key: 'APP_URL' },
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return [parsed.origin];
|
|
51
|
+
}
|
|
@@ -138,7 +138,7 @@ services:
|
|
|
138
138
|
# (X_REALTIME_TOPOLOGY). To turn live queries and channels on: declare realtime: { enabled:
|
|
139
139
|
# true, transport: 'nats', urlEnv: 'NATS_URL' } in app.config.ts, add a NATS service and set the
|
|
140
140
|
# SAME NATS_URL for web, sync and a replicator service (ROLE=replicator, exactly one), start
|
|
141
|
-
# Postgres with wal_level=logical and a
|
|
141
|
+
# Postgres with wal_level=logical and a REPLICATION role on a cluster dedicated to this app (the replicator creates its publication at boot), then set replicas: 1.
|
|
142
142
|
# A NATS_URL under transport 'memory' refuses the boot (X_CONFIG_INVALID), and so does 'nats'
|
|
143
143
|
# without one.
|
|
144
144
|
deploy: { replicas: 0 } # when on: scales on concurrent websockets — pinned to 1 by the port
|
|
@@ -29,6 +29,12 @@ export const SCAFFOLD_ENV_SCHEMA = {
|
|
|
29
29
|
role: 'sync',
|
|
30
30
|
description: 'Realtime fan-out cluster. Only the sync role is asked for it.',
|
|
31
31
|
},
|
|
32
|
+
APP_URL: {
|
|
33
|
+
type: 'url',
|
|
34
|
+
required: false,
|
|
35
|
+
role: ['web', 'sync'],
|
|
36
|
+
description: 'Public origin of the pages. Sync admits sockets from it.',
|
|
37
|
+
},
|
|
32
38
|
SYNC_URL: {
|
|
33
39
|
type: 'string',
|
|
34
40
|
required: false,
|
|
@@ -95,7 +95,7 @@ roles:
|
|
|
95
95
|
# urlEnv: 'NATS_URL' } in app.config.ts (a NATS_URL under transport 'memory' is refused, and
|
|
96
96
|
# 'nats' without it is too); run NATS and set the SAME NATS_URL for web, sync and the
|
|
97
97
|
# replicator; enable exactly one replicator (below) against a Postgres started with
|
|
98
|
-
# wal_level=logical and a
|
|
98
|
+
# wal_level=logical (the replicator creates its publication at boot) and a role with REPLICATION on a cluster dedicated to this app; then set this to true.
|
|
99
99
|
enabled: false
|
|
100
100
|
replicas: 2
|
|
101
101
|
port: 3001
|