@classytic/arc 2.13.1 → 2.14.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,968 @@
1
+ import { t as getUserRoles } from "./types-D57iXYb8.mjs";
2
+ import { n as convertRouteSchema } from "./schemaConverter-De34B1ZG.mjs";
3
+ import { t as resolveActionPermission } from "./actionPermissions-CyUkQu6O.mjs";
4
+ import { t as buildActionBodySchema } from "./createActionRouter-CBxLLbn3.mjs";
5
+ import fp from "fastify-plugin";
6
+ //#region src/docs/openapi/canonical-schemas.ts
7
+ /**
8
+ * Static canonical schemas — referenced once, from
9
+ * `components.schemas`. Per-resource schemas (paginated `oneOf` with
10
+ * the resource's `items.$ref`) are built via
11
+ * `buildPaginatedListSchema(itemRef)` below.
12
+ */
13
+ const CANONICAL_SCHEMAS = {
14
+ ErrorDetail: {
15
+ type: "object",
16
+ required: ["code", "message"],
17
+ description: "Single field-scoped error detail. Mirrors `@classytic/repo-core/errors` ErrorDetail.",
18
+ properties: {
19
+ path: {
20
+ type: "string",
21
+ description: "Dot-path pointer to the offending field (e.g. `lines.0.quantity`)."
22
+ },
23
+ code: { type: "string" },
24
+ message: { type: "string" },
25
+ meta: {
26
+ type: "object",
27
+ additionalProperties: true
28
+ }
29
+ }
30
+ },
31
+ ErrorContract: {
32
+ type: "object",
33
+ required: ["code", "message"],
34
+ description: "Canonical error wire shape emitted by arc's error handler. Mirrors `@classytic/repo-core/errors` ErrorContract — flat top-level `code` / `message`, NOT nested under `{ error: { ... } }`.",
35
+ properties: {
36
+ code: {
37
+ type: "string",
38
+ description: "Hierarchical machine code (e.g. `not_found`, `validation_error`, `order.validation.missing_line`). Arc's legacy UPPER_SNAKE codes (`'NOT_FOUND'`, `'VALIDATION_ERROR'`) also flow through this field for back-compat."
39
+ },
40
+ message: {
41
+ type: "string",
42
+ description: "Human-readable, safe-for-client message."
43
+ },
44
+ status: {
45
+ type: "integer",
46
+ description: "Suggested HTTP status code."
47
+ },
48
+ details: {
49
+ type: "array",
50
+ items: { $ref: "#/components/schemas/ErrorDetail" }
51
+ },
52
+ correlationId: {
53
+ type: "string",
54
+ description: "Trace identifier for support lookups."
55
+ },
56
+ meta: {
57
+ type: "object",
58
+ additionalProperties: true,
59
+ description: "Non-PII metadata. Safe to log, safe to return."
60
+ }
61
+ }
62
+ },
63
+ DeleteResult: {
64
+ type: "object",
65
+ required: ["message"],
66
+ description: "Arc's HTTP DELETE wire shape. The handler returns `{ message, id?, soft? }` (see `BaseCrudController.delete`). // arc-specific extension to repo-core's DeleteResult TYPE — repo-core's type carries internal `count?` for batch adapters that surface counts inline; arc's HTTP handler does not project that to the wire. // arc-specific extension: `meta` (e.g. `{ message: 'Deleted successfully' }`) is merged at the top level by `fastifyAdapter` — `message` already covers that, no second nesting.",
67
+ properties: {
68
+ message: {
69
+ type: "string",
70
+ description: "Human-readable success message."
71
+ },
72
+ id: {
73
+ type: "string",
74
+ description: "Primary key of the removed document (string form)."
75
+ },
76
+ soft: {
77
+ type: "boolean",
78
+ description: "True when a soft-delete plugin intercepted the operation."
79
+ }
80
+ }
81
+ }
82
+ };
83
+ /**
84
+ * Build the per-resource paginated list response schema as a plain
85
+ * `oneOf` of the four canonical list envelopes:
86
+ *
87
+ * - offset (`{ method: 'offset', data, page, limit, total, pages, hasNext, hasPrev }`)
88
+ * - keyset (`{ method: 'keyset', data, limit, hasMore, next }`)
89
+ * - aggregate (`{ method: 'aggregate', data, page, limit, total, pages, hasNext, hasPrev }`)
90
+ * - bare (`{ data }`)
91
+ *
92
+ * The first three carry the `method` literal as a discriminant string;
93
+ * codegen tools narrow on `method`. Bare lacks `method` — consumers
94
+ * narrow on its absence.
95
+ *
96
+ * NOTE on shape parity with repo-core/pagination/types.ts:
97
+ * - Keyset uses `hasMore` (boolean) + `next: string | null` — see
98
+ * `KeysetPaginationResultCore`. We model `next` as nullable string.
99
+ * - Offset/aggregate are structurally identical save for the
100
+ * `method` literal (the discriminant exists so consumers can route
101
+ * "this came from an aggregate, not a plain find").
102
+ */
103
+ function buildPaginatedListSchema(itemRef) {
104
+ return {
105
+ description: "List response — discriminated union of arc's four canonical list shapes. Branch on `method` (`'offset' | 'keyset' | 'aggregate'`) for paginated results; absence of `method` indicates a bare (unpaginated) list.",
106
+ oneOf: [
107
+ {
108
+ type: "object",
109
+ required: [
110
+ "method",
111
+ "data",
112
+ "page",
113
+ "limit",
114
+ "total",
115
+ "pages",
116
+ "hasNext",
117
+ "hasPrev"
118
+ ],
119
+ description: "Offset-paginated result.",
120
+ properties: {
121
+ method: {
122
+ type: "string",
123
+ enum: ["offset"]
124
+ },
125
+ data: {
126
+ type: "array",
127
+ items: { $ref: itemRef }
128
+ },
129
+ page: { type: "integer" },
130
+ limit: { type: "integer" },
131
+ total: { type: "integer" },
132
+ pages: { type: "integer" },
133
+ hasNext: { type: "boolean" },
134
+ hasPrev: { type: "boolean" }
135
+ }
136
+ },
137
+ {
138
+ type: "object",
139
+ required: [
140
+ "method",
141
+ "data",
142
+ "limit",
143
+ "hasMore",
144
+ "next"
145
+ ],
146
+ description: "Keyset-paginated result.",
147
+ properties: {
148
+ method: {
149
+ type: "string",
150
+ enum: ["keyset"]
151
+ },
152
+ data: {
153
+ type: "array",
154
+ items: { $ref: itemRef }
155
+ },
156
+ limit: { type: "integer" },
157
+ hasMore: { type: "boolean" },
158
+ next: {
159
+ type: "string",
160
+ nullable: true,
161
+ description: "Opaque cursor for the next page, or null when `hasMore` is false. Round-trip verbatim as the `after` query param."
162
+ }
163
+ }
164
+ },
165
+ {
166
+ type: "object",
167
+ required: [
168
+ "method",
169
+ "data",
170
+ "page",
171
+ "limit",
172
+ "total",
173
+ "pages",
174
+ "hasNext",
175
+ "hasPrev"
176
+ ],
177
+ description: "Aggregate-paginated result.",
178
+ properties: {
179
+ method: {
180
+ type: "string",
181
+ enum: ["aggregate"]
182
+ },
183
+ data: {
184
+ type: "array",
185
+ items: { $ref: itemRef }
186
+ },
187
+ page: { type: "integer" },
188
+ limit: { type: "integer" },
189
+ total: { type: "integer" },
190
+ pages: { type: "integer" },
191
+ hasNext: { type: "boolean" },
192
+ hasPrev: { type: "boolean" }
193
+ }
194
+ },
195
+ {
196
+ type: "object",
197
+ required: ["data"],
198
+ description: "Bare (unpaginated) list — `{ data: T[] }` only.",
199
+ properties: { data: {
200
+ type: "array",
201
+ items: { $ref: itemRef }
202
+ } }
203
+ }
204
+ ]
205
+ };
206
+ }
207
+ //#endregion
208
+ //#region src/docs/openapi/field-permissions.ts
209
+ /**
210
+ * Field permission descriptions — appended to schema-property
211
+ * `description` strings during component generation so codegen surfaces
212
+ * the perm rule next to the field type.
213
+ */
214
+ /**
215
+ * Format a field permission rule for an OpenAPI field description.
216
+ *
217
+ * Mirrors the runtime field-permission types — the four supported rule
218
+ * kinds map to a sentence each.
219
+ */
220
+ function formatFieldPermDescription(perm) {
221
+ switch (perm.type) {
222
+ case "hidden": return "Hidden — never returned in responses";
223
+ case "visibleTo": return `Visible to: ${(perm.roles ?? []).join(", ")}`;
224
+ case "writableBy": return `Writable by: ${(perm.roles ?? []).join(", ")}`;
225
+ case "redactFor": return `Redacted for: ${(perm.roles ?? []).join(", ")}`;
226
+ default: return perm.type;
227
+ }
228
+ }
229
+ //#endregion
230
+ //#region src/docs/openapi/components.ts
231
+ /**
232
+ * Generate component schema definitions from pre-stored registry
233
+ * schemas.
234
+ *
235
+ * Schemas are generated at resource definition time and stored in the
236
+ * registry. Response schema priority:
237
+ * 1. If resource provides explicit `openApiSchemas.response`, use it
238
+ * as-is.
239
+ * 2. Otherwise, auto-generate from `createBody` + `_id` + timestamps.
240
+ * 3. Fallback to a placeholder doc with just `_id` + timestamps.
241
+ *
242
+ * Note: this emits OpenAPI documentation only — does NOT affect Fastify
243
+ * serialization.
244
+ */
245
+ function generateSchemas(resources) {
246
+ const schemas = { ...CANONICAL_SCHEMAS };
247
+ for (const resource of resources) {
248
+ const storedSchemas = resource.openApiSchemas;
249
+ const fieldPerms = resource.fieldPermissions;
250
+ if (storedSchemas?.response) schemas[resource.name] = {
251
+ type: "object",
252
+ description: resource.displayName,
253
+ ...storedSchemas.response
254
+ };
255
+ else if (storedSchemas?.createBody) schemas[resource.name] = {
256
+ type: "object",
257
+ description: resource.displayName,
258
+ properties: {
259
+ _id: {
260
+ type: "string",
261
+ description: "Unique identifier"
262
+ },
263
+ ...storedSchemas.createBody.properties ?? {},
264
+ createdAt: {
265
+ type: "string",
266
+ format: "date-time",
267
+ description: "Creation timestamp"
268
+ },
269
+ updatedAt: {
270
+ type: "string",
271
+ format: "date-time",
272
+ description: "Last update timestamp"
273
+ }
274
+ }
275
+ };
276
+ else schemas[resource.name] = {
277
+ type: "object",
278
+ description: resource.displayName,
279
+ properties: {
280
+ _id: {
281
+ type: "string",
282
+ description: "Unique identifier"
283
+ },
284
+ createdAt: {
285
+ type: "string",
286
+ format: "date-time",
287
+ description: "Creation timestamp"
288
+ },
289
+ updatedAt: {
290
+ type: "string",
291
+ format: "date-time",
292
+ description: "Last update timestamp"
293
+ }
294
+ }
295
+ };
296
+ const resourceSchema = schemas[resource.name];
297
+ if (fieldPerms && resourceSchema?.properties) {
298
+ const props = resourceSchema.properties;
299
+ for (const [field, perm] of Object.entries(fieldPerms)) {
300
+ const propSchema = props[field];
301
+ if (propSchema) {
302
+ const desc = propSchema.description ?? "";
303
+ const permDesc = formatFieldPermDescription(perm);
304
+ propSchema.description = desc ? `${desc} (${permDesc})` : permDesc;
305
+ } else if (perm.type === "hidden") {}
306
+ }
307
+ }
308
+ if (storedSchemas?.createBody) {
309
+ schemas[`${resource.name}Input`] = {
310
+ type: "object",
311
+ description: `${resource.displayName} create input`,
312
+ ...storedSchemas.createBody
313
+ };
314
+ if (storedSchemas.updateBody) schemas[`${resource.name}Update`] = {
315
+ type: "object",
316
+ description: `${resource.displayName} update input`,
317
+ ...storedSchemas.updateBody
318
+ };
319
+ } else schemas[`${resource.name}Input`] = {
320
+ type: "object",
321
+ description: `${resource.displayName} input`
322
+ };
323
+ }
324
+ return schemas;
325
+ }
326
+ //#endregion
327
+ //#region src/docs/openapi/operations.ts
328
+ /**
329
+ * Standard error responses that every CRUD route ships. Per-route
330
+ * additions (e.g. 404 on get/update/delete, 409 on create/update) are
331
+ * merged on top via `extras.responses`.
332
+ *
333
+ * @internal
334
+ */
335
+ function buildErrorResponses(opts) {
336
+ const responses = {};
337
+ if (opts.requiresAuth) {
338
+ responses["401"] = {
339
+ description: "Authentication required — no valid Bearer token provided",
340
+ content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorContract" } } }
341
+ };
342
+ responses["403"] = {
343
+ description: opts.permRoles?.length ? `Forbidden — requires one of: ${opts.permRoles.join(", ")}` : "Forbidden — insufficient permissions",
344
+ content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorContract" } } }
345
+ };
346
+ }
347
+ responses["500"] = {
348
+ description: "Internal server error",
349
+ content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorContract" } } }
350
+ };
351
+ return responses;
352
+ }
353
+ /**
354
+ * Validation / not-found / conflict — appended to specific CRUD
355
+ * operations. Every shape references `ErrorContract`.
356
+ */
357
+ function errorResponse(description) {
358
+ return {
359
+ description,
360
+ content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorContract" } } }
361
+ };
362
+ }
363
+ /**
364
+ * Create an operation object.
365
+ *
366
+ * @param requiresAuthOverride Override for whether auth is required (used by
367
+ * custom routes that pass `permissions: allowPublic()` etc., which the
368
+ * generic `permissions.{operation}` lookup wouldn't find).
369
+ * @param additionalSecurity Extra security alternatives from external
370
+ * integrations (OR'd with bearerAuth) — e.g. plugin-injected
371
+ * `apiKeyAuth + orgHeader` combos.
372
+ */
373
+ function createOperation(resource, operation, summary, extras, requiresAuthOverride, additionalSecurity = []) {
374
+ const operationPermission = (resource.permissions || {})[operation];
375
+ const isPublic = operationPermission?._isPublic === true;
376
+ operationPermission?._roles;
377
+ const requiresAuth = requiresAuthOverride !== void 0 ? requiresAuthOverride : typeof operationPermission === "function" && !isPublic;
378
+ const permAnnotation = describePermissionForOpenApi(operationPermission);
379
+ const descParts = [];
380
+ if (permAnnotation) descParts.push(`**Permission**: ${permAnnotation.type === "public" ? "Public" : permAnnotation.type === "requireRoles" ? `Requires roles: ${(permAnnotation.roles ?? []).join(", ")}` : "Requires authentication"}`);
381
+ if (resource.presets && resource.presets.length > 0) descParts.push(`**Presets**: ${resource.presets.join(", ")}`);
382
+ const applicableSteps = (resource.pipelineSteps ?? []).filter((s) => {
383
+ if (!s.operations) return true;
384
+ return s.operations.includes(operation);
385
+ });
386
+ const op = {
387
+ tags: [resource.tag || "Resource"],
388
+ summary: `${summary} ${(resource.displayName || resource.name).toLowerCase()}`,
389
+ operationId: `${resource.name}_${operation}`,
390
+ ...descParts.length > 0 && { description: descParts.join("\n\n") },
391
+ ...requiresAuth && { security: [{ bearerAuth: [] }, ...additionalSecurity] },
392
+ ...permAnnotation && { "x-arc-permission": permAnnotation },
393
+ ...applicableSteps.length > 0 && { "x-arc-pipeline": applicableSteps.map((s) => ({
394
+ type: s.type,
395
+ name: s.name
396
+ })) },
397
+ responses: buildErrorResponses({
398
+ requiresAuth,
399
+ permRoles: permAnnotation?.roles
400
+ }),
401
+ ...extras
402
+ };
403
+ if (extras.responses) op.responses = {
404
+ ...buildErrorResponses({
405
+ requiresAuth,
406
+ permRoles: permAnnotation?.roles
407
+ }),
408
+ ...extras.responses
409
+ };
410
+ return op;
411
+ }
412
+ /**
413
+ * Describe a permission check function for OpenAPI.
414
+ * Extracts role, org role, and team permission metadata from permission
415
+ * functions.
416
+ */
417
+ function describePermissionForOpenApi(check) {
418
+ if (!check || typeof check !== "function") return void 0;
419
+ const fn = check;
420
+ if (fn._isPublic === true) return { type: "public" };
421
+ const result = { type: "requireAuth" };
422
+ if (Array.isArray(fn._roles) && fn._roles.length > 0) {
423
+ result.type = "requireRoles";
424
+ result.roles = fn._roles;
425
+ }
426
+ if (Array.isArray(fn._orgRoles) && fn._orgRoles.length > 0) result.orgRoles = fn._orgRoles;
427
+ return result;
428
+ }
429
+ //#endregion
430
+ //#region src/docs/openapi/parameters.ts
431
+ /**
432
+ * Default query parameters for list endpoints when the resource hasn't
433
+ * provided an explicit `openApiSchemas.listQuery` schema. These match
434
+ * the defaults arc's `QueryParser` honours, so codegen consumers get
435
+ * working scaffolds even for kits that don't surface a typed list query.
436
+ */
437
+ const DEFAULT_LIST_PARAMS = [
438
+ {
439
+ name: "page",
440
+ in: "query",
441
+ schema: { type: "integer" },
442
+ description: "Page number"
443
+ },
444
+ {
445
+ name: "limit",
446
+ in: "query",
447
+ schema: { type: "integer" },
448
+ description: "Items per page"
449
+ },
450
+ {
451
+ name: "sort",
452
+ in: "query",
453
+ schema: { type: "string" },
454
+ description: "Sort field (prefix with - for descending)"
455
+ }
456
+ ];
457
+ /**
458
+ * Convert Fastify-style params (`/:id`) to OpenAPI-style params
459
+ * (`/{id}`).
460
+ */
461
+ function toOpenApiPath(path) {
462
+ return path.replace(/:([^/]+)/g, "{$1}");
463
+ }
464
+ /**
465
+ * Convert a JSON-Schema `{ type: 'object', properties: {...} }` into an
466
+ * OpenAPI parameter array — each property becomes one query parameter.
467
+ *
468
+ * `description` is lifted from the property to the Parameter level
469
+ * (OpenAPI's preferred location); the rest of the schema body stays in
470
+ * `param.schema`.
471
+ */
472
+ function convertSchemaToParameters(schema) {
473
+ const params = [];
474
+ const properties = schema.properties || {};
475
+ const required = schema.required || [];
476
+ for (const [name, prop] of Object.entries(properties)) {
477
+ const description = prop.description;
478
+ const { description: _, ...schemaProps } = prop;
479
+ const param = {
480
+ name,
481
+ in: "query",
482
+ required: required.includes(name),
483
+ schema: schemaProps
484
+ };
485
+ if (description) param.description = description;
486
+ params.push(param);
487
+ }
488
+ return params;
489
+ }
490
+ /**
491
+ * Extract path parameters from a route path (e.g. `/foo/:id/bar/:slug`
492
+ * → `[{ name: 'id', ...}, { name: 'slug', ...}]`). All extracted params
493
+ * are typed as `string` — Fastify path captures are always strings.
494
+ */
495
+ function extractPathParams(path) {
496
+ const params = [];
497
+ const matches = path.matchAll(/:([^/]+)/g);
498
+ for (const match of matches) {
499
+ const paramName = match[1];
500
+ if (paramName) params.push({
501
+ name: paramName,
502
+ in: "path",
503
+ required: true,
504
+ schema: { type: "string" }
505
+ });
506
+ }
507
+ return params;
508
+ }
509
+ //#endregion
510
+ //#region src/docs/openapi/action-paths.ts
511
+ /**
512
+ * Action endpoint emitter — `POST /:resource/:id/action`.
513
+ *
514
+ * Generates a single dispatch endpoint per resource that lists every
515
+ * declared action via the `action` discriminant. Body schema is built
516
+ * via the SAME `buildActionBodySchema` runtime uses, so docs and
517
+ * validation stay in sync (one source of truth for the action envelope
518
+ * shape).
519
+ *
520
+ * NOTE: action **response** shape varies per action — the dispatcher
521
+ * returns whatever the handler returned. We can't statically type the
522
+ * response without the handler exposing its return type, and most
523
+ * handlers return either the mutated resource document or a kit-defined
524
+ * envelope. We declare the `200` body schema as an empty object (`{}`)
525
+ * which `@hey-api/openapi-ts` and friends compile to `unknown` — that's
526
+ * the most accurate thing we can say without lying to consumers about
527
+ * shape. Per-action shape is documented in the `description` field;
528
+ * future work could let resource authors declare a per-action
529
+ * `responseSchema`.
530
+ */
531
+ /**
532
+ * Append the action-dispatch path (`POST /:basePath/:id/action`) when
533
+ * the resource declares any `actions`.
534
+ */
535
+ function appendActionPaths(paths, resource, basePath, additionalSecurity) {
536
+ if (!resource.actions || resource.actions.length === 0) return;
537
+ const actionPath = toOpenApiPath(`${basePath}/:id/action`);
538
+ const actionEnum = resource.actions.map((a) => a.name);
539
+ const actionSchemas = {};
540
+ for (const a of resource.actions) if (a.schema) actionSchemas[a.name] = a.schema;
541
+ const bodySchema = buildActionBodySchema(actionEnum, actionSchemas);
542
+ const descLines = [
543
+ "Unified action endpoint for state transitions.",
544
+ "",
545
+ "**Available actions:**"
546
+ ];
547
+ for (const a of resource.actions) {
548
+ const roles = a.permissions?._roles;
549
+ const roleStr = roles?.length ? ` — requires: ${roles.join(" or ")}` : "";
550
+ const descStr = a.description ? ` — ${a.description}` : "";
551
+ descLines.push(`- \`${a.name}\`${roleStr}${descStr}`);
552
+ }
553
+ descLines.push("", "Response shape depends on the action handler — typically the mutated resource document or a kit-defined result envelope. See the per-action description above.");
554
+ const anyAuthRequired = resource.actions.some((a) => {
555
+ const effective = resolveActionPermission({
556
+ action: { permissions: a.permissions },
557
+ resourcePermissions: resource.permissions,
558
+ resourceActionPermissions: resource.actionPermissions
559
+ });
560
+ return typeof effective === "function" && !effective._isPublic;
561
+ });
562
+ if (!paths[actionPath]) paths[actionPath] = {};
563
+ paths[actionPath].post = createOperation(resource, "action", `Perform action (${actionEnum.join(" / ")})`, {
564
+ parameters: [{
565
+ name: "id",
566
+ in: "path",
567
+ required: true,
568
+ schema: { type: "string" },
569
+ description: "Resource ID"
570
+ }],
571
+ description: descLines.join("\n"),
572
+ requestBody: {
573
+ required: true,
574
+ content: { "application/json": { schema: bodySchema } }
575
+ },
576
+ responses: {
577
+ "200": {
578
+ description: "Action executed successfully",
579
+ content: { "application/json": { schema: {} } }
580
+ },
581
+ "400": errorResponse("Invalid action or missing required fields"),
582
+ "404": errorResponse("Resource not found")
583
+ }
584
+ }, anyAuthRequired, additionalSecurity);
585
+ }
586
+ //#endregion
587
+ //#region src/docs/openapi/aggregation-paths.ts
588
+ /**
589
+ * Emit one OpenAPI path entry per declared aggregation.
590
+ *
591
+ * Path: `GET /:resource/aggregations/<name>`
592
+ * Response: `{ rows: AggregationRow[] }` where each row is keyed by
593
+ * the groupBy fields (nested object for joined-alias paths)
594
+ * plus the measure aliases.
595
+ */
596
+ function appendAggregationPaths(paths, resource, basePath, additionalSecurity) {
597
+ if (!resource.aggregations) return;
598
+ for (const agg of resource.aggregations) {
599
+ const path = toOpenApiPath(`${basePath}/aggregations/${agg.name}`);
600
+ const requiresAuth = !agg.permissions?._isPublic;
601
+ const rowSchema = buildAggregationRowSchema(normalizeGroupByForOpenApi(agg.groupBy), agg.measures, agg.lookupAliases);
602
+ const querystring = {
603
+ type: "object",
604
+ properties: {},
605
+ additionalProperties: true
606
+ };
607
+ if (agg.requireDateRange) {
608
+ const props = querystring.properties;
609
+ const f = agg.requireDateRange.field;
610
+ props[`${f}[gte]`] = {
611
+ type: "string",
612
+ description: `Lower bound (inclusive) of required date range on \`${f}\`.`
613
+ };
614
+ props[`${f}[lte]`] = {
615
+ type: "string",
616
+ description: `Upper bound (inclusive) of required date range on \`${f}\`.`
617
+ };
618
+ }
619
+ if (agg.requireFilters?.length) {
620
+ const props = querystring.properties;
621
+ for (const f of agg.requireFilters) props[f] = {
622
+ type: "string",
623
+ description: `Required filter on \`${f}\` — request rejected (400) if missing.`
624
+ };
625
+ }
626
+ const descLines = [];
627
+ if (agg.description) descLines.push(agg.description);
628
+ descLines.push("Portable aggregation. Caller filters via query string narrow the base + tenant scope; response shape is `{ rows: [...] }` matching repo-core's `AggResult` contract.");
629
+ if (Object.keys(agg.measures).length > 0) {
630
+ const measureLines = Object.entries(agg.measures).map(([alias, op]) => `- \`${alias}\` — \`${op}\``).join("\n");
631
+ descLines.push("", "**Measures:**", measureLines);
632
+ }
633
+ if (agg.requireDateRange) descLines.push("", `**Required date range** on \`${agg.requireDateRange.field}\` — supply \`?${agg.requireDateRange.field}[gte]=...&${agg.requireDateRange.field}[lte]=...\`.` + (agg.requireDateRange.maxRangeDays ? ` Range cap: ${agg.requireDateRange.maxRangeDays} days.` : ""));
634
+ if (!paths[path]) paths[path] = {};
635
+ paths[path].get = createOperation(resource, `aggregation.${agg.name}`, agg.summary ?? `Aggregation: ${agg.name}`, {
636
+ description: descLines.join("\n"),
637
+ parameters: [{
638
+ name: "querystring",
639
+ in: "query",
640
+ required: false,
641
+ schema: querystring,
642
+ description: "Filter narrowing — composes with base filter + tenant scope."
643
+ }],
644
+ responses: {
645
+ "200": {
646
+ description: "Aggregation result",
647
+ content: { "application/json": { schema: {
648
+ type: "object",
649
+ required: ["rows"],
650
+ properties: { rows: {
651
+ type: "array",
652
+ items: rowSchema
653
+ } }
654
+ } } }
655
+ },
656
+ "400": errorResponse("Missing required filter or invalid date range"),
657
+ "422": errorResponse("Result row count exceeded `maxGroups` cap"),
658
+ "501": errorResponse("Adapter does not implement `aggregate()`"),
659
+ "504": errorResponse("Aggregation execution timed out")
660
+ }
661
+ }, requiresAuth, additionalSecurity);
662
+ }
663
+ }
664
+ /**
665
+ * Build the JSON Schema for a single aggregation row. Combines the
666
+ * groupBy field shape (nested for joined-alias paths) with the
667
+ * measure-alias scalars.
668
+ *
669
+ * Group keys with dotted paths (e.g. `'category.code'`) emit a nested
670
+ * `category: { code: string }` object, matching the cross-kit
671
+ * `nestDottedKeys` output. Plain group keys are flat.
672
+ *
673
+ * Measure scalars are always `number` — every measure op
674
+ * (`count` / `sum` / `avg` / `min` / `max` / `countDistinct`)
675
+ * produces a numeric result.
676
+ */
677
+ function buildAggregationRowSchema(groupByFields, measures, _lookupAliases) {
678
+ const properties = {};
679
+ for (const field of groupByFields) setNestedSchemaProp(properties, field.split("."), { type: "string" });
680
+ for (const alias of Object.keys(measures)) properties[alias] = { type: "number" };
681
+ return {
682
+ type: "object",
683
+ properties,
684
+ additionalProperties: false
685
+ };
686
+ }
687
+ function normalizeGroupByForOpenApi(groupBy) {
688
+ if (!groupBy) return [];
689
+ return typeof groupBy === "string" ? [groupBy] : groupBy;
690
+ }
691
+ function setNestedSchemaProp(target, path, leaf) {
692
+ if (path.length === 1) {
693
+ target[path[0]] = leaf;
694
+ return;
695
+ }
696
+ const head = path[0];
697
+ const rest = path.slice(1);
698
+ let nested = target[head];
699
+ if (!nested || typeof nested !== "object") {
700
+ nested = {
701
+ type: "object",
702
+ properties: {}
703
+ };
704
+ target[head] = nested;
705
+ }
706
+ if (!nested.properties) nested.properties = {};
707
+ setNestedSchemaProp(nested.properties, rest, leaf);
708
+ }
709
+ //#endregion
710
+ //#region src/docs/openapi/crud-paths.ts
711
+ /**
712
+ * Append the default-CRUD paths (`GET /` list + `POST /` create on the
713
+ * collection path; `GET/PATCH|PUT/DELETE /:id` on the item path).
714
+ * Honours `disableDefaultRoutes`, `disabledRoutes`, and `updateMethod`.
715
+ */
716
+ function appendCrudPaths(paths, resource, basePath, additionalSecurity) {
717
+ if (resource.disableDefaultRoutes) return;
718
+ const disabledSet = new Set(resource.disabledRoutes ?? []);
719
+ const updateMethod = resource.updateMethod ?? "PATCH";
720
+ const collectionPath = {};
721
+ if (!disabledSet.has("list")) collectionPath.get = createOperation(resource, "list", "List all", {
722
+ parameters: resource.openApiSchemas?.listQuery ? convertSchemaToParameters(resource.openApiSchemas.listQuery) : DEFAULT_LIST_PARAMS,
723
+ responses: {
724
+ "200": {
725
+ description: "List of items",
726
+ content: { "application/json": { schema: buildPaginatedListSchema(`#/components/schemas/${resource.name}`) } }
727
+ },
728
+ "400": errorResponse("Validation error — bad filter / sort / pagination params")
729
+ }
730
+ }, void 0, additionalSecurity);
731
+ if (!disabledSet.has("create")) collectionPath.post = createOperation(resource, "create", "Create new", {
732
+ requestBody: {
733
+ required: true,
734
+ content: { "application/json": { schema: { $ref: `#/components/schemas/${resource.name}Input` } } }
735
+ },
736
+ responses: {
737
+ "201": {
738
+ description: "Created successfully",
739
+ content: { "application/json": { schema: { $ref: `#/components/schemas/${resource.name}` } } }
740
+ },
741
+ "400": errorResponse("Validation error — request body failed schema validation"),
742
+ "409": errorResponse("Conflict — duplicate key on a unique-indexed field")
743
+ }
744
+ }, void 0, additionalSecurity);
745
+ if (Object.keys(collectionPath).length > 0) paths[basePath] = collectionPath;
746
+ const itemPath = {};
747
+ if (!disabledSet.has("get")) itemPath.get = createOperation(resource, "get", "Get by ID", {
748
+ parameters: [{
749
+ name: "id",
750
+ in: "path",
751
+ required: true,
752
+ schema: { type: "string" }
753
+ }],
754
+ responses: {
755
+ "200": {
756
+ description: "Item found",
757
+ content: { "application/json": { schema: { $ref: `#/components/schemas/${resource.name}` } } }
758
+ },
759
+ "404": errorResponse("Not found")
760
+ }
761
+ }, void 0, additionalSecurity);
762
+ if (!disabledSet.has("update")) {
763
+ const updateOp = createOperation(resource, "update", "Update", {
764
+ parameters: [{
765
+ name: "id",
766
+ in: "path",
767
+ required: true,
768
+ schema: { type: "string" }
769
+ }],
770
+ requestBody: {
771
+ required: true,
772
+ content: { "application/json": { schema: { $ref: `#/components/schemas/${resource.name}Input` } } }
773
+ },
774
+ responses: {
775
+ "200": {
776
+ description: "Updated successfully",
777
+ content: { "application/json": { schema: { $ref: `#/components/schemas/${resource.name}` } } }
778
+ },
779
+ "400": errorResponse("Validation error — request body failed schema validation"),
780
+ "404": errorResponse("Not found"),
781
+ "409": errorResponse("Conflict — duplicate key on a unique-indexed field")
782
+ }
783
+ }, void 0, additionalSecurity);
784
+ if (updateMethod === "both") {
785
+ itemPath.put = updateOp;
786
+ itemPath.patch = updateOp;
787
+ } else if (updateMethod === "PUT") itemPath.put = updateOp;
788
+ else itemPath.patch = updateOp;
789
+ }
790
+ if (!disabledSet.has("delete")) itemPath.delete = createOperation(resource, "delete", "Delete", {
791
+ parameters: [{
792
+ name: "id",
793
+ in: "path",
794
+ required: true,
795
+ schema: { type: "string" }
796
+ }],
797
+ responses: {
798
+ "200": {
799
+ description: "Deleted successfully",
800
+ content: { "application/json": { schema: { $ref: "#/components/schemas/DeleteResult" } } }
801
+ },
802
+ "404": errorResponse("Not found")
803
+ }
804
+ }, void 0, additionalSecurity);
805
+ if (Object.keys(itemPath).length > 0) paths[toOpenApiPath(`${basePath}/:id`)] = itemPath;
806
+ }
807
+ //#endregion
808
+ //#region src/docs/openapi/custom-paths.ts
809
+ /**
810
+ * Append every entry in `resource.customRoutes` to the `paths` map.
811
+ */
812
+ function appendCustomRoutePaths(paths, resource, basePath, additionalSecurity) {
813
+ for (const route of resource.customRoutes || []) {
814
+ const fullPath = toOpenApiPath(`${basePath}${route.path}`);
815
+ const method = route.method.toLowerCase();
816
+ if (!paths[fullPath]) paths[fullPath] = {};
817
+ const handlerName = route.operation ?? (typeof route.handler === "string" ? route.handler : "handler");
818
+ const isPublicRoute = route.permissions?._isPublic === true;
819
+ const requiresAuthForRoute = !!route.permissions && !isPublicRoute;
820
+ const extras = {
821
+ parameters: extractPathParams(route.path),
822
+ responses: { "200": { description: route.description || "Success" } }
823
+ };
824
+ const rawSchema = route.schema;
825
+ const routeSchema = rawSchema ? convertRouteSchema(rawSchema, "openapi-3.0") : void 0;
826
+ if (routeSchema?.body && [
827
+ "post",
828
+ "put",
829
+ "patch"
830
+ ].includes(method)) extras.requestBody = {
831
+ required: true,
832
+ content: { "application/json": { schema: routeSchema.body } }
833
+ };
834
+ if (routeSchema?.querystring) {
835
+ const queryParams = convertSchemaToParameters(routeSchema.querystring);
836
+ extras.parameters = [...extras.parameters || [], ...queryParams];
837
+ }
838
+ if (routeSchema?.response) {
839
+ const responseSchemas = routeSchema.response;
840
+ for (const [statusCode, schema] of Object.entries(responseSchemas)) extras.responses[statusCode] = {
841
+ description: schema.description || `Response ${statusCode}`,
842
+ content: { "application/json": { schema } }
843
+ };
844
+ }
845
+ paths[fullPath][method] = createOperation(resource, handlerName, route.summary ?? handlerName, extras, requiresAuthForRoute, additionalSecurity);
846
+ }
847
+ }
848
+ //#endregion
849
+ //#region src/docs/openapi/paths.ts
850
+ /**
851
+ * Generate the OpenAPI `paths` entries for a single resource.
852
+ */
853
+ function generateResourcePaths(resource, apiPrefix = "", additionalSecurity = []) {
854
+ const paths = {};
855
+ const basePath = `${apiPrefix}${resource.prefix}`;
856
+ appendCrudPaths(paths, resource, basePath, additionalSecurity);
857
+ appendCustomRoutePaths(paths, resource, basePath, additionalSecurity);
858
+ appendActionPaths(paths, resource, basePath, additionalSecurity);
859
+ appendAggregationPaths(paths, resource, basePath, additionalSecurity);
860
+ return paths;
861
+ }
862
+ //#endregion
863
+ //#region src/docs/openapi/index.ts
864
+ const openApiPlugin = async (fastify, opts = {}) => {
865
+ const { title = "Arc API", version = "1.0.0", description, serverUrl, prefix = "/_docs", apiPrefix = "", authRoles = [] } = opts;
866
+ const buildSpec = () => {
867
+ const arc = fastify.arc;
868
+ const resources = arc?.registry?.getAll() ?? [];
869
+ const externalPaths = arc?.externalOpenApiPaths ?? [];
870
+ return buildOpenApiSpec(resources, {
871
+ title,
872
+ version,
873
+ description,
874
+ serverUrl,
875
+ apiPrefix
876
+ }, externalPaths.length > 0 ? externalPaths : void 0);
877
+ };
878
+ fastify.get(`${prefix}/openapi.json`, async (request, reply) => {
879
+ if (authRoles.length > 0) {
880
+ const user = request.user;
881
+ const roles = getUserRoles(user);
882
+ if (!authRoles.some((r) => roles.includes(r)) && !roles.includes("superadmin")) {
883
+ reply.code(403).send({ error: "Access denied" });
884
+ return;
885
+ }
886
+ }
887
+ return buildSpec();
888
+ });
889
+ fastify.log?.debug?.(`OpenAPI spec available at ${prefix}/openapi.json`);
890
+ };
891
+ /**
892
+ * Build OpenAPI spec from registry resources.
893
+ * Shared by HTTP docs endpoint and CLI export command.
894
+ */
895
+ function buildOpenApiSpec(resources, options = {}, externalPaths) {
896
+ const { title = "Arc API", version = "1.0.0", description, serverUrl, apiPrefix = "" } = options;
897
+ const paths = {};
898
+ const tags = [];
899
+ const additionalSecurity = externalPaths?.flatMap((ext) => ext.resourceSecurity ?? []) ?? [];
900
+ for (const resource of resources) {
901
+ const tagDescParts = [`${resource.displayName || resource.name} operations`];
902
+ if (resource.presets && resource.presets.length > 0) tagDescParts.push(`Presets: ${resource.presets.join(", ")}`);
903
+ if (resource.pipelineSteps && resource.pipelineSteps.length > 0) {
904
+ const stepNames = resource.pipelineSteps.map((s) => `${s.type}(${s.name})`);
905
+ tagDescParts.push(`Pipeline: ${stepNames.join(" → ")}`);
906
+ }
907
+ if (resource.events && resource.events.length > 0) tagDescParts.push(`Events: ${resource.events.join(", ")}`);
908
+ tags.push({
909
+ name: resource.tag || resource.name,
910
+ description: tagDescParts.join(". ")
911
+ });
912
+ const resourcePaths = generateResourcePaths(resource, apiPrefix, additionalSecurity);
913
+ Object.assign(paths, resourcePaths);
914
+ }
915
+ if (externalPaths) for (const ext of externalPaths) {
916
+ for (const [path, methods] of Object.entries(ext.paths)) paths[path] = paths[path] ? {
917
+ ...paths[path],
918
+ ...methods
919
+ } : methods;
920
+ if (ext.tags) {
921
+ for (const tag of ext.tags) if (!tags.find((t) => t.name === tag.name)) tags.push(tag);
922
+ }
923
+ }
924
+ const externalSecuritySchemes = externalPaths?.reduce((acc, ext) => ({
925
+ ...acc,
926
+ ...ext.securitySchemes
927
+ }), {}) ?? {};
928
+ const externalSchemas = externalPaths?.reduce((acc, ext) => ({
929
+ ...acc,
930
+ ...ext.schemas
931
+ }), {}) ?? {};
932
+ return {
933
+ openapi: "3.0.3",
934
+ info: {
935
+ title,
936
+ version,
937
+ ...description && { description }
938
+ },
939
+ ...serverUrl && { servers: [{ url: serverUrl }] },
940
+ paths,
941
+ components: {
942
+ schemas: {
943
+ ...generateSchemas(resources),
944
+ ...externalSchemas
945
+ },
946
+ securitySchemes: {
947
+ bearerAuth: {
948
+ type: "http",
949
+ scheme: "bearer",
950
+ bearerFormat: "JWT"
951
+ },
952
+ orgHeader: {
953
+ type: "apiKey",
954
+ in: "header",
955
+ name: "x-organization-id"
956
+ },
957
+ ...externalSecuritySchemes
958
+ }
959
+ },
960
+ tags
961
+ };
962
+ }
963
+ var openapi_default = fp(openApiPlugin, {
964
+ name: "arc-openapi",
965
+ fastify: "5.x"
966
+ });
967
+ //#endregion
968
+ export { openApiPlugin as n, openapi_default as r, buildOpenApiSpec as t };