@ultimat3/cli 22.5.1 → 22.6.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/CLAUDE.md CHANGED
@@ -79,8 +79,9 @@ Commands: `bun test packages/cli` (from the repo root — the test preload lives
79
79
  | `runtime-jobs.ts` / `runtime-realtime.ts` / `runtime-notify-retention.ts` / `runtime-cache.ts` / `runtime-purge.ts` / `runtime-replica.ts` | `app.config.ts` sections the boot obeys, and what each wires |
80
80
  | `role-start.ts` / `role-start-types.ts` / `role-realtime.ts` | `--role` selection and start/stop for `web`, `sync`, `worker`, `scheduler`; `realtime.enabled: false` drops `sync` and `replicator` |
81
81
  | `role-sync.ts` / `role-replicator.ts` / `runtime-live-feed.ts` | the sync node, the change feed |
82
- | `runtime-render.ts` / `runtime-assets.ts` / `runtime-storage.ts` / `runtime-hooks.ts` / `api-routes.ts` | the HTTP surface: pages, `/icons` + `/media`, `/_storage`, authz, the app's API |
83
- | `runtime-overrides.ts` | the one field a host hands the framework a driver through |
82
+ | `runtime-render.ts` / `runtime-assets.ts` / `runtime-storage.ts` / `runtime-hooks.ts` / `api-routes.ts` | the HTTP surface: pages, `/icons` + `/media`, `/_storage`, authz, the app's API — plus its bearer mounts (`apiMountRoutes`) and pages' bound `POST`s (`pagePostRoutes`), mounted by BOTH boots |
83
+ | `runtime-overrides.ts` | the one field a host hands the framework a driver through — and `routes`, the plain routes for a wire format no primitive speaks (OAuth token endpoint) |
84
+ | `app-openapi.ts` | `openapi.json` (complete when `defineApi({ openapi })` is declared) and each bearer mount's own document; staleness for all of them |
84
85
  | `script-csp.ts` / `style-csp.ts` / `style-bundle.ts` / `page-sync.ts` / `worker-bundle.ts` | CSP hashes, the CSS file, the page's sync target and worker |
85
86
  | `island-bundle.ts` / `island-store.ts` / `island-realtime.ts` / `solid-loader.ts` | islands: one `Bun.build` each, source-addressed; `x build --target docker` writes a verified store the container loads |
86
87
  | `dev-*.ts` | `x dev` only: dashboard sources, traces, the N+1 ledger, the watcher, the reload, the lock, the port |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "22.5.1",
3
+ "version": "22.6.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.5.1",
42
- "@ultimat3/admin": "22.5.1",
43
- "@ultimat3/ai": "22.5.1",
44
- "@ultimat3/auth": "22.5.1",
45
- "@ultimat3/cache": "22.5.1",
46
- "@ultimat3/core": "22.5.1",
47
- "@ultimat3/db": "22.5.1",
48
- "@ultimat3/entity": "22.5.1",
49
- "@ultimat3/flags": "22.5.1",
50
- "@ultimat3/http": "22.5.1",
51
- "@ultimat3/i18n": "22.5.1",
52
- "@ultimat3/jobs": "22.5.1",
53
- "@ultimat3/mail": "22.5.1",
54
- "@ultimat3/manifest": "22.5.1",
55
- "@ultimat3/mcp": "22.5.1",
56
- "@ultimat3/money": "22.5.1",
57
- "@ultimat3/notify": "22.5.1",
58
- "@ultimat3/policy": "22.5.1",
59
- "@ultimat3/pwa": "22.5.1",
60
- "@ultimat3/query": "22.5.1",
61
- "@ultimat3/realtime": "22.5.1",
62
- "@ultimat3/render": "22.5.1",
63
- "@ultimat3/schema": "22.5.1",
64
- "@ultimat3/seo": "22.5.1",
65
- "@ultimat3/storage": "22.5.1",
66
- "@ultimat3/testing": "22.5.1",
67
- "@ultimat3/time": "22.5.1",
41
+ "@ultimat3/action": "22.6.0",
42
+ "@ultimat3/admin": "22.6.0",
43
+ "@ultimat3/ai": "22.6.0",
44
+ "@ultimat3/auth": "22.6.0",
45
+ "@ultimat3/cache": "22.6.0",
46
+ "@ultimat3/core": "22.6.0",
47
+ "@ultimat3/db": "22.6.0",
48
+ "@ultimat3/entity": "22.6.0",
49
+ "@ultimat3/flags": "22.6.0",
50
+ "@ultimat3/http": "22.6.0",
51
+ "@ultimat3/i18n": "22.6.0",
52
+ "@ultimat3/jobs": "22.6.0",
53
+ "@ultimat3/mail": "22.6.0",
54
+ "@ultimat3/manifest": "22.6.0",
55
+ "@ultimat3/mcp": "22.6.0",
56
+ "@ultimat3/money": "22.6.0",
57
+ "@ultimat3/notify": "22.6.0",
58
+ "@ultimat3/policy": "22.6.0",
59
+ "@ultimat3/pwa": "22.6.0",
60
+ "@ultimat3/query": "22.6.0",
61
+ "@ultimat3/realtime": "22.6.0",
62
+ "@ultimat3/render": "22.6.0",
63
+ "@ultimat3/schema": "22.6.0",
64
+ "@ultimat3/seo": "22.6.0",
65
+ "@ultimat3/storage": "22.6.0",
66
+ "@ultimat3/testing": "22.6.0",
67
+ "@ultimat3/time": "22.6.0",
68
68
  "babel-preset-solid": "^1.9.15"
69
69
  }
70
70
  }
package/src/api-routes.ts CHANGED
@@ -2,10 +2,16 @@
2
2
  // half `@ultimat3/query` projects. `x dev` and `serve.ts` both mount THIS rather than each listing
3
3
  // the registries themselves — two lists is how `query.client()` shipped deriving `/_x/query/<kebab>`
4
4
  // against a route neither file mounted, compiling everywhere and 404ing everywhere.
5
+ //
6
+ // And the two contributions that are the SAME primitives reached another way, composed here for
7
+ // the same reason: the bearer mounts `defineApi({ http: { mounts } })` declares (`/v1/*`), and the
8
+ // `POST` a page binds to an action (`defineRoute({ post })`).
5
9
 
6
- import { listActions, toRoute } from '@ultimat3/action';
7
- import type { Route } from '@ultimat3/http';
10
+ import { apiDeclaration, getAction, listActions, toPostBinding, toRoute } from '@ultimat3/action';
11
+ import type { RateLimitStore, Route } from '@ultimat3/http';
12
+ import { bearerMount } from '@ultimat3/http';
8
13
  import { listQueries, toQueryRoute } from '@ultimat3/query';
14
+ import { RoutePostInvalidError, routeEntries } from '@ultimat3/render';
9
15
 
10
16
  /**
11
17
  * Whatever loading the app's modules put in the two registries, as routes. Read at call time,
@@ -14,3 +20,47 @@ import { listQueries, toQueryRoute } from '@ultimat3/query';
14
20
  export function apiRoutes(): readonly Route[] {
15
21
  return [...listActions().map(toRoute), ...listQueries().map(toQueryRoute)];
16
22
  }
23
+
24
+ /**
25
+ * Every bearer mount the app declared, over the SAME projected routes `apiRoutes()` serves — so
26
+ * `/v1/create-case` runs `/api/create-case`'s handler, policy and idempotency, and only the
27
+ * credential, the cut and the per-token allowance differ. `store` is the boot's rate-limit store,
28
+ * so a fleet counts one token's allowance once.
29
+ */
30
+ export function apiMountRoutes(store?: RateLimitStore): readonly Route[] {
31
+ const mounts = apiDeclaration().http?.mounts ?? [];
32
+ if (mounts.length === 0) return [];
33
+ const routes = apiRoutes();
34
+ return mounts.flatMap((mount) =>
35
+ bearerMount({
36
+ prefix: mount.prefix,
37
+ routes,
38
+ scopes: mount.scopes,
39
+ resolveToken: mount.resolveToken,
40
+ ...(mount.rateLimit === undefined ? {} : { rateLimit: mount.rateLimit }),
41
+ ...(store === undefined ? {} : { rateLimitStore: store }),
42
+ }),
43
+ );
44
+ }
45
+
46
+ /**
47
+ * `POST <page path>` for every page declaring `defineRoute({ post: '<action>' })`, answered by that
48
+ * action with the URL's query merged over the posted fields. A name no action is registered under
49
+ * is refused at boot (`X_ROUTE_POST_INVALID`) — a page promising a POST it cannot answer is the
50
+ * one-click unsubscribe that silently never unsubscribes.
51
+ */
52
+ export function pagePostRoutes(): readonly Route[] {
53
+ const routes: Route[] = [];
54
+ for (const entry of routeEntries()) {
55
+ const name = entry.config.post;
56
+ if (name === undefined) continue;
57
+ const target = getAction(name);
58
+ if (target === undefined) {
59
+ throw new RoutePostInvalidError(
60
+ `${entry.file} declares post: '${name}', and no action is registered under that name`,
61
+ );
62
+ }
63
+ routes.push(toPostBinding(target, entry.path));
64
+ }
65
+ return routes;
66
+ }
@@ -6,7 +6,7 @@ import { join } from 'node:path'; // why: Bun ships no path-join primitive.
6
6
  import type { Manifest } from '@ultimat3/manifest';
7
7
  import { MANIFEST_FILENAME } from '@ultimat3/manifest';
8
8
  import { writeAppManifest } from './app-manifest';
9
- import { OPENAPI_FILE, openApiJson } from './app-openapi';
9
+ import { openApiArtifacts } from './app-openapi';
10
10
 
11
11
  export interface ArtifactOptions {
12
12
  /** `x manifest --no-openapi` writes the manifest alone. */
@@ -31,9 +31,11 @@ export async function writeAppArtifacts(
31
31
  await writeAppManifest(root, manifest);
32
32
  written.push(MANIFEST_FILENAME);
33
33
  }
34
- if (options.openapi && (await present(OPENAPI_FILE))) {
35
- await Bun.write(join(root, OPENAPI_FILE), openApiJson(manifest));
36
- written.push(OPENAPI_FILE);
34
+ // `openapi.json`, then each bearer mount's own document (`openapi.v1.json`), from one projection.
35
+ for (const artifact of options.openapi ? openApiArtifacts(manifest) : []) {
36
+ if (!(await present(artifact.file))) continue;
37
+ await Bun.write(join(root, artifact.file), artifact.text);
38
+ written.push(artifact.file);
37
39
  }
38
40
  return written;
39
41
  }
package/src/app-mcp.ts CHANGED
@@ -11,6 +11,7 @@ import { existsSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
  import { logger } from '@ultimat3/core';
13
13
  import type { Route } from '@ultimat3/http';
14
+ import { json, selfOrigin } from '@ultimat3/http';
14
15
  import { type AppMcp, McpAppUnmountedError } from '@ultimat3/mcp';
15
16
  import { APP_CONFIG_EXPORT } from './app-auth';
16
17
  import { APP_CONFIG_FILE } from './app-root';
@@ -113,14 +114,37 @@ export async function appMcpMount(root: string): Promise<AppMcpMount> {
113
114
  warning: new McpAppUnmountedError({ reason: 'no-route', path: declared.path, file }),
114
115
  };
115
116
  }
117
+ // The PUBLIC origin, as the pipeline resolved it (`ctx.https` honours a trusted proxy's
118
+ // `x-forwarded-proto`): behind a TLS-terminating ingress the raw request URL is `http://` on
119
+ // an internal host, and a `resource_metadata` naming that is a URL no client can reach.
120
+ const resource = route.protectedResource;
116
121
  return {
117
122
  routes: [
118
123
  {
119
124
  method: 'POST',
120
125
  path: declared.path,
121
- handler: (request) => route.handle(request.raw),
126
+ handler: (request, ctx) =>
127
+ route.handle(request.raw, { origin: selfOrigin(ctx.url, ctx.https) }),
122
128
  meta: { name: APP_MCP_ROUTE_NAME, auth: 'public', enforcedBy: 'handler' },
123
129
  },
130
+ // RFC 9728 protected-resource metadata, when the app declared `oauth`: path-inserted and
131
+ // at the root, so a client probing either finds the authorization server.
132
+ ...(resource === undefined
133
+ ? []
134
+ : resource.paths.map(
135
+ (path): Route => ({
136
+ method: 'GET',
137
+ path,
138
+ handler: (_request, ctx) => {
139
+ const response = json(resource.document(selfOrigin(ctx.url, ctx.https)));
140
+ // A browser-hosted MCP client reads this cross-origin; it holds nothing secret.
141
+ response.headers.set('access-control-allow-origin', '*');
142
+ response.headers.set('cache-control', 'public, max-age=3600');
143
+ return response;
144
+ },
145
+ meta: { name: `${APP_MCP_ROUTE_NAME}.oauth-protected-resource`, auth: 'public' },
146
+ }),
147
+ )),
124
148
  ],
125
149
  path: declared.path,
126
150
  warning: undefined,
@@ -7,41 +7,106 @@
7
7
 
8
8
  // why: Bun ships no path-join primitive.
9
9
  import { join } from 'node:path';
10
- import { buildOpenApi, serializeOpenApi } from '@ultimat3/action';
10
+ import type { OpenApiDocument } from '@ultimat3/action';
11
+ import {
12
+ apiDeclaration,
13
+ buildOpenApi,
14
+ completeOpenApi,
15
+ mountOpenApi,
16
+ serializeOpenApi,
17
+ } from '@ultimat3/action';
11
18
  import { ERROR_DOCS_URL } from '@ultimat3/core';
19
+ import { bearerMount } from '@ultimat3/http';
12
20
  import type { Manifest } from '@ultimat3/manifest';
13
21
  import { queryOpenApiPaths } from '@ultimat3/query';
22
+ import { apiRoutes } from './api-routes';
14
23
  import type { Finding } from './output';
15
24
 
16
25
  export const OPENAPI_FILE = 'openapi.json';
17
26
 
18
- /** The exact bytes on disk — deterministic, so `x verify` can compare them literally. */
19
- export const openApiJson = (manifest: Manifest): string => {
27
+ /** One generated document: its app-root file and its exact bytes. */
28
+ export interface OpenApiArtifact {
29
+ readonly file: string;
30
+ readonly text: string;
31
+ }
32
+
33
+ /** Both registries in one document — the shape every app had before it declared `openapi`. */
34
+ function mergedDocument(manifest: Manifest): OpenApiDocument {
20
35
  const document = buildOpenApi({ title: manifest.app.name, version: manifest.app.version });
21
36
  // Action paths are `/api/...`, query paths `/_x/query/...`: disjoint by prefix, so the spread
22
37
  // can never shadow one with the other. `serializeOpenApi` sorts the merged keys.
23
- return serializeOpenApi({ ...document, paths: { ...document.paths, ...queryOpenApiPaths() } });
38
+ return { ...document, paths: { ...document.paths, ...queryOpenApiPaths() } };
39
+ }
40
+
41
+ /**
42
+ * The exact bytes on disk — deterministic, so `x verify` can compare them literally. An app that
43
+ * declared `defineApi({ openapi })` gets the COMPLETE document (its info and servers, security
44
+ * schemes, 401/429 on authenticated operations); one that did not keeps the bytes it had.
45
+ */
46
+ export const openApiJson = (manifest: Manifest): string => {
47
+ const merged = mergedDocument(manifest);
48
+ const declared = apiDeclaration();
49
+ if (declared.openapi === undefined) return serializeOpenApi(merged);
50
+ return serializeOpenApi(
51
+ completeOpenApi(merged, {
52
+ declared: declared.openapi,
53
+ routes: apiRoutes(),
54
+ bearer: (declared.http?.mounts ?? []).length > 0,
55
+ }),
56
+ );
57
+ };
58
+
59
+ /**
60
+ * Every document the app maintains: `openapi.json`, then one per bearer mount that names a file
61
+ * (`mounts: [{ prefix: '/v1', openapi: 'openapi.v1.json', … }]`) — only the cut, at the mounted
62
+ * paths. The mount is built once here as the boot builds it, so a scope naming a primitive nothing
63
+ * registered is refused by `x manifest` as it would be by the server (`X_BEARER_MOUNT_INVALID`).
64
+ */
65
+ export const openApiArtifacts = (manifest: Manifest): readonly OpenApiArtifact[] => {
66
+ const declared = apiDeclaration();
67
+ const mounts = (declared.http?.mounts ?? []).filter((mount) => mount.openapi !== undefined);
68
+ const artifacts: OpenApiArtifact[] = [{ file: OPENAPI_FILE, text: openApiJson(manifest) }];
69
+ if (mounts.length === 0) return artifacts;
70
+ const merged = mergedDocument(manifest);
71
+ const routes = apiRoutes();
72
+ for (const mount of mounts) {
73
+ bearerMount({ prefix: mount.prefix, routes, scopes: mount.scopes, resolveToken: () => null });
74
+ artifacts.push({
75
+ file: mount.openapi ?? OPENAPI_FILE,
76
+ text: serializeOpenApi(
77
+ mountOpenApi(merged, { declared: declared.openapi ?? {}, mount, routes }),
78
+ ),
79
+ });
80
+ }
81
+ return artifacts;
24
82
  };
25
83
 
26
84
  /**
27
85
  * The typed client is generated from `openapi.json`, so a stale spec ships a wrong client. One
28
86
  * check, read by `x verify`'s `manifest` step AND `x manifest --check` — which looked at
29
- * `x.manifest.json` alone and answered "fresh" over a spec the gate refused.
87
+ * `x.manifest.json` alone and answered "fresh" over a spec the gate refused. A mount's document
88
+ * is checked the same way, and — because the app ASKED for it by naming the file — is stale when
89
+ * it is missing, where an absent `openapi.json` is an app that does not maintain one.
30
90
  */
31
91
  export async function openApiStaleness(
32
92
  root: string,
33
93
  manifest: Manifest,
34
94
  ): Promise<readonly Finding[]> {
35
- const path = join(root, OPENAPI_FILE);
36
- if (!(await Bun.file(path).exists())) return [];
37
- if ((await Bun.file(path).text()) === openApiJson(manifest)) return [];
38
- return [
39
- {
95
+ const findings: Finding[] = [];
96
+ for (const artifact of openApiArtifacts(manifest)) {
97
+ const path = join(root, artifact.file);
98
+ const exists = await Bun.file(path).exists();
99
+ if (!exists && artifact.file === OPENAPI_FILE) continue;
100
+ if (exists && (await Bun.file(path).text()) === artifact.text) continue;
101
+ findings.push({
40
102
  code: 'X_MANIFEST_STALE',
41
- cause: `${OPENAPI_FILE} does not match the actions the code registers`,
103
+ cause: exists
104
+ ? `${artifact.file} does not match the actions the code registers`
105
+ : `${artifact.file} is declared by a bearer mount and has not been written`,
42
106
  fix: 'x manifest',
43
107
  docs: ERROR_DOCS_URL,
44
- at: OPENAPI_FILE,
45
- },
46
- ];
108
+ at: artifact.file,
109
+ });
110
+ }
111
+ return findings;
47
112
  }
package/src/cmd-dev.ts CHANGED
@@ -232,6 +232,8 @@ async function bootDev(
232
232
  dashboard,
233
233
  islands: () => state.islands,
234
234
  realtime: runtime.realtime,
235
+ rateLimitStore: appRuntime?.rateLimitStore ?? runtime.rateLimitStore,
236
+ appRoutes: appRuntime?.routes,
235
237
  });
236
238
 
237
239
  // The app's `apps/<app>/runtime.ts`, composed exactly as `runRole` composes a caller's
@@ -3,10 +3,10 @@
3
3
  // 500-line ceiling; `serve.ts` composes the production table from the same builders.
4
4
 
5
5
  import type { RealtimeConfig } from '@ultimat3/core';
6
- import type { Route } from '@ultimat3/http';
6
+ import type { RateLimitStore, Route } from '@ultimat3/http';
7
7
  import { describeRoutes } from '@ultimat3/render';
8
8
  import type { Storage } from '@ultimat3/storage';
9
- import { apiRoutes } from './api-routes';
9
+ import { apiMountRoutes, apiRoutes, pagePostRoutes } from './api-routes';
10
10
  import { mountAppMcp } from './app-mcp';
11
11
  import type { DevDashboardInput } from './dev-dashboard';
12
12
  import { devDashboardRoutes } from './dev-dashboard';
@@ -39,6 +39,10 @@ export interface DevRouteTableInput {
39
39
  readonly islands: () => IslandBundle;
40
40
  /** `app.config.ts`'s `realtime`, as the boot obeyed it: off, no document names a sync node. */
41
41
  readonly realtime: Pick<RealtimeConfig, 'enabled'>;
42
+ /** The web role's limiter store — what a bearer mount counts per-token allowances on. */
43
+ readonly rateLimitStore?: RateLimitStore | undefined;
44
+ /** `apps/<app>/runtime.ts`'s plain `routes`, mounted as the container mounts them. */
45
+ readonly appRoutes?: readonly Route[] | undefined;
42
46
  }
43
47
 
44
48
  export interface DevRouteTable {
@@ -87,7 +91,9 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
87
91
  // The same API table the container serves: a read that answers here and 404s in production
88
92
  // is exactly the drift one composition exists to prevent.
89
93
  ...apiRoutes(),
94
+ ...apiMountRoutes(input.rateLimitStore),
90
95
  ...mcpMount.routes,
96
+ ...(input.appRoutes ?? []),
91
97
  // The image pipeline's only HTTP surface: the icons the web manifest declares, and the
92
98
  // variants every `srcset` promises. Mounted before the app's own routes so a page route can
93
99
  // never shadow `/icons` or `/media`.
@@ -117,6 +123,7 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
117
123
  }),
118
124
  ...(serviceWorker === undefined ? [] : serviceWorkerRoutes(serviceWorker)),
119
125
  ...sync.routes,
126
+ ...pagePostRoutes(),
120
127
  ...appRoutes({
121
128
  buildId: input.buildId,
122
129
  resolveIsland: (file) => input.islands().resolverFor(file),
package/src/role-start.ts CHANGED
@@ -43,6 +43,7 @@ import { pgExecutorFor } from './runtime-queue';
43
43
  import type { RunningServices } from './runtime-services';
44
44
  import { inlineScriptSources } from './script-csp';
45
45
  import { inlineStyleSources } from './style-csp';
46
+ import { syncConnectSources } from './sync-url';
46
47
  import { DEV_BINDING } from './web-binding';
47
48
 
48
49
  // Declared beside the option types so `x dev`'s spec reads it without loading this module.
@@ -248,6 +249,9 @@ function startWeb(options: StartRolesOptions, mount?: WebSocketMount<SyncWs>): S
248
249
  extend: {
249
250
  'style-src': inlineStyleSources(options.inlineStyles ?? []),
250
251
  'script-src': inlineScriptSources(options.inlineScripts ?? []),
252
+ // A sync node on ANOTHER origin (`SYNC_URL`), admitted by that origin alone — the
253
+ // baseline's `'self'` is every same-origin rung.
254
+ 'connect-src': syncConnectSources(options.env),
251
255
  },
252
256
  },
253
257
  },
@@ -5,7 +5,7 @@
5
5
  // as the first arm of `overrides?.x ?? <the env-selected default>`.
6
6
 
7
7
  import type { PurgeDriver } from '@ultimat3/cache';
8
- import type { Middleware, RateLimitStore } from '@ultimat3/http';
8
+ import type { Middleware, RateLimitStore, Route } from '@ultimat3/http';
9
9
  import type { JobDriver } from '@ultimat3/jobs';
10
10
  import type { MailDriver } from '@ultimat3/mail';
11
11
  import type { SyncAuthenticator, Transport } from '@ultimat3/realtime/server';
@@ -60,6 +60,15 @@ export interface RuntimeOverrides {
60
60
  readonly isrStore?: IsrStore;
61
61
  /** Prepended to the pipeline by `createServer`, which `startRoles` never passed one. */
62
62
  readonly middleware?: readonly Middleware[];
63
+ /**
64
+ * Plain HTTP routes at paths no primitive projects to — `/.well-known/oauth-authorization-server`,
65
+ * a form-encoded `POST /oauth/token` answering RFC 6749 JSON, a feed with its own content type.
66
+ * Mounted by both boots, after the framework's own and before the pages, through the same
67
+ * pipeline (`meta.auth` required, CSRF, limits, security headers). The one escape hatch for a
68
+ * wire format a primitive cannot speak: an action answers JSON or a problem document, a page
69
+ * answers a document, and a protocol that fixes its own error shape is neither.
70
+ */
71
+ readonly routes?: readonly Route[];
63
72
  /** The `/media/*` transform. Omitted, `builtinImageDriver` — core's PNG/JPEG pipeline. */
64
73
  readonly images?: ImageTransformDriver;
65
74
  /**
package/src/serve-boot.ts CHANGED
@@ -8,7 +8,7 @@ import { configureLifecycle } from '@ultimat3/core';
8
8
  import type { Route } from '@ultimat3/http';
9
9
  import { describeRoutes } from '@ultimat3/render';
10
10
  import { createIsrController } from '@ultimat3/render/server';
11
- import { apiRoutes } from './api-routes';
11
+ import { apiMountRoutes, apiRoutes, pagePostRoutes } from './api-routes';
12
12
  import { loadSignInPath } from './app-auth';
13
13
  import { loadApp } from './app-load';
14
14
  import { appManifest } from './app-manifest';
@@ -169,7 +169,12 @@ async function webSurface(
169
169
  const served = servedStorage(runtime.storage);
170
170
  const routes: readonly Route[] = [
171
171
  ...apiRoutes(),
172
+ // The bearer doors onto a cut of the same routes (`defineApi({ http: { mounts } })`), counted
173
+ // on the store this boot's limiter uses, so one token's allowance is one number fleet-wide.
174
+ ...apiMountRoutes(options.runtime?.rateLimitStore ?? runtime.rateLimitStore),
172
175
  ...mcpMount.routes,
176
+ // The app's own plain routes (`apps/<app>/runtime.ts` `routes`), before any page can shadow one.
177
+ ...(options.runtime?.routes ?? []),
173
178
  ...(serviceWorker === undefined ? [] : serviceWorkerRoutes(serviceWorker)),
174
179
  ...assetRoutes({
175
180
  root: options.root,
@@ -186,6 +191,8 @@ async function webSurface(
186
191
  ...seoRoutes({ env: options.env, site }),
187
192
  // The page's one socket: its worker script, served beside the islands for their reason.
188
193
  ...sync.routes,
194
+ // A page's `POST`, bound to an action (`defineRoute({ post })`) — beside the page's `GET`.
195
+ ...pagePostRoutes(),
189
196
  ...appRoutes({
190
197
  buildId,
191
198
  resolveIsland: (file) => islands.resolverFor(file),
package/src/sync-url.ts CHANGED
@@ -31,6 +31,17 @@ export function syncUrlFrom(env: Readonly<Record<string, string | undefined>>):
31
31
  return declared;
32
32
  }
33
33
 
34
+ /**
35
+ * What the page's CSP `connect-src` must add for the socket: nothing when the node is on the page
36
+ * origin (`'self'` covers ws/wss there), else `SYNC_URL`'s ORIGIN exactly — never a bare `wss:`
37
+ * scheme, which would admit a socket to any host (`@ultimat3/http`'s baseline dropped it).
38
+ */
39
+ export function syncConnectSources(env: Readonly<Record<string, string | undefined>>): string[] {
40
+ const url = syncUrlFrom(env);
41
+ if (url === SYNC_PATH) return [];
42
+ return [new URL(url).origin];
43
+ }
44
+
34
45
  /**
35
46
  * The page origins a sync node admits besides its own host name: `APP_URL`'s, when set. Only a
36
47
  * deployment that serves the page on another host than the node needs it; an unparsable value is