@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/search.ts ADDED
@@ -0,0 +1,108 @@
1
+ // Cross-entity search, derived: every resource's text fields are the index. No separate
2
+ // search config to drift from the entities, and no result an actor could not have opened —
3
+ // the same `admin:read` + `<entity>:read` pair gates the hit and the detail page it links to.
4
+
5
+ import type { CrudCtx } from './crud';
6
+ import { canOperate } from './crud';
7
+ import type { AdminFilter, AdminRow } from './registry';
8
+ import { rowId } from './registry';
9
+ import { type AdminResource, repoOf } from './resource';
10
+
11
+ /** Per resource, and per field within it. Search is a jump box, not a report. */
12
+ const MAX_FIELDS_PER_RESOURCE = 3;
13
+ const DEFAULT_LIMIT_PER_RESOURCE = 5;
14
+
15
+ export interface AdminSearchHit {
16
+ readonly entity: string;
17
+ readonly id: string;
18
+ /** The row's label-field value. Already a string; the view does not format it. */
19
+ readonly label: string;
20
+ readonly matchedField: string;
21
+ /** Mount-relative link to the detail page. */
22
+ readonly href: string;
23
+ }
24
+
25
+ export interface AdminSearchResult {
26
+ readonly term: string;
27
+ readonly hits: readonly AdminSearchHit[];
28
+ readonly searched: readonly string[];
29
+ /** Resources left out, and why — so an operator is never silently shown a subset. */
30
+ readonly skipped: readonly { readonly entity: string; readonly reason: string }[];
31
+ }
32
+
33
+ export interface AdminSearchInput {
34
+ readonly term: string;
35
+ readonly resources: readonly AdminResource[];
36
+ readonly ctx: CrudCtx;
37
+ readonly limitPerResource?: number;
38
+ }
39
+
40
+ /**
41
+ * One query per searchable field rather than one query with an OR: the admin's query IR is
42
+ * a conjunction by design (each filter maps to an indexed predicate), and three small
43
+ * indexed lookups beat one unindexed disjunction.
44
+ */
45
+ async function searchResource(
46
+ resource: AdminResource,
47
+ term: string,
48
+ limit: number,
49
+ ): Promise<readonly AdminSearchHit[]> {
50
+ const repo = repoOf(resource);
51
+ const fields = resource.searchFields.slice(0, MAX_FIELDS_PER_RESOURCE);
52
+ const seen = new Set<string>();
53
+ const hits: AdminSearchHit[] = [];
54
+
55
+ for (const field of fields) {
56
+ const where: readonly AdminFilter[] = [{ field: field.name, op: 'contains', value: term }];
57
+ const rows = await repo.list({ where, sort: resource.defaultSort, limit });
58
+ for (const row of rows) {
59
+ const id = rowId(row, resource.idField);
60
+ if (id === '' || seen.has(id)) continue;
61
+ seen.add(id);
62
+ hits.push({
63
+ entity: resource.name,
64
+ id,
65
+ label: labelOf(row, resource),
66
+ matchedField: field.name,
67
+ href: `${resource.path}/${id}`,
68
+ });
69
+ if (hits.length >= limit) return hits;
70
+ }
71
+ }
72
+ return hits;
73
+ }
74
+
75
+ function labelOf(row: AdminRow, resource: AdminResource): string {
76
+ const value = row[resource.labelField];
77
+ if (typeof value === 'string' && value !== '') return value;
78
+ return rowId(row, resource.idField);
79
+ }
80
+
81
+ export async function adminSearch(input: AdminSearchInput): Promise<AdminSearchResult> {
82
+ const term = input.term.trim();
83
+ const limit = input.limitPerResource ?? DEFAULT_LIMIT_PER_RESOURCE;
84
+ const searched: string[] = [];
85
+ const skipped: { entity: string; reason: string }[] = [];
86
+ const hits: AdminSearchHit[] = [];
87
+
88
+ if (term === '') return { term, hits: [], searched: [], skipped: [] };
89
+
90
+ for (const resource of input.resources) {
91
+ if (!canOperate(resource, 'search', input.ctx)) {
92
+ skipped.push({ entity: resource.name, reason: 'admin.search.skipped.forbidden' });
93
+ continue;
94
+ }
95
+ if (resource.searchFields.length === 0) {
96
+ skipped.push({ entity: resource.name, reason: 'admin.search.skipped.no-text-fields' });
97
+ continue;
98
+ }
99
+ if (resource.repo === undefined) {
100
+ skipped.push({ entity: resource.name, reason: 'admin.search.skipped.no-repo' });
101
+ continue;
102
+ }
103
+ searched.push(resource.name);
104
+ hits.push(...(await searchResource(resource, term, limit)));
105
+ }
106
+
107
+ return { term, hits, searched, skipped };
108
+ }
package/src/theme.ts ADDED
@@ -0,0 +1,59 @@
1
+ // Admin branding through the token system only. `ThemeTokenRef` is a template-literal type,
2
+ // so `accent: '#7c3aed'` is a compile error rather than a code-review comment: branding
3
+ // aliases one design token to another, and every colour still resolves in @ultimat3/ui's
4
+ // light and dark scales.
5
+
6
+ /** Any framework design token. Raw hex, `rgb()`, and colour names cannot satisfy this. */
7
+ export type ThemeTokenRef = `--x-${string}`;
8
+
9
+ export type ThemeMode = 'system' | 'light' | 'dark';
10
+
11
+ export interface AdminBranding {
12
+ /** i18n key for the product name in the header and the document title. */
13
+ readonly nameKey: string;
14
+ readonly logo?: { readonly src: string; readonly altKey: string; readonly width?: number };
15
+ /** Alias for `--x-color-accent`, e.g. `--x-color-brand`. */
16
+ readonly accent?: ThemeTokenRef;
17
+ /** Extra token aliases: `{ '--x-color-surface': '--x-color-brand-surface' }`. */
18
+ readonly tokens?: Readonly<Partial<Record<ThemeTokenRef, ThemeTokenRef>>>;
19
+ /** `system` follows `prefers-color-scheme`; the others pin `data-theme`. */
20
+ readonly mode?: ThemeMode;
21
+ readonly density?: 'comfortable' | 'compact';
22
+ }
23
+
24
+ export const defaultBranding: AdminBranding = {
25
+ nameKey: 'admin.brand.name',
26
+ mode: 'system',
27
+ density: 'comfortable',
28
+ };
29
+
30
+ export function adminBranding(input: Partial<AdminBranding> = {}): AdminBranding {
31
+ return { ...defaultBranding, ...input };
32
+ }
33
+
34
+ export interface ThemeAttributes {
35
+ /** Absent under `system` so the media query decides; present otherwise, and it wins. */
36
+ readonly 'data-theme'?: 'light' | 'dark';
37
+ readonly 'data-density': 'comfortable' | 'compact';
38
+ /** Inline custom-property declarations: token → token, never token → literal. */
39
+ readonly style: string;
40
+ }
41
+
42
+ /**
43
+ * The attributes the admin shell puts on its root element. Written as data attributes plus
44
+ * custom properties so a theme flip re-paints without re-rendering a single component.
45
+ */
46
+ export function themeAttributes(branding: AdminBranding): ThemeAttributes {
47
+ const aliases: [ThemeTokenRef, ThemeTokenRef][] = [];
48
+ if (branding.accent !== undefined) aliases.push(['--x-color-accent', branding.accent]);
49
+ for (const [target, source] of Object.entries(branding.tokens ?? {})) {
50
+ if (source !== undefined) aliases.push([target as ThemeTokenRef, source]);
51
+ }
52
+
53
+ const mode = branding.mode ?? 'system';
54
+ return {
55
+ ...(mode === 'system' ? {} : { 'data-theme': mode }),
56
+ 'data-density': branding.density ?? 'comfortable',
57
+ style: aliases.map(([target, source]) => `${target}: var(${source});`).join(' '),
58
+ };
59
+ }
@@ -0,0 +1,65 @@
1
+ // Form input → the entity's own schema. The admin never writes a second set of rules: it
2
+ // calls the Standard Schema the entity already validates with, so a value the admin accepts
3
+ // is a value the action would accept.
4
+
5
+ export interface ValidationIssue {
6
+ readonly path: string;
7
+ readonly message: string;
8
+ }
9
+
10
+ interface StandardResult {
11
+ readonly value?: unknown;
12
+ readonly issues?: readonly {
13
+ readonly message: string;
14
+ readonly path?: readonly (string | number | { readonly key: string | number })[];
15
+ }[];
16
+ }
17
+
18
+ interface StandardSchema {
19
+ readonly '~standard': {
20
+ validate(value: unknown): StandardResult | Promise<StandardResult>;
21
+ };
22
+ }
23
+
24
+ const isStandardSchema = (schema: unknown): schema is StandardSchema =>
25
+ typeof schema === 'object' &&
26
+ schema !== null &&
27
+ '~standard' in schema &&
28
+ typeof (schema as StandardSchema)['~standard']?.validate === 'function';
29
+
30
+ const pathOf = (
31
+ path: readonly (string | number | { readonly key: string | number })[] | undefined,
32
+ ): string =>
33
+ (path ?? [])
34
+ .map((part) => (typeof part === 'object' ? String(part.key) : String(part)))
35
+ .join('.');
36
+
37
+ export type ValidationResult =
38
+ | { readonly ok: true; readonly value: Readonly<Record<string, unknown>> }
39
+ | { readonly ok: false; readonly issues: readonly ValidationIssue[] };
40
+
41
+ /**
42
+ * An entity with no schema is accepted as-is: `x verify` fails on an entity without one, so
43
+ * a missing schema here means a hand-written test fixture, not a production hole.
44
+ */
45
+ export async function validateInput(
46
+ schema: unknown,
47
+ input: Readonly<Record<string, unknown>>,
48
+ ): Promise<ValidationResult> {
49
+ if (!isStandardSchema(schema)) return { ok: true, value: input };
50
+
51
+ const result = await schema['~standard'].validate(input);
52
+ if (result.issues !== undefined && result.issues.length > 0) {
53
+ return {
54
+ ok: false,
55
+ issues: result.issues.map((issue) => ({
56
+ path: pathOf(issue.path),
57
+ message: issue.message,
58
+ })),
59
+ };
60
+ }
61
+ const value = result.value;
62
+ return typeof value === 'object' && value !== null
63
+ ? { ok: true, value: value as Readonly<Record<string, unknown>> }
64
+ : { ok: true, value: input };
65
+ }
@@ -0,0 +1,217 @@
1
+ // Row value → widget props. This is where the two money/time axioms are actually enforced:
2
+ // a money value that arrived as a float and a timestamp with no IANA zone both fail here,
3
+ // loudly, instead of rendering a number that is wrong for somebody. Views render the props
4
+ // this returns and make no decisions of their own.
5
+
6
+ import type { Money } from '@ultimat3/money';
7
+ import { AdminFieldUnsupportedError } from './errors';
8
+ import type { AdminField } from './fields';
9
+
10
+ export interface WidgetContext {
11
+ /** IANA zone. Required — there is no "server local time" in Ultimate. */
12
+ readonly timeZone: string;
13
+ /** BCP-47, for the number/date/money formatters the widgets call. */
14
+ readonly locale: string;
15
+ }
16
+
17
+ export interface SelectOption {
18
+ readonly value: string;
19
+ readonly labelKey: string;
20
+ }
21
+
22
+ export type WidgetProps =
23
+ | { readonly widget: 'text-input'; readonly field: string; readonly value: string }
24
+ | { readonly widget: 'textarea'; readonly field: string; readonly value: string }
25
+ | { readonly widget: 'number-input'; readonly field: string; readonly value: number | null }
26
+ | { readonly widget: 'money'; readonly field: string; readonly value: Money | null }
27
+ | { readonly widget: 'checkbox'; readonly field: string; readonly value: boolean }
28
+ | {
29
+ readonly widget: 'select';
30
+ readonly field: string;
31
+ readonly value: string | null;
32
+ readonly options: readonly SelectOption[];
33
+ }
34
+ | {
35
+ readonly widget: 'datetime';
36
+ readonly field: string;
37
+ /** Always UTC ISO-8601. The widget formats it into `timeZone`. */
38
+ readonly value: string | null;
39
+ readonly timeZone: string;
40
+ readonly precision: 'date' | 'instant';
41
+ }
42
+ | { readonly widget: 'timezone-picker'; readonly field: string; readonly value: string | null }
43
+ | { readonly widget: 'locale-picker'; readonly field: string; readonly value: string | null }
44
+ | { readonly widget: 'json-editor'; readonly field: string; readonly value: string }
45
+ | {
46
+ readonly widget: 'reference';
47
+ readonly field: string;
48
+ readonly value: string | null;
49
+ readonly entity: string;
50
+ readonly labelField: string;
51
+ }
52
+ | {
53
+ readonly widget: 'upload';
54
+ readonly field: string;
55
+ readonly value: { readonly url: string; readonly name: string } | null;
56
+ };
57
+
58
+ const fail = (field: AdminField, cause: string, fix: string): never => {
59
+ throw new AdminFieldUnsupportedError({ entity: field.entity, field: field.name, cause, fix });
60
+ };
61
+
62
+ const CURRENCY = /^[A-Z]{3}$/;
63
+
64
+ /**
65
+ * `money()` puts a `bigint` on the row — Postgres `bigint` minor units — where `Money` is a
66
+ * number. This is the one place that widening happens, and it refuses rather than round: a
67
+ * value past the safe integer range would render as a different amount than it is.
68
+ */
69
+ const minorUnits = (value: unknown): number | null => {
70
+ if (typeof value === 'bigint') {
71
+ const widened = Number(value);
72
+ return Number.isSafeInteger(widened) ? widened : null;
73
+ }
74
+ return typeof value === 'number' && Number.isInteger(value) ? value : null;
75
+ };
76
+
77
+ /** `Money = { minor: number; currency: string }` or nothing. A float is a bug, not a value. */
78
+ export function assertMoney(field: AdminField, value: unknown): Money | null {
79
+ if (value === null || value === undefined) return null;
80
+ if (typeof value === 'number') {
81
+ return fail(
82
+ field,
83
+ `money value arrived as the number ${value}; money is integer minor units + an ISO currency`,
84
+ `store ${field.name} as { minor, currency } — x g migration "money ${field.entity}.${field.name}"`,
85
+ );
86
+ }
87
+ if (typeof value !== 'object') {
88
+ return fail(
89
+ field,
90
+ `money value is a ${typeof value}`,
91
+ 'return { minor, currency } from the repo',
92
+ );
93
+ }
94
+ const bag = value as { minor?: unknown; currency?: unknown };
95
+ const minor = minorUnits(bag.minor);
96
+ if (minor === null) {
97
+ return fail(
98
+ field,
99
+ `money.minor is ${String(bag.minor)}; minor units are integers`,
100
+ 'multiply by the currency exponent before storing, never round at render time',
101
+ );
102
+ }
103
+ const currency = typeof bag.currency === 'string' ? bag.currency : field.currency;
104
+ if (currency === undefined || !CURRENCY.test(currency)) {
105
+ return fail(
106
+ field,
107
+ `money has no ISO-4217 currency (got ${String(bag.currency)})`,
108
+ `declare the currency with fields: { ${field.name}: { currency: 'EUR' } }`,
109
+ );
110
+ }
111
+ return { minor, currency };
112
+ }
113
+
114
+ /** No zone, no render. A timestamp shown in an implicit zone is a wrong timestamp. */
115
+ export function assertZone(field: AdminField, timeZone: string | undefined): string {
116
+ if (timeZone === undefined || timeZone.trim() === '') {
117
+ return fail(
118
+ field,
119
+ 'timestamp has no IANA time zone; the admin never formats a date in an implicit zone',
120
+ "pass ctx.timeZone (actor.timeZone ?? 'UTC') into the admin view",
121
+ );
122
+ }
123
+ return timeZone;
124
+ }
125
+
126
+ function isoOf(field: AdminField, value: unknown): string | null {
127
+ if (value === null || value === undefined) return null;
128
+ if (value instanceof Date) return value.toISOString();
129
+ if (typeof value === 'string') return value;
130
+ if (typeof value === 'number') return new Date(value).toISOString();
131
+ return fail(field, `timestamp value is a ${typeof value}`, 'return an ISO string or a Date');
132
+ }
133
+
134
+ const optionsFor = (field: AdminField): readonly SelectOption[] =>
135
+ (field.values ?? []).map((value) => ({
136
+ value,
137
+ labelKey: `admin.${field.entity}.field.${field.name}.option.${value}`,
138
+ }));
139
+
140
+ const asText = (value: unknown): string =>
141
+ value === null || value === undefined ? '' : String(value);
142
+
143
+ /** The single dispatch from a field + a raw row value to renderable props. */
144
+ export function widgetProps(field: AdminField, value: unknown, ctx: WidgetContext): WidgetProps {
145
+ switch (field.widget) {
146
+ case 'text-input':
147
+ return { widget: 'text-input', field: field.name, value: asText(value) };
148
+ case 'textarea':
149
+ return { widget: 'textarea', field: field.name, value: asText(value) };
150
+ case 'number-input':
151
+ return {
152
+ widget: 'number-input',
153
+ field: field.name,
154
+ value: typeof value === 'number' ? value : null,
155
+ };
156
+ case 'money':
157
+ return { widget: 'money', field: field.name, value: assertMoney(field, value) };
158
+ case 'checkbox':
159
+ return { widget: 'checkbox', field: field.name, value: value === true };
160
+ case 'select':
161
+ return {
162
+ widget: 'select',
163
+ field: field.name,
164
+ value: value === null || value === undefined ? null : String(value),
165
+ options: optionsFor(field),
166
+ };
167
+ case 'datetime':
168
+ return {
169
+ widget: 'datetime',
170
+ field: field.name,
171
+ value: isoOf(field, value),
172
+ timeZone: assertZone(field, ctx.timeZone),
173
+ precision: field.type === 'date' ? 'date' : 'instant',
174
+ };
175
+ case 'timezone-picker':
176
+ return {
177
+ widget: 'timezone-picker',
178
+ field: field.name,
179
+ value: typeof value === 'string' ? value : null,
180
+ };
181
+ case 'locale-picker':
182
+ return {
183
+ widget: 'locale-picker',
184
+ field: field.name,
185
+ value: typeof value === 'string' ? value : null,
186
+ };
187
+ case 'json-editor':
188
+ return {
189
+ widget: 'json-editor',
190
+ field: field.name,
191
+ value: value === undefined ? '' : JSON.stringify(value, null, 2),
192
+ };
193
+ case 'reference':
194
+ return {
195
+ widget: 'reference',
196
+ field: field.name,
197
+ value: value === null || value === undefined ? null : String(value),
198
+ entity: field.relation?.entity ?? field.name,
199
+ labelField: field.relation?.labelField ?? 'id',
200
+ };
201
+ case 'upload':
202
+ return { widget: 'upload', field: field.name, value: uploadValue(field, value) };
203
+ }
204
+ }
205
+
206
+ function uploadValue(
207
+ field: AdminField,
208
+ value: unknown,
209
+ ): { readonly url: string; readonly name: string } | null {
210
+ if (value === null || value === undefined) return null;
211
+ if (typeof value === 'string') return { url: value, name: value.split('/').pop() ?? field.name };
212
+ const bag = value as { url?: unknown; name?: unknown };
213
+ if (typeof bag.url !== 'string') {
214
+ return fail(field, 'file value has no url', 'return { url, name } from the repo');
215
+ }
216
+ return { url: bag.url, name: typeof bag.name === 'string' ? bag.name : field.name };
217
+ }