@ultimat3/admin 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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +115 -0
  3. package/package.json +48 -0
  4. package/src/action-gate.ts +202 -0
  5. package/src/actions.tsx +94 -0
  6. package/src/admin.ts +186 -0
  7. package/src/ai-panes.ts +139 -0
  8. package/src/audit.ts +183 -0
  9. package/src/authz.ts +129 -0
  10. package/src/crud.ts +278 -0
  11. package/src/detail.tsx +121 -0
  12. package/src/dev/data.ts +344 -0
  13. package/src/dev/facts.ts +180 -0
  14. package/src/dev/index.ts +47 -0
  15. package/src/dev/panel-cache.ts +47 -0
  16. package/src/dev/panel-db.ts +59 -0
  17. package/src/dev/panel-jobs.ts +66 -0
  18. package/src/dev/panel-live.ts +46 -0
  19. package/src/dev/panel-mail.ts +51 -0
  20. package/src/dev/panel-manifest.ts +39 -0
  21. package/src/dev/panel-policy.ts +61 -0
  22. package/src/dev/panel-routes.ts +43 -0
  23. package/src/dev/panel-timeline.ts +82 -0
  24. package/src/dev/panel.ts +58 -0
  25. package/src/dev/server.ts +189 -0
  26. package/src/entity-columns.ts +95 -0
  27. package/src/errors.ts +145 -0
  28. package/src/fields.ts +151 -0
  29. package/src/form.tsx +97 -0
  30. package/src/index.ts +214 -0
  31. package/src/layout.tsx +104 -0
  32. package/src/list.tsx +120 -0
  33. package/src/mcp-tools.ts +201 -0
  34. package/src/mcp.ts +304 -0
  35. package/src/nav.ts +97 -0
  36. package/src/pagination.ts +152 -0
  37. package/src/permissions.ts +92 -0
  38. package/src/policy-bridge.ts +64 -0
  39. package/src/registry.ts +181 -0
  40. package/src/resource.ts +321 -0
  41. package/src/routes.ts +35 -0
  42. package/src/search.ts +108 -0
  43. package/src/theme.ts +59 -0
  44. package/src/validate.ts +65 -0
  45. package/src/widget-value.ts +217 -0
  46. package/src/widgets.tsx +262 -0
package/src/mcp.ts ADDED
@@ -0,0 +1,304 @@
1
+ // The AI-first surface: the admin's resources and actions as MCP tools, wired through
2
+ // `defineAppMcp` so the user's agents drive the user's app. Same authz, same audit, same
3
+ // confirmation rules as the buttons — this file adds a transport, not a second back door.
4
+
5
+ import { agentActor } from '@ultimat3/core';
6
+ import {
7
+ type AnyMcpTool,
8
+ type AppMcp,
9
+ defineAppMcp,
10
+ type JsonSchema,
11
+ jsonResult,
12
+ type McpCaller,
13
+ type McpToolResult,
14
+ type ResolvedToken,
15
+ type ToolArgs,
16
+ } from '@ultimat3/mcp';
17
+ import { invokeAdminAction } from './action-gate';
18
+ import type { AdminApp } from './admin';
19
+ import type { AdminActor } from './authz';
20
+ import {
21
+ adminCreate,
22
+ adminDestroy,
23
+ adminDetail,
24
+ adminList,
25
+ adminUpdate,
26
+ type CrudCtx,
27
+ type CrudResult,
28
+ } from './crud';
29
+ import type { AdminFieldType } from './fields';
30
+ import {
31
+ type AdminMcpTool,
32
+ type AdminToolField,
33
+ adminMcpTools,
34
+ adminToolCatalog,
35
+ } from './mcp-tools';
36
+ import { confirmationToken } from './permissions';
37
+ import type { AdminAction, AdminRow } from './registry';
38
+ import { adminSearch } from './search';
39
+
40
+ export type McpInput = Readonly<Record<string, unknown>>;
41
+
42
+ export type AdminToolResult =
43
+ | { readonly ok: true; readonly data: unknown }
44
+ | { readonly ok: false; readonly error: string; readonly reason: string };
45
+
46
+ const str = (input: McpInput, key: string): string => {
47
+ const value = input[key];
48
+ return typeof value === 'string' ? value : '';
49
+ };
50
+
51
+ const num = (input: McpInput, key: string): number | undefined => {
52
+ const value = input[key];
53
+ return typeof value === 'number' ? value : undefined;
54
+ };
55
+
56
+ const withoutKeys = (input: McpInput, keys: readonly string[]): Record<string, unknown> => {
57
+ const out: Record<string, unknown> = {};
58
+ for (const [key, value] of Object.entries(input)) {
59
+ if (!keys.includes(key)) out[key] = value;
60
+ }
61
+ return out;
62
+ };
63
+
64
+ const actionByName = (app: AdminApp, name: string): AdminAction | undefined =>
65
+ [...app.resources.flatMap((resource) => resource.actions), ...app.globalActions].find(
66
+ (action) => action.name === name,
67
+ );
68
+
69
+ /**
70
+ * Dispatch one tool call. The tool must be in this actor's allowed list — resolving the name
71
+ * against `adminMcpTools()` is what makes "the agent sees what it may do" true rather than
72
+ * aspirational.
73
+ */
74
+ export async function callAdminTool(
75
+ app: AdminApp,
76
+ ctx: CrudCtx,
77
+ name: string,
78
+ input: McpInput,
79
+ ): Promise<AdminToolResult> {
80
+ const tool = adminMcpTools(app, ctx).find((candidate) => candidate.name === name);
81
+ // NOT dead code, and not the only gate: the registry's `visibleTo` predicate already hides
82
+ // this tool from a caller who may not use it, so an MCP call cannot reach here refused.
83
+ // Every other entry point (a direct `callAdminTool`, a future transport) can, so this stays
84
+ // as defence in depth — the authz decision must not live only in the catalog filter.
85
+ if (tool === undefined) {
86
+ return {
87
+ ok: false,
88
+ error: 'X_ADMIN_TOOL_FORBIDDEN',
89
+ reason: `tool "${name}" is not available to actor ${ctx.actor.id}`,
90
+ };
91
+ }
92
+ return dispatch(app, ctx, tool, input);
93
+ }
94
+
95
+ async function dispatch(
96
+ app: AdminApp,
97
+ ctx: CrudCtx,
98
+ tool: AdminMcpTool,
99
+ input: McpInput,
100
+ ): Promise<AdminToolResult> {
101
+ if (tool.kind === 'search') {
102
+ const result = await adminSearch({ term: str(input, 'term'), resources: app.resources, ctx });
103
+ return { ok: true, data: result };
104
+ }
105
+
106
+ if (tool.kind === 'action') {
107
+ const action = actionByName(app, tool.action ?? '');
108
+ if (action === undefined) {
109
+ return { ok: false, error: 'X_ADMIN_TOOL_FORBIDDEN', reason: 'action is not registered' };
110
+ }
111
+ const id = str(input, 'id');
112
+ const result = await invokeAdminAction({
113
+ action,
114
+ input: withoutKeys(input, ['confirmation']),
115
+ actor: ctx.actor,
116
+ authz: ctx.authz,
117
+ audit: ctx.audit,
118
+ requestId: ctx.requestId,
119
+ subject: {
120
+ ...(action.entity === undefined ? {} : { entity: action.entity }),
121
+ ...(id === '' ? {} : { id }),
122
+ },
123
+ confirmation: str(input, 'confirmation'),
124
+ // The agent must echo the token, exactly as the UI makes an operator type it.
125
+ expectedConfirmation: confirmationToken(action.entity ?? 'admin', id),
126
+ });
127
+ return result.ok
128
+ ? { ok: true, data: result.value }
129
+ : { ok: false, error: 'X_ADMIN_DENIED', reason: result.decision.reason };
130
+ }
131
+
132
+ const resource = app.resource(tool.entity ?? '');
133
+ switch (tool.kind) {
134
+ case 'list': {
135
+ const limit = num(input, 'limit');
136
+ const result = await adminList(resource, ctx, {
137
+ cursor: str(input, 'cursor'),
138
+ ...(limit === undefined ? {} : { limit }),
139
+ });
140
+ return result.ok
141
+ ? { ok: true, data: result.page }
142
+ : { ok: false, error: 'X_ADMIN_DENIED', reason: result.decision.reason };
143
+ }
144
+ case 'read':
145
+ return crudResult(await adminDetail(resource, ctx, str(input, 'id')));
146
+ case 'create':
147
+ return crudResult(await adminCreate(resource, ctx, input));
148
+ case 'update':
149
+ return crudResult(
150
+ await adminUpdate(resource, ctx, str(input, 'id'), withoutKeys(input, ['id'])),
151
+ );
152
+ case 'delete':
153
+ return crudResult(
154
+ await adminDestroy(resource, ctx, str(input, 'id'), str(input, 'confirmation')),
155
+ );
156
+ }
157
+ }
158
+
159
+ function crudResult(result: CrudResult<AdminRow>): AdminToolResult {
160
+ if (result.ok) return { ok: true, data: result.row };
161
+ return result.kind === 'denied'
162
+ ? { ok: false, error: 'X_ADMIN_DENIED', reason: result.decision.reason }
163
+ : { ok: false, error: 'X_ADMIN_INVALID', reason: JSON.stringify(result.issues) };
164
+ }
165
+
166
+ export interface AdminMcpOptions {
167
+ readonly app: AdminApp;
168
+ /** Resolve the MCP session's actor. The same hook the HTTP surface uses, never a bypass. */
169
+ actor(session: { readonly token?: string }): Promise<AdminActor | null> | AdminActor | null;
170
+ readonly requestId?: () => string;
171
+ }
172
+
173
+ /** A field type an agent can actually send. Anything richer is a JSON object on the wire. */
174
+ const JSON_TYPE: Readonly<Record<AdminFieldType, NonNullable<JsonSchema['type']>>> = {
175
+ text: 'string',
176
+ textarea: 'string',
177
+ number: 'number',
178
+ money: 'object',
179
+ boolean: 'boolean',
180
+ enum: 'string',
181
+ date: 'string',
182
+ timestamptz: 'string',
183
+ timezone: 'string',
184
+ locale: 'string',
185
+ json: 'object',
186
+ relation: 'string',
187
+ file: 'string',
188
+ };
189
+
190
+ const inputSchema = (fields: readonly AdminToolField[]): JsonSchema => ({
191
+ type: 'object',
192
+ properties: Object.fromEntries(
193
+ fields.map((field) => [field.name, { type: JSON_TYPE[field.type] }]),
194
+ ),
195
+ required: fields.filter((field) => field.required).map((field) => field.name),
196
+ additionalProperties: false,
197
+ });
198
+
199
+ /**
200
+ * `caller.actor` is whatever `resolveToken` returned, so the identity the tool runs as is the
201
+ * one the session authenticated as — id and roles are all authz reads.
202
+ */
203
+ const adminActorOf = (caller: McpCaller): AdminActor => ({
204
+ id: caller.actor.id,
205
+ roles: caller.actor.roles,
206
+ });
207
+
208
+ /**
209
+ * The tool names one caller may call, memoized.
210
+ *
211
+ * WHY memoize: visibility is asked once per tool and each answer re-derives the whole
212
+ * catalog, so an unmemoized `tools/list` costs O(tools²) authz decisions.
213
+ *
214
+ * WHY a `WeakMap` keyed on the caller OBJECT: an entry can never hand one caller another's
215
+ * answer, and it is collected with the caller, so there is nothing to evict.
216
+ *
217
+ * Its LIFETIME is therefore the transport's, and the two transports differ:
218
+ *
219
+ * | Transport | `McpCaller` built | Cache grain |
220
+ * |---|---|---|
221
+ * | HTTP (`transport-http.ts`) | inside `route.handle`, per request | per request |
222
+ * | stdio (`transport-stdio.ts`) | once, in `StdioTransportInput` | per connection |
223
+ *
224
+ * So over stdio a permission change made mid-connection is NOT observed until the client
225
+ * reconnects. That is accepted, not overlooked: the stdio peer is the local developer's own
226
+ * shell, which launched this process and already holds that developer's authority, and the
227
+ * session is short and re-launched per editor/agent run. A connection-scoped catalog is the
228
+ * intended grain there — an invalidation hook would add a second source of truth for
229
+ * visibility to keep in sync with `adminMcpTools`, which is the drift this file avoids
230
+ * everywhere else. Over HTTP, where a token can outlive a permission change, the grain is
231
+ * already per request and the question does not arise.
232
+ *
233
+ * WHY keyed by app too: one process can mount two admins, and their catalogs differ.
234
+ */
235
+ const allowedByCaller = new WeakMap<McpCaller, Map<AdminApp, ReadonlySet<string>>>();
236
+
237
+ function allowedToolNames(
238
+ opts: AdminMcpOptions,
239
+ requestId: () => string,
240
+ caller: McpCaller,
241
+ ): ReadonlySet<string> {
242
+ const perApp = allowedByCaller.get(caller) ?? new Map<AdminApp, ReadonlySet<string>>();
243
+ const cached = perApp.get(opts.app);
244
+ if (cached !== undefined) return cached;
245
+
246
+ const ctx = opts.app.ctx({ actor: adminActorOf(caller), requestId: requestId() });
247
+ // `adminMcpTools` is the actor's allowed list — the same derivation the UI's buttons use.
248
+ // Never a second decision written for MCP.
249
+ const names: ReadonlySet<string> = new Set(adminMcpTools(opts.app, ctx).map(({ name }) => name));
250
+ perApp.set(opts.app, names);
251
+ allowedByCaller.set(caller, perApp);
252
+ return names;
253
+ }
254
+
255
+ function toMcpTool(opts: AdminMcpOptions, requestId: () => string, tool: AdminMcpTool): AnyMcpTool {
256
+ return {
257
+ name: tool.name,
258
+ description: tool.description,
259
+ inputSchema: inputSchema(tool.input),
260
+ destructive: tool.destructive,
261
+ // Visibility IS the gate: a tool this actor may not call is absent from `tools/list` and
262
+ // answers ToolNotFound on call, never Forbidden — Forbidden would confirm the tool exists
263
+ // and turn the catalog into something an agent can enumerate by probing names. The
264
+ // predicate never sees call arguments, so visibility stays input-independent.
265
+ visibleTo: (caller: McpCaller): boolean =>
266
+ allowedToolNames(opts, requestId, caller).has(tool.name),
267
+ async handle(args: ToolArgs, caller: McpCaller): Promise<McpToolResult> {
268
+ const ctx = opts.app.ctx({ actor: adminActorOf(caller), requestId: requestId() });
269
+ const result = await callAdminTool(opts.app, ctx, tool.name, args);
270
+ if (result.ok) return jsonResult(result.data);
271
+ // An expected outcome the model should reason about (a policy said no), not a
272
+ // protocol error: the transport still answers 200 with the denial in the body.
273
+ return { ...jsonResult({ error: result.error, reason: result.reason }), isError: true };
274
+ },
275
+ };
276
+ }
277
+
278
+ /**
279
+ * Mount the admin as an app MCP server.
280
+ *
281
+ * The catalog is built once but answered per caller: every tool carries a `visibleTo`
282
+ * predicate that re-derives that actor's allowed tools, so `tools/list` is answered per
283
+ * caller — one `McpCaller` per HTTP request, one per stdio connection — and a tool the actor
284
+ * may not use is ABSENT from it, while a direct call answers ToolNotFound, never Forbidden.
285
+ * Forbidden would confirm the tool exists, leaking every entity name and operation to anyone
286
+ * who probes. Every call still goes through `callAdminTool`, which re-checks the same allowed
287
+ * list on dispatch, exactly as the UI refuses a button the actor may not click.
288
+ */
289
+ export function adminMcp(opts: AdminMcpOptions): AppMcp {
290
+ const requestId = opts.requestId ?? ((): string => crypto.randomUUID());
291
+
292
+ return defineAppMcp({
293
+ name: 'admin',
294
+ tools: adminToolCatalog(opts.app).map((tool) => toMcpTool(opts, requestId, tool)),
295
+ async resolveToken(token: string): Promise<ResolvedToken | null> {
296
+ const actor = await opts.actor({ token });
297
+ // `kind: 'agent'` — the same actor shape an agent gets everywhere else, so a policy
298
+ // that distinguishes agents from people keeps working on this surface.
299
+ return actor === null
300
+ ? null
301
+ : { actor: agentActor({ id: actor.id, roles: actor.roles ?? [] }), scopes: new Set() };
302
+ },
303
+ });
304
+ }
package/src/nav.ts ADDED
@@ -0,0 +1,97 @@
1
+ // Navigation derived from the resources, grouped, and filtered by the same `admin:read` +
2
+ // `<entity>:read` pair that gates the page itself. A nav item an actor cannot open is not a
3
+ // nav item — the alternative is a sidebar full of 403s.
4
+
5
+ import type { CrudCtx } from './crud';
6
+ import { canOperate } from './crud';
7
+ import type { AdminResource } from './resource';
8
+
9
+ export interface NavItem {
10
+ readonly key: string;
11
+ readonly labelKey: string;
12
+ readonly href: string;
13
+ /** The entity this item lists, or `null` for a built-in page. */
14
+ readonly entity: string | null;
15
+ }
16
+
17
+ export interface NavGroup {
18
+ readonly key: string;
19
+ readonly labelKey: string;
20
+ readonly items: readonly NavItem[];
21
+ }
22
+
23
+ export interface NavOptions {
24
+ /** Explicit group order and membership: `{ 'admin.group.content': ['post', 'tag'] }`. */
25
+ readonly groups?: Readonly<Record<string, readonly string[]>>;
26
+ /** Extra items (dashboards, links) appended to their group. */
27
+ readonly extra?: readonly (NavItem & { readonly group: string })[];
28
+ }
29
+
30
+ const itemFor = (resource: AdminResource): NavItem => ({
31
+ key: resource.name,
32
+ labelKey: resource.titleKey,
33
+ href: resource.path,
34
+ entity: resource.name,
35
+ });
36
+
37
+ /**
38
+ * Declaration order is the default order: an entity registry is written in the order the
39
+ * domain reads, and re-sorting it alphabetically loses that.
40
+ */
41
+ export function adminNav(
42
+ resources: readonly AdminResource[],
43
+ opts: NavOptions = {},
44
+ ): readonly NavGroup[] {
45
+ const byKey = new Map<string, NavItem[]>();
46
+ const order: string[] = [];
47
+
48
+ const push = (groupKey: string, item: NavItem): void => {
49
+ let bucket = byKey.get(groupKey);
50
+ if (bucket === undefined) {
51
+ bucket = [];
52
+ byKey.set(groupKey, bucket);
53
+ order.push(groupKey);
54
+ }
55
+ bucket.push(item);
56
+ };
57
+
58
+ if (opts.groups !== undefined) {
59
+ for (const [groupKey, names] of Object.entries(opts.groups)) {
60
+ for (const name of names) {
61
+ const resource = resources.find((candidate) => candidate.name === name);
62
+ if (resource !== undefined) push(groupKey, itemFor(resource));
63
+ }
64
+ }
65
+ }
66
+
67
+ const placed = new Set([...byKey.values()].flat().map((item) => item.entity ?? item.key));
68
+ for (const resource of resources) {
69
+ if (placed.has(resource.name)) continue;
70
+ push(resource.group, itemFor(resource));
71
+ }
72
+
73
+ for (const item of opts.extra ?? []) push(item.group, item);
74
+
75
+ return order.map((groupKey) => ({
76
+ key: groupKey,
77
+ labelKey: groupKey,
78
+ items: byKey.get(groupKey) ?? [],
79
+ }));
80
+ }
81
+
82
+ /** Drop items the actor cannot list, then drop groups that emptied out. */
83
+ export function visibleNav(
84
+ nav: readonly NavGroup[],
85
+ resources: readonly AdminResource[],
86
+ ctx: CrudCtx,
87
+ ): readonly NavGroup[] {
88
+ const visible = (item: NavItem): boolean => {
89
+ if (item.entity === null) return true;
90
+ const resource = resources.find((candidate) => candidate.name === item.entity);
91
+ return resource !== undefined && canOperate(resource, 'list', ctx);
92
+ };
93
+
94
+ return nav
95
+ .map((group) => ({ ...group, items: group.items.filter(visible) }))
96
+ .filter((group) => group.items.length > 0);
97
+ }
@@ -0,0 +1,152 @@
1
+ // Cursor pagination, and only cursor pagination. `AdminListQuery` has no `offset` field, so
2
+ // "page 400" cannot be expressed: an operator paging a table that is being written to would
3
+ // otherwise skip and repeat rows, and every page would re-scan everything before it.
4
+ // The position itself is encoded by `@ultimat3/core` — one signed cursor format, framework-wide.
5
+
6
+ import { decodeCursor, encodeCursor, isUltimateError } from '@ultimat3/core';
7
+ import type { AdminFilter, AdminListQuery, AdminRow, AdminSort } from './registry';
8
+ import { rowId } from './registry';
9
+ import { type AdminResource, repoOf } from './resource';
10
+
11
+ export interface AdminCursor {
12
+ readonly field: string;
13
+ readonly value: string;
14
+ readonly id: string;
15
+ readonly direction: 'after' | 'before';
16
+ }
17
+
18
+ /** Asking for the name alone keeps the codec free of the row generic. */
19
+ type CursorResource = Pick<AdminResource, 'name'>;
20
+
21
+ /**
22
+ * The signed scope binds a cursor to the resource that issued it: a position taken from the
23
+ * posts table cannot be replayed against users, whatever an operator pastes into the URL.
24
+ */
25
+ const cursorScope = (resource: CursorResource): string => `admin:${resource.name}`;
26
+
27
+ export function encodeAdminCursor(resource: CursorResource, cursor: AdminCursor): string {
28
+ return encodeCursor({
29
+ scope: cursorScope(resource),
30
+ key: [cursor.direction, cursor.field, cursor.value],
31
+ id: cursor.id,
32
+ });
33
+ }
34
+
35
+ /**
36
+ * An unreadable cursor yields `null`, i.e. the first page — a stale bookmark or a hand-edited
37
+ * URL should show the operator page one, not an error page. Deliberately softer than the repo
38
+ * and the read primitive, which surface `X_CURSOR_INVALID`: those are called by code that must
39
+ * learn it paged wrong, while a human just wants the table to render. Forgery is covered either
40
+ * way — core verifies the signature before the payload is trusted, so a tampered or borrowed
41
+ * cursor lands on page one instead of a seek position the client invented.
42
+ */
43
+ export function decodeAdminCursor(
44
+ resource: CursorResource,
45
+ raw: string | null | undefined,
46
+ ): AdminCursor | null {
47
+ if (raw === null || raw === undefined || raw === '') return null;
48
+ try {
49
+ const payload = decodeCursor(raw, cursorScope(resource));
50
+ if (payload.key.length !== 3) return null;
51
+ const [direction, field, value] = payload.key;
52
+ if (direction !== 'after' && direction !== 'before') return null;
53
+ if (typeof field !== 'string' || typeof value !== 'string') return null;
54
+ return { direction, field, value, id: payload.id };
55
+ } catch (error) {
56
+ if (isUltimateError(error) && error.code === 'X_CURSOR_INVALID') return null;
57
+ throw error;
58
+ }
59
+ }
60
+
61
+ export interface PageRequest {
62
+ readonly cursor?: string | null;
63
+ readonly limit?: number;
64
+ readonly sort?: AdminSort;
65
+ readonly where?: readonly AdminFilter[];
66
+ }
67
+
68
+ export interface AdminPage<Row extends AdminRow> {
69
+ readonly rows: readonly Row[];
70
+ readonly sort: AdminSort;
71
+ readonly pageSize: number;
72
+ readonly nextCursor: string | null;
73
+ readonly prevCursor: string | null;
74
+ readonly hasMore: boolean;
75
+ }
76
+
77
+ /** The repo query for one page. Asks for `limit + 1` to learn whether a next page exists. */
78
+ export function listQuery<Row extends AdminRow>(
79
+ resource: AdminResource<Row>,
80
+ req: PageRequest = {},
81
+ ): AdminListQuery {
82
+ const sort = req.sort ?? resource.defaultSort;
83
+ const limit = Math.max(1, Math.min(req.limit ?? resource.pageSize, 200));
84
+ const cursor = decodeAdminCursor(resource, req.cursor);
85
+ const bound =
86
+ cursor === null ? undefined : { field: cursor.field, value: cursor.value, id: cursor.id };
87
+
88
+ return {
89
+ sort,
90
+ limit: limit + 1,
91
+ ...(req.where === undefined ? {} : { where: req.where }),
92
+ ...(bound === undefined
93
+ ? {}
94
+ : cursor?.direction === 'before'
95
+ ? { before: bound }
96
+ : { after: bound }),
97
+ };
98
+ }
99
+
100
+ const cursorValue = (row: AdminRow, field: string): string => {
101
+ const value = row[field];
102
+ if (value instanceof Date) return value.toISOString();
103
+ return value === null || value === undefined ? '' : String(value);
104
+ };
105
+
106
+ /** Turn `limit + 1` rows into a page plus the cursors that walk off either end. */
107
+ export function pageFrom<Row extends AdminRow>(
108
+ resource: AdminResource<Row>,
109
+ req: PageRequest,
110
+ fetched: readonly Row[],
111
+ ): AdminPage<Row> {
112
+ const sort = req.sort ?? resource.defaultSort;
113
+ const pageSize = Math.max(1, Math.min(req.limit ?? resource.pageSize, 200));
114
+ const hasMore = fetched.length > pageSize;
115
+ const rows = hasMore ? fetched.slice(0, pageSize) : fetched;
116
+ const last = rows[rows.length - 1];
117
+ const first = rows[0];
118
+ const incoming = decodeAdminCursor(resource, req.cursor);
119
+
120
+ return {
121
+ rows,
122
+ sort,
123
+ pageSize,
124
+ hasMore,
125
+ nextCursor:
126
+ hasMore && last !== undefined
127
+ ? encodeAdminCursor(resource, {
128
+ direction: 'after',
129
+ field: sort.field,
130
+ value: cursorValue(last, sort.field),
131
+ id: rowId(last, resource.idField),
132
+ })
133
+ : null,
134
+ prevCursor:
135
+ incoming !== null && first !== undefined
136
+ ? encodeAdminCursor(resource, {
137
+ direction: 'before',
138
+ field: sort.field,
139
+ value: cursorValue(first, sort.field),
140
+ id: rowId(first, resource.idField),
141
+ })
142
+ : null,
143
+ };
144
+ }
145
+
146
+ export async function fetchPage<Row extends AdminRow>(
147
+ resource: AdminResource<Row>,
148
+ req: PageRequest = {},
149
+ ): Promise<AdminPage<Row>> {
150
+ const fetched = await repoOf(resource).list(listQuery(resource, req));
151
+ return pageFrom(resource, req, fetched);
152
+ }
@@ -0,0 +1,92 @@
1
+ // The admin's own permission set, and the two rules that hang off it: destructive
2
+ // operations always re-confirm, and destructive operations are always audited. Both are
3
+ // data here (not a code path in a view) so the UI, the HTTP call, and the MCP tool read the
4
+ // same table instead of each remembering the rule.
5
+
6
+ export const ADMIN_READ = 'admin:read';
7
+ export const ADMIN_WRITE = 'admin:write';
8
+ export const ADMIN_DESTROY = 'admin:destroy';
9
+ export const ADMIN_IMPERSONATE = 'admin:impersonate';
10
+
11
+ export const ADMIN_PERMISSIONS = [
12
+ ADMIN_READ,
13
+ ADMIN_WRITE,
14
+ ADMIN_DESTROY,
15
+ ADMIN_IMPERSONATE,
16
+ ] as const;
17
+
18
+ export type AdminPermission = (typeof ADMIN_PERMISSIONS)[number];
19
+
20
+ /** Every mutation or read the admin can perform on a resource. */
21
+ export type AdminOperation = 'list' | 'detail' | 'search' | 'create' | 'update' | 'delete';
22
+
23
+ export const ADMIN_OPERATIONS = ['list', 'detail', 'search', 'create', 'update', 'delete'] as const;
24
+
25
+ export interface AdminPermissionRule {
26
+ /** The admin-level gate. An actor also needs the per-entity permission. */
27
+ readonly permission: AdminPermission;
28
+ /** Destructive: the client must echo a confirmation token before the call runs. */
29
+ readonly destructive: boolean;
30
+ /** Never false. Present so the field exists in `--json` output and in tests. */
31
+ readonly audited: true;
32
+ readonly labelKey: string;
33
+ }
34
+
35
+ const rule = (
36
+ permission: AdminPermission,
37
+ destructive: boolean,
38
+ op: AdminOperation,
39
+ ): AdminPermissionRule => ({
40
+ permission,
41
+ destructive,
42
+ audited: true,
43
+ labelKey: `admin.operation.${op}`,
44
+ });
45
+
46
+ export const ADMIN_OPERATION_RULES: Readonly<Record<AdminOperation, AdminPermissionRule>> = {
47
+ list: rule(ADMIN_READ, false, 'list'),
48
+ detail: rule(ADMIN_READ, false, 'detail'),
49
+ search: rule(ADMIN_READ, false, 'search'),
50
+ create: rule(ADMIN_WRITE, false, 'create'),
51
+ update: rule(ADMIN_WRITE, false, 'update'),
52
+ delete: rule(ADMIN_DESTROY, true, 'delete'),
53
+ };
54
+
55
+ export function ruleFor(op: AdminOperation): AdminPermissionRule {
56
+ return ADMIN_OPERATION_RULES[op];
57
+ }
58
+
59
+ /** The admin-level permission an operation needs, before the per-entity one. */
60
+ export function adminPermissionFor(op: AdminOperation): AdminPermission {
61
+ return ADMIN_OPERATION_RULES[op].permission;
62
+ }
63
+
64
+ /** The per-entity permission, so `post:delete` and `admin:destroy` must BOTH hold. */
65
+ export function entityPermissionFor(entity: string, op: AdminOperation): string {
66
+ const verb = op === 'delete' ? 'delete' : op === 'create' || op === 'update' ? 'write' : 'read';
67
+ return `${entity}:${verb}`;
68
+ }
69
+
70
+ export function isDestructive(op: AdminOperation): boolean {
71
+ return ADMIN_OPERATION_RULES[op].destructive;
72
+ }
73
+
74
+ /**
75
+ * The token a destructive call must echo. Deliberately the human-readable record id: a
76
+ * typed-out id is a re-read of what is about to be deleted, and an agent cannot guess it.
77
+ */
78
+ export function confirmationToken(entity: string, id: string): string {
79
+ return `${entity}:${id}`;
80
+ }
81
+
82
+ export const CONFIRMATION_REQUIRED_REASON = 'admin.error.confirmation-required';
83
+
84
+ /** The spec `definePermissions()` is fed with. Kept pure so tests need no policy runtime. */
85
+ export const ADMIN_PERMISSION_SPEC: Readonly<
86
+ Record<AdminPermission, { readonly descriptionKey: string; readonly implies: readonly string[] }>
87
+ > = {
88
+ [ADMIN_READ]: { descriptionKey: 'admin.permission.read', implies: [] },
89
+ [ADMIN_WRITE]: { descriptionKey: 'admin.permission.write', implies: [ADMIN_READ] },
90
+ [ADMIN_DESTROY]: { descriptionKey: 'admin.permission.destroy', implies: [ADMIN_WRITE] },
91
+ [ADMIN_IMPERSONATE]: { descriptionKey: 'admin.permission.impersonate', implies: [ADMIN_READ] },
92
+ };