@ultimat3/cli 1.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/LICENSE +21 -0
- package/README.md +100 -0
- package/package.json +60 -0
- package/src/app-agents-md.ts +27 -0
- package/src/app-boundaries.ts +206 -0
- package/src/app-evals.ts +74 -0
- package/src/app-load.ts +136 -0
- package/src/app-manifest.ts +137 -0
- package/src/app-openapi.ts +12 -0
- package/src/app-root.ts +57 -0
- package/src/bin.ts +17 -0
- package/src/boundary-cuts.ts +219 -0
- package/src/budgets.ts +92 -0
- package/src/cmd-build.ts +109 -0
- package/src/cmd-db.ts +187 -0
- package/src/cmd-deploy.ts +124 -0
- package/src/cmd-dev.ts +286 -0
- package/src/cmd-doctor.ts +178 -0
- package/src/cmd-errors.ts +99 -0
- package/src/cmd-fix.ts +126 -0
- package/src/cmd-generate.ts +434 -0
- package/src/cmd-help.ts +94 -0
- package/src/cmd-i18n.ts +212 -0
- package/src/cmd-jobs.ts +237 -0
- package/src/cmd-manifest.ts +97 -0
- package/src/cmd-mcp.ts +176 -0
- package/src/cmd-new.ts +133 -0
- package/src/cmd-planned.ts +119 -0
- package/src/cmd-policy.ts +136 -0
- package/src/cmd-registries.ts +195 -0
- package/src/cmd-routes.ts +73 -0
- package/src/cmd-tasks.ts +151 -0
- package/src/cmd-test.ts +109 -0
- package/src/cmd-verify.ts +265 -0
- package/src/command.ts +33 -0
- package/src/dev-assets.ts +177 -0
- package/src/dev-dashboard.ts +242 -0
- package/src/dev-hooks.ts +51 -0
- package/src/dev-policy.ts +82 -0
- package/src/dev-queue.ts +109 -0
- package/src/dev-render.ts +129 -0
- package/src/dev-replicator.ts +92 -0
- package/src/dev-roles.ts +246 -0
- package/src/dev-runtime.ts +203 -0
- package/src/dev-services.ts +75 -0
- package/src/dev-traces.ts +141 -0
- package/src/dispatch.ts +98 -0
- package/src/drift.ts +86 -0
- package/src/error-catalog.ts +156 -0
- package/src/error-contract.ts +212 -0
- package/src/errors.ts +367 -0
- package/src/exec.ts +70 -0
- package/src/hold.ts +48 -0
- package/src/i18n-audit.ts +183 -0
- package/src/index.ts +179 -0
- package/src/jobs-drain.ts +151 -0
- package/src/jobs-json.ts +134 -0
- package/src/jobs-report.ts +132 -0
- package/src/jobs-table.ts +34 -0
- package/src/json-merge.ts +40 -0
- package/src/mcp-db-target.ts +50 -0
- package/src/mcp-errors.ts +99 -0
- package/src/mcp-host.ts +282 -0
- package/src/mcp-test-output.ts +57 -0
- package/src/messages.ts +119 -0
- package/src/output.ts +174 -0
- package/src/parse.ts +243 -0
- package/src/policy-facts.ts +196 -0
- package/src/policy-fixture.ts +71 -0
- package/src/registry.ts +73 -0
- package/src/scaffold-fixture.ts +69 -0
- package/src/scaffold-typecheck.ts +240 -0
- package/src/source-files.ts +38 -0
- package/src/table.ts +19 -0
- package/src/tasks-facts.ts +113 -0
- package/src/templates/action.ts +193 -0
- package/src/templates/admin.ts +46 -0
- package/src/templates/catalog-json.ts +17 -0
- package/src/templates/entity.ts +157 -0
- package/src/templates/index.ts +23 -0
- package/src/templates/job.ts +148 -0
- package/src/templates/locales.ts +93 -0
- package/src/templates/naming.ts +97 -0
- package/src/templates/policy.ts +120 -0
- package/src/templates/query.ts +116 -0
- package/src/templates/resource.ts +199 -0
- package/src/templates/route.ts +138 -0
- package/src/templates/scaffold-app.ts +320 -0
- package/src/templates/scaffold-docs.ts +156 -0
- package/src/templates/scaffold-i18n.ts +149 -0
- package/src/templates/scaffold-icon.ts +54 -0
- package/src/templates/scaffold-package-shape.ts +49 -0
- package/src/templates/scaffold-repo.ts +427 -0
- package/src/test-select.ts +130 -0
- package/src/test-shards.ts +188 -0
- package/src/thrown-by.ts +24 -0
- package/src/ts-scan.ts +217 -0
- package/src/verify-step.ts +83 -0
- package/src/verify-tests.ts +166 -0
- package/src/version-loader.ts +16 -0
- package/src/workspace-checks.ts +288 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// Mounting `@ultimat3/admin`'s `/_x` dashboard in the `x dev` process. The CLI contributes only
|
|
2
|
+
// what no registry holds — a SQL runner on the live dev database, the caught outbox, the committed
|
|
3
|
+
// manifest, and two panels of process facts — and projects the dashboard onto HTTP routes.
|
|
4
|
+
// A panel implemented here instead of in `admin` would be the second copy this seam exists to ban.
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
DevPanel,
|
|
8
|
+
DevSources,
|
|
9
|
+
InvalidationFact,
|
|
10
|
+
MailFact,
|
|
11
|
+
ManifestFact,
|
|
12
|
+
PolicyFact,
|
|
13
|
+
RequestTrace,
|
|
14
|
+
SqlResult,
|
|
15
|
+
} from '@ultimat3/admin/dev';
|
|
16
|
+
import { DEV_BASE_PATH, DEV_PANELS, defaultDevSources, devDashboard } from '@ultimat3/admin/dev';
|
|
17
|
+
import { recentInvalidations } from '@ultimat3/cache';
|
|
18
|
+
import type { Role } from '@ultimat3/core';
|
|
19
|
+
import type { Route, UltimateRequest } from '@ultimat3/http';
|
|
20
|
+
import { json as jsonResponse } from '@ultimat3/http';
|
|
21
|
+
import type { MemoryMailDriver } from '@ultimat3/mail';
|
|
22
|
+
import { isMemoryDriver } from '@ultimat3/mail';
|
|
23
|
+
import type { Manifest } from '@ultimat3/manifest';
|
|
24
|
+
import { checkAppBoundaries } from './app-boundaries';
|
|
25
|
+
import { appManifest, readAppManifest } from './app-manifest';
|
|
26
|
+
import { devPolicyMatrix } from './dev-policy';
|
|
27
|
+
import type { RunningServices } from './dev-runtime';
|
|
28
|
+
import type { DevServices } from './dev-services';
|
|
29
|
+
import type { TraceRecorder } from './dev-traces';
|
|
30
|
+
import type { Finding } from './output';
|
|
31
|
+
|
|
32
|
+
export interface DevStatus {
|
|
33
|
+
readonly url: string;
|
|
34
|
+
readonly services: DevServices;
|
|
35
|
+
readonly roles: readonly Role[];
|
|
36
|
+
readonly findings: readonly Finding[];
|
|
37
|
+
readonly reloads: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface DevDashboardInput {
|
|
41
|
+
readonly root: string;
|
|
42
|
+
readonly runtime: RunningServices;
|
|
43
|
+
/** Read at request time: the process's live facts change while the dashboard is mounted. */
|
|
44
|
+
status(): DevStatus;
|
|
45
|
+
/** NODE_ENV/X_ENV as x dev saw it; `devDashboard` refuses to mount in production. */
|
|
46
|
+
readonly env?: string | undefined;
|
|
47
|
+
/** The spans this process recorded. Absent when `x dev` did not install the exporter. */
|
|
48
|
+
readonly traces?: TraceRecorder | undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Read-only is already enforced by `assertReadOnly` inside `dbPanel`, before `runSql` is ever
|
|
53
|
+
* reached. A second gate here would be a second authz: two places to update when `x db psql
|
|
54
|
+
* --write` changes what is allowed, and one of them would eventually disagree.
|
|
55
|
+
*/
|
|
56
|
+
async function runSql(input: DevDashboardInput, sql: string): Promise<SqlResult> {
|
|
57
|
+
const started = performance.now();
|
|
58
|
+
const rows = await input.runtime.db.query<Readonly<Record<string, unknown>>>({
|
|
59
|
+
text: sql,
|
|
60
|
+
values: [],
|
|
61
|
+
});
|
|
62
|
+
const elapsedMs = Math.round(performance.now() - started);
|
|
63
|
+
// Columns come from the first row because the driver returns objects, not a described result
|
|
64
|
+
// set; no rows means no columns to name, which the panel renders as an empty grid.
|
|
65
|
+
const columns = Object.keys(rows[0] ?? {});
|
|
66
|
+
return { columns, rows: rows.map((row) => columns.map((column) => row[column])), elapsedMs };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** `MailMessage.locale` is non-optional in `@ultimat3/mail`, so the panel never has to guess. */
|
|
70
|
+
function mailFacts(outbox: MemoryMailDriver): readonly MailFact[] {
|
|
71
|
+
return outbox.outbox().map((entry) => ({
|
|
72
|
+
id: entry.result.id,
|
|
73
|
+
to: entry.message.to.join(', '),
|
|
74
|
+
subject: entry.message.subject,
|
|
75
|
+
locale: entry.message.locale,
|
|
76
|
+
html: entry.message.html,
|
|
77
|
+
text: entry.message.text,
|
|
78
|
+
sentAt: entry.at.toISOString(),
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Top-level keys only: that is the granularity `manifestPanel` splits into added/removed/changed. */
|
|
83
|
+
const topLevel = (manifest: Manifest | undefined): ReadonlyMap<string, unknown> =>
|
|
84
|
+
new Map<string, unknown>(manifest === undefined ? [] : Object.entries(manifest));
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* A side that is missing stays `undefined` rather than becoming `null`: `manifestPanel` reads
|
|
88
|
+
* exactly that distinction to tell an added key from a changed one.
|
|
89
|
+
*/
|
|
90
|
+
function manifestDiff(emitted: Manifest, committed: Manifest | undefined): ManifestFact['diff'] {
|
|
91
|
+
const left = topLevel(emitted);
|
|
92
|
+
const right = topLevel(committed);
|
|
93
|
+
return [...new Set([...left.keys(), ...right.keys()])]
|
|
94
|
+
.filter((key) => JSON.stringify(left.get(key)) !== JSON.stringify(right.get(key)))
|
|
95
|
+
.map((key) => ({ path: key, emitted: left.get(key), committed: right.get(key) }));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function manifestFact(root: string): Promise<ManifestFact> {
|
|
99
|
+
const [{ manifest: emitted }, committed] = await Promise.all([
|
|
100
|
+
appManifest(root),
|
|
101
|
+
readAppManifest(root),
|
|
102
|
+
]);
|
|
103
|
+
return { emitted, committed: committed ?? null, diff: manifestDiff(emitted, committed) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* `@ultimat3/cache` keeps the report of every `invalidateTags` fan-out; the panel reads it back
|
|
108
|
+
* as its log. Only the shape differs — the cache owns the facts, /_x owns the rendering.
|
|
109
|
+
*/
|
|
110
|
+
const invalidationFacts = (): readonly InvalidationFact[] =>
|
|
111
|
+
recentInvalidations().map((event) => ({
|
|
112
|
+
at: event.at,
|
|
113
|
+
tags: event.tags,
|
|
114
|
+
busted: event.busted,
|
|
115
|
+
source: event.source,
|
|
116
|
+
}));
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Every source only this process can answer. It owns the SQL connection, the caught outbox, the
|
|
120
|
+
* committed manifest on disk, the span exporter and the app's registries — no other host can.
|
|
121
|
+
*
|
|
122
|
+
* `subscribers` is the one left unwired, and stays that way until `@ultimat3/realtime` records a
|
|
123
|
+
* subscriber's matcher decision: `LiveSubscriberFact.trace` is the live panel's whole question,
|
|
124
|
+
* and nothing in the registry retains it. `defaultDevSources` rejects it with `X_NOT_IMPLEMENTED`
|
|
125
|
+
* and the wiring line, which the live panel degrades into its own `dev.live.no-sync-node` note —
|
|
126
|
+
* a bare empty list would claim nobody is subscribed, which is a different and unearned answer.
|
|
127
|
+
*/
|
|
128
|
+
export function devSources(input: DevDashboardInput): DevSources {
|
|
129
|
+
const traces = input.traces;
|
|
130
|
+
// Only the memory driver retains what it accepted. Once a credential selects a real transport
|
|
131
|
+
// the messages are at the provider, so the hook is omitted rather than answered with `[]` —
|
|
132
|
+
// an empty outbox claims nobody was mailed, which is a different and unearned answer.
|
|
133
|
+
const outbox = isMemoryDriver(input.runtime.mail) ? input.runtime.mail : undefined;
|
|
134
|
+
return defaultDevSources({
|
|
135
|
+
hooks: {
|
|
136
|
+
runSql: (sql: string): Promise<SqlResult> => runSql(input, sql),
|
|
137
|
+
...(outbox === undefined
|
|
138
|
+
? {}
|
|
139
|
+
: { mail: (): Promise<readonly MailFact[]> => Promise.resolve(mailFacts(outbox)) }),
|
|
140
|
+
manifest: (): Promise<ManifestFact> => manifestFact(input.root),
|
|
141
|
+
invalidations: (): Promise<readonly InvalidationFact[]> =>
|
|
142
|
+
Promise.resolve(invalidationFacts()),
|
|
143
|
+
// Read through the app's own policies at request time, so a reload that changes a rule
|
|
144
|
+
// changes the matrix without a remount.
|
|
145
|
+
policyMatrix: (): Promise<readonly PolicyFact[]> => Promise.resolve(devPolicyMatrix()),
|
|
146
|
+
// Spread, never passed as `undefined`: a host with no recorder must fall back to the
|
|
147
|
+
// refusal `defaultDevSources` already carries, not to an empty timeline.
|
|
148
|
+
...(traces === undefined
|
|
149
|
+
? {}
|
|
150
|
+
: { traces: (): Promise<readonly RequestTrace[]> => Promise.resolve(traces.traces()) }),
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
interface ServicesPanelData extends DevStatus {
|
|
156
|
+
readonly stateDir: string;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Both CLI panels ignore the `DevSources` argument, and must: these are facts about this
|
|
161
|
+
* process — which port it bound, which roles it started, which files would not import — not
|
|
162
|
+
* introspection of the app's registries. No registry could answer them.
|
|
163
|
+
*/
|
|
164
|
+
const servicesPanel = (input: DevDashboardInput): DevPanel<ServicesPanelData> => ({
|
|
165
|
+
key: 'services',
|
|
166
|
+
titleKey: 'dev.panel.services',
|
|
167
|
+
question: 'which services is this process talking to, and did anything fail to load?',
|
|
168
|
+
data(): Promise<ServicesPanelData> {
|
|
169
|
+
const status = input.status();
|
|
170
|
+
return Promise.resolve({ ...status, stateDir: status.services.stateDir });
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
interface BoundariesPanelData {
|
|
175
|
+
readonly findings: readonly Finding[];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const boundariesPanel = (input: DevDashboardInput): DevPanel<BoundariesPanelData> => ({
|
|
179
|
+
key: 'boundaries',
|
|
180
|
+
titleKey: 'dev.panel.boundaries',
|
|
181
|
+
question: 'does any file import across a boundary the build will reject?',
|
|
182
|
+
async data(): Promise<BoundariesPanelData> {
|
|
183
|
+
return { findings: await checkAppBoundaries(input.root) };
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
export function devPanels(input: DevDashboardInput): readonly DevPanel[] {
|
|
188
|
+
return [...DEV_PANELS, servicesPanel(input), boundariesPanel(input)];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* `handle` answers `null` only for a path outside `basePath`, and every path below was generated
|
|
193
|
+
* from it — unreachable, answered anyway. A `!` here would turn a future `basePath` change into a
|
|
194
|
+
* runtime crash instead of a payload that names the mismatch.
|
|
195
|
+
*/
|
|
196
|
+
const notClaimed = (path: string): Response =>
|
|
197
|
+
jsonResponse(
|
|
198
|
+
{
|
|
199
|
+
panel: path,
|
|
200
|
+
ok: false,
|
|
201
|
+
error: {
|
|
202
|
+
code: 'X_ROUTE_NOT_FOUND',
|
|
203
|
+
cause: `x dev mounted ${path} but the /_x dashboard did not claim it`,
|
|
204
|
+
fix: 'x dev --json # then report the DEV_BASE_PATH / route table mismatch',
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
{ status: 404 },
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const devRoute = (path: string, name: string, handler: Route['handler']): Route => ({
|
|
211
|
+
method: 'GET',
|
|
212
|
+
path,
|
|
213
|
+
// Public: /_x exists to be read without credentials by whatever drives the dev loop, and
|
|
214
|
+
// `devDashboard` refuses to construct at all outside development.
|
|
215
|
+
meta: { name, auth: 'public', tags: ['_x'] },
|
|
216
|
+
handler,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* One route for the base path plus one per panel, because the router matches exact paths. The
|
|
221
|
+
* dashboard is built once — its sources close over this process, and rebuilding per request would
|
|
222
|
+
* re-run `assertDevOnly` on every hit for no new answer.
|
|
223
|
+
*/
|
|
224
|
+
export function devDashboardRoutes(input: DevDashboardInput): readonly Route[] {
|
|
225
|
+
const panels = devPanels(input);
|
|
226
|
+
const dashboard = devDashboard({
|
|
227
|
+
basePath: DEV_BASE_PATH,
|
|
228
|
+
panels,
|
|
229
|
+
sources: devSources(input),
|
|
230
|
+
...(input.env === undefined ? {} : { env: input.env }),
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const handler = async (request: UltimateRequest): Promise<Response> =>
|
|
234
|
+
(await dashboard.handle(request.raw)) ?? notClaimed(request.pathname);
|
|
235
|
+
|
|
236
|
+
return [
|
|
237
|
+
devRoute(DEV_BASE_PATH, 'dev._x', handler),
|
|
238
|
+
...panels.map((panel) =>
|
|
239
|
+
devRoute(`${DEV_BASE_PATH}/${panel.key}`, `dev._x.${panel.key}`, handler),
|
|
240
|
+
),
|
|
241
|
+
];
|
|
242
|
+
}
|
package/src/dev-hooks.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The two seams `@ultimat3/http` leaves open, bound to the packages that own them. `authorize`
|
|
2
|
+
// decides for pages only, from the SAME `Policy` object every other surface evaluates — the route
|
|
3
|
+
// table's declared permission — so a denial in `x dev` is the one production produces.
|
|
4
|
+
|
|
5
|
+
import { actorOf } from '@ultimat3/action';
|
|
6
|
+
import type { AuthzDecision, ServerHooks } from '@ultimat3/http';
|
|
7
|
+
import { asCtx } from '@ultimat3/http';
|
|
8
|
+
import type { KnownPermission, Policy } from '@ultimat3/policy';
|
|
9
|
+
import { can, evaluate } from '@ultimat3/policy';
|
|
10
|
+
import { routeFor } from '@ultimat3/render';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `RouteGuard.permission` is a bare string — `@ultimat3/render` keeps policy structural on
|
|
14
|
+
* purpose. `can()` checks the name against the registry; this only checks the shape, so a
|
|
15
|
+
* malformed guard denies with "no policy registered" instead of throwing inside the pipeline.
|
|
16
|
+
*/
|
|
17
|
+
const isPermission = (value: string): value is KnownPermission => /^[^:]+:[^:]+$/.test(value);
|
|
18
|
+
|
|
19
|
+
/** A page carries only the permission label, because that is all `RouteGuard` keeps. */
|
|
20
|
+
function policyFor(path: string): Policy<unknown, unknown> | undefined {
|
|
21
|
+
const permission = routeFor(path)?.config.policy?.permission;
|
|
22
|
+
return permission !== undefined && isPermission(permission) ? can(permission) : undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function devHooks(): ServerHooks {
|
|
26
|
+
return {
|
|
27
|
+
authorize: (route, _request, ctx): AuthzDecision => {
|
|
28
|
+
// An action route never arrives here: it carries `enforcedBy: 'handler'`, so the pipeline
|
|
29
|
+
// never asks. `invoke` is its one evaluation, and the only one holding the row a row-level
|
|
30
|
+
// rule reads — reconstructing it here would be a second authz system, one row short.
|
|
31
|
+
const policy = policyFor(route.path);
|
|
32
|
+
if (policy === undefined) {
|
|
33
|
+
return {
|
|
34
|
+
allowed: false,
|
|
35
|
+
reason: `no policy is registered under ${route.meta.policy ?? route.meta.name}`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
// `actorOf` is the framework's own anonymous → null mapping, so "nobody" denies with
|
|
39
|
+
// X_UNAUTHENTICATED here exactly as it does inside `invoke`. The decision is passed
|
|
40
|
+
// straight through: `PolicyDecision` and `AuthzDecision` are the same shape by design,
|
|
41
|
+
// and an adapter here would be the beginning of a second authz model.
|
|
42
|
+
const context = asCtx(ctx);
|
|
43
|
+
return evaluate(policy, {
|
|
44
|
+
input: ctx.input,
|
|
45
|
+
actor: actorOf(context),
|
|
46
|
+
row: null,
|
|
47
|
+
ctx: context,
|
|
48
|
+
}).decision;
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// The `/_x` policy panel's source: the app's own policies, decided by `@ultimat3/policy`'s own
|
|
2
|
+
// `policyMatrix()` — the same function `x g policy` generates a test against. The CLI supplies
|
|
3
|
+
// only the two things no registry holds: which actors to ask about, and which capability each
|
|
4
|
+
// policy gates. Re-deriving a verdict here would be the second authz the framework bans.
|
|
5
|
+
|
|
6
|
+
import { listActions } from '@ultimat3/action';
|
|
7
|
+
import type { PolicyFact } from '@ultimat3/admin/dev';
|
|
8
|
+
import type { NamedActor, Policy } from '@ultimat3/policy';
|
|
9
|
+
import { policyMatrix, roleDefinitions, testActor } from '@ultimat3/policy';
|
|
10
|
+
import { listQueries } from '@ultimat3/query';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One capability and the policy that decides it. A primitive's `capability` IS its policy's own
|
|
14
|
+
* label (`policyCapability` returns exactly that), so the capability identifies the gate: two
|
|
15
|
+
* primitives reporting the same one are two call sites of one rule, not two answers to one cell.
|
|
16
|
+
*/
|
|
17
|
+
interface PolicyGate {
|
|
18
|
+
readonly permission: string;
|
|
19
|
+
readonly policy: Policy;
|
|
20
|
+
/** The primitives that gate on it — the panel's answer to "where is this enforced?". */
|
|
21
|
+
readonly usedBy: readonly string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Every actor the matrix is computed for: one per role the app declared with `defineRoles`, plus
|
|
26
|
+
* the anonymous caller. Derived rather than flagged, because the roles ARE the app's declaration
|
|
27
|
+
* of who exists — a `--actor` flag would be a second place to keep that list.
|
|
28
|
+
*/
|
|
29
|
+
export function devActors(): readonly NamedActor[] {
|
|
30
|
+
const roles = Object.keys(roleDefinitions()).sort();
|
|
31
|
+
return [
|
|
32
|
+
{ name: 'anonymous', actor: null },
|
|
33
|
+
...roles.map((role) => testActor(role, { roles: [role] })),
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Every gated capability in the app, actions and queries alike, sorted for a stable panel. */
|
|
38
|
+
export function devPolicyGates(): readonly PolicyGate[] {
|
|
39
|
+
const gates = new Map<string, { permission: string; policy: Policy; usedBy: string[] }>();
|
|
40
|
+
const add = (permission: string, policy: Policy, primitive: string): void => {
|
|
41
|
+
// A policy that gates on nothing reports an empty capability; there is no cell to draw for it.
|
|
42
|
+
if (permission.length === 0) return;
|
|
43
|
+
const existing = gates.get(permission);
|
|
44
|
+
if (existing === undefined) gates.set(permission, { permission, policy, usedBy: [primitive] });
|
|
45
|
+
else existing.usedBy.push(primitive);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
for (const target of listActions())
|
|
49
|
+
add(target.describe().capability, target.policy, `action:${target.name}`);
|
|
50
|
+
for (const target of listQueries())
|
|
51
|
+
add(target.describe().capability, target.policy, `query:${target.name}`);
|
|
52
|
+
|
|
53
|
+
return [...gates.values()].sort((a, b) => a.permission.localeCompare(b.permission));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The matrix, actor by actor and capability by capability.
|
|
58
|
+
*
|
|
59
|
+
* Evaluated with no row on purpose: `/_x` asks whether an actor may reach a capability at all, and
|
|
60
|
+
* there is no row to hand a row-level rule outside a real request. A rule that needs one therefore
|
|
61
|
+
* shows its no-row verdict, and the trace says so rather than letting the cell read as a flat deny.
|
|
62
|
+
*/
|
|
63
|
+
export function devPolicyMatrix(): readonly PolicyFact[] {
|
|
64
|
+
const actors = devActors();
|
|
65
|
+
return devPolicyGates().flatMap((gate) => {
|
|
66
|
+
const matrix = policyMatrix(gate.policy, { actors, input: {} });
|
|
67
|
+
return matrix.rows.map(
|
|
68
|
+
(row): PolicyFact => ({
|
|
69
|
+
permission: gate.permission,
|
|
70
|
+
actorId: row.actor,
|
|
71
|
+
allowed: row.allowed,
|
|
72
|
+
trace: [
|
|
73
|
+
`${gate.policy.label}: ${row.allowed ? 'allow' : 'deny'}`,
|
|
74
|
+
...(row.deciding === null ? [] : [`deciding rule: ${row.deciding}`]),
|
|
75
|
+
...(row.reason === null ? [] : [`reason: ${row.reason}`]),
|
|
76
|
+
`enforced in: ${gate.usedBy.join(', ')}`,
|
|
77
|
+
'evaluated with no row — a row-level rule decides again on the real request',
|
|
78
|
+
],
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
}
|
package/src/dev-queue.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// The database and the job queue, started together and released together. Split from
|
|
2
|
+
// `dev-runtime.ts` because `x jobs` needs exactly this pair and nothing else — and because a
|
|
3
|
+
// process that installs two ambient accessors (`db()`, `jobDriver()`) must have one place that
|
|
4
|
+
// takes both back, or the next command in the same process inherits a driver over a closed socket.
|
|
5
|
+
|
|
6
|
+
import type { PgliteClient, PostgresClient, SqlFragment } from '@ultimat3/db';
|
|
7
|
+
import {
|
|
8
|
+
createPgliteClient,
|
|
9
|
+
createPostgresClient,
|
|
10
|
+
pgliteDataDir,
|
|
11
|
+
raw,
|
|
12
|
+
setDbClient,
|
|
13
|
+
} from '@ultimat3/db';
|
|
14
|
+
import type { JobDriver, PgExecutor } from '@ultimat3/jobs';
|
|
15
|
+
import { createPgDriver, resetJobDriver, SQL_JOBS_TABLE, setJobDriver } from '@ultimat3/jobs';
|
|
16
|
+
import type { DevServices } from './dev-services';
|
|
17
|
+
|
|
18
|
+
/** Both embedded and external clients boot lazily and close explicitly. */
|
|
19
|
+
export type DevDbClient = PgliteClient | PostgresClient;
|
|
20
|
+
|
|
21
|
+
export interface RunningQueue {
|
|
22
|
+
readonly db: DevDbClient;
|
|
23
|
+
readonly jobs: JobDriver;
|
|
24
|
+
stop(): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function startDb(services: DevServices): DevDbClient {
|
|
28
|
+
const binding = services.db;
|
|
29
|
+
const client =
|
|
30
|
+
binding.mode === 'embedded'
|
|
31
|
+
? // `pgliteDataDir` is `@ultimat3/db`'s own reader of the `pglite://` form; a second parser
|
|
32
|
+
// here is a second thing to keep right when the form changes.
|
|
33
|
+
createPgliteClient({ dataDir: pgliteDataDir(binding.url) })
|
|
34
|
+
: createPostgresClient({ url: binding.url });
|
|
35
|
+
setDbClient(client);
|
|
36
|
+
return client;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `@ultimat3/jobs` deliberately depends on no database package: Postgres reaches it as an
|
|
41
|
+
* injected `PgExecutor`. Boot code is what supplies one, and this is the boot.
|
|
42
|
+
*
|
|
43
|
+
* The fragment is assembled by hand rather than through `sql`` ` because the driver hands over
|
|
44
|
+
* `$1..$n` text it wrote itself plus already-bound values — there is no interpolation to guard.
|
|
45
|
+
*/
|
|
46
|
+
function executorFor(client: DevDbClient): PgExecutor {
|
|
47
|
+
return {
|
|
48
|
+
query: <R>(text: string, values: readonly unknown[]): Promise<readonly R[]> =>
|
|
49
|
+
client.query<R>({ text, values } satisfies SqlFragment),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The dev queue is the real Postgres queue on the embedded Postgres — claiming, leases and the
|
|
55
|
+
* one-live-job-per-key index all behave here exactly as in production. A memory queue in dev
|
|
56
|
+
* would hide every bug this driver exists to make impossible.
|
|
57
|
+
*
|
|
58
|
+
* PGlite speaks the extended protocol, which carries one statement per round trip, so the DDL
|
|
59
|
+
* is applied statement by statement. Safe to split on `;`: `SQL_JOBS_TABLE` is a fixed constant
|
|
60
|
+
* with no semicolon inside a literal, and `driver-pg-sql.test.ts` is where that stays true.
|
|
61
|
+
*/
|
|
62
|
+
async function startJobs(client: DevDbClient): Promise<JobDriver> {
|
|
63
|
+
for (const statement of SQL_JOBS_TABLE.split(';')) {
|
|
64
|
+
if (statement.trim().length > 0) await client.execute(raw(statement));
|
|
65
|
+
}
|
|
66
|
+
const driver = createPgDriver({ executor: executorFor(client) });
|
|
67
|
+
setJobDriver(driver);
|
|
68
|
+
return driver;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Release both ambient accessors, then the resources behind them, in that order: a driver reset
|
|
73
|
+
* after its database is closed leaves a window where `jobDriver()` answers over a dead socket.
|
|
74
|
+
* A stale driver is worse than none — the next command sees one installed and skips queue
|
|
75
|
+
* startup entirely, so every query it makes fails on a connection this process already dropped.
|
|
76
|
+
*/
|
|
77
|
+
async function releaseQueue(db: DevDbClient, jobs: JobDriver | undefined): Promise<void> {
|
|
78
|
+
resetJobDriver();
|
|
79
|
+
setDbClient(undefined);
|
|
80
|
+
await jobs?.close?.();
|
|
81
|
+
await db.close();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The db + jobs half of `startServices`, alone: `x jobs` needs a real queue and nothing else —
|
|
86
|
+
* no transport, no storage, no mail — and booting those for a command that never reports on them
|
|
87
|
+
* would pay for services it cannot even use. `startServices` builds on this so there is one boot
|
|
88
|
+
* path for "which database" and "which queue", not two.
|
|
89
|
+
*/
|
|
90
|
+
export async function startQueue(services: DevServices): Promise<RunningQueue> {
|
|
91
|
+
const db = startDb(services);
|
|
92
|
+
try {
|
|
93
|
+
// Pay the Postgres boot here, so the first request is not the slow one and a broken database
|
|
94
|
+
// fails at boot rather than on some later query.
|
|
95
|
+
await db.ping();
|
|
96
|
+
const jobs = await startJobs(db);
|
|
97
|
+
return { db, jobs, stop: () => releaseQueue(db, jobs) };
|
|
98
|
+
} catch (error) {
|
|
99
|
+
// `db.ping()` or `startJobs` is where a broken database is supposed to fail. Without this,
|
|
100
|
+
// the caller exits holding the PGlite lock and the ambient accessors, and nothing is left to
|
|
101
|
+
// release them. The rejection that started the unwind is the one worth reporting.
|
|
102
|
+
try {
|
|
103
|
+
await releaseQueue(db, undefined);
|
|
104
|
+
} catch {
|
|
105
|
+
// Cleanup noise never replaces the boot failure.
|
|
106
|
+
}
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Projecting the route table onto HTTP routes `x dev` can serve. Every mode goes through
|
|
2
|
+
// `@ultimat3/render`'s own function for that mode — the CLI picks the mode and supplies the
|
|
3
|
+
// document, it never decides what a mode means or what headers it earns.
|
|
4
|
+
//
|
|
5
|
+
// The document is head + shell. Islands are the compiled client graph's, and there is no
|
|
6
|
+
// compiled graph before `x build`, so a dev page serves its real `<head>`, its real status and
|
|
7
|
+
// its real cache headers around an empty root — never a 404.
|
|
8
|
+
|
|
9
|
+
import type { Ctx } from '@ultimat3/core';
|
|
10
|
+
import type { RouteMeta as HttpRouteMeta, Route, RouteParams } from '@ultimat3/http';
|
|
11
|
+
import { asCtx, html, stream } from '@ultimat3/http';
|
|
12
|
+
import type { IsrController, RenderResult, RouteEntry } from '@ultimat3/render';
|
|
13
|
+
import {
|
|
14
|
+
contentHash,
|
|
15
|
+
createIsrController,
|
|
16
|
+
headFromMeta,
|
|
17
|
+
renderHead,
|
|
18
|
+
renderSpa,
|
|
19
|
+
renderSsr,
|
|
20
|
+
routeEntries,
|
|
21
|
+
SPA_ROOT_ID,
|
|
22
|
+
seoRenderers,
|
|
23
|
+
staticHeaders,
|
|
24
|
+
streamResult,
|
|
25
|
+
} from '@ultimat3/render';
|
|
26
|
+
|
|
27
|
+
export interface DevRenderOptions {
|
|
28
|
+
readonly buildId: string;
|
|
29
|
+
/** Injected so a test can drive the ISR store without a timer. */
|
|
30
|
+
readonly isr?: IsrController;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** What a route's `meta(data)` is given. `url` is a string because that is what `ld.*` embeds. */
|
|
34
|
+
export interface DevRouteData extends Record<string, unknown> {
|
|
35
|
+
readonly url: string;
|
|
36
|
+
readonly params: RouteParams;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const LANG = 'en';
|
|
40
|
+
|
|
41
|
+
const headFor = async (entry: RouteEntry, data: DevRouteData): Promise<string> =>
|
|
42
|
+
renderHead(
|
|
43
|
+
headFromMeta(await entry.config.meta(data), seoRenderers({ path: new URL(data.url).pathname })),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
async function documentFor(entry: RouteEntry, data: DevRouteData): Promise<string> {
|
|
47
|
+
return shellFor(await headFor(entry, data));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const shellFor = (head: string): string =>
|
|
51
|
+
`<!doctype html><html lang="${LANG}"><head>${head}</head>` +
|
|
52
|
+
`<body><div id="${SPA_ROOT_ID}"></div></body></html>`;
|
|
53
|
+
|
|
54
|
+
async function resultFor(
|
|
55
|
+
entry: RouteEntry,
|
|
56
|
+
data: DevRouteData,
|
|
57
|
+
options: DevRenderOptions,
|
|
58
|
+
isr: IsrController,
|
|
59
|
+
ctx: Ctx,
|
|
60
|
+
): Promise<RenderResult> {
|
|
61
|
+
const url = new URL(data.url);
|
|
62
|
+
switch (entry.config.render) {
|
|
63
|
+
case 'static': {
|
|
64
|
+
// Not `renderStatic`: that enumerates every prerendered path for the build. A request
|
|
65
|
+
// names exactly one, and it earns the same content-hashed headers.
|
|
66
|
+
const body = await documentFor(entry, data);
|
|
67
|
+
return { status: 200, headers: staticHeaders(contentHash(body), options.buildId), body };
|
|
68
|
+
}
|
|
69
|
+
case 'isr': {
|
|
70
|
+
const served = await isr.serve(url.pathname, () => documentFor(entry, data));
|
|
71
|
+
return served.result;
|
|
72
|
+
}
|
|
73
|
+
case 'spa':
|
|
74
|
+
return renderSpa({
|
|
75
|
+
entry,
|
|
76
|
+
buildId: options.buildId,
|
|
77
|
+
head: await headFor(entry, data),
|
|
78
|
+
chunks: [],
|
|
79
|
+
lang: LANG,
|
|
80
|
+
});
|
|
81
|
+
case 'stream': {
|
|
82
|
+
const head = await headFor(entry, data);
|
|
83
|
+
return streamResult(
|
|
84
|
+
{
|
|
85
|
+
head: `<!doctype html><html lang="${LANG}"><head>${head}</head><body>`,
|
|
86
|
+
shell: `<div id="${SPA_ROOT_ID}"></div>`,
|
|
87
|
+
holes: [],
|
|
88
|
+
},
|
|
89
|
+
{ buildId: options.buildId },
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
default:
|
|
93
|
+
return renderSsr({ entry, params: data.params, url, ctx }, () => documentFor(entry, data), {
|
|
94
|
+
buildId: options.buildId,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const responseOf = (result: RenderResult): Response =>
|
|
100
|
+
typeof result.body === 'string'
|
|
101
|
+
? html(result.body, { status: result.status, headers: result.headers })
|
|
102
|
+
: stream(result.body, { status: result.status, headers: result.headers });
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* `auth` follows the route's own guard, so a gated page is gated in dev by the same pipeline
|
|
106
|
+
* stage that gates it in production. A route that declares no policy is public by declaration.
|
|
107
|
+
*/
|
|
108
|
+
const metaOf = (entry: RouteEntry): HttpRouteMeta => ({
|
|
109
|
+
name: entry.file,
|
|
110
|
+
auth: entry.config.policy === undefined ? 'public' : 'required',
|
|
111
|
+
render: entry.config.render,
|
|
112
|
+
tags: [entry.surface],
|
|
113
|
+
...(entry.config.policy === undefined ? {} : { policy: entry.config.policy.permission }),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/** One HTTP route per registered `route` primitive, in the table's own order. */
|
|
117
|
+
export function appRoutes(options: DevRenderOptions): readonly Route[] {
|
|
118
|
+
const isr = options.isr ?? createIsrController({ buildId: options.buildId });
|
|
119
|
+
return routeEntries().map((entry) => ({
|
|
120
|
+
method: 'GET' as const,
|
|
121
|
+
path: entry.path,
|
|
122
|
+
meta: metaOf(entry),
|
|
123
|
+
// `ctx.params` is the router's own match — the CLI never re-parses a path it did not match.
|
|
124
|
+
handler: async (request, ctx): Promise<Response> => {
|
|
125
|
+
const data: DevRouteData = { url: request.url.href, params: ctx.params };
|
|
126
|
+
return responseOf(await resultFor(entry, data, options, isr, asCtx(ctx)));
|
|
127
|
+
},
|
|
128
|
+
}));
|
|
129
|
+
}
|