@ultimat3/cli 23.0.0 → 25.0.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 +28 -13
- package/README.md +32 -7
- package/package.json +29 -29
- package/src/admin-catalog-keys.ts +2 -2
- package/src/affected.ts +30 -36
- package/src/api-registration.ts +89 -0
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-boundaries.ts +17 -4
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +26 -25
- package/src/app-load.ts +38 -14
- package/src/app-manifest.ts +4 -10
- package/src/app-mcp.ts +31 -46
- package/src/app-name.ts +52 -0
- package/src/app-permissions-borrowed.ts +123 -0
- package/src/app-permissions-site.ts +48 -0
- package/src/app-permissions.ts +7 -17
- package/src/app-root.ts +9 -5
- package/src/bin.ts +3 -7
- package/src/browser-launcher.ts +4 -2
- package/src/budgets-charged.ts +60 -0
- package/src/budgets-scan.ts +76 -0
- package/src/budgets-unmeasured.ts +91 -0
- package/src/budgets.ts +117 -154
- package/src/cdp-shot-driver.ts +20 -1
- package/src/cdp-shot-watch.ts +35 -31
- package/src/ci-log.ts +0 -0
- package/src/cmd-auth-spec.ts +16 -0
- package/src/cmd-auth.ts +65 -0
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +151 -22
- package/src/cmd-db-backfill.ts +26 -13
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db.ts +10 -6
- package/src/cmd-deploy-compose.ts +94 -0
- package/src/cmd-deploy-drain.ts +48 -0
- package/src/cmd-deploy-helm.ts +7 -6
- package/src/cmd-deploy-start-first.ts +178 -0
- package/src/cmd-deploy.ts +158 -36
- package/src/cmd-dev.ts +18 -356
- package/src/cmd-doctor-spec.ts +2 -9
- package/src/cmd-doctor.ts +57 -80
- package/src/cmd-env.ts +55 -16
- package/src/cmd-generate.ts +89 -32
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +21 -17
- package/src/cmd-jobs-spec.ts +7 -22
- package/src/cmd-jobs.ts +33 -140
- package/src/cmd-manifest-spec.ts +1 -1
- package/src/cmd-manifest.ts +6 -25
- package/src/cmd-mcp.ts +71 -22
- package/src/cmd-new.ts +36 -8
- package/src/cmd-planned.ts +24 -8
- package/src/cmd-routes.ts +15 -5
- package/src/cmd-secrets.ts +47 -6
- package/src/cmd-shot-matrix.ts +22 -7
- package/src/cmd-shot-spec.ts +10 -2
- package/src/cmd-shot.ts +46 -41
- package/src/cmd-tasks.ts +2 -2
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +14 -30
- package/src/cmd-verify.ts +4 -4
- package/src/coverage-lcov.ts +4 -2
- package/src/db-backfill.ts +2 -2
- package/src/db-replay-engine.ts +4 -10
- package/src/db-subscribes.ts +27 -16
- package/src/dev-boot.ts +290 -0
- package/src/dev-child-watch.ts +63 -12
- package/src/dev-dashboard-guard.ts +76 -0
- package/src/dev-dashboard.ts +56 -30
- package/src/dev-lock.ts +100 -31
- package/src/dev-rebuild.ts +80 -0
- package/src/dev-restart-retire.ts +46 -0
- package/src/dev-route-table.ts +30 -8
- package/src/dev-supervisor.ts +114 -22
- package/src/dev-traces.ts +53 -20
- package/src/dispatch.ts +17 -13
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -0
- package/src/doctor-offline.ts +6 -5
- package/src/document-options.ts +7 -0
- package/src/drift-replica-identity.ts +42 -0
- package/src/drift.ts +29 -6
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +29 -3
- package/src/error-page-csp.ts +14 -15
- package/src/error-unthrown.ts +29 -11
- package/src/errors.ts +54 -83
- package/src/exec.ts +8 -0
- package/src/favicon.ts +2 -2
- package/src/fix-command.ts +9 -1
- package/src/framework-env.ts +148 -0
- package/src/framework-schema-apply.ts +78 -0
- package/src/framework-schema-stamp.ts +96 -0
- package/src/framework-schema.ts +25 -3
- package/src/generate-catalog-locales.ts +79 -0
- package/src/generate-files.ts +4 -3
- package/src/generate-kinds.ts +18 -0
- package/src/generate-plural.ts +50 -0
- package/src/generate-shadow.ts +95 -0
- package/src/generate-write.ts +47 -13
- package/src/gh-target.ts +12 -7
- package/src/hold.ts +56 -1
- package/src/i18n-audit.ts +25 -9
- package/src/i18n-index.ts +5 -6
- package/src/i18n-registration.ts +7 -9
- package/src/import-scan.ts +28 -3
- package/src/index.ts +2 -6
- package/src/island-bundle.ts +57 -79
- package/src/island-capture.ts +1 -1
- package/src/island-duplicate-refusal.ts +57 -0
- package/src/island-duplicates.ts +221 -0
- package/src/island-entry-missing.ts +64 -0
- package/src/island-harness-route.ts +20 -4
- package/src/island-harness-script.ts +23 -2
- package/src/island-identity.ts +17 -14
- package/src/island-package-dedupe.ts +150 -0
- package/src/island-realtime.ts +36 -16
- package/src/island-routes.ts +2 -2
- package/src/island-runtime.ts +241 -0
- package/src/island-sources.ts +105 -0
- package/src/island-states-load.ts +6 -0
- package/src/island-store.ts +90 -6
- package/src/jobs-json.ts +0 -26
- package/src/jobs-report.ts +36 -20
- package/src/local-cli-handoff.ts +90 -0
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +20 -4
- package/src/mcp-host.ts +22 -14
- package/src/mcp-test-run.ts +38 -0
- package/src/mcp-ui-inspect.ts +3 -2
- package/src/mcp-ui-interact.ts +3 -2
- package/src/mcp-ui.ts +8 -7
- package/src/measure-scope.ts +15 -10
- package/src/messages.ts +29 -16
- package/src/metrics-endpoint.ts +28 -3
- package/src/migration-extensions.ts +1 -1
- package/src/otlp-export.ts +2 -2
- package/src/output.ts +24 -0
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +19 -31
- package/src/parse.ts +0 -8
- package/src/permission-grants.ts +3 -3
- package/src/posix-path.ts +30 -0
- package/src/prerender-locales.ts +6 -4
- package/src/prerender-out.ts +34 -7
- package/src/prerender-report.ts +58 -0
- package/src/prerender-served.ts +52 -0
- package/src/prerender.ts +71 -103
- package/src/pwa-artifacts.ts +33 -94
- package/src/pwa-manifest-locales.ts +9 -9
- package/src/registry.ts +2 -0
- package/src/retry-memo.ts +1 -1
- package/src/revalidated-response.ts +4 -3
- package/src/role-realtime.ts +7 -1
- package/src/role-replicator.ts +124 -29
- package/src/role-start.ts +69 -85
- package/src/role-sync.ts +119 -25
- package/src/root-env.ts +157 -28
- package/src/route-islands.ts +69 -0
- package/src/runtime-admin.ts +4 -1
- package/src/runtime-assets.ts +26 -9
- package/src/runtime-bindings.ts +27 -7
- package/src/runtime-cache.ts +46 -55
- package/src/runtime-hooks.ts +2 -1
- package/src/runtime-idempotency-scope.ts +23 -0
- package/src/runtime-isr-outcome.ts +100 -0
- package/src/runtime-isr.ts +36 -0
- package/src/runtime-jobs.ts +76 -41
- package/src/runtime-live-feed.ts +1 -1
- package/src/runtime-mail.ts +26 -0
- package/src/runtime-mfa-warning.ts +29 -0
- package/src/runtime-notify-retention.ts +10 -46
- package/src/runtime-overrides.ts +4 -4
- package/src/runtime-purge.ts +38 -16
- package/src/runtime-queue.ts +43 -22
- package/src/runtime-realtime.ts +11 -45
- package/src/runtime-render.ts +136 -114
- package/src/runtime-replica.ts +3 -8
- package/src/runtime-services.ts +49 -26
- package/src/runtime-storage-upload.ts +2 -2
- package/src/runtime-storage.ts +13 -8
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/secrets-rotation.ts +8 -9
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +51 -12
- package/src/serve-drain.ts +11 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-retire.ts +95 -0
- package/src/serve-web.ts +31 -15
- package/src/serve.ts +27 -10
- package/src/server-barrels.ts +6 -3
- package/src/shot-browser.ts +1 -1
- package/src/shot-locale.ts +6 -33
- package/src/shot-settle.ts +3 -3
- package/src/shot-theme.ts +1 -1
- package/src/shot-viewport.ts +152 -0
- package/src/signal-shred.ts +26 -12
- package/src/site-asset-routes.ts +2 -2
- package/src/site-assets.ts +3 -5
- package/src/site-config.ts +9 -48
- package/src/site-seo.ts +7 -5
- package/src/sitemap-lastmod.ts +5 -0
- package/src/solid-loader.ts +12 -3
- package/src/source-files.ts +19 -3
- package/src/static-report.ts +5 -0
- package/src/stored-object-headers.ts +43 -0
- package/src/style-routes.ts +2 -2
- package/src/sw-artifacts.ts +8 -8
- package/src/sw-precache-plan.ts +34 -4
- package/src/sync-authenticator.ts +3 -3
- package/src/sync-url.ts +3 -3
- package/src/tasks-facts.ts +3 -29
- package/src/templates/action.ts +7 -4
- package/src/templates/backfill.ts +4 -4
- package/src/templates/entity.ts +2 -2
- package/src/templates/github/ci.yml.ts +28 -18
- package/src/templates/github/image.yml.ts +92 -0
- package/src/templates/github/runner.ts +18 -0
- package/src/templates/guard-bare-error.ts +16 -2
- package/src/templates/guard-unzoned-date.ts +56 -6
- package/src/templates/index.ts +1 -1
- package/src/templates/job-body-test.ts +2 -2
- package/src/templates/job.ts +6 -6
- package/src/templates/naming.ts +28 -0
- package/src/templates/query.ts +9 -8
- package/src/templates/resource-page.ts +9 -7
- package/src/templates/resource-service.ts +2 -2
- package/src/templates/route.ts +26 -14
- package/src/templates/scaffold-api.ts +2 -3
- package/src/templates/scaffold-app.ts +8 -3
- package/src/templates/scaffold-auth.ts +205 -70
- package/src/templates/scaffold-bin.ts +122 -0
- package/src/templates/scaffold-claude-commands.ts +11 -11
- package/src/templates/scaffold-container-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +16 -8
- package/src/templates/scaffold-dashboard-bare.ts +8 -4
- package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
- package/src/templates/scaffold-dashboard-example.ts +112 -172
- package/src/templates/scaffold-dashboard-shared.ts +62 -1
- package/src/templates/scaffold-dashboard.ts +9 -5
- package/src/templates/scaffold-db-client.ts +4 -2
- package/src/templates/scaffold-db-package.ts +2 -2
- package/src/templates/scaffold-docs.ts +20 -77
- package/src/templates/scaffold-domain-package.ts +0 -4
- package/src/templates/scaffold-entries.ts +4 -3
- package/src/templates/scaffold-env.ts +6 -3
- package/src/templates/scaffold-gitattributes.ts +59 -0
- package/src/templates/scaffold-helm-fences.ts +76 -0
- package/src/templates/scaffold-helm-templates.ts +100 -19
- package/src/templates/scaffold-helm.ts +62 -5
- package/src/templates/scaffold-http.ts +1 -1
- package/src/templates/scaffold-i18n.ts +15 -2
- package/src/templates/scaffold-icon.ts +2 -2
- package/src/templates/scaffold-repo.ts +27 -16
- package/src/templates/scaffold-shell.ts +55 -37
- package/src/test-counts.ts +2 -6
- package/src/test-ignores.ts +25 -0
- package/src/test-only-shipped.ts +193 -0
- package/src/test-passes.ts +0 -15
- package/src/test-select.ts +7 -15
- package/src/test-shards.ts +40 -95
- package/src/test-workers.ts +26 -4
- package/src/theme-boot.ts +8 -33
- package/src/theme-brand.ts +66 -0
- package/src/trusted-hops-error.ts +17 -0
- package/src/trusted-hops.ts +49 -0
- package/src/unscanned-admin.ts +2 -2
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +29 -48
- package/src/verify-coverage-run.ts +3 -2
- package/src/verify-deadline.ts +207 -33
- package/src/verify-merge.ts +108 -43
- package/src/verify-part-step.ts +98 -0
- package/src/verify-role-load-probe.ts +12 -2
- package/src/verify-role-load.ts +9 -4
- package/src/verify-run.ts +32 -8
- package/src/verify-shard.ts +13 -2
- package/src/verify-stalled.ts +152 -0
- package/src/verify-step.ts +2 -0
- package/src/verify-test-run.ts +3 -2
- package/src/verify-tests.ts +5 -3
- package/src/worker-bundle.ts +2 -2
- package/src/workspace-checks.ts +53 -7
- package/src/workspace-graph.ts +3 -2
- package/src/write-line.ts +82 -7
- package/src/browser-launcher-fake-html.ts +0 -80
- package/src/browser-launcher-fake.ts +0 -168
- package/src/build-errors.ts +0 -54
- package/src/jobs-drain.ts +0 -163
- package/src/scaffold-typecheck.ts +0 -227
- package/src/thrown-by.ts +0 -24
- package/src/verify-errors.ts +0 -58
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The one reader of an app's `app.config.ts`: imported here and nowhere else in the tree
|
|
2
|
+
// (`bun run boundaries`, `X_CONFIG_IMPORT_OUTSIDE_LOADER`), its `config` export handed back through
|
|
3
|
+
// core's own `defineConfig` — so every boot fact the CLI reads is the validated, default-merged
|
|
4
|
+
// `AppConfig`, never a structural walk with a fallback of its own.
|
|
5
|
+
|
|
6
|
+
// why: Bun exposes no path-join primitive, and the config path is app-root-relative.
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import type { AppConfig, AppConfigInput } from '@ultimat3/core';
|
|
9
|
+
import { ConfigInvalidError, defineConfig, describeValue } from '@ultimat3/core';
|
|
10
|
+
import { APP_CONFIG_FILE } from './app-root';
|
|
11
|
+
|
|
12
|
+
/** The export every app declares. Named, never default — the CLI and the runtime both import it. */
|
|
13
|
+
export const APP_CONFIG_EXPORT = 'config';
|
|
14
|
+
|
|
15
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
16
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One named export of `root`'s `app.config.ts`, or `undefined` when the root has no such file or
|
|
20
|
+
* the module does not export `name`. A module that will not import throws as it did — the callers
|
|
21
|
+
* that report rather than crash (`x doctor`'s env check) turn that into a finding.
|
|
22
|
+
*/
|
|
23
|
+
export async function appConfigExport(root: string, name: string): Promise<unknown> {
|
|
24
|
+
const path = join(root, APP_CONFIG_FILE);
|
|
25
|
+
if (!(await Bun.file(path).exists())) return undefined;
|
|
26
|
+
const module = (await import(path)) as Record<string, unknown>;
|
|
27
|
+
return Object.hasOwn(module, name) ? module[name] : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `root`'s `AppConfig`, or `undefined` when the root has no `app.config.ts` — not an app, and each
|
|
32
|
+
* caller's "nothing declared" answer is its own. A file that IS there is held to `defineConfig`'s
|
|
33
|
+
* one validator: a hand-built `config` object reaches the boot validated and defaulted exactly as
|
|
34
|
+
* one built by `defineConfig` does, which re-merges to itself. One resolved through a 24.x core is
|
|
35
|
+
* refused (its defaults wrote keys 25.0.0 removed): install every `@ultimat3/*` at one version. A file with no `config` object is refused rather than read as "every default".
|
|
36
|
+
*/
|
|
37
|
+
export async function loadAppConfig(root: string): Promise<AppConfig | undefined> {
|
|
38
|
+
const path = join(root, APP_CONFIG_FILE);
|
|
39
|
+
if (!(await Bun.file(path).exists())) return undefined;
|
|
40
|
+
const exported = await appConfigExport(root, APP_CONFIG_EXPORT);
|
|
41
|
+
if (!isRecord(exported)) {
|
|
42
|
+
throw new ConfigInvalidError({
|
|
43
|
+
cause: `${path} exports no ${APP_CONFIG_EXPORT} object (${describeValue(exported)})`,
|
|
44
|
+
fix: "export const config = defineConfig({ name: 'my-app' });",
|
|
45
|
+
meta: { key: APP_CONFIG_EXPORT },
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
// A cast over a record whose structure `defineConfig` screens first (`shapeIssues`, per layer):
|
|
49
|
+
// the validator IS the parse, and a second hand-written schema here would be a second answer.
|
|
50
|
+
return defineConfig(exported as unknown as AppConfigInput);
|
|
51
|
+
}
|
package/src/app-env.ts
CHANGED
|
@@ -2,18 +2,13 @@
|
|
|
2
2
|
// `app.config.ts`, the `.env.example` projected from it, and the drift between the two. One
|
|
3
3
|
// declaration, both files (axiom 2) — nothing here holds a second list of variable names.
|
|
4
4
|
|
|
5
|
-
// why: Bun ships no synchronous exists; `existsSync` answers whether this root is an app.
|
|
6
|
-
import { existsSync } from 'node:fs';
|
|
7
5
|
// why: Bun exposes no path API — the two files this module reads are joined to the app root.
|
|
8
6
|
import { join } from 'node:path';
|
|
9
7
|
import type { EnvSchema, EnvVarDecl } from '@ultimat3/core';
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
ENV_EXAMPLE_PATH,
|
|
13
|
-
ERROR_DOCS_URL,
|
|
14
|
-
renderEnvExample,
|
|
15
|
-
} from '@ultimat3/core';
|
|
8
|
+
import { ENV_EXAMPLE_PATH, ERROR_DOCS_URL, parseEnvKeys } from '@ultimat3/core';
|
|
9
|
+
import { appConfigExport } from './app-config-load';
|
|
16
10
|
import { APP_CONFIG_FILE } from './app-root';
|
|
11
|
+
import { appEnvExample, FRAMEWORK_SECRETS } from './framework-env';
|
|
17
12
|
import type { Finding } from './output';
|
|
18
13
|
import { findingFrom } from './output';
|
|
19
14
|
|
|
@@ -45,28 +40,26 @@ export const isEnvSchema = (value: unknown): value is EnvSchema =>
|
|
|
45
40
|
isRecord(value) && Object.values(value).every(isDecl);
|
|
46
41
|
|
|
47
42
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* that will not import throws, and the caller turns that into the finding.
|
|
43
|
+
* The declaration `app.config.ts` exports, through the one loader (`app-config-load.ts`).
|
|
44
|
+
* `undefined` means "this root declares no environment"; a config that will not import throws, and
|
|
45
|
+
* the caller turns that into the finding.
|
|
52
46
|
*/
|
|
53
47
|
export async function loadEnvSchema(root: string): Promise<EnvSchema | undefined> {
|
|
54
|
-
const
|
|
55
|
-
if (!existsSync(configPath)) return undefined;
|
|
56
|
-
const module = (await import(configPath)) as Record<string, unknown>;
|
|
57
|
-
const declared = module[ENV_SCHEMA_EXPORT];
|
|
58
|
-
if (declared === undefined) return undefined;
|
|
48
|
+
const declared = await appConfigExport(root, ENV_SCHEMA_EXPORT);
|
|
59
49
|
return isEnvSchema(declared) ? declared : undefined;
|
|
60
50
|
}
|
|
61
51
|
|
|
62
|
-
/**
|
|
63
|
-
|
|
52
|
+
/**
|
|
53
|
+
* The bytes `.env.example` must hold: the app's declaration, then the framework's deploy-required
|
|
54
|
+
* keys (`framework-env.ts`). Deterministic, so a rewrite that changes nothing diffs to nothing.
|
|
55
|
+
*/
|
|
56
|
+
export const envExampleFor = (schema: EnvSchema): string => appEnvExample(schema);
|
|
64
57
|
|
|
65
58
|
const driftFinding = (cause: string): Finding => ({
|
|
66
59
|
code: 'X_ENV_EXAMPLE_DRIFT',
|
|
67
60
|
cause,
|
|
68
|
-
// The generator,
|
|
69
|
-
//
|
|
61
|
+
// The generator, never a `Bun.write(…)` call: that needs a schema object in scope, and a gate
|
|
62
|
+
// reader has a shell.
|
|
70
63
|
fix: 'x env example',
|
|
71
64
|
docs: ERROR_DOCS_URL,
|
|
72
65
|
at: ENV_EXAMPLE_PATH,
|
|
@@ -97,12 +90,20 @@ export async function envExampleFindings(root: string): Promise<readonly Finding
|
|
|
97
90
|
}
|
|
98
91
|
const text = await file.text();
|
|
99
92
|
if (text === expected) return [];
|
|
100
|
-
const
|
|
93
|
+
const present = new Set(parseEnvKeys(text));
|
|
94
|
+
const missing = Object.keys(schema).filter((key) => !present.has(key));
|
|
95
|
+
// The framework's keys are owed by every deployed app, whatever its schema says (#679), and an
|
|
96
|
+
// example written before they were part of the contract is missing exactly these.
|
|
97
|
+
const owed = FRAMEWORK_SECRETS.map((secret) => secret.key).filter(
|
|
98
|
+
(key) => !Object.hasOwn(schema, key) && !present.has(key),
|
|
99
|
+
);
|
|
101
100
|
return [
|
|
102
101
|
driftFinding(
|
|
103
|
-
|
|
104
|
-
? `${ENV_EXAMPLE_PATH} does not declare ${
|
|
105
|
-
:
|
|
102
|
+
missing.length > 0
|
|
103
|
+
? `${ENV_EXAMPLE_PATH} does not declare ${missing.join(', ')}, declared by ${ENV_SCHEMA_EXPORT} in ${APP_CONFIG_FILE}`
|
|
104
|
+
: owed.length > 0
|
|
105
|
+
? `${ENV_EXAMPLE_PATH} does not declare ${owed.join(', ')}, which the framework refuses to boot without outside development/test`
|
|
106
|
+
: `${ENV_EXAMPLE_PATH} is no longer the projection of ${ENV_SCHEMA_EXPORT} — a description, a default or the required flag has moved`,
|
|
106
107
|
),
|
|
107
108
|
];
|
|
108
109
|
}
|
package/src/app-load.ts
CHANGED
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
// `registerQueries` / `registerRoute` name the rest — so `x manifest`, `x routes` and `x verify`
|
|
4
4
|
// read exactly the tables the running server reads.
|
|
5
5
|
|
|
6
|
-
// Bun ships no `Bun.*` path API: `
|
|
7
|
-
|
|
8
|
-
import { join, relative, resolve, sep } from 'node:path';
|
|
6
|
+
// Bun ships no `Bun.*` path API: `join`/`resolve` reach the files a scan imports.
|
|
7
|
+
import { join, resolve } from 'node:path';
|
|
9
8
|
import { isAction, isMutator, listActions, registerActions } from '@ultimat3/action';
|
|
10
9
|
import { describeEntities, registeredEntities } from '@ultimat3/entity';
|
|
11
|
-
import {
|
|
10
|
+
import type { AppLocaleSet } from '@ultimat3/i18n/app-catalogs';
|
|
11
|
+
import { appLocaleSet, UNDECLARED_LOCALES } from '@ultimat3/i18n/app-catalogs';
|
|
12
12
|
import { isJobHandle, isTaskHandle, registeredJobs, registeredTasks } from '@ultimat3/jobs';
|
|
13
13
|
import type { ErrorCodeFact } from '@ultimat3/manifest';
|
|
14
14
|
import { isQuery, listQueries, registerQueries } from '@ultimat3/query';
|
|
@@ -40,6 +40,7 @@ import { collectDeclaredCodes } from './error-contract';
|
|
|
40
40
|
import type { Finding } from './output';
|
|
41
41
|
import { findingFrom } from './output';
|
|
42
42
|
import { hasPathSegment } from './path-segments';
|
|
43
|
+
import { posixRelative, toPosix } from './posix-path';
|
|
43
44
|
import { siteAssetTable } from './site-assets';
|
|
44
45
|
import { isTest } from './source-files';
|
|
45
46
|
|
|
@@ -83,13 +84,13 @@ export interface LoadedApp {
|
|
|
83
84
|
/** Every `X_*` code the app's source declares, by code — the one fact no registry holds. */
|
|
84
85
|
readonly errorCodes: readonly ErrorCodeFact[];
|
|
85
86
|
/**
|
|
86
|
-
* The
|
|
87
|
-
* `defineCatalogs()
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
87
|
+
* The app's locales, default first — `@ultimat3/i18n`'s `appLocaleSet`, the ONE reader of
|
|
88
|
+
* `defineCatalogs()`, asked once here so every step after the load reads one answer. Never
|
|
89
|
+
* `localeConfig()` read blind (it answers an earlier import's declaration for an app that made
|
|
90
|
+
* none) and never a regex over the source. An app with no catalogs, or whose catalog module would
|
|
91
|
+
* not import (the scan's finding already says so), is `UNDECLARED_LOCALES`.
|
|
91
92
|
*/
|
|
92
|
-
readonly
|
|
93
|
+
readonly locales: AppLocaleSet;
|
|
93
94
|
/** Modules that would not import, and primitives that would not register. */
|
|
94
95
|
readonly findings: readonly Finding[];
|
|
95
96
|
}
|
|
@@ -121,6 +122,17 @@ export function resetAppLoad(): void {
|
|
|
121
122
|
resetReloadGraph();
|
|
122
123
|
}
|
|
123
124
|
|
|
125
|
+
/**
|
|
126
|
+
* One glob's hits in code-unit order of their POSIX spelling — never the host's. Sorted raw, `\`
|
|
127
|
+
* (0x5C) sorts after a digit or a capital where `/` (0x2F) sorts before one, so Windows imported
|
|
128
|
+
* `app/a1/page.tsx` before `app/a/page.tsx` and Linux after: one app, two cascades.
|
|
129
|
+
*/
|
|
130
|
+
export function sortModulePaths(paths: readonly string[]): string[] {
|
|
131
|
+
const keyed = paths.map((path) => ({ path, key: toPosix(path) }));
|
|
132
|
+
keyed.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
133
|
+
return keyed.map((one) => one.path);
|
|
134
|
+
}
|
|
135
|
+
|
|
124
136
|
/**
|
|
125
137
|
* Every module path the app globs match, SORTED — with the API index first. `Bun.Glob` answers in
|
|
126
138
|
* directory order, which is the filesystem's — ext4 hashes names with a per-filesystem seed — so
|
|
@@ -144,7 +156,7 @@ export async function appModulePaths(root: string): Promise<readonly string[]> {
|
|
|
144
156
|
if (!seen.has(absolute)) matched.push(absolute);
|
|
145
157
|
seen.add(absolute);
|
|
146
158
|
}
|
|
147
|
-
found.push(...matched
|
|
159
|
+
found.push(...sortModulePaths(matched));
|
|
148
160
|
}
|
|
149
161
|
// Resolved, like the glob's own answers: `loadApp('.')` is a legal call.
|
|
150
162
|
const index = resolve(root, API_INDEX);
|
|
@@ -196,7 +208,7 @@ export async function scanAppModules(root: string, options: ScanOptions): Promis
|
|
|
196
208
|
// file it holds, so this loop imported none of them and the app registered nothing.
|
|
197
209
|
// The ROOT-RELATIVE path is tested, never the absolute one: an app checked out under
|
|
198
210
|
// `~/work/my.test.app` matched `.test.` on every file and loaded none of them.
|
|
199
|
-
const file =
|
|
211
|
+
const file = posixRelative(root, absolute);
|
|
200
212
|
if (hasPathSegment(absolute, 'node_modules') || isTest(file)) continue;
|
|
201
213
|
if (ENTRY_POINT.test(file) || CLIENT_ENTRY_POINT.test(file) || STATES_FILE.test(file)) {
|
|
202
214
|
continue;
|
|
@@ -249,16 +261,28 @@ export async function loadApp(root: string): Promise<LoadedApp> {
|
|
|
249
261
|
) {
|
|
250
262
|
findings.push(emptyAppFinding(root));
|
|
251
263
|
}
|
|
252
|
-
// Read after the loop, never before it: `configureLocales` runs on the app's own import.
|
|
253
264
|
return {
|
|
254
265
|
root,
|
|
255
266
|
files: scan.files,
|
|
256
267
|
errorCodes: await appErrorCodes(root),
|
|
257
|
-
|
|
268
|
+
locales: await loadedLocales(root),
|
|
258
269
|
findings,
|
|
259
270
|
};
|
|
260
271
|
}
|
|
261
272
|
|
|
273
|
+
/**
|
|
274
|
+
* After the scan, never before it: the scan imported the catalog module, so the set it exports is
|
|
275
|
+
* what `loadAppCatalogs` recognises. A module that would not import is the scan's finding, not a
|
|
276
|
+
* second one here.
|
|
277
|
+
*/
|
|
278
|
+
async function loadedLocales(root: string): Promise<AppLocaleSet> {
|
|
279
|
+
try {
|
|
280
|
+
return await appLocaleSet(root);
|
|
281
|
+
} catch {
|
|
282
|
+
return UNDECLARED_LOCALES;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
262
286
|
/** No primitive in any registry this scan fills — a process that registered some itself is not empty. */
|
|
263
287
|
const registersNothing = (): boolean =>
|
|
264
288
|
listActions().length === 0 &&
|
package/src/app-manifest.ts
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
import { knownPermissions } from '@ultimat3/policy';
|
|
22
22
|
import { describeQueries } from '@ultimat3/query';
|
|
23
23
|
import type { RouteDescriptor } from '@ultimat3/render';
|
|
24
|
-
import {
|
|
24
|
+
import { describePages } from '@ultimat3/render';
|
|
25
25
|
import { loadApp } from './app-load';
|
|
26
26
|
import { AppPackageInvalidError } from './errors';
|
|
27
27
|
import type { Finding } from './output';
|
|
@@ -105,7 +105,7 @@ type UrlRoute = RouteDescriptor & { readonly surface: 'site' | 'app' | 'api' };
|
|
|
105
105
|
const hasUrl = (route: RouteDescriptor): route is UrlRoute => route.surface !== 'shared';
|
|
106
106
|
|
|
107
107
|
const routeFacts = (): readonly RouteFact[] =>
|
|
108
|
-
|
|
108
|
+
describePages()
|
|
109
109
|
.filter(hasUrl)
|
|
110
110
|
.map((route) => ({
|
|
111
111
|
url: route.path,
|
|
@@ -117,14 +117,8 @@ const routeFacts = (): readonly RouteFact[] =>
|
|
|
117
117
|
...budgetOf(route),
|
|
118
118
|
}));
|
|
119
119
|
|
|
120
|
-
function budgetOf(route: RouteDescriptor): { budget?: { js
|
|
121
|
-
|
|
122
|
-
return {
|
|
123
|
-
budget: {
|
|
124
|
-
...(route.budgetJs === null ? {} : { js: route.budgetJs }),
|
|
125
|
-
...(route.budgetLcp === null ? {} : { lcp: route.budgetLcp }),
|
|
126
|
-
},
|
|
127
|
-
};
|
|
120
|
+
function budgetOf(route: RouteDescriptor): { budget?: { js: string } } {
|
|
121
|
+
return route.budgetJs === null ? {} : { budget: { js: route.budgetJs } };
|
|
128
122
|
}
|
|
129
123
|
|
|
130
124
|
/**
|
package/src/app-mcp.ts
CHANGED
|
@@ -1,27 +1,26 @@
|
|
|
1
1
|
// The app's own MCP endpoint, mounted by the web role. `defineAppMcp` built `mcp.route` — a
|
|
2
2
|
// `POST` handler with token auth and per-class rate limits — and `app.config.ts` declared
|
|
3
|
-
// `ai: { mcp: { expose: true
|
|
3
|
+
// `ai: { mcp: { expose: true } }` by DEFAULT, and nothing between the two served it:
|
|
4
4
|
// neither `x dev` nor `runRole` mounted the route, so `POST /mcp` answered `X_ROUTE_NOT_FOUND` in
|
|
5
5
|
// every app ever scaffolded (measured 2026-09-05). The contract is one file: `apps/<app>/mcp.ts`
|
|
6
6
|
// exports `mcp` — one `AppMcp`, or an array of them, one per population (customers, staff,
|
|
7
|
-
// affiliates) — this module finds it, and both boots mount what it carries: endpoint
|
|
8
|
-
// `
|
|
7
|
+
// affiliates) — this module finds it, and both boots mount what it carries: EVERY endpoint at its
|
|
8
|
+
// own `defineAppMcp({ path })`, the one path its RFC 9728 metadata and its 401 also name.
|
|
9
9
|
|
|
10
10
|
// why: a directory's existence — `Bun.file().exists()` answers for files, and `apps/` is a directory.
|
|
11
11
|
import { existsSync } from 'node:fs';
|
|
12
|
-
// why: Bun exposes no path-join primitive;
|
|
12
|
+
// why: Bun exposes no path-join primitive; each candidate is joined to root.
|
|
13
13
|
import { join } from 'node:path';
|
|
14
14
|
import { logger } from '@ultimat3/core';
|
|
15
15
|
import type { Route } from '@ultimat3/http';
|
|
16
|
-
import {
|
|
16
|
+
import { jsonResponse, selfOrigin } from '@ultimat3/http';
|
|
17
17
|
import {
|
|
18
18
|
type AppMcp,
|
|
19
19
|
McpAppUnmountedError,
|
|
20
20
|
McpPathDuplicateError,
|
|
21
21
|
PROTECTED_RESOURCE_WELL_KNOWN,
|
|
22
22
|
} from '@ultimat3/mcp';
|
|
23
|
-
import {
|
|
24
|
-
import { APP_CONFIG_FILE } from './app-root';
|
|
23
|
+
import { loadAppConfig } from './app-config-load';
|
|
25
24
|
|
|
26
25
|
/** The one file an app writes, per app directory. */
|
|
27
26
|
export const APP_MCP_GLOB = 'apps/*/mcp.ts';
|
|
@@ -37,7 +36,7 @@ export const APP_MCP_ROUTE_NAME = 'mcp';
|
|
|
37
36
|
export interface AppMcpMount {
|
|
38
37
|
/** `[]` when `expose` is false, when nothing exports `mcp`, or when no endpoint has a route. */
|
|
39
38
|
readonly routes: readonly Route[];
|
|
40
|
-
/** Endpoint #0's `POST <path>` when mounted, else `null` — the default
|
|
39
|
+
/** Endpoint #0's `POST <path>` when mounted, else `null` — the default endpoint. */
|
|
41
40
|
readonly path: string | null;
|
|
42
41
|
/** Every mounted endpoint's `POST <path>`, in export order — the boot line prints one each. */
|
|
43
42
|
readonly paths: readonly string[];
|
|
@@ -49,34 +48,19 @@ export interface AppMcpMount {
|
|
|
49
48
|
readonly warning: McpAppUnmountedError | undefined;
|
|
50
49
|
}
|
|
51
50
|
|
|
52
|
-
interface ExposeDeclaration {
|
|
53
|
-
readonly expose: boolean;
|
|
54
|
-
readonly path: string;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
51
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
58
52
|
typeof value === 'object' && value !== null;
|
|
59
53
|
|
|
60
54
|
/**
|
|
61
|
-
* `
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* An app with no config file has nothing exposed and nothing to warn about.
|
|
55
|
+
* `defineAppMcp`'s own default — `mcp/src/transport-http.ts`, `input.path ?? '/mcp'` — named here
|
|
56
|
+
* ONLY in a warning about an endpoint that has no route and so no resolved path. It never decides
|
|
57
|
+
* where anything mounts: a mounted endpoint's path is `route.path`.
|
|
65
58
|
*/
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (!isRecord(config) || !isRecord(config['ai']) || !isRecord(config['ai']['mcp'])) {
|
|
72
|
-
return undefined;
|
|
73
|
-
}
|
|
74
|
-
const mcp = config['ai']['mcp'];
|
|
75
|
-
const path = mcp['path'];
|
|
76
|
-
return {
|
|
77
|
-
expose: mcp['expose'] === true,
|
|
78
|
-
path: typeof path === 'string' && path.startsWith('/') ? path : '/mcp',
|
|
79
|
-
};
|
|
59
|
+
const UNROUTED_MCP_PATH = '/mcp';
|
|
60
|
+
|
|
61
|
+
/** `config.ai.mcp.expose` off the one loader (`app-config-load.ts`); false with no config file. */
|
|
62
|
+
async function exposed(root: string): Promise<boolean> {
|
|
63
|
+
return (await loadAppConfig(root))?.ai.mcp.expose ?? false;
|
|
80
64
|
}
|
|
81
65
|
|
|
82
66
|
const isAppMcp = (value: unknown): value is AppMcp =>
|
|
@@ -112,21 +96,20 @@ const NOTHING: AppMcpMount = { routes: [], path: null, paths: [], warning: undef
|
|
|
112
96
|
* does not have and answer 401 before the token was ever read.
|
|
113
97
|
*/
|
|
114
98
|
export async function appMcpMount(root: string): Promise<AppMcpMount> {
|
|
115
|
-
|
|
116
|
-
if (declared === undefined || !declared.expose) return NOTHING;
|
|
99
|
+
if (!(await exposed(root))) return NOTHING;
|
|
117
100
|
const files = await candidates(root);
|
|
118
101
|
const fallbackFile = 'apps/web/mcp.ts';
|
|
119
102
|
for (const file of files) {
|
|
120
103
|
const module = (await import(join(root, file))) as Record<string, unknown>;
|
|
121
104
|
const endpoints = endpointsOf(module[APP_MCP_EXPORT]);
|
|
122
105
|
if (endpoints === undefined) continue;
|
|
123
|
-
return mountEndpoints(endpoints,
|
|
106
|
+
return mountEndpoints(endpoints, file);
|
|
124
107
|
}
|
|
125
108
|
return {
|
|
126
109
|
...NOTHING,
|
|
127
110
|
warning: new McpAppUnmountedError({
|
|
128
111
|
reason: 'missing',
|
|
129
|
-
path:
|
|
112
|
+
path: UNROUTED_MCP_PATH,
|
|
130
113
|
file: files[0] ?? fallbackFile,
|
|
131
114
|
}),
|
|
132
115
|
};
|
|
@@ -137,11 +120,7 @@ export async function appMcpMount(root: string): Promise<AppMcpMount> {
|
|
|
137
120
|
* route: it is skipped and the FIRST such is the warning, so one unfinished population never takes
|
|
138
121
|
* the others down with it. Two endpoints on one route is thrown — see `McpPathDuplicateError`.
|
|
139
122
|
*/
|
|
140
|
-
function mountEndpoints(
|
|
141
|
-
endpoints: readonly AppMcp[],
|
|
142
|
-
defaultPath: string,
|
|
143
|
-
file: string,
|
|
144
|
-
): AppMcpMount {
|
|
123
|
+
function mountEndpoints(endpoints: readonly AppMcp[], file: string): AppMcpMount {
|
|
145
124
|
const routes: Route[] = [];
|
|
146
125
|
const paths: string[] = [];
|
|
147
126
|
const claimed = new Map<string, number>();
|
|
@@ -151,13 +130,15 @@ function mountEndpoints(
|
|
|
151
130
|
if (route === undefined) {
|
|
152
131
|
warning ??= new McpAppUnmountedError({
|
|
153
132
|
reason: 'no-route',
|
|
154
|
-
path:
|
|
133
|
+
path: UNROUTED_MCP_PATH,
|
|
155
134
|
file,
|
|
156
135
|
...(index === 0 ? {} : { endpoint: index }),
|
|
157
136
|
});
|
|
158
137
|
return;
|
|
159
138
|
}
|
|
160
|
-
|
|
139
|
+
// The descriptor's own path — the one its metadata, its `resourceUrl` and its 401 were built
|
|
140
|
+
// over. A mount path from anywhere else is a resource advertised where nothing answers.
|
|
141
|
+
const path = route.path;
|
|
161
142
|
for (const mounted of endpointRoutes(route, path, index)) {
|
|
162
143
|
const key = `${mounted.method} ${mounted.path}`;
|
|
163
144
|
const first = claimed.get(key);
|
|
@@ -174,8 +155,7 @@ function mountEndpoints(
|
|
|
174
155
|
}
|
|
175
156
|
paths.push(path);
|
|
176
157
|
});
|
|
177
|
-
|
|
178
|
-
return { routes, path, paths, warning };
|
|
158
|
+
return { routes, path: endpoints[0]?.route?.path ?? null, paths, warning };
|
|
179
159
|
}
|
|
180
160
|
|
|
181
161
|
type Descriptor = NonNullable<AppMcp['route']>;
|
|
@@ -197,7 +177,7 @@ function endpointRoutes(route: Descriptor, path: string, index: number): readonl
|
|
|
197
177
|
method: 'GET',
|
|
198
178
|
path: metadataPath,
|
|
199
179
|
handler: (_request, ctx) => {
|
|
200
|
-
const response =
|
|
180
|
+
const response = jsonResponse(resource.document(selfOrigin(ctx.url, ctx.https)));
|
|
201
181
|
// A browser-hosted MCP client reads this cross-origin; it holds nothing secret.
|
|
202
182
|
response.headers.set('access-control-allow-origin', '*');
|
|
203
183
|
response.headers.set('cache-control', 'public, max-age=3600');
|
|
@@ -213,8 +193,13 @@ function endpointRoutes(route: Descriptor, path: string, index: number): readonl
|
|
|
213
193
|
// The PUBLIC origin, as the pipeline resolved it (`ctx.https` honours a trusted proxy's
|
|
214
194
|
// `x-forwarded-proto`): behind a TLS-terminating ingress the raw request URL is `http://`
|
|
215
195
|
// on an internal host, and a `resource_metadata` naming that is a URL no client can reach.
|
|
196
|
+
// The client ADDRESS likewise: failed tokens are metered per address, and the descriptor
|
|
197
|
+
// has no other way to learn it.
|
|
216
198
|
handler: (request, ctx) =>
|
|
217
|
-
route.handle(request.raw, {
|
|
199
|
+
route.handle(request.raw, {
|
|
200
|
+
origin: selfOrigin(ctx.url, ctx.https),
|
|
201
|
+
address: ctx.ip ?? undefined,
|
|
202
|
+
}),
|
|
218
203
|
meta: { name, auth: 'public', enforcedBy: 'handler' },
|
|
219
204
|
},
|
|
220
205
|
...metadata,
|
package/src/app-name.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// `x new <name>`'s last refusal before a byte lands: the slug must be a name core's `defineConfig`
|
|
2
|
+
// accepts, because it is written verbatim as `app.config.ts`'s `name:` and spelled into every
|
|
3
|
+
// identifier the scaffold derives (`<app>Seed`). The rule is asked of core, never restated here.
|
|
4
|
+
|
|
5
|
+
import { defineConfig, isUltimateError, renderThrowable, UltimateError } from '@ultimat3/core';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Why core's own config validator refuses `name`, in core's words, or `undefined` when it accepts
|
|
9
|
+
* it — asked by building the one config that name would scaffold, every other field defaulted. A
|
|
10
|
+
* copy of core's pattern here would be a second answer to "what is an app name", and the day one
|
|
11
|
+
* of them moved `x new` would again write an app whose `app.config.ts` throws at its own import.
|
|
12
|
+
*/
|
|
13
|
+
export function appNameIssue(name: string): string | undefined {
|
|
14
|
+
try {
|
|
15
|
+
defineConfig({ name });
|
|
16
|
+
return undefined;
|
|
17
|
+
} catch (error) {
|
|
18
|
+
return isUltimateError(error) ? error.cause : renderThrowable(error);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The bound the suggestion trims an over-long slug to — re-proved by `appNameIssue`, not trusted. */
|
|
23
|
+
const MAX_LENGTH = 64;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A slug core accepts, as close to `slug` as one edit gets: a leading digit gains an `app-` prefix
|
|
27
|
+
* (an identifier cannot start with one either), a one-letter name an `-app` suffix, and an
|
|
28
|
+
* over-long one is cut at the bound. `undefined` when none of those land on a valid name.
|
|
29
|
+
*/
|
|
30
|
+
export function suggestAppName(slug: string): string | undefined {
|
|
31
|
+
let candidate = /^[0-9]/.test(slug) ? `app-${slug}` : slug;
|
|
32
|
+
if (candidate.length < 2) candidate = `${candidate}-app`;
|
|
33
|
+
candidate = candidate.slice(0, MAX_LENGTH).replace(/-+$/, '');
|
|
34
|
+
return appNameIssue(candidate) === undefined ? candidate : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `x new 9lives`, `x new a`: a slug core's `defineConfig` would refuse at the app's first boot. */
|
|
38
|
+
export class AppNameInvalidError extends UltimateError {
|
|
39
|
+
constructor(input: {
|
|
40
|
+
name: string;
|
|
41
|
+
slug: string;
|
|
42
|
+
issue: string;
|
|
43
|
+
invocation: string;
|
|
44
|
+
flags: readonly string[];
|
|
45
|
+
}) {
|
|
46
|
+
super({
|
|
47
|
+
code: 'X_CLI_BAD_FLAG',
|
|
48
|
+
cause: `"${input.name}" scaffolds the app name "${input.slug}", which app.config.ts's defineConfig refuses: ${input.issue}`,
|
|
49
|
+
fix: [input.invocation, suggestAppName(input.slug) ?? '<name>', ...input.flags].join(' '),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// The `policy` step's provenance question: every permission the app's OWN actions, queries and
|
|
2
|
+
// routes require is one the app ITSELF declares. `defineAdmin()` declares `<entity>:read|write|
|
|
3
|
+
// delete` for every mounted entity, so an app rule naming one passed in the web process and threw
|
|
4
|
+
// X_PERMISSION_UNKNOWN in any process that never mounted the admin — with nothing saying so.
|
|
5
|
+
|
|
6
|
+
import { listActions } from '@ultimat3/action';
|
|
7
|
+
import { type Policy, permissionDeclarationSites, policyPermissions } from '@ultimat3/policy';
|
|
8
|
+
import { listQueries } from '@ultimat3/query';
|
|
9
|
+
import { routeEntries } from '@ultimat3/render';
|
|
10
|
+
import { frameFile, rootRelative, siteFile } from './app-permissions-site';
|
|
11
|
+
import type { Finding } from './output';
|
|
12
|
+
|
|
13
|
+
/** One permission one app declaration requires. */
|
|
14
|
+
export interface AppRule {
|
|
15
|
+
readonly permission: string;
|
|
16
|
+
/** `action createPost`, `query postList`, or the route file. */
|
|
17
|
+
readonly by: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** An app rule whose permission no app module declares, and the file that did declare it. */
|
|
21
|
+
export interface BorrowedPermission extends AppRule {
|
|
22
|
+
/**
|
|
23
|
+
* Root-relative paths of EVERY declaring module (`node_modules/…` or `../…`), distinct and
|
|
24
|
+
* sorted: naming only the first told the reader one package declared it when two did.
|
|
25
|
+
*/
|
|
26
|
+
readonly from: readonly string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* EVERY permission in each tree, composites included — unlike the grant question, which judges
|
|
31
|
+
* only a bare `can()`: an `or()` branch is still a `can()` that asserted its name, so it needs the
|
|
32
|
+
* app's own declaration as much as a bare one does. A screen the admin MOUNTS is not here: those
|
|
33
|
+
* are the package's rules, and declaring their names is the package's job.
|
|
34
|
+
*/
|
|
35
|
+
export function appRules(): readonly AppRule[] {
|
|
36
|
+
const named = (by: string, policy: Policy<unknown, unknown>): readonly AppRule[] =>
|
|
37
|
+
policyPermissions(policy).map((permission) => ({ permission, by }));
|
|
38
|
+
return [
|
|
39
|
+
...listActions().flatMap((target) => named(`action ${target.name}`, target.policy)),
|
|
40
|
+
...listQueries().flatMap((target) => named(`query ${target.name}`, target.policy)),
|
|
41
|
+
...routeEntries().flatMap((entry) => {
|
|
42
|
+
const permission = entry.config.policy?.permission;
|
|
43
|
+
return permission === undefined ? [] : [{ permission, by: entry.file }];
|
|
44
|
+
}),
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** An app module's root-relative path, or `undefined` for a dependency's or an unreadable frame. */
|
|
49
|
+
const appFile = (root: string, site: string): string | undefined => {
|
|
50
|
+
const file = siteFile(root, site);
|
|
51
|
+
return file === undefined || file.split('/').includes('node_modules') ? undefined : file;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Provenance by the declaring module's PATH, which is where the call ran — never by the name's
|
|
56
|
+
* shape: `posts:read` is the same string whether `defineAdmin()` or the app declared it. A name
|
|
57
|
+
* with any unreadable site is one whose origin nobody can state, so it is not judged — and that
|
|
58
|
+
* covers an UNKNOWN name too, which has no site at all: it is X_PERMISSION_UNKNOWN's, asked first,
|
|
59
|
+
* and a second finding would be a second instruction for one fault.
|
|
60
|
+
*/
|
|
61
|
+
export function borrowedPermissions(
|
|
62
|
+
root: string,
|
|
63
|
+
rules: readonly AppRule[] = appRules(),
|
|
64
|
+
declaredAt: Readonly<Record<string, readonly string[]>> = permissionDeclarationSites(),
|
|
65
|
+
): readonly BorrowedPermission[] {
|
|
66
|
+
const found = new Map<string, BorrowedPermission>();
|
|
67
|
+
for (const rule of rules) {
|
|
68
|
+
const sites = Object.hasOwn(declaredAt, rule.permission) ? declaredAt[rule.permission] : [];
|
|
69
|
+
const files = (sites ?? []).map(frameFile);
|
|
70
|
+
if (files.length === 0 || files.some((file) => file === undefined)) continue;
|
|
71
|
+
if ((sites ?? []).some((site) => appFile(root, site) !== undefined)) continue;
|
|
72
|
+
const from = [
|
|
73
|
+
...new Set(files.flatMap((file) => (file === undefined ? [] : [rootRelative(root, file)]))),
|
|
74
|
+
].sort();
|
|
75
|
+
found.set(`${rule.permission}\u0000${rule.by}`, { ...rule, from });
|
|
76
|
+
}
|
|
77
|
+
return [...found.values()].sort((a, b) =>
|
|
78
|
+
a.permission === b.permission
|
|
79
|
+
? a.by.localeCompare(b.by)
|
|
80
|
+
: a.permission.localeCompare(b.permission),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The app file its permissions are declared in — where the missing name is added. Read off the
|
|
86
|
+
* recorded sites, never a scaffold path; none under the root is `undefined`, never a guess.
|
|
87
|
+
*/
|
|
88
|
+
export function permissionsFile(root: string): string | undefined {
|
|
89
|
+
for (const sites of Object.values(permissionDeclarationSites())) {
|
|
90
|
+
for (const site of sites) {
|
|
91
|
+
const file = appFile(root, site);
|
|
92
|
+
if (file !== undefined) return file;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A TS string literal for the name — quoted the house way unless the name itself needs escaping. */
|
|
99
|
+
const literal = (name: string): string =>
|
|
100
|
+
/^[\w:.*-]+$/.test(name) ? `'${name}'` : JSON.stringify(name);
|
|
101
|
+
|
|
102
|
+
export function borrowedFinding(
|
|
103
|
+
entry: BorrowedPermission,
|
|
104
|
+
declaredIn: string | undefined,
|
|
105
|
+
): Finding {
|
|
106
|
+
const [only, ...more] = entry.from;
|
|
107
|
+
const declarers =
|
|
108
|
+
more.length === 0 ? `only ${only} declares it` : `only ${entry.from.join(', ')} declare it`;
|
|
109
|
+
const where =
|
|
110
|
+
declaredIn === undefined
|
|
111
|
+
? 'paste this call into an app module'
|
|
112
|
+
: `paste this call into ${declaredIn} (definePermissions() merges, so a second call is fine)`;
|
|
113
|
+
const finding: Finding = {
|
|
114
|
+
code: 'X_PERMISSION_BORROWED',
|
|
115
|
+
cause:
|
|
116
|
+
`${entry.by} requires ${literal(entry.permission)}, and ${declarers} — no app module does, ` +
|
|
117
|
+
"so the rule holds only in a process that ran that package's declaration (defineAdmin()) " +
|
|
118
|
+
`first; ${where}, then x verify --only policy`,
|
|
119
|
+
// A call to paste, never prose: `add …` read as a command answered `add: command not found`.
|
|
120
|
+
fix: `definePermissions([${literal(entry.permission)}])`,
|
|
121
|
+
};
|
|
122
|
+
return declaredIn === undefined ? finding : { ...finding, at: declaredIn };
|
|
123
|
+
}
|