@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/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 { canOperate } from './crud';
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
- if (term === '') return { term, hits: [], searched: [], skipped: [] };
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
- if (!canOperate(resource, 'search', input.ctx)) {
103
- skipped.push({ entity: resource.name, reason: 'admin.search.skipped.forbidden' });
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
  }
@@ -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
- `store ${field.name} as { minor, currency } — x g migration "money ${field.entity}.${field.name}"`,
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 dateOnly: DateTimeFormatter = (at, options) =>
65
+ export const formatCalendarDate: DateTimeFormatter = (at, options) =>
59
66
  new Intl.DateTimeFormat(options.locale, {
60
- timeZone: options.zone,
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' ? dateOnly : undefined}
91
+ format={props.precision === 'date' ? formatCalendarDate : undefined}
85
92
  />
86
93
  );
87
94
  case 'checkbox':