@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,277 @@
1
+ // What the admin is built from (docs/proposals/p0.9-admin-shell.md §4.4, §5):
2
+ // who the caller is and what they may do, and one metadata contract for core
3
+ // resources and plugin entities. Definitions and grants only, never row values.
4
+ import { createRoute, type OpenAPIHono } from "@hono/zod-openapi";
5
+ import {
6
+ type AccessContext,
7
+ type EntityDefinition,
8
+ FRESH_MS,
9
+ holds,
10
+ ORDER,
11
+ ORDER_LINE,
12
+ permissionsOf,
13
+ type Resource,
14
+ } from "@kedge/core";
15
+ import type { Context } from "hono";
16
+ import type { AdminPermissions, KedgeEnv } from "./access";
17
+ import { authzFingerprint } from "./admin-shell";
18
+ import { ok } from "./http";
19
+ import type { AuthDeps, EntityAdmin } from "./types";
20
+
21
+ const iso = (ms: number | null) => (ms === null ? null : new Date(ms).toISOString());
22
+ const STRONG = "password+totp";
23
+
24
+ /** The admin's request codec for a list (§5.1): how it encodes filters, sort and the cursor. */
25
+ type Codec = "entity" | "order" | "audit";
26
+
27
+ export interface AdminMetaDeps {
28
+ auth: AuthDeps;
29
+ entities?: {
30
+ store: EntityAdmin;
31
+ declared: readonly { plugin: string; entity: string; def: EntityDefinition }[];
32
+ };
33
+ /** Plugin tracks whose target is a core resource, in `trackMetadata`'s shape (§5.1). */
34
+ tracksOn?: (ctx: AccessContext, on: "order" | "order_line") => unknown[];
35
+ /** Whether the store has the routes a core resource's endpoints name. */
36
+ orders: boolean;
37
+ auditLog: boolean;
38
+ }
39
+
40
+ const classes = (ctx: AccessContext) =>
41
+ new Set(["public", ...(["pii", "financial"] as const).filter((c) => holds(ctx.grants, `field:${c}`))]);
42
+
43
+ /** A core registration's fields, as the admin shows them to this caller. */
44
+ function coreFields(r: Resource, ctx: AccessContext) {
45
+ const held = classes(ctx);
46
+ return Object.entries(r.fields)
47
+ .filter(([, f]) => f.admin)
48
+ .map(([name, f]) => ({
49
+ name,
50
+ type: f.admin?.type,
51
+ ...(f.admin?.wire ? { wire: f.admin.wire } : {}),
52
+ class: f.class,
53
+ label: f.admin?.label,
54
+ required: false,
55
+ filter: f.filter ?? [],
56
+ sort: Boolean(f.sort),
57
+ search: Boolean(f.search),
58
+ scope: null,
59
+ readable: held.has(f.class),
60
+ writable: false,
61
+ }));
62
+ }
63
+
64
+ /**
65
+ * A create the caller can actually use (§5.1): every required field writable,
66
+ * the row able to fall inside the caller's write scope, and an order to attach
67
+ * to when the entity needs one. A prediction of the create route's own checks,
68
+ * so no form is offered that always rolls back.
69
+ */
70
+ export function createUsable(
71
+ ctx: AccessContext,
72
+ def: EntityDefinition,
73
+ write: string,
74
+ fields: readonly { name: string; required: boolean; writable: boolean; scope: string | null }[],
75
+ ): boolean {
76
+ return createFill(ctx, def, write, fields) !== false;
77
+ }
78
+
79
+ /**
80
+ * Which scope column a scoped writer's form must fill with the caller (§5.1):
81
+ * null for an unscoped writer, the column's scope for a scoped one, false when
82
+ * no create can succeed.
83
+ */
84
+ export function createFill(
85
+ ctx: AccessContext,
86
+ def: EntityDefinition,
87
+ write: string,
88
+ fields: readonly { name: string; required: boolean; writable: boolean; scope: string | null }[],
89
+ ): "assignee" | "location" | null | false {
90
+ const scopes = ctx.grants.get(write);
91
+ if (!scopes || scopes.size === 0) return false;
92
+ if (fields.some((f) => f.required && !f.writable)) return false;
93
+ if (def.order === "required" && !holds(ctx.grants, "order:read")) return false;
94
+ if (scopes.has("all")) return null;
95
+ const field = (scope: string) => fields.find((f) => f.scope === scope);
96
+ if (scopes.has("self") && field("assignee")?.writable) return "assignee";
97
+ if (scopes.has("location") && field("location")?.writable && ctx.locations.length > 0) return "location";
98
+ return false;
99
+ }
100
+
101
+ export function metaRoutes(app: OpenAPIHono<KedgeEnv>, perms: AdminPermissions, deps: AdminMetaDeps): void {
102
+ const { auth } = deps;
103
+
104
+ app.openapi(
105
+ perms.declare(
106
+ // Any signed-in staff member or key: it answers only about the caller.
107
+ { permission: "meta:read", store: true },
108
+ createRoute({ method: "get", path: "/admin/meta/me", responses: ok("Who the caller is and what they may do") }),
109
+ ),
110
+ async (c: Context<KedgeEnv>) => {
111
+ const a = c.var.access;
112
+ const ctx = a.ctx;
113
+ const s = a.session;
114
+ const permissions = [...ctx.grants.entries()]
115
+ .map(([code, scopes]) => ({ code, scopes: [...scopes].sort() }))
116
+ .sort((x, y) => (x.code < y.code ? -1 : x.code > y.code ? 1 : 0));
117
+ const authz = await authzFingerprint(ctx, s?.method ?? null, auth.accessContract ?? "");
118
+ const locations = [...ctx.locations].sort();
119
+ if (ctx.principal.kind === "staff" && s) {
120
+ const profile = await auth.ceremonies.staffProfile(ctx.principal.id);
121
+ const strong = s.method === STRONG;
122
+ return c.json(
123
+ {
124
+ principal: { kind: "staff", id: ctx.principal.id, email: profile?.email ?? null },
125
+ session: {
126
+ id: s.id,
127
+ method: s.method,
128
+ strong,
129
+ expires_at: iso(s.expiresAt),
130
+ authenticated_at: iso(s.authenticatedAt),
131
+ // A factor or password change needs a strong session this fresh (p0.5 §2.6).
132
+ fresh_until: strong ? iso(s.authenticatedAt + FRESH_MS) : null,
133
+ },
134
+ roles: (profile?.roles ?? []).map((r) => ({ ...r, counted: !r.strong || strong })),
135
+ permissions,
136
+ locations,
137
+ authz,
138
+ },
139
+ 200,
140
+ );
141
+ }
142
+ return c.json(
143
+ {
144
+ principal: { kind: ctx.principal.kind, id: "id" in ctx.principal ? ctx.principal.id : null },
145
+ permissions,
146
+ locations,
147
+ authz,
148
+ },
149
+ 200,
150
+ );
151
+ },
152
+ );
153
+
154
+ app.openapi(
155
+ perms.declare(
156
+ { permission: "meta:read", store: true },
157
+ createRoute({
158
+ method: "get",
159
+ path: "/admin/meta/resources",
160
+ responses: ok("Every resource the caller may read, core and plugin, as definitions"),
161
+ }),
162
+ ),
163
+ async (c: Context<KedgeEnv>) => {
164
+ const ctx = c.var.access.ctx;
165
+ const resources: Record<string, unknown>[] = [];
166
+ if (deps.orders && holds(ctx.grants, ORDER.permission)) {
167
+ const line = {
168
+ id: "core.order_line",
169
+ kind: "core",
170
+ label: ORDER_LINE.admin?.label,
171
+ title: ORDER_LINE.admin?.title ?? null,
172
+ list: ORDER_LINE.admin?.list ?? [],
173
+ fields: coreFields(ORDER_LINE, ctx),
174
+ actions: { create: false, update: false, archive: false, assign: false },
175
+ tracks: deps.tracksOn?.(ctx, "order_line") ?? [],
176
+ };
177
+ resources.push({
178
+ id: "core.order",
179
+ kind: "core",
180
+ label: ORDER.admin?.label,
181
+ nav: { section: "orders", order: 10 },
182
+ endpoints: {
183
+ list: { path: "/admin/orders", page: "cursor", codec: "order" satisfies Codec },
184
+ search: "/admin/orders/search",
185
+ read: "/admin/orders/{id}",
186
+ },
187
+ title: ORDER.admin?.title ?? null,
188
+ list: ORDER.admin?.list ?? [],
189
+ fields: coreFields(ORDER, ctx),
190
+ nested: [{ name: "lines", resource: line }],
191
+ actions: { create: false, update: false, archive: false, assign: false },
192
+ tracks: deps.tracksOn?.(ctx, "order") ?? [],
193
+ });
194
+ }
195
+ if (deps.auditLog && holds(ctx.grants, "audit:read")) {
196
+ const pii = holds(ctx.grants, "field:pii");
197
+ const f = (name: string, label: string, type: string, extra: Record<string, unknown> = {}) => ({
198
+ name,
199
+ type,
200
+ class: "public",
201
+ label,
202
+ required: false,
203
+ filter: [],
204
+ sort: false,
205
+ search: false,
206
+ scope: null,
207
+ readable: true,
208
+ writable: false,
209
+ ...extra,
210
+ });
211
+ resources.push({
212
+ id: "core.audit_log",
213
+ kind: "core",
214
+ label: "Audit log",
215
+ nav: { section: "system", order: 30 },
216
+ endpoints: { list: { path: "/admin/audit-log", page: "before", codec: "audit" satisfies Codec } },
217
+ title: "action",
218
+ list: ["created_at", "actor", "action", "entity_type", "entity_id"],
219
+ // As the route answers (p0.5 §7.2): `ip` and `user_agent` are null without
220
+ // field:pii, and `changes` is projected, financial values only with field:financial.
221
+ fields: [
222
+ f("created_at", "When", "datetime", { wire: "iso" }),
223
+ f("actor", "Actor", "string", { filter: ["eq"] }),
224
+ f("action", "Action", "string"),
225
+ f("entity_type", "Entity", "string", { filter: ["eq"] }),
226
+ f("entity_id", "Entity id", "string", { filter: ["eq"] }),
227
+ f("changes", "Changes", "json", { projected: true }),
228
+ f("ip", "IP", "string", { class: "pii", readable: pii }),
229
+ f("user_agent", "User agent", "string", { class: "pii", readable: pii }),
230
+ ],
231
+ actions: { create: false, update: false, archive: false, assign: false },
232
+ tracks: [],
233
+ });
234
+ }
235
+ if (deps.entities) {
236
+ const meta = deps.entities.store.metadata(ctx) as { entities: Record<string, unknown>[] };
237
+ for (const e of meta.entities) {
238
+ const plugin = e.plugin as string;
239
+ const entity = e.entity as string;
240
+ const declared = deps.entities.declared.find((d) => d.plugin === plugin && d.entity === entity);
241
+ if (!declared) continue;
242
+ const p = permissionsOf(plugin, entity, declared.def);
243
+ const fields = (e.fields as Record<string, unknown>[]).map((x) =>
244
+ x.type === "datetime" ? { ...x, wire: "ms" } : x,
245
+ ) as { name: string; required: boolean; writable: boolean; scope: string | null }[];
246
+ const actions = e.actions as { create: boolean; update: boolean; archive: boolean; assign: boolean };
247
+ const base = `/admin/plugins/${plugin}/${entity}`;
248
+ resources.push({
249
+ id: `plugin.${plugin}.${entity}`,
250
+ kind: "entity",
251
+ plugin,
252
+ entity,
253
+ label: e.label,
254
+ nav: { section: `plugin.${plugin}`, order: 10 },
255
+ endpoints: {
256
+ list: { path: base, page: "cursor", codec: "entity" satisfies Codec },
257
+ search: `${base}/search`,
258
+ read: `${base}/{id}`,
259
+ ...(actions.update ? { update: `${base}/{id}` } : {}),
260
+ ...(actions.archive ? { archive: `${base}/{id}/archive` } : {}),
261
+ create: base,
262
+ },
263
+ order: e.order,
264
+ title: e.title,
265
+ list: e.list,
266
+ fields,
267
+ actions: { ...actions, create: createUsable(ctx, declared.def, p.write, fields) },
268
+ // The scope column a scoped writer's create fills with the caller, if any (§5.1).
269
+ create_fill: createFill(ctx, declared.def, p.write, fields) || null,
270
+ tracks: e.tracks,
271
+ });
272
+ }
273
+ }
274
+ return c.json({ version: 1, resources }, 200);
275
+ },
276
+ );
277
+ }
@@ -0,0 +1,40 @@
1
+ // P0.7's lifecycle routes (docs/proposals/p0.7-tracks-entities.md §5.1),
2
+ // under /admin/system so no plugin route or entity name can collide with
3
+ // them. Each change is one audited batch under the route's scope.
4
+ import { createRoute, type OpenAPIHono, z } from "@hono/zod-openapi";
5
+ import type { AdminPermissions, KedgeEnv } from "./access";
6
+ import { ok } from "./http";
7
+ import type { PluginAdmin } from "./types";
8
+
9
+ const Name = z.string().regex(/^[a-z][a-z0-9_]{0,30}$/);
10
+
11
+ export function pluginRoutes(app: OpenAPIHono<KedgeEnv>, perms: AdminPermissions, plugins: PluginAdmin): void {
12
+ app.openapi(
13
+ perms.declare(
14
+ "plugin:read",
15
+ createRoute({
16
+ method: "get",
17
+ path: "/admin/system/plugins",
18
+ responses: ok("Each installed plugin, its state and what it declares"),
19
+ }),
20
+ ),
21
+ async (c) => c.json({ plugins: await plugins.list() }, 200),
22
+ );
23
+ for (const [action, to] of [
24
+ ["disable", "disabled"],
25
+ ["enable", "active"],
26
+ ] as const) {
27
+ app.openapi(
28
+ perms.declare(
29
+ { permission: "plugin:write", action: `plugin.${action}` },
30
+ createRoute({
31
+ method: "post",
32
+ path: `/admin/system/plugins/{name}/${action}`,
33
+ request: { params: z.object({ name: Name }) },
34
+ responses: ok(action === "disable" ? "The plugin, disabled: no new use" : "The plugin, active again"),
35
+ }),
36
+ ),
37
+ async (c) => c.json(await plugins.set(c.req.valid("param").name, to), 200),
38
+ );
39
+ }
40
+ }
@@ -0,0 +1,192 @@
1
+ // P0.6a's staff routes (p0.6a §2.8, §4.9): the deliveries staff act on, the
2
+ // relay's health, and the unresolved effects with their decisions. Every
3
+ // mutation runs inside the admin gate's batch scope, so its one batch carries
4
+ // the audit row and re-proves the permission at commit (p0.5 §7.1). Nothing
5
+ // here re-sends an effect: a decision is recorded, and its own delivery
6
+ // applies it on the effect's shard.
7
+ import { createRoute, type OpenAPIHono, z } from "@hono/zod-openapi";
8
+ import { actorOf, KedgeError, RELAY_LIMITS, readableWebhookTypes } from "@kedge/core";
9
+ import type { AdminPermissions, KedgeEnv } from "./access";
10
+ import { jsonBody, ok } from "./http";
11
+ import type { DeliveryAdmin } from "./types";
12
+
13
+ const Cursor = z.string().min(1).max(RELAY_LIMITS.STAFF_CURSOR_MAX).optional();
14
+ const Limit = z.coerce.number().int().min(1).max(100).default(50);
15
+ const DeliveryParam = z.object({ kind: z.enum(["sub", "rel", "res", "whk"]), seq: z.coerce.number().int().min(1) });
16
+ // `printedKey`'s form (core): the home shard in two hex digits, then the logical key.
17
+ const EffectKey = z.string().regex(/^efk_[0-9a-f]{2}_[0-9a-f]{64}$/);
18
+
19
+ export function relayRoutes(app: OpenAPIHono<KedgeEnv>, deliveries: DeliveryAdmin, perms: AdminPermissions): void {
20
+ app.openapi(
21
+ perms.declare(
22
+ "delivery:read",
23
+ createRoute({
24
+ method: "get",
25
+ path: "/admin/deliveries",
26
+ request: {
27
+ query: z.object({
28
+ state: z.enum(["failed", "blocked", "orphaned"]),
29
+ plugin: z.string().min(1).max(64).optional(),
30
+ cursor: Cursor,
31
+ limit: Limit,
32
+ }),
33
+ },
34
+ responses: ok("Failed, blocked or orphaned deliveries, paged by kind and seq"),
35
+ }),
36
+ ),
37
+ async (c) => {
38
+ const q = c.req.valid("query");
39
+ return c.json(
40
+ await deliveries.problems({
41
+ state: q.state,
42
+ ...(q.plugin ? { plugin: q.plugin } : {}),
43
+ // A webhook delivery shows only for a type the reader may read unscoped (p0.6b §2.9, codex r2).
44
+ webhookTypes: readableWebhookTypes(c.var.access.ctx.grants),
45
+ cursor: q.cursor ?? null,
46
+ limit: q.limit,
47
+ }),
48
+ 200,
49
+ );
50
+ },
51
+ );
52
+
53
+ app.openapi(
54
+ perms.declare(
55
+ "delivery:read",
56
+ createRoute({
57
+ method: "get",
58
+ path: "/admin/deliveries/health",
59
+ responses: ok("How far behind the relay is: the oldest due timer and delivery, counts, and each shard"),
60
+ }),
61
+ ),
62
+ async (c) => c.json((await deliveries.health()) as Record<string, unknown>, 200),
63
+ );
64
+
65
+ // A retry or discard of a delivery that isn't `failed` changes nothing and
66
+ // says so: two staff acting at once see one of them win (§2.8).
67
+ for (const [action, permission] of [
68
+ ["retry", "delivery:retry"],
69
+ ["discard", "delivery:discard"],
70
+ ] as const) {
71
+ app.openapi(
72
+ perms.declare(
73
+ permission,
74
+ createRoute({
75
+ method: "post",
76
+ path: `/admin/deliveries/{kind}/{seq}/${action}`,
77
+ request: {
78
+ params: DeliveryParam,
79
+ // The row's `updated_at` as the caller saw it (codex code r11): only that failure is acted
80
+ // on, so a repeat arriving after the delivery failed again changes nothing.
81
+ query: z.object({ expected_updated_at: z.coerce.number().int().min(0).optional() }),
82
+ },
83
+ responses: ok(action === "retry" ? "The delivery, due now" : "The delivery, given up on"),
84
+ }),
85
+ ),
86
+ async (c) => {
87
+ const { kind, seq } = c.req.valid("param");
88
+ const expected = c.req.valid("query").expected_updated_at ?? null;
89
+ if (kind === "whk") {
90
+ // Named one at a time, a webhook delivery still needs its type's read permission (p0.6b §2.9).
91
+ const type = await deliveries.webhookEventType(seq);
92
+ if (type === null || !readableWebhookTypes(c.var.access.ctx.grants).includes(type))
93
+ throw new KedgeError("NOT_FOUND", "Not found");
94
+ if (action === "retry")
95
+ throw new KedgeError("DELIVERY_KIND_REPLAY", "A webhook delivery is sent again by replay", {
96
+ route: "POST /admin/webhooks/deliveries/{id}/replay",
97
+ });
98
+ }
99
+ const done =
100
+ action === "retry"
101
+ ? await deliveries.retry(kind, seq, expected)
102
+ : await deliveries.discard(kind, seq, expected);
103
+ if (!done) throw new KedgeError("DELIVERY_STATE", "Only a failed delivery can be retried or discarded");
104
+ return c.json({ kind, seq, [action === "retry" ? "retried" : "discarded"]: true }, 200);
105
+ },
106
+ );
107
+ }
108
+
109
+ app.openapi(
110
+ perms.declare(
111
+ "effect:read",
112
+ createRoute({
113
+ method: "get",
114
+ path: "/admin/effects",
115
+ request: {
116
+ query: z.object({ state: z.enum(["unresolved", "conflict", "divergence"]), cursor: Cursor, limit: Limit }),
117
+ },
118
+ responses: ok("Unresolved effects or divergences across the journal's shards, or key conflicts"),
119
+ }),
120
+ ),
121
+ async (c) => {
122
+ const q = c.req.valid("query");
123
+ return c.json(
124
+ await deliveries.effects({
125
+ state: q.state,
126
+ cursor: q.cursor ?? null,
127
+ limit: q.limit,
128
+ webhookTypes: readableWebhookTypes(c.var.access.ctx.grants),
129
+ }),
130
+ 200,
131
+ );
132
+ },
133
+ );
134
+
135
+ app.openapi(
136
+ perms.declare(
137
+ // Its own envelope (code r17): a replay must answer the resolution id it created.
138
+ { permission: "effect:resolve", own: true },
139
+ createRoute({
140
+ method: "post",
141
+ path: "/admin/effects/{key}/resolve",
142
+ request: {
143
+ params: z.object({ key: EffectKey }),
144
+ ...jsonBody(
145
+ z.object({
146
+ expected_revision: z.number().int().min(0),
147
+ form: z.enum(["observe", "succeeded", "failed_final"]),
148
+ result: z.record(z.string(), z.unknown()).optional(),
149
+ code: z.string().min(1).max(64).optional(),
150
+ }),
151
+ ),
152
+ },
153
+ responses: ok(
154
+ "The resolution's id; its own delivery applies it. Needs an Idempotency-Key, whose retry answers the same id",
155
+ ),
156
+ }),
157
+ ),
158
+ async (c) => {
159
+ const b = c.req.valid("json");
160
+ const out = await deliveries.resolve({
161
+ key: c.req.valid("param").key,
162
+ expectedRevision: b.expected_revision,
163
+ form: b.form,
164
+ ...(b.result ? { result: b.result } : {}),
165
+ ...(b.code ? { code: b.code } : {}),
166
+ staffId: actorOf(c.var.access.ctx.principal),
167
+ idempotencyKey: c.req.header("idempotency-key") ?? null,
168
+ });
169
+ return c.json(
170
+ { resolution_id: out.resolutionId, state: "pending", ...(out.replayed ? { replayed: true } : {}) },
171
+ 200,
172
+ );
173
+ },
174
+ );
175
+
176
+ app.openapi(
177
+ perms.declare(
178
+ "effect:read",
179
+ createRoute({
180
+ method: "get",
181
+ path: "/admin/effects/resolutions/{id}",
182
+ request: { params: z.object({ id: z.string().min(1).max(64) }) },
183
+ responses: ok("A resolution's delivery: pending, then applied, conflict, canceled or discarded"),
184
+ }),
185
+ ),
186
+ async (c) => {
187
+ const r = await deliveries.resolution(c.req.valid("param").id);
188
+ if (!r) throw new KedgeError("NOT_FOUND", "No such resolution");
189
+ return c.json(r as Record<string, unknown>, 200);
190
+ },
191
+ );
192
+ }
@@ -0,0 +1,102 @@
1
+ // P0.7's track routes (docs/proposals/p0.7-tracks-entities.md §3.3, §3.4,
2
+ // §3.8). A transition's permission depends on the transition named in the
3
+ // body, and a row-scoped grant is compiled into the batch, so the store
4
+ // authorizes and audits each command itself (`store: true`): the gate only
5
+ // authenticates and checks CSRF.
6
+ import { createRoute, type OpenAPIHono, z } from "@hono/zod-openapi";
7
+ import { TRACK_LIMITS } 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 { TrackAdmin } from "./types";
12
+
13
+ const Name = z.string().regex(/^[a-z][a-z0-9_]{0,40}$/);
14
+ const Params = z.object({ plugin: Name, track: Name, aggregate_id: z.string().min(3).max(200) });
15
+ const Token = z.object({ recovery_id: z.string().min(1).max(64), version: z.number().int().min(1) });
16
+
17
+ export function trackRoutes(app: OpenAPIHono<KedgeEnv>, perms: AdminPermissions, tracks: TrackAdmin): void {
18
+ const command = (c: Context<KedgeEnv>, p: z.infer<typeof Params>) => {
19
+ const client = clientOf(c);
20
+ return {
21
+ plugin: p.plugin,
22
+ track: p.track,
23
+ aggregateId: p.aggregate_id,
24
+ idempotencyKey: c.req.header("idempotency-key") ?? null,
25
+ ip: client.ip,
26
+ userAgent: client.userAgent,
27
+ };
28
+ };
29
+
30
+ app.openapi(
31
+ perms.declare(
32
+ { permission: "track:read", store: true },
33
+ createRoute({
34
+ method: "get",
35
+ path: "/admin/plugins/{plugin}/tracks/{track}/{aggregate_id}",
36
+ request: { params: Params, query: z.object({ history_after: z.coerce.number().int().min(0).optional() }) },
37
+ responses: ok("The track: its state, token, transitions and a page of history"),
38
+ }),
39
+ ),
40
+ async (c) => {
41
+ const p = c.req.valid("param");
42
+ return c.json(
43
+ (await tracks.read(
44
+ c.var.access.ctx,
45
+ p.plugin,
46
+ p.track,
47
+ p.aggregate_id,
48
+ c.req.valid("query").history_after,
49
+ )) as object,
50
+ 200,
51
+ );
52
+ },
53
+ );
54
+
55
+ app.openapi(
56
+ perms.declare(
57
+ { permission: "track:start", store: true },
58
+ createRoute({
59
+ method: "post",
60
+ path: "/admin/plugins/{plugin}/tracks/{track}/{aggregate_id}/start",
61
+ request: { params: Params },
62
+ responses: ok("The started track, or a receipt when the caller can't read it"),
63
+ }),
64
+ ),
65
+ async (c) => c.json((await tracks.start(c.var.access.ctx, command(c, c.req.valid("param")))) as object, 200),
66
+ );
67
+
68
+ app.openapi(
69
+ perms.declare(
70
+ { permission: "track:transition", store: true },
71
+ createRoute({
72
+ method: "post",
73
+ path: "/admin/plugins/{plugin}/tracks/{track}/{aggregate_id}/transitions",
74
+ request: {
75
+ params: Params,
76
+ ...jsonBody(
77
+ z.object({
78
+ transition: Name,
79
+ expected: Token,
80
+ input: z.record(z.string(), z.unknown()).optional(),
81
+ comment: z.string().min(1).max(TRACK_LIMITS.COMMENT_MAX).nullable().optional(),
82
+ }),
83
+ ),
84
+ },
85
+ responses: ok("The moved track, or a receipt when the caller can't read it"),
86
+ }),
87
+ ),
88
+ async (c) => {
89
+ const b = c.req.valid("json");
90
+ return c.json(
91
+ (await tracks.transition(c.var.access.ctx, {
92
+ ...command(c, c.req.valid("param")),
93
+ transition: b.transition,
94
+ expected: b.expected,
95
+ ...(b.input !== undefined ? { input: b.input } : {}),
96
+ comment: b.comment ?? null,
97
+ })) as object,
98
+ 200,
99
+ );
100
+ },
101
+ );
102
+ }