@ultimat3/admin 2.0.0 → 4.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 +7 -2
- package/README.md +6 -3
- package/package.json +16 -16
- package/src/admin.ts +34 -0
- package/src/authz.ts +18 -0
- package/src/crud.ts +31 -9
- package/src/detail.tsx +13 -1
- package/src/dev/data.ts +86 -101
- package/src/dev/facts.ts +24 -1
- package/src/dev/panel-db.ts +18 -2
- package/src/dev/panel-routes.ts +5 -4
- package/src/dev/panel.ts +9 -15
- package/src/errors.ts +24 -0
- package/src/fields.ts +42 -3
- package/src/index.ts +1 -0
- package/src/inert-jsx.ts +187 -0
- package/src/mcp.ts +35 -3
- package/src/policy-bridge.ts +9 -2
- package/src/resource.ts +15 -10
- package/src/search.ts +55 -5
- package/src/widget-value.ts +2 -1
- package/src/widgets.tsx +10 -3
package/src/search.ts
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
// the same `admin:read` + `<entity>:read` pair gates the hit and the detail page it links to.
|
|
4
4
|
|
|
5
5
|
import { expectedQueryLoop } from '@ultimat3/db';
|
|
6
|
+
import { type AuditEntry, deniedDraft } from './audit';
|
|
7
|
+
import { denied } from './authz';
|
|
6
8
|
import type { CrudCtx } from './crud';
|
|
7
|
-
import {
|
|
9
|
+
import { decideOperation } from './crud';
|
|
8
10
|
import type { AdminFilter, AdminRow } from './registry';
|
|
9
11
|
import { rowId } from './registry';
|
|
10
12
|
import { type AdminResource, repoOf } from './resource';
|
|
@@ -29,6 +31,14 @@ export interface AdminSearchResult {
|
|
|
29
31
|
readonly searched: readonly string[];
|
|
30
32
|
/** Resources left out, and why — so an operator is never silently shown a subset. */
|
|
31
33
|
readonly skipped: readonly { readonly entity: string; readonly reason: string }[];
|
|
34
|
+
/**
|
|
35
|
+
* One entry per resource this call decided about: allowed, or refused. Search read rows out of
|
|
36
|
+
* every readable entity and wrote NOTHING, on either path — while `permissions.ts` carries
|
|
37
|
+
* `audited: true` for it, `audit.ts` says denied attempts are logged too, and `CLAUDE.md` says
|
|
38
|
+
* "every admin operation is audited, reads included". Shaped like `adminList`'s: the subject is
|
|
39
|
+
* the table, so there is no `entityId`.
|
|
40
|
+
*/
|
|
41
|
+
readonly audit: readonly AuditEntry[];
|
|
32
42
|
}
|
|
33
43
|
|
|
34
44
|
export interface AdminSearchInput {
|
|
@@ -89,20 +99,45 @@ function labelOf(row: AdminRow, resource: AdminResource): string {
|
|
|
89
99
|
return rowId(row, resource.idField);
|
|
90
100
|
}
|
|
91
101
|
|
|
102
|
+
const SKIPPED_FORBIDDEN = 'admin.search.skipped.forbidden';
|
|
103
|
+
|
|
92
104
|
export async function adminSearch(input: AdminSearchInput): Promise<AdminSearchResult> {
|
|
105
|
+
const { ctx } = input;
|
|
93
106
|
const term = input.term.trim();
|
|
94
107
|
const limit = input.limitPerResource ?? DEFAULT_LIMIT_PER_RESOURCE;
|
|
95
108
|
const searched: string[] = [];
|
|
96
109
|
const skipped: { entity: string; reason: string }[] = [];
|
|
97
110
|
const hits: AdminSearchHit[] = [];
|
|
111
|
+
const audit: AuditEntry[] = [];
|
|
98
112
|
|
|
99
|
-
|
|
113
|
+
// Nothing was decided and nothing was read, so there is nothing to log: an empty term never
|
|
114
|
+
// reaches a repo.
|
|
115
|
+
if (term === '') return { term, hits: [], searched: [], skipped: [], audit: [] };
|
|
100
116
|
|
|
101
117
|
for (const resource of input.resources) {
|
|
102
|
-
|
|
103
|
-
|
|
118
|
+
const offered = resource.operations.includes('search');
|
|
119
|
+
const decision = decideOperation(resource, 'search', ctx);
|
|
120
|
+
if (!offered || !decision.allowed) {
|
|
121
|
+
skipped.push({ entity: resource.name, reason: SKIPPED_FORBIDDEN });
|
|
122
|
+
audit.push(
|
|
123
|
+
await ctx.audit.append(
|
|
124
|
+
deniedDraft({
|
|
125
|
+
requestId: ctx.requestId,
|
|
126
|
+
actor: ctx.actor,
|
|
127
|
+
operation: 'search',
|
|
128
|
+
kind: 'operation',
|
|
129
|
+
entity: resource.name,
|
|
130
|
+
entityId: null,
|
|
131
|
+
// A resource that does not OFFER search was refused by the registry, not by a policy,
|
|
132
|
+
// and the decision object would otherwise read `allow` on a denied entry.
|
|
133
|
+
decision: offered ? decision : denied(decision.permission, SKIPPED_FORBIDDEN),
|
|
134
|
+
}),
|
|
135
|
+
),
|
|
136
|
+
);
|
|
104
137
|
continue;
|
|
105
138
|
}
|
|
139
|
+
// Neither of these is an authorization event: the resource is searchable in principle and has
|
|
140
|
+
// no text index or no repo to ask. They stay in `skipped` and out of the log.
|
|
106
141
|
if (resource.searchFields.length === 0) {
|
|
107
142
|
skipped.push({ entity: resource.name, reason: 'admin.search.skipped.no-text-fields' });
|
|
108
143
|
continue;
|
|
@@ -112,8 +147,23 @@ export async function adminSearch(input: AdminSearchInput): Promise<AdminSearchR
|
|
|
112
147
|
continue;
|
|
113
148
|
}
|
|
114
149
|
searched.push(resource.name);
|
|
150
|
+
// Appended BEFORE the read, so a repo that throws still leaves the record that the rows were
|
|
151
|
+
// asked for — `audit.ts`: if it isn't logged, it didn't happen.
|
|
152
|
+
audit.push(
|
|
153
|
+
await ctx.audit.append({
|
|
154
|
+
requestId: ctx.requestId,
|
|
155
|
+
actor: ctx.actor,
|
|
156
|
+
operation: 'search',
|
|
157
|
+
kind: 'operation',
|
|
158
|
+
entity: resource.name,
|
|
159
|
+
entityId: null,
|
|
160
|
+
permission: decision.permission,
|
|
161
|
+
outcome: 'allowed',
|
|
162
|
+
reason: decision.reason,
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
115
165
|
hits.push(...(await searchResource(resource, term, limit)));
|
|
116
166
|
}
|
|
117
167
|
|
|
118
|
-
return { term, hits, searched, skipped };
|
|
168
|
+
return { term, hits, searched, skipped, audit };
|
|
119
169
|
}
|
package/src/widget-value.ts
CHANGED
|
@@ -92,7 +92,8 @@ export function assertMoney(field: AdminField, value: unknown): Money | null {
|
|
|
92
92
|
return fail(
|
|
93
93
|
field,
|
|
94
94
|
`money value arrived as the number ${value}; money is integer minor units + an ISO currency`,
|
|
95
|
-
`
|
|
95
|
+
// `x db gen` writes migrations; `x g migration` is not a generator and never was.
|
|
96
|
+
`store ${field.name} as { minor, currency } — x db gen "change ${field.entity}.${field.name} to money"`,
|
|
96
97
|
);
|
|
97
98
|
}
|
|
98
99
|
if (typeof value !== 'object') {
|
package/src/widgets.tsx
CHANGED
|
@@ -54,10 +54,17 @@ const wiring = (control: FieldControl | undefined): ControlWiring =>
|
|
|
54
54
|
/**
|
|
55
55
|
* A `date` column has no time of day, and ui's default formatter always renders one. A
|
|
56
56
|
* rendered midnight is wrong in every zone but the one the value was stored in.
|
|
57
|
+
*
|
|
58
|
+
* `'UTC'`, never `options.zone`: a calendar date is zone-INDEPENDENT by construction — the value
|
|
59
|
+
* is `YYYY-MM-DD` and the spec parses it as UTC midnight (`date-time-view.ts` says so where it
|
|
60
|
+
* refuses an offsetless date-TIME). Formatting that instant in the viewer's zone moves it: an
|
|
61
|
+
* `effective_on` of `2026-08-18` read as `Aug 17, 2026` for every operator west of Greenwich,
|
|
62
|
+
* which is the exact bug `date()` exists to prevent — measured under `TZ=America/Los_Angeles`.
|
|
63
|
+
* The instant branch keeps `options.zone`, because an instant genuinely has one.
|
|
57
64
|
*/
|
|
58
|
-
const
|
|
65
|
+
export const formatCalendarDate: DateTimeFormatter = (at, options) =>
|
|
59
66
|
new Intl.DateTimeFormat(options.locale, {
|
|
60
|
-
timeZone:
|
|
67
|
+
timeZone: 'UTC',
|
|
61
68
|
dateStyle: 'medium',
|
|
62
69
|
}).format(at);
|
|
63
70
|
|
|
@@ -81,7 +88,7 @@ function readView(props: WidgetProps, field: AdminField, ctx: WidgetContext): JS
|
|
|
81
88
|
<DateTime
|
|
82
89
|
value={props.value}
|
|
83
90
|
timeZone={props.timeZone}
|
|
84
|
-
format={props.precision === 'date' ?
|
|
91
|
+
format={props.precision === 'date' ? formatCalendarDate : undefined}
|
|
85
92
|
/>
|
|
86
93
|
);
|
|
87
94
|
case 'checkbox':
|