@ultimat3/admin 22.14.0 → 23.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +73 -80
- package/README.md +276 -87
- package/package.json +19 -17
- package/src/action-form.tsx +139 -0
- package/src/action-gate.ts +127 -28
- package/src/action-input.ts +119 -0
- package/src/action-label.ts +9 -0
- package/src/action-row.ts +37 -0
- package/src/actions.tsx +89 -66
- package/src/actor.ts +53 -0
- package/src/admin.module.scss +298 -0
- package/src/admin.ts +187 -23
- package/src/audit-pg.ts +207 -0
- package/src/audit-schema.ts +47 -0
- package/src/audit.ts +109 -22
- package/src/authz.ts +11 -0
- package/src/batch-bar.tsx +66 -0
- package/src/batch-job.ts +152 -0
- package/src/batch-matching.ts +92 -0
- package/src/batch.ts +431 -0
- package/src/catalog-keys.ts +55 -0
- package/src/crud-input.ts +104 -0
- package/src/crud-outcome.ts +166 -0
- package/src/crud.ts +129 -177
- package/src/describe.ts +131 -0
- package/src/detail.tsx +115 -43
- package/src/dev/data.ts +24 -6
- package/src/dev/facts.ts +27 -0
- package/src/dev/panel-db.ts +37 -5
- package/src/dev/panel-jobs.ts +8 -0
- package/src/dev/panel-routes.ts +10 -0
- package/src/dev/panel.ts +6 -0
- package/src/dev/server.ts +15 -7
- package/src/entity-columns.ts +24 -5
- package/src/errors.ts +83 -0
- package/src/fields.ts +63 -4
- package/src/form-decode.ts +91 -0
- package/src/form.tsx +78 -45
- package/src/index.ts +198 -9
- package/src/jobs/fleet-repo.ts +143 -0
- package/src/jobs/job-actions.ts +172 -0
- package/src/jobs/job-entities.ts +118 -0
- package/src/jobs/job-labels.ts +108 -0
- package/src/jobs/job-repo.ts +103 -0
- package/src/jobs/job-resources.ts +211 -0
- package/src/jobs/job-where.ts +170 -0
- package/src/jobs/operator.ts +34 -0
- package/src/jobs/overview-data.ts +129 -0
- package/src/jobs/overview-tab.tsx +61 -0
- package/src/jobs/overview.tsx +189 -0
- package/src/json-text.ts +52 -0
- package/src/layout.tsx +55 -43
- package/src/list-columns.tsx +53 -0
- package/src/list-filter-bar.tsx +207 -0
- package/src/list-filters.ts +217 -0
- package/src/list-request.ts +127 -0
- package/src/list-scope-tabs.tsx +60 -0
- package/src/list-scope.ts +113 -0
- package/src/list.tsx +111 -72
- package/src/mcp-tools.ts +57 -13
- package/src/mcp.ts +108 -38
- package/src/mounts.ts +44 -0
- package/src/page-guard.tsx +26 -29
- package/src/policy-bridge.ts +110 -15
- package/src/registry.ts +138 -8
- package/src/related.ts +105 -0
- package/src/relations.ts +224 -0
- package/src/renderers.tsx +89 -0
- package/src/repo-entity.ts +204 -0
- package/src/resource-fields.ts +116 -0
- package/src/resource-layout.ts +89 -0
- package/src/resource-list.ts +185 -0
- package/src/resource.ts +129 -79
- package/src/route-config.ts +71 -0
- package/src/routes.ts +55 -54
- package/src/row-scope-write.ts +130 -0
- package/src/screen-action.tsx +333 -0
- package/src/screen-frame.tsx +167 -0
- package/src/screen-home.tsx +201 -0
- package/src/screen-lookup.tsx +157 -0
- package/src/screen-resource.tsx +393 -0
- package/src/screen-system.tsx +69 -0
- package/src/screens.ts +56 -0
- package/src/search.ts +13 -9
- package/src/stylesheet-scope.ts +8 -0
- package/src/test-ctx.ts +31 -0
- package/src/validate.ts +3 -0
- package/src/widget-value.ts +36 -8
- package/src/widgets.tsx +84 -12
package/src/audit.ts
CHANGED
|
@@ -24,7 +24,12 @@ export interface AuditEntry {
|
|
|
24
24
|
/** ISO-8601 UTC. Stored UTC, formatted per viewer zone at the edge. */
|
|
25
25
|
readonly at: string;
|
|
26
26
|
readonly requestId: string;
|
|
27
|
-
readonly actor: {
|
|
27
|
+
readonly actor: {
|
|
28
|
+
readonly id: string;
|
|
29
|
+
readonly roles: readonly string[];
|
|
30
|
+
/** The tenant the actor acted under. Absent for a single-tenant app. */
|
|
31
|
+
readonly orgId?: string;
|
|
32
|
+
};
|
|
28
33
|
/** `list` | `create` | `update` | `delete`, or an action name. */
|
|
29
34
|
readonly operation: string;
|
|
30
35
|
readonly kind: 'operation' | 'action';
|
|
@@ -50,19 +55,49 @@ export interface AuditDraft {
|
|
|
50
55
|
readonly diff?: readonly AuditFieldDiff[];
|
|
51
56
|
}
|
|
52
57
|
|
|
53
|
-
/** Where
|
|
58
|
+
/** Where a copy of every entry goes besides the log's own store: stdout as JSON lines, OTel. */
|
|
54
59
|
export interface AuditSink {
|
|
55
60
|
write(entry: AuditEntry): Promise<void> | void;
|
|
56
61
|
}
|
|
57
62
|
|
|
63
|
+
/** A position in the trail: the entry a page ended on. Entries are ordered `at` then `id`. */
|
|
64
|
+
export interface AuditCursor {
|
|
65
|
+
readonly at: string;
|
|
66
|
+
readonly id: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface AuditQuery {
|
|
70
|
+
readonly entity?: string;
|
|
71
|
+
/** One row's trail — what the detail page's history card asks, with `entity`. */
|
|
72
|
+
readonly entityId?: string;
|
|
73
|
+
readonly actorId?: string;
|
|
74
|
+
/** Only entries written by an actor of this tenant. */
|
|
75
|
+
readonly orgId?: string;
|
|
76
|
+
readonly limit?: number;
|
|
77
|
+
/** Keyset: only entries strictly OLDER than this one. There is no offset. */
|
|
78
|
+
readonly before?: AuditCursor;
|
|
79
|
+
/**
|
|
80
|
+
* Leave out the reads that were allowed — `list`, `detail`, `search`, a page — and keep what
|
|
81
|
+
* changed something or was refused. What a row's history shows.
|
|
82
|
+
*/
|
|
83
|
+
readonly changes?: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
58
86
|
export interface AuditLog {
|
|
59
87
|
append(draft: AuditDraft): Promise<AuditEntry>;
|
|
60
|
-
/**
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
88
|
+
/**
|
|
89
|
+
* Newest first, at most `limit` (`AUDIT_PAGE_MAX` at the outside). A copy — the log cannot be
|
|
90
|
+
* mutated through what it hands out. Async because the record is a table, not this process.
|
|
91
|
+
*/
|
|
92
|
+
entries(query?: AuditQuery): Promise<readonly AuditEntry[]>;
|
|
93
|
+
/**
|
|
94
|
+
* Run a write and its entry as ONE unit where the store can: a durable log opens a transaction,
|
|
95
|
+
* so a row never changes without its entry and an entry never names a write that rolled back.
|
|
96
|
+
* The memory log has nothing to join and runs `run` as it is.
|
|
97
|
+
*/
|
|
98
|
+
atomic<T>(run: () => Promise<T>): Promise<T>;
|
|
99
|
+
/** `memory`, `postgres`, or an app's own word. What the manifest records about this admin. */
|
|
100
|
+
readonly kind: string;
|
|
66
101
|
}
|
|
67
102
|
|
|
68
103
|
export interface AuditLogOptions {
|
|
@@ -74,12 +109,52 @@ export interface AuditLogOptions {
|
|
|
74
109
|
readonly capacity?: number;
|
|
75
110
|
}
|
|
76
111
|
|
|
112
|
+
/** The most entries one read answers, whatever it asked for. */
|
|
113
|
+
export const AUDIT_PAGE_MAX = 200;
|
|
114
|
+
|
|
115
|
+
/** What a read answers when it names no limit. */
|
|
116
|
+
export const AUDIT_PAGE_DEFAULT = 100;
|
|
117
|
+
|
|
118
|
+
/** The operations that only LOOK. An allowed one is a read; a refused one is still an event. */
|
|
119
|
+
export const AUDIT_READ_OPERATIONS: readonly string[] = ['list', 'detail', 'search', 'page'];
|
|
120
|
+
|
|
121
|
+
/** An entry that changed nothing and was refused nothing — the only kind `changes` leaves out. */
|
|
122
|
+
export const isAllowedRead = (entry: AuditEntry): boolean =>
|
|
123
|
+
entry.kind === 'operation' &&
|
|
124
|
+
entry.outcome === 'allowed' &&
|
|
125
|
+
AUDIT_READ_OPERATIONS.includes(entry.operation);
|
|
126
|
+
|
|
127
|
+
/** The page size a query asks for, bounded. Refused when it is not a count at all. */
|
|
128
|
+
export const auditLimit = (subject: string, query: AuditQuery): number =>
|
|
129
|
+
Math.min(
|
|
130
|
+
AUDIT_PAGE_MAX,
|
|
131
|
+
query.limit === undefined
|
|
132
|
+
? AUDIT_PAGE_DEFAULT
|
|
133
|
+
: // `slice(0, NaN)` is `[]`: an unreadable limit would answer "nothing was ever logged".
|
|
134
|
+
// 0 stays legal — asking for none is a coherent request.
|
|
135
|
+
finiteCount(subject, 'entries limit', query.limit),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
/** Where the page after `entries` starts, or `null` for an empty page. */
|
|
139
|
+
export const auditCursorOf = (entries: readonly AuditEntry[]): AuditCursor | null => {
|
|
140
|
+
const last = entries[entries.length - 1];
|
|
141
|
+
return last === undefined ? null : { at: last.at, id: last.id };
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/** `-1` when `a` is NEWER than `b`: the trail's one order, `at` then `id`, both descending. */
|
|
145
|
+
const newestFirst = (a: AuditCursor, b: AuditCursor): number =>
|
|
146
|
+
a.at === b.at ? (a.id === b.id ? 0 : a.id > b.id ? -1 : 1) : a.at > b.at ? -1 : 1;
|
|
147
|
+
|
|
77
148
|
export function auditEntry(draft: AuditDraft, id: string, at: Date): AuditEntry {
|
|
78
149
|
return {
|
|
79
150
|
id,
|
|
80
151
|
at: at.toISOString(),
|
|
81
152
|
requestId: draft.requestId,
|
|
82
|
-
actor: {
|
|
153
|
+
actor: {
|
|
154
|
+
id: draft.actor.id,
|
|
155
|
+
roles: draft.actor.roles ?? [],
|
|
156
|
+
...(draft.actor.orgId === undefined ? {} : { orgId: draft.actor.orgId }),
|
|
157
|
+
},
|
|
83
158
|
operation: draft.operation,
|
|
84
159
|
kind: draft.kind,
|
|
85
160
|
entity: draft.entity,
|
|
@@ -102,6 +177,7 @@ export function memoryAuditLog(opts: AuditLogOptions = {}): AuditLog {
|
|
|
102
177
|
const log: AuditEntry[] = [];
|
|
103
178
|
|
|
104
179
|
return {
|
|
180
|
+
kind: 'memory',
|
|
105
181
|
async append(draft: AuditDraft): Promise<AuditEntry> {
|
|
106
182
|
// Timestamp first: the entry is stamped when it happened, not when the id generator
|
|
107
183
|
// got around to it.
|
|
@@ -112,20 +188,31 @@ export function memoryAuditLog(opts: AuditLogOptions = {}): AuditLog {
|
|
|
112
188
|
for (const sink of sinks) await sink.write(entry);
|
|
113
189
|
return entry;
|
|
114
190
|
},
|
|
115
|
-
entries(query = {}): readonly AuditEntry[] {
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
);
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
191
|
+
async entries(query = {}): Promise<readonly AuditEntry[]> {
|
|
192
|
+
const limit = auditLimit(SUBJECT, query);
|
|
193
|
+
const before = query.before;
|
|
194
|
+
// Insertion order reversed IS newest first, ties included: two entries of one millisecond
|
|
195
|
+
// read in the order they were written, which an id drawn at random could not promise.
|
|
196
|
+
const at = before === undefined ? -1 : log.findIndex((entry) => entry.id === before.id);
|
|
197
|
+
const older =
|
|
198
|
+
before === undefined
|
|
199
|
+
? log
|
|
200
|
+
: at >= 0
|
|
201
|
+
? log.slice(0, at)
|
|
202
|
+
: log.filter((entry) => newestFirst(before, entry) < 0);
|
|
203
|
+
return older
|
|
204
|
+
.filter(
|
|
205
|
+
(entry) =>
|
|
206
|
+
(query.entity === undefined || entry.entity === query.entity) &&
|
|
207
|
+
(query.entityId === undefined || entry.entityId === query.entityId) &&
|
|
208
|
+
(query.actorId === undefined || entry.actor.id === query.actorId) &&
|
|
209
|
+
(query.orgId === undefined || entry.actor.orgId === query.orgId) &&
|
|
210
|
+
(query.changes !== true || !isAllowedRead(entry)),
|
|
211
|
+
)
|
|
212
|
+
.reverse()
|
|
213
|
+
.slice(0, limit);
|
|
128
214
|
},
|
|
215
|
+
atomic: (run) => run(),
|
|
129
216
|
};
|
|
130
217
|
}
|
|
131
218
|
|
package/src/authz.ts
CHANGED
|
@@ -147,6 +147,17 @@ export function expandPermissions(granted: readonly string[]): readonly string[]
|
|
|
147
147
|
return [...out].sort();
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
/**
|
|
151
|
+
* The admin permissions whose grant carries `permission` — `admin:write` is carried by
|
|
152
|
+
* `admin:destroy`. The inverse of `expandPermissions`, so an authz that decides one name at a time
|
|
153
|
+
* (`roleAuthz`) answers exactly what a grant-list authz answers for the same grants.
|
|
154
|
+
*/
|
|
155
|
+
export function impliersOf(permission: string): readonly string[] {
|
|
156
|
+
return Object.keys(ADMIN_PERMISSION_SPEC).filter(
|
|
157
|
+
(candidate) => candidate !== permission && expandPermissions([candidate]).includes(permission),
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
150
161
|
/**
|
|
151
162
|
* A grant-list authz for tests, seeds, and `x dev --actor`. Production always goes through
|
|
152
163
|
* `policyAuthz()` so the app's real rules (ownership, org scoping) are the ones evaluated.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// The list's batch bar: one native form posted at the LIST's own URL — whose query string is the
|
|
2
|
+
// filter "all matching" means — holding which action, over which rows, and the submit. The row
|
|
3
|
+
// checkboxes sit in the table and join this form through their `form` attribute, because a form
|
|
4
|
+
// cannot hold a table that already holds the filter bar's. No script: selection is a checkbox,
|
|
5
|
+
// "every row this filter matches" is a radio, and the server answers with what it did.
|
|
6
|
+
|
|
7
|
+
import { t } from '@ultimat3/i18n';
|
|
8
|
+
import { Button, Checkbox, Radio, Select } from '@ultimat3/ui';
|
|
9
|
+
import type { JSX } from 'solid-js';
|
|
10
|
+
import { actionLabelKey } from './action-label';
|
|
11
|
+
import { BATCH_OPERATION, OPERATION_FIELD } from './actions';
|
|
12
|
+
import styles from './admin.module.scss';
|
|
13
|
+
import type { AdminAction } from './registry';
|
|
14
|
+
|
|
15
|
+
/** The id of the batch form, which every row checkbox names in its `form` attribute. */
|
|
16
|
+
export const BATCH_FORM_ID = 'x-admin-batch';
|
|
17
|
+
|
|
18
|
+
/** The posted field each checked row's id rides in; repeated once per row. */
|
|
19
|
+
export const BATCH_IDS_FIELD = 'ids';
|
|
20
|
+
|
|
21
|
+
/** `checked` (the default) or `all` — every row the list's scope and filters match. */
|
|
22
|
+
export const BATCH_SELECTION_FIELD = 'selection';
|
|
23
|
+
|
|
24
|
+
export interface AdminBatchBarProps {
|
|
25
|
+
/** The batch actions this actor may run on this resource — decided by the caller's gate. */
|
|
26
|
+
readonly actions: readonly AdminAction[];
|
|
27
|
+
/** The list's URL in its current state: the form posts here. */
|
|
28
|
+
readonly href: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function AdminBatchBar(props: AdminBatchBarProps): JSX.Element {
|
|
32
|
+
if (props.actions.length === 0) return <span class="x-admin-batch-empty" />;
|
|
33
|
+
return (
|
|
34
|
+
<form id={BATCH_FORM_ID} class={styles['batch']} method="post" action={props.href}>
|
|
35
|
+
<input type="hidden" name={OPERATION_FIELD} value={BATCH_OPERATION} />
|
|
36
|
+
<Select
|
|
37
|
+
name="name"
|
|
38
|
+
aria-label={t('admin.batch.action')}
|
|
39
|
+
options={props.actions.map((action) => ({
|
|
40
|
+
value: action.name,
|
|
41
|
+
label: t(actionLabelKey(action)),
|
|
42
|
+
}))}
|
|
43
|
+
/>
|
|
44
|
+
<Radio
|
|
45
|
+
legend={t('admin.batch.over')}
|
|
46
|
+
name={BATCH_SELECTION_FIELD}
|
|
47
|
+
direction="row"
|
|
48
|
+
value="checked"
|
|
49
|
+
options={[
|
|
50
|
+
{ value: 'checked', label: t('admin.batch.checked') },
|
|
51
|
+
{ value: 'all', label: t('admin.batch.all') },
|
|
52
|
+
]}
|
|
53
|
+
/>
|
|
54
|
+
<Button type="submit" size="sm" variant="secondary">
|
|
55
|
+
{t('admin.batch.run')}
|
|
56
|
+
</Button>
|
|
57
|
+
</form>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One row's checkbox, joined to the batch form by its id. */
|
|
62
|
+
export function BatchCheckbox(props: { readonly id: string; readonly label: string }): JSX.Element {
|
|
63
|
+
return (
|
|
64
|
+
<Checkbox form={BATCH_FORM_ID} name={BATCH_IDS_FIELD} value={props.id} label={props.label} />
|
|
65
|
+
);
|
|
66
|
+
}
|
package/src/batch-job.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// The queued half of a batch: ONE framework job, `admin.batch`, that runs one chunk of a batch
|
|
2
|
+
// past its declared threshold through the same per-row gate an inline batch uses. A job and not a
|
|
3
|
+
// ninth kind of thing — `enqueue` and a declaration are all it uses of `@ultimat3/jobs`.
|
|
4
|
+
|
|
5
|
+
import { userActor, withChildContext } from '@ultimat3/core';
|
|
6
|
+
import { type AnyJobHandle, getJob, job, t } from '@ultimat3/jobs';
|
|
7
|
+
import type { AdminActor } from './authz';
|
|
8
|
+
import { type BatchEnqueue, type BatchRow, runBatchRows } from './batch';
|
|
9
|
+
import { AdminMountMissingError } from './errors';
|
|
10
|
+
import { jsonText } from './json-text';
|
|
11
|
+
import { adminMountAt, adminMounts } from './mounts';
|
|
12
|
+
|
|
13
|
+
/** The queued input text, back to an object. Anything else is no input, which the schema judges. */
|
|
14
|
+
const inputOf = (text: string): Readonly<Record<string, unknown>> => {
|
|
15
|
+
try {
|
|
16
|
+
const value: unknown = JSON.parse(text);
|
|
17
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
18
|
+
? (value as Readonly<Record<string, unknown>>)
|
|
19
|
+
: {};
|
|
20
|
+
} catch {
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** The job's durable name: the queue key every queued chunk carries. Never renamed. */
|
|
26
|
+
export const ADMIN_BATCH_JOB = 'admin.batch';
|
|
27
|
+
|
|
28
|
+
const chunkInput = t.object({
|
|
29
|
+
basePath: t.string,
|
|
30
|
+
entity: t.string,
|
|
31
|
+
action: t.string,
|
|
32
|
+
ids: t.array(t.string),
|
|
33
|
+
// The action's input as JSON text: the action's own schema judges it again on the worker, so
|
|
34
|
+
// the queue carries no second description of a contract this package does not own.
|
|
35
|
+
input: t.string,
|
|
36
|
+
batchId: t.string,
|
|
37
|
+
index: t.number.int(),
|
|
38
|
+
requestId: t.string,
|
|
39
|
+
actor: t.object({
|
|
40
|
+
id: t.string,
|
|
41
|
+
roles: t.array(t.string),
|
|
42
|
+
orgId: t.optional(t.string),
|
|
43
|
+
locale: t.optional(t.string),
|
|
44
|
+
timeZone: t.optional(t.string),
|
|
45
|
+
}),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export interface AdminBatchChunk {
|
|
49
|
+
readonly basePath: string;
|
|
50
|
+
readonly entity: string;
|
|
51
|
+
readonly action: string;
|
|
52
|
+
readonly ids: readonly string[];
|
|
53
|
+
/** `jsonText` of the validated input. */
|
|
54
|
+
readonly input: string;
|
|
55
|
+
readonly batchId: string;
|
|
56
|
+
readonly index: number;
|
|
57
|
+
readonly requestId: string;
|
|
58
|
+
readonly actor: {
|
|
59
|
+
readonly id: string;
|
|
60
|
+
readonly roles: readonly string[];
|
|
61
|
+
readonly orgId?: string | undefined;
|
|
62
|
+
readonly locale?: string | undefined;
|
|
63
|
+
readonly timeZone?: string | undefined;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Run one chunk: find the admin it was queued from, and every row through the gate AS the
|
|
69
|
+
* operator who queued it — their roles decide, their tenant scopes every read. Nothing about the
|
|
70
|
+
* operator is trusted beyond what the queue row carries, which only the server wrote.
|
|
71
|
+
*/
|
|
72
|
+
export async function runBatchChunk(chunk: AdminBatchChunk): Promise<readonly BatchRow[]> {
|
|
73
|
+
const app = adminMountAt(chunk.basePath);
|
|
74
|
+
if (app === undefined) {
|
|
75
|
+
throw new AdminMountMissingError({
|
|
76
|
+
basePath: chunk.basePath,
|
|
77
|
+
mounted: adminMounts().map((one) => one.basePath),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
const resource = app.resource(chunk.entity);
|
|
81
|
+
const action = resource.actions.find((declared) => declared.name === chunk.action);
|
|
82
|
+
// An action renamed or removed between the enqueue and the claim has nothing to run: every row
|
|
83
|
+
// is refused by the gate's own answer for an action this resource does not declare.
|
|
84
|
+
if (action === undefined) {
|
|
85
|
+
return chunk.ids.map((id) => ({ id, outcome: 'refused', reason: 'admin.actions.unknown' }));
|
|
86
|
+
}
|
|
87
|
+
const actor: AdminActor = {
|
|
88
|
+
id: chunk.actor.id,
|
|
89
|
+
roles: chunk.actor.roles,
|
|
90
|
+
...(chunk.actor.orgId === undefined ? {} : { orgId: chunk.actor.orgId }),
|
|
91
|
+
...(chunk.actor.locale === undefined ? {} : { locale: chunk.actor.locale }),
|
|
92
|
+
...(chunk.actor.timeZone === undefined ? {} : { timeZone: chunk.actor.timeZone }),
|
|
93
|
+
};
|
|
94
|
+
const ctx = app.ctx({ actor, requestId: chunk.requestId });
|
|
95
|
+
// The operator is the ambient actor too, tenant included — what the entity's tenant guard
|
|
96
|
+
// reads — exactly as `mcp.ts` installs an agent for its call.
|
|
97
|
+
return withChildContext(
|
|
98
|
+
{
|
|
99
|
+
actor: userActor({
|
|
100
|
+
id: actor.id,
|
|
101
|
+
roles: [...(actor.roles ?? [])],
|
|
102
|
+
...(actor.orgId === undefined ? {} : { orgId: actor.orgId }),
|
|
103
|
+
}),
|
|
104
|
+
},
|
|
105
|
+
() => runBatchRows({ resource, action, ctx, ids: chunk.ids, input: inputOf(chunk.input) }),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The job, declared on first use and once per process. `tenant: 'none'` because the TENANT is
|
|
111
|
+
* the operator's and the body installs it (`runBatchChunk`); a chunk is one attempt — a handler
|
|
112
|
+
* that half-ran is not one to re-run, and every row it reached is already on the audit log.
|
|
113
|
+
*/
|
|
114
|
+
export function adminBatchJob(): AnyJobHandle {
|
|
115
|
+
const seated = getJob(ADMIN_BATCH_JOB);
|
|
116
|
+
if (seated !== undefined) return seated;
|
|
117
|
+
return job({
|
|
118
|
+
name: ADMIN_BATCH_JOB,
|
|
119
|
+
input: chunkInput,
|
|
120
|
+
idempotencyKey: (chunk) => `${ADMIN_BATCH_JOB}:${chunk.batchId}:${String(chunk.index)}`,
|
|
121
|
+
tenant: 'none',
|
|
122
|
+
retry: { attempts: 1, backoff: 'fixed' },
|
|
123
|
+
// A chunk's input is the server's own, written by `batchEnqueue` below: parsed, never trusted
|
|
124
|
+
// past what it names — the rows are decided again, one by one, on the worker.
|
|
125
|
+
run: ({ input }) => runBatchChunk(input),
|
|
126
|
+
}) as AnyJobHandle;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** What `runAdminBatch` queues a chunk with: one `admin.batch` job, its id answered. */
|
|
130
|
+
export const batchEnqueue =
|
|
131
|
+
(basePath: string): BatchEnqueue =>
|
|
132
|
+
async (chunk) => {
|
|
133
|
+
const actor = chunk.ctx.actor;
|
|
134
|
+
const queued = await adminBatchJob().enqueue({
|
|
135
|
+
basePath,
|
|
136
|
+
entity: chunk.resource.name,
|
|
137
|
+
action: chunk.action.name,
|
|
138
|
+
ids: [...chunk.ids],
|
|
139
|
+
input: jsonText(chunk.input),
|
|
140
|
+
batchId: chunk.batchId,
|
|
141
|
+
index: chunk.index,
|
|
142
|
+
requestId: chunk.ctx.requestId,
|
|
143
|
+
actor: {
|
|
144
|
+
id: actor.id,
|
|
145
|
+
roles: [...(actor.roles ?? [])],
|
|
146
|
+
...(actor.orgId === undefined ? {} : { orgId: actor.orgId }),
|
|
147
|
+
...(actor.locale === undefined ? {} : { locale: actor.locale }),
|
|
148
|
+
...(actor.timeZone === undefined ? {} : { timeZone: actor.timeZone }),
|
|
149
|
+
},
|
|
150
|
+
} satisfies AdminBatchChunk);
|
|
151
|
+
return queued.id;
|
|
152
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// "All matching" as ONE set-based call: an action that declares `matching` is handed the list's own
|
|
2
|
+
// `where` and answers `{ affected, remaining }` — the shape of a store's bounded bulk verb. Decided
|
|
3
|
+
// by the batch's gate, confirmed as a set, audited as one entry; `batch.ts` routes here.
|
|
4
|
+
|
|
5
|
+
import type { AdminDecision } from './authz';
|
|
6
|
+
import type { CrudCtx } from './crud';
|
|
7
|
+
import { type AdminListRequest, listWhere } from './list-scope';
|
|
8
|
+
import { ADMIN_DESTROY, CONFIRMATION_REQUIRED_REASON, confirmationToken } from './permissions';
|
|
9
|
+
import type { AdminAction, AdminActionCtx, AdminMatchingResult } from './registry';
|
|
10
|
+
import type { AdminResource } from './resource';
|
|
11
|
+
|
|
12
|
+
/** The reason the one entry of a set-based batch carries. */
|
|
13
|
+
export const BATCH_MATCHING_REASON = 'admin.batch.matching';
|
|
14
|
+
const MATCHING_FAILED_REASON = 'admin.error.action-failed';
|
|
15
|
+
|
|
16
|
+
/** The token a destructive set-based batch is typed against: the count is the store's to know. */
|
|
17
|
+
export const matchingConfirmationToken = (entity: string): string =>
|
|
18
|
+
confirmationToken(entity, 'all matching');
|
|
19
|
+
|
|
20
|
+
export type MatchingAnswer =
|
|
21
|
+
| { readonly ok: true; readonly result: AdminMatchingResult }
|
|
22
|
+
| { readonly ok: false; readonly token: string; readonly decision: AdminDecision };
|
|
23
|
+
|
|
24
|
+
export interface MatchingInput {
|
|
25
|
+
readonly resource: AdminResource;
|
|
26
|
+
readonly action: AdminAction & Required<Pick<AdminAction, 'matching'>>;
|
|
27
|
+
readonly ctx: CrudCtx;
|
|
28
|
+
readonly request: Pick<AdminListRequest, 'scope' | 'filters'>;
|
|
29
|
+
/** Already validated against the action's schema. */
|
|
30
|
+
readonly input: Readonly<Record<string, unknown>>;
|
|
31
|
+
readonly confirmation?: string | undefined;
|
|
32
|
+
/** The batch's own grant, decided before this is called. */
|
|
33
|
+
readonly decision: AdminDecision;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function runMatching(args: MatchingInput): Promise<MatchingAnswer> {
|
|
37
|
+
const { resource, action, ctx } = args;
|
|
38
|
+
// `expected`, as the gate names it: a typed-to-confirm echo the page prints, not a secret.
|
|
39
|
+
const expected = matchingConfirmationToken(resource.name);
|
|
40
|
+
if (action.destructive === true && args.confirmation !== expected) {
|
|
41
|
+
return {
|
|
42
|
+
ok: false,
|
|
43
|
+
token: expected,
|
|
44
|
+
decision: {
|
|
45
|
+
allowed: false,
|
|
46
|
+
permission: ADMIN_DESTROY,
|
|
47
|
+
reason: CONFIRMATION_REQUIRED_REASON,
|
|
48
|
+
trace: [`confirmation: expected "${expected}"`],
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
// The list's own composition, the row scope first: an org-scoped operator's set is that org's.
|
|
53
|
+
const { where } = listWhere(resource, ctx.actor, args.request);
|
|
54
|
+
const actionCtx: AdminActionCtx = {
|
|
55
|
+
requestId: ctx.requestId,
|
|
56
|
+
actorId: ctx.actor.id,
|
|
57
|
+
locale: ctx.actor.locale ?? 'en',
|
|
58
|
+
timeZone: ctx.actor.timeZone ?? 'UTC',
|
|
59
|
+
};
|
|
60
|
+
const entry = {
|
|
61
|
+
requestId: ctx.requestId,
|
|
62
|
+
actor: ctx.actor,
|
|
63
|
+
operation: action.name,
|
|
64
|
+
kind: 'action',
|
|
65
|
+
entity: resource.name,
|
|
66
|
+
entityId: null,
|
|
67
|
+
permission: args.decision.permission,
|
|
68
|
+
} as const;
|
|
69
|
+
let result: AdminMatchingResult;
|
|
70
|
+
try {
|
|
71
|
+
result = await action.matching({ where, input: args.input, ctx: actionCtx });
|
|
72
|
+
} catch (error) {
|
|
73
|
+
// The gate's rule: the failure is recorded before it travels, and nothing is read off it.
|
|
74
|
+
await ctx.audit.append({
|
|
75
|
+
...entry,
|
|
76
|
+
outcome: 'failed',
|
|
77
|
+
reason: MATCHING_FAILED_REASON,
|
|
78
|
+
diff: [],
|
|
79
|
+
});
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
await ctx.audit.append({
|
|
83
|
+
...entry,
|
|
84
|
+
outcome: 'allowed',
|
|
85
|
+
reason: BATCH_MATCHING_REASON,
|
|
86
|
+
diff: [
|
|
87
|
+
{ field: 'affected', before: null, after: result.affected },
|
|
88
|
+
{ field: 'remaining', before: null, after: result.remaining },
|
|
89
|
+
],
|
|
90
|
+
});
|
|
91
|
+
return { ok: true, result };
|
|
92
|
+
}
|