@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.
- package/CLAUDE.md +83 -91
- package/README.md +282 -89
- package/package.json +19 -17
- package/src/action-form.tsx +139 -0
- package/src/action-gate.ts +141 -68
- 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 +87 -0
- package/src/batch-queue.ts +90 -0
- package/src/batch.ts +396 -0
- package/src/catalog-keys.ts +55 -0
- package/src/crud-input.ts +104 -0
- package/src/crud-outcome.ts +224 -0
- package/src/crud.ts +172 -176
- package/src/describe.ts +131 -0
- package/src/detail.tsx +115 -43
- package/src/dev/data.ts +27 -12
- package/src/dev/facts.ts +28 -1
- package/src/dev/index.ts +1 -0
- package/src/dev/panel-cache.ts +8 -3
- 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-timeline.ts +7 -2
- package/src/dev/panel.ts +10 -2
- package/src/dev/server.ts +16 -11
- package/src/dev/sql-runner.ts +31 -0
- package/src/entity-columns.ts +24 -5
- package/src/errors.ts +92 -5
- package/src/fields.ts +63 -4
- package/src/form-decode.ts +132 -0
- package/src/form.tsx +96 -49
- package/src/index.ts +199 -9
- package/src/jobs/fleet-repo.ts +155 -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 +137 -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/keyset-seek.ts +189 -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 +242 -0
- package/src/list-request.ts +129 -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 +112 -38
- package/src/mounts.ts +44 -0
- package/src/page-guard.tsx +26 -29
- package/src/pagination.ts +10 -4
- package/src/policy-bridge.ts +115 -18
- package/src/registry.ts +140 -9
- package/src/related.ts +105 -0
- package/src/relations.ts +240 -0
- package/src/renderers.tsx +89 -0
- package/src/repo-entity.ts +195 -0
- package/src/resource-fields.ts +116 -0
- package/src/resource-layout.ts +89 -0
- package/src/resource-list.ts +194 -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/row-version.ts +20 -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 +441 -0
- package/src/screen-system.tsx +75 -0
- package/src/screens.ts +56 -0
- package/src/search.ts +22 -10
- 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 -16
package/src/page-guard.tsx
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// What a refused screen renders and the audit entry it leaves. The wrapper that DECIDES is
|
|
2
|
+
// `guardedScreen()` in `screen-frame.tsx`: `routes.ts` never hands a host the author's component
|
|
3
|
+
// — it hands that screen, which asks the SAME `decideAll` every CRUD call and every nav item
|
|
4
|
+
// asks, audits the refusal through this file, and only then calls the author's code.
|
|
4
5
|
|
|
5
6
|
import { t } from '@ultimat3/i18n';
|
|
6
7
|
import type { JSX } from 'solid-js';
|
|
7
|
-
import
|
|
8
|
+
import styles from './admin.module.scss';
|
|
8
9
|
import { deniedDraft } from './audit';
|
|
9
|
-
import {
|
|
10
|
-
import type {
|
|
10
|
+
import type { AdminDecision } from './authz';
|
|
11
|
+
import type { CrudCtx } from './crud';
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* The refusal, rendered as the page. Not a redirect and not a 404: an operator who is missing a
|
|
@@ -21,7 +22,7 @@ export function AdminPageDenied(props: {
|
|
|
21
22
|
return (
|
|
22
23
|
<section class="x-admin-denied" role="alert">
|
|
23
24
|
<h1>{t(props.titleKey)}</h1>
|
|
24
|
-
<p>
|
|
25
|
+
<p class={styles['refusal']}>
|
|
25
26
|
{t('admin.denied.body', {
|
|
26
27
|
permission: props.decision.permission,
|
|
27
28
|
reason: props.decision.reason,
|
|
@@ -32,27 +33,23 @@ export function AdminPageDenied(props: {
|
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
36
|
+
* The audit entry of a refused SCREEN. The page is the subject, so its path is what the row
|
|
37
|
+
* names — there is no entity and no row id to key a refused screen by. One writer, so a custom
|
|
38
|
+
* page and a generated form's GET leave the same entry.
|
|
37
39
|
*/
|
|
38
|
-
export function
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
);
|
|
54
|
-
return <AdminPageDenied titleKey={route.titleKey} decision={decision} />;
|
|
55
|
-
}
|
|
56
|
-
return component(props);
|
|
57
|
-
};
|
|
40
|
+
export async function auditRefusal(
|
|
41
|
+
ctx: CrudCtx,
|
|
42
|
+
path: string,
|
|
43
|
+
decision: AdminDecision,
|
|
44
|
+
): Promise<void> {
|
|
45
|
+
await ctx.audit.append(
|
|
46
|
+
deniedDraft({
|
|
47
|
+
requestId: ctx.requestId,
|
|
48
|
+
actor: ctx.actor,
|
|
49
|
+
operation: 'page',
|
|
50
|
+
kind: 'operation',
|
|
51
|
+
entity: path,
|
|
52
|
+
decision,
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
58
55
|
}
|
package/src/pagination.ts
CHANGED
|
@@ -10,7 +10,8 @@ import { type AdminResource, repoOf } from './resource';
|
|
|
10
10
|
|
|
11
11
|
export interface AdminCursor {
|
|
12
12
|
readonly field: string;
|
|
13
|
-
|
|
13
|
+
/** The boundary row's sort value as text, or `null` when it holds none — a position too. */
|
|
14
|
+
readonly value: string | null;
|
|
14
15
|
readonly id: string;
|
|
15
16
|
readonly direction: 'after' | 'before';
|
|
16
17
|
}
|
|
@@ -50,7 +51,7 @@ export function decodeAdminCursor(
|
|
|
50
51
|
if (payload.key.length !== 3) return null;
|
|
51
52
|
const [direction, field, value] = payload.key;
|
|
52
53
|
if (direction !== 'after' && direction !== 'before') return null;
|
|
53
|
-
if (typeof field !== 'string' || typeof value !== 'string') return null;
|
|
54
|
+
if (typeof field !== 'string' || (typeof value !== 'string' && value !== null)) return null;
|
|
54
55
|
return { direction, field, value, id: payload.id };
|
|
55
56
|
} catch (error) {
|
|
56
57
|
if (isUltimateError(error) && error.code === 'X_CURSOR_INVALID') return null;
|
|
@@ -98,10 +99,15 @@ export function listQuery<Row extends AdminRow>(
|
|
|
98
99
|
};
|
|
99
100
|
}
|
|
100
101
|
|
|
101
|
-
|
|
102
|
+
/**
|
|
103
|
+
* The boundary's sort value as the cursor carries it. A NULL stays `null` — JSON holds one — and
|
|
104
|
+
* is never `''`: an empty string is a text value of its own, and the seek past a NULL boundary
|
|
105
|
+
* reads the NULL rows, which a seek past `''` never reaches.
|
|
106
|
+
*/
|
|
107
|
+
const cursorValue = (row: AdminRow, field: string): string | null => {
|
|
102
108
|
const value = row[field];
|
|
103
109
|
if (value instanceof Date) return value.toISOString();
|
|
104
|
-
return value === null || value === undefined ?
|
|
110
|
+
return value === null || value === undefined ? null : String(value);
|
|
105
111
|
};
|
|
106
112
|
|
|
107
113
|
/**
|
package/src/policy-bridge.ts
CHANGED
|
@@ -3,13 +3,26 @@
|
|
|
3
3
|
// policies and the dashboard — and no second authz implementation can grow next to it.
|
|
4
4
|
|
|
5
5
|
import { type Actor, userActor } from '@ultimat3/core';
|
|
6
|
-
import {
|
|
7
|
-
|
|
6
|
+
import {
|
|
7
|
+
can,
|
|
8
|
+
definePermissions,
|
|
9
|
+
evaluate,
|
|
10
|
+
isKnownPermission,
|
|
11
|
+
type KnownPermission,
|
|
12
|
+
type Permission,
|
|
13
|
+
type Policy,
|
|
14
|
+
} from '@ultimat3/policy';
|
|
15
|
+
import {
|
|
16
|
+
type AdminActor,
|
|
17
|
+
type AdminAuthz,
|
|
18
|
+
type AdminDecision,
|
|
19
|
+
type AdminSubject,
|
|
20
|
+
allowed,
|
|
21
|
+
denied,
|
|
22
|
+
impliersOf,
|
|
23
|
+
} from './authz';
|
|
8
24
|
import { ADMIN_PERMISSIONS } from './permissions';
|
|
9
25
|
|
|
10
|
-
/** The admin's own permission set, registered with the policy layer at import time. */
|
|
11
|
-
export const adminPermissions = definePermissions(ADMIN_PERMISSIONS);
|
|
12
|
-
|
|
13
26
|
/**
|
|
14
27
|
* The admin carries its own actor shape; @ultimat3/policy evaluates core's `Actor`. The
|
|
15
28
|
* mapping lives here because this file is the only one allowed to speak to the policy layer.
|
|
@@ -27,15 +40,109 @@ const policyActor = (actor: AdminActor): Actor =>
|
|
|
27
40
|
function readDecision(permission: string, result: unknown): AdminDecision {
|
|
28
41
|
const bag = (typeof result === 'object' && result !== null ? result : {}) as {
|
|
29
42
|
allowed?: unknown;
|
|
30
|
-
|
|
43
|
+
decision?: unknown;
|
|
31
44
|
trace?: unknown;
|
|
32
45
|
};
|
|
46
|
+
// The reason lives on the evaluation's `decision`, not beside `allowed`. It was read off the top
|
|
47
|
+
// level, where no evaluation has ever carried one, so every refusal an operator read said
|
|
48
|
+
// `admin.policy.evaluated` — the same string whichever rule refused them.
|
|
49
|
+
const inner = (typeof bag.decision === 'object' && bag.decision !== null ? bag.decision : {}) as {
|
|
50
|
+
reason?: unknown;
|
|
51
|
+
};
|
|
33
52
|
const verdict = result === true || bag.allowed === true;
|
|
34
|
-
const reason = typeof
|
|
53
|
+
const reason = typeof inner.reason === 'string' ? inner.reason : 'admin.policy.evaluated';
|
|
35
54
|
const trace = Array.isArray(bag.trace) ? bag.trace.map((line) => String(line)) : [];
|
|
36
55
|
return verdict ? allowed(permission, reason, trace) : denied(permission, reason, trace);
|
|
37
56
|
}
|
|
38
57
|
|
|
58
|
+
/** `resource:verb` — the only shape `can()` takes. Anything else is denied, never thrown. */
|
|
59
|
+
const isPermission = (value: string): value is Permission => /^[^:]+:[^:]+$/.test(value);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Declare the admin's OWN permissions (`ADMIN_PERMISSIONS`) and the ones a mount DERIVES —
|
|
63
|
+
* `<entity>:read|write|delete`, a page's, an action's — so `can()` knows them. Granting them stays
|
|
64
|
+
* the app's: a role map that names none of these refuses every screen. Idempotent, and this file's
|
|
65
|
+
* because it is the one that speaks to `@ultimat3/policy`.
|
|
66
|
+
*
|
|
67
|
+
* Called by `defineAdmin()`, never at module scope. The permission registry is permissive while
|
|
68
|
+
* EMPTY and closed once it holds one name, so registering `admin:*` on import closed the set for
|
|
69
|
+
* every module that merely shared a process with this package — an app that imported the admin
|
|
70
|
+
* for a type, and every test file that ran after an admin one.
|
|
71
|
+
*/
|
|
72
|
+
export function declareAdminPermissions(permissions: readonly string[]): void {
|
|
73
|
+
definePermissions([...ADMIN_PERMISSIONS, ...permissions.filter(isPermission)]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const evaluated = (
|
|
77
|
+
permission: string,
|
|
78
|
+
policy: Policy,
|
|
79
|
+
actor: AdminActor,
|
|
80
|
+
subject: AdminSubject | undefined,
|
|
81
|
+
): AdminDecision => {
|
|
82
|
+
// `EvaluateArgs` carries exactly one payload. An admin subject with no action input IS
|
|
83
|
+
// that payload: a row-level rule has nothing but the entity and id to decide on.
|
|
84
|
+
const payload = subject?.input ?? subject;
|
|
85
|
+
// `row` is passed through only when the surface LOADED one. Omitting it and passing
|
|
86
|
+
// `row: undefined` are different facts to `evaluate`, and a rule that reads `row` must see
|
|
87
|
+
// `null` — "no row was loaded" — rather than a value the admin invented from `input`.
|
|
88
|
+
const row = subject === undefined || !('row' in subject) ? undefined : { row: subject.row };
|
|
89
|
+
return readDecision(
|
|
90
|
+
permission,
|
|
91
|
+
evaluate(policy, { actor: policyActor(actor), input: payload, ...row }),
|
|
92
|
+
);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The default authz: the app's ROLE MAP decides. Every permission the admin asks about is
|
|
97
|
+
* `can(permission)` — "does a role this actor holds grant it" — so an app with a `defineRoles()`
|
|
98
|
+
* call has a working, closed admin with no permission → policy table to keep in step. An app
|
|
99
|
+
* whose rules read the row or the tenant passes `policyAuthz({ policies })` instead.
|
|
100
|
+
*/
|
|
101
|
+
export function roleAuthz(): AdminAuthz {
|
|
102
|
+
return {
|
|
103
|
+
decide({ permission, actor, subject }): AdminDecision {
|
|
104
|
+
// Asked BEFORE `can()`, which throws on a name the registry lacks: a decision is an answer,
|
|
105
|
+
// and an undeclared permission is a refusal with its fix, never a 500 out of a nav render.
|
|
106
|
+
if (!isPermission(permission) || !isKnownPermission(permission)) {
|
|
107
|
+
return denied(permission, 'admin.policy.missing', [
|
|
108
|
+
`"${permission}" is not a declared resource:verb permission`,
|
|
109
|
+
`fix: definePermissions(['${permission}']), then grant it to a role in defineRoles()`,
|
|
110
|
+
]);
|
|
111
|
+
}
|
|
112
|
+
// `can()` asserts the name against the registry `declareAdminPermissions` filled. The cast
|
|
113
|
+
// is the same one a route guard's bare string takes: the registry, not the type, is the check.
|
|
114
|
+
const own = evaluated(permission, can(permission as KnownPermission), actor, subject);
|
|
115
|
+
if (own.allowed) return own;
|
|
116
|
+
// The admin's implications, as `staticAuthz` applies them: a role granting `admin:destroy`
|
|
117
|
+
// holds `admin:write` and `admin:read` too. Without this the same grants answered two ways.
|
|
118
|
+
for (const implier of impliersOf(permission)) {
|
|
119
|
+
const carried = evaluated(implier, can(implier as KnownPermission), actor, subject);
|
|
120
|
+
if (carried.allowed) {
|
|
121
|
+
return allowed(permission, carried.reason, [
|
|
122
|
+
...carried.trace,
|
|
123
|
+
`${permission} is implied by ${implier}`,
|
|
124
|
+
]);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return own;
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* ONE policy answers every gate the admin asks — "whoever may run the app may run its admin".
|
|
134
|
+
* The shape a small app starts in: a single grant its role map already has, with no table of
|
|
135
|
+
* per-entity permissions to keep in step as entities are added. The pair is still asked
|
|
136
|
+
* (`admin:write` then `posts:write`), each is still audited under its own name, and the day a
|
|
137
|
+
* view-only operator is needed this becomes `roleAuthz()` or `policyAuthz({ policies })`.
|
|
138
|
+
*/
|
|
139
|
+
export function singlePolicyAuthz(policy: Policy): AdminAuthz {
|
|
140
|
+
return {
|
|
141
|
+
decide: ({ permission, actor, subject }): AdminDecision =>
|
|
142
|
+
evaluated(permission, policy, actor, subject),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
39
146
|
export interface PolicyAuthzInput {
|
|
40
147
|
/** Permission name → the policy that decides it. `describeActions()` supplies these. */
|
|
41
148
|
readonly policies: Readonly<Record<string, Policy>>;
|
|
@@ -55,17 +162,7 @@ export function policyAuthz(input: PolicyAuthzInput): AdminAuthz {
|
|
|
55
162
|
`fix: definePermissions({ '${permission}': … }) or can('${permission}') on the action`,
|
|
56
163
|
]);
|
|
57
164
|
}
|
|
58
|
-
|
|
59
|
-
// that payload: a row-level rule has nothing but the entity and id to decide on.
|
|
60
|
-
const payload = subject?.input ?? subject;
|
|
61
|
-
// `row` is passed through only when the surface LOADED one. Omitting it and passing
|
|
62
|
-
// `row: undefined` are different facts to `evaluate`, and a rule that reads `row` must see
|
|
63
|
-
// `null` — "no row was loaded" — rather than a value the admin invented from `input`.
|
|
64
|
-
const row = subject === undefined || !('row' in subject) ? undefined : { row: subject.row };
|
|
65
|
-
return readDecision(
|
|
66
|
-
permission,
|
|
67
|
-
evaluate(policy, { actor: policyActor(actor), input: payload, ...row }),
|
|
68
|
-
);
|
|
165
|
+
return evaluated(permission, policy, actor, subject);
|
|
69
166
|
},
|
|
70
167
|
};
|
|
71
168
|
}
|
package/src/registry.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// is the compile-time proof that a real `entity()` result satisfies it; it is checked by
|
|
7
7
|
// `tsc`, not asserted in a comment, because the admin used to read fields no entity had.
|
|
8
8
|
|
|
9
|
-
import type { Entity,
|
|
9
|
+
import type { Entity, Operator, Table } from '@ultimat3/entity';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* What the author declared about one column — `@ultimat3/entity`'s `ColumnMeta`, narrowed to
|
|
@@ -37,11 +37,21 @@ export interface AdminColumnMeta {
|
|
|
37
37
|
* hands back the resolved target — but its presence is what makes the column a foreign key.
|
|
38
38
|
*/
|
|
39
39
|
readonly references?: () => unknown;
|
|
40
|
+
/**
|
|
41
|
+
* `.sealed()`: encrypted at rest, opened on read, absent from every schema that leaves the
|
|
42
|
+
* server. Present means the admin derives NO readable field for it — see `entity-columns.ts`.
|
|
43
|
+
*/
|
|
44
|
+
readonly sealed?: { readonly lookup: boolean };
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
/** One column of a registered entity. An `@ultimat3/entity` `Column` satisfies this. */
|
|
43
48
|
export interface AdminColumn {
|
|
44
49
|
readonly $meta: AdminColumnMeta;
|
|
50
|
+
/**
|
|
51
|
+
* The column's own guard. Optional in this surface only because a hand-built fixture has none;
|
|
52
|
+
* every `entity()` column carries it, and `repo-entity.ts` parses a URL's id through it.
|
|
53
|
+
*/
|
|
54
|
+
readonly $parse?: (value: unknown) => unknown;
|
|
45
55
|
}
|
|
46
56
|
|
|
47
57
|
/** One column of `$describe()` output. Money is the one property that becomes two of these. */
|
|
@@ -65,6 +75,12 @@ export interface AdminEntity {
|
|
|
65
75
|
readonly $columns: Readonly<Record<string, AdminColumn>>;
|
|
66
76
|
/** The Standard Schema the entity validates with; forms hand input straight to it. */
|
|
67
77
|
readonly $schema: unknown;
|
|
78
|
+
/**
|
|
79
|
+
* The column the handle scopes every read and write by, or `null`. Optional in this surface only
|
|
80
|
+
* because a hand-built fixture has none; every `entity()` result carries it. The admin never
|
|
81
|
+
* renders it as an input — the acting actor's tenant is the only value it may hold.
|
|
82
|
+
*/
|
|
83
|
+
readonly $tenantColumn?: string | null;
|
|
68
84
|
/** Resolves foreign-key targets. See `entity-columns.ts` for what the admin takes from it. */
|
|
69
85
|
$describe(): AdminEntityDescription;
|
|
70
86
|
}
|
|
@@ -80,14 +96,63 @@ type Satisfies<Surface, T extends Surface> = T;
|
|
|
80
96
|
export type RegisteredEntity = Satisfies<AdminEntity, Entity<AdminRow>>;
|
|
81
97
|
|
|
82
98
|
/**
|
|
83
|
-
*
|
|
84
|
-
* the
|
|
85
|
-
*
|
|
99
|
+
* The slice of a typed handle's read chain the admin drives — `database()`'s `db.<entity>`. Named
|
|
100
|
+
* for the reason `AdminEntity` is: one file changes when the handle grows, and `RegisteredTable`
|
|
101
|
+
* is the `tsc`-checked proof a real one satisfies it.
|
|
86
102
|
*/
|
|
87
|
-
export
|
|
103
|
+
export interface AdminTableRead {
|
|
104
|
+
where(filter: Readonly<Record<string, unknown>>): AdminTableRead;
|
|
105
|
+
andWhere(column: string, op: Operator, value?: unknown): AdminTableRead;
|
|
106
|
+
orderBy(column: string, direction?: 'asc' | 'desc'): AdminTableRead;
|
|
107
|
+
limit(rows: number): AdminTableRead;
|
|
108
|
+
all(): Promise<readonly AdminRow[]>;
|
|
109
|
+
one(): Promise<AdminRow | null>;
|
|
110
|
+
count(): Promise<number>;
|
|
111
|
+
/** Read for the entity NAME only: it is how a handle is matched to the entity it serves. */
|
|
112
|
+
plan(): { readonly entity: string };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** One table of the app's typed handle. Tenancy, soft delete and sealing are its own. */
|
|
116
|
+
export interface AdminTable extends AdminTableRead {
|
|
117
|
+
insert(values: Readonly<Record<string, unknown>>): Promise<AdminRow>;
|
|
118
|
+
update(id: string, patch: Readonly<Record<string, unknown>>): Promise<AdminRow>;
|
|
119
|
+
delete(id: string): Promise<void>;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The app's `database()` result, as `defineAdmin({ db })` takes it. Keys are the app's spelling. */
|
|
123
|
+
export type AdminDb = Readonly<Record<string, AdminTable>>;
|
|
124
|
+
|
|
125
|
+
/** The claim, checked: `database()`'s table IS an `AdminTable`. */
|
|
126
|
+
export type RegisteredTable = Satisfies<AdminTable, Table<AdminRow>>;
|
|
127
|
+
|
|
128
|
+
export type FilterOp =
|
|
129
|
+
| 'eq'
|
|
130
|
+
| 'neq'
|
|
131
|
+
| 'contains'
|
|
132
|
+
| 'gt'
|
|
133
|
+
| 'gte'
|
|
134
|
+
| 'lt'
|
|
135
|
+
| 'lte'
|
|
136
|
+
| 'in'
|
|
137
|
+
/** `value: true` keeps the rows with no value, `value: false` the rows with one. */
|
|
138
|
+
| 'is-null';
|
|
88
139
|
|
|
89
|
-
export
|
|
140
|
+
export const FILTER_OPS: readonly FilterOp[] = [
|
|
141
|
+
'eq',
|
|
142
|
+
'neq',
|
|
143
|
+
'contains',
|
|
144
|
+
'gt',
|
|
145
|
+
'gte',
|
|
146
|
+
'lt',
|
|
147
|
+
'lte',
|
|
148
|
+
'in',
|
|
149
|
+
'is-null',
|
|
150
|
+
];
|
|
90
151
|
|
|
152
|
+
/**
|
|
153
|
+
* One predicate of a list read. Every list the admin issues is a CONJUNCTION of these — the URL's
|
|
154
|
+
* filters, the scope's, and the resource's row scope — so each maps to one indexed comparison.
|
|
155
|
+
*/
|
|
91
156
|
export interface AdminFilter {
|
|
92
157
|
readonly field: string;
|
|
93
158
|
readonly op: FilterOp;
|
|
@@ -101,7 +166,8 @@ export interface AdminSort {
|
|
|
101
166
|
|
|
102
167
|
export interface KeysetBound {
|
|
103
168
|
readonly field: string;
|
|
104
|
-
|
|
169
|
+
/** The boundary row's sort value as text; `null` when it holds none, which sorts LAST. */
|
|
170
|
+
readonly value: string | null;
|
|
105
171
|
readonly id: string;
|
|
106
172
|
}
|
|
107
173
|
|
|
@@ -147,10 +213,33 @@ export interface AdminActionCtx {
|
|
|
147
213
|
readonly timeZone: string;
|
|
148
214
|
}
|
|
149
215
|
|
|
216
|
+
/** How a batch action behaves past the size a request should run inline. */
|
|
217
|
+
export interface AdminBatchOptions {
|
|
218
|
+
/**
|
|
219
|
+
* The most rows one request runs INLINE. A selection larger than this is handed to the queue —
|
|
220
|
+
* one `admin.batch` job per chunk — and the answer says how many rows were queued, not done.
|
|
221
|
+
*/
|
|
222
|
+
readonly threshold: number;
|
|
223
|
+
/** Rows per queued job. Omitted: `threshold`. */
|
|
224
|
+
readonly chunk?: number;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** What a set-based "all matching" answers — the shape of a store's own bulk verb. */
|
|
228
|
+
export interface AdminMatchingResult {
|
|
229
|
+
/** Rows this call changed. */
|
|
230
|
+
readonly affected: number;
|
|
231
|
+
/** Rows still matching afterwards. Run again until it is zero. */
|
|
232
|
+
readonly remaining: number;
|
|
233
|
+
}
|
|
234
|
+
|
|
150
235
|
/**
|
|
151
236
|
* A registered `action` as the admin surfaces it. `permission` is not optional: an action
|
|
152
237
|
* with no policy is an open door, and the admin refuses to render one
|
|
153
238
|
* (X_ADMIN_POLICY_MISSING).
|
|
239
|
+
*
|
|
240
|
+
* One declaration, every projection: the button on the detail page and on each list row, the
|
|
241
|
+
* action's own form, the batch bar, the MCP tool, the audit entry. `when` and `batch` narrow WHERE
|
|
242
|
+
* it appears and never add a second handler.
|
|
154
243
|
*/
|
|
155
244
|
export interface AdminAction<Input = Readonly<Record<string, unknown>>, Output = unknown> {
|
|
156
245
|
readonly name: string;
|
|
@@ -158,15 +247,47 @@ export interface AdminAction<Input = Readonly<Record<string, unknown>>, Output =
|
|
|
158
247
|
/** The entity the button belongs to. Absent = a global action in the toolbar. */
|
|
159
248
|
readonly entity?: string;
|
|
160
249
|
readonly destructive?: boolean;
|
|
250
|
+
/** Absent: `admin.action.<name>` — `actionLabelKey`, the one spelling every screen reads. */
|
|
161
251
|
readonly labelKey?: string;
|
|
162
252
|
/** Mirrors the action's own `mcp` block; the admin MCP surface honours `expose`. */
|
|
163
253
|
readonly mcp?: { readonly expose?: boolean; readonly description?: string };
|
|
164
|
-
/**
|
|
254
|
+
/**
|
|
255
|
+
* The action's input schema — a `t.object({ … })`. Rendered as the action's form (one control
|
|
256
|
+
* per property), validated before the handler runs with each issue shown against its field,
|
|
257
|
+
* and handed to the MCP tool definition. Absent: the action takes no input, and its button is a
|
|
258
|
+
* confirm only. The row's `id` is the admin's own envelope and is never a property here.
|
|
259
|
+
*/
|
|
165
260
|
readonly input?: unknown;
|
|
261
|
+
/**
|
|
262
|
+
* Whether the action applies to THIS row. Decides the button on the detail page and on each
|
|
263
|
+
* list row, and is evaluated again on the server before the handler runs — a hidden button is
|
|
264
|
+
* not an authorization: `X_ADMIN_ACTION_NOT_APPLICABLE`. The row it reads carries no sealed
|
|
265
|
+
* column. Absent: the action applies to every row.
|
|
266
|
+
*/
|
|
267
|
+
readonly when?: (row: AdminRow) => boolean;
|
|
268
|
+
/**
|
|
269
|
+
* In the list's batch bar: run once per selected row through the same gate as the button, each
|
|
270
|
+
* row audited, the answer counting done, refused and failed. `true` runs every batch inline;
|
|
271
|
+
* `{ threshold }` queues a selection larger than that as one job per chunk.
|
|
272
|
+
*/
|
|
273
|
+
readonly batch?: true | AdminBatchOptions;
|
|
274
|
+
/**
|
|
275
|
+
* The batch bar's "all matching" as ONE set-based call instead of a walk of rows: handed the
|
|
276
|
+
* list's own `where` — row scope, then scope, then filters — it changes what matches and answers
|
|
277
|
+
* how many it changed and how many still match. For a store with a bounded bulk verb of its own
|
|
278
|
+
* (the jobs operator's `requeueMany`). Decided and audited once, for the set; the per-row gate is
|
|
279
|
+
* not asked per row, so the verb's own predicate is where `when` holds. A checked selection
|
|
280
|
+
* still runs row by row through `handle`. Requires `batch`.
|
|
281
|
+
*/
|
|
282
|
+
readonly matching?: (args: {
|
|
283
|
+
readonly where: readonly AdminFilter[];
|
|
284
|
+
readonly input: Input;
|
|
285
|
+
readonly ctx: AdminActionCtx;
|
|
286
|
+
}) => Promise<AdminMatchingResult>;
|
|
166
287
|
handle(args: { input: Input; ctx: AdminActionCtx }): Promise<Output>;
|
|
167
288
|
}
|
|
168
289
|
|
|
169
|
-
/** A registered `job` as
|
|
290
|
+
/** A registered `job` as an AI pane is told about it (`ai-panes.ts`) — `describeJobs()` output. */
|
|
170
291
|
export interface AdminJobSummary {
|
|
171
292
|
readonly name: string;
|
|
172
293
|
readonly queue?: string;
|
|
@@ -185,3 +306,13 @@ export function rowId(row: AdminRow, idField: string): string {
|
|
|
185
306
|
const value = row[idField];
|
|
186
307
|
return typeof value === 'string' ? value : String(value ?? '');
|
|
187
308
|
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* A row as something DRAWN or DECIDED on reads it: its enumerable values, minus every sealed
|
|
312
|
+
* column. A repository row keeps a sealed property off its enumerable set already; a hand-written
|
|
313
|
+
* `repo:` may not, so the names are dropped explicitly — a computed cell is rendered and an
|
|
314
|
+
* action's `when` is evaluated per row, and a sealed value reaches neither.
|
|
315
|
+
*/
|
|
316
|
+
export function computedRow(row: AdminRow, sealed: readonly string[]): AdminRow {
|
|
317
|
+
return Object.fromEntries(Object.entries(row).filter(([key]) => !sealed.includes(key)));
|
|
318
|
+
}
|
package/src/related.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// A row's RELATED rows: the `hasMany` relations a resource names in `related`, each read as the
|
|
2
|
+
// related resource's own list — its gate, its row scope, its columns — narrowed to the rows that
|
|
3
|
+
// point at this one. No second table is declared anywhere: the related resource already is one.
|
|
4
|
+
|
|
5
|
+
import { relationsFor as entityRelationsFor } from '@ultimat3/entity';
|
|
6
|
+
import { adminList, type CrudCtx, canOperate } from './crud';
|
|
7
|
+
import { AdminFieldUnsupportedError } from './errors';
|
|
8
|
+
import type { AdminPage } from './pagination';
|
|
9
|
+
import type { AdminFilter, AdminRow } from './registry';
|
|
10
|
+
import type { AdminResource } from './resource';
|
|
11
|
+
|
|
12
|
+
/** How many related rows a detail page shows per relation. The rest are one link away. */
|
|
13
|
+
export const RELATED_PAGE_SIZE = 10;
|
|
14
|
+
|
|
15
|
+
/** One declared relation, resolved against the admin it is declared in. */
|
|
16
|
+
export interface AdminRelated {
|
|
17
|
+
/** The relation's name on the entity — what `related: [...]` spelled. */
|
|
18
|
+
readonly name: string;
|
|
19
|
+
/** The resource whose rows these are. */
|
|
20
|
+
readonly resource: AdminResource;
|
|
21
|
+
/** The property of THAT resource holding the foreign key. */
|
|
22
|
+
readonly field: string;
|
|
23
|
+
/** The property of the row in hand the foreign key points at. */
|
|
24
|
+
readonly localKey: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The `related` names of one resource, resolved. Every name is a `hasMany` of the entity whose
|
|
29
|
+
* target is a resource of this admin — anything else is refused here, where it is declared, never
|
|
30
|
+
* drawn as an empty card.
|
|
31
|
+
*/
|
|
32
|
+
export function relatedOf(
|
|
33
|
+
resources: readonly AdminResource[],
|
|
34
|
+
resource: AdminResource,
|
|
35
|
+
): readonly AdminRelated[] {
|
|
36
|
+
if (resource.related.length === 0) return [];
|
|
37
|
+
// The one read of `@ultimat3/entity`'s relations in this package: the FK already written IS
|
|
38
|
+
// the relation, so the admin derives nothing of its own and names nothing twice.
|
|
39
|
+
const relations = entityRelationsFor(resource.name);
|
|
40
|
+
const offered = Object.values(relations)
|
|
41
|
+
.filter((relation) => relation.kind === 'hasMany')
|
|
42
|
+
.map((relation) => relation.name);
|
|
43
|
+
const hint = `resources.${resource.name}.related may name: ${offered.length > 0 ? offered.join(', ') : 'nothing — no entity references this one'}`;
|
|
44
|
+
|
|
45
|
+
return resource.related.map((name) => {
|
|
46
|
+
const refuse = (cause: string, fix: string): never => {
|
|
47
|
+
throw new AdminFieldUnsupportedError({ entity: resource.name, field: name, cause, fix });
|
|
48
|
+
};
|
|
49
|
+
const relation = Object.hasOwn(relations, name) ? relations[name] : undefined;
|
|
50
|
+
if (relation === undefined) {
|
|
51
|
+
return refuse('named in related and is not a relation of the entity', hint);
|
|
52
|
+
}
|
|
53
|
+
if (relation.kind !== 'hasMany') {
|
|
54
|
+
return refuse(
|
|
55
|
+
`named in related and is a belongsTo: the "${relation.localKey}" field already links to its row`,
|
|
56
|
+
hint,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
const target = resources.find((known) => known.name === relation.to);
|
|
60
|
+
if (target === undefined) {
|
|
61
|
+
return refuse(
|
|
62
|
+
`named in related, and its rows are "${relation.to}", which is not a resource of this admin`,
|
|
63
|
+
`add the ${relation.to} entity to defineAdmin({ entities }) — its own list is what related draws`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return { name, resource: target, field: relation.remoteKey, localKey: relation.localKey };
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** One related list as the detail page draws it. */
|
|
71
|
+
export interface AdminRelatedList {
|
|
72
|
+
readonly related: AdminRelated;
|
|
73
|
+
readonly page: AdminPage<AdminRow>;
|
|
74
|
+
/** The one predicate that makes this list THIS row's. */
|
|
75
|
+
readonly filter: AdminFilter;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The related lists of one row — one statement each, read beside each other. A related resource
|
|
80
|
+
* this actor may not list is LEFT OUT, silently: they asked for this row, and the refusal they
|
|
81
|
+
* would get by opening that list is logged there. Everything else is `adminList`: the related
|
|
82
|
+
* resource's policy decides, its `rows` narrows, and the read is audited under its own name.
|
|
83
|
+
*/
|
|
84
|
+
export async function relatedLists(
|
|
85
|
+
related: readonly AdminRelated[],
|
|
86
|
+
row: AdminRow,
|
|
87
|
+
ctx: CrudCtx,
|
|
88
|
+
): Promise<readonly AdminRelatedList[]> {
|
|
89
|
+
const readable = related.filter((one) => canOperate(one.resource, 'list', ctx));
|
|
90
|
+
const read = await Promise.all(
|
|
91
|
+
readable.map(async (one): Promise<AdminRelatedList | null> => {
|
|
92
|
+
const key = row[one.localKey];
|
|
93
|
+
if (key === null || key === undefined) return null;
|
|
94
|
+
const filter: AdminFilter = { field: one.field, op: 'eq', value: String(key) };
|
|
95
|
+
const result = await adminList<AdminRow>(one.resource, ctx, {
|
|
96
|
+
// No scope, not even the default one: a related row counts whatever tab it sits under.
|
|
97
|
+
scope: null,
|
|
98
|
+
filters: [filter],
|
|
99
|
+
limit: RELATED_PAGE_SIZE,
|
|
100
|
+
});
|
|
101
|
+
return result.ok ? { related: one, page: result.page, filter } : null;
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
return read.filter((one): one is AdminRelatedList => one !== null);
|
|
105
|
+
}
|