@kedge/server 0.0.0-stage → 0.10.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.
@@ -0,0 +1,813 @@
1
+ // P0.5's admin surface (p0.5-auth-and-access.md §3, §4, §5.2, §7.2): staff,
2
+ // roles, invites, storefronts, API keys, the audit log, and order reads
3
+ // through the resource registry. Each route declares its permission; the
4
+ // store re-proves every authority rule inside its batch, so the route's check
5
+ // is only the fast refusal.
6
+ import { createRoute, type OpenAPIHono, z } from "@hono/zod-openapi";
7
+ import { actorOf, ENTITY_LIMITS, KedgeError, ORDER, ORDER_LINE, type Resource } from "@kedge/core";
8
+ import type { Context } from "hono";
9
+ import { type AdminPermissions, clientOf, type KedgeEnv } from "./access";
10
+ import { jsonBody, ok } from "./http";
11
+ import type { AdminRequest, AdminStore, KeyringAdmin, OrderFilter, OrderQuery } from "./types";
12
+
13
+ const ID = z.string().min(1).max(64);
14
+
15
+ interface AuditEntry {
16
+ id: string;
17
+ batchId: string;
18
+ actor: string;
19
+ action: string;
20
+ entityType: string | null;
21
+ entityId: string | null;
22
+ changes: unknown;
23
+ ip: string | null;
24
+ userAgent: string | null;
25
+ createdAt: number;
26
+ }
27
+ const Instant = z.iso.datetime({ offset: true }).transform((v) => Date.parse(v));
28
+ const iso = (ms: unknown) => (typeof ms === "number" ? new Date(ms).toISOString() : null);
29
+ const Grant = z.object({
30
+ permission: z.string().min(3).max(128),
31
+ row_scope: z.enum(["all", "self", "location"]).default("all"),
32
+ });
33
+ const Grants = z.array(Grant).max(128);
34
+ const RoleRefs = z.array(z.object({ id: ID, version: z.number().int().min(1) })).max(16);
35
+ const Origins = z.array(z.url().max(255)).max(16);
36
+ const grants = (g: z.infer<typeof Grants>) => g.map((x) => ({ permission: x.permission, rowScope: x.row_scope }));
37
+
38
+ /** Instants the order API has always given as ISO strings (the cart's and P0.3's order view). */
39
+ const ISO_FIELDS = new Set(["period_start", "period_end", "place_by"]);
40
+
41
+ /** A registry row in the API's conventions: money as `{ amount, currency_code }`, never a bare integer. */
42
+ function moneyFields(resource: Resource, row: Record<string, unknown>, currency: unknown): Record<string, unknown> {
43
+ const out: Record<string, unknown> = {};
44
+ for (const [k, v] of Object.entries(row)) {
45
+ const f = resource.fields[k];
46
+ if (f?.class === "financial" && typeof v === "number") out[k] = { amount: v, currency_code: currency };
47
+ else if (ISO_FIELDS.has(k)) out[k] = iso(v);
48
+ else out[k] = v;
49
+ }
50
+ return out;
51
+ }
52
+
53
+ export function orderRecordJson(row: Record<string, unknown>): Record<string, unknown> {
54
+ const currency = row.currency_code;
55
+ const out = moneyFields(ORDER, row, currency);
56
+ if (Array.isArray(row.lines))
57
+ out.lines = (row.lines as Record<string, unknown>[]).map((l) => moneyFields(ORDER_LINE, l, currency));
58
+ return out;
59
+ }
60
+
61
+ /**
62
+ * An issued invite, with the admin's link to it (p0.9 §3.5) on the origin the
63
+ * request came to, which is always an admin origin (§4.1). The token rides in
64
+ * the fragment, so opening the link puts it in no server log or Referer.
65
+ */
66
+ const issuedInvite = (c: Context, i: { id: string; token: string; expiresAt: number }) => ({
67
+ id: i.id,
68
+ token: i.token,
69
+ expires_at: iso(i.expiresAt),
70
+ invite_url: `${new URL(c.req.url).origin}/app/invite#${i.token}`,
71
+ });
72
+ const issuedKey = (k: { id: string; key: string; tokenPrefix: string }) => ({
73
+ id: k.id,
74
+ key: k.key,
75
+ token_prefix: k.tokenPrefix,
76
+ });
77
+
78
+ export function adminRoutes(
79
+ app: OpenAPIHono<KedgeEnv>,
80
+ perms: AdminPermissions,
81
+ admin: AdminStore | undefined,
82
+ orderQuery: OrderQuery | undefined,
83
+ keyring: KeyringAdmin | undefined,
84
+ now: () => number,
85
+ ): void {
86
+ const ctx = (c: Context<KedgeEnv>) => c.var.access.ctx;
87
+ const req = (c: Context<KedgeEnv>): AdminRequest => {
88
+ const client = clientOf(c);
89
+ return { idempotencyKey: c.req.header("idempotency-key") ?? null, ip: client.ip, userAgent: client.userAgent };
90
+ };
91
+ /**
92
+ * A creating command requires its key (docs/06, p0.5 §8): a key, an invite
93
+ * or a role made twice by a retry whose response was lost would leave a live
94
+ * credential nobody holds, or exhaust a storefront's four keys.
95
+ */
96
+ const keyed = (c: Context<KedgeEnv>): AdminRequest => {
97
+ const r = req(c);
98
+ const k = r.idempotencyKey;
99
+ if (!k || new TextEncoder().encode(k).length > 255)
100
+ throw new KedgeError("BAD_REQUEST", "An Idempotency-Key header (1 to 255 bytes) is required", {
101
+ requirement: "idempotency_key",
102
+ });
103
+ return r;
104
+ };
105
+ /** Credentials leave once, and no cache may keep them. */
106
+ const once = (c: Context) => c.header("cache-control", "no-store");
107
+
108
+ if (orderQuery) {
109
+ app.openapi(
110
+ perms.declare(
111
+ { permission: "order:read", scoped: true },
112
+ createRoute({
113
+ method: "get",
114
+ path: "/admin/orders/{id}",
115
+ request: { params: z.object({ id: z.string().regex(/^ord_[0-9A-HJKMNP-TV-Z]{26}$/) }) },
116
+ responses: ok("An order, scoped and projected for the caller"),
117
+ }),
118
+ ),
119
+ async (c) => {
120
+ // Outside the caller's scope is NOT_FOUND, never FORBIDDEN, so ids can't be probed (§6.2).
121
+ const row = await orderQuery.get(ctx(c), c.req.valid("param").id);
122
+ if (!row) throw new KedgeError("NOT_FOUND", "Order not found");
123
+ return c.json(orderRecordJson(row), 200);
124
+ },
125
+ );
126
+ app.openapi(
127
+ perms.declare(
128
+ { permission: "order:read", scoped: true },
129
+ createRoute({
130
+ method: "get",
131
+ path: "/admin/orders",
132
+ request: {
133
+ query: z.object({
134
+ status: z.string().max(200).optional(),
135
+ payment_status: z.string().max(200).optional(),
136
+ location: ID.optional(),
137
+ created_from: Instant.optional(),
138
+ created_to: Instant.optional(),
139
+ placed_from: Instant.optional(),
140
+ placed_to: Instant.optional(),
141
+ sort: z.enum(["created_at", "-created_at", "total", "-total"]).optional(),
142
+ expand: z.literal("lines").optional(),
143
+ cursor: z.string().max(2048).optional(),
144
+ limit: z.coerce.number().int().min(1).max(100).optional(),
145
+ }),
146
+ },
147
+ responses: ok("A page of orders the caller may see"),
148
+ }),
149
+ ),
150
+ async (c) => {
151
+ const q = c.req.valid("query");
152
+ const filters: OrderFilter[] = [];
153
+ const set = (field: string, v: string | undefined) => {
154
+ if (v === undefined) return;
155
+ const values = v.split(",").filter(Boolean);
156
+ filters.push(values.length > 1 ? { field, op: "in", value: values } : { field, op: "eq", value: v });
157
+ };
158
+ set("status", q.status);
159
+ set("payment_status", q.payment_status);
160
+ if (q.location) filters.push({ field: "location", op: "eq", value: q.location });
161
+ if (q.created_from !== undefined) filters.push({ field: "created_at", op: "gte", value: q.created_from });
162
+ if (q.created_to !== undefined) filters.push({ field: "created_at", op: "lt", value: q.created_to });
163
+ if (q.placed_from !== undefined) filters.push({ field: "placed_at", op: "gte", value: q.placed_from });
164
+ if (q.placed_to !== undefined) filters.push({ field: "placed_at", op: "lt", value: q.placed_to });
165
+ const page = await orderQuery.list(ctx(c), {
166
+ filters,
167
+ ...(q.sort
168
+ ? {
169
+ sort: {
170
+ field: q.sort.replace(/^-/, ""),
171
+ direction: q.sort.startsWith("-") ? ("desc" as const) : ("asc" as const),
172
+ },
173
+ }
174
+ : {}),
175
+ ...(q.expand ? { expand: ["lines"] } : {}),
176
+ cursor: q.cursor ?? null,
177
+ ...(q.limit !== undefined ? { limit: q.limit } : {}),
178
+ });
179
+ return c.json({ ...page, data: page.data.map(orderRecordJson) }, 200);
180
+ },
181
+ );
182
+
183
+ // Totals and emails are classified values, which never go in a URL (§5.2).
184
+ const Filter = z.object({
185
+ field: z.string().max(64),
186
+ op: z.enum(["eq", "in", "gte", "lt"]),
187
+ value: z.union([z.string().max(320), z.number(), z.array(z.union([z.string().max(320), z.number()])).max(50)]),
188
+ });
189
+ app.openapi(
190
+ perms.declare(
191
+ { permission: "order:read", read: true, scoped: true },
192
+ createRoute({
193
+ method: "post",
194
+ path: "/admin/orders/search",
195
+ request: jsonBody(
196
+ z.object({
197
+ filters: z.array(Filter).max(20).optional(),
198
+ sort: z.object({ field: z.string().max(64), direction: z.enum(["asc", "desc"]) }).optional(),
199
+ search: z
200
+ .object({
201
+ fields: z.array(z.string().max(64)).min(1).max(ENTITY_LIMITS.SEARCH_FIELDS_MAX),
202
+ term: z.string().min(1).max(320),
203
+ })
204
+ .optional(),
205
+ expand: z.array(z.literal("lines")).max(1).optional(),
206
+ cursor: z.string().max(2048).optional(),
207
+ limit: z.number().int().min(1).max(100).optional(),
208
+ }),
209
+ ),
210
+ responses: ok("A page of matching orders"),
211
+ }),
212
+ ),
213
+ async (c) => {
214
+ const b = c.req.valid("json");
215
+ const page = await orderQuery.search(ctx(c), {
216
+ ...(b.filters ? { filters: b.filters } : {}),
217
+ ...(b.sort ? { sort: b.sort } : {}),
218
+ ...(b.search ? { search: b.search } : {}),
219
+ ...(b.expand ? { expand: b.expand } : {}),
220
+ cursor: b.cursor ?? null,
221
+ ...(b.limit !== undefined ? { limit: b.limit } : {}),
222
+ });
223
+ return c.json({ ...page, data: page.data.map(orderRecordJson) }, 200);
224
+ },
225
+ );
226
+ }
227
+
228
+ if (keyring) {
229
+ // The keyring steps keep their own envelope, ordered around their checks (codex code r14).
230
+ const retryOf = (c: Context<KedgeEnv>) => ({
231
+ principal: actorOf(c.var.access.ctx.principal),
232
+ key: c.req.header("idempotency-key") ?? null,
233
+ });
234
+ // The keyring protocol (§4.1, §9): Owner only, each step audited and asserted in its batch.
235
+ const keyringJson = (k: Awaited<ReturnType<KeyringAdmin["state"]>>[number]) => ({
236
+ family: k.family,
237
+ active: k.active,
238
+ retiring: k.retiring,
239
+ activated_at: iso(k.activatedAt),
240
+ held: k.held,
241
+ });
242
+ const Family = z.object({ family: z.enum(["pepper", "totp", "auth"]) });
243
+ app.openapi(
244
+ perms.declare(
245
+ "owner:manage",
246
+ createRoute({ method: "get", path: "/admin/keyring", responses: ok("Each secret's versions") }),
247
+ ),
248
+ async (c) => c.json({ keyring: (await keyring.state()).map(keyringJson) }, 200),
249
+ );
250
+ app.openapi(
251
+ perms.declare(
252
+ { permission: "owner:manage", own: true },
253
+ createRoute({
254
+ method: "post",
255
+ path: "/admin/keyring/{family}/activate",
256
+ request: { params: Family, ...jsonBody(z.object({ version: z.number().int().min(2) })) },
257
+ responses: ok("The new version is active; the old one is retiring"),
258
+ }),
259
+ ),
260
+ async (c) =>
261
+ c.json(
262
+ keyringJson(
263
+ await keyring.activate(c.req.valid("param").family, c.req.valid("json").version, now(), retryOf(c)),
264
+ ),
265
+ 200,
266
+ ),
267
+ );
268
+ for (const step of ["complete", "rollback"] as const)
269
+ app.openapi(
270
+ perms.declare(
271
+ { permission: "owner:manage", own: true },
272
+ createRoute({
273
+ method: "post",
274
+ path: `/admin/keyring/{family}/${step}`,
275
+ request: { params: Family },
276
+ responses: ok(step === "complete" ? "The retiring version is retired" : "Back to the retiring version"),
277
+ }),
278
+ ),
279
+ async (c) => c.json(keyringJson(await keyring[step](c.req.valid("param").family, now(), retryOf(c))), 200),
280
+ );
281
+ }
282
+
283
+ if (!admin) return;
284
+ const StaffParam = z.object({ id: ID });
285
+
286
+ // ---- staff and invites --------------------------------------------------------------
287
+
288
+ app.openapi(
289
+ perms.declare("staff:read", createRoute({ method: "get", path: "/admin/staff", responses: ok("Staff") })),
290
+ async (c) => c.json({ staff: await admin.staffList(ctx(c)) }, 200),
291
+ );
292
+ app.openapi(
293
+ perms.declare(
294
+ { permission: "staff:write", own: true },
295
+ createRoute({
296
+ method: "post",
297
+ path: "/admin/staff/invites",
298
+ request: jsonBody(z.object({ email: z.email().max(320), roles: RoleRefs.min(1) })),
299
+ responses: ok("The invite and its one-time token"),
300
+ }),
301
+ ),
302
+ async (c) => {
303
+ once(c);
304
+ const b = c.req.valid("json");
305
+ return c.json(
306
+ issuedInvite(c, await admin.createInvite(ctx(c), { email: b.email, roleIds: b.roles }, keyed(c))),
307
+ 200,
308
+ );
309
+ },
310
+ );
311
+ app.openapi(
312
+ perms.declare(
313
+ { permission: "staff:write", own: true },
314
+ createRoute({
315
+ method: "put",
316
+ path: "/admin/staff/{id}/roles",
317
+ request: { params: StaffParam, ...jsonBody(z.object({ roles: RoleRefs })) },
318
+ responses: ok("The member's roles"),
319
+ }),
320
+ ),
321
+ async (c) => {
322
+ const { id } = c.req.valid("param");
323
+ const { roles } = c.req.valid("json");
324
+ await admin.assignRoles(ctx(c), id, { roleIds: roles }, req(c));
325
+ return c.json({ id, role_ids: roles.map((r) => r.id) }, 200);
326
+ },
327
+ );
328
+ app.openapi(
329
+ perms.declare(
330
+ { permission: "staff:write", own: true },
331
+ createRoute({
332
+ method: "put",
333
+ path: "/admin/staff/{id}/locations",
334
+ request: { params: StaffParam, ...jsonBody(z.object({ location_ids: z.array(ID).max(64) })) },
335
+ responses: ok("The member's locations"),
336
+ }),
337
+ ),
338
+ async (c) => {
339
+ const { id } = c.req.valid("param");
340
+ const { location_ids } = c.req.valid("json");
341
+ await admin.setLocations(ctx(c), { staffId: id }, location_ids, req(c));
342
+ return c.json({ id, location_ids }, 200);
343
+ },
344
+ );
345
+ app.openapi(
346
+ perms.declare(
347
+ { permission: "staff:write", own: true },
348
+ createRoute({
349
+ method: "post",
350
+ path: "/admin/staff/{id}/deactivate",
351
+ request: { params: StaffParam },
352
+ responses: ok("Deactivated; every session ended"),
353
+ }),
354
+ ),
355
+ async (c) => {
356
+ const { id } = c.req.valid("param");
357
+ await admin.deactivateStaff(ctx(c), id, req(c));
358
+ return c.json({ id, status: "deactivated" }, 200);
359
+ },
360
+ );
361
+ // The one staff reset in this slice: an owner's reenrol invite (D-053, §3.4).
362
+ app.openapi(
363
+ perms.declare(
364
+ { permission: "staff:write", own: true },
365
+ createRoute({
366
+ method: "post",
367
+ path: "/admin/staff/{id}/reset",
368
+ request: { params: StaffParam },
369
+ responses: ok("A reenrol invite and its one-time token"),
370
+ }),
371
+ ),
372
+ async (c) => {
373
+ once(c);
374
+ return c.json(issuedInvite(c, await admin.resetFactors(ctx(c), c.req.valid("param").id, keyed(c))), 200);
375
+ },
376
+ );
377
+
378
+ // ---- roles ----------------------------------------------------------------------------
379
+
380
+ app.openapi(
381
+ perms.declare("role:read", createRoute({ method: "get", path: "/admin/roles", responses: ok("Roles") })),
382
+ async (c) => c.json({ roles: await admin.rolesList(ctx(c)) }, 200),
383
+ );
384
+ app.openapi(
385
+ perms.declare(
386
+ { permission: "role:write", own: true },
387
+ createRoute({
388
+ method: "post",
389
+ path: "/admin/roles",
390
+ request: jsonBody(
391
+ z.object({
392
+ code: z.string().regex(/^[a-z][a-z0-9_]{1,39}$/),
393
+ name: z.string().min(1).max(80),
394
+ grants: Grants,
395
+ strong_auth: z.boolean().optional(),
396
+ }),
397
+ ),
398
+ responses: ok("The new role"),
399
+ }),
400
+ ),
401
+ async (c) => {
402
+ const b = c.req.valid("json");
403
+ return c.json(
404
+ await admin.createRole(
405
+ ctx(c),
406
+ { code: b.code, name: b.name, grants: grants(b.grants), strongAuth: b.strong_auth === true },
407
+ keyed(c),
408
+ ),
409
+ 200,
410
+ );
411
+ },
412
+ );
413
+ app.openapi(
414
+ perms.declare(
415
+ { permission: "role:write", own: true },
416
+ createRoute({
417
+ method: "put",
418
+ path: "/admin/roles/{id}/grants",
419
+ request: {
420
+ params: z.object({ id: ID }),
421
+ ...jsonBody(z.object({ expected_version: z.number().int().min(1), grants: Grants })),
422
+ },
423
+ responses: ok("The role's new version"),
424
+ }),
425
+ ),
426
+ async (c) => {
427
+ const b = c.req.valid("json");
428
+ return c.json(
429
+ await admin.setRoleGrants(
430
+ ctx(c),
431
+ c.req.valid("param").id,
432
+ { expectedVersion: b.expected_version, grants: grants(b.grants) },
433
+ req(c),
434
+ ),
435
+ 200,
436
+ );
437
+ },
438
+ );
439
+ app.openapi(
440
+ perms.declare(
441
+ { permission: "role:write", own: true },
442
+ createRoute({
443
+ method: "delete",
444
+ path: "/admin/roles/{id}",
445
+ request: { params: z.object({ id: ID }) },
446
+ responses: ok("Deleted"),
447
+ }),
448
+ ),
449
+ async (c) => {
450
+ await admin.deleteRole(ctx(c), c.req.valid("param").id, req(c));
451
+ return c.json({ id: c.req.valid("param").id, deleted: true }, 200);
452
+ },
453
+ );
454
+
455
+ // ---- storefronts and their publishable keys ------------------------------------------
456
+
457
+ app.openapi(
458
+ perms.declare(
459
+ "storefront:read",
460
+ createRoute({ method: "get", path: "/admin/storefronts", responses: ok("Storefronts") }),
461
+ ),
462
+ async (c) => c.json({ storefronts: await admin.storefrontsList(ctx(c)) }, 200),
463
+ );
464
+ app.openapi(
465
+ perms.declare(
466
+ { permission: "storefront:write", own: true },
467
+ createRoute({
468
+ method: "post",
469
+ path: "/admin/storefronts",
470
+ request: jsonBody(z.object({ name: z.string().min(1).max(80), allowed_origins: Origins })),
471
+ responses: ok("The new storefront"),
472
+ }),
473
+ ),
474
+ async (c) => {
475
+ const b = c.req.valid("json");
476
+ return c.json(
477
+ await admin.createStorefront(ctx(c), { name: b.name, allowedOrigins: b.allowed_origins }, keyed(c)),
478
+ 200,
479
+ );
480
+ },
481
+ );
482
+ app.openapi(
483
+ perms.declare(
484
+ { permission: "storefront:write", own: true },
485
+ createRoute({
486
+ method: "put",
487
+ path: "/admin/storefronts/{id}/origins",
488
+ request: {
489
+ params: z.object({ id: ID }),
490
+ // `expected`: the list as the caller read it; the write is refused CONFLICT if it changed since.
491
+ ...jsonBody(z.object({ allowed_origins: Origins, expected: Origins.optional() })),
492
+ },
493
+ responses: ok("The storefront's origins"),
494
+ }),
495
+ ),
496
+ async (c) => {
497
+ const { id } = c.req.valid("param");
498
+ const { allowed_origins, expected } = c.req.valid("json");
499
+ await admin.setOrigins(ctx(c), id, allowed_origins, req(c), expected);
500
+ return c.json({ id, allowed_origins }, 200);
501
+ },
502
+ );
503
+ app.openapi(
504
+ perms.declare(
505
+ { permission: "storefront:write", own: true },
506
+ createRoute({
507
+ method: "post",
508
+ path: "/admin/storefronts/{id}/keys",
509
+ request: { params: z.object({ id: ID }), ...jsonBody(z.object({ name: z.string().min(1).max(80) })) },
510
+ responses: ok("A publishable key, shown once"),
511
+ }),
512
+ ),
513
+ async (c) => {
514
+ once(c);
515
+ const key = await admin.createPublishableKey(
516
+ ctx(c),
517
+ { name: c.req.valid("json").name, storefrontId: c.req.valid("param").id },
518
+ keyed(c),
519
+ );
520
+ return c.json(issuedKey(key), 200);
521
+ },
522
+ );
523
+
524
+ // A publishable key is managed under storefront:write, as it was created (codex code r7);
525
+ // the store re-proves that and the key's managers inside its batch.
526
+ const StorefrontKey = z.object({ id: ID, key_id: ID });
527
+ app.openapi(
528
+ perms.declare(
529
+ { permission: "storefront:write", own: true },
530
+ createRoute({
531
+ method: "post",
532
+ path: "/admin/storefronts/{id}/keys/{key_id}/revoke",
533
+ request: { params: StorefrontKey, ...jsonBody(z.object({ at: Instant.optional() })) },
534
+ responses: ok("Revoked, now or at the given time"),
535
+ }),
536
+ ),
537
+ async (c) => {
538
+ const r = await admin.revokeKey(
539
+ ctx(c),
540
+ c.req.valid("param").key_id,
541
+ c.req.valid("json").at,
542
+ req(c),
543
+ c.req.valid("param").id,
544
+ );
545
+ return c.json({ id: r.id, revoked_at: iso(r.revokedAt) }, 200);
546
+ },
547
+ );
548
+ app.openapi(
549
+ perms.declare(
550
+ { permission: "storefront:write", own: true },
551
+ createRoute({
552
+ method: "post",
553
+ path: "/admin/storefronts/{id}/keys/{key_id}/rotate",
554
+ request: {
555
+ params: StorefrontKey,
556
+ ...jsonBody(
557
+ z.object({
558
+ overlap_seconds: z
559
+ .number()
560
+ .int()
561
+ .min(0)
562
+ .max(7 * 86_400),
563
+ }),
564
+ ),
565
+ },
566
+ responses: ok("The new key, shown once, and the old key's deadline"),
567
+ }),
568
+ ),
569
+ async (c) => {
570
+ once(c);
571
+ const r = await admin.rotateKey(
572
+ ctx(c),
573
+ c.req.valid("param").key_id,
574
+ { overlapMs: c.req.valid("json").overlap_seconds * 1000 },
575
+ keyed(c),
576
+ c.req.valid("param").id,
577
+ );
578
+ return c.json({ ...issuedKey(r), previous: { id: r.previous.id, revoked_at: iso(r.previous.revokedAt) } }, 200);
579
+ },
580
+ );
581
+
582
+ // ---- credential references (p0.10 §6.2) ----------------------------------------------
583
+ // The permission depends on the ref's kind (api_key:write for a secret key,
584
+ // storefront:write otherwise), so the store authorizes inside its own batch.
585
+
586
+ const Ref = z.object({ ref: z.string().regex(/^[A-Za-z0-9_-]{32,128}$/) });
587
+ const RefKind = z.enum(["secret", "publishable", "storefront"]);
588
+ const refJson = (r: {
589
+ ref: string;
590
+ kind: string;
591
+ state: string;
592
+ storefrontId: string | null;
593
+ keyId: string | null;
594
+ }) => ({ ref: r.ref, kind: r.kind, state: r.state, storefront_id: r.storefrontId, key_id: r.keyId });
595
+ app.openapi(
596
+ perms.declare(
597
+ { permission: "api_key:write", store: true },
598
+ createRoute({
599
+ method: "put",
600
+ path: "/admin/credential-refs/{ref}",
601
+ request: {
602
+ params: Ref,
603
+ ...jsonBody(
604
+ z.discriminatedUnion("kind", [
605
+ z.object({
606
+ kind: z.literal("secret"),
607
+ name: z.string().min(1).max(80),
608
+ grants: Grants.min(1),
609
+ location_ids: z.array(ID).max(64).optional(),
610
+ }),
611
+ z.object({ kind: z.literal("publishable"), name: z.string().min(1).max(80), storefront_id: ID }),
612
+ z.object({ kind: z.literal("storefront"), name: z.string().min(1).max(80), allowed_origins: Origins }),
613
+ ]),
614
+ ),
615
+ },
616
+ responses: ok("The key the ref made, shown once; CREDENTIAL_REF_TAKEN if it was used or voided"),
617
+ }),
618
+ ),
619
+ async (c) => {
620
+ once(c);
621
+ const b = c.req.valid("json");
622
+ const made = await admin.createByRef(
623
+ ctx(c),
624
+ c.req.valid("param").ref,
625
+ b.kind === "secret"
626
+ ? {
627
+ kind: "secret",
628
+ name: b.name,
629
+ grants: grants(b.grants),
630
+ ...(b.location_ids ? { locationIds: b.location_ids } : {}),
631
+ }
632
+ : b.kind === "publishable"
633
+ ? { kind: "publishable", name: b.name, storefrontId: b.storefront_id }
634
+ : { kind: "storefront", name: b.name, allowedOrigins: b.allowed_origins },
635
+ req(c),
636
+ );
637
+ return c.json({ ...refJson(made), key: made.key, token_prefix: made.tokenPrefix }, 200);
638
+ },
639
+ );
640
+ app.openapi(
641
+ perms.declare(
642
+ { permission: "api_key:write", store: true },
643
+ createRoute({
644
+ method: "post",
645
+ path: "/admin/credential-refs/{ref}/void",
646
+ request: { params: Ref, ...jsonBody(z.object({ kind: RefKind })) },
647
+ responses: ok("The ref's outcome, kept for ever: fenced, or revoked with what it made"),
648
+ }),
649
+ ),
650
+ async (c) =>
651
+ c.json(refJson(await admin.voidRef(ctx(c), c.req.valid("param").ref, c.req.valid("json").kind, req(c))), 200),
652
+ );
653
+
654
+ // ---- API keys ----------------------------------------------------------------------
655
+
656
+ const KeyParam = z.object({ id: ID });
657
+ app.openapi(
658
+ perms.declare(
659
+ "api_key:read",
660
+ createRoute({
661
+ method: "get",
662
+ path: "/admin/api-keys",
663
+ request: { query: z.object({ stale_pepper: z.enum(["1"]).optional() }) },
664
+ responses: ok("API keys, by display prefix"),
665
+ }),
666
+ ),
667
+ async (c) =>
668
+ c.json({ keys: await admin.keysList(ctx(c), { stalePepper: c.req.valid("query").stale_pepper === "1" }) }, 200),
669
+ );
670
+ app.openapi(
671
+ perms.declare(
672
+ { permission: "api_key:write", own: true },
673
+ createRoute({
674
+ method: "post",
675
+ path: "/admin/api-keys",
676
+ request: jsonBody(
677
+ z.object({
678
+ name: z.string().min(1).max(80),
679
+ grants: Grants.min(1),
680
+ location_ids: z.array(ID).max(64).optional(),
681
+ }),
682
+ ),
683
+ responses: ok("A secret key, shown once"),
684
+ }),
685
+ ),
686
+ async (c) => {
687
+ once(c);
688
+ const b = c.req.valid("json");
689
+ const key = await admin.createSecretKey(
690
+ ctx(c),
691
+ { name: b.name, grants: grants(b.grants), ...(b.location_ids ? { locationIds: b.location_ids } : {}) },
692
+ keyed(c),
693
+ );
694
+ return c.json(issuedKey(key), 200);
695
+ },
696
+ );
697
+ app.openapi(
698
+ perms.declare(
699
+ { permission: "api_key:write", own: true },
700
+ createRoute({
701
+ method: "put",
702
+ path: "/admin/api-keys/{id}/locations",
703
+ request: { params: KeyParam, ...jsonBody(z.object({ location_ids: z.array(ID).max(64) })) },
704
+ responses: ok("The key's locations"),
705
+ }),
706
+ ),
707
+ async (c) => {
708
+ const { id } = c.req.valid("param");
709
+ const { location_ids } = c.req.valid("json");
710
+ await admin.setLocations(ctx(c), { apiKeyId: id }, location_ids, req(c));
711
+ return c.json({ id, location_ids }, 200);
712
+ },
713
+ );
714
+ app.openapi(
715
+ perms.declare(
716
+ { permission: "api_key:write", own: true },
717
+ createRoute({
718
+ method: "post",
719
+ path: "/admin/api-keys/{id}/revoke",
720
+ request: { params: KeyParam, ...jsonBody(z.object({ at: Instant.optional() })) },
721
+ responses: ok("Revoked, now or at the given time"),
722
+ }),
723
+ ),
724
+ async (c) => {
725
+ const r = await admin.revokeKey(ctx(c), c.req.valid("param").id, c.req.valid("json").at, req(c));
726
+ return c.json({ id: r.id, revoked_at: iso(r.revokedAt) }, 200);
727
+ },
728
+ );
729
+ app.openapi(
730
+ perms.declare(
731
+ { permission: "api_key:write", own: true },
732
+ createRoute({
733
+ method: "post",
734
+ path: "/admin/api-keys/{id}/rotate",
735
+ request: {
736
+ params: KeyParam,
737
+ ...jsonBody(
738
+ z.object({
739
+ overlap_seconds: z
740
+ .number()
741
+ .int()
742
+ .min(0)
743
+ .max(7 * 86_400),
744
+ }),
745
+ ),
746
+ },
747
+ responses: ok("The new key, shown once, and the old key's deadline"),
748
+ }),
749
+ ),
750
+ async (c) => {
751
+ once(c);
752
+ const r = await admin.rotateKey(
753
+ ctx(c),
754
+ c.req.valid("param").id,
755
+ { overlapMs: c.req.valid("json").overlap_seconds * 1000 },
756
+ keyed(c),
757
+ );
758
+ return c.json({ ...issuedKey(r), previous: { id: r.previous.id, revoked_at: iso(r.previous.revokedAt) } }, 200);
759
+ },
760
+ );
761
+
762
+ // ---- the audit log (§7.2) ----------------------------------------------------------------
763
+
764
+ app.openapi(
765
+ perms.declare(
766
+ "audit:read",
767
+ createRoute({
768
+ method: "get",
769
+ path: "/admin/audit-log",
770
+ request: {
771
+ query: z.object({
772
+ entity_type: z.string().max(64).optional(),
773
+ entity_id: ID.optional(),
774
+ actor: z.string().max(128).optional(),
775
+ before: z.string().max(200).optional(),
776
+ limit: z.coerce.number().int().min(1).max(100).optional(),
777
+ }),
778
+ },
779
+ responses: ok("Audit entries, newest first"),
780
+ }),
781
+ ),
782
+ async (c) => {
783
+ const q = c.req.valid("query");
784
+ // The cursor is `<created_at>.<id>`: nothing classified is in it.
785
+ let before: { createdAt: number; id: string } | undefined;
786
+ if (q.before) {
787
+ const m = /^(\d+)\.(.+)$/.exec(q.before);
788
+ if (!m) throw new KedgeError("BAD_REQUEST", "Invalid cursor", { cursor: "invalid" });
789
+ before = { createdAt: Number(m[1]), id: m[2] as string };
790
+ }
791
+ const r = await admin.auditLog(ctx(c), {
792
+ ...(q.entity_type ? { entityType: q.entity_type } : {}),
793
+ ...(q.entity_id ? { entityId: q.entity_id } : {}),
794
+ ...(q.actor ? { actor: q.actor } : {}),
795
+ ...(before ? { before } : {}),
796
+ ...(q.limit !== undefined ? { limit: q.limit } : {}),
797
+ });
798
+ const entries = (r.entries as AuditEntry[]).map((e) => ({
799
+ id: e.id,
800
+ batch_id: e.batchId,
801
+ actor: e.actor,
802
+ action: e.action,
803
+ entity_type: e.entityType,
804
+ entity_id: e.entityId,
805
+ changes: e.changes,
806
+ ip: e.ip,
807
+ user_agent: e.userAgent,
808
+ created_at: iso(e.createdAt),
809
+ }));
810
+ return c.json({ entries, next_cursor: r.next ? `${r.next.createdAt}.${r.next.id}` : null }, 200);
811
+ },
812
+ );
813
+ }