@ultimat3/admin 22.15.0 → 24.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.
Files changed (97) hide show
  1. package/CLAUDE.md +83 -91
  2. package/README.md +282 -89
  3. package/package.json +19 -17
  4. package/src/action-form.tsx +139 -0
  5. package/src/action-gate.ts +141 -68
  6. package/src/action-input.ts +119 -0
  7. package/src/action-label.ts +9 -0
  8. package/src/action-row.ts +37 -0
  9. package/src/actions.tsx +89 -66
  10. package/src/actor.ts +53 -0
  11. package/src/admin.module.scss +298 -0
  12. package/src/admin.ts +187 -23
  13. package/src/audit-pg.ts +207 -0
  14. package/src/audit-schema.ts +47 -0
  15. package/src/audit.ts +109 -22
  16. package/src/authz.ts +11 -0
  17. package/src/batch-bar.tsx +66 -0
  18. package/src/batch-job.ts +152 -0
  19. package/src/batch-matching.ts +87 -0
  20. package/src/batch-queue.ts +90 -0
  21. package/src/batch.ts +396 -0
  22. package/src/catalog-keys.ts +55 -0
  23. package/src/crud-input.ts +104 -0
  24. package/src/crud-outcome.ts +224 -0
  25. package/src/crud.ts +172 -176
  26. package/src/describe.ts +131 -0
  27. package/src/detail.tsx +115 -43
  28. package/src/dev/data.ts +27 -12
  29. package/src/dev/facts.ts +28 -1
  30. package/src/dev/index.ts +1 -0
  31. package/src/dev/panel-cache.ts +8 -3
  32. package/src/dev/panel-db.ts +37 -5
  33. package/src/dev/panel-jobs.ts +8 -0
  34. package/src/dev/panel-routes.ts +10 -0
  35. package/src/dev/panel-timeline.ts +7 -2
  36. package/src/dev/panel.ts +10 -2
  37. package/src/dev/server.ts +16 -11
  38. package/src/dev/sql-runner.ts +31 -0
  39. package/src/entity-columns.ts +24 -5
  40. package/src/errors.ts +92 -5
  41. package/src/fields.ts +63 -4
  42. package/src/form-decode.ts +132 -0
  43. package/src/form.tsx +96 -49
  44. package/src/index.ts +199 -9
  45. package/src/jobs/fleet-repo.ts +155 -0
  46. package/src/jobs/job-actions.ts +172 -0
  47. package/src/jobs/job-entities.ts +118 -0
  48. package/src/jobs/job-labels.ts +108 -0
  49. package/src/jobs/job-repo.ts +137 -0
  50. package/src/jobs/job-resources.ts +211 -0
  51. package/src/jobs/job-where.ts +170 -0
  52. package/src/jobs/operator.ts +34 -0
  53. package/src/jobs/overview-data.ts +129 -0
  54. package/src/jobs/overview-tab.tsx +61 -0
  55. package/src/jobs/overview.tsx +189 -0
  56. package/src/json-text.ts +52 -0
  57. package/src/keyset-seek.ts +189 -0
  58. package/src/layout.tsx +55 -43
  59. package/src/list-columns.tsx +53 -0
  60. package/src/list-filter-bar.tsx +207 -0
  61. package/src/list-filters.ts +242 -0
  62. package/src/list-request.ts +129 -0
  63. package/src/list-scope-tabs.tsx +60 -0
  64. package/src/list-scope.ts +113 -0
  65. package/src/list.tsx +111 -72
  66. package/src/mcp-tools.ts +57 -13
  67. package/src/mcp.ts +112 -38
  68. package/src/mounts.ts +44 -0
  69. package/src/page-guard.tsx +26 -29
  70. package/src/pagination.ts +10 -4
  71. package/src/policy-bridge.ts +115 -18
  72. package/src/registry.ts +140 -9
  73. package/src/related.ts +105 -0
  74. package/src/relations.ts +240 -0
  75. package/src/renderers.tsx +89 -0
  76. package/src/repo-entity.ts +195 -0
  77. package/src/resource-fields.ts +116 -0
  78. package/src/resource-layout.ts +89 -0
  79. package/src/resource-list.ts +194 -0
  80. package/src/resource.ts +129 -79
  81. package/src/route-config.ts +71 -0
  82. package/src/routes.ts +55 -54
  83. package/src/row-scope-write.ts +130 -0
  84. package/src/row-version.ts +20 -0
  85. package/src/screen-action.tsx +333 -0
  86. package/src/screen-frame.tsx +167 -0
  87. package/src/screen-home.tsx +201 -0
  88. package/src/screen-lookup.tsx +157 -0
  89. package/src/screen-resource.tsx +441 -0
  90. package/src/screen-system.tsx +75 -0
  91. package/src/screens.ts +56 -0
  92. package/src/search.ts +22 -10
  93. package/src/stylesheet-scope.ts +8 -0
  94. package/src/test-ctx.ts +31 -0
  95. package/src/validate.ts +3 -0
  96. package/src/widget-value.ts +36 -8
  97. package/src/widgets.tsx +84 -16
package/src/admin.ts CHANGED
@@ -2,21 +2,40 @@
2
2
  // each action off the entity it names, builds the nav, and returns the route table plus the
3
3
  // audit log and authz every surface then shares. Nothing here queries or renders; it wires.
4
4
 
5
+ // For its side effect: the admin's stylesheets are claimed for the surface that serves them.
6
+ import './stylesheet-scope';
7
+ import { ANONYMOUS_ADMIN_ACTOR, requestActor } from './actor';
5
8
  import { type AuditLog, memoryAuditLog } from './audit';
6
9
  import type { AdminActor, AdminAuthz } from './authz';
10
+ import { batchPlan } from './batch';
11
+ import { adminBatchJob } from './batch-job';
12
+ import { adminCatalogKeys } from './catalog-keys';
7
13
  import type { CrudCtx } from './crud';
8
14
  import { permissionsForOperation } from './crud';
9
- import { AdminActionDuplicateError, AdminPagePathInvalidError } from './errors';
15
+ import { type AdminDescription, describeAdmin } from './describe';
16
+ import {
17
+ AdminActionDuplicateError,
18
+ AdminFieldUnsupportedError,
19
+ AdminPagePathInvalidError,
20
+ AdminRepoUnboundError,
21
+ } from './errors';
22
+ import { JOB_PERMISSION_NOUN, JOBS_GROUP, JOBS_PATH, jobsAdmin } from './jobs/job-resources';
23
+ import { registerAdminMount } from './mounts';
10
24
  import { adminNav, type NavGroup, type NavItem, type NavOptions, visibleNav } from './nav';
11
25
  import { type AdminCustomPage, type AdminPageComponent, pageNavItems, pageRoutes } from './pages';
12
- import type { AdminOperation } from './permissions';
13
- import type { AdminAction, AdminEntity, AdminJobSummary, AdminRow } from './registry';
26
+ import { ADMIN_OPERATIONS, type AdminOperation } from './permissions';
27
+ import { declareAdminPermissions, roleAuthz } from './policy-bridge';
28
+ import type { AdminAction, AdminDb, AdminEntity, AdminRepo, AdminRow } from './registry';
29
+ import { relatedOf } from './related';
30
+ import { adminRepoFor, adminTablesOf } from './repo-entity';
14
31
  import {
15
32
  type AdminResource,
16
33
  type AdminResourceOptions,
17
34
  adminResource,
18
35
  resourceFor,
19
36
  } from './resource';
37
+ import { LOOKUP_SEGMENT } from './resource-list';
38
+ import { mountAdminRoutes } from './route-config';
20
39
  import { type AdminBranding, adminBranding, type ThemeAttributes, themeAttributes } from './theme';
21
40
 
22
41
  /** The host app's auth hook: it owns sessions, the admin owns what a session may do. */
@@ -27,6 +46,8 @@ export interface AdminAuth {
27
46
 
28
47
  export type AdminView =
29
48
  | 'list'
49
+ /** A resource read as a TARGET: the picker every filter and input referencing it searches. */
50
+ | 'lookup'
30
51
  | 'detail'
31
52
  | 'create'
32
53
  | 'edit'
@@ -56,16 +77,27 @@ export interface AdminRoute {
56
77
 
57
78
  export interface DefineAdminInput {
58
79
  readonly entities: readonly AdminEntity[];
59
- /** Per-entity overrides and repo binding, keyed by entity name. */
80
+ /**
81
+ * The app's `database()` handle, passed once. Every entity's rows are read and written through
82
+ * its own table there — tenancy, soft delete and sealing included — so an app writes no adapter.
83
+ * An entity the handle does not carry needs `resources.<entity>.repo`, or it is
84
+ * `X_ADMIN_REPO_UNBOUND` here, at declaration.
85
+ */
86
+ readonly db?: AdminDb;
87
+ /** Per-entity overrides, keyed by entity name. `repo` replaces the handle's table for one. */
60
88
  readonly resources?: Readonly<Record<string, AdminResourceOptions>>;
61
89
  /** Registered actions. Each is attached to `action.entity`, or the global toolbar. */
62
90
  readonly actions?: readonly AdminAction[];
63
- readonly jobs?: readonly AdminJobSummary[];
64
91
  /** Screens the generator would never write. Declared here so they are not a second surface. */
65
92
  readonly pages?: readonly AdminCustomPage[];
66
93
  readonly nav?: NavOptions;
67
94
  readonly branding?: Partial<AdminBranding>;
68
- readonly auth: AdminAuth;
95
+ /**
96
+ * Both halves default. `actor`: the one the HTTP pipeline resolved for this request. `authz`:
97
+ * the app's role map (`roleAuthz()`) — every permission is "does a role this actor holds grant
98
+ * it". Pass `policyAuthz({ policies })` when a rule reads the row or the tenant.
99
+ */
100
+ readonly auth?: Partial<AdminAuth>;
69
101
  readonly audit?: AuditLog;
70
102
  /** Mount point. Every route path is prefixed with it. */
71
103
  readonly basePath?: string;
@@ -78,7 +110,6 @@ export interface AdminApp {
78
110
  readonly resources: readonly AdminResource[];
79
111
  /** Actions with no entity: imports, backfills, anything app-wide. */
80
112
  readonly globalActions: readonly AdminAction[];
81
- readonly jobs: readonly AdminJobSummary[];
82
113
  readonly nav: readonly NavGroup[];
83
114
  readonly routes: readonly AdminRoute[];
84
115
  readonly audit: AuditLog;
@@ -88,12 +119,29 @@ export interface AdminApp {
88
119
  /** Nav for one actor, with everything they cannot open removed. */
89
120
  navFor(ctx: CrudCtx): readonly NavGroup[];
90
121
  ctx(input: { readonly actor: AdminActor; readonly requestId: string }): CrudCtx;
122
+ /**
123
+ * The handle for the request in flight: `auth.actor(request)`, with anonymous as a real actor a
124
+ * decision refuses. What every mounted screen and every write is decided against.
125
+ */
126
+ requestCtx(request: Request): Promise<CrudCtx>;
127
+ /**
128
+ * This admin as plain data — resources with their filters, sorts, scopes and row scope, routes
129
+ * with their permissions. What the manifest records; JSON-safe and sorted.
130
+ */
131
+ describe(): AdminDescription;
132
+ /**
133
+ * Every catalog key this declaration derives, sorted and distinct — what the `i18n` step asks
134
+ * each of the app's catalogs for (`catalog-keys.ts`).
135
+ */
136
+ catalogKeys(): readonly string[];
91
137
  }
92
138
 
93
139
  const VIEW_OPERATION: Readonly<
94
140
  Record<Exclude<AdminView, 'jobs' | 'audit' | 'dashboard' | 'page'>, AdminOperation>
95
141
  > = {
96
142
  list: 'list',
143
+ // A lookup IS a list read of the target — same gate, same row scope, same audit entry.
144
+ lookup: 'list',
97
145
  detail: 'detail',
98
146
  create: 'create',
99
147
  edit: 'update',
@@ -114,11 +162,12 @@ function resourceRoutes(basePath: string, resource: AdminResource): readonly Adm
114
162
  view,
115
163
  entity: resource.name,
116
164
  titleKey: resource.titleKey,
117
- permissions: permissionsForOperation(resource.name, op),
165
+ permissions: permissionsForOperation(resource.permission, op),
118
166
  });
119
167
  };
120
168
 
121
169
  add('list', base);
170
+ add('lookup', `${base}/${LOOKUP_SEGMENT}`);
122
171
  add('create', `${base}/new`);
123
172
  add('detail', `${base}/:id`);
124
173
  add('edit', `${base}/:id/edit`);
@@ -187,24 +236,112 @@ function assertUniqueActionNames(
187
236
  }
188
237
  }
189
238
 
239
+ /**
240
+ * The repo one resource reads through: its own `repo:` override, else the entity's table in the
241
+ * app's handle. Neither is refused HERE — a resource with no repo mounts four routes that all fail
242
+ * on the first row they ask for, and nothing would say so until an operator opened one.
243
+ */
244
+ function repoFor(
245
+ entity: AdminEntity,
246
+ override: AdminRepo<AdminRow> | undefined,
247
+ tables: ReadonlyMap<string, Parameters<typeof adminRepoFor>[1]>,
248
+ ): AdminRepo<AdminRow> {
249
+ if (override !== undefined) return override;
250
+ const table = tables.get(entity.$name);
251
+ if (table === undefined) {
252
+ throw new AdminRepoUnboundError({ entity: entity.$name, handles: [...tables.keys()].sort() });
253
+ }
254
+ return adminRepoFor(entity, table);
255
+ }
256
+
257
+ /**
258
+ * A batch action belongs to a resource — the batch bar is a list's — and a declared threshold is a
259
+ * count. An action with a threshold declares the one `admin.batch` job here, at declaration, so a
260
+ * process that loads this admin is a process that can run its queued chunks.
261
+ */
262
+ function assertBatchActions(
263
+ resources: readonly AdminResource[],
264
+ declared: readonly AdminAction[],
265
+ ): void {
266
+ for (const action of declared) {
267
+ if (action.batch !== undefined && action.entity === undefined) {
268
+ // The closest shipped code is a field's, and its cause renders `<entity>.<field>:` — so the
269
+ // two halves name WHERE the option was written, never a column of an entity called "admin".
270
+ throw new AdminFieldUnsupportedError({
271
+ entity: 'defineAdmin',
272
+ field: `actions["${action.name}"].batch`,
273
+ cause:
274
+ "declares batch with no entity: the batch bar is a resource list's, and a global action belongs to none",
275
+ fix: `name the resource the batch runs over — { name: '${action.name}', entity: '<entity>', batch: true } — or drop batch`,
276
+ });
277
+ }
278
+ }
279
+ for (const action of [...resources.flatMap((resource) => resource.actions), ...declared]) {
280
+ if (action.matching !== undefined && action.batch === undefined) {
281
+ throw new AdminFieldUnsupportedError({
282
+ entity: 'defineAdmin',
283
+ field: `actions["${action.name}"].matching`,
284
+ cause:
285
+ 'declares matching without batch: "all matching" is an option of the batch bar, and this action is not in it',
286
+ fix: `add batch: true to { name: '${action.name}', … }, or drop matching`,
287
+ });
288
+ }
289
+ }
290
+ for (const action of resources.flatMap((resource) => resource.actions)) {
291
+ if (batchPlan(action) !== null) adminBatchJob();
292
+ }
293
+ }
294
+
295
+ /** The overview's sidebar link, gated on the route's own pair so the link and the page agree. */
296
+ const jobsOverviewNavItem = (): NavItem & { readonly group: string } => ({
297
+ key: 'jobs',
298
+ labelKey: 'admin.jobs.title',
299
+ href: JOBS_PATH,
300
+ entity: null,
301
+ permissions: permissionsForOperation(JOB_PERMISSION_NOUN, 'list'),
302
+ group: JOBS_GROUP,
303
+ });
304
+
190
305
  /** Derive the whole admin from the registries. */
191
306
  export function defineAdmin(input: DefineAdminInput): AdminApp {
192
307
  const basePath = input.basePath ?? '/admin';
193
308
  const branding = adminBranding(input.branding ?? {});
194
309
  const audit = input.audit ?? memoryAuditLog();
195
- const actions = input.actions ?? [];
196
- const overrides = input.resources ?? {};
310
+ // The jobs dashboard is declared for every admin: four resources and their actions, each an
311
+ // ordinary declaration an app could have written — and an app's `resources:` entry for one of
312
+ // them (`x_jobs: { rows }`) overrides it as it would its own.
313
+ const jobs = jobsAdmin(basePath);
314
+ const actions = [...(input.actions ?? []), ...jobs.actions];
315
+ const overrides: Readonly<Record<string, AdminResourceOptions>> = {
316
+ ...jobs.resources,
317
+ ...Object.fromEntries(
318
+ Object.entries(input.resources ?? {}).map(([name, own]) => [
319
+ name,
320
+ { ...(Object.hasOwn(jobs.resources, name) ? jobs.resources[name] : {}), ...own },
321
+ ]),
322
+ ),
323
+ };
324
+ const tables = input.db === undefined ? new Map() : adminTablesOf(input.db);
325
+ const auth: AdminAuth = {
326
+ actor: input.auth?.actor ?? (() => requestActor().actor),
327
+ authz: input.auth?.authz ?? roleAuthz(),
328
+ };
197
329
 
198
- const resources = input.entities.map((entity) => {
330
+ const resources = [...input.entities, ...jobs.entities].map((entity) => {
199
331
  const own = actions.filter((action) => action.entity === entity.$name);
200
- const opts = overrides[entity.$name];
332
+ const opts = Object.hasOwn(overrides, entity.$name) ? overrides[entity.$name] : undefined;
201
333
  return adminResource<AdminRow>(entity, {
202
334
  ...(opts ?? {}),
335
+ repo: repoFor(entity, opts?.repo, tables),
203
336
  actions: [...(opts?.actions ?? []), ...own],
204
337
  });
205
338
  });
206
339
 
207
340
  assertUniqueActionNames(resources, actions);
341
+ // Refused where they are written, not on the first detail page: a `related` name that is not a
342
+ // `hasMany` of the entity, a batch threshold that is not a count.
343
+ for (const resource of resources) relatedOf(resources, resource);
344
+ assertBatchActions(resources, actions);
208
345
 
209
346
  const pages = input.pages ?? [];
210
347
  const navOptions = input.nav ?? {};
@@ -212,7 +349,7 @@ export function defineAdmin(input: DefineAdminInput): AdminApp {
212
349
  ...(navOptions.extra ?? []),
213
350
  ...pageNavItems(pages),
214
351
  ];
215
- const nav = adminNav(resources, { ...navOptions, extra });
352
+ const nav = adminNav(resources, { ...navOptions, extra: [...extra, jobsOverviewNavItem()] });
216
353
  assertUniqueResourcePaths(basePath, resources);
217
354
  const generated: AdminRoute[] = [
218
355
  {
@@ -230,11 +367,11 @@ export function defineAdmin(input: DefineAdminInput): AdminApp {
230
367
  permissions: permissionsForOperation('admin', 'search'),
231
368
  },
232
369
  {
233
- path: `${basePath}/jobs`,
370
+ path: `${basePath}${JOBS_PATH}`,
234
371
  view: 'jobs',
235
372
  entity: null,
236
373
  titleKey: 'admin.jobs.title',
237
- permissions: permissionsForOperation('job', 'list'),
374
+ permissions: permissionsForOperation(JOB_PERMISSION_NOUN, 'list'),
238
375
  },
239
376
  {
240
377
  path: `${basePath}/audit`,
@@ -256,26 +393,53 @@ export function defineAdmin(input: DefineAdminInput): AdminApp {
256
393
  ),
257
394
  ];
258
395
 
259
- return {
396
+ // Every permission a route or an action names is one the policy layer must be able to hear
397
+ // about, whichever authz decides it: `can()` refuses a name its registry lacks.
398
+ declareAdminPermissions([
399
+ ...routes.flatMap((route) => route.permissions),
400
+ ...resources.flatMap((resource) =>
401
+ ADMIN_OPERATIONS.flatMap((op) => permissionsForOperation(resource.permission, op)),
402
+ ),
403
+ ...[...resources.flatMap((resource) => resource.actions), ...actions].map(
404
+ (action) => action.permission,
405
+ ),
406
+ ]);
407
+
408
+ const ctx = ({ actor, requestId }: { actor: AdminActor; requestId: string }): CrudCtx => ({
409
+ actor,
410
+ requestId,
411
+ audit,
412
+ authz: auth.authz,
413
+ });
414
+
415
+ const app: AdminApp = {
260
416
  basePath,
261
417
  branding,
262
418
  theme: themeAttributes(branding),
263
419
  resources,
264
420
  globalActions: actions.filter((action) => action.entity === undefined),
265
- jobs: input.jobs ?? [],
266
421
  nav,
267
422
  routes,
268
423
  audit,
269
- authz: input.auth.authz,
270
- auth: input.auth,
424
+ authz: auth.authz,
425
+ auth,
271
426
  resource(name: string): AdminResource {
272
427
  return resourceFor(resources, name);
273
428
  },
274
- navFor(ctx: CrudCtx): readonly NavGroup[] {
275
- return visibleNav(nav, resources, ctx);
429
+ navFor(crud: CrudCtx): readonly NavGroup[] {
430
+ return visibleNav(nav, resources, crud);
276
431
  },
277
- ctx({ actor, requestId }): CrudCtx {
278
- return { actor, requestId, audit, authz: input.auth.authz };
432
+ ctx,
433
+ async requestCtx(request: Request): Promise<CrudCtx> {
434
+ const actor = await auth.actor(request);
435
+ return ctx({ actor: actor ?? ANONYMOUS_ADMIN_ACTOR, requestId: requestActor().requestId });
279
436
  },
437
+ describe: () => describeAdmin(basePath, resources, routes, audit.kind),
438
+ catalogKeys: () => adminCatalogKeys(app),
280
439
  };
440
+ // In the framework's one route list, as what they are: mounted, each with its permissions.
441
+ mountAdminRoutes(basePath, routes);
442
+ // Declared is mountable: a host serves every admin in this registry under its base path.
443
+ registerAdminMount(app);
444
+ return app;
281
445
  }
@@ -0,0 +1,207 @@
1
+ // The durable audit log: one framework table, one insert per entry, read back by keyset. What
2
+ // `defineAdmin({ audit: postgresAuditLog() })` names — the memory log is a ring that forgets at
3
+ // every restart, and a row's history that ends at the last deploy is not a history.
4
+
5
+ import { isUltimateError, uuid } from '@ultimat3/core';
6
+ import { type DbClient, db, withTransaction } from '@ultimat3/db';
7
+ import {
8
+ AUDIT_READ_OPERATIONS,
9
+ type AuditDraft,
10
+ type AuditEntry,
11
+ type AuditFieldDiff,
12
+ type AuditLog,
13
+ type AuditOutcome,
14
+ type AuditQuery,
15
+ type AuditSink,
16
+ auditEntry,
17
+ auditLimit,
18
+ isAllowedRead,
19
+ } from './audit';
20
+ import { ADMIN_AUDIT_TABLE } from './audit-schema';
21
+ import { jsonText } from './json-text';
22
+
23
+ const SUBJECT = 'postgresAuditLog';
24
+
25
+ /** `recorded_at` is the database's clock and not a parameter: the gap to `at` is the audit lag. */
26
+ export const SQL_ADMIN_AUDIT_INSERT = `
27
+ insert into x_admin_audit (
28
+ id, at, request_id, actor_id, actor_roles, org_id,
29
+ operation, kind, entity, entity_id, permission, outcome, reason, diff
30
+ ) values (
31
+ $1::uuid, $2::timestamptz, $3, $4, $5::jsonb, $6,
32
+ $7, $8, $9, $10, $11, $12, $13, $14::jsonb
33
+ )
34
+ `;
35
+
36
+ const COLUMNS =
37
+ 'id, at, request_id, actor_id, actor_roles, org_id, operation, kind, entity, entity_id, permission, outcome, reason, diff';
38
+
39
+ interface AuditRow {
40
+ readonly id: string;
41
+ readonly at: Date | string;
42
+ readonly request_id: string;
43
+ readonly actor_id: string;
44
+ readonly actor_roles: unknown;
45
+ readonly org_id: string | null;
46
+ readonly operation: string;
47
+ readonly kind: string;
48
+ readonly entity: string;
49
+ readonly entity_id: string | null;
50
+ readonly permission: string;
51
+ readonly outcome: string;
52
+ readonly reason: string;
53
+ readonly diff: unknown;
54
+ }
55
+
56
+ /** A `jsonb` column, whichever way the driver hands it back: parsed, or as its text. */
57
+ const parsed = (value: unknown): unknown => {
58
+ if (typeof value !== 'string') return value;
59
+ try {
60
+ return JSON.parse(value);
61
+ } catch {
62
+ return null;
63
+ }
64
+ };
65
+
66
+ const OUTCOMES: readonly AuditOutcome[] = ['allowed', 'denied', 'failed'];
67
+
68
+ const entryOf = (row: AuditRow): AuditEntry => {
69
+ const roles = parsed(row.actor_roles);
70
+ const diff = parsed(row.diff);
71
+ const outcome = OUTCOMES.find((known) => known === row.outcome) ?? 'failed';
72
+ return {
73
+ id: row.id,
74
+ at: (row.at instanceof Date ? row.at : new Date(row.at)).toISOString(),
75
+ requestId: row.request_id,
76
+ actor: {
77
+ id: row.actor_id,
78
+ roles: Array.isArray(roles) ? roles.map(String) : [],
79
+ ...(row.org_id === null ? {} : { orgId: row.org_id }),
80
+ },
81
+ operation: row.operation,
82
+ kind: row.kind === 'action' ? 'action' : 'operation',
83
+ entity: row.entity,
84
+ entityId: row.entity_id,
85
+ permission: row.permission,
86
+ outcome,
87
+ reason: row.reason,
88
+ diff: Array.isArray(diff) ? (diff as readonly AuditFieldDiff[]) : [],
89
+ };
90
+ };
91
+
92
+ /**
93
+ * The `where` of one read, built from the predicates the query actually names — never
94
+ * `($1 is null or entity = $1)`, which hides the index from the planner. Every fragment is fixed
95
+ * text; only values are bound.
96
+ */
97
+ function selectFor(query: AuditQuery, limit: number): { text: string; values: unknown[] } {
98
+ const where: string[] = [];
99
+ const values: unknown[] = [];
100
+ const bind = (value: unknown): string => {
101
+ values.push(value);
102
+ return `$${String(values.length)}`;
103
+ };
104
+ if (query.entity !== undefined) where.push(`entity = ${bind(query.entity)}`);
105
+ if (query.entityId !== undefined) where.push(`entity_id = ${bind(query.entityId)}`);
106
+ if (query.actorId !== undefined) where.push(`actor_id = ${bind(query.actorId)}`);
107
+ if (query.orgId !== undefined) where.push(`org_id = ${bind(query.orgId)}`);
108
+ if (query.before !== undefined) {
109
+ where.push(
110
+ `(at, id) < (${bind(query.before.at)}::timestamptz, ${bind(query.before.id)}::uuid)`,
111
+ );
112
+ }
113
+ if (query.changes === true) {
114
+ const reads = AUDIT_READ_OPERATIONS.map((operation) => bind(operation)).join(', ');
115
+ where.push(`(kind = 'action' or outcome <> 'allowed' or operation not in (${reads}))`);
116
+ }
117
+ const filter = where.length === 0 ? '' : ` where ${where.join(' and ')}`;
118
+ return {
119
+ text: `select ${COLUMNS} from ${ADMIN_AUDIT_TABLE}${filter} order by at desc, id desc limit ${bind(limit)}`,
120
+ values,
121
+ };
122
+ }
123
+
124
+ export interface PostgresAuditLogOptions {
125
+ /**
126
+ * The database to write to. Omitted — the ordinary case — it is the process's own (`db()`), so
127
+ * an entry joins whatever transaction is open and every role writes through the pool it booted.
128
+ */
129
+ readonly client?: DbClient;
130
+ /**
131
+ * Persist the reads that were allowed too — every list page, every row opened. Off by default:
132
+ * that is one insert per GET, and a trail of who LOOKED is a decision an app makes. A refused
133
+ * read is an event and is always written.
134
+ */
135
+ readonly reads?: boolean;
136
+ readonly sinks?: readonly AuditSink[];
137
+ readonly now?: () => Date;
138
+ /** Must answer a uuid: the column is one. Omitted, a time-ordered v7. */
139
+ readonly nextId?: () => string;
140
+ }
141
+
142
+ /**
143
+ * The audit log an admin names to keep its trail: `defineAdmin({ audit: postgresAuditLog() })`.
144
+ *
145
+ * One `insert` per entry, and `atomic` puts a write and its entry in one transaction — so a row
146
+ * never changes without the entry that says who changed it. A repository pinned to its own client
147
+ * cannot join that transaction (`X_REPO_CLIENT_PINNED`, raised before its first statement is
148
+ * sent): the write then runs as it would have with no log, and its entry follows it.
149
+ */
150
+ export function postgresAuditLog(options: PostgresAuditLogOptions = {}): AuditLog {
151
+ const now = options.now ?? ((): Date => new Date());
152
+ const nextId = options.nextId ?? uuid;
153
+ const sinks = options.sinks ?? [];
154
+ const reads = options.reads === true;
155
+ // Resolved per statement, never captured: inside `atomic` it is the open transaction.
156
+ const client = (): DbClient => (options.client === undefined ? db() : options.client);
157
+
158
+ return {
159
+ kind: 'postgres',
160
+ async append(draft: AuditDraft): Promise<AuditEntry> {
161
+ const entry = auditEntry(draft, nextId(), now());
162
+ if (reads || !isAllowedRead(entry)) {
163
+ // A plain `{ text, values }`, the shape `pgExecutorFor` hands the same client: the text
164
+ // is this file's constant and every value is bound.
165
+ await client().query({
166
+ text: SQL_ADMIN_AUDIT_INSERT,
167
+ values: [
168
+ entry.id,
169
+ entry.at,
170
+ entry.requestId,
171
+ entry.actor.id,
172
+ JSON.stringify(entry.actor.roles),
173
+ entry.actor.orgId ?? null,
174
+ entry.operation,
175
+ entry.kind,
176
+ entry.entity,
177
+ entry.entityId,
178
+ entry.permission,
179
+ entry.outcome,
180
+ entry.reason,
181
+ // `jsonText`, not `JSON.stringify`: a `money()` diff holds a bigint, which the
182
+ // second throws on — after the write it describes has already happened.
183
+ jsonText(entry.diff),
184
+ ],
185
+ });
186
+ }
187
+ for (const sink of sinks) await sink.write(entry);
188
+ return entry;
189
+ },
190
+ async entries(query = {}): Promise<readonly AuditEntry[]> {
191
+ const select = selectFor(query, auditLimit(SUBJECT, query));
192
+ const rows = await client().query<AuditRow>(select);
193
+ return rows.map(entryOf);
194
+ },
195
+ async atomic<T>(run: () => Promise<T>): Promise<T> {
196
+ try {
197
+ return await withTransaction(
198
+ run,
199
+ options.client === undefined ? {} : { client: options.client },
200
+ );
201
+ } catch (error) {
202
+ if (isUltimateError(error) && error.code === 'X_REPO_CLIENT_PINNED') return run();
203
+ throw error;
204
+ }
205
+ },
206
+ };
207
+ }
@@ -0,0 +1,47 @@
1
+ // The DDL of the admin's one framework table, and nothing else: a LEAF module, so the boot's
2
+ // `FRAMEWORK_SCHEMA` (`@ultimat3/cli`) can apply it in every role through `@ultimat3/admin/schema`
3
+ // without pulling one screen, one view or one stylesheet into a worker's module graph.
4
+
5
+ /** The table. One name, read by this DDL and by `audit-pg.ts`'s insert and select. */
6
+ export const ADMIN_AUDIT_TABLE = 'x_admin_audit';
7
+
8
+ /**
9
+ * Applied by the boot (`@ultimat3/cli`'s `FRAMEWORK_SCHEMA`), never by an app migration — the rule
10
+ * every framework table follows. A plain table: there is no `appendOnly` entity modifier in this
11
+ * tree, so "append-only" is `audit-pg.ts` issuing an `insert` and a `select` and nothing else.
12
+ *
13
+ * **Two indexes.** `(entity, entity_id, at desc, id desc)` is the detail page's history card: one
14
+ * row's trail, newest first, a keyset seek with no sort. `(at desc, id desc)` is the audit screen.
15
+ * `actor_id` and `org_id` have none — both are filters over the second index's order, and an index
16
+ * is a write cost paid on every audited request.
17
+ *
18
+ * **`diff` is what the admin already redacted.** A sealed column and a `sensitive` field reach it
19
+ * as `[redacted]` (`crud-input.ts`'s `rowDiff`), so no plaintext is ever a value in this table.
20
+ *
21
+ * **No retention and no purge**, for `x_audit`'s reason: how long a trail is kept is a legal
22
+ * question with a different answer per app.
23
+ */
24
+ export const SQL_ADMIN_AUDIT_TABLE = `
25
+ create table if not exists x_admin_audit (
26
+ id uuid primary key,
27
+ at timestamptz not null,
28
+ request_id text not null,
29
+ actor_id text not null,
30
+ actor_roles jsonb not null,
31
+ org_id text,
32
+ operation text not null,
33
+ kind text not null,
34
+ entity text not null,
35
+ entity_id text,
36
+ permission text not null,
37
+ outcome text not null,
38
+ reason text not null,
39
+ diff jsonb not null,
40
+ recorded_at timestamptz not null default now()
41
+ );
42
+
43
+ create index if not exists x_admin_audit_entity_idx
44
+ on x_admin_audit (entity, entity_id, at desc, id desc);
45
+
46
+ create index if not exists x_admin_audit_at_idx on x_admin_audit (at desc, id desc);
47
+ `;