diff --git a/.changeset/openapi-complete-spec.md b/.changeset/openapi-complete-spec.md new file mode 100644 index 00000000..9f737fe1 --- /dev/null +++ b/.changeset/openapi-complete-spec.md @@ -0,0 +1,14 @@ +--- +"ornn-api": minor +--- + +Make `GET /api/v1/openapi.json` a complete, usable contract (#1214). + +Two defects made the published spec unusable for client generation: + +- **Every schema was empty.** `toSchema` called `zod-to-json-schema@3`, which only understands zod 3 internals and returns `{}` for a zod 4 schema *without throwing*. The document stayed structurally valid while describing nothing: `GET /skill-search` advertised `parameters: []`, and every request and response body was `schema: {}`. `toSchema` now uses zod 4's built-in `z.toJSONSchema`, so property names, types, descriptions, enums, defaults, and numeric bounds all reach the spec. Request and response schemas are generated in opposite directions, so a `.default()` field is correctly optional on the way in and required on the way out. The dependency is removed. +- **Every error response was described wrong.** Errors were documented as `application/json` wrapping the legacy `{ data, error }` envelope, but the API has emitted RFC 7807 `application/problem+json` with fields at the body root since #456. Generated clients read `error.message` and got `undefined`. All error responses now declare the real problem+json body — `type`, `title`, `status`, `detail`, `instance`, `code`, `requestId`, plus per-field `errors[]` on validation failures. + +Coverage goes from 13 documented operations to all 107, including previously undocumented domains: skillsets, versions, dist-tags, closures, diffs, audit, analytics, notifications, announcements, broadcasts, quota, redemption codes, admin settings, LLM providers, `/me/*`, `/users/*`, permissions, ownership transfer, the GitHub mirror, and the K8s probes. Every operation now carries a summary, an integrator-facing description, a unique `operationId`, tags, an explicit security declaration, described parameters with schemas and examples, and its full set of error responses. + +The spec is now assembled from one module per domain under `src/openapi/paths/`, deriving schemas from the same Zod definitions the handlers validate against. Contract tests enforce both directions against the booted router — no documented endpoint the API does not serve, and no served endpoint the spec does not document — with no allowlist, so the coverage gap cannot silently reopen. diff --git a/.github/release-notes-20260807.md b/.github/release-notes-20260807.md new file mode 100644 index 00000000..8dde3a88 --- /dev/null +++ b/.github/release-notes-20260807.md @@ -0,0 +1,17 @@ +## Fixed + +- The API reference published empty schemas for every request and response +- Skill search documented none of its query parameters +- Error responses were described with the wrong body shape +- Skill download docs showed a version-tag form that fails +- Few technical bugs fixed + +## New Feature + +- The machine-readable API reference now covers every endpoint +- Fetching the API reference supports conditional requests and caching + +## Changed + +- API reference descriptions rewritten for agent developers +- Technical enhancement diff --git a/bun.lock b/bun.lock index fe322d78..6deeb38b 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,6 @@ "posthog-node": "^5.38.6", "yaml": "^2.9.0", "zod": "^4.4.3", - "zod-to-json-schema": "^3.25.1", }, "devDependencies": { "@types/bun": "latest", @@ -1512,8 +1511,6 @@ "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], - "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], "zustand": ["zustand@5.0.14", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g=="], diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md index fadf84be..5533a88e 100644 --- a/docs/CONVENTIONS.md +++ b/docs/CONVENTIONS.md @@ -529,12 +529,43 @@ Every response carries: ## 10. OpenAPI -- `GET /v1/openapi.json` is the source of truth. +- `GET /api/v1/openapi.json` is the source of truth. It is generated at server boot from the same Zod schemas the handlers validate against, so it cannot drift from the running API. - Every route declares security, request content types, all documented error responses, and at least one example. -- CI contract test asserts every handler in code appears in the spec with complete metadata. - Error `type` URLs point to live documentation per § 1.6. -### 10.1 Skill manifest JSON Schema +### 10.1 Where the spec lives + +``` +ornn-api/src/openapi/ +├── helpers.ts shared building blocks — the ONLY sanctioned way to +│ declare a response, a parameter, or a request body +├── specBuilder.ts document assembly: info, tags, security schemes +└── paths/.ts one module per domain, exporting (prefix) => PathMap +``` + +**Adding a route means adding it to the matching `paths/` module in the same change.** There is no allowlist to opt out of; CI fails otherwise (§ 10.2). + +Two rules keep the document honest: + +- **Schemas are derived, not transcribed.** Import the domain's Zod schema and pass it to `toSchema` / `jsonBody` / `queryParams` / `jsonResponse`. Hand-written JSON Schema is a last resort, permitted only where no Zod schema exists, and must name the TypeScript interface it mirrors. +- **`toSchema` uses zod 4's built-in `z.toJSONSchema`.** Do **not** reintroduce `zod-to-json-schema`: that package only understands zod 3 internals and returns `{}` for every zod 4 schema *without throwing*. It silently emptied every schema and parameter list in the published document (#1214). `helpers.test.ts` and `tests/contract/openapi.test.ts` both assert schemas are non-empty. + +Request and response schemas are generated in different directions. A field with a `.default()` is optional on the way in and guaranteed on the way out, so `jsonBody` / `queryParams` use `io: "input"` and `jsonResponse` uses `io: "output"`. Response schemas are emitted without `additionalProperties: false` so adding a field server-side does not break clients validating against a cached spec. + +### 10.2 Enforced invariants + +`ornn-api/tests/contract/` asserts, against the **booted router**, in both directions: + +| Invariant | Meaning | Test | +|---|---|---| +| documented ⇒ registered | The spec never advertises an endpoint the API does not serve. | `openapiRoutes.test.ts` | +| registered ⇒ documented | The API never serves an endpoint the spec does not describe. | `openapiRoutes.test.ts` | + +`openapi.test.ts` additionally requires, per operation: a summary, a description of real substance, a unique `operationId`, at least one declared tag (itself declared at the document root), an explicit `security` declaration, every templated path parameter declared, a description and non-empty schema on every parameter, at least one 2xx with content, at least one error response, and every 4xx/5xx typed as `application/problem+json` with RFC 7807 fields at the body root. + +Both directions matter. Before #1214 only the first was enforced, and the document decayed to describing 13 of 104 routes while still passing CI. + +### 10.3 Skill manifest JSON Schema The canonical JSON Schema for `SKILL.md` YAML frontmatter is published at: diff --git a/ornn-api/package.json b/ornn-api/package.json index 701382b2..db2854af 100644 --- a/ornn-api/package.json +++ b/ornn-api/package.json @@ -24,8 +24,7 @@ "pino-pretty": "^13.1.3", "posthog-node": "^5.38.6", "yaml": "^2.9.0", - "zod": "^4.4.3", - "zod-to-json-schema": "^3.25.1" + "zod": "^4.4.3" }, "devDependencies": { "@types/bun": "latest", diff --git a/ornn-api/src/bootstrap.ts b/ornn-api/src/bootstrap.ts index 49fadb03..489bdb9b 100644 --- a/ornn-api/src/bootstrap.ts +++ b/ornn-api/src/bootstrap.ts @@ -11,6 +11,7 @@ import type { ContentfulStatusCode } from "hono/utils/http-status"; import { cors } from "hono/cors"; import { join } from "node:path"; import { readFileSync } from "node:fs"; +import { createHash } from "node:crypto"; import pino from "pino"; import { type SkillConfig } from "./infra/config"; @@ -1127,7 +1128,23 @@ export async function bootstrap( serverUrl: config.ornnApiBaseUrl, version: pkg.version, }); - app.get("/api/v1/openapi.json", (c) => c.json(spec)); + // Serialize once at boot. The document is static for the lifetime of the + // process, and now that it describes all 107 operations it is ~1 MB — so + // `c.json(spec)` would re-run JSON.stringify over the whole thing on every + // request. `ETag` + `Cache-Control` per CONVENTIONS.md §8, which lists this + // endpoint as public and cacheable; a conditional request costs a hash + // comparison instead of a megabyte of transfer. + const specJson = JSON.stringify(spec); + const specEtag = `"${createHash("sha256").update(specJson).digest("hex").slice(0, 32)}"`; + logger.info({ bytes: specJson.length, etag: specEtag }, "OpenAPI spec built"); + app.get("/api/v1/openapi.json", (c) => { + if (c.req.header("if-none-match") === specEtag) return c.body(null, 304); + return c.body(specJson, 200, { + "Content-Type": "application/json; charset=UTF-8", + "Cache-Control": "public, max-age=300", + ETag: specEtag, + }); + }); // Kubernetes liveness probe — process is alive. No dependency checks. // `/health` kept as an alias for backward compatibility; K8s manifests diff --git a/ornn-api/src/openapi/helpers.test.ts b/ornn-api/src/openapi/helpers.test.ts new file mode 100644 index 00000000..aa2375c2 --- /dev/null +++ b/ornn-api/src/openapi/helpers.test.ts @@ -0,0 +1,262 @@ +/** + * Unit tests for the OpenAPI helper layer (#1214). + * + * The single most important assertion in this file is that `toSchema` + * produces a populated schema. Its predecessor — `zod-to-json-schema@3` + * against zod 4 schemas — returned `{}` for everything without throwing, + * so the spec stayed structurally valid while describing nothing at all. + * Nothing caught it because nothing asserted on schema *content*. + * + * @module openapi/helpers.test + */ + +import { describe, expect, test } from "bun:test"; +import { z } from "zod"; +import { + bearerAuth, + binaryResponse, + envelope, + jsonBody, + jsonResponse, + noContentResponse, + optionalAuth, + pathParam, + problemResponses, + publicAuth, + queryParam, + queryParams, + rawJsonResponse, + sseResponse, + toSchema, + zipBody, +} from "./helpers"; + +const sample = z.object({ + q: z.string().describe("Search text"), + limit: z.coerce.number().int().min(1).max(100).default(20).describe("Page size"), + scope: z.enum(["public", "private", "mixed"]).optional().describe("Visibility filter"), +}); + +describe("toSchema", () => { + test("emits properties, types, and descriptions from a zod 4 schema", () => { + const schema = toSchema(sample) as { + type?: string; + properties?: Record; + }; + expect(schema.type).toBe("object"); + expect(Object.keys(schema.properties ?? {})).toEqual(["q", "limit", "scope"]); + expect(schema.properties!.q!.type).toBe("string"); + expect(schema.properties!.q!.description).toBe("Search text"); + }); + + test("preserves numeric bounds, defaults, and enum members", () => { + const schema = toSchema(sample) as { + properties: Record>; + }; + expect(schema.properties.limit!.minimum).toBe(1); + expect(schema.properties.limit!.maximum).toBe(100); + expect(schema.properties.limit!.default).toBe(20); + expect(schema.properties.scope!.enum).toEqual(["public", "private", "mixed"]); + }); + + test("input direction leaves defaulted fields optional; output requires them", () => { + // A caller may omit `limit` (the server fills it in), but every + // response carries it. Conflating the two mislabels half the fields + // in a generated client. + const input = toSchema(sample, "input") as { required?: string[] }; + const output = toSchema(sample, "output") as { required?: string[] }; + expect(input.required).toEqual(["q"]); + expect(output.required).toEqual(["q", "limit"]); + }); + + test("strips $schema, which is not valid inside an OpenAPI Schema Object", () => { + expect(toSchema(sample).$schema).toBeUndefined(); + }); + + test("response schemas stay open to new fields", () => { + // `additionalProperties: false` on a response is hostile to forward + // compatibility: adding a field server-side would fail strict client + // validation against a cached spec. + expect(JSON.stringify(toSchema(sample, "output"))).not.toContain("additionalProperties"); + }); + + test("does not throw on types with no JSON Schema equivalent", () => { + const withDate = z.object({ at: z.date().describe("timestamp") }); + expect(() => toSchema(withDate)).not.toThrow(); + }); + + test("inlines a schema reused in two places rather than emitting $defs", () => { + const inner = z.object({ name: z.string() }); + const json = JSON.stringify(toSchema(z.object({ a: inner, b: inner }))); + expect(json).not.toContain("$defs"); + expect(json).not.toContain("$ref"); + }); +}); + +describe("success responses", () => { + test("envelope wraps the payload in { data, error }", () => { + const wrapped = envelope({ type: "string" }) as { + required: string[]; + properties: Record; + }; + expect(wrapped.required).toEqual(["data", "error"]); + expect(wrapped.properties.data).toEqual({ type: "string" }); + }); + + test("jsonResponse defaults to 200 and honours an explicit status", () => { + expect(Object.keys(jsonResponse(sample, "ok"))).toEqual(["200"]); + expect(Object.keys(jsonResponse(sample, "created", { status: 201 }))).toEqual(["201"]); + }); + + test("jsonResponse nests the payload schema under data and never leaves it empty", () => { + const response = jsonResponse(sample, "ok") as Record< + string, + { content: Record } } }> } + >; + const data = response["200"]!.content["application/json"]!.schema.properties.data; + expect(Object.keys(data.properties as object).length).toBeGreaterThan(0); + }); + + test("jsonResponse wraps a supplied example in the envelope", () => { + const response = jsonResponse(sample, "ok", { example: { q: "pdf" } }) as Record< + string, + { content: Record } + >; + expect(response["200"]!.content["application/json"]!.example).toEqual({ + data: { q: "pdf" }, + error: null, + }); + }); + + test("rawJsonResponse does not envelope, and honours a media type", () => { + const response = rawJsonResponse({ type: "object" }, "schema doc", { + mediaType: "application/schema+json", + }) as Record }> }>; + const content = response["200"]!.content["application/schema+json"]!; + expect(content.schema).toEqual({ type: "object" }); + }); + + test("binaryResponse and noContentResponse produce the expected shapes", () => { + const binary = binaryResponse("the ZIP", "application/zip") as Record< + string, + { content: Record } + >; + expect(binary["200"]!.content["application/zip"]!.schema.format).toBe("binary"); + expect(Object.keys(noContentResponse("deleted"))).toEqual(["204"]); + }); + + test("sseResponse lists the event vocabulary in its description", () => { + const response = sseResponse("Generation stream", ["token", "error"]) as Record< + string, + { description: string } + >; + expect(response["200"]!.description).toContain("`token`"); + expect(response["200"]!.description).toContain("`error`"); + }); +}); + +describe("problemResponses", () => { + test("uses application/problem+json with RFC 7807 fields at the body root", () => { + const responses = problemResponses(404) as Record< + string, + { content: Record } }> } + >; + const schema = responses["404"]!.content["application/problem+json"]!.schema; + for (const field of ["type", "title", "status", "detail", "instance", "code", "requestId"]) { + expect(schema.properties[field]).toBeDefined(); + } + // The legacy envelope must not come back — clients would read error + // fields one level too deep. + expect(schema.properties.data).toBeUndefined(); + }); + + test("accepts bare codes and per-operation detail overrides together", () => { + const responses = problemResponses(401, { 409: "A skill with this name already exists." }) as Record< + string, + { description: string } + >; + expect(Object.keys(responses).sort()).toEqual(["401", "409"]); + expect(responses["409"]!.description).toBe("A skill with this name already exists."); + expect(responses["401"]!.description).toContain("Unauthorized"); + }); + + test("every generated error response carries a description", () => { + const responses = problemResponses(400, 403, 404, 500) as Record; + for (const body of Object.values(responses)) expect(body.description.length).toBeGreaterThan(0); + }); +}); + +describe("security helpers", () => { + test("distinguish required, optional, and absent authentication", () => { + expect(bearerAuth()).toEqual([{ BearerAuth: [] }]); + // `{}` is the OpenAPI idiom for "no security is also acceptable". + expect(optionalAuth()).toEqual([{}, { BearerAuth: [] }]); + // `[]` disables inherited security rather than omitting the key. + expect(publicAuth()).toEqual([]); + }); +}); + +describe("parameters", () => { + test("queryParams expands a zod object into described query parameters", () => { + const params = queryParams(sample) as Array<{ + name: string; + in: string; + required: boolean; + description?: string; + schema: Record; + }>; + expect(params.map((p) => p.name)).toEqual(["q", "limit", "scope"]); + expect(params.every((p) => p.in === "query")).toBe(true); + expect(params.every((p) => p.description !== undefined)).toBe(true); + expect(params.every((p) => Object.keys(p.schema).length > 0)).toBe(true); + }); + + test("queryParams marks only zod-required fields as required", () => { + const params = queryParams(sample) as Array<{ name: string; required: boolean }>; + expect(params.find((p) => p.name === "q")!.required).toBe(true); + // Defaulted and optional fields are both optional for the caller. + expect(params.find((p) => p.name === "limit")!.required).toBe(false); + expect(params.find((p) => p.name === "scope")!.required).toBe(false); + }); + + test("pathParam is always required and carries its description", () => { + const param = pathParam("idOrName", "Skill UUID or unique name", { type: "string" }, "web-summarizer"); + expect(param.required).toBe(true); + expect(param.in).toBe("path"); + expect(param.example).toBe("web-summarizer"); + expect(param.description).toBe("Skill UUID or unique name"); + }); + + test("queryParam defaults to an optional string", () => { + const param = queryParam("cursor", "Opaque pagination cursor"); + expect(param.required).toBe(false); + expect(param.schema).toEqual({ type: "string" }); + }); +}); + +describe("request bodies", () => { + test("jsonBody is required by default and describes itself", () => { + const body = jsonBody(sample, "The search request") as { + required: boolean; + description: string; + content: Record } }>; + }; + expect(body.required).toBe(true); + expect(body.description).toBe("The search request"); + expect(Object.keys(body.content["application/json"]!.schema.properties).length).toBeGreaterThan(0); + }); + + test("jsonBody uses the input direction, so defaulted fields stay optional", () => { + const body = jsonBody(sample, "d") as { + content: Record; + }; + expect(body.content["application/json"]!.schema.required).toEqual(["q"]); + }); + + test("zipBody declares a binary application/zip payload", () => { + const body = zipBody("The skill package") as { + content: Record; + }; + expect(body.content["application/zip"]!.schema.format).toBe("binary"); + }); +}); diff --git a/ornn-api/src/openapi/helpers.ts b/ornn-api/src/openapi/helpers.ts new file mode 100644 index 00000000..2ac95323 --- /dev/null +++ b/ornn-api/src/openapi/helpers.ts @@ -0,0 +1,479 @@ +/** + * Shared building blocks for the OpenAPI 3.1 spec (#1214). + * + * Every per-domain path module under `openapi/paths/` composes its + * operations from these helpers so the whole spec speaks one dialect: + * one success envelope, one RFC 7807 error shape, one auth scheme, one + * way of turning a Zod schema into query parameters. + * + * Two wire shapes matter and they are NOT the same: + * + * - success (2xx) — `{ data: T, error: null }` (CONVENTIONS.md §1.2) + * - failure (4xx/5xx) — RFC 7807 fields at the body root, served as + * `application/problem+json` (CONVENTIONS.md §1.3, #456) + * + * The pre-#1214 builder described errors with the legacy `{ data, error }` + * envelope under `application/json`, which no longer matches what + * `app.onError` in bootstrap.ts emits. Generated clients built from that + * spec parsed error bodies at the wrong depth. `problemResponses()` is + * the fix — it is the only sanctioned way to declare a non-2xx response. + * + * @module openapi/helpers + */ + +import { z, type ZodTypeAny } from "zod"; + +export type JsonSchema = Record; +export type Operation = Record; +export type PathItem = Record; +export type PathMap = Record; + +// --------------------------------------------------------------------------- +// Zod → JSON Schema +// --------------------------------------------------------------------------- + +/** + * Which side of the wire a schema describes. + * + * `input` — what a client sends. Fields carrying a Zod `.default()` are + * optional, because the client may omit them. + * `output` — what the server sends back. Defaulted fields are always + * present, so they are required. + */ +export type SchemaDirection = "input" | "output"; + +/** + * Convert a Zod schema to an inline JSON Schema for embedding in the spec. + * + * Uses Zod 4's first-party `z.toJSONSchema`. The previous implementation + * called `zod-to-json-schema@3`, which only understands Zod 3 internals: + * against this codebase's Zod 4 schemas it returned `{}` for *every* + * schema without erroring. The published spec consequently advertised + * `parameters: []` for `GET /skill-search` and `schema: {}` for every + * request and response body — the concrete reason integrators reported + * that parameters and field descriptions were missing. Do not reintroduce + * that dependency — `helpers.test.ts` and the "schema generation is not + * silently empty" block in `tests/contract/openapi.test.ts` both pin + * non-empty output. + * + * OpenAPI 3.1 is a superset of JSON Schema draft 2020-12, so the emitted + * schemas are valid Schema Objects as-is. + * + * One sharp edge worth knowing: a schema ending in `.transform()` is a + * `ZodPipe`, and only its **input** side has a JSON Schema representation. + * `direction: "output"` yields `{}` for such a field. That is correct for + * request bodies and query strings (which are input) and a trap for + * responses — if a response schema ever ends in a transform, describe the + * emitted shape by hand rather than publishing an empty object. The + * "no empty schema" assertions in `tests/contract/openapi.test.ts` catch + * it if anyone tries. + */ +export function toSchema(zodSchema: ZodTypeAny, direction: SchemaDirection = "output"): JsonSchema { + const result = z.toJSONSchema(zodSchema, { + target: "draft-2020-12", + io: direction, + // Emit `{}` for types with no JSON Schema equivalent (z.date, z.bigint, + // z.custom) instead of throwing and taking the whole spec down. + unrepresentable: "any", + // Inline a schema used in several places rather than hoisting it into + // `$defs`. Keeps every operation self-contained: no pointer chasing for + // a human reader, and no `$ref`-resolution bugs in third-party + // generators. True cycles still fall back to `$ref` — they must. + reused: "inline", + }) as JsonSchema; + + // `$schema` is meaningful in a standalone JSON Schema document but is not + // a valid key inside an OpenAPI Schema Object. + delete result.$schema; + + // `io: "output"` stamps `additionalProperties: false` on every object. + // That is accurate today but hostile to clients tomorrow: any field we + // add server-side would fail strict client-side validation against a + // cached spec. Responses are documented as open, which is what an + // evolving API contract should promise. + if (direction === "output") stripAdditionalPropertiesFalse(result); + + return result; +} + +/** Recursively drop `additionalProperties: false`. See `toSchema`. */ +function stripAdditionalPropertiesFalse(node: unknown): void { + if (Array.isArray(node)) { + for (const child of node) stripAdditionalPropertiesFalse(child); + return; + } + if (typeof node !== "object" || node === null) return; + const obj = node as Record; + if (obj.additionalProperties === false) delete obj.additionalProperties; + for (const value of Object.values(obj)) stripAdditionalPropertiesFalse(value); +} + +// --------------------------------------------------------------------------- +// Success responses +// --------------------------------------------------------------------------- + +/** + * Wrap a payload schema in the standard success envelope. + * + * Built structurally rather than with a Zod combinator so callers can + * pass either a Zod schema or a hand-written JSON Schema fragment. + */ +export function envelope(data: JsonSchema): JsonSchema { + return { + type: "object", + required: ["data", "error"], + properties: { + data, + error: { + type: "null", + description: "Always null on a 2xx response. Failures use the RFC 7807 body instead.", + }, + }, + }; +} + +export interface JsonResponseOptions { + /** HTTP status. Defaults to 200; creates should pass 201. */ + readonly status?: number; + /** Example of the `data` payload — embedded inside the envelope. */ + readonly example?: unknown; + /** Response headers worth documenting (e.g. `ETag`, `Cache-Control`). */ + readonly headers?: Record; +} + +/** + * Declare a JSON success response whose body is the standard envelope. + * `schema` describes the `data` payload only — the envelope is added here. + */ +export function jsonResponse( + schema: ZodTypeAny | JsonSchema, + description: string, + options: JsonResponseOptions = {}, +): Record { + const dataSchema = isZod(schema) ? toSchema(schema) : schema; + const content: Record = { schema: envelope(dataSchema) }; + if (options.example !== undefined) { + content.example = { data: options.example, error: null }; + } + const response: Record = { + description, + content: { "application/json": content }, + }; + if (options.headers) response.headers = options.headers; + return { [String(options.status ?? 200)]: response }; +} + +/** + * Declare a JSON success response whose body is NOT enveloped — the + * payload sits at the body root. Only for endpoints that deliberately + * opt out (e.g. the SKILL.md manifest JSON Schema, which external + * schema-store tooling consumes raw). + */ +export function rawJsonResponse( + schema: JsonSchema, + description: string, + options: { status?: number; mediaType?: string; headers?: Record } = {}, +): Record { + const response: Record = { + description, + content: { [options.mediaType ?? "application/json"]: { schema } }, + }; + if (options.headers) response.headers = options.headers; + return { [String(options.status ?? 200)]: response }; +} + +/** Declare a binary (file download) success response. */ +export function binaryResponse( + description: string, + mediaType = "application/octet-stream", + headers?: Record, +): Record { + const response: Record = { + description, + content: { [mediaType]: { schema: { type: "string", format: "binary" } } }, + }; + if (headers) response.headers = headers; + return { 200: response }; +} + +/** + * Declare a Server-Sent Events response. + * + * `events` documents the discriminated event names the stream can emit. + * SSE has no schema language in OpenAPI, so the event vocabulary lives + * in the description where a human or an agent will actually read it. + */ +/** + * How a stream lays out its SSE frames. The two surfaces genuinely differ, + * and a client that dispatches on the wrong one silently receives nothing: + * + * - `data-only` — payload frames carry no `event:` line, so the consumer + * must dispatch on the JSON body's own `type` field. Used by the + * generation and playground streams. + * - `named-events` — each frame carries both an `event:` line and the JSON + * `data:` line, so `EventSource.addEventListener()` works. Used by + * the assistant stream. + */ +export type SseFrameStyle = "data-only" | "named-events"; + +const FRAME_STYLE_TEXT: Record = { + "data-only": + "SSE frame stream. Payload frames are `data: \\n\\n` with **no** `event:` line — dispatch on the JSON body's own `type` field, not on an event name. Keep-alive frames are sent periodically to hold the connection open and MUST be ignored.", + "named-events": + "SSE frame stream. Each payload frame carries both an `event: ` line and a `data: ` line, so either `EventSource.addEventListener()` or dispatching on the JSON `type` field works. Comment frames (`: keepalive`) are sent periodically to hold the connection open and MUST be ignored.", +}; + +export function sseResponse( + description: string, + events: readonly string[] = [], + frameStyle: SseFrameStyle = "data-only", +): Record { + const eventList = events.length > 0 + ? ` Event types: ${events.map((e) => `\`${e}\``).join(", ")}.` + : ""; + return { + 200: { + description: `${description}${eventList}`, + content: { + "text/event-stream": { + schema: { type: "string", description: FRAME_STYLE_TEXT[frameStyle] }, + }, + }, + }, + }; +} + +/** Declare a 204 No Content success response. */ +export function noContentResponse(description: string): Record { + return { 204: { description } }; +} + +// --------------------------------------------------------------------------- +// Error responses (RFC 7807) +// --------------------------------------------------------------------------- + +/** + * The RFC 7807 body emitted by `app.onError` in bootstrap.ts. Kept in + * lockstep with `ProblemJsonBody` in `shared/types/index.ts` — if that + * interface changes, this must change with it. + */ +export const problemJsonSchema: JsonSchema = { + type: "object", + required: ["type", "title", "status", "detail", "instance", "code", "requestId"], + properties: { + type: { + type: "string", + format: "uri", + description: "URI identifying the error class. Dereference for documentation on this error.", + }, + title: { + type: "string", + description: "Short, human-readable summary of the error class. Stable per status code.", + }, + status: { + type: "integer", + description: "HTTP status code, repeated in the body so it survives logging and proxying.", + }, + detail: { + type: "string", + description: "Human-readable explanation specific to this occurrence. Safe to surface to end users.", + }, + instance: { + type: "string", + description: "Request path that produced the error.", + }, + code: { + type: "string", + description: + "Machine-readable error code (e.g. `skill_not_found`, `validation_error`). Branch on this, never on `detail`.", + }, + requestId: { + type: ["string", "null"], + description: "Correlation id, echoed in the `X-Request-ID` response header. Quote it in bug reports.", + }, + }, +}; +// Deliberately NOT documented here: an `errors[]` array. `ProblemJsonBody` +// in `shared/types/index.ts` declares the field as optional, but nothing +// populates it — `buildProblemJsonBody` never sets it, and `validateBody` +// flattens the Zod issues into `detail` as `: ` pairs joined +// with "; " (middleware/validate.ts `formatIssues`). Documenting a field the +// server never emits is what sent integrators looking for it in the first +// place. If per-field errors are ever emitted for real, add the property +// here and in `buildProblemJsonBody` in the same change. + +/** + * Default `detail` copy per status. Domain modules override these via + * `problemResponses({ 404: "..." })` when the generic wording would lose + * information an integrator needs. + */ +const DEFAULT_PROBLEM_DESCRIPTIONS: Record = { + 400: "Bad request — the body, query, or path failed validation. `detail` names the rejected fields as `: ` pairs joined with `; `.", + 401: "Unauthorized — missing, expired, or invalid bearer token. Obtain a fresh token from NyxID and retry.", + 403: "Forbidden — authenticated, but the caller lacks the permission or ownership this operation requires.", + 404: "Not found — no such resource, or it exists but is not visible to this caller. Private resources return 404 rather than 403 so their existence is not leaked.", + 405: "Method not allowed for this path.", + 409: "Conflict — the request collides with existing state (e.g. duplicate name, concurrent modification).", + 410: "Gone — the resource existed but has been permanently removed.", + 413: "Payload too large — the upload exceeds the server's configured maximum package size.", + 415: "Unsupported media type — send one of the `Content-Type` values this operation declares.", + 422: "Unprocessable — syntactically valid but semantically rejected.", + 429: "Rate limited — too many requests. Back off and retry; consult `Retry-After` when present.", + 500: "Internal server error — unexpected failure. Retry with backoff; quote `requestId` if it persists.", + 502: "Bad gateway — an upstream dependency returned an invalid response.", + 503: "Service unavailable — a required dependency (LLM gateway, storage, sandbox) is down or unconfigured. Retry with backoff.", + 504: "Gateway timeout — an upstream dependency did not respond in time.", +}; + +/** + * Declare one or more RFC 7807 error responses. + * + * Accepts either bare status codes or a `{ status: description }` map so + * a domain can explain *why* this particular operation returns a 409 + * without losing the shared body schema: + * + * problemResponses(401, 404) + * problemResponses(400, { 409: "A skill with this name already exists." }) + */ +export function problemResponses( + ...codes: Array> +): Record { + const map: Record = {}; + const add = (code: number, description?: string): void => { + map[String(code)] = { + description: description ?? DEFAULT_PROBLEM_DESCRIPTIONS[code] ?? `Error ${code}.`, + content: { + "application/problem+json": { schema: problemJsonSchema }, + }, + }; + }; + for (const entry of codes) { + if (typeof entry === "number") add(entry); + else for (const [code, description] of Object.entries(entry)) add(Number(code), description); + } + return map; +} + +// --------------------------------------------------------------------------- +// Security +// --------------------------------------------------------------------------- + +/** Operation requires a NyxID bearer token. */ +export function bearerAuth(): Record[] { + return [{ BearerAuth: [] }]; +} + +/** + * Operation is reachable without credentials but returns a richer or + * wider result when a token is supplied (visibility-scoped listings). + * `{}` is the OpenAPI idiom for "no security is also acceptable". + */ +export function optionalAuth(): Record[] { + return [{}, { BearerAuth: [] }]; +} + +/** Operation is unauthenticated by design. `[]` disables inherited security. */ +export function publicAuth(): Record[] { + return []; +} + +// --------------------------------------------------------------------------- +// Parameters +// --------------------------------------------------------------------------- + +/** + * Expand an object Zod schema into an array of `in: query` parameters, + * preserving per-field `.describe()` text, defaults, and enums, and + * marking as required exactly the fields Zod marks required. + */ +export function queryParams(schema: ZodTypeAny): unknown[] { + // Request side: a field with a `.default()` is optional for the caller. + const jsonSchema = toSchema(schema, "input") as { + properties?: Record; + required?: string[]; + }; + if (!jsonSchema.properties) return []; + const required = new Set(jsonSchema.required ?? []); + return Object.entries(jsonSchema.properties).map(([name, prop]) => { + const { description, ...rest } = prop as { description?: string } & JsonSchema; + const param: Record = { + name, + in: "query", + required: required.has(name), + schema: description === undefined ? rest : { ...rest, description }, + }; + if (description !== undefined) param.description = description; + return param; + }); +} + +/** A single hand-written query parameter. */ +export function queryParam( + name: string, + description: string, + schema: JsonSchema = { type: "string" }, + required = false, +): Record { + return { name, in: "query", required, description, schema }; +} + +/** A path parameter. Path params are always required per the OpenAPI spec. */ +export function pathParam( + name: string, + description: string, + schema: JsonSchema = { type: "string" }, + example?: unknown, +): Record { + const param: Record = { name, in: "path", required: true, description, schema }; + if (example !== undefined) param.example = example; + return param; +} + +/** A header parameter. */ +export function headerParam( + name: string, + description: string, + required = false, + schema: JsonSchema = { type: "string" }, +): Record { + return { name, in: "header", required, description, schema }; +} + +// --------------------------------------------------------------------------- +// Request bodies +// --------------------------------------------------------------------------- + +/** A required `application/json` request body described by a Zod schema. */ +export function jsonBody( + schema: ZodTypeAny | JsonSchema, + description: string, + options: { required?: boolean; example?: unknown } = {}, +): Record { + const content: Record = { + // Request side: `.default()` fields are optional for the caller. + schema: isZod(schema) ? toSchema(schema, "input") : schema, + }; + if (options.example !== undefined) content.example = options.example; + return { + required: options.required ?? true, + description, + content: { "application/json": content }, + }; +} + +/** An `application/zip` binary request body (skill package upload). */ +export function zipBody(description: string): Record { + return { + required: true, + description, + content: { "application/zip": { schema: { type: "string", format: "binary" } } }, + }; +} + +// --------------------------------------------------------------------------- +// Internals +// --------------------------------------------------------------------------- + +function isZod(value: ZodTypeAny | JsonSchema): value is ZodTypeAny { + return typeof (value as { safeParse?: unknown }).safeParse === "function"; +} diff --git a/ornn-api/src/openapi/paths/account.ts b/ornn-api/src/openapi/paths/account.ts new file mode 100644 index 00000000..7338fb30 --- /dev/null +++ b/ornn-api/src/openapi/paths/account.ts @@ -0,0 +1,1036 @@ +/** + * Account domain — everything that describes *the caller* (#1214). + * + * Thirteen operations, all caller-scoped, all bearer-authenticated. An + * agent integrating Ornn touches this domain in four situations: + * + * 1. **Identity bootstrap** — `GET /me` answers "who am I, and which + * permissions does my token carry", which decides whether the + * `/admin/*` surface is reachable at all. `GET /me/orgs` (+ the + * `GET /me/orgs/{orgId}` back-fill lookup) answers "which NyxID + * orgs can I share a skill with". + * 2. **Budget** — `GET /me/quota` is the pre-flight check before any + * billed call (playground chat, skill generation, assistant chat). + * Those surfaces reserve a unit *before* the LLM call, so an agent + * that reads `remaining === 0` should stop rather than eat a 429. + * 3. **Top-up** — `POST /me/redemption-codes/redeem` converts an + * admin-issued code into quota grants on the caller's current-month + * buckets; `GET /me/redemption-codes/history` and + * `GET /me/launch-promo` explain what has already been granted. + * 4. **Execution parameters** — `GET /me/models` is the only + * non-admin way to discover which `model` values the playground / + * skill-gen / assistant surfaces will accept. + * + * Two ancillary aggregations (`/me/skills/grants-summary`, + * `/me/shared-skills/sources-summary`) and two telemetry pings + * (`/activity/login`, `/activity/logout`) round the domain out. + * + * Ornn is not the source of truth for identity: everything here is + * derived from the NyxID identity token the proxy forwards, or proxied + * to NyxID on the caller's behalf. Several reads fail **soft** — when + * NyxID is unreachable they answer `[]` instead of 5xx — which is + * called out per-operation because an agent must not read an empty list + * as "the caller definitively has none". + * + * Response payloads here are hand-written JSON Schema because they have + * no Zod source at all — these handlers project them inline + * (`c.json({ data: {...} })`) off domain TypeScript interfaces + * (`QuotaSnapshot`, `LaunchPromoStatus`, `OrgMembershipFact`, …). The one + * request body that *does* have a Zod source — `redeemSchema` in + * `domains/redemption-codes/types.ts` — is generated from it rather than + * transcribed, so its bounds cannot drift from the validator. + * + * @module openapi/paths/account + */ + +import { + bearerAuth, + headerParam, + jsonBody, + jsonResponse, + pathParam, + problemResponses, + queryParam, + toSchema, + type JsonSchema, + type PathMap, +} from "../helpers"; +import { redeemSchema } from "../../domains/redemption-codes/types"; + +/** + * Request body for the redeem endpoint, generated from the runtime + * validator so `minLength` / `maxLength` cannot drift, then overlaid with + * the prose the Zod schema has no room for. + * + * `redeemSchema` ends in a `.transform()`. That matters: under zod 4 a + * transform is a `ZodPipe`, and only the **input** side has a JSON Schema + * representation — `toSchema(redeemSchema, "output")` would yield `{}` for + * `code`. `jsonBody` uses the input direction, which is both correct here + * and the reason this works at all. + */ +const redeemBodySchema: JsonSchema = (() => { + const generated = toSchema(redeemSchema, "input") as JsonSchema & { + properties?: Record; + }; + return { + ...generated, + properties: { + ...generated.properties, + code: { + ...(generated.properties?.code ?? {}), + description: + "The redemption code. Trimmed and upper-cased server-side before lookup, so any casing is accepted. Minted codes are 16 characters drawn from the ambiguity-free alphabet `ABCDEFGHJKMNPQRSTUVWXYZ23456789` (no `0`, `O`, `1`, `I`, `L`), but the field accepts up to 64 characters so future formats do not break clients.", + examples: ["K7M2QX9RTVBN4PZ3"], + }, + }, + }; +})(); + +// --------------------------------------------------------------------------- +// Payload schemas +// --------------------------------------------------------------------------- + +const identitySchema: JsonSchema = { + type: "object", + required: ["userId", "email", "displayName", "roles", "permissions"], + properties: { + userId: { + type: "string", + description: + "Stable NyxID user id (the identity token's `sub` claim). This is the value every ownership check, grant list, and `authorUserId` field in the rest of the API compares against.", + }, + email: { + type: "string", + description: + "Caller's email. Empty string when the identity token carried no `email` claim — do not assume it is populated.", + }, + displayName: { + type: "string", + description: + "Human label, from the token's `name` claim, falling back to `email`. **Can be an empty string** — when the token carries neither claim there is no further fallback (the `userId` fallback in the code is unreachable, because the missing email has already been normalised to `\"\"`). Render your own fallback to `userId` rather than trusting this field to be populated.", + }, + roles: { + type: "array", + items: { type: "string" }, + description: + "NyxID role names on the token. Informational only — authorization is decided by `permissions`, never by this list.", + }, + permissions: { + type: "array", + items: { type: "string" }, + description: + "Request scopes minted onto the token, formatted `ornn::` (e.g. `ornn:skill:create`, `ornn:admin:skill`). Check membership here before attempting a scoped operation; `ornn:admin:skill` is the platform-admin scope that unlocks `/admin/*`. Empty when the proxy authenticated via `X-NyxID-*` headers instead of an identity token — in that mode Ornn has no RBAC data and every scope-gated endpoint will answer 403.", + }, + }, +}; + +const successFlagSchema: JsonSchema = { + type: "object", + required: ["success"], + properties: { + success: { + type: "boolean", + description: "Always `true`. The event was handed to the analytics emitter.", + }, + }, +}; + +const orgMembershipSchema: JsonSchema = { + type: "object", + required: ["userId", "role", "displayName"], + properties: { + userId: { + type: "string", + description: + "NyxID org id. This is the value to put in a skill's `sharedWithOrgs` grant list — orgs are addressed by the same id space as users, hence the field name.", + }, + role: { + type: "string", + enum: ["admin", "member"], + description: + "Caller's role in this org. NyxID `viewer` memberships are filtered out upstream and never appear here.", + }, + displayName: { + type: "string", + description: "Org display name, so a picker can be rendered without a second round-trip.", + }, + }, +}; + +const orgSummarySchema: JsonSchema = { + type: "object", + required: ["userId", "displayName", "avatarUrl"], + properties: { + userId: { + type: "string", + description: "The org id, echoed back. Equals the `orgId` path parameter unless NyxID reports a different canonical id.", + }, + displayName: { + type: "string", + description: "Org display name. Falls back to the raw org id when NyxID returned no `display_name`.", + }, + avatarUrl: { + type: ["string", "null"], + description: "Org avatar URL, or `null` when the org has none.", + }, + }, +}; + +const nyxidServiceSchema: JsonSchema = { + type: "object", + required: ["id", "slug", "label", "description", "tier"], + properties: { + id: { + type: "string", + description: + "NyxID service id — pass this when tying a skill to a service. Synthetic platform entries use the reserved form `synthetic:` and never exist in NyxID's catalog.", + }, + slug: { type: "string", description: "URL-safe service slug (e.g. `chrono-storage`)." }, + label: { type: "string", description: "Human-readable service name for a picker." }, + description: { + type: ["string", "null"], + description: "Service description from NyxID, `null` when unset. Synthetic entries carry an empty string.", + }, + tier: { + type: "string", + enum: ["admin", "personal"], + description: + "`admin` — a NyxID public/platform service; tying a skill to it marks that skill as a **system skill** and forces it public. `personal` — a private service the caller created; tying to it leaves the skill's visibility untouched. Synthetic entries are always `admin`.", + }, + }, +}; + +const grantOrgBucketSchema: JsonSchema = { + type: "object", + required: ["id", "displayName", "skillCount"], + properties: { + id: { type: "string", description: "NyxID org id." }, + displayName: { + type: "string", + description: + "Org display name resolved best-effort from NyxID. Falls back to the raw org id when the lookup fails or no caller token was forwarded — so a value equal to `id` means \"name unresolved\", not \"org literally named that\".", + }, + skillCount: { + type: "integer", + description: "How many of the caller's skills are shared with this org.", + }, + }, +}; + +const grantUserBucketSchema: JsonSchema = { + type: "object", + required: ["userId", "email", "displayName", "skillCount"], + properties: { + userId: { type: "string", description: "NyxID user id of the grantee." }, + email: { + type: "string", + description: + "Grantee email from Ornn's user directory. Empty string when that user has never signed into Ornn.", + }, + displayName: { + type: "string", + description: "Grantee display name, falling back to email, then to the raw user id.", + }, + skillCount: { + type: "integer", + description: "How many of the caller's skills are shared with this user.", + }, + }, +}; + +const sourceOrgBucketSchema: JsonSchema = { + ...grantOrgBucketSchema, + properties: { + ...(grantOrgBucketSchema.properties as Record), + skillCount: { + type: "integer", + description: "How many skills reach the caller through membership in this org.", + }, + }, +}; + +const sourceUserBucketSchema: JsonSchema = { + ...grantUserBucketSchema, + properties: { + ...(grantUserBucketSchema.properties as Record), + userId: { type: "string", description: "NyxID user id of the author who shared with the caller." }, + skillCount: { + type: "integer", + description: "How many skills this author has shared directly with the caller.", + }, + }, +}; + +const launchPromoStatusSchema: JsonSchema = { + type: "object", + required: ["promoEnabled", "claimed", "rank", "totalSlots", "slotsRemaining", "awardedAt"], + properties: { + promoEnabled: { + type: "boolean", + description: + "Whether the launch promo is switched on in platform settings. When `false` every other field is still returned but no award can happen.", + }, + claimed: { + type: "boolean", + description: + "Whether this caller has already been awarded. Awards are one-per-user and idempotent; a claimed user never gets a second code.", + }, + rank: { + type: ["integer", "null"], + description: + "Caller's 1-based Ornn registration rank (1 = first ever user). `null` when the caller is not yet in Ornn's user directory. Eligibility requires `rank <= totalSlots`.", + }, + totalSlots: { type: "integer", description: "Configured size of the promo cohort (e.g. 500)." }, + slotsRemaining: { + type: "integer", + description: "`totalSlots` minus the number of awards already handed out. Never negative.", + }, + awardedAt: { + type: ["string", "null"], + format: "date-time", + description: "ISO-8601 UTC timestamp of the award, or `null` when `claimed` is `false`.", + }, + }, +}; + +const surfaceSnapshotSchema: JsonSchema = { + type: "object", + required: ["defaultAllotment", "adminGrant", "used", "remaining", "warningThreshold", "warning"], + properties: { + defaultAllotment: { + type: "integer", + description: + "Effective monthly allotment for this surface — `max(the default snapshotted when the bucket was first touched, the current platform default)`. Raising the platform default mid-month grants headroom; lowering it never retroactively shrinks an existing bucket.", + }, + adminGrant: { + type: "integer", + description: + "Extra units added to this month's bucket by admin grants and redemption codes. Resets with the bucket at the UTC month rollover — grants do not carry over.", + }, + used: { + type: "integer", + description: + "Units consumed this month, including in-flight reservations. A unit is reserved before the LLM call and refunded if the run ends in a system error or client abort.", + }, + remaining: { + type: "integer", + description: + "`max(0, defaultAllotment + adminGrant - used)`. Already accounts for runs currently streaming, so it is the number an agent should gate on. `0` means the next billed call on this surface returns 429 `quota_exceeded`.", + }, + warningThreshold: { + type: "number", + description: + "Fraction of the cap at which the UI shows a soft warning (default `0.8`). Advisory only — nothing is blocked at this level.", + }, + warning: { + type: "boolean", + description: + "`true` once `cap > 0 && used >= floor(cap * warningThreshold)`, where `cap = defaultAllotment + adminGrant`. The `cap > 0` guard means a zero-cap bucket never warns even though `used >= 0` trivially holds. Advisory only.", + }, + }, +}; + +const quotaSnapshotSchema: JsonSchema = { + type: "object", + required: [ + "isAdmin", + "monthMarker", + "monthStart", + "monthEnd", + "nextMonthlyResetAt", + "playground", + "skillGen", + ], + properties: { + isAdmin: { + type: "boolean", + description: + "`true` when the caller holds `ornn:admin:skill`. Admins bypass quota entirely — their buckets are still reported but nothing is charged against them, so ignore `remaining` for these callers.", + }, + monthMarker: { + type: "string", + description: "UTC calendar month this snapshot describes, formatted `YYYY-MM`.", + examples: ["2026-08"], + }, + monthStart: { + type: "string", + format: "date-time", + description: "ISO-8601 UTC timestamp of the first instant of `monthMarker`.", + }, + monthEnd: { + type: "string", + format: "date-time", + description: "ISO-8601 UTC timestamp of the first instant of the *next* month (exclusive bound).", + }, + nextMonthlyResetAt: { + type: "string", + format: "date-time", + description: + "When the buckets abandon and reset. Equal to `monthEnd`; exposed separately so a client can render a countdown without knowing the bucket model.", + }, + playground: { + ...surfaceSnapshotSchema, + description: "Bucket for the playground chat surface (`POST /playground/chat`).", + }, + skillGen: { + ...surfaceSnapshotSchema, + description: "Bucket for the AI skill-generation surface (`POST /skills/generate`).", + }, + }, + description: + "Per-surface monthly quota. The assistant surface (`POST /assistant/chat`) also reserves and charges, but it is not admin-grantable or redeemable in v1 and therefore is not reported here.", +}; + +const pickerModelSchema: JsonSchema = { + type: "object", + required: ["modelId", "displayName", "isDefault"], + properties: { + modelId: { + type: "string", + description: + "The value to send as the `model` field on the corresponding surface's request body. Opaque — do not parse it or assume a provider prefix.", + examples: ["claude-sonnet-4-6"], + }, + displayName: { + type: "string", + description: "Human label for a picker. Not stable — never key off it.", + }, + isDefault: { + type: "boolean", + description: + "`true` for the model the server would pick if the request omits `model`. At most one item is marked, and it is always sorted first.", + }, + }, +}; + +const appliedGrantSchema: JsonSchema = { + type: "object", + required: ["surface", "amount", "monthMarker", "newAdminGrant"], + properties: { + surface: { + type: "string", + enum: ["playground", "skillGen"], + description: "Which quota bucket received the grant.", + }, + amount: { type: "integer", description: "Units added by this entry of the code." }, + monthMarker: { + type: "string", + description: "UTC month (`YYYY-MM`) whose bucket was credited — always the current month.", + examples: ["2026-08"], + }, + newAdminGrant: { + type: "integer", + description: + "The bucket's `adminGrant` total *after* this grant was applied. Compare against the pre-redemption value from `GET /me/quota` to confirm the credit landed.", + }, + }, +}; + +const historyGrantSchema: JsonSchema = { + type: "object", + required: ["surface", "amount"], + properties: { + surface: { + type: "string", + enum: ["playground", "skillGen"], + description: "Surface this entry of the code targeted.", + }, + amount: { type: "integer", description: "Units the entry was worth." }, + }, +}; + +const historyItemSchema: JsonSchema = { + type: "object", + required: ["id", "code", "grants", "note", "redeemedAt", "expiresAt", "createdAt"], + properties: { + id: { type: "string", description: "Redemption-code document id (24-char Mongo ObjectId hex)." }, + code: { + type: "string", + description: + "The full code, unmasked. Safe here because the caller already redeemed it and it is single-use across the whole platform — it can never be redeemed again.", + }, + grants: { + type: "array", + items: historyGrantSchema, + description: "What the code was worth, one entry per surface. Never empty.", + }, + note: { + type: ["string", "null"], + description: "Free-text note the issuing admin attached, or `null`.", + }, + redeemedAt: { + type: ["string", "null"], + format: "date-time", + description: + "ISO-8601 UTC timestamp of redemption. Practically always populated in this listing — it only returns codes this caller redeemed.", + }, + expiresAt: { + type: "string", + format: "date-time", + description: "ISO-8601 UTC expiry the code carried when it was minted.", + }, + createdAt: { + type: "string", + format: "date-time", + description: "ISO-8601 UTC timestamp of when the admin minted the code.", + }, + }, +}; + +// --------------------------------------------------------------------------- +// Operations +// --------------------------------------------------------------------------- + +export function accountPaths(prefix: string): PathMap { + return { + [`${prefix}/me`]: { + get: { + summary: "Get the caller's identity and permission scopes", + description: + "Return the identity snapshot Ornn derived from the NyxID identity token on this request: user id, email, display name, roles, and — most importantly — the permission scopes the token carries. Call this once at the start of a session and cache it for the token's lifetime; nothing here changes without a new token. Agents should branch on `permissions` rather than probing endpoints and handling 403s: `ornn:admin:skill` unlocks every `/admin/*` route, `ornn:skill:create` is needed to upload, `ornn:skill:build` to generate, `ornn:playground:use` to run playground chat. This endpoint exists because some NyxID-created accounts ship an OAuth `id_token` that is missing `name`/`email` while the proxy-forwarded identity token is complete — so treat this response, not the id_token, as authoritative for display fields.", + operationId: "getMe", + tags: ["Account"], + security: bearerAuth(), + responses: { + ...jsonResponse(identitySchema, "Identity snapshot for the bearer token on this request.", { + example: { + userId: "usr_01HXYZ2K3M4N5P6Q7R8S9T", + email: "agent@example.com", + displayName: "Build Agent", + roles: ["user"], + permissions: ["ornn:skill:read", "ornn:skill:create", "ornn:skill:build"], + }, + }), + ...problemResponses(401), + }, + }, + }, + + [`${prefix}/activity/login`]: { + post: { + summary: "Record a session-opened telemetry event", + description: + "Fire-and-forget server-side acknowledgement that the caller opened a session. The identity attached to the event is taken from the NyxID identity token — the request body is ignored entirely and no client-supplied identity is trusted. This is pure telemetry: it grants nothing, creates no session state, and is never required before any other call. Integrators only need it if they want their agent's sessions to show up alongside web sessions in platform analytics. Always answers `200` with `{ success: true }`, even if the analytics sink is down — never gate your flow on it, and never retry it.", + operationId: "recordLoginActivity", + tags: ["Account"], + security: bearerAuth(), + responses: { + ...jsonResponse(successFlagSchema, "Event accepted for emission.", { + example: { success: true }, + }), + ...problemResponses(401), + }, + }, + }, + + [`${prefix}/activity/logout`]: { + post: { + summary: "Record a session-closed telemetry event", + description: + "Mirror of `POST /activity/login` for session close. Fire-and-forget, identity taken from the NyxID identity token, no body read, nothing invalidated. Calling this does **not** revoke the bearer token — token lifecycle belongs to NyxID, and the token keeps working until it expires or NyxID revokes it. Always answers `200` with `{ success: true }`; do not retry.", + operationId: "recordLogoutActivity", + tags: ["Account"], + security: bearerAuth(), + responses: { + ...jsonResponse(successFlagSchema, "Event accepted for emission.", { + example: { success: true }, + }), + ...problemResponses(401), + }, + }, + }, + + [`${prefix}/me/orgs`]: { + get: { + summary: "List the caller's NyxID org memberships", + description: + "Return the orgs the caller belongs to as `admin` or `member` (NyxID `viewer` memberships are filtered out upstream and never appear). Use this to build the candidate list for a skill's `sharedWithOrgs` grants — an org id that is not in this list will be rejected by the share write-gate. **Fails soft:** if the NyxID proxy did not forward the caller's access token, or the org lookup errored, this returns an empty `items` array with a `200`. An empty list therefore means \"either the caller is in no org, or we could not ask\" — it is not proof of non-membership, so do not cache a negative result aggressively. Write paths that need an authoritative answer use a separate resolution-aware gate that answers `503 org_membership_unavailable` instead of guessing.", + operationId: "listMyOrgs", + tags: ["Account"], + security: bearerAuth(), + responses: { + ...jsonResponse( + { + type: "object", + required: ["items"], + properties: { + items: { + type: "array", + items: orgMembershipSchema, + description: + "Org memberships, unordered. Empty when the caller has none *or* when the lookup could not be resolved.", + }, + }, + }, + "The caller's org memberships (possibly empty — see the fail-soft note).", + { + example: { + items: [ + { userId: "org_01HQ8Z7Y6X5W4V3U2T1S", role: "admin", displayName: "Chrono AI" }, + { userId: "org_01HQ9A0B1C2D3E4F5G6H", role: "member", displayName: "Platform Team" }, + ], + }, + }, + ), + ...problemResponses(401), + }, + }, + }, + + [`${prefix}/me/orgs/{orgId}`]: { + get: { + summary: "Look up one org by id (display-name back-fill)", + description: + "Proxy a single-org read to NyxID using the caller's forwarded access token, and project it down to `{ userId, displayName, avatarUrl }`. This exists for back-fill: a skill's grant list stores bare org ids, and the caller may no longer be a member of an org that still appears there, so `GET /me/orgs` cannot resolve every name. Resolve one id at a time with this endpoint. NyxID decides visibility — an org the caller cannot see is reported as `404`, identically to an org that does not exist, so existence is never leaked. A `404` is also what you get when the proxy stripped the caller's access token, since Ornn then has no credential to act on their behalf; treat `404` as \"unknown org, render the raw id\" rather than as a hard error.", + operationId: "getMyOrg", + tags: ["Account"], + security: bearerAuth(), + parameters: [ + pathParam( + "orgId", + "NyxID org id, exactly as it appears in `GET /me/orgs` or in a skill's `sharedWithOrgs` list. Opaque string — URL-encode it and do not attempt to parse it.", + { type: "string" }, + "org_01HQ8Z7Y6X5W4V3U2T1S", + ), + ], + responses: { + ...jsonResponse(orgSummarySchema, "Org summary as NyxID reports it to this caller.", { + example: { + userId: "org_01HQ8Z7Y6X5W4V3U2T1S", + displayName: "Chrono AI", + avatarUrl: null, + }, + }), + ...problemResponses( + 401, + { + 404: "Not found (`org_not_found`) — no such org, the caller may not see it, or the proxy forwarded no access token so Ornn could not ask. The three cases are deliberately indistinguishable.", + }, + { + 500: "Internal error, in one of two shapes. `NYXID_ORG_LOOKUP_FAILED` — NyxID answered with an unexpected non-2xx status (anything other than 403/404); the upstream status and the first 200 characters of its body are quoted in `detail`. `internal_error` — the call never completed a usable response: a transport-layer failure (DNS, connection refused, timeout) or a 2xx body that is not JSON; `detail` is the generic \"Internal server error\", so there is nothing to parse. Branch on both codes, not just the first — the transport case is the more common one. Both are retryable with backoff.", + }, + ), + }, + }, + }, + + [`${prefix}/me/nyxid-services`]: { + get: { + summary: "List NyxID services the caller may tie a skill to", + description: + "Return the catalog of NyxID services eligible as a skill's service binding: every NyxID **public** service (`tier: \"admin\"`), plus the caller's own **private** services (`tier: \"personal\"`), plus any synthetic platform entries an admin configured (also `tier: \"admin\"`, id prefixed `synthetic:`). Read `tier` before binding, because it changes the skill: tying to an `admin`-tier service marks the skill a **system skill** and forces it public, whereas tying to a `personal` service leaves visibility untouched. Synthetic entries are appended last and exist only inside Ornn — they resolve to no NyxID record. **Fails soft:** when the caller's access token was not forwarded, or NyxID's catalog call fails, the NyxID-sourced rows are silently dropped and only synthetic entries come back, still with a `200`. A short list is therefore not proof the catalog is small.", + operationId: "listMyNyxidServices", + tags: ["Account"], + security: bearerAuth(), + responses: { + ...jsonResponse( + { + type: "object", + required: ["items"], + properties: { + items: { + type: "array", + items: nyxidServiceSchema, + description: + "Eligible services. NyxID catalog entries first (unordered), synthetic platform entries appended last.", + }, + }, + }, + "Services this caller may bind a skill to.", + { + example: { + items: [ + { + id: "svc_01HQ8Z7Y6X5W4V3U2T1S", + slug: "chrono-storage", + label: "Chrono Storage", + description: "Object storage for skill packages", + tier: "admin", + }, + { + id: "synthetic:internal-tools", + slug: "internal-tools", + label: "Internal Tools", + description: "", + tier: "admin", + }, + ], + }, + }, + ), + ...problemResponses(401), + }, + }, + }, + + [`${prefix}/me/skills/grants-summary`]: { + get: { + summary: "Summarise who the caller has shared their skills with", + description: + "Aggregate every skill the caller **owns** by grantee, and return two buckets — `orgs` and `users` — each carrying a display name and a skill count. This answers \"I have shared N skills with X\" in one round-trip, which is otherwise an N+1 walk over the caller's skills and their grant lists. Read-only and derived: it never lists the skills themselves, so use `GET /skill-search?scope=private` for that. Display names are resolved best-effort (orgs via NyxID, users via Ornn's directory) and silently fall back to the raw id on failure — a `displayName` equal to the id means the lookup did not resolve. For the mirror-image view (who has shared *with* the caller) use `GET /me/shared-skills/sources-summary`.", + operationId: "getMySkillGrantsSummary", + tags: ["Account"], + security: bearerAuth(), + responses: { + ...jsonResponse( + { + type: "object", + required: ["orgs", "users"], + properties: { + orgs: { + type: "array", + items: grantOrgBucketSchema, + description: "One entry per org that has been granted at least one of the caller's skills.", + }, + users: { + type: "array", + items: grantUserBucketSchema, + description: "One entry per user who has been granted at least one of the caller's skills directly.", + }, + }, + }, + "Grantee buckets for the caller's own skills.", + { + example: { + orgs: [{ id: "org_01HQ8Z7Y6X5W4V3U2T1S", displayName: "Chrono AI", skillCount: 4 }], + users: [ + { + userId: "usr_01HXYZ2K3M4N5P6Q7R8S9T", + email: "teammate@example.com", + displayName: "Teammate", + skillCount: 2, + }, + ], + }, + }, + ), + ...problemResponses(401), + }, + }, + }, + + [`${prefix}/me/shared-skills/sources-summary`]: { + get: { + summary: "Summarise who has shared skills with the caller", + description: + "The mirror of `GET /me/skills/grants-summary`: aggregate the skills the caller can see *because someone granted them*, bucketed by where the access comes from. `orgs` are bridge memberships — orgs the caller belongs to where a member granted a private skill to the org; `users` are authors who granted directly. Skills the caller owns and public skills are excluded, so this is strictly the \"shared with me\" surface. Same best-effort name resolution and same id-fallback caveat as the grants summary. Note it depends on the caller's org membership lookup, which fails soft to \"no orgs\" — if NyxID is unreachable the `orgs` bucket can come back empty even though bridge shares exist.", + operationId: "getMySharedSkillSourcesSummary", + tags: ["Account"], + security: bearerAuth(), + responses: { + ...jsonResponse( + { + type: "object", + required: ["orgs", "users"], + properties: { + orgs: { + type: "array", + items: sourceOrgBucketSchema, + description: + "One entry per org through which skills reach the caller. Empty when the caller is in no org *or* the membership lookup failed soft.", + }, + users: { + type: "array", + items: sourceUserBucketSchema, + description: "One entry per author who shared a skill with the caller directly.", + }, + }, + }, + "Source buckets for skills shared with the caller.", + { + example: { + orgs: [{ id: "org_01HQ8Z7Y6X5W4V3U2T1S", displayName: "Chrono AI", skillCount: 7 }], + users: [ + { + userId: "usr_01HAAA2K3M4N5P6Q7R8S9T", + email: "author@example.com", + displayName: "Skill Author", + skillCount: 1, + }, + ], + }, + }, + ), + ...problemResponses(401), + }, + }, + }, + + [`${prefix}/me/launch-promo`]: { + get: { + summary: "Get the caller's launch-promo eligibility and claim status", + description: + "Report whether the launch promo is running, whether this caller has already been awarded, their 1-based Ornn registration rank, and how many cohort slots remain. Eligibility is `promoEnabled && !claimed && rank !== null && rank <= totalSlots && slotsRemaining > 0`. This endpoint is **read-only** — there is no self-serve claim route. Awards are handed out by a platform admin (or the stargazer cron), which mints a redemption code and delivers it as a notification; the resulting quota only lands once the caller redeems that code via `POST /me/redemption-codes/redeem`. So `claimed: true` means \"a code was issued to you\", not \"your quota already went up\" — cross-check `GET /me/redemption-codes/history`.", + operationId: "getMyLaunchPromoStatus", + tags: ["Account"], + security: bearerAuth(), + responses: { + ...jsonResponse(launchPromoStatusSchema, "Launch-promo status for the caller.", { + example: { + promoEnabled: true, + claimed: false, + rank: 137, + totalSlots: 500, + slotsRemaining: 363, + awardedAt: null, + }, + }), + ...problemResponses(401), + }, + }, + }, + + [`${prefix}/me/quota`]: { + get: { + summary: "Get the caller's monthly quota snapshot", + description: + "Return the caller's current-month quota buckets for the two metered surfaces — `playground` and `skillGen`. This is the pre-flight check for any billed call: gate on `remaining > 0` before `POST /playground/chat` or `POST /skills/generate`, because those surfaces reserve a unit *before* the LLM call and answer `429 quota_exceeded` when the bucket is empty. `remaining` already reflects in-flight reservations, so it is safe to poll during a streaming run; a run that ends in a system error or client abort releases its unit and `remaining` goes back up. Buckets are per calendar month in UTC and reset by abandonment at `nextMonthlyResetAt` — nothing carries over. When `isAdmin` is `true` the caller bypasses charging entirely and the numbers are informational only. The assistant surface is metered too but is not reported here (it is neither admin-grantable nor redeemable in v1). To top up, redeem a code with `POST /me/redemption-codes/redeem`.", + operationId: "getMyQuota", + tags: ["Account"], + security: bearerAuth(), + responses: { + ...jsonResponse(quotaSnapshotSchema, "Current-month quota snapshot for the caller.", { + example: { + isAdmin: false, + monthMarker: "2026-08", + monthStart: "2026-08-01T00:00:00.000Z", + monthEnd: "2026-09-01T00:00:00.000Z", + nextMonthlyResetAt: "2026-09-01T00:00:00.000Z", + playground: { + defaultAllotment: 100, + adminGrant: 50, + used: 122, + remaining: 28, + warningThreshold: 0.8, + warning: true, + }, + skillGen: { + defaultAllotment: 20, + adminGrant: 0, + used: 3, + remaining: 17, + warningThreshold: 0.8, + warning: false, + }, + }, + }), + ...problemResponses(401), + }, + }, + }, + + [`${prefix}/me/models`]: { + get: { + summary: "List the LLM models enabled for a surface", + description: + "Return the models an ordinary caller may select for one execution surface, plus the id the server will use when the request omits `model`. This is the only non-admin model-discovery route — the admin catalogue under `/admin/settings/llm-providers` is scope-gated. Results are the union across every configured provider, filtered to models that are enabled for the requested surface and not soft-removed, sorted with the default first. Feed `modelId` straight into the surface's request body; do not hard-code model ids, because an admin can enable or retire one at any time without a deploy. `defaultModelId` is `null` only when no model is enabled for that surface at all — in that state the surface itself answers `503 MODEL_UNAVAILABLE`, so treat `null` as \"do not attempt the call\".", + operationId: "listMyModels", + tags: ["Account"], + security: bearerAuth(), + parameters: [ + queryParam( + "surface", + "Which execution surface to list models for. Required — there is no default, and any other value (including omitting it) is rejected with `400 invalid_surface`. `playground` → `POST /playground/chat`; `skillGen` → `POST /skills/generate`; `assistant` → `POST /assistant/chat`.", + { + type: "string", + enum: ["playground", "skillGen", "assistant"], + examples: ["playground"], + }, + true, + ), + ], + responses: { + ...jsonResponse( + { + type: "object", + required: ["items", "defaultModelId"], + properties: { + items: { + type: "array", + items: pickerModelSchema, + description: + "Selectable models, default first, then alphabetical by `displayName`. Empty when no model is enabled for this surface.", + }, + defaultModelId: { + type: ["string", "null"], + description: + "Model used when a request omits `model`. Honours the platform's per-surface pin when one is configured, otherwise the per-model default flag, otherwise the first item. `null` when `items` is empty.", + }, + }, + }, + "Models enabled for the requested surface.", + { + example: { + items: [ + { modelId: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6", isDefault: true }, + { modelId: "gpt-5.2", displayName: "GPT-5.2", isDefault: false }, + ], + defaultModelId: "claude-sonnet-4-6", + }, + }, + ), + ...problemResponses( + { + 400: "Bad request (`invalid_surface`) — the `surface` query parameter was missing or is not one of `playground`, `skillGen`, `assistant`.", + }, + 401, + ), + }, + }, + }, + + [`${prefix}/me/redemption-codes/redeem`]: { + post: { + summary: "Redeem a code for quota credit", + description: + "Consume an admin-issued redemption code and apply its grants to the caller's **current-month** quota buckets. Codes are single-use across the entire platform — the first caller to redeem wins and every later attempt gets `409`. Any authenticated caller may redeem; no admin scope is involved. The code is normalised server-side (trimmed and upper-cased) before lookup, so casing and stray whitespace in user-pasted input are tolerated. Grants land as `adminGrant` on the current month's bucket and expire with it at the UTC month rollover — redeeming late in a month wastes the credit, so redeem when you intend to spend. Claiming the code is a destructive one-shot, so **send an `Idempotency-Key`**: with one, a retry after a lost response replays the original `200` verbatim instead of hitting the `409` a second attempt would otherwise earn. Without one the operation is genuinely not retryable — a network failure after the server claimed the code leaves the code consumed and a bare retry returns `409`; in that case reconcile with `GET /me/redemption-codes/history` before retrying. Confirm the new balance either way with `GET /me/quota`.", + operationId: "redeemRedemptionCode", + tags: ["Account"], + security: bearerAuth(), + parameters: [ + headerParam( + "Idempotency-Key", + "Optional client-generated retry key, at most 255 characters after trimming. The first response for a given `(caller, method, path, key)` tuple is cached for 24 hours; a retry with the same key replays that exact status and body and adds `Idempotency-Replay: true`, which is what makes a redeem whose response was lost in transit safe to repeat. Responses of `500` and above are never cached, so a retry after one re-executes the handler rather than replaying. A missing, empty, or over-long key is ignored silently — the request runs normally with no replay protection, and no `400` is raised.", + ), + ], + requestBody: jsonBody( + redeemBodySchema, + "The code to consume. Generated from `redeemSchema` in `domains/redemption-codes/types.ts` — the schema the route's `validateBody` actually runs — so the published bounds cannot drift from the validator.", + { example: { code: "K7M2QX9RTVBN4PZ3" } }, + ), + responses: { + ...jsonResponse( + { + type: "object", + required: ["codeId", "redeemedAt", "grants"], + properties: { + codeId: { + type: "string", + description: "Id of the redeemed code document — the same `id` it will carry in the history listing.", + }, + redeemedAt: { + type: "string", + format: "date-time", + description: "ISO-8601 UTC timestamp of the redemption.", + }, + grants: { + type: "array", + items: appliedGrantSchema, + description: + "One entry per surface the code credited, in the order they were applied. Never empty on success.", + }, + }, + }, + "Code consumed and its grants applied to the caller's current-month buckets.", + { + example: { + codeId: "665f1c2a9d4b7e3f10ab42c9", + redeemedAt: "2026-08-07T09:14:22.118Z", + grants: [ + { surface: "playground", amount: 200, monthMarker: "2026-08", newAdminGrant: 250 }, + { surface: "skillGen", amount: 25, monthMarker: "2026-08", newAdminGrant: 25 }, + ], + }, + }, + ), + ...problemResponses( + { + 400: "Bad request (`INVALID_REDEEM_BODY`) — the body is not valid JSON, `code` is missing, or it is longer than 64 characters. `detail` carries the rejected fields as `: ` pairs joined with `; `; there is no per-field `errors[]` array. Unparseable JSON reports the fixed detail `Request body must be valid JSON`.", + }, + 401, + { + 404: "Not found (`redemption_code_not_found`) — no code matches after normalisation. Check for transcription errors between the visually similar glyphs the alphabet deliberately excludes.", + }, + { + 409: "Conflict (`redemption_code_already_redeemed`) — this code was already consumed, by this caller or another. Codes are single-use; this is also what a retry of a request that actually succeeded returns.", + }, + { + 410: "Gone — the code is no longer usable: `redemption_code_expired` (past its `expiresAt`) or `redemption_code_invalidated` (an admin revoked it before anyone redeemed it). Neither is retryable; ask for a new code.", + }, + { + 500: "Internal error (`redemption_code_redeem_failed`) — the redemption failed, and the response alone does not say on which side of the atomic claim. Either it failed **before** the claim (the datastore lookup errored; nothing was consumed and the code is still `active`), or **after** it while applying one of the grants (the code is consumed, some grants may have landed, and there is deliberately no rollback). Distinguish the two with `GET /me/redemption-codes/history`: if the code is absent it was never consumed and retrying is correct; if it is present, do not retry — compare `GET /me/quota` against the code's grant bundle and ask an admin to top up whatever is missing.", + }, + ), + }, + }, + }, + + [`${prefix}/me/redemption-codes/history`]: { + get: { + summary: "List redemption codes the caller has redeemed", + description: + "Offset-paginated listing of the codes this caller redeemed, newest first, with their full code string, grant bundle, and timestamps. Scoped to the caller — it never exposes codes redeemed by anyone else, nor unredeemed codes sitting in the admin pool. Use it to reconcile a redemption whose response was lost in transit (look for `codeId` before retrying a `POST .../redeem`, which would otherwise return `409`), or to audit where a month's `adminGrant` balance came from. Pagination is offset-based here rather than cursor-based like the skill listings, and both parameters are silently clamped instead of rejected — an out-of-range value never produces a `400`, so read `page` and `pageSize` back from the response rather than assuming your request was honoured.", + operationId: "listMyRedemptionCodeHistory", + tags: ["Account"], + security: bearerAuth(), + parameters: [ + queryParam( + "page", + "1-based page number. Defaults to `1`. Clamped into `[1, 10000]` — anything below, above, or unparseable is silently coerced rather than rejected. The upper bound exists to stop a huge offset driving an unbounded collection scan.", + { type: "integer", minimum: 1, maximum: 10000, default: 1, examples: [1] }, + ), + queryParam( + "pageSize", + "Items per page. Defaults to `20`, clamped into `[1, 100]`. Non-numeric or missing values fall back to the default rather than erroring.", + { type: "integer", minimum: 1, maximum: 100, default: 20, examples: [20] }, + ), + ], + responses: { + ...jsonResponse( + { + type: "object", + required: ["items", "total", "page", "pageSize", "totalPages"], + properties: { + items: { + type: "array", + items: historyItemSchema, + description: "Redeemed codes for this page, newest first.", + }, + total: { + type: "integer", + description: "Total number of codes this caller has ever redeemed, across all pages.", + }, + page: { + type: "integer", + description: "The page actually served, after clamping. Compare against what you sent.", + }, + pageSize: { + type: "integer", + description: "The page size actually applied, after clamping.", + }, + totalPages: { + type: "integer", + description: "`ceil(total / pageSize)`, floored at `1` — so an empty history still reports `1`.", + }, + }, + }, + "One page of the caller's redemption history.", + { + example: { + items: [ + { + id: "665f1c2a9d4b7e3f10ab42c9", + code: "K7M2QX9RTVBN4PZ3", + grants: [ + { surface: "playground", amount: 200 }, + { surface: "skillGen", amount: 25 }, + ], + note: "Launch promo cohort", + redeemedAt: "2026-08-07T09:14:22.118Z", + expiresAt: "2026-12-31T23:59:59.000Z", + createdAt: "2026-08-01T12:00:00.000Z", + }, + ], + total: 1, + page: 1, + pageSize: 20, + totalPages: 1, + }, + }, + ), + ...problemResponses(401), + }, + }, + }, + }; +} diff --git a/ornn-api/src/openapi/paths/admin.ts b/ornn-api/src/openapi/paths/admin.ts new file mode 100644 index 00000000..87767dc7 --- /dev/null +++ b/ornn-api/src/openapi/paths/admin.ts @@ -0,0 +1,1255 @@ +/** + * Admin core — the platform-operator surface (#1214). + * + * Thirteen operations behind one request scope, `ornn:admin:skill`. An + * agent reaches this domain only when its token carries that scope; the + * cheapest way to find out is `GET /api/v1/me` and to look for it in + * `permissions` before attempting anything here. Without it every + * operation in this module answers `403`, and without a token at all, + * `401`. + * + * The domain splits into six unrelated concerns that happen to share a + * gate: + * + * 1. **Skill moderation** — `GET /admin/skills` is the only listing in + * the API with no visibility filter at all: it returns every skill + * on the deployment, private ones included. `DELETE /admin/skills/{id}` + * is a hard, cascading, irreversible delete that ignores ownership. + * `POST /admin/skills/{idOrName}/versions/{version}/agentseal-rescan` + * re-runs the AgentSeal static scan on one immutable version. + * 2. **User directory** — `GET /admin/users`, the paginated admin/normal + * roster with per-user skill and activity counts. + * 3. **Dashboard** — `GET /admin/dashboard/stats`, two tiles of totals. + * The activity feed that used to live beside it moved to PostHog. + * 4. **Platform settings** — the legacy singleton (`auditWaiverThreshold` + * plus an LLM-provider override) read and patched at + * `/admin/settings`, and the whole-configuration + * `/admin/settings/export` + `/admin/settings/import` pair that moves + * every settings *section* between deployments. + * 5. **GitHub mirror operations** — kick off a reconcile + * (`POST /admin/mirror/reconcile`, fire-and-forget, `202`) and read + * the resulting snapshot (`GET /admin/mirror/status`). + * 6. **Launch promo** — manually award a user + * (`POST /admin/launch-promo/award/{userId}`) and inspect the most + * recent awards (`GET /admin/launch-promo/recent`). + * + * Two cross-cutting behaviours to internalise before integrating: + * + * **Secrets are masked, never returned.** Every settings read replaces a + * stored credential with a *mid-mask* — first four characters, a run of + * `•` (U+2022), last four (`sk-p••••••••3f9a`). The bullet is a sentinel: + * writing a value back that still contains one tells the server "keep the + * value you already have". So the read-modify-write round trip is safe, + * and an agent must never try to reconstruct the real key from a mask. + * The settings *export* uses a different sentinel, ``, + * with the same preserve-on-import semantics. + * + * **Three handlers here still emit the pre-#456 error envelope.** The + * `503` on the AgentSeal rescan, the `409`/`503` on the mirror reconcile, + * and the `413` on the settings import are produced inline with + * `c.json({ data: null, error: { code, message } }, status)` instead of + * being raised through the global RFC 7807 handler. They are documented + * below with the standard problem schema for consistency with the rest of + * the spec, and each carries an explicit warning in its `description`. + * A client that parses error bodies must tolerate both shapes on those + * three statuses; branch on the HTTP status first, and read `code` from + * whichever of the root or `error` object is present. + * + * Schema provenance: the settings export/import section payloads are + * generated from the ten section Zod schemas in + * `domains/settings/sections/` — the exact schemas the importer validates + * against, so the documented shape cannot drift from the validator. + * Everything else is hand-written JSON Schema, because those handlers + * project their responses inline off TypeScript interfaces + * (`DashboardStats`, `AdminUserRow`, `ExportEnvelope`, `ImportResult`, + * `ScheduledRunStatus`, `LaunchPromoClaimDoc`) with no Zod source at all. + * + * @module openapi/paths/admin + */ + +import { + bearerAuth, + jsonBody, + jsonResponse, + pathParam, + problemResponses, + queryParam, + toSchema, + type JsonSchema, + type PathMap, +} from "../helpers"; +import { assistantSchema } from "../../domains/settings/sections/assistant"; +import { extrasSchema } from "../../domains/settings/sections/extras"; +import { launchPromoSchema } from "../../domains/settings/sections/launchPromo"; +import { mirrorSchema } from "../../domains/settings/sections/mirror"; +import { nyxidSchema } from "../../domains/settings/sections/nyxid"; +import { playgroundSchema } from "../../domains/settings/sections/playground"; +import { skillAuditSchema } from "../../domains/settings/sections/skillAudit"; +import { skillGenSchema } from "../../domains/settings/sections/skillGen"; +import { sourceSyncSchema } from "../../domains/settings/sections/sourceSync"; +import { telemetrySchema } from "../../domains/settings/sections/telemetry"; + +// --------------------------------------------------------------------------- +// Shared prose +// --------------------------------------------------------------------------- + +/** Appended to every operation description in this module. */ +const ADMIN_SCOPE_NOTE = + "Requires a bearer token whose `permissions` array contains the platform-admin request scope `ornn:admin:skill` — the scope NyxID mints onto its \"Platform Admin\" role. A token without it gets `403` (`code: \"forbidden\"`); no token, an expired token, or a token the proxy could not validate gets `401` (`code: \"auth_missing\"`). There is no finer-grained admin scope: the same one scope unlocks every operation in this domain."; + +// --------------------------------------------------------------------------- +// Skill moderation payloads +// --------------------------------------------------------------------------- + +const adminSkillRowSchema: JsonSchema = { + type: "object", + required: [ + "guid", + "name", + "description", + "createdBy", + "createdByEmail", + "createdByDisplayName", + "createdOn", + "updatedOn", + "isPrivate", + "tags", + ], + properties: { + guid: { + type: "string", + description: + "Skill id (UUID). This is the value `DELETE /admin/skills/{id}` expects — that endpoint resolves by guid only, never by name.", + examples: ["3f2a91c4-0d5b-4a1e-9d2f-7c8b6e5a4310"], + }, + name: { + type: "string", + description: "Registry-unique skill name, the human-facing identifier used everywhere else in the API.", + examples: ["web-summarizer"], + }, + description: { type: "string", description: "One-line summary from the skill's SKILL.md frontmatter." }, + createdBy: { + type: "string", + description: + "NyxID user id of the owner. Feed this back as the `userId` query parameter to narrow the listing to one author. Empty string on legacy rows written before ownership was recorded.", + }, + createdByEmail: { + type: "string", + description: "Owner's email, denormalised onto the skill document at create time. Empty string when unknown.", + }, + createdByDisplayName: { + type: "string", + description: "Owner's display name, denormalised at create time. Empty string when unknown. May be stale if the user has since renamed themselves in NyxID.", + }, + createdOn: { type: "string", format: "date-time", description: "Creation timestamp (ISO 8601, UTC). The listing is sorted by this field, descending." }, + updatedOn: { type: "string", format: "date-time", description: "Timestamp of the most recent publish or metadata change (ISO 8601, UTC)." }, + isPrivate: { + type: "boolean", + description: + "`true` when the skill is owner-only. Private skills appear here and nowhere else in the API for a non-owner — this listing deliberately skips the visibility filter every other listing applies. Legacy rows with no stored flag are reported as `true`.", + }, + tags: { + type: "array", + items: { type: "string" }, + description: "Classification tags from `metadata.tags`. Empty array when the skill declared none.", + }, + }, +}; + +const adminSkillListSchema: JsonSchema = { + type: "object", + required: ["items", "total", "page", "pageSize", "totalPages"], + properties: { + items: { type: "array", items: adminSkillRowSchema, description: "This page of skills, newest first." }, + total: { type: "integer", description: "Total number of skills matching the filter across all pages." }, + page: { type: "integer", description: "Page number actually served, after clamping (always ≥ 1)." }, + pageSize: { type: "integer", description: "Page size actually served, after clamping to 1–100." }, + totalPages: { type: "integer", description: "`ceil(total / pageSize)`. `0` when `total` is 0." }, + }, +}; + +const successFlagSchema: JsonSchema = { + type: "object", + required: ["success"], + properties: { + success: { + type: "boolean", + description: "Always `true`. The operation is reported through the status code; this field carries no extra information.", + }, + }, +}; + +const agentsealFindingSchema: JsonSchema = { + type: "object", + description: + "One AgentSeal finding. The shape is defined by the scanner, not by Ornn, and is passed through verbatim — treat it as an open object and read defensively. In practice entries carry a rule identifier, a severity, a file path, and a message.", + additionalProperties: true, +}; + +const agentsealScanSchema: JsonSchema = { + type: "object", + required: ["score", "findings", "scannedAt", "agentsealVersion"], + properties: { + score: { + type: "number", + description: + "Trust score, 0–100, computed from severity-weighted penalties against the findings below. Higher is safer. This value replaces whatever the version's previous scan recorded.", + examples: [92], + }, + findings: { type: "array", items: agentsealFindingSchema, description: "Every issue the sweep raised. Empty array on a clean scan." }, + scannedAt: { type: "string", format: "date-time", description: "When this scan completed (ISO 8601, UTC)." }, + agentsealVersion: { + type: "string", + description: + "Pinned AgentSeal package version that produced the score. Compare against the version recorded on an older scan to tell a rules-change from a package-content change.", + }, + scannedFiles: { + type: "integer", + description: "Number of files the sweep actually inspected. Absent on snapshots written before this field was added.", + }, + }, +}; + +const agentsealRescanSchema: JsonSchema = { + type: "object", + required: ["skillGuid", "skillName", "version", "scan"], + properties: { + skillGuid: { type: "string", description: "Resolved skill id, useful when the request addressed the skill by name." }, + skillName: { type: "string", description: "Resolved skill name." }, + version: { type: "string", description: "The `.` version that was rescanned, echoed back." }, + scan: { + oneOf: [agentsealScanSchema, { type: "null" }], + description: + "The new scan snapshot, already persisted onto the version document. `null` means the scan produced no result and **nothing was persisted** — the previous snapshot, if any, is untouched. Do not read `null` as \"clean\"; read it as \"unknown\".", + }, + }, +}; + +// --------------------------------------------------------------------------- +// User directory + dashboard payloads +// --------------------------------------------------------------------------- + +const adminUserRowSchema: JsonSchema = { + type: "object", + required: ["userId", "email", "displayName", "skillCount", "lastActiveAt", "activityCount", "firstJoinedAt"], + properties: { + userId: { type: "string", description: "NyxID user id — the same value that appears as `createdBy` on a skill and as the `{userId}` path parameter on the launch-promo award endpoint." }, + email: { type: "string", description: "Email from the directory row. Empty string when the identity token never carried one." }, + displayName: { type: "string", description: "Display name from the directory row. Empty string when unknown." }, + skillCount: { type: "integer", description: "Number of skills in the registry whose `createdBy` is this user, counted live at request time." }, + lastActiveAt: { + type: ["string", "null"], + format: "date-time", + description: "Timestamp of the most recent authenticated request from this user (ISO 8601, UTC), or `null` if the directory row has never been stamped.", + }, + activityCount: { + type: "integer", + description: + "Count of authenticated requests seen from this user. This is a request counter, not a count of meaningful actions — it grows with polling. Monotonic, so it is usable for ordering but not as a business metric.", + }, + firstJoinedAt: { + type: ["string", "null"], + format: "date-time", + description: "First time Ornn saw this user (ISO 8601, UTC), or `null` on rows predating the field. This is Ornn's first sighting, not the NyxID account creation date.", + }, + }, +}; + +const adminUserListSchema: JsonSchema = { + type: "object", + required: ["items", "page", "pageSize", "total", "totalPages"], + properties: { + items: { type: "array", items: adminUserRowSchema, description: "This page of users." }, + page: { type: "integer", description: "Page number actually served, after clamping (always ≥ 1)." }, + pageSize: { type: "integer", description: "Page size actually served, after clamping to 1–200." }, + total: { + type: "integer", + description: + "Users matching `role` + `q`, counted over the pool the service loads for in-application sorting — and that pool is hard-capped at 5000 rows. A role bucket with more than 5000 matches therefore reports exactly `5000`, and nothing in the payload flags the truncation. Narrow with `q` if you need an exact count.", + }, + totalPages: { + type: "integer", + description: + "`ceil(total / pageSize)`, floored at 1 — an empty result still reports `1`. Derived from the capped `total`, so on a bucket larger than 5000 rows it is an under-estimate and paging to the last page will not reach the end of the directory.", + }, + }, +}; + +const dashboardStatsSchema: JsonSchema = { + type: "object", + required: ["users", "skills"], + properties: { + users: { + type: "object", + required: ["total", "admin", "normal"], + description: "User tiles, from the user directory. The two buckets partition the total exactly: `total === admin + normal`.", + properties: { + total: { type: "integer", description: "Every user Ornn has ever seen authenticate." }, + admin: { type: "integer", description: "Users whose most recent token carried the platform-admin scope." }, + normal: { type: "integer", description: "Everyone else." }, + }, + }, + skills: { + type: "object", + required: ["total", "system", "public", "private"], + description: + "Skill tiles. The three buckets partition the total exactly: `total === system + public + private`. `system` means `isSystemSkill: true`; `public` means publicly visible **and** not a system skill; `private` means owner-only.", + properties: { + total: { type: "integer", description: "Every skill document in the registry." }, + system: { type: "integer", description: "Platform-provided system skills." }, + public: { type: "integer", description: "Publicly listed, non-system skills." }, + private: { type: "integer", description: "Owner-only skills." }, + }, + }, + }, +}; + +// --------------------------------------------------------------------------- +// Platform settings payloads +// --------------------------------------------------------------------------- + +const MASK_NOTE = + "Mid-masked on read: first four characters, a run of `•` (U+2022), last four — e.g. `sk-p••••••••3f9a`. Values of eight characters or fewer are replaced entirely by bullets, and an unset value reads back as the empty string. The bullet is a write-side sentinel: PATCH a value that still contains one and the stored secret is preserved untouched, which makes a read-modify-write round trip safe."; + +const platformSettingsSchema: JsonSchema = { + type: "object", + required: ["auditWaiverThreshold", "llmProvider"], + properties: { + auditWaiverThreshold: { + type: "number", + minimum: 0, + maximum: 10, + description: + "Audit overall score, 0–10, at or above which a new share grant applies without a waiver. Below it, the audit-gated share-request flow kicks in (owner justification, then reviewer decision). Stored rounded to one decimal place.", + examples: [6], + }, + llmProvider: { + type: "object", + required: ["gatewayUrl", "apiKey"], + description: + "Legacy single-provider override, consulted by every playground / skill-generation / assistant LLM call. Both fields empty means \"fall back to the deployment's environment configuration\" (the Chrono LLM gateway reached through a NyxID service-account token exchange). The richer per-provider catalog lives under `/admin/settings/llm-providers` and is managed separately.", + properties: { + gatewayUrl: { + type: "string", + description: "Gateway base URL. Empty string means the env default is used. Returned verbatim — this is not a secret.", + examples: ["https://api.openai.com/v1"], + }, + apiKey: { + type: "string", + description: `Direct bearer key used instead of the service-account token exchange. Encrypted at rest. ${MASK_NOTE}`, + }, + }, + }, + }, +}; + +const platformSettingsPatchSchema: JsonSchema = { + type: "object", + description: + "Partial update. Send only the keys you intend to change; every key is optional and any key that is not one of the two below is silently ignored. The body must contain at least one recognised key or the request is rejected with `400`.", + properties: { + auditWaiverThreshold: { + type: "number", + minimum: 0, + maximum: 10, + description: + "New waiver threshold. Coerced with `Number()`, then required to be finite and within 0–10, then rounded to one decimal. Anything outside that range — including `NaN`, `Infinity`, and non-numeric strings — is a `400` (`code: \"invalid_setting\"`).", + examples: [7.5], + }, + llmProvider: { + type: "object", + description: + "LLM override. Field-level partial: omit `gatewayUrl` or `apiKey` and the stored value for that field is carried forward unchanged, so `{ \"llmProvider\": { \"gatewayUrl\": \"...\" } }` re-points the gateway without disturbing the key.", + properties: { + gatewayUrl: { + type: "string", + description: + "Absolute URL, or the empty string to clear the override and fall back to the environment default. A non-empty value that does not parse as a URL is a `400`.", + }, + apiKey: { + type: "string", + description: + "New bearer key, or the empty string to clear it. If the value contains a `•` (U+2022) it is treated as the mid-mask you just read back from `GET /admin/settings` and the stored key is preserved unchanged — so echoing the GET response back is a no-op, by design. Leading and trailing whitespace is trimmed.", + }, + }, + }, + }, +}; + +// --------------------------------------------------------------------------- +// Settings export / import payloads +// --------------------------------------------------------------------------- + +/** + * Per-section JSON Schemas, generated from the ten Zod section schemas in + * `domains/settings/sections/`. The exporter writes all ten keys and the + * importer validates each candidate against the very same schema, so one + * set of definitions serves both directions. None of these schemas carries + * a Zod `.default()`, which is why the input and output projections are + * identical and reusing them here is safe. + */ +const sectionSchemas: Record = { + playground: { + ...toSchema(playgroundSchema), + description: "Playground surface: default LLM provider/model, SSE keep-alive cadence, and the default monthly quota granted to non-admin users.", + }, + skillGen: { + ...toSchema(skillGenSchema), + description: "Skill-generation surface — same knobs as `playground`, applied to the skill-authoring LLM calls.", + }, + assistant: { + ...toSchema(assistantSchema), + description: "Ornn Assistant surface (grounded Q&A) — same knobs as `playground`.", + }, + mirror: { + ...toSchema(mirrorSchema), + description: + "GitHub mirror: kill switch, repository coordinates, GitHub App credentials, and the reconcile cron (interpreted in `Asia/Singapore`; empty string disables the schedule). `appPrivateKey` is a secret — redacted on export, preserved on import when the sentinel comes back unchanged.", + }, + nyxid: { + ...toSchema(nyxidSchema), + description: + "NyxID and adjacent service coordinates: service-account OAuth endpoint and client id/secret, the NyxID API base URL, and the chrono-storage / chrono-sandbox base URLs plus the storage bucket. `clientSecret` is a secret — redacted on export.", + }, + skillAudit: { + ...toSchema(skillAuditSchema), + description: + "Skill audit configuration: LLM audit toggle plus its default provider/model, the 0–10 risk threshold, and the AgentSeal toggle and timeout. Note the cross-field rule the importer enforces — `llmAuditDefaultProviderId` is required whenever `llmAuditEnabled` is true.", + }, + telemetry: { + ...toSchema(telemetrySchema), + description: + "PostHog configuration. Changes take effect on the next ornn-api restart, not immediately. `postHogApiKey` is a secret — redacted on export.", + }, + extras: { + ...toSchema(extrasSchema), + description: + "Extra synthetic NyxID services an operator has declared. Service names must be unique within the array; duplicates are rejected by the importer as a section-level failure.", + }, + launchPromo: { + ...toSchema(launchPromoSchema), + description: + "Launch-promo configuration read by both launch-promo endpoints in this domain: enabled flag, GitHub repo coordinates, slot cap, per-claim Playground and Skill-Generation grants, poll interval, code expiry, and the bundled NyxID invite code.", + }, + sourceSync: { + ...toSchema(sourceSyncSchema), + description: + "GitHub source-sync poller: enabled flag, service-account token, poll cron, per-skill minimum re-check interval, and the auto-publish switch. `githubToken` is a secret — redacted on export.", + }, +}; + +const exportedLlmProviderSchema: JsonSchema = { + type: "object", + required: ["_id", "name", "gatewayUrl", "modelListUrl", "apiFormat", "auth", "maxOutputTokens", "defaultTemperature"], + description: + "One configured LLM provider, with its secret auth field redacted. Note what is **not** here: the `models` catalog is derived data and is deliberately excluded, so per-model enable/default flags do not survive an export/import round trip — re-sync the catalog and set the flags again on the target deployment.", + properties: { + _id: { type: "string", description: "Provider id." }, + name: { type: "string", description: "Operator-facing provider name." }, + gatewayUrl: { type: "string", description: "Base URL completions are sent to." }, + modelListUrl: { type: "string", description: "URL the model-catalog sync reads." }, + apiFormat: { + type: "string", + enum: ["chat-completion", "responses"], + description: "Wire dialect this provider speaks.", + }, + auth: { + type: "object", + description: + "Discriminated on `kind`: `apiKey` carries `apiKey`; `tokenUrl` carries `tokenUrl`, `clientId`, `clientSecret`; `basic` carries `username`, `password`. The secret member for the given kind (`apiKey` / `clientSecret` / `password`) is replaced with ``.", + required: ["kind"], + properties: { + kind: { type: "string", enum: ["apiKey", "tokenUrl", "basic"], description: "Auth strategy discriminator." }, + }, + additionalProperties: true, + }, + maxOutputTokens: { type: "integer", description: "Per-call output-token ceiling applied to this provider." }, + defaultTemperature: { type: "number", description: "Sampling temperature used when a caller does not specify one." }, + }, +}; + +const exportEnvelopeSchema: JsonSchema = { + type: "object", + required: ["schemaVersion", "exportedAt", "ornnVersion", "sections"], + properties: { + schemaVersion: { + type: "integer", + const: 1, + description: + "Envelope format version. The importer demands an exact match — a file carrying any other value is rejected wholesale, with no partial write.", + }, + exportedAt: { type: "string", format: "date-time", description: "When the export was produced (ISO 8601, UTC)." }, + ornnVersion: { + type: ["string", "null"], + description: "Release of the ornn-api instance that produced the file, or `null` when the deployment did not report one. Informational — the importer does not check it.", + }, + sections: { + type: "object", + required: [...Object.keys(sectionSchemas), "llmProviders"], + description: + "Every settings section, keyed by section id. All ten sections are always present, even when a section has never been edited (defaults are emitted). Secret fields are replaced with the string sentinel `` so the file never carries plaintext or ciphertext.", + properties: { + ...sectionSchemas, + llmProviders: { + type: "array", + items: exportedLlmProviderSchema, + description: "Configured LLM providers. Present in the export for review purposes but **not** applied by the importer in v1.", + }, + }, + }, + }, +}; + +const importBodySchema: JsonSchema = { + type: "object", + required: ["schemaVersion"], + description: + "An export envelope, normally posted back verbatim from `GET /admin/settings/export`, plus an optional `dryRun` flag. Unrecognised top-level keys are ignored.", + properties: { + schemaVersion: { + type: "integer", + const: 1, + description: + "Must equal `1`. Any other value (including a missing key) aborts the whole import — the response is still `200`, with `aggregateStatus: \"failed\"` and a single `extras` section entry naming `schemaVersion`.", + }, + sections: { + type: "object", + description: + "Sections to apply, keyed by section id. Omit a section to leave the target deployment's value untouched — it comes back as `skipped`. Each present section is validated against that section's schema; a section that fails validation is reported as `failed` and skipped, while its siblings still apply. Secret fields carrying a `` or mid-mask sentinel preserve the target's existing value rather than overwriting it with the sentinel string.", + properties: { + ...sectionSchemas, + llmProviders: { + type: "array", + items: exportedLlmProviderSchema, + description: + "Accepted but never applied in v1. Supplying it adds an `llmProviders` entry to the response with `status: \"skipped\"` and an explanatory error. Manage providers through `/admin/settings/llm-providers` instead.", + }, + }, + }, + dryRun: { + type: "boolean", + description: + "When `true`, validate every section and report what would happen without writing anything. Dry-run sections report `status: \"applied\"` with an empty `changedFields`, so use the flag to catch validation failures, not to preview a diff. Combined with the `dryRun` query parameter by logical OR, not by precedence: the import is a dry run when this field is `true` **or** the query parameter is `1`/`true`. Sending `dryRun: false` therefore cannot force a real write past a `?dryRun=1` on the URL — remove the query parameter instead.", + }, + }, +}; + +const importResultSchema: JsonSchema = { + type: "object", + required: ["schemaVersion", "aggregateStatus", "sections"], + properties: { + schemaVersion: { type: "integer", const: 1, description: "The schema version the server understands. Always `1`, even on a rejected import." }, + aggregateStatus: { + type: "string", + enum: ["applied", "partial", "failed"], + description: + "Roll-up across the section results: `failed` when every section that was attempted failed, `partial` when some applied and some failed, `applied` when none failed. Careful — an import that touched nothing at all (all sections skipped) also reports `applied`, so confirm against `sections[].status` rather than trusting this field to mean \"something changed\".", + }, + sections: { + type: "array", + description: "One entry per known section, in registry order, plus a trailing `llmProviders` entry when that key was present in the request.", + items: { + type: "object", + required: ["id", "status"], + properties: { + id: { + type: "string", + enum: [...Object.keys(sectionSchemas), "llmProviders"], + description: "Section id this result describes.", + }, + status: { + type: "string", + enum: ["applied", "skipped", "failed"], + description: + "`applied` — written (or, under `dryRun`, validated cleanly). `skipped` — absent from the request, or an `llmProviders` block that v1 refuses to apply. `failed` — the payload did not validate, or the write threw.", + }, + changedFields: { + type: "array", + items: { type: "string" }, + description: "Field names whose stored value actually changed. Empty on a no-op write and always empty under `dryRun`. Absent on `skipped` and `failed` entries.", + }, + errors: { + type: "array", + description: "Why this section failed, one entry per problem. Absent on success.", + items: { + type: "object", + required: ["field", "message"], + properties: { + field: { type: "string", description: "Dotted path to the offending field, or the empty string when the failure was not field-specific (e.g. a write error)." }, + message: { type: "string", description: "What was wrong." }, + }, + }, + }, + }, + }, + }, + }, +}; + +// --------------------------------------------------------------------------- +// Mirror payloads +// --------------------------------------------------------------------------- + +const reconcileAcceptedSchema: JsonSchema = { + type: "object", + required: ["status", "startedAt"], + properties: { + status: { type: "string", enum: ["running"], description: "Always `running` — the response is sent before any work happens." }, + startedAt: { + type: "string", + format: "date-time", + description: "When this pod started the run (ISO 8601, UTC). Quote it if a later `409` reports a run still in flight.", + }, + }, +}; + +const mirrorStatusSchema: JsonSchema = { + type: "object", + required: ["enabled", "repo", "appId", "installationId", "appPrivateKey", "counts", "scheduledRun"], + properties: { + enabled: { type: "boolean", description: "Mirror kill switch. When `false` no reconcile runs, scheduled or manual." }, + repo: { + type: "object", + required: ["owner", "repo", "branch"], + description: "Target repository coordinates. All three are empty strings when the mirror has never been configured.", + properties: { + owner: { type: "string", description: "GitHub owner (user or org login).", examples: ["ChronoAIProject"] }, + repo: { type: "string", description: "GitHub repository name.", examples: ["ornn-skills"] }, + branch: { type: "string", description: "Branch commits land on.", examples: ["main"] }, + }, + }, + appId: { type: "string", description: "GitHub App id used to authenticate the mirror. Empty string when unset." }, + installationId: { type: "string", description: "GitHub App installation id on the target repository. Empty string when unset." }, + appPrivateKey: { type: "string", description: `GitHub App private key. ${MASK_NOTE}` }, + counts: { + type: "object", + required: ["eligible", "synced", "lagging", "neverSynced", "oldestUnsyncedAt"], + description: + "Live aggregate over every **public** skill — private skills are never mirrored and are excluded from all four counts. `eligible === synced + lagging + neverSynced`.", + properties: { + eligible: { type: "integer", description: "Public skills, i.e. everything the mirror is responsible for." }, + synced: { type: "integer", description: "Skills whose mirrored version equals their current latest version." }, + lagging: { type: "integer", description: "Skills mirrored at an older version than their current latest — a reconcile will move these." }, + neverSynced: { type: "integer", description: "Skills that have never been committed to the mirror." }, + oldestUnsyncedAt: { + type: ["string", "null"], + format: "date-time", + description: "Creation timestamp of the oldest never-synced skill (ISO 8601, UTC), or `null` when `neverSynced` is 0. The practical \"how far behind are we\" signal.", + }, + }, + }, + scheduledRun: { + type: "object", + required: ["status", "lastRunAt", "lastFinishedAt", "lastDurationMs", "lastError", "nextRunAt"], + description: + "The cluster-wide, persisted view of the most recent **scheduled** fire. Manual runs started through `POST /admin/mirror/reconcile` are tracked per-pod and never appear here — polling this block will not tell you when your manual reconcile finished. Every field reads as its empty value when the scheduler failed to start on this pod.", + properties: { + status: { + type: "string", + enum: ["succeeded", "failed", "running", "never_run"], + description: "Outcome of the last scheduled fire. `never_run` means no run has been recorded — a fresh deployment, or the schedule is disabled.", + }, + lastRunAt: { type: ["string", "null"], format: "date-time", description: "Start of the last scheduled fire (ISO 8601, UTC)." }, + lastFinishedAt: { type: ["string", "null"], format: "date-time", description: "End of the last scheduled fire (ISO 8601, UTC). `null` while one is in flight." }, + lastDurationMs: { type: ["integer", "null"], description: "Wall-clock duration of the last completed scheduled fire, in milliseconds." }, + lastError: { type: ["string", "null"], description: "Failure message from the last fire; `null` unless `status` is `failed`." }, + nextRunAt: { type: ["string", "null"], format: "date-time", description: "Next scheduled fire (ISO 8601, UTC), or `null` when the cron is empty or the scheduler is not running." }, + }, + }, + }, +}; + +// --------------------------------------------------------------------------- +// Launch-promo payloads +// --------------------------------------------------------------------------- + +const launchPromoAwardSchema: JsonSchema = { + type: "object", + required: ["claim"], + properties: { + claim: { + type: "object", + required: ["userId", "eligibilityRank", "redemptionCodeId", "redemptionCode", "awardedAt", "awardedBy"], + properties: { + userId: { type: "string", description: "The awarded user's NyxID id — the `{userId}` path parameter, echoed back." }, + eligibilityRank: { + type: "integer", + description: "The user's 1-based Ornn registration rank at award time, frozen onto the claim so a later audit can answer \"why was this user eligible\".", + examples: [37], + }, + redemptionCodeId: { type: "string", description: "Id of the minted redemption code in the redemption-codes domain." }, + redemptionCode: { + type: "string", + description: + "The code string itself. This is the **only** place it is returned — the claim record stores only the id. The user also receives it in an in-app notification, so there is normally no need to relay it manually.", + }, + awardedAt: { type: "string", format: "date-time", description: "When the award landed (ISO 8601, UTC)." }, + awardedBy: { type: "string", description: "Who triggered it: the calling admin's user id here, or the sentinel `system:cron` for automated awards." }, + }, + }, + }, +}; + +const launchPromoRecentSchema: JsonSchema = { + type: "object", + required: ["items"], + properties: { + items: { + type: "array", + description: "Most recent claims first. No pagination cursor — raise `limit` to see further back.", + items: { + type: "object", + required: ["userId", "eligibilityRank", "redemptionCodeId", "awardedAt", "awardedBy", "githubLogin"], + properties: { + userId: { type: "string", description: "Awarded user's NyxID id." }, + eligibilityRank: { type: "integer", description: "Registration rank frozen at award time." }, + redemptionCodeId: { + type: "string", + description: "Redemption-code id. The code string is deliberately not exposed here — only the award response returns it.", + }, + awardedAt: { type: "string", format: "date-time", description: "When the award landed (ISO 8601, UTC)." }, + awardedBy: { type: "string", description: "Admin user id, or the sentinel `system:cron`." }, + githubLogin: { + type: ["string", "null"], + description: "GitHub login recorded at award time. Populated on cron-driven awards (that is how the user was matched) and normally `null` for manual admin awards.", + }, + }, + }, + }, + }, +}; + +// --------------------------------------------------------------------------- +// Paths +// --------------------------------------------------------------------------- + +export function adminPaths(prefix: string): PathMap { + return { + [`${prefix}/admin/skills`]: { + get: { + summary: "List every skill on the platform", + description: + `Moderation listing over the entire registry. Unlike \`GET /api/v1/skill-search\` and every other listing in the API, this one applies **no visibility filter**: private skills belonging to other users are returned in full, which is exactly why it sits behind the platform-admin scope. Results are always sorted by creation time, newest first; there is no sort parameter. Use it to find a skill to moderate, then act with \`DELETE /api/v1/admin/skills/{id}\` or the AgentSeal rescan endpoint. For ordinary discovery — including anything an agent does on its own behalf — use the search endpoints instead; they are cheaper and respect visibility. Pagination is page-based rather than cursor-based here, and out-of-range or non-numeric pagination values are silently clamped rather than rejected, so always read \`page\` and \`pageSize\` back off the response instead of assuming the server used what you sent. Both the count and the fetch run under a five-second server-side time limit; a query broad enough to exceed it fails with \`500\` rather than holding the database connection open. ${ADMIN_SCOPE_NOTE}`, + operationId: "listAdminSkills", + tags: ["Admin"], + security: bearerAuth(), + parameters: [ + queryParam( + "page", + "1-based page number. Values below 1, non-numeric values, and a missing parameter all resolve to 1. There is no upper bound — a page past the end returns an empty `items` array with the real `total`.", + { type: "integer", minimum: 1, default: 1, examples: [1] }, + ), + queryParam( + "pageSize", + "Rows per page. Clamped to 1–100; anything outside that range, including a non-numeric value, is clamped rather than rejected. Defaults to 20.", + { type: "integer", minimum: 1, maximum: 100, default: 20, examples: [20] }, + ), + queryParam( + "q", + "Case-insensitive substring filter matched against skill `name` **or** `description`. Regex metacharacters are escaped server-side, so the value is treated as a literal. Omit or send an empty string for no filter. Note this is an unanchored scan — a very short `q` on a large registry is the query most likely to hit the five-second timeout.", + { type: "string", examples: ["pdf"] }, + ), + queryParam( + "userId", + "Restrict the listing to skills owned by this NyxID user id (exact match on the skill's `createdBy`). Take the value from `items[].createdBy` here or from `GET /api/v1/admin/users`. Combines with `q` as a logical AND.", + { type: "string", examples: ["usr_01HXYZ7QK3M2N4P5R6S7T8V9W0"] }, + ), + ], + responses: { + ...jsonResponse(adminSkillListSchema, "One page of skills, newest first.", { + example: { + items: [ + { + guid: "3f2a91c4-0d5b-4a1e-9d2f-7c8b6e5a4310", + name: "web-summarizer", + description: "Summarise a web page into bullet points.", + createdBy: "usr_01HXYZ7QK3M2N4P5R6S7T8V9W0", + createdByEmail: "author@example.com", + createdByDisplayName: "Ada Lovelace", + createdOn: "2026-07-14T09:21:04.113Z", + updatedOn: "2026-08-02T16:40:55.007Z", + isPrivate: false, + tags: ["web", "summarisation"], + }, + ], + total: 1, + page: 1, + pageSize: 20, + totalPages: 1, + }, + }), + ...problemResponses(401, 403, { + 500: "The listing exceeded the five-second server-side query budget, or the database was unreachable. Narrow the filter (a longer `q`, or a `userId`) and retry.", + }), + }, + }, + }, + + [`${prefix}/admin/skills/{id}`]: { + delete: { + summary: "Hard-delete any skill", + description: + `Permanently removes a skill regardless of who owns it: every version row is dropped, every stored package is deleted from object storage, and the skill document itself is removed. There is no soft-delete, no tombstone, and no undo — a subsequent read of the same id or name returns \`404\`, and the name becomes available for reuse. Prefer deprecation or a visibility flip for anything short of abuse. Storage cleanup is best-effort: an object-storage failure is logged and the database rows are still removed, so an orphaned package may survive a partial failure while the API-level delete still reports success. The operation is effectively idempotent from the caller's point of view — the second call answers \`404\`. ${ADMIN_SCOPE_NOTE}`, + operationId: "deleteAdminSkill", + tags: ["Admin"], + security: bearerAuth(), + parameters: [ + pathParam( + "id", + "Skill **guid**. This endpoint resolves by id only — unlike most skill routes it will not accept a skill name, and passing one produces `404` (`code: \"skill_not_found\"`). Read the value from `guid` in `GET /api/v1/admin/skills`.", + { type: "string" }, + "3f2a91c4-0d5b-4a1e-9d2f-7c8b6e5a4310", + ), + ], + responses: { + ...jsonResponse(successFlagSchema, "The skill, all of its versions, and its packages were deleted.", { + example: { success: true }, + }), + ...problemResponses(401, 403, { + 404: "No skill with this guid (`code: \"skill_not_found\"`). Also returned when a skill *name* was passed instead of a guid.", + }), + }, + }, + }, + + [`${prefix}/admin/skills/{idOrName}/versions/{version}/agentseal-rescan`]: { + post: { + summary: "Re-run the AgentSeal scan on one skill version", + description: + `Re-downloads the immutable package for a single published version, runs the AgentSeal static safety sweep over it, and overwrites that version's stored trust score and findings with the result. Two reasons to call it: a false positive that a newer AgentSeal ruleset has since fixed, and picking up a rules update without waiting for the author to publish again. Only this one version is touched — sibling versions keep their existing scores. The scan is synchronous and can take a while on a large package, so use a generous client timeout rather than retrying on a slow response; a retry runs the whole scan again. No request body is required. Note the \`null\` case in the response: when the deployment has a scanner wired but the scan yields no result, the response is \`200\` with \`scan: null\` and **nothing is persisted** — the previous snapshot survives untouched. Treat \`null\` as "unknown", never as "clean". ${ADMIN_SCOPE_NOTE}`, + operationId: "rescanSkillVersionAgentSeal", + tags: ["Admin"], + security: bearerAuth(), + parameters: [ + pathParam( + "idOrName", + "Skill guid **or** registry-unique skill name. The guid is tried first, then the name, so either works.", + { type: "string" }, + "web-summarizer", + ), + pathParam( + "version", + "Exact `.` version to rescan — two non-negative integers, no leading zeroes and no patch component. Dist-tags such as `latest` are **not** resolved here; resolve the tag first via `GET /api/v1/skills/{idOrName}/dist-tags` and pass the literal version.", + { type: "string", pattern: "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$" }, + "1.4", + ), + ], + responses: { + ...jsonResponse(agentsealRescanSchema, "The scan completed. When `scan` is non-null it has already been persisted onto the version document.", { + example: { + skillGuid: "3f2a91c4-0d5b-4a1e-9d2f-7c8b6e5a4310", + skillName: "web-summarizer", + version: "1.4", + scan: { + score: 92, + findings: [], + scannedAt: "2026-08-07T04:12:30.442Z", + agentsealVersion: "0.9.3", + scannedFiles: 11, + }, + }, + }), + ...problemResponses( + { + 400: "The `version` path segment is not a valid `.` string (`code: \"invalid_version\"`). Dist-tag names land here too.", + }, + 401, + 403, + { + 404: "No such skill (`code: \"skill_not_found\"`), or the skill exists but has no such version (`code: \"skill_version_not_found\"`).", + 500: "The package could not be downloaded from object storage, or the scanner crashed mid-sweep. The stored snapshot is unchanged; retry.", + 503: + "This deployment has no AgentSeal scanner wired (`code: \"agentseal_disabled\"`) — common in development and CI images that ship without the scanner binary. Nothing was scanned and nothing was written; retrying will not help until the deployment is reconfigured. **Body shape warning:** this particular 503 is emitted inline as the legacy `{ \"data\": null, \"error\": { \"code\", \"message\" } }` envelope under `application/json`, not as the RFC 7807 document shown here.", + }, + ), + }, + }, + }, + + [`${prefix}/admin/users`]: { + get: { + summary: "List platform users", + description: + `The admin user roster, one role bucket at a time, with per-user activity and authorship counts. The pool is Ornn's own user directory — a row appears the first time a user authenticates against Ornn, so this is not a mirror of the NyxID account list and a NyxID user who has never called Ornn will not be here. Use it to find the \`userId\` that other admin endpoints take (skill filtering, quota administration, launch-promo awards). Sorting happens in the application rather than the database, which is why the role pool is hard-bounded at 5000 rows: the query returns at most 5000 matching users in the database's natural order, and only then are the skill counts joined, the sort applied, and the page sliced. On a bucket bigger than that, \`total\` saturates at exactly 5000, \`totalPages\` follows it, and which users were dropped is not determined by any ordering you can see here — narrow with \`q\` rather than paging deeper. Pagination values are clamped silently, but \`role\`, \`sort\`, and \`dir\` are validated strictly and reject unknown values with \`400\`. ${ADMIN_SCOPE_NOTE}`, + operationId: "listAdminUsers", + tags: ["Admin"], + security: bearerAuth(), + parameters: [ + queryParam( + "role", + "Which bucket to list. `admin` = users whose most recent token carried the platform-admin scope; `normal` = everyone else. The two buckets are disjoint and there is no \"all\" option — call twice to see the whole directory. Defaults to `normal`. Any other value is a `400` (`code: \"invalid_role\"`).", + { type: "string", enum: ["admin", "normal"], default: "normal" }, + ), + queryParam( + "page", + "1-based page number. Values below 1 and non-numeric values resolve to 1. Defaults to 1.", + { type: "integer", minimum: 1, default: 1, examples: [1] }, + ), + queryParam( + "pageSize", + "Rows per page. Clamped to 1–200 rather than rejected. Defaults to 20.", + { type: "integer", minimum: 1, maximum: 200, default: 20, examples: [50] }, + ), + queryParam( + "q", + "Case-insensitive filter over the directory, matched as an `email` **prefix** OR a `displayName` **substring**. Mind the asymmetry: the email side is anchored at the start, so `q=\"ada@\"` matches `ada@example.com` but `q=\"@example.com\"` matches no addresses at all however many users are on that domain; the display-name side is unanchored, so `q=\"Lovelace\"` hits `Ada Lovelace`. Regex metacharacters are escaped server-side, so the value is always treated as a literal. Applied before sorting and pagination; whitespace is trimmed and an all-whitespace value counts as absent.", + { type: "string", examples: ["ada@"] }, + ), + queryParam( + "sort", + "Column to sort by. Defaults to `lastActiveAt`. Rows whose sort value is null always sort last regardless of `dir`. An unrecognised value is a `400` (`code: \"invalid_sort\"`).", + { + type: "string", + enum: ["displayName", "email", "skillCount", "lastActiveAt", "activityCount", "firstJoinedAt"], + default: "lastActiveAt", + }, + ), + queryParam( + "dir", + "Sort direction. Defaults to `desc`, which pairs with the default `lastActiveAt` sort to put the most recently active users first. An unrecognised value is a `400` (`code: \"invalid_dir\"`).", + { type: "string", enum: ["asc", "desc"], default: "desc" }, + ), + ], + responses: { + ...jsonResponse(adminUserListSchema, "One page of users in the requested role bucket.", { + example: { + items: [ + { + userId: "usr_01HXYZ7QK3M2N4P5R6S7T8V9W0", + email: "ada@example.com", + displayName: "Ada Lovelace", + skillCount: 12, + lastActiveAt: "2026-08-07T03:55:12.004Z", + activityCount: 4821, + firstJoinedAt: "2026-02-11T08:02:44.910Z", + }, + ], + page: 1, + pageSize: 20, + total: 1, + totalPages: 1, + }, + }), + ...problemResponses( + { + 400: "`role`, `sort`, or `dir` carried a value outside its enum (`code`: `invalid_role` / `invalid_sort` / `invalid_dir`).", + }, + 401, + 403, + ), + }, + }, + }, + + [`${prefix}/admin/dashboard/stats`]: { + get: { + summary: "Platform totals", + description: + `Two tiles of counters — users split by role, skills split by system / public / private — computed live on every call with no caching. Both groupings are exact partitions, so the sub-counts always add up to their total; that property is worth asserting on if you build alerting off this. It is the cheapest single call for a health-at-a-glance view, but it is not a time series: there is no history, no delta, and no date range. For trends and for the per-event activity feed that used to live next to this endpoint, use the PostHog dashboard the deployment is wired to instead. Takes no parameters. ${ADMIN_SCOPE_NOTE}`, + operationId: "getAdminDashboardStats", + tags: ["Admin"], + security: bearerAuth(), + parameters: [], + responses: { + ...jsonResponse(dashboardStatsSchema, "Current totals.", { + example: { + users: { total: 418, admin: 3, normal: 415 }, + skills: { total: 1204, system: 14, public: 902, private: 288 }, + }, + }), + ...problemResponses(401, 403), + }, + }, + }, + + [`${prefix}/admin/settings`]: { + get: { + summary: "Read platform settings", + description: + `Returns the singleton platform-settings document: the audit waiver threshold and the legacy single-provider LLM override. This is a small, legacy surface — the modern, section-based configuration lives under \`/admin/settings/{section}\` and \`/admin/settings/llm-providers\`, and the whole-configuration snapshot is \`GET /api/v1/admin/settings/export\`. Reads are served from a short-lived in-process cache, so a value written through the PATCH below is visible immediately on the pod that handled the write but may take up to about thirty seconds to appear on other pods; do not use this endpoint as a strongly-consistent read-back. \`llmProvider.apiKey\` comes back mid-masked and can be echoed straight back into the PATCH body to leave it unchanged. ${ADMIN_SCOPE_NOTE}`, + operationId: "getPlatformSettings", + tags: ["Admin"], + security: bearerAuth(), + parameters: [], + responses: { + ...jsonResponse(platformSettingsSchema, "Current platform settings, secrets mid-masked.", { + example: { + auditWaiverThreshold: 6, + llmProvider: { gatewayUrl: "https://api.openai.com/v1", apiKey: "sk-p••••••••3f9a" }, + }, + }), + ...problemResponses(401, 403), + }, + }, + patch: { + summary: "Update platform settings", + description: + `Partial update of the platform-settings singleton. Only two keys are recognised, \`auditWaiverThreshold\` and \`llmProvider\`; anything else in the body is ignored, and a body containing none of them is rejected with \`400\` rather than treated as a no-op — that is the guard against a typo'd key silently doing nothing. Validation is per-field and fail-fast: the first bad field aborts the request and nothing is written. \`llmProvider\` is itself a partial — an omitted \`gatewayUrl\` or \`apiKey\` carries the stored value forward — and an \`apiKey\` still containing the \`•\` mask sentinel preserves the existing secret, so the safe pattern is GET, edit the fields you care about, PATCH the whole object back. The response is the full updated document, re-masked, read back through the cache-busting path. ${ADMIN_SCOPE_NOTE}`, + operationId: "patchPlatformSettings", + tags: ["Admin"], + security: bearerAuth(), + parameters: [], + requestBody: jsonBody(platformSettingsPatchSchema, "The settings keys to change.", { + example: { + auditWaiverThreshold: 7.5, + llmProvider: { gatewayUrl: "https://api.anthropic.com/v1", apiKey: "sk-p••••••••3f9a" }, + }, + }), + responses: { + ...jsonResponse(platformSettingsSchema, "The updated settings, secrets mid-masked.", { + // Response to the request example above: `gatewayUrl` took the new + // value, and the masked `apiKey` that was echoed back preserved the + // stored secret, so it re-masks to exactly what the GET returned. + example: { + auditWaiverThreshold: 7.5, + llmProvider: { gatewayUrl: "https://api.anthropic.com/v1", apiKey: "sk-p••••••••3f9a" }, + }, + }), + ...problemResponses( + { + 400: "The body was not a JSON object (`code: \"invalid_body\"`), contained no recognised setting key, or a recognised key failed its field check — out-of-range threshold, non-object `llmProvider`, non-string or unparseable `gatewayUrl`, non-string `apiKey` (`code: \"invalid_setting\"`).", + }, + 401, + 403, + ), + }, + }, + }, + + [`${prefix}/admin/settings/export`]: { + get: { + summary: "Export the full platform configuration", + description: + `Produces a portable snapshot of every settings section — the ten configuration sections plus a read-only view of the configured LLM providers — wrapped in a versioned envelope that \`POST /api/v1/admin/settings/import\` accepts verbatim. Use it to clone a deployment's configuration, to diff staging against production, or to keep a reviewable backup before a risky change. Every secret field is replaced with the string sentinel \`\`, so the file is safe to commit or attach to a ticket; it also means the file alone cannot stand up a new deployment, and the target keeps its own secrets when the sentinel is imported unchanged. Two further gaps to plan around: per-model enable and default flags are not exported (the model catalog is derived data — re-sync it on the target), and the LLM providers block is exported but never applied on import. Despite the \`Content-Disposition\` header this is a normal enveloped JSON response, not a file stream: read the document from \`data\`, and use the header only if you want the server's suggested filename. ${ADMIN_SCOPE_NOTE}`, + operationId: "exportPlatformSettings", + tags: ["Admin"], + security: bearerAuth(), + parameters: [], + responses: { + ...jsonResponse(exportEnvelopeSchema, "The complete settings snapshot, secrets replaced with redaction sentinels.", { + headers: { + "Content-Disposition": { + description: + "`attachment` with a generated filename of the form `ornn-settings--.json`, where `` is the deployment's configured environment name. Advisory only — the body is still the standard `{ data, error }` envelope, so a client saving the response verbatim would be saving the envelope, not the export document.", + schema: { type: "string" }, + example: 'attachment; filename="ornn-settings-prod-2026-08-07T04-12-30-442Z.json"', + }, + }, + }), + ...problemResponses(401, 403), + }, + }, + }, + + [`${prefix}/admin/settings/import`]: { + post: { + summary: "Import a platform configuration snapshot", + description: + `Applies an export envelope to this deployment, one section at a time. **Read the result body, not just the status code:** short of a transport-level failure this endpoint answers \`200\` even when nothing was applied — a wrong \`schemaVersion\`, a section that fails validation, and a section whose write threw all surface as entries in \`data.sections[]\` with the roll-up in \`data.aggregateStatus\`. A \`200\` alone is not confirmation of success.\n\nThe apply is per-section atomic but not transactional across sections: sections are processed in registry order, each valid one is written on its own, and a failure part-way through leaves the earlier sections applied. \`aggregateStatus: "partial"\` is the signal for that state. A \`schemaVersion\` mismatch is the one hard stop — it aborts before any write.\n\nAlways dry-run first. Send \`dryRun: true\` in the body (or \`?dryRun=1\`) to validate every section and write nothing. The two sources are OR-ed rather than ranked: dry-run is on as soon as either says so, so \`{"dryRun": false}\` cannot force a real write past a \`?dryRun=1\` on the URL — drop the query parameter instead. Note the asymmetry: a dry-run section reports \`applied\` with an empty \`changedFields\`, so a dry run proves the payload validates but tells you nothing about what would change.\n\nSecret handling mirrors the export: a secret field still carrying \`\` or a mid-mask sentinel preserves the target's existing value instead of overwriting it, so importing a redacted file never destroys credentials on the target. LLM providers are accepted and reported but never applied in v1. ${ADMIN_SCOPE_NOTE}`, + operationId: "importPlatformSettings", + tags: ["Admin"], + security: bearerAuth(), + parameters: [ + queryParam( + "dryRun", + "Validate without writing. Accepts the literal strings `1` or `true`; anything else, including `0` and `false`, is treated as absent. OR-ed with the body field `dryRun`: setting either one turns the dry run on, and neither can switch the other off. Prefer the body, and use the query form only for shell clients that cannot easily edit the payload.", + { type: "string", enum: ["1", "true"] }, + ), + ], + requestBody: jsonBody(importBodySchema, "An export envelope, optionally with `dryRun`.", { + example: { + schemaVersion: 1, + dryRun: true, + sections: { + playground: { defaultProviderId: "prv_openai", defaultModelId: "gpt-4o", sseKeepAliveMs: 15000, defaultMonthlyQuota: 200 }, + telemetry: { + postHogEnabled: true, + postHogApiKey: "", + postHogHost: "https://eu.i.posthog.com", + postHogProjectId: "41822", + postHogErrorSampleRate: 0.1, + }, + }, + }, + }), + responses: { + ...jsonResponse( + importResultSchema, + "The import was processed. Inspect `aggregateStatus` and every entry in `sections[]` — this status is returned for rejected and partially-applied imports too.", + { + // Paired with the request example above: a dry run carrying only + // `playground` and `telemetry`. Every known section gets an entry + // in registry order — the eight the request omitted come back + // `skipped` — and dry-run sections report `applied` with an empty + // `changedFields`, which is why the roll-up reads `applied` even + // though nothing was written. + example: { + schemaVersion: 1, + aggregateStatus: "applied", + sections: [ + { id: "playground", status: "applied", changedFields: [] }, + { id: "skillGen", status: "skipped" }, + { id: "assistant", status: "skipped" }, + { id: "mirror", status: "skipped" }, + { id: "nyxid", status: "skipped" }, + { id: "skillAudit", status: "skipped" }, + { id: "telemetry", status: "applied", changedFields: [] }, + { id: "extras", status: "skipped" }, + { id: "launchPromo", status: "skipped" }, + { id: "sourceSync", status: "skipped" }, + ], + }, + }, + ), + ...problemResponses( + { + 400: "The request body was not valid JSON, or was not a JSON object (`code: \"invalid_body\"`). Semantic problems with an otherwise well-formed envelope are reported in the 200 body instead.", + }, + 401, + 403, + { + 413: "The body exceeded the deployment's import size cap (1 MiB by default) and was rejected before parsing (`code: \"payload_too_large\"`). Trim the envelope to the sections you actually need. **Body shape warning:** this 413 is emitted inline as the legacy `{ \"data\": null, \"error\": { \"code\", \"message\" } }` envelope under `application/json`, not as the RFC 7807 document shown here.", + }, + ), + }, + }, + }, + + [`${prefix}/admin/mirror/reconcile`]: { + post: { + summary: "Trigger a full GitHub mirror reconcile", + description: + `Starts a full reconcile of every mirror-eligible skill against the configured GitHub repository and returns immediately with \`202\` — the work runs in the background on the pod that accepted the request, and the response says only that it started. Nothing about the outcome is available from this call. Takes no request body.\n\nPolling for completion is the subtle part. \`GET /api/v1/admin/mirror/status\` reports the last **scheduled** run and does not track manual ones, so the practical signal that a manual reconcile finished is the \`counts\` block in that response settling (\`lagging\` and \`neverSynced\` falling to their expected values). Do not wait on \`scheduledRun\`.\n\nThe in-flight guard is per-pod and in-memory: a second call routed to the same pod while a run is active gets \`409\`, but two pods can each start a run at the same moment. The scheduled path is protected properly; this manual path accepts that small risk, whose worst case is a duplicate-tag conflict logged by the loser. Use it after changing mirror settings or to recover from a failed scheduled run; the daily cron covers steady state. ${ADMIN_SCOPE_NOTE}`, + operationId: "triggerMirrorReconcile", + tags: ["Admin"], + security: bearerAuth(), + parameters: [], + responses: { + ...jsonResponse(reconcileAcceptedSchema, "The reconcile was accepted and is now running in the background. No work has been done yet when this response is sent.", { + status: 202, + example: { status: "running", startedAt: "2026-08-07T04:12:30.442Z" }, + }), + ...problemResponses(401, 403, { + 409: + "A reconcile started by this pod is still running (`code: \"reconcile_already_running\"`); the message quotes its start time. Wait for the mirror counts to settle and retry. **Body shape warning:** this 409 is emitted inline as the legacy `{ \"data\": null, \"error\": { \"code\", \"message\" } }` envelope under `application/json`, not as the RFC 7807 document shown here.", + 503: + "The mirror is switched off, or it is on but incompletely configured — missing owner/repo/branch or GitHub App credentials (`code: \"mirror_disabled\"`; the message distinguishes the two). Fix the configuration under `/admin/settings/mirror` first; retrying changes nothing. **Body shape warning:** this 503 is emitted inline as the legacy `{ \"data\": null, \"error\": { \"code\", \"message\" } }` envelope under `application/json`, not as the RFC 7807 document shown here.", + }), + }, + }, + }, + + [`${prefix}/admin/mirror/status`]: { + get: { + summary: "GitHub mirror status and configuration", + description: + `One call that answers "is the mirror configured, is it healthy, and how far behind is it": the full mirror configuration (private key mid-masked), live drift counts over every public skill, and the outcome of the most recent scheduled run. It exists as a single endpoint so an operator view renders without a second round-trip, which is why the configuration block is duplicated here from \`/admin/settings/mirror\`.\n\nThe counts are computed live by scanning public skills, so this is not a free call — poll it on the order of seconds, not continuously. \`counts.lagging\` plus \`counts.neverSynced\` is the practical backlog, and \`counts.oldestUnsyncedAt\` is the age of the worst offender.\n\nThe \`scheduledRun\` block is cluster-wide and persisted, so it survives pod restarts — but it covers **scheduled** fires only. A run started through \`POST /api/v1/admin/mirror/reconcile\` never appears there; watch the counts instead. Takes no parameters. ${ADMIN_SCOPE_NOTE}`, + operationId: "getMirrorStatus", + tags: ["Admin"], + security: bearerAuth(), + parameters: [], + responses: { + ...jsonResponse(mirrorStatusSchema, "Mirror configuration, drift counts, and the last scheduled run.", { + example: { + enabled: true, + repo: { owner: "ChronoAIProject", repo: "ornn-skills", branch: "main" }, + appId: "1284412", + installationId: "78221093", + appPrivateKey: "----••••••••----", + counts: { eligible: 902, synced: 874, lagging: 21, neverSynced: 7, oldestUnsyncedAt: "2026-06-30T11:02:19.884Z" }, + scheduledRun: { + status: "succeeded", + lastRunAt: "2026-08-07T02:00:00.000Z", + lastFinishedAt: "2026-08-07T02:04:41.219Z", + lastDurationMs: 281219, + lastError: null, + nextRunAt: "2026-08-08T02:00:00.000Z", + }, + }, + }), + ...problemResponses(401, 403), + }, + }, + }, + + [`${prefix}/admin/launch-promo/award/{userId}`]: { + post: { + summary: "Manually award the launch promo to a user", + description: + `Grants one launch-promo claim to a specific user: mints a redemption code carrying the configured Playground and Skill-Generation credits, records an append-only claim row, and drops an in-app notification containing the code. Takes no request body — the grant amounts, slot cap, and code expiry all come from the \`launchPromo\` settings section, so configure that section before calling this.\n\nEvery eligibility rule is enforced server-side and each failure gets its own status: the promo must be enabled (\`400\`), the user must exist in Ornn's directory (\`404\`), their registration rank must fall within the slot cap (\`403\`), free slots must remain (\`409\`), and they must not already have claimed (\`409\`). None of those are retryable without changing configuration or picking a different user.\n\nIdempotent by construction: the claim row is keyed on the user id, so a duplicate call — including two racing calls — yields exactly one award and \`ALREADY_CLAIMED\` for the loser. Safe to retry blindly after a network timeout. The credits are **not** applied directly; the user redeems the returned code themselves. The response is the only place the code string is returned, but the user already has it in their notification, so relaying it manually is normally unnecessary. Note the status code: this creates a claim yet answers \`200\`, not \`201\`, and sends no \`Location\` header. ${ADMIN_SCOPE_NOTE}`, + operationId: "awardLaunchPromo", + tags: ["Admin"], + security: bearerAuth(), + parameters: [ + pathParam( + "userId", + "NyxID user id of the recipient, exactly as it appears in `GET /api/v1/admin/users`. Not an email and not a display name — an unknown id is a `404`.", + { type: "string" }, + "usr_01HXYZ7QK3M2N4P5R6S7T8V9W0", + ), + ], + responses: { + ...jsonResponse(launchPromoAwardSchema, "The claim was recorded and a redemption code was minted. The notification carrying the code is best-effort: a delivery failure is logged and does not roll back the award.", { + example: { + claim: { + userId: "usr_01HXYZ7QK3M2N4P5R6S7T8V9W0", + eligibilityRank: 37, + redemptionCodeId: "rc_01HXYZ8M2N4P5R6S7T8V9W0AB", + redemptionCode: "ORNN-LAUNCH-7QK3-M2N4", + awardedAt: "2026-08-07T04:12:30.442Z", + awardedBy: "usr_01HADMIN4P5R6S7T8V9W0XYZ", + }, + }, + }), + ...problemResponses( + { + 400: "The launch promo is disabled, or it is enabled but configured with zero credits on both surfaces, which would mint a useless code (`code: \"PROMO_DISABLED\"`). Fix the `launchPromo` settings section first.", + }, + 401, + { + 403: "Either the caller lacks `ornn:admin:skill` (`code: \"forbidden\"`), or the target user's registration rank is past the configured slot cap and they were never eligible (`code: \"RANK_EXCEEDED\"`). Branch on `code` — the two are unrelated.", + 404: "No such user in Ornn's directory (`code: \"USER_NOT_FOUND\"`). The user must have authenticated against Ornn at least once before they can be awarded.", + 409: "Either the user has already claimed (`code: \"ALREADY_CLAIMED\"`, also returned when two callers raced and the other one won) or every configured slot is already taken (`code: \"SLOTS_EXHAUSTED\"`).", + 500: "The award failed for a reason outside the eligibility rules — typically minting the redemption code (`code: \"LAUNCH_PROMO_ERROR\"`). Retry is safe: the claim row is written only after the code exists, and its primary key prevents a double award.", + }, + ), + }, + }, + }, + + [`${prefix}/admin/launch-promo/recent`]: { + get: { + summary: "List recent launch-promo awards", + description: + `Observability over the launch promo: the most recently awarded claims, newest first, spanning both manual admin awards and automated ones (\`awardedBy\` distinguishes them — a real user id versus the sentinel \`system:cron\`). Use it to confirm an award landed, to audit who granted what, and to sanity-check burn rate against the configured slot cap. For the remaining-slot count itself, read \`slotsRemaining\` from \`GET /api/v1/me/launch-promo\` — that is the only endpoint that computes it. The \`launchPromo\` settings section carries \`totalSlots\`, the configured cap, not the remainder.\n\nThere is no cursor and no total: the only control is \`limit\`, and to look further back you raise it. Redemption code **strings** are deliberately not exposed — only their ids — so this endpoint cannot be used to harvest unredeemed codes. Awards are append-only, so a row that appears here never changes or disappears. ${ADMIN_SCOPE_NOTE}`, + operationId: "listRecentLaunchPromoAwards", + tags: ["Admin"], + security: bearerAuth(), + parameters: [ + queryParam( + "limit", + "Maximum claims to return, newest first. Clamped to 1–500 rather than rejected, so an out-of-range or non-numeric value is silently corrected. Defaults to 50.", + { type: "integer", minimum: 1, maximum: 500, default: 50, examples: [50] }, + ), + ], + responses: { + ...jsonResponse(launchPromoRecentSchema, "The most recent claims, newest first.", { + example: { + items: [ + { + userId: "usr_01HXYZ7QK3M2N4P5R6S7T8V9W0", + eligibilityRank: 37, + redemptionCodeId: "rc_01HXYZ8M2N4P5R6S7T8V9W0AB", + awardedAt: "2026-08-07T04:12:30.442Z", + awardedBy: "usr_01HADMIN4P5R6S7T8V9W0XYZ", + githubLogin: null, + }, + ], + }, + }), + ...problemResponses(401, 403), + }, + }, + }, + }; +} diff --git a/ornn-api/src/openapi/paths/adminQuota.ts b/ornn-api/src/openapi/paths/adminQuota.ts new file mode 100644 index 00000000..4ffdde1a --- /dev/null +++ b/ornn-api/src/openapi/paths/adminQuota.ts @@ -0,0 +1,1006 @@ +/** + * Admin quota administration and redemption codes (#1214). + * + * Nine operator-only operations across two collaborating domains: + * + * - **Quota** (`/admin/quota/*`) — inspect what non-admin users have + * spent this calendar month, read one user's month-by-month history, + * and top a user (or 500 users) up with a direct grant. Every grant + * appends a row to an audit trail that is itself readable here. + * - **Redemption codes** (`/admin/redemption-codes/*`) — the *deferred* + * form of the same grant. Instead of crediting a known `userId`, an + * admin mints a single-use code carrying a bundle of per-surface + * grants and hands it out; whoever redeems it at + * `POST /api/v1/me/redemption-codes/redeem` credits themselves. + * Because minting is a grant with the recipient left blank, it sits + * behind the same permission as a direct grant. + * + * The bucket model these operations manipulate is defined in + * `domains/quota/types.ts`: one bucket per (`userId`, `surface`, + * `monthMarker`), where `remaining = defaultAllotment + adminGrant − + * used`. Buckets are **calendar-month, UTC, no carry-over** — a grant + * applied on the 30th evaporates at the rollover a day later. Every + * write here therefore targets the *current* month and only the current + * month; there is no API for pre-funding a future month. + * + * Only two surfaces are grantable: `playground` and `skillGen`. The + * assistant surface (#970) is metered and charged like the others but is + * neither admin-grantable nor redeemable in v1, so it is absent from + * every enum in this module. That list comes from `SURFACES`, which is + * imported rather than transcribed so the spec cannot drift from the + * runtime enum. + * + * Schema provenance: the mint request body is generated from + * `mintCodeSchema` — the same Zod schema `validateBody` runs — and then + * decorated with per-field prose. Everything else is hand-written JSON + * Schema, because it has no Zod source at all: the two grant bodies are + * module-private consts inside `domains/admin/quota/routes.ts`, and every + * response payload in this domain is projected inline by its handler off + * TypeScript interfaces (`QuotaGrantAuditDoc`, `RedemptionCodeDoc`, the + * `serializeCode()` mapper) with no schema behind it. + * + * @module openapi/paths/adminQuota + */ + +import { + bearerAuth, + jsonBody, + jsonResponse, + pathParam, + problemResponses, + queryParam, + toSchema, + type JsonSchema, + type PathMap, +} from "../helpers"; +import { QUOTA_ADMIN_PERMISSION, SURFACES } from "../../domains/quota/types"; +import { mintCodeSchema, REDEMPTION_CODE_STATUSES } from "../../domains/redemption-codes/types"; + +// --------------------------------------------------------------------------- +// Shared prose +// --------------------------------------------------------------------------- + +/** + * Appended to every description in this module. All nine operations are + * gated by the identical `requirePermission(QUOTA_ADMIN_PERMISSION)` + * middleware, so the sentence is written once and the scope string is + * imported rather than typed out — the enum and the docs cannot drift. + */ +const SCOPE_NOTE = + `Operator-only: the caller's NyxID identity token must carry the \`${QUOTA_ADMIN_PERMISSION}\` ` + + "permission. A token without it answers `403 forbidden`, and no token at all answers " + + "`401 auth_missing`. Check `permissions` on `GET /api/v1/me` before attempting any operation here."; + +/** Surfaces a grant or a redemption code may target, straight from the runtime enum. */ +const GRANTABLE_SURFACES: readonly string[] = [...SURFACES]; + +// --------------------------------------------------------------------------- +// Local schema helpers +// --------------------------------------------------------------------------- + +/** + * Overlay per-field documentation onto a generated JSON Schema. + * + * Constraints (`minItems`, `maximum`, `format`, …) stay owned by the Zod + * schema the handler actually validates against; only the prose is added + * here. Used for the mint body so the published contract carries both + * the real bounds and an explanation of what each field means. + */ +function withFieldDocs(schema: JsonSchema, docs: Record): JsonSchema { + const properties = (schema.properties ?? {}) as Record; + const merged: Record = { ...properties }; + for (const [field, extra] of Object.entries(docs)) { + merged[field] = { ...(properties[field] ?? {}), ...extra }; + } + return { ...schema, properties: merged }; +} + +// --------------------------------------------------------------------------- +// Quota payload schemas +// --------------------------------------------------------------------------- + +const surfaceEnumSchema: JsonSchema = { + type: "string", + enum: GRANTABLE_SURFACES, + description: + "Metered surface the row applies to. `playground` meters `POST /api/v1/playground/chat`; `skillGen` meters `POST /api/v1/skills/generate` and its source/OpenAPI variants.", +}; + +const quotaUserRowSchema: JsonSchema = { + type: "object", + required: ["userId", "email", "displayName", "defaultAllotment", "adminGrant", "used", "remaining"], + properties: { + userId: { + type: "string", + description: + "NyxID user id. This is the value to send as `userId` to `POST /api/v1/admin/quota/grant`, and the `{userId}` path segment of the lifetime endpoint.", + examples: ["8f14e45fceea167a5a36dedd4bea2543"], + }, + email: { + type: "string", + description: + "Last-known email from the user directory mirror, which is refreshed opportunistically on each authenticated request. Always non-empty here: the directory query behind this listing skips users whose mirrored email is blank, so such users never surface in `items` at all — reach their buckets through `GET /api/v1/admin/quota/users/{userId}/lifetime` if you already know the id.", + examples: ["ada@example.com"], + }, + displayName: { + type: "string", + description: "Last-known human label (token `name` claim, falling back to email, then to the user id).", + examples: ["Ada Lovelace"], + }, + defaultAllotment: { + type: "integer", + description: + "The *effective* platform default for this month, i.e. `max(the default snapshotted when the bucket was first touched, the default currently configured in platform settings)`. Raising the platform default hands existing buckets the headroom immediately; lowering it never retroactively shrinks a live bucket.", + examples: [100], + }, + adminGrant: { + type: "integer", + description: + "Credits added on top of the default this month by direct grants and by redemptions. This is the only component an operator can move; it resets to 0 at the UTC month rollover.", + examples: [50], + }, + used: { + type: "integer", + description: + "Units consumed this month, including in-flight reservations. A unit is taken *before* the LLM call and refunded if the run ends in a system error or client abort, so this number can go down as well as up.", + examples: [122], + }, + remaining: { + type: "integer", + description: + "`max(0, defaultAllotment + adminGrant − used)`. When this hits 0 the user's next call to that surface is rejected with `429`; grant them credit to unblock without waiting for the rollover.", + examples: [28], + }, + }, +}; + +const quotaUsersPageSchema: JsonSchema = { + type: "object", + required: ["items", "page", "pageSize", "total", "totalPages", "monthMarker", "monthStart", "monthEnd"], + properties: { + items: { + type: "array", + items: quotaUserRowSchema, + description: + "One row per non-admin user on this page, ordered by most-recently-seen first. Users holding the admin permission are filtered out entirely because they bypass quota and have no meaningful bucket.", + }, + page: { type: "integer", description: "The 1-based page number that was served.", examples: [1] }, + pageSize: { type: "integer", description: "The page size actually applied after clamping into `[1, 100]`.", examples: [20] }, + total: { + type: "integer", + description: + "Rows in the candidate pool this request fetched, after removing admins — **not** the platform-wide user count. The pool is capped at `pageSize × 5` directory rows, so `total` is a lower bound and `totalPages` never exceeds 5. Treat this listing as a recency-ordered typeahead, not as an exhaustive user export.", + examples: [43], + }, + totalPages: { + type: "integer", + description: "`ceil(total / pageSize)`, floored at 1. Bounded by the pool cap described on `total`; asking for a page beyond it returns an empty `items`.", + examples: [3], + }, + monthMarker: { + type: "string", + description: "The bucket month these figures belong to, as `YYYY-MM` in UTC. Always the current month — this endpoint has no historical mode; use the lifetime endpoint for that.", + examples: ["2026-08"], + }, + monthStart: { + type: "string", + format: "date-time", + description: "Inclusive start of the bucket month (ISO-8601, UTC midnight on the 1st).", + examples: ["2026-08-01T00:00:00.000Z"], + }, + monthEnd: { + type: "string", + format: "date-time", + description: "Exclusive end of the bucket month — equivalently, the instant every bucket in this response is abandoned and the next month's counters start from zero.", + examples: ["2026-09-01T00:00:00.000Z"], + }, + }, +}; + +const lifetimeBucketSchema: JsonSchema = { + type: "object", + required: ["monthMarker", "monthStart", "monthEnd", "used", "defaultAllotment", "adminGrant", "usedByModel"], + properties: { + monthMarker: { type: "string", description: "Bucket month as `YYYY-MM` in UTC.", examples: ["2026-07"] }, + monthStart: { type: "string", format: "date-time", description: "Inclusive start of the month (ISO-8601, UTC)." }, + monthEnd: { type: "string", format: "date-time", description: "Exclusive end of the month (ISO-8601, UTC)." }, + used: { type: "integer", description: "Units consumed in that month. For the current month this includes in-flight reservations.", examples: [37] }, + defaultAllotment: { + type: "integer", + description: + "The default snapshotted into this bucket at first touch. Unlike the current-month listing, historical rows are **not** re-maxed against today's platform default — they report what was stored.", + examples: [100], + }, + adminGrant: { type: "integer", description: "Credits granted into that month by admins or redemptions.", examples: [50] }, + usedByModel: { + type: "object", + additionalProperties: { type: "integer" }, + description: + "Per-model breakdown of `used` for chargeable runs, as `{ modelId: count }`. Keys are model ids with `.` and `$` replaced by `_` (MongoDB path restriction), and runs whose model could not be determined are tallied under `__unknown__`. `{}` for months recorded before per-model tallying, and the sum of the values may be lower than `used` because reservations are counted at reserve time but tallied only on a chargeable outcome.", + examples: [{ "claude-sonnet-4-6": 30, "gpt-5_2": 7 }], + }, + }, +}; + +const lifetimeSchema: JsonSchema = { + type: "object", + required: ["items", "currentMonth"], + properties: { + items: { + type: "array", + items: lifetimeBucketSchema, + description: + "Every month this user has a bucket for on the requested surface, oldest first. Months in which the user made no call have no bucket and are simply absent — the series is sparse, so do not index it positionally. Empty for an unknown or never-active user.", + }, + currentMonth: { + type: "string", + description: + "The server's current `YYYY-MM` marker in UTC. Compare against the last `items[].monthMarker` to tell whether the tail row is the live bucket (still moving) or a closed historical one.", + examples: ["2026-08"], + }, + }, +}; + +const grantResultSchema: JsonSchema = { + type: "object", + required: ["auditId", "applied", "monthMarker", "newAdminGrant"], + properties: { + auditId: { + type: "string", + description: + "Id of the audit row this grant appended, a server-generated UUID. Look it up as `_id` in `GET /api/v1/admin/quota/grants` — that is the only way to confirm, after a lost response, whether a grant actually landed.", + examples: ["a3f1c2e4-9d4b-4e3f-90ab-42c9665f1c2a"], + }, + applied: { type: "integer", enum: [1], description: "Always `1`. Present so the single-grant and bulk-grant responses can be read by the same client code." }, + monthMarker: { + type: "string", + description: "The bucket month the credit landed in, as `YYYY-MM` in UTC. Always the current month; the credit disappears at the rollover.", + examples: ["2026-08"], + }, + newAdminGrant: { + type: "integer", + description: + "The target's total `adminGrant` for that month *after* this increment — not the amount granted. Compare against the value you expected to detect a duplicate submission.", + examples: [250], + }, + }, +}; + +const bulkGrantRowSchema: JsonSchema = { + type: "object", + required: ["userId", "ok"], + properties: { + userId: { type: "string", description: "The target user id this row reports on.", examples: ["8f14e45fceea167a5a36dedd4bea2543"] }, + ok: { type: "boolean", description: "Whether the credit landed for this user. Rows are independent — a `false` row does not roll back the `true` rows before it." }, + auditId: { type: "string", description: "Audit row id (a server-generated UUID), present only when `ok` is `true`.", examples: ["a3f1c2e4-9d4b-4e3f-90ab-42c9665f1c2a"] }, + error: { + type: "string", + description: + "Failure message, present only when `ok` is `false`. Free-form and intended for an operator to read — do not branch on its text. These are infrastructure failures (the bucket write or the audit insert did not go through), never input problems: `surface` and `amount` are single values shared by the whole batch and the body is validated before any row is attempted, so bad input rejects the entire call with a `400` rather than producing failed rows.", + }, + }, +}; + +const bulkGrantResultSchema: JsonSchema = { + type: "object", + required: ["applied", "requested", "monthMarker", "results"], + properties: { + applied: { type: "integer", description: "How many rows succeeded. Strictly less than `requested` on a partial failure — which is still reported as `200`.", examples: [498] }, + requested: { + type: "integer", + description: + "How many *distinct* user ids were processed. The server de-duplicates `userIds` before granting, so this can be lower than the array length you sent; a duplicated id is credited once, not twice.", + examples: [500], + }, + monthMarker: { type: "string", description: "Bucket month every credit landed in, as `YYYY-MM` in UTC.", examples: ["2026-08"] }, + results: { + type: "array", + items: bulkGrantRowSchema, + description: "One row per distinct user id, in submission order. Always inspect this — a `200` does not mean every row succeeded.", + }, + }, +}; + +const grantAuditRowSchema: JsonSchema = { + type: "object", + required: ["_id", "adminUserId", "adminEmail", "adminDisplayName", "targetUserId", "surface", "amount", "monthMarker", "createdAt"], + properties: { + _id: { + type: "string", + description: + "Audit row id — the value returned as `auditId` by the grant endpoints. Note the underscore: this document is projected straight out of MongoDB, so unlike the rest of the API the identifier field is `_id`, not `id`. It is a UUID generated by the server, **not** an ObjectId hex — do not confuse it with `code.id` on the redemption-code operations, which is one.", + examples: ["a3f1c2e4-9d4b-4e3f-90ab-42c9665f1c2a"], + }, + adminUserId: { + type: "string", + description: + "Who issued the grant. For rows generated by a redemption this equals `targetUserId`, because redeeming credits the redeemer to themselves — see `note`.", + }, + adminEmail: { type: "string", description: "Issuer's email, snapshotted at grant time so rendering the audit needs no NyxID round-trip." }, + adminDisplayName: { type: "string", description: "Issuer's display name, snapshotted at grant time." }, + targetUserId: { type: "string", description: "Who received the credit." }, + surface: surfaceEnumSchema, + amount: { type: "integer", description: "Credits added by this grant (always positive — negative grants are rejected).", examples: [200] }, + note: { + type: "string", + description: + "Optional free-text reason supplied by the issuer. Redemption-generated rows carry a server-written note of the form `Redeemed code ABCD****`, where only the first four characters of the code are revealed — that prefix is how you tell a redemption apart from a hand-issued grant.", + examples: ["Compensating for the 2026-08-03 outage"], + }, + monthMarker: { type: "string", description: "Bucket month the credit landed in, as `YYYY-MM` in UTC.", examples: ["2026-08"] }, + createdAt: { type: "string", format: "date-time", description: "When the grant was issued (ISO-8601, UTC). Rows are returned newest-first by this field." }, + }, +}; + +const grantAuditPageSchema: JsonSchema = { + type: "object", + required: ["items", "total", "page", "pageSize", "totalPages"], + properties: { + items: { type: "array", items: grantAuditRowSchema, description: "Audit rows for this page, newest first." }, + total: { type: "integer", description: "Total rows matching the filters across all pages. This one *is* an exact count.", examples: [1284] }, + page: { type: "integer", description: "The page actually served, after clamping into `[1, 10000]`.", examples: [1] }, + pageSize: { type: "integer", description: "The page size actually applied, after clamping into `[1, 200]`.", examples: [50] }, + totalPages: { type: "integer", description: "`ceil(total / pageSize)`, floored at 1 — so an empty audit trail still reports `1`.", examples: [26] }, + }, +}; + +// --------------------------------------------------------------------------- +// Redemption-code payload schemas +// --------------------------------------------------------------------------- + +const actorSchema: JsonSchema = { + type: "object", + required: ["userId", "email", "displayName"], + properties: { + userId: { type: "string", description: "NyxID user id of the actor." }, + email: { type: "string", description: "Actor's email, snapshotted at the moment they touched the code." }, + displayName: { type: "string", description: "Actor's display name, snapshotted at the moment they touched the code." }, + }, +}; + +const codeGrantEntrySchema: JsonSchema = { + type: "object", + required: ["surface", "amount"], + properties: { + surface: surfaceEnumSchema, + amount: { type: "integer", minimum: 1, maximum: 100000, description: "Credits this entry adds to the redeemer's current-month bucket for that surface.", examples: [200] }, + }, +}; + +const redemptionCodeSchema: JsonSchema = { + type: "object", + required: [ + "id", + "code", + "grants", + "note", + "status", + "createdAt", + "createdBy", + "expiresAt", + "redeemedAt", + "redeemedBy", + "invalidatedAt", + "invalidatedBy", + ], + properties: { + id: { + type: "string", + description: "Code document id (MongoDB ObjectId hex). Use it as the `{id}` path segment for the detail and invalidate operations.", + examples: ["665f1c2a9d4b7e3f10ab42c9"], + }, + code: { + type: "string", + description: + "The redemption token itself, in canonical upper case. Sixteen characters over the ambiguity-free alphabet `ABCDEFGHJKMNPQRSTUVWXYZ23456789` (no `0`, `O`, `1`, `I`, `L`), so it survives being read aloud or retyped from a screenshot. It is returned in full by mint, list, and detail alike — there is no one-time reveal, so treat any log or UI that renders this field as handling a bearer secret.", + examples: ["K7M2QX9RTVBN4PZ3"], + }, + grants: { + type: "array", + items: codeGrantEntrySchema, + description: "The bundle this code applies on redemption — at most one entry per surface, never empty.", + }, + note: { type: ["string", "null"], description: "Free-text label supplied at mint time, or `null`. Also matched (case-insensitively, as a substring) by the `search` filter on the list endpoint.", examples: ["Launch promo cohort"] }, + status: { + type: "string", + enum: [...REDEMPTION_CODE_STATUSES], + description: + "Lifecycle state. `active` — mintable value still on the table. `redeemed` — consumed by someone; terminal. `invalidated` — retired by an admin before anyone redeemed it; terminal. Note that expiry is **not** a status: an expired code stays `active` and is rejected only at redemption time, so filter on `expiresAt` yourself when hunting dead inventory.", + }, + createdAt: { type: "string", format: "date-time", description: "When the code was minted (ISO-8601, UTC). Listings are ordered newest-first by this field." }, + createdBy: { ...actorSchema, description: "The admin who minted the code, snapshotted at mint time." }, + expiresAt: { + type: "string", + format: "date-time", + description: "After this instant the code can no longer be redeemed (`410 redemption_code_expired`). Set at mint time and immutable thereafter.", + examples: ["2026-12-31T23:59:59.000Z"], + }, + redeemedAt: { type: ["string", "null"], format: "date-time", description: "When the code was consumed, or `null` while `status` is not `redeemed`." }, + redeemedBy: { oneOf: [actorSchema, { type: "null" }], description: "Who consumed the code, or `null` while it has not been redeemed." }, + invalidatedAt: { type: ["string", "null"], format: "date-time", description: "When an admin retired the code, or `null`." }, + invalidatedBy: { oneOf: [actorSchema, { type: "null" }], description: "The admin who retired the code, or `null`." }, + }, +}; + +const codeEnvelopeSchema: JsonSchema = { + type: "object", + required: ["code"], + properties: { + code: { ...redemptionCodeSchema, description: "The full code document. Every single-code operation in this domain wraps its result under this key." }, + }, +}; + +const codeListSchema: JsonSchema = { + type: "object", + required: ["items", "total", "page", "pageSize", "totalPages"], + properties: { + items: { type: "array", items: redemptionCodeSchema, description: "Codes on this page, newest-minted first. Includes the plaintext `code` of every row." }, + total: { type: "integer", description: "Total codes matching the filters across all pages.", examples: [312] }, + page: { type: "integer", description: "The page that was served. Not clamped from above — a page past the end returns an empty `items`.", examples: [1] }, + pageSize: { type: "integer", description: "The page size actually applied, after clamping into `[1, 100]`.", examples: [20] }, + totalPages: { type: "integer", description: "`ceil(total / pageSize)`, floored at 1.", examples: [16] }, + }, +}; + +const CODE_EXAMPLE = { + id: "665f1c2a9d4b7e3f10ab42c9", + code: "K7M2QX9RTVBN4PZ3", + grants: [ + { surface: "playground", amount: 200 }, + { surface: "skillGen", amount: 25 }, + ], + note: "Launch promo cohort", + status: "active", + createdAt: "2026-08-01T12:00:00.000Z", + createdBy: { userId: "8f14e45fceea167a5a36dedd4bea2543", email: "ops@example.com", displayName: "Ops Team" }, + expiresAt: "2026-12-31T23:59:59.000Z", + redeemedAt: null, + redeemedBy: null, + invalidatedAt: null, + invalidatedBy: null, +} as const; + +// --------------------------------------------------------------------------- +// Request bodies +// --------------------------------------------------------------------------- + +const grantBodySchema: JsonSchema = { + type: "object", + required: ["userId", "surface", "amount"], + properties: { + userId: { + type: "string", + minLength: 1, + description: + "NyxID user id of the recipient — the `userId` from `GET /api/v1/admin/quota/users` or `GET /api/v1/users/search`, never an email. It is **not** validated against the directory: a typo silently creates an orphan bucket nobody can spend, so resolve the id first and check the audit trail afterwards.", + examples: ["8f14e45fceea167a5a36dedd4bea2543"], + }, + surface: { + type: "string", + enum: GRANTABLE_SURFACES, + description: + "Which bucket to credit. Only these two surfaces are grantable; `assistant` is metered but not admin-grantable in v1 and is rejected as a validation error.", + examples: ["playground"], + }, + amount: { + type: "integer", + minimum: 1, + maximum: 100000, + description: "Credits to add. Must be a positive integer no greater than 100000. Zero and negative amounts are rejected — there is no API for clawing credit back.", + examples: [200], + }, + note: { + type: "string", + maxLength: 500, + description: "Optional reason, up to 500 characters, stored verbatim on the audit row. Write something an auditor reading it in six months can act on.", + examples: ["Compensating for the 2026-08-03 outage"], + }, + }, +}; + +const bulkGrantBodySchema: JsonSchema = { + type: "object", + required: ["userIds", "surface", "amount"], + properties: { + userIds: { + type: "array", + items: { type: "string", minLength: 1 }, + minItems: 1, + maxItems: 500, + description: + "Recipients, 1–500 NyxID user ids. Duplicates are collapsed server-side before any credit is applied, so a repeated id is credited once. Ids are not validated against the directory. Batch larger cohorts across several calls — each call is one round of sequential grants.", + examples: [["8f14e45fceea167a5a36dedd4bea2543", "c4ca4238a0b923820dcc509a6f75849b"]], + }, + surface: { + type: "string", + enum: GRANTABLE_SURFACES, + description: "Which bucket to credit for every recipient. One surface per call — crediting both surfaces takes two calls.", + examples: ["playground"], + }, + amount: { + type: "integer", + minimum: 1, + maximum: 100000, + description: "Credits to add to each recipient. Positive integer, at most 100000. The same amount goes to every id; there is no per-recipient amount.", + examples: [50], + }, + note: { + type: "string", + maxLength: 500, + description: "Optional reason, up to 500 characters, copied onto every audit row this call appends.", + examples: ["August beta cohort top-up"], + }, + }, +}; + +/** + * Generated from `mintCodeSchema` (the exact schema `validateBody` runs + * on this route) and then annotated. Bounds, the `date-time` format, and + * the `maxItems` ceiling therefore track the runtime validator; only the + * prose is authored here. The duplicate-surface `.refine()` on `grants` + * has no JSON Schema equivalent and is documented in the field text. + */ +const mintBodySchema: JsonSchema = withFieldDocs(toSchema(mintCodeSchema, "input"), { + grants: { + description: + "The grant bundle the code applies on redemption. One to two entries, at most one per surface — a duplicate surface is rejected with `400`, since the redeem path applies each entry independently.", + items: codeGrantEntrySchema, + }, + note: { + description: + "Optional label for the code, up to 500 characters. Visible to admins in the listing and searchable there as a case-insensitive substring; it is never shown to the person redeeming the code.", + examples: ["Launch promo cohort"], + }, + expiresAt: { + description: + "When the code stops being redeemable, as an ISO-8601 UTC timestamp. Must be strictly in the future at mint time. Independent of the credits themselves: a code redeemed on the last day of a month yields credit that expires hours later at the month rollover, so pick an expiry that lands well inside the month you want the credit spent in.", + examples: ["2026-12-31T23:59:59.000Z"], + }, +}); + +// --------------------------------------------------------------------------- +// Shared parameters +// --------------------------------------------------------------------------- + +function surfaceParam(purpose: string): Record { + return queryParam( + "surface", + `${purpose} Optional — defaults to \`playground\` when omitted. Only the two grantable surfaces are accepted; anything else, including the metered-but-not-grantable \`assistant\` surface, is rejected with \`400 invalid_surface\`.`, + { type: "string", enum: GRANTABLE_SURFACES, default: "playground", examples: ["playground"] }, + ); +} + +// --------------------------------------------------------------------------- +// Paths +// --------------------------------------------------------------------------- + +export function adminQuotaPaths(prefix: string): PathMap { + return { + [`${prefix}/admin/quota/users`]: { + get: { + summary: "List per-user quota for the current month", + description: + "Browse what ordinary users have spent on one metered surface this calendar month, one row per user, with the default allotment, admin-granted top-ups, consumption, and remaining balance already reconciled. This is the operator's entry point before issuing a grant: find the user, read `remaining`, then call `POST /api/v1/admin/quota/grant` with the `userId` from the row. " + + "Only the current UTC month is reported — there is no date range parameter; use `GET /api/v1/admin/quota/users/{userId}/lifetime` for history. Users holding the admin permission are excluded from the result entirely, because admins bypass quota and never accumulate a bucket. " + + "Read the pagination semantics carefully, because they are not the usual ones. The handler first pulls a recency-ordered candidate pool of at most `pageSize × 5` directory rows matching `q`, removes admins, and then slices the requested page out of what is left — so `total` describes that pool and not the platform, `totalPages` never exceeds 5, and requesting a later page returns an empty `items` rather than an error. Narrow with `q` instead of paging deeply. " + + "All three pagination-ish inputs are silently clamped rather than validated, so the only `400` this operation can produce comes from `surface`. " + + SCOPE_NOTE, + operationId: "adminListQuotaUsers", + tags: ["Admin"], + security: bearerAuth(), + parameters: [ + surfaceParam("Which metered surface's buckets to report."), + queryParam( + "q", + "Case-insensitive **email prefix** filter (not a full-text search, and it does not match display names or user ids). Regex metacharacters are escaped, so a literal `+` or `.` in an address is safe to send. Omit or send an empty string to get the most-recently-seen users instead.", + { type: "string", default: "", examples: ["ada@"] }, + ), + queryParam( + "page", + "1-based page number within the candidate pool. Defaults to `1`; anything below 1 or unparseable is silently coerced to `1` rather than rejected. Because the pool is capped at five pages' worth of rows, values above 5 return an empty page.", + { type: "integer", minimum: 1, default: 1, examples: [1] }, + ), + queryParam( + "pageSize", + "Rows per page. Defaults to `20` and is clamped into `[1, 100]`. It also scales the candidate pool, which is fetched as `pageSize × 5` rows — raising it widens what is searchable as well as what is returned.", + { type: "integer", minimum: 1, maximum: 100, default: 20, examples: [20] }, + ), + ], + responses: { + ...jsonResponse(quotaUsersPageSchema, "One page of current-month quota rows for non-admin users.", { + example: { + items: [ + { + userId: "8f14e45fceea167a5a36dedd4bea2543", + email: "ada@example.com", + displayName: "Ada Lovelace", + defaultAllotment: 100, + adminGrant: 50, + used: 122, + remaining: 28, + }, + ], + page: 1, + pageSize: 20, + total: 43, + totalPages: 3, + monthMarker: "2026-08", + monthStart: "2026-08-01T00:00:00.000Z", + monthEnd: "2026-09-01T00:00:00.000Z", + }, + }), + ...problemResponses( + { + 400: "Bad request (`invalid_surface`) — `surface` was present but is not one of `playground` or `skillGen`. The pagination parameters cannot produce a 400; they are clamped.", + }, + 401, + 403, + ), + }, + }, + }, + + [`${prefix}/admin/quota/users/{userId}/lifetime`]: { + get: { + summary: "Get one user's month-by-month quota history", + description: + "Return every monthly bucket ever recorded for a single user on one metered surface, oldest first, including a per-model breakdown of what each month's consumption was spent on. Use it to answer \"is this user's request for more credit consistent with how they have actually been using the platform\" before granting, or to reconstruct spend after the fact. " + + "The series is sparse: a month in which the user made no call has no bucket and simply does not appear, so iterate on `monthMarker` rather than assuming contiguous months. The final entry is the live current-month bucket only when its `monthMarker` equals the `currentMonth` field returned alongside the items — otherwise the user has not touched this surface yet this month. " + + "An unknown, deleted, or never-active user is **not** an error: the response is `200` with `items: []`. There is consequently no way to distinguish \"no such user\" from \"user with no history\" here; resolve the id against `GET /api/v1/admin/quota/users` first if that distinction matters. " + + SCOPE_NOTE, + operationId: "adminGetUserQuotaLifetime", + tags: ["Admin"], + security: bearerAuth(), + parameters: [ + pathParam( + "userId", + "NyxID user id whose history to read — the opaque `sub` claim from the identity token, as surfaced by `GET /api/v1/admin/quota/users` or `GET /api/v1/users/search`. Not an email address. Unknown ids return an empty series rather than a 404.", + { type: "string", minLength: 1 }, + "8f14e45fceea167a5a36dedd4bea2543", + ), + surfaceParam("Which metered surface's history to return. One surface per call — reading both takes two calls."), + ], + responses: { + ...jsonResponse(lifetimeSchema, "The user's full bucket history for the requested surface, oldest month first.", { + example: { + items: [ + { + monthMarker: "2026-07", + monthStart: "2026-07-01T00:00:00.000Z", + monthEnd: "2026-08-01T00:00:00.000Z", + used: 37, + defaultAllotment: 100, + adminGrant: 0, + usedByModel: { "claude-sonnet-4-6": 30, "gpt-5_2": 7 }, + }, + { + monthMarker: "2026-08", + monthStart: "2026-08-01T00:00:00.000Z", + monthEnd: "2026-09-01T00:00:00.000Z", + used: 122, + defaultAllotment: 100, + adminGrant: 50, + usedByModel: { "claude-sonnet-4-6": 122 }, + }, + ], + currentMonth: "2026-08", + }, + }), + ...problemResponses( + { + 400: "Bad request — `surface` is not one of `playground` or `skillGen` (`invalid_surface`), or the `{userId}` segment resolved to an empty string (`invalid_user_id`).", + }, + 401, + 403, + ), + }, + }, + }, + + [`${prefix}/admin/quota/grant`]: { + post: { + summary: "Grant quota credit to one user", + description: + "Add credit to a single user's **current-month** bucket for one surface, and append a row to the grant audit trail. The amount is added to that bucket's `adminGrant` component, which unblocks the user immediately — they do not need to re-authenticate and nothing is queued. " + + "Three properties matter before you call this. It is **additive, not absolute**: sending `amount: 200` twice leaves the user with 400 extra credits, so this operation is emphatically not idempotent and must not be blind-retried on a timeout — reconcile against `GET /api/v1/admin/quota/grants` first, matching on the `auditId` from the response. It targets **only the current UTC month**, and the credit is abandoned at the rollover, so granting late in a month gives the recipient very little time to spend it. And the recipient id is **not verified**: a mistyped `userId` succeeds and creates a bucket nobody owns. " + + "Succeeds with `200`, not `201` — no addressable resource is created, and the audit row is retrievable only through the listing. To defer the grant to a recipient you cannot name up front, mint a redemption code instead. " + + SCOPE_NOTE, + operationId: "adminGrantQuota", + tags: ["Admin"], + security: bearerAuth(), + requestBody: jsonBody(grantBodySchema, "The recipient, the surface to credit, the number of credits, and an optional audit note.", { + example: { + userId: "8f14e45fceea167a5a36dedd4bea2543", + surface: "playground", + amount: 200, + note: "Compensating for the 2026-08-03 outage", + }, + }), + responses: { + ...jsonResponse(grantResultSchema, "Credit applied to the target's current-month bucket and recorded in the audit trail.", { + example: { auditId: "a3f1c2e4-9d4b-4e3f-90ab-42c9665f1c2a", applied: 1, monthMarker: "2026-08", newAdminGrant: 250 }, + }), + ...problemResponses( + { + 400: "Bad request. Either the body failed schema validation (`INVALID_GRANT_BODY` — missing `userId`, unknown `surface`, non-integer or out-of-range `amount`, `note` over 500 characters; `detail` names the field), or the grant itself was refused (`invalid_grant_amount`). Note that the handler wraps **every** failure raised while applying the grant into this second 400, including a datastore outage — so a `400 invalid_grant_amount` whose `detail` does not mention the amount should be read as an internal failure and retried after checking the audit trail, not as a malformed request.", + }, + 401, + 403, + ), + }, + }, + }, + + [`${prefix}/admin/quota/grant/bulk`]: { + post: { + summary: "Grant the same quota credit to many users", + description: + "Apply one surface/amount grant to up to 500 users in a single call — the cohort form of `POST /api/v1/admin/quota/grant`, with identical semantics per recipient: additive, current-month only, unverified ids, and an audit row each. Duplicate ids in `userIds` are collapsed before anything is applied, so `requested` in the response is the distinct count and may be smaller than the array you sent. " + + "This operation is **partially fallible and still answers `200`**. Recipients are processed sequentially and independently; a failure on one does not abort the run or roll back the ones already applied. Always read `applied` against `requested` and walk `results[]` for `ok: false` rows — treating a `200` as \"everything landed\" is the mistake this shape is designed to prevent. Re-driving only the failed ids is safe; re-sending the whole batch double-credits everyone who already succeeded. " + + "One surface per call. Crediting both `playground` and `skillGen` for the same cohort is two calls, and there is no per-recipient amount — split the cohort if amounts differ. " + + SCOPE_NOTE, + operationId: "adminBulkGrantQuota", + tags: ["Admin"], + security: bearerAuth(), + requestBody: jsonBody(bulkGrantBodySchema, "The recipient cohort, the surface to credit, the per-recipient amount, and an optional audit note copied onto every row.", { + example: { + userIds: ["8f14e45fceea167a5a36dedd4bea2543", "c4ca4238a0b923820dcc509a6f75849b"], + surface: "playground", + amount: 50, + note: "August beta cohort top-up", + }, + }), + responses: { + ...jsonResponse(bulkGrantResultSchema, "The batch ran to completion. Per-recipient outcomes are in `results` — success is not implied by this status.", { + example: { + applied: 1, + requested: 2, + monthMarker: "2026-08", + results: [ + { userId: "8f14e45fceea167a5a36dedd4bea2543", ok: true, auditId: "a3f1c2e4-9d4b-4e3f-90ab-42c9665f1c2a" }, + { userId: "c4ca4238a0b923820dcc509a6f75849b", ok: false, error: "MongoServerError: connection timed out" }, + ], + }, + }), + ...problemResponses( + { + 400: "Bad request (`INVALID_BULK_GRANT_BODY`) — the body failed schema validation: `userIds` empty, longer than 500, or containing an empty string; unknown `surface`; non-integer, non-positive, or over-100000 `amount`; `note` longer than 500 characters. `detail` names the offending field. Per-recipient failures are **not** reported here — they come back as `ok: false` rows inside a `200`.", + }, + 401, + 403, + ), + }, + }, + }, + + [`${prefix}/admin/quota/grants`]: { + get: { + summary: "List the quota-grant audit trail", + description: + "Offset-paginated, newest-first log of every credit ever added to any bucket, with the issuer and the recipient both snapshotted on the row so rendering it needs no identity lookups. This is the authoritative record for two questions an operator asks constantly: \"did my grant actually land\" (filter by `userId` and match the `auditId` the grant returned) and \"where did this user's balance come from\" (the sum of `amount` for a `userId` in one `monthMarker` is exactly that month's `adminGrant`). " + + "It is also the reconciliation step before retrying any grant, because the grant endpoints are additive and a blind retry double-credits. " + + "Redemptions appear here too, as self-grants: `adminUserId` equals `targetUserId` and `note` reads `Redeemed code ABCD****`, revealing only the first four characters of the consumed code. Filter those out by comparing the two id fields if you want hand-issued grants alone. " + + "Both pagination inputs are silently clamped rather than rejected, so this operation has no `400` at all — read `page` and `pageSize` back from the response instead of assuming your request was honoured. The `page` ceiling of 10000 is a deliberate guard against a huge offset driving an unbounded collection scan. " + + SCOPE_NOTE, + operationId: "adminListQuotaGrantAudit", + tags: ["Admin"], + security: bearerAuth(), + parameters: [ + queryParam( + "userId", + "Filter to grants **received** by this NyxID user id (exact match on the audit row's `targetUserId`). Omit for all recipients. An empty string is treated as omitted.", + { type: "string", examples: ["8f14e45fceea167a5a36dedd4bea2543"] }, + ), + queryParam( + "adminUserId", + "Filter to grants **issued** by this NyxID user id (exact match on `adminUserId`). Combine with `userId` to isolate one operator's grants to one recipient; on a redemption-generated row the two ids are equal.", + { type: "string", examples: ["c4ca4238a0b923820dcc509a6f75849b"] }, + ), + queryParam( + "page", + "1-based page number. Defaults to `1` and is clamped into `[1, 10000]` — out-of-range and unparseable values are coerced, never rejected.", + { type: "integer", minimum: 1, maximum: 10000, default: 1, examples: [1] }, + ), + queryParam( + "pageSize", + "Rows per page. Defaults to `50` and is clamped into `[1, 200]`. Note this differs from the other listings in this domain, which default to 20 and cap at 100.", + { type: "integer", minimum: 1, maximum: 200, default: 50, examples: [50] }, + ), + ], + responses: { + ...jsonResponse(grantAuditPageSchema, "One page of grant-audit rows, newest first.", { + example: { + items: [ + { + _id: "a3f1c2e4-9d4b-4e3f-90ab-42c9665f1c2a", + adminUserId: "c4ca4238a0b923820dcc509a6f75849b", + adminEmail: "ops@example.com", + adminDisplayName: "Ops Team", + targetUserId: "8f14e45fceea167a5a36dedd4bea2543", + surface: "playground", + amount: 200, + note: "Compensating for the 2026-08-03 outage", + monthMarker: "2026-08", + createdAt: "2026-08-04T09:14:22.118Z", + }, + ], + total: 1284, + page: 1, + pageSize: 50, + totalPages: 26, + }, + }), + ...problemResponses(401, 403), + }, + }, + }, + + [`${prefix}/admin/redemption-codes`]: { + post: { + summary: "Mint a redemption code", + description: + "Create a single-use code carrying a bundle of per-surface quota grants, to hand to a recipient you cannot or do not want to name up front. It is the deferred twin of `POST /api/v1/admin/quota/grant`: whoever redeems it at `POST /api/v1/me/redemption-codes/redeem` credits their **own** current-month buckets, and the resulting grants land in the same audit trail as hand-issued ones (as self-grants noted `Redeemed code ABCD****`). Because minting is a grant with the recipient left blank, it requires the same permission. " + + "A code is consumable exactly once **platform-wide**, not once per user — the first redeemer wins and everyone else gets a `409`. The generated token is returned in full in the response, and remains readable through the list and detail operations forever after; there is no one-time reveal, so anything that logs or renders this response is handling a bearer secret. " + + "`expiresAt` bounds redeemability, not the credit. Credit granted by a redemption still expires at the UTC month rollover like any other grant, so a code redeemed on the 31st is nearly worthless — set an expiry that lands well inside the month you want the credit spent in, and say so when you hand the code out. " + + "Succeeds with `200`, not `201`, and sets no `Location` header, even though it creates a resource. Retrying a request whose response you lost mints a **second, distinct** code rather than returning the first — search the listing by `note` before re-minting. " + + SCOPE_NOTE, + operationId: "adminMintRedemptionCode", + tags: ["Admin"], + security: bearerAuth(), + requestBody: jsonBody(mintBodySchema, "The grant bundle the code will apply, an optional admin-facing label, and the instant the code stops being redeemable. Generated from `mintCodeSchema` in `domains/redemption-codes/types.ts`, which is the runtime validator.", { + example: { + grants: [ + { surface: "playground", amount: 200 }, + { surface: "skillGen", amount: 25 }, + ], + note: "Launch promo cohort", + expiresAt: "2026-12-31T23:59:59.000Z", + }, + }), + responses: { + ...jsonResponse(codeEnvelopeSchema, "Code minted. The plaintext token is in `code.code`.", { example: { code: CODE_EXAMPLE } }), + ...problemResponses( + { + 400: "Bad request (`invalid_redemption_code_body`) — the body failed validation or the service refused it. Causes: `grants` empty or holding more than one entry for the same surface; an `amount` that is not a positive integer at most 100000; an unknown `surface`; `note` over 500 characters; `expiresAt` absent, not an ISO-8601 UTC timestamp, or not strictly in the future. `detail` names the field for schema failures.", + }, + 401, + 403, + { + 500: "Internal error (`redemption_code_mint_failed`) — the datastore rejected the insert, or five consecutive generated tokens collided with existing codes. No code was created; retrying is safe.", + }, + ), + }, + }, + + get: { + summary: "List redemption codes", + description: + "Offset-paginated inventory of every code ever minted, newest first, with the plaintext token, grant bundle, lifecycle state, and the actors who created, redeemed, or retired each one. Use it to find a code to invalidate, to confirm whether a code you handed out has been used and by whom, or to recover the token from a mint response you lost. " + + "Two filters, and they compose. `status` narrows to `active` / `redeemed` / `invalidated`; note that **expiry is not a status** — an expired code stays `active` and is refused only at redemption time, so to find dead inventory filter `status=active` and compare `expiresAt` against now yourself. `search` matches either a **prefix** of the code (case-insensitively — the value is upper-cased before matching, mirroring how codes are stored) or a **substring** of the admin note; the two are OR'd, so a partial token pasted from a support ticket and a campaign label both work. " + + "`search` is the current parameter name; `q` is accepted as a legacy alias and is used only when `search` is absent. Prefer `search`. " + + "`pageSize` is clamped into `[1, 100]` and `page` is floored at 1 but has no ceiling, so a large page simply returns an empty `items`. The only `400` this operation produces comes from an unrecognised `status`. " + + SCOPE_NOTE, + operationId: "adminListRedemptionCodes", + tags: ["Admin"], + security: bearerAuth(), + parameters: [ + queryParam( + "status", + "Lifecycle filter. Omit for all states. Unlike every other parameter here an unrecognised value is rejected rather than ignored. Remember that expiry is not a status — expired codes are still `active`.", + { type: "string", enum: [...REDEMPTION_CODE_STATUSES], examples: ["active"] }, + ), + queryParam( + "search", + "Matches a case-insensitive **prefix** of the code (the value is upper-cased first, since codes are stored canonical-uppercase) OR a case-insensitive **substring** of the admin `note`. Trimmed; an empty or whitespace-only value is treated as absent.", + { type: "string", examples: ["K7M2"] }, + ), + queryParam( + "q", + "Legacy alias for `search`, kept for older clients. Consulted **only** when `search` is absent — sending both silently ignores this one. New integrations should use `search`.", + { type: "string", examples: ["Launch promo"] }, + ), + queryParam( + "page", + "1-based page number. Defaults to `1`; values below 1 and unparseable values are coerced to `1`. There is no upper clamp — a page past the end returns an empty `items` rather than an error.", + { type: "integer", minimum: 1, default: 1, examples: [1] }, + ), + queryParam( + "pageSize", + "Codes per page. Defaults to `20` and is clamped into `[1, 100]`; out-of-range values are coerced, never rejected.", + { type: "integer", minimum: 1, maximum: 100, default: 20, examples: [20] }, + ), + ], + responses: { + ...jsonResponse(codeListSchema, "One page of redemption codes, newest-minted first.", { + example: { items: [CODE_EXAMPLE], total: 312, page: 1, pageSize: 20, totalPages: 16 }, + }), + ...problemResponses( + { + 400: "Bad request (`INVALID_STATUS`) — `status` was present but is not one of `active`, `redeemed`, `invalidated`. No other parameter on this operation can produce a 400; the rest are clamped or ignored.", + }, + 401, + 403, + ), + }, + }, + }, + + [`${prefix}/admin/redemption-codes/{id}`]: { + get: { + summary: "Get a redemption code by id", + description: + "Fetch one code's full record: the plaintext token, its grant bundle, its lifecycle state, and the actor snapshots for whoever minted, redeemed, or retired it. Addressed by the document id (`code.id`), **not** by the token string — there is no admin lookup-by-code endpoint, so resolve a token a user quoted you through `GET /api/v1/admin/redemption-codes?search=` first, then come here with the `id`. " + + "The natural use is confirming outcome before acting: check `status` and `redeemedBy` before telling a user their code was already used, and re-read after an invalidate to confirm the transition. Note again that expiry is not reflected in `status` — a code past `expiresAt` still reads `active` even though redemption will refuse it. " + + SCOPE_NOTE, + operationId: "adminGetRedemptionCode", + tags: ["Admin"], + security: bearerAuth(), + parameters: [ + pathParam( + "id", + "Code document id — the `id` field from the mint or list response, a 24-character MongoDB ObjectId hex string. This is not the redemption token itself; passing a token here returns `404`.", + { type: "string", minLength: 1, examples: ["665f1c2a9d4b7e3f10ab42c9"] }, + "665f1c2a9d4b7e3f10ab42c9", + ), + ], + responses: { + ...jsonResponse(codeEnvelopeSchema, "The code document.", { example: { code: CODE_EXAMPLE } }), + ...problemResponses( + { 400: "Bad request (`invalid_redemption_code_id`) — the `{id}` segment resolved to an empty string." }, + 401, + 403, + { + 404: "Not found (`redemption_code_not_found`) — no code carries this id. Also what you get for a malformed id or for passing the redemption token instead of the document id.", + }, + ), + }, + }, + }, + + [`${prefix}/admin/redemption-codes/{id}/invalidate`]: { + post: { + summary: "Invalidate an unredeemed redemption code", + description: + "Retire an `active` code so it can never be redeemed, moving it to the terminal `invalidated` state and stamping the acting admin onto the record. Use it when a code leaks, is sent to the wrong recipient, or a campaign is cancelled — it is the only way to take minted value off the table before it expires. " + + "The transition is one-way and only from `active`. A code that has already been redeemed **cannot** be invalidated (`409`), because the credit is already spent into the redeemer's bucket; this operation never claws grants back, and there is no reverse operation to un-invalidate. It is also **not idempotent in its status codes**: the first call answers `200`, and a repeat on the same code answers `409 redemption_code_already_invalidated` even though the desired end state already holds — treat that specific 409 as success when reconciling a retry. " + + "The request takes no body. On success the full updated document is returned, so there is no need to re-read it. " + + SCOPE_NOTE, + operationId: "adminInvalidateRedemptionCode", + tags: ["Admin"], + security: bearerAuth(), + parameters: [ + pathParam( + "id", + "Code document id — the `id` field from the mint or list response, a 24-character MongoDB ObjectId hex string, not the redemption token. Resolve a user-quoted token through the list endpoint's `search` filter first.", + { type: "string", minLength: 1, examples: ["665f1c2a9d4b7e3f10ab42c9"] }, + "665f1c2a9d4b7e3f10ab42c9", + ), + ], + responses: { + ...jsonResponse(codeEnvelopeSchema, "The code is now `invalidated`. The returned document carries `invalidatedAt` and `invalidatedBy`.", { + example: { + code: { + ...CODE_EXAMPLE, + status: "invalidated", + invalidatedAt: "2026-08-07T09:14:22.118Z", + invalidatedBy: { userId: "c4ca4238a0b923820dcc509a6f75849b", email: "ops@example.com", displayName: "Ops Team" }, + }, + }, + }), + ...problemResponses( + { 400: "Bad request (`invalid_redemption_code_id`) — the `{id}` segment resolved to an empty string." }, + 401, + 403, + { 404: "Not found (`redemption_code_not_found`) — no code carries this id." }, + { + 409: "Conflict — the code is not in the `active` state. `redemption_code_already_redeemed`: someone consumed it, the credit is spent, and invalidation is impossible. `redemption_code_already_invalidated`: it was already retired — the end state you wanted already holds, so this is a safe outcome for a retried request.", + }, + { + 500: "Internal error (`redemption_code_invalidate_failed`) — the state pivot failed for a reason other than the conflicts above (typically a datastore failure). The code's state is unchanged; re-read it with `GET /api/v1/admin/redemption-codes/{id}` before retrying.", + }, + ), + }, + }, + }, + }; +} diff --git a/ornn-api/src/openapi/paths/adminSettings.ts b/ornn-api/src/openapi/paths/adminSettings.ts new file mode 100644 index 00000000..d5d92fc3 --- /dev/null +++ b/ornn-api/src/openapi/paths/adminSettings.ts @@ -0,0 +1,804 @@ +/** + * Admin platform-configuration surface (#1214) — 27 operations behind the + * single `ornn:admin:skill` request scope. + * + * Two sub-surfaces live here, and they are not variations of each other: + * + * 1. **Settings sections** — ten `GET` + `PUT` pairs at + * `/admin/settings/{publicPath}`. Each section is one document in + * `platform_settings`, owned by one subsystem, with its own Zod + * schema. `domains/settings/routes.ts` registers them in a loop over + * the section registry, so the *paths themselves are computed at boot* + * — which is precisely why a source-code scan missed all twenty of + * them and the router-reflection contract test did not. + * 2. **LLM providers** — full CRUD over `llm_providers`, plus the + * upstream model-catalog sync and the per-model surface-flag patch + * (#270). This is the catalogue the section pins in (1) point at. + * + * **The section half of this module is generated, not transcribed.** It + * iterates the same `sections` registry the router iterates and derives + * every path key, request body, and response payload from the registry + * entry's own `publicPath`, `schema`, `secretFields`, and `defaults`. Add + * an eleventh section and its two operations appear here on their own, + * with a real payload schema, without anyone editing this file — the + * reflection test cannot go red for a section that exists. What a new + * section *does* need is a row in `SECTION_PROSE` below: `Record` is total, so TypeScript names the missing row at compile time. A + * runtime fallback keeps the document buildable in the meantime, so the + * failure surfaces as a typecheck error rather than a spec-build crash. + * + * Three behaviours of this surface trip up integrators, all documented per + * operation below: + * + * **Secrets round-trip as masks.** A secret field never leaves the server + * in plaintext: `GET` mid-masks it, and a `PUT` carrying a value that still + * contains the mask's `•` sentinel means *keep the stored secret*. The + * whole point is that read-modify-write is safe. An admin UI depends on it. + * + * **The section `PUT` returns a third top-level key.** Its body is + * `{ data, error, meta }`, where `meta.changedFields` lists the field names + * that actually changed. That is not the standard envelope, so it is + * described here by hand rather than through `jsonResponse`. + * + * **A section `PUT` is a full replace, not a merge.** The body is validated + * against the entire section schema; an omitted required field is a `400`, + * not "leave it alone". + * + * @module openapi/paths/adminSettings + */ + +import { + bearerAuth, + envelope, + jsonBody, + jsonResponse, + noContentResponse, + pathParam, + problemResponses, + toSchema, + type JsonSchema, + type PathItem, + type PathMap, +} from "../helpers"; +import { sections, type SectionId } from "../../domains/settings/sections"; +import { + modelFlagsPatchSchema, + providerCreateSchema, + providerUpdateSchema, +} from "../../domains/settings/llmProviders/service"; + +// --------------------------------------------------------------------------- +// Shared prose +// --------------------------------------------------------------------------- + +/** + * Appended to every description in this module. Every route here is + * `nyxidAuthMiddleware()` followed by `requirePermission("ornn:admin:skill")`, + * in that order, which is what fixes 401-before-403. + */ +const ADMIN_SCOPE_NOTE = + "Requires a bearer token whose `permissions` array contains the platform-admin request scope `ornn:admin:skill`. Authentication is checked first: no token, an expired token, or one the proxy could not validate is `401` (`code: \"auth_missing\"`), and a valid token without the scope is `403` (`code: \"forbidden\"`). There is no finer-grained settings scope — the same one scope unlocks every operation in this module."; + +/** Read-path caching. Identical for all ten sections (`SettingsServiceImpl`). */ +const CACHE_NOTE = + "Section reads are served from a per-pod in-process cache with a 30-second TTL. The pod that handles a write busts its own entry, so reading back through the same connection is immediate, but another replica can keep serving the previous value for up to thirty seconds. Do not treat this as a strongly-consistent read-back, and do not poll it as a change feed."; + +/** Applies to every section: a missing row reads as the section defaults. */ +const DEFAULTS_NOTE = + "A section that has never been written is not a `404` — stored values are merged over the section defaults on read, so every documented field is always present and a fresh deployment returns a fully-populated document."; + +/** + * Mid-mask semantics. The single most confusing part of this surface: the + * mask is a *sentinel*, not a redaction, and echoing it back is the + * supported way to leave a credential untouched. + */ +const MASK_SEMANTICS = + "Secret fields are encrypted at rest and never returned in plaintext. `GET` replaces the value with a **mid-mask** — first four characters, a run of `•` (U+2022), last four (`ghp_••••••••7f3a`); anything eight characters or shorter is blurred entirely, and an unset secret stays the empty string. The bullet is a sentinel: on `PUT`, a value containing a `•` **anywhere** means *keep the secret already stored*, so the read-modify-write round trip — `GET`, edit the other fields, `PUT` the whole object back — preserves the credential without the client ever holding it. Send a plaintext value to rotate the secret, or the empty string to clear it. Never try to reconstruct the real value from a mask, and never persist a mask as if it were the credential. `GET /api/v1/admin/settings/export` uses a second sentinel, ``, with exactly the same preserve-on-write meaning."; + +/** + * Per-section secret sentence, derived from the registry's `secretFields` + * so it can never disagree with what the route actually masks. + */ +function secretNote(secretFields: ReadonlyArray): string { + if (secretFields.length === 0) { + return "This section stores no secrets: every field round-trips verbatim, and nothing in it is masked or encrypted."; + } + const list = secretFields.map((field) => `\`${field}\``).join(", "); + const plural = secretFields.length > 1 ? "s" : ""; + return `Secret field${plural} in this section: ${list}. ${MASK_SEMANTICS}`; +} + +/** + * `meta` on a section `PUT`. Modelled by hand because it is a third + * top-level key the standard envelope helper does not know about. + */ +const changedFieldsMetaSchema: JsonSchema = { + type: "object", + required: ["changedFields"], + description: + "Write receipt. This key sits **beside** `data` and `error` at the top level of the body — it is not part of the standard success envelope, and it is present only on this `PUT`.", + properties: { + changedFields: { + type: "array", + items: { type: "string" }, + description: + "Names of the fields whose stored value actually changed, compared field-by-field against the pre-write document (objects and arrays by their JSON form). Names only — values, secret or not, are never echoed here. A secret appears in this list only when you sent a new plaintext value; sending the mask back leaves it out, which makes this the cheapest way to confirm that a round trip preserved rather than rotated a credential. An empty array means the write was accepted and changed nothing.", + examples: [["enabled", "reconcileSchedule"]], + }, + }, +}; + +// --------------------------------------------------------------------------- +// Per-section prose +// --------------------------------------------------------------------------- + +/** + * The half of a section's documentation that cannot be derived: what the + * section actually controls and what writing it does to a running + * deployment. Everything else — path, payload schema, secret list, example + * skeleton — comes from the registry entry. + */ +interface SectionProse { + /** Human name of the section, used in both summaries. */ + readonly title: string; + /** What this section owns, field by field. Multi-sentence. */ + readonly overview: string; + /** What changes in the running system when it is written. Multi-sentence. */ + readonly writeEffect: string; + /** + * Realistic values layered over `meta.defaults` to build the payload + * example. Merging over the defaults guarantees the example carries every + * field the current schema declares, without hand-maintaining a full copy. + */ + readonly exampleOverrides?: Readonly>; + /** Plausible `meta.changedFields` for the `PUT` response example. */ + readonly changedFieldsExample: readonly string[]; +} + +/** + * Keyed by section **id**, not by URL path — the two differ for three + * sections (`skillGen` → `skill-generation`, `nyxid` → `integrations/nyxid`, + * `telemetry` → `posthog`), and the id is what the export/import payloads + * and the Mongo `_id` use. + * + * Total by construction: adding a `SectionId` without a row here is a + * compile error naming the missing section. + */ +const SECTION_PROSE: Record = { + playground: { + title: "Playground", + overview: + "Owns the Playground surface — the sandboxed multi-turn chat at `POST /api/v1/playground/chat`. `defaultProviderId` and `defaultModelId` are the surface's **advertised** pin: `GET /api/v1/me/models?surface=playground` reports `defaultModelId` so a picker pre-selects it. They do NOT drive execution. When a chat request omits `modelId`, the resolver picks the model whose per-model `defaultForPlayground` flag is set (falling back to the first enabled row by display name) and never reads this section — so if the two disagree, the picker shows one model and the run uses another. Keep the pin and the `defaultForPlayground` flag under `/admin/settings/llm-providers` pointed at the same model. `sseKeepAliveMs` (1000–600000) is how often the chat stream emits a keep-alive frame to stop an idle proxy closing the connection. `defaultMonthlyQuota` (0–1000000) is the monthly Playground allowance a non-admin user starts with.", + writeEffect: + "The new provider/model pin takes effect on the next chat turn resolved by a pod whose cache has expired — there is no restart and no draining of in-flight streams. Nothing here checks that the pinned provider or model exists or is enabled for this surface; the schema validates types only, so a stale `defaultModelId` is accepted at this write and fails later on the execute path instead. `defaultMonthlyQuota` seeds new grants only and does not retroactively raise or claw back an allowance already issued.", + exampleOverrides: { + defaultProviderId: "8f2a1c34-9b7e-4d51-a0c6-1e5b3d9f0742", + defaultModelId: "gpt-4o", + }, + changedFieldsExample: ["defaultModelId"], + }, + + skillGen: { + title: "Skill generation", + overview: + "Owns the skill-authoring surface — the three streaming generators at `POST /api/v1/skills/generate`, `.../generate/from-source`, and `.../generate/from-openapi`. The four knobs mirror `playground` exactly: `defaultProviderId` / `defaultModelId` are the advertised pin reported by `GET /api/v1/me/models?surface=skillGen` — but, exactly as for `playground`, execution resolves through the per-model `defaultForSkillGen` flag and never reads this section, so keep the two in agreement, `sseKeepAliveMs` (1000–600000) paces the generation stream's keep-alive frames, and `defaultMonthlyQuota` (0–1000000) is the monthly generation allowance for a non-admin user — seeded lower than Playground's by default because a generation turn is the more expensive call. Note that the LLM skill-audit pipeline borrows this surface for model resolution when `skillAudit.llmAuditDefaultModelId` is unset; there is no dedicated audit surface.", + writeEffect: + "Applies to the next generation request resolved after the writing pod's cache entry expires; a stream already open keeps the model it started with. As with `playground`, the pinned ids are not checked for existence or for being enabled on this surface, so a wrong id is accepted here and fails when a generation is attempted. Because the audit pipeline falls through to this surface, changing the pin can silently re-point LLM audits as well.", + exampleOverrides: { + defaultProviderId: "8f2a1c34-9b7e-4d51-a0c6-1e5b3d9f0742", + defaultModelId: "claude-sonnet-4-6", + }, + changedFieldsExample: ["defaultProviderId", "defaultModelId"], + }, + + assistant: { + title: "Ornn Assistant", + overview: + "Owns the Ornn Assistant surface (#970) — the grounded, non-agentic Q&A chat at `POST /api/v1/assistant/chat`, which answers from a curated knowledge base plus a visibility-scoped skill retrieval and executes nothing. Field-for-field identical to `playground` and `skillGen` so the quota and SSE machinery treat all three surfaces uniformly: provider/model pin, keep-alive cadence, and the starting monthly allowance, which is seeded between the other two because Q&A turns are cheaper than generation but more frequent than Playground runs.", + writeEffect: + "Takes effect on the next assistant turn once the writing pod's cache entry expires. Two things to know before relying on this pin. As on the other two surfaces, it is advertised rather than executed: an assistant turn that omits `modelId` resolves through the per-model `defaultForAssistant` flag, not through this section. And the `GET /api/v1/me/models` default resolver only special-cases `playground`, falling through to the skill-generation section for every other surface — so for `surface=assistant` the picker reports the **skillGen** pin, not this one. Set the per-model flag to control behaviour; treat both pins here as display metadata until that resolver is fixed.", + exampleOverrides: { + defaultProviderId: "8f2a1c34-9b7e-4d51-a0c6-1e5b3d9f0742", + defaultModelId: "gpt-4o-mini", + }, + changedFieldsExample: ["defaultModelId", "defaultMonthlyQuota"], + }, + + mirror: { + title: "GitHub mirror", + overview: + "Owns the GitHub mirror — the job that publishes this registry's skills into a GitHub repository. `enabled` is the master switch. `owner` / `repo` / `branch` are the destination coordinates; `owner` and `repo` must satisfy GitHub's own naming rules (`owner` up to 39 characters of alphanumerics and inner hyphens, `repo` up to 100 of alphanumerics, dot, underscore, hyphen) or be the empty string for \"not configured\". `appId`, `installationId`, and `appPrivateKey` are the GitHub App credentials the mirror authenticates with. `reconcileSchedule` is a cron expression parsed by `cron-parser` (five-field UNIX or six-field with-seconds, both accepted) and interpreted in `Asia/Singapore`, which has no DST — the default `0 2 * * *` reads literally as 02:00 Singapore time. An empty `reconcileSchedule` disables the scheduled sweep only; publish-time webhooks still fire.", + writeEffect: + "Flipping `enabled` to `false` halts mirroring without discarding coordinates or credentials, and `POST /api/v1/admin/mirror/reconcile` starts answering `503 mirror_disabled` — that is the safe way to pause. Turning it on with incomplete coordinates or credentials produces the same `503` from the reconcile endpoint, because completeness is checked there and not on this write. A changed `reconcileSchedule` is picked up by the in-process scheduler; use `GET /api/v1/admin/mirror/status` to see when the next sweep is due.", + exampleOverrides: { + enabled: true, + owner: "acme-ai", + repo: "ornn-skills", + branch: "main", + appId: "1234567", + installationId: "87654321", + appPrivateKey: "----••••••••••••----", + }, + changedFieldsExample: ["enabled", "branch"], + }, + + nyxid: { + title: "NyxID integration", + overview: + "Owns the server-side coordinates for NyxID and the two chrono services the backend calls. `tokenUrl`, `clientId`, and `clientSecret` are the service-account OAuth2 credentials ornn-api exchanges for its own access token; `baseApiUrl` is the NyxID API the backend proxies through. `chronoStorageUrl` plus `chronoStorageBucket` locate skill-package object storage, and `chronoSandboxUrl` locates the execution sandbox. Every URL field must be an `http://` or `https://` URL whose host is public — loopback, RFC1918, link-local, and cloud-metadata addresses are refused with `URL host is private/loopback/link-local; set ORNN_URL_ALLOWLIST_CIDR to allow`, because this section's `tokenUrl` receives the client secret on first use and a private-address bypass here is a credential-exfiltration primitive. Deployments that genuinely live at a private address allowlist it through the `ORNN_URL_ALLOWLIST_CIDR` environment variable, not through this API. The bucket name must match `^[a-z0-9.-]{1,63}$`. Every field accepts the empty string as \"not configured\". Browser-facing NyxID link coordinates are **not** here — they ship in ornn-web's ConfigMap.", + writeEffect: + "Rotating `clientSecret` invalidates nothing on the NyxID side; it only changes what this deployment presents on its next token exchange, so rotate on NyxID first. Re-pointing `chronoStorageUrl` or `chronoStorageBucket` does not migrate anything already stored — packages written under the old coordinates stay there and become unreadable, so treat a bucket change as a data migration and not a settings edit.", + exampleOverrides: { + tokenUrl: "https://nyx-api.example.com/oauth/token", + clientId: "ornn-api", + clientSecret: "ornn••••••••4c2d", + baseApiUrl: "https://nyx-api.example.com", + chronoStorageUrl: "https://storage.example.com", + chronoStorageBucket: "ornn-skills", + chronoSandboxUrl: "https://sandbox.example.com", + }, + changedFieldsExample: ["baseApiUrl"], + }, + + skillAudit: { + title: "Skill audit", + overview: + "Owns the safety review that runs over a skill version. `llmAuditEnabled` switches the LLM half on, and `llmAuditDefaultProviderId` / `llmAuditDefaultModelId` choose the reviewing model — when the model id is empty the pipeline falls through to the skill-generation surface's default. `riskThreshold` (0–10, one decimal place is typical) is the score at or above which a version is treated as risky; it replaced the older `auditWaiverThreshold` knob on the legacy `/admin/settings` singleton. `agentSealEnabled` and `agentSealTimeoutMs` (1000–600000) control the static AgentSeal scan that runs alongside the LLM review. One cross-field rule is enforced by the schema: `llmAuditDefaultProviderId` is required whenever `llmAuditEnabled` is `true`, and violating it is rejected as `llmAuditDefaultProviderId: required when llmAuditEnabled is true`.", + writeEffect: + "The new values apply to audits started after the write; an audit already running keeps the threshold and timeout it began with, and no previously-issued verdict is recomputed. Lowering `riskThreshold` therefore does not retroactively fail skills that already passed — re-run `POST /api/v1/skills/{idOrName}/audit` for anything you need re-judged under the new bar. Turning `agentSealEnabled` on in a deployment whose image ships no scanner does not fail here; the rescan endpoint answers `503 agentseal_disabled` instead.", + exampleOverrides: { + llmAuditEnabled: true, + llmAuditDefaultProviderId: "8f2a1c34-9b7e-4d51-a0c6-1e5b3d9f0742", + llmAuditDefaultModelId: "gpt-4o", + }, + changedFieldsExample: ["llmAuditEnabled", "llmAuditDefaultProviderId", "riskThreshold"], + }, + + telemetry: { + title: "PostHog telemetry", + overview: + "Owns PostHog analytics configuration. Note the name split: the section id is `telemetry` — which is what the Mongo row and the export/import payloads key on — while the URL segment is `posthog`, renamed because the section only ever carried PostHog config. `postHogEnabled` is the master switch; with it off the backend installs a no-op tracker no matter what else is set. `postHogApiKey` is the project API key (the public `phc_…` value), and an empty key disables ingestion on its own. `postHogHost` is the ingest host and must be a public `http(s)` URL, or empty to fall back to the environment variable. `postHogProjectId` is informational and appears in log lines for correlation. `postHogErrorSampleRate` (0–1) sub-samples `api.error` 5xx events. OpenTelemetry fields once lived here and were removed in #271 — Ornn runs no OTel pipeline.", + writeEffect: + "**Restart-required.** The tracker is constructed once at boot from this section, so a write here changes nothing about a running pod: the new configuration is picked up on the next ornn-api container restart. Everything else in this module is hot. Environment variables remain the bootstrap fallback and the seed for the very first read on a fresh database, so clearing a field here does not necessarily switch telemetry off.", + exampleOverrides: { + postHogEnabled: true, + postHogApiKey: "phc_••••••••9a1f", + postHogHost: "https://eu.i.posthog.com", + postHogProjectId: "41822", + }, + changedFieldsExample: ["postHogEnabled", "postHogHost"], + }, + + extras: { + title: "Extra NyxID services", + overview: + "Owns `extraNyxidServices`, the list of additional synthetic NyxID services this deployment exposes beyond the built-in set — the ones a skill can be bound to and a user can hold credentials for. Each entry needs a `name` matching `^[A-Za-z0-9._-]{1,64}$` (mixed case, dots, dashes, and underscores are all fine, spaces are not, so the value is safe to drop into a URL path segment unencoded) and a `baseUrl` that is either empty or a public `http(s)` URL. `scopes` is optional and free-form. Names must be unique within the array; a duplicate is rejected as `extraNyxidServices..name: duplicate service name \"\"`.", + writeEffect: + "This is a whole-array replace: send the complete list every time, because an entry you omit is deleted, not left in place. Removing a service that skills or users are already bound to does not cascade — those bindings keep naming a service this deployment no longer advertises — so retire a service by first re-pointing what references it.", + exampleOverrides: { + extraNyxidServices: [ + { name: "NyxID", baseUrl: "https://nyx-api.example.com", scopes: ["profile"] }, + ], + }, + changedFieldsExample: ["extraNyxidServices"], + }, + + launchPromo: { + title: "Launch promo", + overview: + "Owns the GitHub-star → Ornn-credit launch promotion (#724) read by both the poller and the manual-award endpoint `POST /api/v1/admin/launch-promo/award/{userId}`. `enabled` gates the whole thing. `repoOwner` / `repoName` say which repository's stargazers are eligible. `totalSlots` (0–100000) caps lifetime claims — the service refuses to award once claims reach it. `awardPlayground` and `awardSkillGen` are the per-claim monthly credit grants for those two surfaces. `pollIntervalMs` drives the auto-poll loop and `0` disables it, leaving only the manual award path. `codeExpiryDays` (1–365) is how long a minted redemption code stays valid. `nyxidInviteCode` (up to 64 characters) is the static invite code bundled into the claim notification, editable here so a rotation needs no redeploy. Defaults are deliberately inert: disabled, with no repository set.", + writeEffect: + "Nothing here is retroactive. Lowering `totalSlots` below the number of claims already made does not revoke anything — it only stops further awards; raising it re-opens the promo. `awardPlayground` / `awardSkillGen` and `codeExpiryDays` apply to codes minted after the write, so already-issued codes keep the grant bundle and expiry they were minted with. Changing `repoOwner` / `repoName` re-points future eligibility checks and does not re-evaluate past claims.", + exampleOverrides: { + enabled: true, + repoOwner: "ChronoAIProject", + repoName: "Ornn", + nyxidInviteCode: "ORNN-LAUNCH", + }, + changedFieldsExample: ["enabled", "repoOwner", "repoName"], + }, + + sourceSync: { + title: "GitHub source sync", + overview: + "Owns the poller that watches the upstream GitHub repositories of GitHub-sourced skills and detects when one has moved (#1175). Mind the URL: this section's path segment is the camelCase `sourceSync`, not the kebab-case every other multi-word section uses. `enabled` is the master switch. `githubToken` is a service-account token used **only** to authenticate reads of public repositories so drift checks escape the unauthenticated 60-requests-per-hour-per-IP ceiling (authenticated is 5000/hour with free `304`s) — it grants nothing the public web does not already; when empty the runtime falls back to the `ORNN_SOURCE_SYNC_GITHUB_TOKEN` environment variable, and when both are empty the poller runs unauthenticated and rate-limited. `pollSchedule` is a cron expression interpreted in `Asia/Singapore`, matching the mirror scheduler; empty disables the schedule. `minCheckIntervalMinutes` (minimum 1) floors how often any single skill is re-checked, independent of how often the cron fires. `autoPublish` is the full unattended switch — when true, detected drift publishes a new version by itself.", + writeEffect: + "`autoPublish` is the field to think hardest about: turning it on lets an upstream repository change become a published skill version with no human in the loop, and it is the one setting in this module that can create registry content on its own. `enabled: false` leaves the token and schedule in place, which is the reversible way to stop the poller. A tightened `minCheckIntervalMinutes` throttles the next sweep rather than the one already running.", + exampleOverrides: { + enabled: true, + githubToken: "ghp_••••••••7f3a", + }, + changedFieldsExample: ["enabled", "autoPublish"], + }, +}; + +/** + * Safety net for a section added to the registry before its prose row. + * `SECTION_PROSE` is total, so TypeScript flags that case at compile time + * and this branch is unreachable in a type-clean tree — it exists so the + * generated document still builds (and the router-reflection test still + * passes) in a tree where the typecheck has not been fixed yet. It is + * deliberately blunt about being undocumented rather than pretending to + * describe the section. + */ +function fallbackProse(id: SectionId, publicPath: string): SectionProse { + return { + title: id, + overview: `Settings section \`${id}\`, served at \`/admin/settings/${publicPath}\`. **This section has no hand-written documentation yet** — the payload schema below is generated from its Zod schema in \`domains/settings/sections/${id}\` and is accurate, but what the fields mean and what writing them does to a running deployment is not described here. Read the section module before depending on it.`, + writeEffect: `Replaces the stored \`${id}\` document after validating the body against the section schema. The runtime effect of that write is undocumented; see \`domains/settings/sections/${id}\`.`, + changedFieldsExample: [], + }; +} + +// --------------------------------------------------------------------------- +// Section operations (generated from the registry) +// --------------------------------------------------------------------------- + +/** `skillGen` → `SkillGen`, so operationIds read `getSkillGenSettings`. */ +function pascal(id: string): string { + return id.charAt(0).toUpperCase() + id.slice(1); +} + +/** + * The `GET` + `PUT` pair for one settings section, with the payload schema, + * the example skeleton, and the secret sentence all derived from the + * registry entry. + */ +function sectionPathItem(id: SectionId): PathItem { + const meta = sections[id]; + const prose = SECTION_PROSE[id] ?? fallbackProse(id, meta.publicPath); + const dataSchema = toSchema(meta.schema, "output"); + const example: Record = { + ...(meta.defaults as Record), + ...(prose.exampleOverrides ?? {}), + }; + const secrets = secretNote(meta.secretFields); + const putEnvelope = envelope(dataSchema); + + return { + get: { + summary: `Read the ${prose.title} settings section`, + description: `${prose.overview}\n\n${secrets}\n\n${DEFAULTS_NOTE} ${CACHE_NOTE}\n\n${ADMIN_SCOPE_NOTE}`, + operationId: `get${pascal(id)}Settings`, + tags: ["Admin"], + security: bearerAuth(), + parameters: [], + responses: { + ...jsonResponse( + dataSchema, + `The current \`${id}\` section, defaults filled in and secrets mid-masked.`, + { example }, + ), + ...problemResponses(401, 403), + }, + }, + put: { + summary: `Replace the ${prose.title} settings section`, + description: `${prose.writeEffect}\n\n**This is a full replace, not a merge.** The body is validated against the entire section schema, so every required field must be present — omitting one is a \`400\`, not a request to leave it as it was. The supported pattern is: \`GET\` the section, change the fields you care about, and \`PUT\` the whole object back. Unknown keys are stripped rather than rejected, so a misspelled field name is silently ignored; check the returned \`meta.changedFields\` to confirm the edit you intended actually landed.\n\nValidation happens in two layers with two different error codes. The body must first be a JSON object at all (\`400 invalid_body\`; an empty body is read as \`{}\` and then fails the schema). It is then parsed by the section schema (\`400 invalid_setting\`), which reports only the **first** failing field, as \`: \` — fix it and resend to see the next one.\n\nOn success the body carries a third top-level key, \`meta.changedFields\`, beside the usual \`data\` and \`error\`. The write also busts this pod's cache entry for the section.\n\n${secrets}\n\n${ADMIN_SCOPE_NOTE}`, + operationId: `update${pascal(id)}Settings`, + tags: ["Admin"], + security: bearerAuth(), + parameters: [], + requestBody: jsonBody( + meta.schema, + `The complete \`${id}\` section. Every field the schema declares must be present.`, + { example }, + ), + responses: { + 200: { + description: `The stored \`${id}\` section after the write, secrets re-masked, plus the \`meta.changedFields\` write receipt.`, + content: { + "application/json": { + schema: { + ...putEnvelope, + required: ["data", "error", "meta"], + properties: { + ...(putEnvelope.properties as Record), + meta: changedFieldsMetaSchema, + }, + }, + example: { + data: example, + error: null, + meta: { changedFields: [...prose.changedFieldsExample] }, + }, + }, + }, + }, + ...problemResponses( + { + 400: "The body was not valid JSON or not a JSON object (`code: \"invalid_body\"`), or it failed the section schema (`code: \"invalid_setting\"`, `detail` being the first offending field as `: `). Nothing was written in either case.", + }, + 401, + 403, + ), + }, + }, + }; +} + +// --------------------------------------------------------------------------- +// LLM provider payloads +// --------------------------------------------------------------------------- + +/** + * One row of a provider's model catalogue. Hand-written: the handler + * projects `LlmProviderModel` (a TypeScript interface, no Zod source) + * straight onto the wire, with `Date` fields serialised as ISO 8601. + */ +const providerModelSchema: JsonSchema = { + type: "object", + description: + "One model in this provider's catalogue. Rows are normally created by the sync endpoint rather than by hand. The six surface flags can also be written by `POST` and `PUT /admin/settings/llm-providers/{id}` through their `models[]` array, but the per-model `PATCH` is the only path that enforces the platform-wide at-most-one-default-per-surface invariant — set a default any other way and you can end up with two.", + required: [ + "id", + "displayName", + "enabledForPlayground", + "enabledForSkillGen", + "enabledForAssistant", + "defaultForPlayground", + "defaultForSkillGen", + "defaultForAssistant", + "removed", + "firstSeenAt", + "lastSyncedAt", + ], + properties: { + id: { + type: "string", + description: "Provider-issued model id, exactly as the upstream catalogue spells it. This is the value a caller puts in a surface request's `model` field.", + examples: ["gpt-4o"], + }, + displayName: { type: "string", description: "Operator-facing label. Falls back to `id` when upstream supplies nothing better." }, + enabledForPlayground: { type: "boolean", description: "Selectable on the Playground surface. A model is usable there only when this is true **and** `removed` is false." }, + enabledForSkillGen: { type: "boolean", description: "Selectable on the skill-generation surface, same rule." }, + enabledForAssistant: { type: "boolean", description: "Selectable on the Ornn Assistant surface, same rule." }, + defaultForPlayground: { type: "boolean", description: "This is the Playground default. At most one model across **all** providers may carry this, and it implies `enabledForPlayground`." }, + defaultForSkillGen: { type: "boolean", description: "This is the skill-generation default, under the same at-most-one-across-all-providers rule." }, + defaultForAssistant: { type: "boolean", description: "This is the Ornn Assistant default, under the same rule." }, + removed: { + type: "boolean", + description: + "The model was in this catalogue once and is no longer offered upstream. The row is kept for history, every resolver skips it, its default flags were cleared when it disappeared, and its flags cannot be patched until a sync brings it back.", + }, + firstSeenAt: { type: "string", format: "date-time", description: "When this model first appeared in the catalogue (ISO 8601, UTC). Preserved across syncs and across a removal/reappearance." }, + lastSyncedAt: { type: "string", format: "date-time", description: "When the last sync observed this model (ISO 8601, UTC)." }, + }, +}; + +/** + * The provider's credential block as it comes back on a read: a + * discriminated union on `kind`, with the secret member mid-masked. + */ +const providerAuthSchema: JsonSchema = { + description: + "How this provider is authenticated, discriminated on `kind`. Exactly one secret member per kind — `apiKey`, `clientSecret`, or `password` — and it is always mid-masked on the way out. Writing back a value that still contains a `•` preserves the stored secret.", + oneOf: [ + { + type: "object", + title: "apiKey", + required: ["kind", "apiKey"], + properties: { + kind: { type: "string", enum: ["apiKey"], description: "Static bearer key sent on every call." }, + apiKey: { type: "string", description: "Mid-masked API key. Empty string when none is configured.", examples: ["sk-p••••••••3f9a"] }, + }, + }, + { + type: "object", + title: "tokenUrl", + required: ["kind", "tokenUrl", "clientId", "clientSecret"], + properties: { + kind: { type: "string", enum: ["tokenUrl"], description: "OAuth2 client-credentials exchange against `tokenUrl`." }, + tokenUrl: { type: "string", description: "Token endpoint. Must be a public `http(s)` URL — this is where the client secret is sent." }, + clientId: { type: "string", description: "OAuth2 client id. Not a secret; returned verbatim." }, + clientSecret: { type: "string", description: "Mid-masked OAuth2 client secret.", examples: ["cid_••••••••b71e"] }, + }, + }, + { + type: "object", + title: "basic", + required: ["kind", "username", "password"], + properties: { + kind: { type: "string", enum: ["basic"], description: "HTTP basic authentication." }, + username: { type: "string", description: "Basic-auth username. Not a secret; returned verbatim." }, + password: { type: "string", description: "Mid-masked basic-auth password.", examples: ["pass••••••••word"] }, + }, + }, + ], +}; + +/** One provider as every read path in this module returns it. */ +const providerSchema: JsonSchema = { + type: "object", + description: + "A configured LLM provider with its credential mid-masked. The same shape is returned by the list, the read, the create, the update, the sync, and the per-model patch — every write path re-reads through the masking projection, so no write response ever echoes a plaintext secret back.", + required: [ + "_id", + "name", + "gatewayUrl", + "modelListUrl", + "apiFormat", + "auth", + "models", + "maxOutputTokens", + "defaultTemperature", + "createdAt", + "updatedAt", + "updatedBy", + ], + properties: { + _id: { type: "string", description: "Server-assigned provider id — a UUID minted on create. This is the `{id}` path parameter everywhere below.", examples: ["8f2a1c34-9b7e-4d51-a0c6-1e5b3d9f0742"] }, + name: { type: "string", description: "Operator-facing provider name, unique across providers at create time.", examples: ["OpenAI"] }, + gatewayUrl: { type: "string", description: "Base URL completions are sent to. Must be a public `http(s)` URL." }, + modelListUrl: { type: "string", description: "URL the catalogue sync reads the model list from. Must be a public `http(s)` URL." }, + apiFormat: { type: "string", enum: ["chat-completion", "responses"], description: "Wire dialect this provider speaks. Decides both how completions are framed and how the sync parses the model list." }, + auth: providerAuthSchema, + models: { type: "array", items: providerModelSchema, description: "The provider's model catalogue, including rows flagged `removed`. Populated by the sync endpoint." }, + maxOutputTokens: { type: "integer", description: "Per-call output-token ceiling applied to every request routed through this provider (1–1000000).", examples: [8192] }, + defaultTemperature: { type: "number", description: "Sampling temperature (0–2) used when a caller does not specify one.", examples: [0.7] }, + createdAt: { type: "string", format: "date-time", description: "When the provider was created (ISO 8601, UTC)." }, + updatedAt: { type: "string", format: "date-time", description: "When the provider document was last written — including by a sync or a per-model patch (ISO 8601, UTC)." }, + updatedBy: { type: "string", description: "NyxID user id of the admin behind the most recent write." }, + }, +}; + +const providerListSchema: JsonSchema = { + type: "object", + required: ["items"], + description: "All configured providers. There is no pagination and no filtering — a deployment has a handful of providers, not a directory of them.", + properties: { + items: { type: "array", items: providerSchema, description: "Every provider, each with its credential mid-masked and its full model catalogue inlined." }, + }, +}; + +const syncResultSchema: JsonSchema = { + type: "object", + required: ["provider", "result"], + properties: { + provider: providerSchema, + result: { + type: "object", + required: ["added", "updated", "removed"], + description: "What the sync changed. All three zero means the upstream catalogue matched what was already stored — the sync is idempotent, so that is the expected result of running it twice.", + properties: { + added: { type: "integer", description: "Models seen for the first time. They arrive with every surface flag `false`, so a new upstream model never re-routes traffic on its own." }, + updated: { type: "integer", description: "Known models whose `displayName` changed, or that came back after having been flagged `removed`." }, + removed: { type: "integer", description: "Models that disappeared from the upstream catalogue on this run and were flagged `removed`. Counts the transition only, not the standing total of removed rows." }, + }, + }, + }, +}; + +/** Example provider, reused across the operation examples. */ +const providerExample = { + _id: "8f2a1c34-9b7e-4d51-a0c6-1e5b3d9f0742", + name: "OpenAI", + gatewayUrl: "https://api.openai.com/v1", + modelListUrl: "https://api.openai.com/v1/models", + apiFormat: "chat-completion", + auth: { kind: "apiKey", apiKey: "sk-p••••••••3f9a" }, + models: [ + { + id: "gpt-4o", + displayName: "GPT-4o", + enabledForPlayground: true, + enabledForSkillGen: true, + enabledForAssistant: true, + defaultForPlayground: true, + defaultForSkillGen: false, + defaultForAssistant: false, + removed: false, + firstSeenAt: "2026-05-02T09:14:00.000Z", + lastSyncedAt: "2026-08-07T04:12:30.442Z", + }, + ], + maxOutputTokens: 8192, + defaultTemperature: 0.7, + createdAt: "2026-05-02T09:14:00.000Z", + updatedAt: "2026-08-07T04:12:30.442Z", + updatedBy: "usr_01HXYZ7QK3M2N4P5R6S7T8V9W0", +}; + +const providerIdParam = pathParam( + "id", + "Provider id — the `_id` returned by the create call and by the listing. A UUID; an id that does not resolve is a `404` (`code: \"provider_not_found\"`).", + { type: "string" }, + "8f2a1c34-9b7e-4d51-a0c6-1e5b3d9f0742", +); + +// --------------------------------------------------------------------------- +// Path map +// --------------------------------------------------------------------------- + +/** + * All 27 operations, keyed by their full `/api/v1` path. + * + * The ten section entries are computed from the registry so they match the + * loop in `domains/settings/routes.ts` by construction; the seven provider + * entries must match the registrations in + * `domains/settings/llmProviders/routes.ts` character for character. Both + * halves are asserted against the booted router by a contract test. + */ +export function adminSettingsPaths(prefix: string): PathMap { + const paths: PathMap = {}; + + for (const id of Object.keys(sections) as SectionId[]) { + paths[`${prefix}/admin/settings/${sections[id].publicPath}`] = sectionPathItem(id); + } + + paths[`${prefix}/admin/settings/llm-providers`] = { + get: { + summary: "List configured LLM providers", + description: `Every LLM provider this deployment can route completions through, each with its full model catalogue inlined and its credential mid-masked. This is the admin-side catalogue that decides what ordinary callers see: the per-model \`enabledFor…\` flags below are exactly what \`GET /api/v1/me/models\` filters on, and the \`defaultFor…\` flags supply the fallback model for a surface whose settings section has no explicit pin. Unpaginated and unfiltered by design — a deployment has a handful of providers.\n\nStart here when a surface reports \`503 MODEL_UNAVAILABLE\`: that state means no model is both enabled for the surface and not \`removed\`, which is visible in this response. ${MASK_SEMANTICS}\n\n${ADMIN_SCOPE_NOTE}`, + operationId: "listLlmProviders", + tags: ["Admin"], + security: bearerAuth(), + parameters: [], + responses: { + ...jsonResponse(providerListSchema, "Every configured provider, credentials mid-masked.", { + example: { items: [providerExample] }, + }), + ...problemResponses(401, 403), + }, + }, + post: { + summary: "Create an LLM provider", + description: `Register a new provider. \`name\` must be unique across providers — a collision is a \`409\` and nothing is written. \`gatewayUrl\` and \`modelListUrl\` must both be public \`http(s)\` URLs; loopback, RFC1918, link-local, and cloud-metadata hosts are refused at the schema, because these URLs receive the provider credential on first use. \`apiFormat\` picks the wire dialect and therefore also how the model list is parsed.\n\n\`auth\` is a discriminated union on \`kind\`: \`apiKey\` (one static key), \`tokenUrl\` (OAuth2 client-credentials — \`tokenUrl\` is itself public-URL checked), or \`basic\` (username/password). The secret member is encrypted before it is stored.\n\n\`models\` is optional and normally omitted: create the provider, then call the sync endpoint to populate the catalogue from upstream. If you do supply models, every surface flag you leave out defaults to \`false\`, so a hand-seeded catalogue never re-routes a surface by accident.\n\nThe \`201\` body is not an echo of what you sent — the provider is re-read through the masking projection, so the credential you just submitted comes back mid-masked. ${ADMIN_SCOPE_NOTE}`, + operationId: "createLlmProvider", + tags: ["Admin"], + security: bearerAuth(), + parameters: [], + requestBody: jsonBody(providerCreateSchema, "The provider to create.", { + example: { + name: "OpenAI", + gatewayUrl: "https://api.openai.com/v1", + modelListUrl: "https://api.openai.com/v1/models", + apiFormat: "chat-completion", + auth: { kind: "apiKey", apiKey: "sk-proj-REPLACE-ME" }, + maxOutputTokens: 8192, + defaultTemperature: 0.7, + }, + }), + responses: { + ...jsonResponse(providerSchema, "The provider was created. Body is the stored document, credential mid-masked.", { + status: 201, + example: { ...providerExample, models: [] }, + }), + ...problemResponses( + { + 400: "The body was not a JSON object (`code: \"invalid_body\"`), or it failed the provider schema (`code: \"invalid_provider_input\"`, `detail` being the first offending field as `: `) — a missing field, a non-public `gatewayUrl` / `modelListUrl` / `tokenUrl`, an unknown `apiFormat` or `auth.kind`, or an out-of-range `maxOutputTokens` / `defaultTemperature`.", + 409: "A provider with this `name` already exists (`code: \"PROVIDER_NAME_TAKEN\"`). Nothing was written; pick another name or update the existing provider instead.", + }, + 401, + 403, + ), + }, + }, + }; + + paths[`${prefix}/admin/settings/llm-providers/{id}`] = { + get: { + summary: "Read one LLM provider", + description: `One provider by id, with its full model catalogue and its credential mid-masked — the same projection the listing uses. Use it to inspect a single provider's catalogue before flipping surface flags, and as the read half of the read-modify-write cycle for the \`PUT\` below.\n\n${MASK_SEMANTICS}\n\n${ADMIN_SCOPE_NOTE}`, + operationId: "getLlmProvider", + tags: ["Admin"], + security: bearerAuth(), + parameters: [providerIdParam], + responses: { + ...jsonResponse(providerSchema, "The provider, credential mid-masked.", { example: providerExample }), + ...problemResponses( + 401, + 403, + { 404: "No provider with this id (`code: \"provider_not_found\"`)." }, + ), + }, + }, + put: { + summary: "Update an LLM provider", + description: `Despite the verb this is a **partial** update: every field is optional, and one you omit keeps its stored value. Send only what you are changing.\n\nThree fields have semantics worth reading twice. **\`auth\`** is replaced whole when present — send the complete discriminated object, including \`kind\`, not just the member you are editing; the secret member follows the mask rule, so echoing back the mid-masked value you read preserves the stored credential and sending plaintext rotates it. **\`models\`** distinguishes absent from empty: omit it and the stored catalogue is untouched, send \`[]\` and the catalogue is wiped. For any model you do send, an omitted surface flag inherits the stored value for that model rather than resetting to \`false\`, and \`firstSeenAt\` / \`lastSyncedAt\` are preserved — only the sync endpoint moves those. **\`name\`** is not re-checked for uniqueness on this path, unlike on create, so a rename can collide with an existing provider without a \`409\`.\n\nThe response is the provider re-read through the masking projection. ${ADMIN_SCOPE_NOTE}`, + operationId: "updateLlmProvider", + tags: ["Admin"], + security: bearerAuth(), + parameters: [providerIdParam], + requestBody: jsonBody(providerUpdateSchema, "The provider fields to change. All optional; omitted fields keep their stored value.", { + example: { + gatewayUrl: "https://api.openai.com/v1", + maxOutputTokens: 16384, + auth: { kind: "apiKey", apiKey: "sk-p••••••••3f9a" }, + }, + }), + responses: { + ...jsonResponse(providerSchema, "The updated provider, credential mid-masked.", { + example: { ...providerExample, maxOutputTokens: 16384 }, + }), + ...problemResponses( + { + 400: "The body was not a JSON object (`code: \"invalid_body\"`), or a field that was present failed validation (`code: \"invalid_provider_input\"`, `detail` being the first offending field as `: `).", + }, + 401, + 403, + { 404: "No provider with this id (`code: \"provider_not_found\"`). The existence check runs before the body is validated against the provider schema, but AFTER the JSON-object gate — so a malformed or non-object body still returns `400 invalid_body` even for an unknown id." }, + ), + }, + }, + delete: { + summary: "Delete an LLM provider", + description: `Permanently removes the provider document and, with it, the entire model catalogue underneath. There is no soft delete, no undo, and no confirmation step — the \`removed\` flag on a model row is about upstream catalogue drift and has nothing to do with this.\n\nNothing cascades and nothing is checked first. A settings section still pinning a model that lived only on this provider keeps the now-dangling \`defaultModelId\`, and the surface falls back to whatever else is enabled — or answers \`503 MODEL_UNAVAILABLE\` if that leaves it with nothing. Check what depends on the provider before deleting it, and re-check the surface sections afterwards. Returns \`204\` with no body. ${ADMIN_SCOPE_NOTE}`, + operationId: "deleteLlmProvider", + tags: ["Admin"], + security: bearerAuth(), + parameters: [providerIdParam], + responses: { + ...noContentResponse("The provider and its model catalogue were deleted. No body."), + ...problemResponses( + 401, + 403, + { 404: "No provider with this id (`code: \"provider_not_found\"`) — including the case where a concurrent delete already removed it." }, + ), + }, + }, + }; + + paths[`${prefix}/admin/settings/llm-providers/{id}/sync`] = { + post: { + summary: "Sync a provider's model catalogue", + description: `Fetches the provider's model list from its \`modelListUrl\` (authenticating with the stored credential, parsed according to \`apiFormat\`) and reconciles it against the stored catalogue. Takes no request body.\n\nThe merge is deliberately conservative and idempotent — running it twice against an unchanged upstream reports \`{ added: 0, updated: 0, removed: 0 }\`. A model already known keeps all six surface flags and gets a fresh \`lastSyncedAt\`. A model seen for the first time arrives with every flag \`false\`, so a new upstream model can never re-route a surface on its own; enable it explicitly with the per-model \`PATCH\`. A model that has disappeared upstream is **not** deleted — it is flagged \`removed: true\` and kept for history, with its default flags cleared in the same write so a surface is never left pointing at a model that no longer exists. A \`removed\` row that reappears upstream flips back and keeps the flags it had.\n\nThis is the only write path that touches \`firstSeenAt\` / \`lastSyncedAt\`, and it is the intended way to populate a freshly-created provider. The response carries both the re-read provider and the three counters. ${ADMIN_SCOPE_NOTE}`, + operationId: "syncLlmProviderModels", + tags: ["Admin"], + security: bearerAuth(), + parameters: [providerIdParam], + responses: { + ...jsonResponse(syncResultSchema, "The catalogue was reconciled. `result` reports what changed; `provider` is the provider after the merge.", { + example: { provider: providerExample, result: { added: 2, updated: 1, removed: 0 } }, + }), + ...problemResponses( + 401, + 403, + { 404: "No provider with this id (`code: \"provider_not_found\"`)." }, + { + 503: "The provider's model-list endpoint could not be read (`code: \"MODEL_LIST_UNREACHABLE\"`) — unreachable host, TLS failure, rejected credential, or an unparseable response; the underlying message is quoted in `detail`. The stored catalogue is untouched, so this is safe to retry, but a rejected credential or a wrong `modelListUrl` will not fix itself.", + }, + ), + }, + }, + }; + + paths[`${prefix}/admin/settings/llm-providers/{id}/models/{modelId}`] = { + patch: { + summary: "Set one model's per-surface flags", + description: `The single write path for the six surface flags on one model — \`enabledForPlayground\`, \`enabledForSkillGen\`, \`enabledForAssistant\`, \`defaultForPlayground\`, \`defaultForSkillGen\`, \`defaultForAssistant\`. Send any subset; a flag you omit is preserved. At least one recognised flag must be present, and since unknown keys are stripped before that check, a body of only misspelled keys fails as an empty patch.\n\nThree invariants are enforced server-side, and two of them change state you did not name in the request. Setting \`defaultForX: true\` **clears that same flag on every other model across every other provider** — at most one default per surface exists platform-wide — and forces \`enabledForX: true\` on this model, because a default that is not enabled would silently mis-route the surface. Setting \`enabledForX: false\` on a model that is currently the surface default also clears \`defaultForX\`. And a model flagged \`removed: true\` refuses all patches until a sync brings it back.\n\nThe response is **this provider only**. Sibling providers whose defaults were just cleared are not in it — re-list if you need the platform-wide picture. Note also that \`{modelId}\` is a single path segment: a provider whose model ids contain a slash cannot be addressed through this route. ${ADMIN_SCOPE_NOTE}`, + operationId: "patchLlmProviderModelFlags", + tags: ["Admin"], + security: bearerAuth(), + parameters: [ + providerIdParam, + pathParam( + "modelId", + "The model's provider-issued id, exactly as it appears in the provider's `models[].id` — not the display name. Must be a single path segment. A model id that is not on this provider is a `404` (`code: \"MODEL_NOT_FOUND\"`).", + { type: "string" }, + "gpt-4o", + ), + ], + requestBody: jsonBody(modelFlagsPatchSchema, "The surface flags to change. Any subset; at least one must be present.", { + example: { enabledForPlayground: true, defaultForPlayground: true }, + }), + responses: { + ...jsonResponse(providerSchema, "The provider after the patch, with this model's flags updated and the credential mid-masked.", { + example: providerExample, + }), + ...problemResponses( + { + 400: "The body was not a JSON object (`code: \"invalid_body\"`); or it carried no recognised flag (`code: \"invalid_provider_input\"`, `detail: \"At least one flag must be provided\"`), which is also what a body of only misspelled keys produces; or the model is flagged `removed` (`code: \"MODEL_REMOVED\"`) and must be restored by a sync before its flags can change.", + }, + 401, + 403, + { + 404: "Either no provider with this id (`code: \"provider_not_found\"`) or no such model on that provider (`code: \"MODEL_NOT_FOUND\"`). Branch on `code` to tell them apart.", + }, + ), + }, + }, + }; + + return paths; +} diff --git a/ornn-api/src/openapi/paths/auditAnalytics.ts b/ornn-api/src/openapi/paths/auditAnalytics.ts new file mode 100644 index 00000000..65e237a3 --- /dev/null +++ b/ornn-api/src/openapi/paths/auditAnalytics.ts @@ -0,0 +1,734 @@ +/** + * OpenAPI paths for the **Audit & analytics** domain (#1214). + * + * Two read-heavy, skill-scoped surfaces that answer different questions + * about the same skill: + * + * - **Audit** (`domains/skills/audit/routes.ts`) — "is this skill safe + * to install and run?". An audit is an LLM review of one specific + * *version's* package bytes, scored across five dimensions + * (security, code_quality, documentation, reliability, + * permission_scope) and reduced to a green / yellow / red verdict. + * Audits are never triggered implicitly: sharing a skill does not + * audit it, and pulling a skill does not audit it. An owner or a + * platform admin POSTs to start one, then polls the GET endpoints + * for the terminal record. + * - **Analytics** (`domains/analytics/routes.ts`) — "is this skill + * actually being used, and does it work?". Two aggregates over + * append-only event logs: an execution summary (counts, success + * rate, latency percentiles, top error codes) and a time-bucketed + * pull series split by `api` / `web` / `playground`. Only the pull + * log is written in this build — no code path records an execution + * event, so the execution summary is always all-zero / all-null. + * + * Everything here is skill-scoped and therefore visibility-scoped: the + * read endpoints mirror `GET /skills/{idOrName}` exactly. A private + * skill the caller cannot read answers `404 skill_not_found` — never + * 403 — so existence is not leaked. + * + * No Zod schemas exist for these payloads (the domains model their wire + * shapes as TypeScript interfaces in `types.ts`, and the one Zod body + * schema in the audit routes module is not exported), so the schemas + * below are hand-written and must be kept in lockstep with + * `domains/skills/audit/types.ts` and `domains/analytics/types.ts`. + * + * @module openapi/paths/auditAnalytics + */ + +import { + bearerAuth, + jsonBody, + jsonResponse, + optionalAuth, + pathParam, + problemResponses, + queryParam, + type JsonSchema, + type PathMap, +} from "../helpers"; + +// --------------------------------------------------------------------------- +// Shared schema fragments — audit +// --------------------------------------------------------------------------- + +/** Mirrors `AuditDimension` in `domains/skills/audit/types.ts`. */ +const auditDimensionSchema: JsonSchema = { + type: "string", + enum: ["security", "code_quality", "documentation", "reliability", "permission_scope"], + description: + "One of the five fixed scoring dimensions. A completed audit always carries exactly one score per dimension — the parser rejects LLM output that omits any of them, so clients may index by dimension without a presence check.", +}; + +const auditScoreSchema: JsonSchema = { + type: "object", + required: ["dimension", "score", "rationale"], + properties: { + dimension: auditDimensionSchema, + score: { + type: "integer", + minimum: 0, + maximum: 10, + description: "Integer 0–10, clamped and rounded server-side. Higher is better.", + }, + rationale: { + type: "string", + description: + "One or two sentences explaining the score. May be an empty string when the model returned no rationale.", + }, + }, +}; + +const auditFindingSchema: JsonSchema = { + type: "object", + required: ["dimension", "severity", "message"], + properties: { + dimension: auditDimensionSchema, + severity: { + type: "string", + enum: ["info", "warning", "critical"], + description: + "A single `critical` finding forces the verdict to `red` regardless of the numeric scores. Treat `critical` as a hard stop before executing the skill.", + }, + file: { + type: "string", + description: + "Path of the offending file relative to the skill package root (e.g. `scripts/run.py`). Absent when the finding is about the package as a whole.", + }, + line: { + type: "integer", + description: "1-indexed line number inside `file`. Absent when the model did not localise the finding.", + }, + message: { + type: "string", + description: "Short description of what was found. Always non-empty.", + }, + }, +}; + +/** Mirrors `AuditRecord` in `domains/skills/audit/types.ts`. */ +const auditRecordSchema: JsonSchema = { + type: "object", + required: [ + "_id", + "skillGuid", + "version", + "skillHash", + "status", + "verdict", + "overallScore", + "scores", + "findings", + "model", + "createdAt", + "triggeredBy", + ], + description: + "One audit run of one skill version. Rows are append-only: every trigger inserts a new record and updates it in place from `running` to a terminal state. Nothing is ever overwritten, so the history endpoint is a full audit trail.", + properties: { + _id: { + type: "string", + description: "UUID of this audit run. Stable; use it to correlate a poll with the run you triggered.", + }, + skillGuid: { type: "string", description: "GUID of the audited skill (never the name)." }, + version: { + type: "string", + description: "The skill version this run scored, e.g. `1.2`. An audit is always version-specific.", + }, + skillHash: { + type: "string", + description: + "SHA-256 of the package bytes at audit time. The cache key: re-triggering with unchanged bytes inside the 30-day TTL returns this same record instead of spending another LLM call.", + }, + status: { + type: "string", + enum: ["running", "completed", "failed"], + description: + "`running` — the LLM pipeline is still in flight and the result fields below are placeholders (verdict `yellow`, overallScore `0`, empty `scores`/`findings`). `completed` — every result field is final. `failed` — the pipeline errored; see `errorMessage`. Branch on this before reading `verdict`.", + }, + verdict: { + type: "string", + enum: ["green", "yellow", "red"], + description: + "`green` — overall ≥ 7.5, every dimension ≥ 5, no critical findings. `yellow` — any dimension < 5 or overall < 7.5. `red` — any critical finding, or any dimension < 3. Meaningless unless `status === \"completed\"`.", + }, + overallScore: { + type: "number", + minimum: 0, + maximum: 10, + description: "Weighted mean of the five dimension scores, rounded to one decimal. `0` while `status === \"running\"`.", + }, + scores: { + type: "array", + items: auditScoreSchema, + description: "Exactly five entries once completed, one per dimension, in the fixed dimension order. Empty while running.", + }, + findings: { + type: "array", + items: auditFindingSchema, + description: "Concrete issues the model flagged. May legitimately be empty on a clean `green` audit.", + }, + model: { + type: "string", + description: + "LLM model id used for this run, snapshotted at trigger time so historical records stay interpretable after an admin swaps providers. Empty string when no audit model is configured.", + }, + createdAt: { + type: "string", + format: "date-time", + description: "ISO-8601 UTC timestamp of when the run was queued.", + }, + completedAt: { + type: "string", + format: "date-time", + description: "ISO-8601 UTC timestamp of the transition to `completed` or `failed`. Absent while `running`.", + }, + errorMessage: { + type: "string", + description: "Short failure cause, truncated to 500 characters. Present only when `status === \"failed\"`.", + }, + triggeredBy: { + type: "string", + description: + "NyxID user id of whoever started the run, or `system` when an automated pipeline did. Cache hits carry the id of the *original* triggerer, not yours.", + }, + }, +}; + +const AUDIT_RECORD_EXAMPLE = { + _id: "3f5b1a0e-8c1a-4a52-9f4c-0c0a1c7b9d21", + skillGuid: "550e8400-e29b-41d4-a716-446655440000", + version: "1.2", + skillHash: "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + status: "completed", + verdict: "yellow", + overallScore: 7.2, + scores: [ + { dimension: "security", score: 6, rationale: "Shells out to curl with an unvalidated URL argument." }, + { dimension: "code_quality", score: 8, rationale: "Small, readable, single-responsibility scripts." }, + { dimension: "documentation", score: 8, rationale: "SKILL.md documents every input and output." }, + { dimension: "reliability", score: 7, rationale: "No retry on the network call." }, + { dimension: "permission_scope", score: 7, rationale: "Requests network access it only sometimes uses." }, + ], + findings: [ + { + dimension: "security", + severity: "warning", + file: "scripts/fetch.sh", + line: 12, + message: "URL is interpolated into a shell command without quoting.", + }, + ], + model: "gpt-4.1-mini", + createdAt: "2026-08-01T09:12:44.101Z", + completedAt: "2026-08-01T09:13:02.884Z", + triggeredBy: "usr_01J8Z4H9Q3N2", +}; + +/** A freshly queued run — every result field is still a placeholder. */ +function runningAuditExample(id: string, createdAt: string, triggeredBy: string): Record { + return { + _id: id, + skillGuid: AUDIT_RECORD_EXAMPLE.skillGuid, + version: AUDIT_RECORD_EXAMPLE.version, + skillHash: AUDIT_RECORD_EXAMPLE.skillHash, + status: "running", + verdict: "yellow", + overallScore: 0, + scores: [], + findings: [], + model: AUDIT_RECORD_EXAMPLE.model, + createdAt, + triggeredBy, + }; +} + +/** + * `POST` body for both trigger endpoints. Hand-written because the + * route module's `auditTriggerSchema` is a file-local `const` and is not + * exported — importing it would not compile. + */ +const auditTriggerBodySchema: JsonSchema = { + type: "object", + properties: { + force: { + type: "boolean", + default: false, + description: + "When `true`, skip the cache lookup and always start a fresh run. When `false` (default) a completed audit of the same package bytes younger than the 30-day TTL is returned as-is, spending no LLM budget.", + }, + }, + // No `additionalProperties: false`: the route parses the body with a + // non-strict Zod object, so unknown keys are stripped and the request + // still succeeds. Documenting a rejection the server never performs + // would make validating gateways refuse bodies the API accepts. +}; + +// --------------------------------------------------------------------------- +// Shared schema fragments — analytics +// --------------------------------------------------------------------------- + +/** Mirrors `SkillAnalyticsSummary` in `domains/analytics/types.ts`. */ +const analyticsSummarySchema: JsonSchema = { + type: "object", + required: [ + "skillGuid", + "window", + "executionCount", + "successCount", + "failureCount", + "timeoutCount", + "successRate", + "latencyMs", + "uniqueUsers", + "topErrorCodes", + ], + properties: { + skillGuid: { + type: "string", + description: "GUID of the skill the aggregate covers — resolved from the `idOrName` path parameter.", + }, + window: { + type: "string", + enum: ["7d", "30d", "all"], + description: "Echo of the `window` query parameter, so a cached response is self-describing.", + }, + version: { + type: "string", + description: "Echo of the `version` query parameter. Absent when the aggregate spans every version.", + }, + executionCount: { + type: "integer", + description: "Total execution events in the window. `0` means no data — not a failure.", + }, + successCount: { type: "integer", description: "Executions with outcome `success`." }, + failureCount: { type: "integer", description: "Executions with outcome `failure`." }, + timeoutCount: { type: "integer", description: "Executions with outcome `timeout`." }, + successRate: { + type: ["number", "null"], + minimum: 0, + maximum: 1, + description: + "`successCount / executionCount` as a decimal in [0, 1], or `null` when `executionCount === 0`. Do not coerce `null` to `0` — they mean different things.", + }, + latencyMs: { + type: "object", + required: ["p50", "p95", "p99"], + description: + "Wall-clock invocation latency percentiles, in milliseconds, computed server-side over the window. Every field is `null` when there were no executions.", + properties: { + p50: { type: ["integer", "null"], description: "Median latency in ms." }, + p95: { type: ["integer", "null"], description: "95th-percentile latency in ms." }, + p99: { type: ["integer", "null"], description: "99th-percentile latency in ms." }, + }, + }, + uniqueUsers: { + type: "integer", + description: + "Count of distinct caller ids across the events in the window. The event log reserves the literal id `anonymous` for executions recorded off an unauthenticated path, so those collapse into one user rather than being dropped. `0` until an execution hook exists.", + }, + topErrorCodes: { + type: "array", + maxItems: 5, + description: + "Up to five most frequent error codes across non-success executions, descending by count. Empty when nothing failed or when failures carried no error code.", + items: { + type: "object", + required: ["code", "count"], + properties: { + code: { type: "string", description: "Free-form lowercase error code emitted by the execution hook." }, + count: { type: "integer", description: "Occurrences of this code in the window." }, + }, + }, + }, + }, +}; + +/** Mirrors `PullBucketCount` in `domains/analytics/types.ts`. */ +const pullBucketSchema: JsonSchema = { + type: "object", + required: ["bucket", "total", "bySource"], + properties: { + bucket: { + type: "string", + format: "date-time", + description: + "ISO-8601 UTC timestamp at the START of the bucket, truncated to the requested granularity (e.g. `2026-08-01T00:00:00.000Z` for `bucket=day`). Buckets are always UTC-pinned; the client's timezone is never consulted.", + }, + total: { type: "integer", description: "Pull count in this bucket across all sources." }, + bySource: { + type: "object", + required: ["api", "web", "playground"], + description: + "Per-source split. All three keys are always present and zero-filled, so charts do not need null handling.", + properties: { + api: { + type: "integer", + description: + "One per `GET /skills/{idOrName}/json` — the only source that actually hands back package contents. SDK, CLI, or an external agent consuming the skill programmatically; the closest signal to real machine adoption.", + }, + web: { + type: "integer", + description: + "One per `GET /skills/{idOrName}` metadata read by an authenticated caller. Not ornn-web-specific despite the name — any client that reads skill metadata increments it, and that endpoint transfers no package bytes.", + }, + playground: { + type: "integer", + description: + "One per `POST /playground/chat` request whose body carries a `skillId`. That route is stateless and receives the full message history each turn, so this counts chat turns, not playground sessions.", + }, + }, + }, + }, +}; + +// --------------------------------------------------------------------------- +// Reusable parameters +// --------------------------------------------------------------------------- + +const skillIdOrNameParam = pathParam( + "idOrName", + "Skill GUID (e.g. `550e8400-e29b-41d4-a716-446655440000`) or the skill's unique name (e.g. `web-summarizer`). Both resolve to the same skill; the GUID is stable across renames, so prefer it for stored references.", + { type: "string" }, + "web-summarizer", +); + +// --------------------------------------------------------------------------- +// Path map +// --------------------------------------------------------------------------- + +/** + * Build the Audit + Analytics slice of the spec. + * + * @param prefix Mount prefix of the v1 API (`/api/v1`). Path keys must + * reproduce the Hono routes verbatim after this prefix — the contract + * test in `tests/contract/openapiRoutes.test.ts` reflects the booted + * router against these keys and fails on any drift, including a + * renamed path parameter. + */ +export function auditAnalyticsPaths(prefix: string): PathMap { + return { + [`${prefix}/skills/{idOrName}/audit`]: { + get: { + summary: "Get the latest audit for a skill version", + description: + "Read the **newest** audit row for one skill version, and only when that row's status is `completed`. This is a pure cache read — it never starts an audit and never spends LLM budget, so it is safe to call on every pull. Use it as the pre-flight safety check before installing or executing a third-party skill: gate on `verdict` (`red` means do not run) and inspect `findings` for the specifics.\n\n" + + "Without `version` the audit of the skill's latest version is returned. With `version` the audit of that exact version is returned; the skill/version pair must exist or you get a 404 before any audit lookup happens.\n\n" + + "Three different situations all surface as 404 and you must distinguish them by the `code` field: `skill_not_found` (no such skill, or a private skill you cannot read), `skill_version_not_found` (the skill exists but not at that version), and `audit_not_found` (the version's newest audit row is not `completed`).\n\n" + + "`audit_not_found` is broader than \"never audited\": only the single newest row for the version is inspected, so a `running` or `failed` row **masks** an older completed audit. A previously published verdict therefore disappears for the whole duration of a re-audit, and stays hidden after a failed run until the next successful one. Treat `audit_not_found` as \"unknown risk\", never as a pass. When you need the last known-good verdict regardless of what is in flight, use `GET /skills/{idOrName}/audit/summary-by-version`, which genuinely selects the latest *completed* run per version; when you need to see the in-flight or failed row itself (and its `errorMessage`), use `GET /skills/{idOrName}/audit/history`.\n\n" + + "Visibility matches `GET /skills/{idOrName}`: public skills are readable anonymously; a private skill requires a token whose bearer is the author, a grantee (direct or via a granted org), or a platform admin.", + operationId: "getSkillAudit", + tags: ["Audit"], + security: optionalAuth(), + parameters: [ + skillIdOrNameParam, + queryParam( + "version", + "Version to read the audit for. Either a literal `.` version (e.g. `1.2`) or an `@`-prefixed dist-tag (e.g. `@latest`, `@beta`) which is resolved server-side. Omit for the skill's current latest version. A malformed literal (`1.2.3`, `v1`) is a 400, not a 404.", + { type: "string", examples: ["1.2", "@latest"] }, + ), + ], + responses: { + ...jsonResponse(auditRecordSchema, "The resolved version's newest audit run — always `status: \"completed\"`.", { + example: AUDIT_RECORD_EXAMPLE, + }), + ...problemResponses( + { + 400: "Bad request — `version` is not a valid `.` literal (`invalid_version`) or the `@` dist-tag name is empty (`invalid_dist_tag`).", + }, + { + 404: "Not found — `skill_not_found` (unknown skill, or private and not visible to this caller), `skill_version_not_found` (unknown version or unset dist-tag), or `audit_not_found` (the version's newest audit row is not `completed` — never audited, a run is in flight, or the last run failed; an older completed run is NOT surfaced). Branch on `code`.", + }, + ), + }, + }, + post: { + summary: "Start an audit of a skill (owner or platform admin)", + description: + "Queue an LLM audit of the skill's latest version. This is the \"Start Auditing\" action: audits are never implicit — publishing, sharing, and changing permissions all leave the audit state untouched — so an owner has to ask for one explicitly.\n\n" + + "The call is **asynchronous and returns 200, not 201**. A row is inserted at `status: \"running\"` and returned immediately; the LLM pipeline (download package → bundle readable files → score → classify) then completes in the background and updates that same row to `completed` or `failed`. Poll `GET /skills/{idOrName}/audit/history` and match the returned `_id` — that is the only read that shows the run while it is `running` and the only one that shows an `errorMessage` if it ends `failed`. `GET /skills/{idOrName}/audit` answers 404 `audit_not_found` for both of those states, since it only ever returns the version's newest row and only when that row is `completed`. A typical run finishes in tens of seconds; poll no faster than every few seconds.\n\n" + + "Cache semantics: with the default `force: false`, a completed audit of the *same package bytes* younger than the 30-day TTL short-circuits the pipeline and is returned verbatim — you will get a record with `status: \"completed\"` and a `triggeredBy` that is not you. That is the intended cheap path. Send `force: true` only when you specifically need a re-score (e.g. the audit model was upgraded); it always spends an LLM call. The request body is optional — an empty body is accepted and treated as `{}`.\n\n" + + "Authorization is checked in the handler, not by a route scope: the caller must be the skill's author, or hold `ornn:admin:skill`. Anyone else gets 403 `not_skill_owner`. Platform admins may prefer `POST /admin/skills/{idOrName}/audit`, which skips the ownership lookup entirely.\n\n" + + "Completion fans out notifications: the owner is always notified; users and org members the skill is shared with are notified only on a `yellow` or `red` verdict.", + operationId: "triggerSkillAudit", + tags: ["Audit"], + security: bearerAuth(), + parameters: [skillIdOrNameParam], + requestBody: jsonBody( + auditTriggerBodySchema, + "Optional trigger options. Omit the body entirely to accept the defaults.", + { required: false, example: { force: true } }, + ), + responses: { + ...jsonResponse( + auditRecordSchema, + "Audit accepted. Either a freshly inserted `running` record (poll for the verdict) or, on a cache hit, an existing `completed` record. Check `status` before reading `verdict`.", + { + example: runningAuditExample( + "b81e0f66-2d43-4d2a-9a53-6c1f5b7f8a10", + "2026-08-07T11:02:10.004Z", + "usr_01J8Z4H9Q3N2", + ), + }, + ), + ...problemResponses( + { 400: "Bad request — the body is not valid JSON, or `force` is not a boolean (`invalid_audit_body`)." }, + 401, + { 403: "Forbidden — `not_skill_owner`. Only the skill's author or a holder of `ornn:admin:skill` may start an audit." }, + { 404: "Not found — `skill_not_found`: no such skill." }, + { + 500: "Internal server error — the audit defaults could not be resolved from platform settings, or the audit row could not be persisted. Retry with backoff.", + }, + ), + }, + }, + }, + + [`${prefix}/skills/{idOrName}/audit/summary-by-version`]: { + get: { + summary: "Latest completed audit per skill version", + description: + "One request that answers \"which versions of this skill have been audited, and how did each score?\". Returns a map keyed by version string, where each value is that version's most recent **completed** audit record.\n\n" + + "Versions that have never completed an audit are simply absent from the map — there is no `null` placeholder. Treat a missing key as \"not audited yet\" (unknown risk), never as a passing grade. Running and failed runs are excluded too, so the map only ever contains records with `status: \"completed\"`.\n\n" + + "Use this instead of N calls to `GET /skills/{idOrName}/audit?version=` when you are deciding which version of a skill to pin: it is a single round-trip over every version the skill has. When you need in-flight or failed runs, or multiple runs of the same version, use the history endpoint.\n\n" + + "Visibility matches `GET /skills/{idOrName}` — a private skill the caller cannot read answers 404 `skill_not_found`.", + operationId: "getSkillAuditSummaryByVersion", + tags: ["Audit"], + security: optionalAuth(), + parameters: [skillIdOrNameParam], + responses: { + ...jsonResponse( + { + type: "object", + required: ["byVersion"], + properties: { + byVersion: { + type: "object", + description: + "Version string → that version's most recent completed audit. Keys are the literal version strings (e.g. `1.2`); unaudited versions are omitted. An empty object means no version of this skill has ever completed an audit.", + additionalProperties: auditRecordSchema, + }, + }, + }, + "Per-version audit map. May be empty.", + { + example: { + byVersion: { + "1.2": AUDIT_RECORD_EXAMPLE, + }, + }, + }, + ), + ...problemResponses({ + 404: "Not found — `skill_not_found`: no such skill, or it is private and not visible to this caller.", + }), + }, + }, + }, + + [`${prefix}/skills/{idOrName}/audit/history`]: { + get: { + summary: "List every audit run for a skill", + description: + "Full audit trail for a skill, newest first, across every version. Unlike `GET /skills/{idOrName}/audit`, this returns **every** row regardless of status — `running`, `completed`, and `failed` — so it is the endpoint to poll after triggering an audit and the endpoint to read when you need to know *why* an audit did not produce a verdict (`status: \"failed\"` plus `errorMessage`).\n\n" + + "Records are append-only: each trigger inserts a new row, so the same version can appear many times. Ordering is by `createdAt` descending, so `items[0]` is the most recent run of any version.\n\n" + + "The optional `version` filter is an **exact string match on the stored record**, applied after retrieval — it does not resolve dist-tags. Passing `@latest` here returns an empty list; pass the concrete version (e.g. `1.2`).\n\n" + + "There is no pagination: the response carries every stored record for the skill. Audit rows are low-cardinality (one per explicit trigger), but do not assume a bound if you are rendering them.\n\n" + + "Visibility matches `GET /skills/{idOrName}`.", + operationId: "listSkillAuditHistory", + tags: ["Audit"], + security: optionalAuth(), + parameters: [ + skillIdOrNameParam, + queryParam( + "version", + "Narrow the history to a single version. Exact literal match against the record's stored `version` (e.g. `1.2`) — dist-tags are NOT resolved here. Omit to get every version's runs.", + { type: "string", examples: ["1.2"] }, + ), + ], + responses: { + ...jsonResponse( + { + type: "object", + required: ["items"], + properties: { + items: { + type: "array", + items: auditRecordSchema, + description: + "Every stored audit run, newest first. Empty when the skill has never been audited (or when the `version` filter matched nothing).", + }, + }, + }, + "Audit history, newest first. May be empty.", + { example: { items: [AUDIT_RECORD_EXAMPLE] } }, + ), + ...problemResponses({ + 404: "Not found — `skill_not_found`: no such skill, or it is private and not visible to this caller.", + }), + }, + }, + }, + + [`${prefix}/admin/skills/{idOrName}/audit`]: { + post: { + summary: "Force an audit as a platform admin", + description: + "Platform-admin twin of `POST /skills/{idOrName}/audit`. Identical semantics — asynchronous, returns **200** with the `running` row (or a cached `completed` row), same `force` body, same background pipeline, same notification fan-out — with the ownership check removed: it audits any skill on the platform, including ones the caller has no relationship to.\n\n" + + "Requires the `ornn:admin:skill` request scope on the bearer token, enforced by `requirePermission` before the handler runs. A valid token without that scope is 403 `forbidden`; no token at all is 401.\n\n" + + "Prefer this route for moderation and incident response (re-scoring a reported skill, back-filling audits after a model upgrade). Note that `force` still defaults to `false`, so a plain call can return a cached record without running anything — send `force: true` when you genuinely need fresh scores.\n\n" + + "Poll `GET /skills/{idOrName}/audit/history` for the outcome; the admin path has no separate read endpoint.", + operationId: "adminTriggerSkillAudit", + tags: ["Audit"], + security: bearerAuth(), + parameters: [skillIdOrNameParam], + requestBody: jsonBody( + auditTriggerBodySchema, + "Optional trigger options. Omit the body entirely to accept the defaults.", + { required: false, example: { force: true } }, + ), + responses: { + ...jsonResponse( + auditRecordSchema, + "Audit accepted. A freshly inserted `running` record, or an existing `completed` record on a cache hit.", + { + example: runningAuditExample( + "c02a7f31-5f9c-4e7d-8a1b-1d9e2f3a4b5c", + "2026-08-07T11:05:41.772Z", + "usr_01J9ADMIN0001", + ), + }, + ), + ...problemResponses( + { 400: "Bad request — the body is not valid JSON, or `force` is not a boolean (`invalid_audit_body`)." }, + 401, + { 403: "Forbidden — the token lacks the `ornn:admin:skill` permission." }, + { 404: "Not found — `skill_not_found`: no such skill." }, + { + 500: "Internal server error — the audit defaults could not be resolved from platform settings, or the audit row could not be persisted. Retry with backoff.", + }, + ), + }, + }, + }, + + [`${prefix}/skills/{idOrName}/analytics`]: { + get: { + summary: "Execution summary for a skill", + description: + "Aggregate health of a skill's executions over a rolling window: how often it ran, how often it succeeded, how slow it was, and what it failed with. Use it to decide whether a skill is dependable before wiring it into an agent, and to monitor a skill you own after publishing.\n\n" + + "**No execution hook is wired in this build.** The aggregate reads an append-only execution log, and nothing in the API writes to it yet — the recording call is reserved for the SDK / CLI / agent-proxy hooks and has no caller today. (The playground records *pulls*, not executions; see `GET /skills/{idOrName}/analytics/pulls`.) Until a hook lands, every skill answers `executionCount: 0`, `successRate: null`, `latencyMs` all-`null`, `uniqueUsers: 0`, `topErrorCodes: []`, no matter how heavily it is used. Read a zero aggregate as \"no telemetry\", never as \"no usage\", and do not gate a skill on it. The response shape below is stable and starts carrying real numbers the moment a hook begins emitting.\n\n" + + "`window` selects the lookback (`7d`, `30d` default, or `all` for the full retained history). `version` narrows to events recorded against one exact version string — it is a literal match on the event's `skillVersion` field, with no dist-tag resolution, and events stored without a pinned version (`skillVersion` is optional on the event) are excluded from a version-filtered aggregate.\n\n" + + "Percentiles are computed server-side from raw latency samples in the window; `uniqueUsers` counts distinct caller ids on the stored events.\n\n" + + "Visibility matches `GET /skills/{idOrName}` — a private skill the caller cannot read answers 404 `skill_not_found`. Note that `window` is validated *before* the visibility check, so an invalid window yields 400 even for a skill you cannot see.", + operationId: "getSkillAnalyticsSummary", + tags: ["Analytics"], + security: optionalAuth(), + parameters: [ + skillIdOrNameParam, + queryParam( + "window", + "Rolling lookback for the aggregate. `7d` and `30d` are relative to now; `all` disables the time filter entirely. Defaults to `30d` when omitted or empty. Any other value is rejected with 400 `INVALID_WINDOW`.", + { type: "string", enum: ["7d", "30d", "all"], default: "30d" }, + ), + queryParam( + "version", + "Restrict the aggregate to executions of one exact version, e.g. `1.2`. Literal match — dist-tags such as `@latest` are not resolved and will simply match nothing. Omit to aggregate across all versions.", + { type: "string", examples: ["1.2"] }, + ), + ], + responses: { + ...jsonResponse(analyticsSummarySchema, "Execution aggregate for the requested window.", { + example: { + skillGuid: "550e8400-e29b-41d4-a716-446655440000", + window: "30d", + executionCount: 412, + successCount: 389, + failureCount: 19, + timeoutCount: 4, + successRate: 0.9442, + latencyMs: { p50: 820, p95: 3140, p99: 7600 }, + uniqueUsers: 37, + topErrorCodes: [ + { code: "sandbox_timeout", count: 4 }, + { code: "missing_input", count: 3 }, + ], + }, + }), + ...problemResponses( + { 400: "Bad request — `INVALID_WINDOW`: `window` must be exactly `7d`, `30d`, or `all`." }, + { 404: "Not found — `skill_not_found`: no such skill, or it is private and not visible to this caller." }, + ), + }, + }, + }, + + [`${prefix}/skills/{idOrName}/analytics/pulls`]: { + get: { + summary: "Pull time series for a skill", + description: + "Time-bucketed count of a skill's pull events — the adoption signal, as opposed to the reliability signal from `GET /skills/{idOrName}/analytics`. \"Pull\" covers three distinct emission points and only one of them hands out package bytes: `api` is one per `GET /skills/{idOrName}/json` (the package contents), `web` is one per `GET /skills/{idOrName}` metadata read from any client, and `playground` is one per `POST /playground/chat` request bound to a `skillId` — i.e. per chat turn. A caller that reads metadata and then pulls the package increments both `web` and `api`. For real machine adoption, read `bySource.api`.\n\n" + + "Only authenticated callers are counted. `/skills/{idOrName}/json` and `/playground/chat` both require a token, and the metadata read records nothing when it is served anonymously — so on a public skill this series is a lower bound on actual traffic, not a complete count.\n\n" + + "Buckets are UTC-truncated to the requested `bucket` granularity and returned ascending by time. **Empty buckets are omitted** — the series is sparse, so a client rendering a chart must zero-fill the gaps itself rather than assuming one entry per interval.\n\n" + + "The range is `[from, to)` — `from` inclusive, `to` exclusive. Both default relative to now: `to` defaults to the current instant and `from` defaults to seven days before `to`, so an unparameterised call returns roughly the last week bucketed by day. There is no server-side cap on the range, so pairing `bucket=hour` with a multi-year range will produce a very large response; pick the granularity to match the span.\n\n" + + "Visibility matches `GET /skills/{idOrName}`. All query-parameter validation happens before the visibility check, so malformed parameters yield 400 even for a skill you cannot see.", + operationId: "getSkillPullsTimeSeries", + tags: ["Analytics"], + security: optionalAuth(), + parameters: [ + skillIdOrNameParam, + queryParam( + "bucket", + "Bucket granularity, truncated in UTC. Defaults to `day` when omitted or empty. Any other value is rejected with 400 `INVALID_BUCKET`.", + { type: "string", enum: ["hour", "day", "month"], default: "day" }, + ), + queryParam( + "from", + "Inclusive lower bound, any string `Date` can parse — send ISO-8601 UTC (e.g. `2026-07-01T00:00:00Z`). Defaults to seven days before `to`. Must be strictly earlier than `to`.", + { type: "string", format: "date-time", examples: ["2026-07-01T00:00:00Z"] }, + ), + queryParam( + "to", + "Exclusive upper bound, ISO-8601 UTC (e.g. `2026-08-01T00:00:00Z`). Defaults to now. Pulls landing exactly on this instant are excluded.", + { type: "string", format: "date-time", examples: ["2026-08-01T00:00:00Z"] }, + ), + queryParam( + "version", + "Restrict the series to pulls of one exact version, e.g. `1.2`. Literal match — dist-tags are not resolved. Omit to count pulls of every version.", + { type: "string", examples: ["1.2"] }, + ), + ], + responses: { + ...jsonResponse( + { + type: "object", + required: ["items"], + properties: { + items: { + type: "array", + items: pullBucketSchema, + description: + "Non-empty buckets only, ascending by `bucket`. An empty array means there were no pulls in the range — zero-fill client-side when charting.", + }, + }, + }, + "Sparse pull time series for the requested range and granularity.", + { + example: { + items: [ + { bucket: "2026-07-30T00:00:00.000Z", total: 14, bySource: { api: 11, web: 2, playground: 1 } }, + { bucket: "2026-07-31T00:00:00.000Z", total: 9, bySource: { api: 6, web: 3, playground: 0 } }, + ], + }, + }, + ), + ...problemResponses( + { + 400: "Bad request — `INVALID_BUCKET` (`bucket` not one of `hour` / `day` / `month`) or `invalid_range` (`from` or `to` is not a parseable date, or `from` is not strictly earlier than `to`).", + }, + { 404: "Not found — `skill_not_found`: no such skill, or it is private and not visible to this caller." }, + ), + }, + }, + }, + }; +} diff --git a/ornn-api/src/openapi/paths/generation.ts b/ornn-api/src/openapi/paths/generation.ts new file mode 100644 index 00000000..1677c31a --- /dev/null +++ b/ornn-api/src/openapi/paths/generation.ts @@ -0,0 +1,531 @@ +/** + * LLM-backed streaming surfaces: skill generation, the playground agent + * loop, and the Ornn assistant (#1214). + * + * Everything in this module shares one shape and one cost model: + * + * - The success response is **always** a `text/event-stream`, never the + * `{ data, error }` envelope. The envelope only ever appears on the + * other domains' JSON endpoints; here a 200 means "the stream opened", + * not "the work succeeded". + * - Every request runs through the same pre-stream gauntlet — auth → + * route scope → burst rate limit (where mounted) → body validation → + * model resolution → quota reserve. Every one of those gates fails + * with an ordinary RFC 7807 `application/problem+json` body and a 4xx/5xx + * status **before** a single SSE byte is written. Integrators MUST check + * the HTTP status (and `Content-Type`) before attaching an SSE parser. + * - Once the stream is open, failures arrive *inside* the stream as an + * error event on a 200 response. A terminal in-stream error is not an + * HTTP error and will never retro-actively change the status code. + * - Each request reserves one per-user monthly quota slot on its surface + * (`skillGen` / `playground` / `assistant`) before the stream opens and + * reconciles it when the stream ends. Reconciliation is *not* uniform: + * on `playground` and `assistant` an abort after the provider has + * emitted billable output still consumes the slot (the upstream tokens + * are already paid for), while `skillGen` decides purely from the frames + * it emitted and refunds any abort. Each operation states its own rule. + * + * @module openapi/paths/generation + */ + +import { + bearerAuth, + jsonBody, + problemResponses, + sseResponse, + type JsonSchema, + type PathMap, +} from "../helpers"; +import { assistantChatRequestSchema } from "../../domains/assistant/routes"; + +/** + * Per-message / per-prompt character ceiling enforced by the JSON branch + * of every surface here. Mirrors `MAX_GENERATION_CHARS` + * (`domains/skills/generation/routes.ts`), `MAX_CHAT_MESSAGE_CHARS` + * (`domains/playground/routes.ts`, `domains/assistant/routes.ts`) and the + * web client's `MAX_INPUT_CHARS`. ~8k tokens at 4 chars/token. + */ +const MAX_MESSAGE_CHARS = 32_000; + +// --------------------------------------------------------------------------- +// Request bodies +// +// None of the five handlers exposes an exported Zod schema except the +// assistant's, so the shapes below are hand-written from the inline +// `validateBody(z.object({...}))` declarations (from-source, from-openapi, +// playground) and from the manual parse in the hybrid `/skills/generate` +// handler. (They are NOT taken from the old hand-mirrored +// `openapi/schemas.ts`, which #1214 deleted — its copies had drifted: +// `generateJsonBodySchema` still said `model` where the handler reads +// `modelId`, and its playground body was missing `modelId` entirely.) +// --------------------------------------------------------------------------- + +const modelIdProperty: JsonSchema = { + type: "string", + description: + "Optional admin-curated model id. Omit to use the surface's default model. When supplied it must be a model an administrator has enabled for this surface — an unknown id fails with 400 `MODEL_NOT_FOUND`, a known-but-disabled id with 400 `MODEL_NOT_ENABLED`. Enumerate the ids you may pass with `GET /api/v1/me/models?surface=`.", + examples: ["gpt-4.1-mini"], +}; + +const generateJsonBody: JsonSchema = { + type: "object", + description: + "Send exactly one of `prompt` (single-turn) or `messages` (multi-turn). If both are present `messages` wins and `prompt` is ignored. Unparseable JSON, a JSON array, or a JSON scalar fails with 400 `invalid_body`; an empty body is read as `{}`, so it gets past that check and fails with 400 `missing_prompt` instead.", + properties: { + prompt: { + type: "string", + maxLength: MAX_MESSAGE_CHARS, + description: `Single-turn natural-language description of the skill to build. Rejected with 400 \`prompt_too_long\` above ${MAX_MESSAGE_CHARS} characters.`, + examples: ["Build a skill that extracts tables from a PDF and returns them as CSV."], + }, + messages: { + type: "array", + description: + "Multi-turn conversation history for iterative refinement — resend the whole transcript on every turn, the server holds no session state. Takes precedence over `prompt`.", + items: { + type: "object", + required: ["role", "content"], + properties: { + role: { + type: "string", + enum: ["user", "assistant"], + description: "Who produced this turn. `assistant` turns are the model's previous replies, echoed back verbatim.", + }, + content: { + type: "string", + maxLength: MAX_MESSAGE_CHARS, + description: `Turn text. Any single turn longer than ${MAX_MESSAGE_CHARS} characters fails the whole request with 400 \`content_too_long\`.`, + }, + }, + }, + }, + modelId: modelIdProperty, + }, +}; + +const generateMultipartBody: JsonSchema = { + type: "object", + required: ["prompt"], + description: + "Multipart form. Use this encoding only when you want to seed generation with an existing package; otherwise prefer `application/json`. `messages` is not available on this encoding, and the server does not length-check `prompt` here — keep it under the same 32 000-character budget yourself.", + properties: { + prompt: { + type: "string", + description: + "Natural-language description of the skill to build, or — when `package` is attached — of the modification to apply to it. Missing or empty fails with 400 `missing_prompt`.", + }, + modelId: { + type: "string", + description: "Same semantics as the JSON body's `modelId`. Sent as a plain form field.", + }, + package: { + type: "string", + format: "binary", + description: + "Optional existing skill package ZIP. The server reads only `SKILL.md` plus anything under `scripts/`, `references/` and `assets/` (a single top-level wrapper folder is unwrapped, `__MACOSX/` ignored), concatenates the text, and prepends it to the prompt as context. Unreadable/binary entries are skipped silently; a ZIP that cannot be opened at all surfaces as 500.", + }, + }, +}; + +const fromSourceBody: JsonSchema = { + type: "object", + description: + "Provide exactly one source: `code` for inline text, or `repoUrl` for a public GitHub repository the server fetches for you. Neither fails with 400 `missing_source`; both fails with 400 `AMBIGUOUS_SOURCE`.", + properties: { + code: { + type: "string", + description: + "Inline source, typically several route/controller/handler files concatenated. Prefix each file with a `// FILE: ` marker so the model can attribute endpoints to files — that is exactly the layout the `repoUrl` fetcher produces. Whitespace-only content fails with 400 `empty_source`.", + examples: ["// FILE: src/routes/users.ts\nrouter.get('/users/:id', getUser)\n"], + }, + repoUrl: { + type: "string", + format: "uri", + description: + "Public GitHub URL — `https://github.com/{owner}/{repo}` or `https://github.com/{owner}/{repo}/tree/{ref}/{subpath}`. Only the `github.com` host is accepted, and the fetch is unauthenticated (GitHub's 60 requests/hour/IP anonymous budget applies), so private repositories and rate-limit exhaustion both come back as 400 `repo_fetch_failed`.", + examples: ["https://github.com/honojs/hono/tree/main/src/middleware"], + }, + path: { + type: "string", + description: + "Repository sub-directory to harvest, overriding any `/tree/{ref}/{subpath}` in `repoUrl`. When neither is given the fetcher probes, in order: `src/routes`, `src/controllers`, `src/handlers`, `src/api`, `src/app/api`, `routes`, `controllers`, `app`, and uses the first that yields files. At most 8 files of at most 16 KiB each are pulled, and only `.ts` `.tsx` `.js` `.mjs` `.py` `.go` `.java` `.rb` `.rs` are considered — point this at the directory that actually holds your handlers rather than the repo root.", + examples: ["src/api/v2"], + }, + framework: { + type: "string", + description: + "Optional framework hint that short-circuits auto-detection. Free-form; the model reads it as prose. Only used when it cannot be inferred from the fetched files.", + examples: ["fastapi"], + }, + description: { + type: "string", + description: + "Optional free-form context appended to the prompt — auth model, base URL, which endpoints matter, anything the source alone does not reveal.", + }, + modelId: modelIdProperty, + }, +}; + +const fromOpenApiBody: JsonSchema = { + type: "object", + required: ["spec"], + properties: { + spec: { + type: "string", + minLength: 1, + description: + "The complete OpenAPI document as a **string** (JSON or YAML, either version) — not a parsed object. It is inlined verbatim into the LLM prompt, so a large spec consumes the model's context budget directly; for anything sizeable, hand-trim it to the operations you care about (or use `endpoints`) before sending.", + examples: ["{\"openapi\":\"3.1.0\",\"info\":{\"title\":\"Billing\",\"version\":\"1\"},\"paths\":{ }}"], + }, + endpoints: { + type: "array", + description: + "Optional allow-list narrowing the generated reference to a subset of operations. The server joins the entries with `, ` into a `Focus ONLY on these endpoints:` instruction, so send plain strings such as `GET /v1/invoices`. The validator accepts any JSON value here and does not check element types — non-string entries stringify into unusable prompt text, so send strings.", + items: { type: "string" }, + examples: [["GET /v1/invoices", "POST /v1/invoices"]], + }, + description: { + type: "string", + description: + "Optional free-form context appended to the prompt — how to authenticate, environment base URLs, which workflows the skill should make easy.", + }, + modelId: modelIdProperty, + }, +}; + +const playgroundMessage: JsonSchema = { + type: "object", + required: ["role", "content"], + properties: { + role: { + type: "string", + enum: ["user", "assistant", "tool", "system"], + description: + "Turn author. Replay the transcript you received: `assistant` turns that requested tools carry `toolCalls`, and each `tool` turn answering one carries the matching `toolCallId`.", + }, + content: { + type: "string", + maxLength: MAX_MESSAGE_CHARS, + description: `Turn text — for a \`tool\` turn, the serialized tool result. Any turn longer than ${MAX_MESSAGE_CHARS} characters fails the request with 400 \`VALIDATION_ERROR\`.`, + }, + toolCalls: { + type: "array", + description: "Tool invocations the model requested on this `assistant` turn. Copy back exactly what the `tool-call` events delivered.", + items: { + type: "object", + required: ["id", "name", "args"], + properties: { + id: { type: "string", description: "Correlates with the answering turn's `toolCallId`." }, + name: { type: "string", description: "Tool name, e.g. `load_skill` or `execute_in_sandbox`." }, + args: { type: "object", additionalProperties: true, description: "Arguments the model produced for the call." }, + }, + }, + }, + toolCallId: { + type: "string", + description: "On a `tool` turn, the `toolCalls[].id` this result answers.", + }, + }, +}; + +const playgroundChatBody: JsonSchema = { + type: "object", + required: ["messages"], + properties: { + messages: { + type: "array", + minItems: 1, + maxItems: 100, + description: + "Full conversation transcript, oldest first. The server keeps no session state — resend everything each turn. Between 1 and 100 turns.", + items: playgroundMessage, + }, + skillId: { + type: "string", + description: + "Optional skill GUID or name to bind the session to. Its package is injected into the model's context up front, so the agent can use the skill without spending a `load_skill` round-trip. Resolution is strict, not best-effort: an id that does not exist — or one your visibility does not let you read, which answers identically so existence is never leaked — aborts the run before the first model call. The stream still opens with 200 but carries only `error` (`message` = `Failed to load skill: Skill '' not found`) followed by `finish` with `finishReason: \"error\"`. Binding also records a `playground` pull in analytics.", + examples: ["pdf-table-extract"], + }, + envVars: { + type: "object", + additionalProperties: { type: "string" }, + description: + "Environment variables handed to sandbox executions started by this session, keyed by variable name. Scoped to this request only — nothing is persisted. Values are frequently credentials: send them over TLS, never log the request body, and prefer short-lived tokens.", + examples: [{ API_BASE_URL: "https://api.acme.dev", REPORT_TZ: "UTC" }], + }, + modelId: modelIdProperty, + }, +}; + +// --------------------------------------------------------------------------- +// Operations +// --------------------------------------------------------------------------- + +/** + * Shared tail of every generation description: the event vocabulary, the + * shape of `generation_complete.raw`, and what the caller still has to do + * with it. Repeated per operation because an agent typically reads one + * operation object in isolation. + */ +const GENERATION_STREAM_CONTRACT = + "Frames are plain `data:` lines carrying a JSON object with a `type` field — there is no SSE `event:` line on payload frames, so dispatch on `type` and not on the parser's event name. Vocabulary: `generation_start` (LLM call opened), `token` (`content` = incremental text, emit-as-you-go), `validation_error` (`message`, `retrying`), `generation_complete` (`raw` = the model's full output), `error` (`message`, terminal). Separate keep-alive frames named `keepalive` with an empty payload arrive every `skillGen.sseKeepAliveMs` (admin-settable, 15 000 ms fallback) — ignore them. `raw` is a JSON **document string**, not a ZIP and not markdown: parse it to get `{ name, description, category, tags, readmeBody, runtimes, dependencies, envVars, scripts[], outputType? }`. Nothing is persisted — assemble the package yourself and `POST /api/v1/skills` to publish it."; + +const GENERATION_COST_CONTRACT = + "Requires the `ornn:skill:build` scope. One per-user monthly `skillGen` quota slot is reserved before the stream opens and reconciled when it ends, purely from what the stream emitted: the slot is consumed if and only if a `generation_complete` or a `validation_error` frame went out, and released in every other case. Two consequences worth designing for — a run that ends on `error` without a preceding `validation_error` costs nothing, and disconnecting mid-stream also costs nothing no matter how many `token` frames you already consumed (unlike `/playground/chat` and `/assistant/chat`, this surface has no abort-after-billable-output commit); conversely a single-turn run whose retry also fails validation consumes the slot even though you never received `generation_complete`. Model resolution and the quota check both run before the first byte, so their failures are ordinary JSON errors — never a truncated stream."; + +function generateOperation(): Record { + return { + summary: "Generate a skill package from a prompt (SSE stream)", + description: + "Streams an LLM-authored skill package from a natural-language brief. This is the front door of the generation family; use `/skills/generate/from-source` when you already have backend code and `/skills/generate/from-openapi` when you already have a spec. " + + "The endpoint is hybrid on `Content-Type`. With `application/json` you send either `prompt` (single-turn) or `messages` (multi-turn refinement — resend the whole transcript, the server is stateless); with `multipart/form-data` you send a `prompt` field plus an optional `package` ZIP whose text files are read and prepended as context, which is how you ask for a modification of an existing skill rather than a fresh one. Any other content type is rejected with 400 `invalid_content_type`. " + + "Retry behaviour differs by mode and is worth handling explicitly: the single-turn `prompt` path re-asks the model once when the first answer is not valid JSON (you see `validation_error` with `retrying: true`) and may then end on `error` with no `generation_complete` at all, while the multi-turn `messages` path does not retry — it emits `validation_error` with `retrying: false` and still emits `generation_complete` carrying output that failed validation, so re-validate `raw` before trusting it. " + + GENERATION_STREAM_CONTRACT + + " " + + GENERATION_COST_CONTRACT + + " A per-user burst limiter of 20 requests/minute applies; every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, and a rejection adds `Retry-After`.", + operationId: "generateSkill", + tags: ["Generation"], + security: bearerAuth(), + parameters: [], + requestBody: { + required: true, + description: + "Either a JSON brief (`prompt` or `messages`) or a multipart form (`prompt` plus an optional package ZIP to modify).", + content: { + "application/json": { + schema: generateJsonBody, + example: { + prompt: "Build a skill that extracts tables from a PDF and returns them as CSV.", + modelId: "gpt-4.1-mini", + }, + }, + "multipart/form-data": { schema: generateMultipartBody }, + }, + }, + responses: { + ...sseResponse( + "The stream is open. A 200 means the request passed every gate and the LLM call started — it does not mean generation succeeded; watch for `generation_complete` versus a terminal `error`.", + ["generation_start", "token", "validation_error", "generation_complete", "error", "keepalive"], + ), + ...problemResponses( + { + 400: + "Rejected before the stream opened. Codes: `invalid_content_type` (neither JSON nor multipart), `invalid_body` (unparseable JSON, or valid JSON that is an array or a scalar rather than an object), `missing_prompt` (no usable `prompt` — an empty body lands here, since it is read as `{}`), `prompt_too_long` / `content_too_long` (over 32 000 characters), `MODEL_NOT_FOUND` / `MODEL_NOT_ENABLED` (the `modelId` you asked for is unknown, or not enabled for the `skillGen` surface).", + }, + 401, + { 403: "`forbidden` — the token authenticated but carries no `ornn:skill:build` scope. Generation is a high-cost surface and is gated separately from ordinary skill reads." }, + { + 429: + "Either `rate_limited` (more than 20 requests in the trailing minute for this user — consult `Retry-After`) or `quota_exceeded` (this month's `skillGen` allowance is spent). Both are raised before any LLM cost is incurred.", + }, + { + 500: + "`internal_error`. The realistic trigger here is a `package` attachment that is not a readable ZIP: the archive is opened before any generation gate, and a corrupt central directory surfaces as an unhandled failure rather than a validation error.", + }, + { 503: "`MODEL_UNAVAILABLE` — no model is currently enabled for the `skillGen` surface. This is a platform configuration state, not a transient outage; retrying will not help until an administrator enables one." }, + ), + }, + }; +} + +function generateFromSourceOperation(): Record { + return { + summary: "Generate an API-reference skill from backend source code (SSE stream)", + description: + "Turns existing backend code into a `plain` (documentation-only, no runtime scripts) skill that teaches an agent how to call that service. Supply the code inline via `code`, or hand over a public GitHub URL via `repoUrl` and let the server harvest it — exactly one of the two, never both. " + + "The harvester is deliberately small: it walks one directory (`path`, else the URL's `/tree/{ref}/{subpath}`, else a list of conventional route folders), takes at most 8 source files of at most 16 KiB each, concatenates them with `// FILE: ` markers, and infers a framework hint. Every failure mode of that fetch — not a GitHub URL, private repository, missing directory, anonymous rate limit exhausted — collapses into a single 400 `repo_fetch_failed` whose `detail` carries the underlying reason. For anything larger or non-public, fetch the files yourself and pass them as `code`. " + + "Unlike the prompt-driven endpoint this path never retries: a model answer that fails schema validation produces `validation_error` with `retrying: false` and is still delivered in the following `generation_complete`, so validate `raw` yourself before publishing. " + + GENERATION_STREAM_CONTRACT + + " " + + GENERATION_COST_CONTRACT + + " Note that this route carries no per-minute burst limiter (unlike `POST /skills/generate`); the monthly quota is the only throttle, so a 429 here means `quota_exceeded`.", + operationId: "generateSkillFromSource", + tags: ["Generation"], + security: bearerAuth(), + parameters: [], + requestBody: jsonBody( + fromSourceBody, + "Exactly one source (`code` or `repoUrl`), plus optional harvest and prompt hints.", + { + example: { + repoUrl: "https://github.com/acme/billing-api", + path: "src/routes", + framework: "hono", + description: "Public REST API behind an API-key header. Focus on the invoice endpoints.", + }, + }, + ), + responses: { + ...sseResponse( + "The stream is open and the source has already been resolved — any repository fetch happened before this response, so a 200 guarantees the model saw non-empty code.", + ["generation_start", "token", "validation_error", "generation_complete", "error", "keepalive"], + ), + ...problemResponses( + { + 400: + "Codes: `invalid_from_source_body` (body failed schema validation — see `detail`), `missing_source` (neither `code` nor `repoUrl`), `AMBIGUOUS_SOURCE` (both supplied), `repo_fetch_failed` (GitHub URL unrecognised, repository/path unreachable, or anonymous rate limit exhausted), `empty_source` (the resolved code is blank), `MODEL_NOT_FOUND` / `MODEL_NOT_ENABLED` (bad or disabled `modelId` for the `skillGen` surface).", + }, + 401, + { 403: "`forbidden` — the token lacks the `ornn:skill:build` scope required by every generation endpoint." }, + { 429: "`quota_exceeded` — this month's `skillGen` allowance is spent. Raised before any LLM cost; there is no per-minute limiter on this route." }, + { 503: "`MODEL_UNAVAILABLE` — no model is enabled for the `skillGen` surface. Requires an administrator to enable one; retrying will not clear it." }, + ), + }, + }; +} + +function generateFromOpenApiOperation(): Record { + return { + summary: "Generate an API-reference skill from an OpenAPI spec (SSE stream)", + description: + "Converts an OpenAPI document into a `plain` (documentation-only) skill that teaches an agent how to call the described API. This is the highest-fidelity member of the generation family — prefer it over `/skills/generate/from-source` whenever a spec exists, because the model reads declared schemas instead of inferring them from handler code. " + + "`spec` is the document as a raw string (JSON or YAML) and is inlined verbatim into the prompt, so it competes with the model's context budget: for a large surface, narrow it with `endpoints` (an allow-list of `METHOD /path` strings) or pre-trim the document. `description` adds context the spec cannot express, such as how to obtain credentials. " + + "Like the from-source path this never retries — a schema-invalid answer yields `validation_error` with `retrying: false` and is still delivered in `generation_complete`, so re-validate `raw` before you publish it. " + + GENERATION_STREAM_CONTRACT + + " " + + GENERATION_COST_CONTRACT + + " No per-minute burst limiter is mounted on this route; the monthly quota is the only throttle.", + operationId: "generateSkillFromOpenApi", + tags: ["Generation"], + security: bearerAuth(), + parameters: [], + requestBody: jsonBody( + fromOpenApiBody, + "The OpenAPI document as a string, plus optional endpoint narrowing and extra context.", + { + example: { + spec: "openapi: 3.1.0\ninfo:\n title: Billing\n version: '1'\npaths:\n /v1/invoices:\n get: { summary: List invoices }\n", + endpoints: ["GET /v1/invoices"], + description: "Authenticate with `Authorization: Bearer `; sandbox base URL is https://sandbox.acme.dev.", + }, + }, + ), + responses: { + ...sseResponse( + "The stream is open. The spec is not parsed or validated server-side — it is handed to the model as text — so a malformed document produces a poor skill rather than an HTTP error.", + ["generation_start", "token", "validation_error", "generation_complete", "error", "keepalive"], + ), + ...problemResponses( + { + 400: + "Codes: `invalid_from_openapi_body` (body failed schema validation — most often a missing or empty `spec`, or `spec` sent as an object instead of a string; see `detail`), `MODEL_NOT_FOUND` / `MODEL_NOT_ENABLED` (bad or disabled `modelId` for the `skillGen` surface).", + }, + 401, + { 403: "`forbidden` — the token lacks the `ornn:skill:build` scope required by every generation endpoint." }, + { 429: "`quota_exceeded` — this month's `skillGen` allowance is spent. Raised before any LLM cost; there is no per-minute limiter on this route." }, + { 503: "`MODEL_UNAVAILABLE` — no model is enabled for the `skillGen` surface. Requires an administrator to enable one." }, + ), + }, + }; +} + +function playgroundChatOperation(): Record { + return { + summary: "Run the playground agent loop (SSE stream)", + description: + "Drives the agentic playground: a tool-using chat loop that can load a skill and execute code in an isolated sandbox on the caller's behalf. This is the only surface in the API that runs user-supplied code, which is why it sits behind its own `ornn:playground:use` scope. Use `/assistant/chat` instead when you only want grounded answers with no execution, and the generation endpoints when you want a skill authored rather than run. " + + "The server is stateless across requests: send the entire transcript in `messages` (1–100 turns, 32 000 characters each) every time, including the `assistant` turns that carried `toolCalls` and the `tool` turns that answered them. `skillId` pre-loads a skill's package into context, saving a `load_skill` round-trip — but it is a hard dependency, not a hint: if the id is unknown or your visibility does not permit reading it (both answer as not-found, so existence is never leaked), the run ends before the model is called. You still get a 200 stream, carrying only `error` (`Failed to load skill: …`) and `finish` with `finishReason: \"error\"`, and the reserved quota slot is released. `envVars` are forwarded to sandbox executions for this request only and are never persisted. " + + "Stream shape: payload frames are bare `data:` lines holding a JSON object with a `type` field, and unlike the assistant they carry no SSE `event:` line, so dispatch on `type`. Vocabulary (kebab-case, distinct from the snake_case used elsewhere): `text-delta` (`delta`), `tool-call` (`toolCall` = `{ id, name, args }`), `tool-result` (`toolCallId`, `result`), `file-output` (`file` = `{ path, content, size, mimeType }`), `error` (`message`) and `finish` (`finishReason`). `error` is not the end of the stream here: the agent loop always follows it with a `finish` carrying `finishReason` `error` or `abort`, so treat `finish` as the terminator and `stop` as the only clean ending. (A bare trailing `error` with no `finish` means the server-side loop itself blew up.) Every one of these still arrives on the same 200. The very first frame is a ~2 KB SSE comment used to punch through buffering proxies and `: keepalive ` comments follow at the admin-configured interval — ignore all comment frames. " + + "Cost: 20 requests/minute per user (`RateLimit-*` headers on every response, `Retry-After` on rejection) plus one monthly `playground` quota slot, reserved before the stream opens and committed once genuinely billable output (a non-empty delta, a tool call, a tool result, a file) has been produced — aborting after that point does not refund it.", + operationId: "playgroundChat", + tags: ["Playground"], + security: bearerAuth(), + parameters: [], + requestBody: jsonBody( + playgroundChatBody, + "Full conversation transcript plus optional skill binding, sandbox environment variables, and model override.", + { + example: { + messages: [{ role: "user", content: "Use the pdf-table-extract skill on this invoice and show me the CSV." }], + skillId: "pdf-table-extract", + envVars: { REPORT_TZ: "UTC" }, + }, + }, + ), + responses: { + ...sseResponse( + "The stream is open. A 200 only means every pre-stream gate passed; an in-stream `error` event still arrives on this same 200 response.", + ["text-delta", "tool-call", "tool-result", "file-output", "error", "finish"], + ), + ...problemResponses( + { + 400: + "Codes: `VALIDATION_ERROR` (body failed schema validation — empty or >100 `messages`, an unknown `role`, a turn over 32 000 characters; see `detail`), `MODEL_NOT_FOUND` / `MODEL_NOT_ENABLED` (the `modelId` is unknown, or not enabled for the `playground` surface).", + }, + 401, + { 403: "`forbidden` — the token lacks the `ornn:playground:use` scope. The playground executes code, so it is gated separately from read-only surfaces." }, + { + 429: + "Either `rate_limited` (more than 20 requests in the trailing minute for this user — the limiter runs ahead of body validation, so malformed floods are also throttled) or `quota_exceeded` (this month's `playground` allowance is spent).", + }, + { 503: "`MODEL_UNAVAILABLE` — no model is enabled for the `playground` surface. An administrator must enable one." }, + ), + }, + }; +} + +function assistantChatOperation(): Record { + return { + summary: "Ask the Ornn assistant a grounded question (SSE stream)", + description: + "A read-only, non-agentic Q&A stream about Ornn itself and about the skills the caller is allowed to see. Every answer is grounded in a curated knowledge-base digest plus a visibility-scoped skill retrieval that exposes only safe fields (`name`, `description`, `tags`, `category`, `createdOn`, author user id) — no emails, storage keys, sharing lists, or private-membership data ever reach the model. It runs no tools, executes nothing, and mutates nothing; reach for `/playground/chat` when you need execution and for the generation endpoints when you need a skill authored. " + + "This is the one LLM surface here that needs no extra scope — any authenticated bearer token may call it, so a 403 is not part of its contract. The server keeps no session state: resend the full transcript in `messages` (1–100 turns, `user`/`assistant` only, 32 000 characters per turn) on every request. " + + "Stream shape follows CONVENTIONS §6.3 exactly: each payload frame carries a native `event:` line whose name equals the JSON payload's `type`, so either dispatch style works. Vocabulary: `chat_start` (`model` = the resolved model id), `chat_text_delta` (`delta`), `chat_error` (`code`, `message` — terminal, still on a 200 response) and `chat_finish` (optional `usage` = `{ inputTokens, outputTokens, totalTokens }`). The opening ~2 KB comment frame and the periodic `: keepalive ` comments are anti-buffering padding — ignore them. " + + "Cost: 30 requests/minute per user (`RateLimit-*` on every response, `Retry-After` on rejection) and one monthly `assistant` quota slot, reserved before the stream opens; once tokens have streamed, an abort commits the slot rather than refunding it.", + operationId: "assistantChat", + tags: ["Assistant"], + security: bearerAuth(), + parameters: [], + requestBody: jsonBody( + assistantChatRequestSchema, + "Full conversation transcript (`user` / `assistant` turns only) and an optional model override.", + { + example: { + messages: [{ role: "user", content: "Which of my skills can parse PDFs, and how do I publish a new version?" }], + }, + }, + ), + responses: { + ...sseResponse( + "The stream is open. Model resolution and the quota reserve already succeeded, so any later failure arrives as a `chat_error` event on this 200 rather than as an HTTP error.", + ["chat_start", "chat_text_delta", "chat_error", "chat_finish"], + // The assistant is the one stream that emits a native `event:` line + // (assistant/routes.ts writes `event: \ndata: `); the + // generation and playground streams send bare `data:` frames. + "named-events", + ), + ...problemResponses( + { + 400: + "Codes: `VALIDATION_ERROR` (body failed schema validation — empty or >100 `messages`, a role other than `user`/`assistant`, a turn over 32 000 characters; see `detail`), `MODEL_NOT_FOUND` / `MODEL_NOT_ENABLED` (the `modelId` is unknown, or not enabled for the `assistant` surface).", + }, + 401, + { + 429: + "Either `rate_limited` (more than 30 requests in the trailing minute for this user — the limiter runs ahead of body validation) or `quota_exceeded` (this month's `assistant` allowance is spent).", + }, + { 503: "`MODEL_UNAVAILABLE` — no model is enabled for the `assistant` surface. An administrator must enable one before the assistant answers." }, + ), + }, + }; +} + +/** + * All LLM-streaming operations, keyed by their full `/api/v1` path. + * + * `prefix` is the API mount point (`/api/v1`) supplied by the spec + * builder; the path tails below must match the Hono registrations in + * `domains/skills/generation/routes.ts`, `domains/playground/routes.ts` + * and `domains/assistant/routes.ts` character for character — a contract + * test reflects the booted router against this map. + */ +export function generationPaths(prefix: string): PathMap { + return { + [`${prefix}/skills/generate`]: { post: generateOperation() }, + [`${prefix}/skills/generate/from-source`]: { post: generateFromSourceOperation() }, + [`${prefix}/skills/generate/from-openapi`]: { post: generateFromOpenApiOperation() }, + [`${prefix}/playground/chat`]: { post: playgroundChatOperation() }, + [`${prefix}/assistant/chat`]: { post: assistantChatOperation() }, + }; +} diff --git a/ornn-api/src/openapi/paths/messaging.ts b/ornn-api/src/openapi/paths/messaging.ts new file mode 100644 index 00000000..63c2006f --- /dev/null +++ b/ornn-api/src/openapi/paths/messaging.ts @@ -0,0 +1,1256 @@ +/** + * Messaging domain — everything Ornn pushes *at* a caller, plus the admin + * surfaces that author it (#1214). + * + * Fourteen operations across three collaborating sub-domains. They are + * documented together because they are not independent: two of them are + * write surfaces whose output is read through a third. + * + * 1. **Notifications** (`/notifications*`, 4 ops, caller-scoped). + * The inbox. `GET /notifications` is a *merged* feed — per-user + * notifications that Ornn's own domain events emit (audit finished, + * quota granted, GitHub auto-sync succeeded/failed, a skillset member + * became unreadable) interleaved by `createdAt` with the admin-authored + * broadcasts the caller is a recipient of. Rows are a discriminated + * union on `source`; an integrator MUST branch on it, because the two + * variants do not share a title field (`title` vs `titleI18n`). + * Read state is per-caller and lives in two different places + * (a column on the notification, a receipt row for a broadcast), but + * `POST /notifications/{id}/read` hides that: it accepts either kind + * of id and routes by lookup. + * + * 2. **Announcements** (`/announcements*`, `/admin/announcements*`, 6 ops). + * Site-wide notices rendered by the web SPA — a landing-page popup + * (`/announcements/active`, at most one) and a News-page archive + * (`/announcements`, everything released). These are **not** delivered + * to the inbox and carry no per-user read state: they are anonymous, + * unauthenticated reads with scheduling windows, written by admins. + * + * 3. **Broadcasts** (`/admin/broadcasts*`, 4 ops, admin-only). + * The authoring surface for inbox messages. There is deliberately no + * public broadcast read endpoint — users receive broadcasts through + * `GET /notifications`, which is why creating one has observable + * effects on every recipient's `unread-count`. + * + * **Which one do I want?** If you are an agent reacting to events about + * your own skills, you want `GET /notifications` (poll `unread-count`, + * fetch the feed, acknowledge with mark-read). If you are rendering a + * product surface, you want `/announcements`. The `/admin/*` halves both + * require the `ornn:admin:skill` permission scope and are not part of a + * normal agent integration. + * + * **Bilingual content, two different encodings.** Announcements flatten + * locales into sibling fields (`titleEn` / `titleZh`) with EN required and + * ZH optional-and-empty-when-unset. Broadcasts nest them (`titleI18n: + * { en, zh }`) with **both** locales required. Neither surface negotiates + * language server-side — both locales are always returned and the client + * resolves at render time, falling back to EN when the active locale's + * slot is empty. + * + * Response schemas here are hand-written JSON Schema because these handlers + * project their wire shapes inline from TypeScript interfaces (`FeedItemDto`, + * `AdminAnnouncementDto`, `AdminBroadcastResponse`) with no Zod schema + * describing the output. The two broadcast request bodies DO have a Zod + * source of truth and are generated from it. + * + * @module openapi/paths/messaging + */ + +import { + bearerAuth, + jsonBody, + jsonResponse, + pathParam, + problemResponses, + publicAuth, + queryParam, + type JsonSchema, + type PathMap, +} from "../helpers"; +import { + createBroadcastSchema, + patchBroadcastSchema, +} from "../../domains/broadcasts/schemas"; +import { NOTIFICATION_CATEGORIES } from "../../domains/notifications/types"; + +// --------------------------------------------------------------------------- +// Shared fragments +// --------------------------------------------------------------------------- + +/** `{ en, zh }` pair as broadcasts encode it. Both locales always present. */ +function i18nPair(what: string): JsonSchema { + return { + type: "object", + required: ["en", "zh"], + properties: { + en: { type: "string", description: `English ${what}. Always non-empty.` }, + zh: { type: "string", description: `Chinese ${what}. Always non-empty — broadcasts require both locales at create time, unlike announcements where ZH is optional.` }, + }, + description: `Bilingual ${what}. No server-side language negotiation: both locales are always returned and the client picks, falling back to \`en\`.`, + }; +} + +/** + * Fields of a stored per-user notification, shared by the `source: "user"` + * feed row and the mark-read response — which returns the raw document and + * therefore carries every field below EXCEPT the `source` discriminator. + */ +const notificationProperties: Record = { + _id: { + type: "string", + description: + "Notification id (UUID v4). Pass this to `POST /notifications/{id}/read`.", + examples: ["1f6b3c9e-2a4d-4c1f-9b7e-0d5a8c3e1f42"], + }, + userId: { + type: "string", + description: + "Recipient NyxID user id. Always equals the caller's own `userId` from `GET /me` — the feed is strictly caller-scoped and there is no way to read another user's notifications.", + }, + category: { + type: "string", + enum: [...NOTIFICATION_CATEGORIES], + description: + "Event class that produced this notification. This is the field to switch on for automated handling — `title` and `body` are human prose and their wording is not part of the contract. `audit.completed` fires on every audit of a skill you own; `audit.risky_for_consumer` fires on a yellow/red audit of a skill shared *with* you; `quota.credits_granted` fires when an admin grant or a redeemed code tops up your buckets; `launchPromo.codeDelivered` fires once, when the launch-promo cohort awards you a redemption code — the code itself is in `data`, so read it from there rather than parsing it out of `title`; `skillset.member_unreadable` warns that a member skill of your skillset stopped being readable by you; `skill.source_broken`, `skill.auto_synced`, and `skill.auto_sync_failed` report the outcome of the automatic GitHub-source drift check. The vocabulary is closed and additive — unknown values should be tolerated, never rejected.", + }, + title: { + type: "string", + description: + "One-line human summary for a list view. Plain text, never empty. Wording is not stable across releases — do not parse it.", + }, + body: { + type: "string", + description: + "Longer plain-text explanation for a detail view. **Key is absent** (not null) when the emitting event supplied none, so use a presence check rather than a truthiness check on `null`.", + }, + link: { + type: "string", + description: + "Deep link into the ornn-web SPA, as a root-relative path — never an absolute URL, so prepend your own web origin. **Key is absent** when the event has no click target (e.g. quota grants). Purely a UI affordance; it is not an API endpoint.", + examples: ["/skills/3f1c0a4e-9c2b-4a1e-9e3a-6b5d2f7c8a10/audits?version=1.2.0"], + }, + data: { + type: "object", + additionalProperties: true, + description: + "Structured payload for machine handling, shaped by `category` — e.g. `{ skillGuid, skillName, version, verdict, overallScore }` for the audit categories, `{ surface, amount, adminDisplayName }` for `quota.credits_granted`, `{ redemptionCodeId, redemptionCode, nyxidInviteCode, awardPlayground, awardSkillGen }` for `launchPromo.codeDelivered` (`nyxidInviteCode` is `null` when the promo bundles no invite), `{ skillGuid, repo, ref }` for `skill.source_broken`. Always an object, `{}` when the emitter supplied nothing. Read ids from here rather than scraping them out of `link`.", + }, + readAt: { + type: ["string", "null"], + format: "date-time", + description: "ISO-8601 UTC timestamp of when the caller marked this read, or `null` while unread.", + }, + createdAt: { + type: "string", + format: "date-time", + description: "ISO-8601 UTC timestamp of emission. This is the sort key for the merged feed.", + }, +}; + +/** Fields always present on a stored notification, whatever the emitter supplied. */ +const NOTIFICATION_REQUIRED = ["_id", "userId", "category", "title", "data", "readAt", "createdAt"]; + +const feedItemUserSchema: JsonSchema = { + type: "object", + required: ["source", ...NOTIFICATION_REQUIRED], + properties: { + source: { + type: "string", + const: "user", + description: + "Discriminator. `user` means this row came from the caller's own `notifications` collection — a domain event addressed to them specifically. Branch on this before reading any other field.", + }, + ...notificationProperties, + }, +}; + +/** + * Mark-read response for a per-user notification: the stored document, + * returned verbatim. Note the absent `source` — the handler skips the feed + * projection here, so this shape is NOT interchangeable with a feed row. + */ +const notificationDocumentSchema: JsonSchema = { + type: "object", + required: NOTIFICATION_REQUIRED, + properties: notificationProperties, + description: + "The per-user notification after the update, with `readAt` now populated. Same field set as a `source: \"user\"` feed row **minus the `source` discriminator** — the handler returns the stored document rather than the feed projection.", +}; + +const broadcastReceiptSchema: JsonSchema = { + type: "object", + required: ["source", "readAt"], + properties: { + source: { + type: "string", + const: "broadcast", + description: + "Present only on this variant. Its presence is what tells you a broadcast receipt was written rather than a notification updated.", + }, + readAt: { + type: "string", + format: "date-time", + description: "ISO-8601 UTC timestamp on the caller's read receipt for this broadcast.", + }, + }, + description: + "The read receipt written for a broadcast. Deliberately minimal — it does not echo the broadcast's content back.", +}; + +const feedItemBroadcastSchema: JsonSchema = { + type: "object", + required: ["_id", "source", "titleI18n", "bodyMarkdownI18n", "createdAt", "readAt"], + properties: { + _id: { + type: "string", + description: + "Broadcast id (UUID v4) — the same id the admin surface reports as `id` on `GET /admin/broadcasts`. Pass it to `POST /notifications/{id}/read` exactly like a notification id.", + }, + source: { + type: "string", + const: "broadcast", + description: + "Discriminator. `broadcast` means an admin authored this message and it landed in the inbox of every targeted user. There is no `category`, `link`, or `data` on this variant, and `title`/`body` do not exist — read `titleI18n` / `bodyMarkdownI18n` instead.", + }, + titleI18n: i18nPair("title"), + bodyMarkdownI18n: i18nPair("body, in Markdown"), + createdAt: { + type: "string", + format: "date-time", + description: + "ISO-8601 UTC timestamp of when the admin created the broadcast. Editing a broadcast does not move it — the feed keeps its original position.", + }, + readAt: { + type: ["string", "null"], + format: "date-time", + description: + "ISO-8601 UTC timestamp from the caller's read receipt, or `null` when they have no receipt. Read state is per-caller: the same broadcast is unread for one user and read for another.", + }, + }, +}; + +const feedItemSchema: JsonSchema = { + oneOf: [feedItemUserSchema, feedItemBroadcastSchema], + description: + "One inbox row. A tagged union discriminated by the `source` property, whose value is the literal `\"user\"` or `\"broadcast\"`; select the variant on that field before reading anything else. The two variants share only `_id`, `createdAt`, and `readAt`.", +}; + +const publicAnnouncementProperties: Record = { + id: { + type: "string", + description: "Announcement id (UUID v4).", + examples: ["b1e5f0d2-7c3a-4f8b-9d61-2a0c4e7f5b93"], + }, + titleEn: { type: "string", description: "English title. Always non-empty — EN is the canonical locale." }, + titleZh: { + type: "string", + description: "Chinese title. Empty string when the admin left it unset; fall back to `titleEn` in that case.", + }, + bodyMarkdownEn: { type: "string", description: "English body as Markdown. Always non-empty. Render it — it is authored content, not plain text." }, + bodyMarkdownZh: { type: "string", description: "Chinese body as Markdown. Empty string when unset; fall back to `bodyMarkdownEn`." }, + ctaLabelEn: { + type: ["string", "null"], + description: "English label for the call-to-action button. Non-`null` if and only if `ctaUrl` is non-`null` — the pair is validated as both-or-neither on write.", + }, + ctaLabelZh: { + type: ["string", "null"], + description: "Chinese CTA label. Independent of `ctaLabelEn` and may be `null` even when a CTA exists; fall back to `ctaLabelEn`.", + }, + ctaUrl: { + type: ["string", "null"], + format: "uri", + description: "Absolute URL the CTA button opens. Locale-independent — one URL serves both languages. `null` when the announcement has no CTA.", + }, +}; + +const publicAnnouncementSchema: JsonSchema = { + type: "object", + required: ["id", "titleEn", "titleZh", "bodyMarkdownEn", "bodyMarkdownZh", "ctaLabelEn", "ctaLabelZh", "ctaUrl"], + properties: publicAnnouncementProperties, + description: + "Anonymous-safe projection. Scheduling internals (`enabled`, `startsAt`, `endsAt`) and audit fields (`createdBy`, `createdAt`, `updatedAt`) are deliberately stripped — they exist only on the admin shape.", +}; + +const publicAnnouncementListItemSchema: JsonSchema = { + type: "object", + required: [ + "id", + "titleEn", + "titleZh", + "bodyMarkdownEn", + "bodyMarkdownZh", + "ctaLabelEn", + "ctaLabelZh", + "ctaUrl", + "publishedAt", + ], + properties: { + ...publicAnnouncementProperties, + publishedAt: { + type: "string", + format: "date-time", + description: + "ISO-8601 UTC timestamp of when this announcement became visible — `startsAt` when a schedule was set, otherwise `createdAt`. Render this as the date eyebrow. Note the list is sorted by `createdAt`, so a back-dated `startsAt` can make `publishedAt` non-monotonic down the array.", + }, + }, +}; + +const adminAnnouncementSchema: JsonSchema = { + type: "object", + required: [ + "id", + "titleEn", + "titleZh", + "bodyMarkdownEn", + "bodyMarkdownZh", + "ctaLabelEn", + "ctaLabelZh", + "ctaUrl", + "enabled", + "startsAt", + "endsAt", + "createdBy", + "createdAt", + "updatedAt", + ], + properties: { + ...publicAnnouncementProperties, + enabled: { + type: "boolean", + description: + "Master switch. `false` hides the announcement from both public endpoints regardless of its window. Flip this rather than deleting when you want to retract a live notice.", + }, + startsAt: { + type: ["string", "null"], + format: "date-time", + description: "Inclusive lower bound of the visibility window, or `null` for no lower bound (visible from creation).", + }, + endsAt: { + type: ["string", "null"], + format: "date-time", + description: + "Exclusive upper bound of the visibility window, or `null` for open-ended. Expiry removes the announcement from the popup but **not** from the News-page archive.", + }, + createdBy: { type: "string", description: "NyxID user id of the admin who created the announcement. Never changes, even after edits." }, + createdAt: { type: "string", format: "date-time", description: "ISO-8601 UTC creation timestamp. Also the sort key for the admin list and the tiebreak for which announcement is 'active'." }, + updatedAt: { type: "string", format: "date-time", description: "ISO-8601 UTC timestamp of the last edit. Equals `createdAt` until the first PATCH." }, + }, +}; + +const adminBroadcastSchema: JsonSchema = { + type: "object", + required: [ + "id", + "titleI18n", + "bodyMarkdownI18n", + "createdBy", + "updatedBy", + "recipientUserIds", + "createdAt", + "updatedAt", + "readCount", + ], + properties: { + id: { + type: "string", + description: + "Broadcast id (UUID v4). This is the same value recipients see as `_id` on their `source: \"broadcast\"` feed rows.", + examples: ["9c8e1a70-5b2d-4e63-8f0a-1d7c4b6e2905"], + }, + titleI18n: i18nPair("title"), + bodyMarkdownI18n: i18nPair("body, in Markdown"), + createdBy: { type: "string", description: "NyxID user id of the admin who authored the broadcast." }, + updatedBy: { + type: "string", + description: "NyxID user id of the admin who last edited it. Equals `createdBy` until the first PATCH.", + }, + recipientUserIds: { + type: ["array", "null"], + items: { type: "string" }, + description: + "Targeting list, frozen at create time. `null` means every user receives it. A non-empty array of NyxID user ids means only those users do — to everyone else the broadcast does not exist at all (invisible in the feed, in `unread-count`, and to `mark-all-read`, and `POST /notifications/{id}/read` on it answers 404 so the id cannot be probed). Never `undefined` on the wire.", + }, + createdAt: { type: "string", format: "date-time", description: "ISO-8601 UTC creation timestamp. Also the position the message takes in every recipient's feed." }, + updatedAt: { type: "string", format: "date-time", description: "ISO-8601 UTC timestamp of the last edit. Equals `createdAt` until the first PATCH." }, + readCount: { + type: "integer", + minimum: 0, + description: + "Number of distinct users who have read this broadcast. Counts receipts, so for a targeted broadcast the denominator is `recipientUserIds.length` and for an everyone-broadcast it is the whole user base. `0` on a freshly created broadcast.", + }, + }, +}; + +const deletedIdSchema: JsonSchema = { + type: "object", + required: ["id"], + properties: { + id: { type: "string", description: "Id of the record that was deleted, echoed back for correlation." }, + }, +}; + +// --------------------------------------------------------------------------- +// Request bodies (announcements — hand-written; see the module report) +// --------------------------------------------------------------------------- + +const CTA_PAIRING_RULE = + "`ctaLabelEn` and `ctaUrl` are validated as a pair: send both, or neither. Sending one alone is rejected with 400 and the offending field named in `detail`. `ctaLabelZh` is independent — it may be omitted even when a CTA exists."; + +const announcementCreateBody: JsonSchema = { + type: "object", + required: ["titleEn", "bodyMarkdownEn", "enabled"], + properties: { + titleEn: { + type: "string", + minLength: 1, + maxLength: 200, + description: "English title. Required, trimmed, must be non-empty after trimming.", + }, + titleZh: { + type: "string", + maxLength: 200, + description: "Chinese title. Optional; omit it or send `\"\"` to leave the locale unset — readers then fall back to `titleEn`.", + }, + bodyMarkdownEn: { + type: "string", + minLength: 1, + maxLength: 20000, + description: "English body as Markdown. Required, trimmed, must be non-empty after trimming.", + }, + bodyMarkdownZh: { + type: "string", + maxLength: 20000, + description: "Chinese body as Markdown. Optional; omit to leave unset.", + }, + ctaLabelEn: { + type: ["string", "null"], + minLength: 1, + maxLength: 80, + description: `English call-to-action button label. ${CTA_PAIRING_RULE}`, + }, + ctaLabelZh: { + type: ["string", "null"], + minLength: 1, + maxLength: 80, + description: "Chinese call-to-action button label. Optional translation of `ctaLabelEn`; readers fall back to the EN label when this is `null`.", + }, + ctaUrl: { + type: ["string", "null"], + format: "uri", + maxLength: 2048, + description: `Absolute URL the CTA opens. Must parse as a URL. ${CTA_PAIRING_RULE}`, + }, + enabled: { + type: "boolean", + description: + "Required — there is no default. `false` creates the announcement in a hidden state so it can be drafted and reviewed before going live.", + }, + startsAt: { + type: ["string", "null"], + format: "date-time", + description: + "Inclusive start of the visibility window as an ISO-8601 date-time (a `Z` suffix or an explicit `±hh:mm` offset are both accepted; a bare local timestamp is rejected). Omit or send `null` for 'visible immediately'. Also becomes the archive's `publishedAt`.", + examples: ["2026-08-10T09:00:00Z"], + }, + endsAt: { + type: ["string", "null"], + format: "date-time", + description: + "Exclusive end of the visibility window, same format as `startsAt`. Must be strictly after `startsAt` when both are set, otherwise 400 `INVALID_ANNOUNCEMENT_WINDOW`. Omit or send `null` for open-ended.", + examples: ["2026-08-24T09:00:00Z"], + }, + }, + description: + "Announcement to create. Mirrors the route's Zod validator in `domains/announcements/routes.ts`, which is not exported and therefore transcribed here rather than generated.", +}; + +/** + * Patch body. Deliberately NOT a re-export of `announcementCreateBody.properties`: + * every field is optional here, and five of them mean something different on a + * patch than they do on a create (nothing is "required", `enabled` is the + * retraction switch rather than a draft flag, and an omitted window bound keeps + * its stored value instead of meaning "unbounded"). Field descriptions are what + * Swagger UI and generated clients actually show, so they are written for the + * patch path rather than inherited from create. + */ +const announcementUpdateProperties: Record = { + titleEn: { + type: "string", + minLength: 1, + maxLength: 200, + description: "English title. Optional on a patch; when sent it is trimmed and must still be non-empty.", + }, + titleZh: { + type: "string", + maxLength: 200, + description: "Chinese title. Omit to keep the stored translation; send `\"\"` to clear it, after which readers fall back to `titleEn`.", + }, + bodyMarkdownEn: { + type: "string", + minLength: 1, + maxLength: 20000, + description: "English body as Markdown. Optional on a patch; when sent it is trimmed and must still be non-empty.", + }, + bodyMarkdownZh: { + type: "string", + maxLength: 20000, + description: "Chinese body as Markdown. Omit to keep the stored translation; send `\"\"` to clear it.", + }, + ctaLabelEn: { + type: ["string", "null"], + minLength: 1, + maxLength: 80, + description: + "English call-to-action button label. The `ctaLabelEn`/`ctaUrl` both-or-neither rule is checked against **this payload alone**, not against the merged record — so send both together to change either, and send both as `null` to drop the CTA.", + }, + ctaLabelZh: { + type: ["string", "null"], + minLength: 1, + maxLength: 80, + description: "Chinese call-to-action button label. Independent of the pairing rule; omit to keep it, send `null` to drop it.", + }, + ctaUrl: { + type: ["string", "null"], + format: "uri", + maxLength: 2048, + description: + "Absolute URL the CTA opens. Must parse as a URL when non-`null`, and is subject to the same payload-local pairing rule as `ctaLabelEn`.", + }, + enabled: { + type: "boolean", + description: + "Visibility switch. Setting it to `false` is the retraction path — the announcement disappears from both public endpoints immediately but is preserved, so `true` puts it back. Omit to leave the current state alone.", + }, + startsAt: { + type: ["string", "null"], + format: "date-time", + description: + "Inclusive start of the visibility window as an ISO-8601 date-time (a `Z` suffix or an explicit `±hh:mm` offset are both accepted; a bare local timestamp is rejected). **Omit to keep the stored bound**; send `null` to clear it, which makes the announcement visible from creation.", + examples: ["2026-08-10T09:00:00Z"], + }, + endsAt: { + type: ["string", "null"], + format: "date-time", + description: + "Exclusive end of the visibility window, same format as `startsAt`. **Omit to keep the stored bound**; send `null` to clear it (open-ended). When the merged result has both bounds set, `endsAt` must be strictly after `startsAt`, otherwise 400 `INVALID_ANNOUNCEMENT_WINDOW`.", + examples: ["2026-08-24T09:00:00Z"], + }, +}; + +const announcementUpdateBody: JsonSchema = { + type: "object", + properties: announcementUpdateProperties, + description: + "Sparse patch — send only the fields you intend to change; omitted fields keep their stored value. Nothing is required individually, but the body must carry at least one field (an empty object is rejected with 400 `invalid_announcement_input`, detail `No fields to update`). Per-field rules match create for the fields you do send, with two window caveats: the `startsAt < endsAt` check runs against the *merged* result (the side you did not send is read from storage), while the `ctaLabelEn`/`ctaUrl` both-or-neither check runs against the **patch payload alone** — so changing only `ctaUrl` on an announcement that already has a label is rejected; resend both.", +}; + +// --------------------------------------------------------------------------- +// Operations +// --------------------------------------------------------------------------- + +export function messagingPaths(prefix: string): PathMap { + return { + [`${prefix}/notifications`]: { + get: { + summary: "List the caller's merged notification feed", + description: + "Return the caller's inbox: their own per-user notifications interleaved with every admin broadcast they are a recipient of, sorted by `createdAt` descending. Rows are a discriminated union on `source` — `\"user\"` rows carry `category` / `title` / `body` / `link` / `data`, `\"broadcast\"` rows carry bilingual `titleI18n` / `bodyMarkdownI18n` and nothing else — so switch on `source` before touching any other field. This is the endpoint an agent polls to learn about events on its own skills (audit verdicts, GitHub auto-sync outcomes, quota grants); use `category` for automated handling and treat `title` / `body` as human prose whose wording may change. There is **no cursor pagination**: the feed is a top-N window over both sources, so a caller who needs history beyond `limit` cannot page further back. Read state is per-caller and is reported the same way for both variants (`readAt`), even though a notification stores it inline while a broadcast stores a separate receipt. Cheap sibling: `GET /notifications/unread-count` returns just the badge number, which is what you should poll on an interval.", + operationId: "listNotifications", + tags: ["Notifications"], + security: bearerAuth(), + parameters: [ + queryParam( + "unread", + "Set to the exact literal string `true` to return only unread rows (per-user notifications with `readAt === null`, and broadcasts with no receipt for this caller). Any other value — including `1`, `TRUE`, `yes`, or the empty string — is treated as `false`, so this parameter never produces a validation error. Defaults to `false` (all rows).", + { type: "string", enum: ["true", "false"], default: "false", examples: ["true"] }, + ), + queryParam( + "limit", + "Maximum rows to return. Parsed as a base-10 integer and clamped into `[1, 200]`; the default is `50`. Nothing here 400s — a missing, empty, or unparseable value (`abc`) silently falls back to the default, and out-of-range values are clamped rather than rejected. The bound applies to the *merged* result, not per source: asking for `10` yields the 10 newest rows across notifications and broadcasts combined, never 10 of each.", + { type: "integer", minimum: 1, maximum: 200, default: 50, examples: [50] }, + ), + ], + responses: { + ...jsonResponse( + { + type: "object", + required: ["items"], + properties: { + items: { + type: "array", + items: feedItemSchema, + description: + "Feed rows, newest first, at most `limit` of them. Empty when the caller has no notifications and is a recipient of no broadcast.", + }, + }, + }, + "The caller's merged inbox feed.", + { + example: { + items: [ + { + _id: "9c8e1a70-5b2d-4e63-8f0a-1d7c4b6e2905", + source: "broadcast", + titleI18n: { en: "Scheduled maintenance", zh: "计划维护" }, + bodyMarkdownI18n: { + en: "The registry will be read-only on **Sunday 03:00–04:00 UTC**.", + zh: "注册表将于 **周日 03:00–04:00 UTC** 进入只读状态。", + }, + createdAt: "2026-08-07T08:00:00.000Z", + readAt: null, + }, + { + _id: "1f6b3c9e-2a4d-4c1f-9b7e-0d5a8c3e1f42", + source: "user", + userId: "usr_01HXYZ2K3M4N5P6Q7R8S9T", + category: "audit.completed", + title: "Skill audit passed — pdf-extract v1.2.0 · score 9.1/10", + body: "Audit verdict was green. No follow-up required.", + link: "/skills/3f1c0a4e-9c2b-4a1e-9e3a-6b5d2f7c8a10/audits?version=1.2.0", + data: { + skillGuid: "3f1c0a4e-9c2b-4a1e-9e3a-6b5d2f7c8a10", + skillName: "pdf-extract", + version: "1.2.0", + verdict: "green", + overallScore: 9.1, + }, + readAt: "2026-08-07T07:41:03.902Z", + createdAt: "2026-08-07T07:12:44.001Z", + }, + ], + }, + }, + ), + ...problemResponses(401, { + 500: "Internal error (`internal_error`) — the notifications or broadcasts collection could not be read. Nothing was mutated; retry with backoff.", + }), + }, + }, + }, + + [`${prefix}/notifications/unread-count`]: { + get: { + summary: "Count the caller's unread notifications", + description: + "Return a single integer: unread per-user notifications plus broadcasts the caller is a recipient of and has no read receipt for. This is the badge endpoint — poll it on an interval and only fetch the feed when the number moves. Be aware of what the saving actually is: it counts per-user rows instead of fetching them and it answers with one integer instead of a page of Markdown bodies, but it still reads the same broadcast roster the feed does, so the win is in payload size and client work rather than in database round-trips. The count and the feed are consistent with each other: `unread-count` equals the number of rows `GET /notifications?unread=true&limit=200` would return, up to that cap. Targeted broadcasts the caller is not a recipient of never contribute.", + operationId: "getNotificationUnreadCount", + tags: ["Notifications"], + security: bearerAuth(), + responses: { + ...jsonResponse( + { + type: "object", + required: ["count"], + properties: { + count: { + type: "integer", + minimum: 0, + description: + "Total unread items across both sources. Unbounded — it is a true count, not clamped to the feed's 200-row window.", + }, + }, + }, + "The caller's unread badge number.", + { example: { count: 3 } }, + ), + ...problemResponses(401, { + 500: "Internal error (`internal_error`) — the unread count or the broadcast roster could not be read. Keep showing the last known badge value and retry with backoff.", + }), + }, + }, + }, + + [`${prefix}/notifications/{id}/read`]: { + post: { + summary: "Mark one notification or broadcast as read", + description: + "Acknowledge a single inbox row. The id space is shared: pass either a per-user notification `_id` or a broadcast `_id` — the server resolves which by lookup (per-user first, since those are orders of magnitude more common) and does the right thing, marking the notification's `readAt` or upserting a broadcast read receipt. Has **no request body**. Safe to repeat, but the two sources differ in what a repeat does: a broadcast receipt is written with `$setOnInsert`, so the **first** read time wins and re-marking returns the original `readAt`; a per-user notification's `readAt` is overwritten with the current time on every call. If you care about first-read time, do not re-mark notifications. The 200 payload is a tagged union that mirrors which kind of row was hit: a per-user notification comes back as the full stored document, while a broadcast comes back as the minimal `{ source: \"broadcast\", readAt }` receipt — note the asymmetry, the notification variant does **not** carry a `source` field even though the feed's rows do. Use `POST /notifications/mark-all-read` instead of looping this endpoint over a page of ids.", + operationId: "markNotificationRead", + tags: ["Notifications"], + security: bearerAuth(), + parameters: [ + pathParam( + "id", + "The `_id` of the feed row to acknowledge — either a per-user notification id or a broadcast id, both UUID v4. Take it verbatim from `GET /notifications`; there is no separate endpoint per source.", + { type: "string", format: "uuid" }, + "1f6b3c9e-2a4d-4c1f-9b7e-0d5a8c3e1f42", + ), + ], + responses: { + ...jsonResponse( + { + oneOf: [notificationDocumentSchema, broadcastReceiptSchema], + description: + "Union of two shapes. The notification variant is the full stored document (no `source` field); the broadcast variant is the two-field receipt (`source` is present and equals `\"broadcast\"`). Test for the `source` key to tell them apart.", + }, + "The row was marked read for this caller.", + { + example: { source: "broadcast", readAt: "2026-08-07T09:20:11.443Z" }, + }, + ), + ...problemResponses(401, { + 404: "Not found (`notification_not_found`) — no per-user notification with this id belongs to the caller, and no broadcast with this id is visible to them. A targeted broadcast the caller is not a recipient of answers 404 identically to a typo'd id, so the endpoint cannot be used to probe for the existence of targeted messages.", + 500: "Internal error (`internal_error`) — the lookup or the read-state write failed. The row may or may not have been marked; re-reading the feed is the cheapest way to find out, and re-marking is harmless for broadcasts (first read time wins).", + }), + }, + }, + }, + + [`${prefix}/notifications/mark-all-read`]: { + post: { + summary: "Mark the caller's entire inbox as read", + description: + "Clear the badge in one call: sets `readAt` on every unread per-user notification and writes a read receipt for every visible broadcast the caller has not yet acknowledged. Scoped to the caller and to broadcasts they are actually a recipient of — a targeted broadcast addressed to someone else is never touched. Has **no request body** and takes no parameters; there is no way to limit it to a subset, so use `POST /notifications/{id}/read` when you need per-row control. Returns the number of rows that actually *transitioned* to read across both sources, so calling it twice returns a positive number then `0`. That makes it safe to retry: the operation is idempotent even though it is a POST, and a `0` on retry means the first attempt had already landed. After a successful call `GET /notifications/unread-count` reports `0`.", + operationId: "markAllNotificationsRead", + tags: ["Notifications"], + security: bearerAuth(), + responses: { + ...jsonResponse( + { + type: "object", + required: ["updated"], + properties: { + updated: { + type: "integer", + minimum: 0, + description: + "How many rows changed from unread to read — per-user notifications plus newly written broadcast receipts. `0` when the inbox was already fully read.", + }, + }, + }, + "Every unread row visible to the caller is now read.", + { example: { updated: 3 } }, + ), + ...problemResponses(401, { + 500: "Internal error (`internal_error`) — the sweep failed partway. Per-user notifications are marked before broadcast receipts are written, so a failure on the broadcast half leaves the per-user half already read. Retrying is safe: the second pass only touches what is still unread.", + }), + }, + }, + }, + + [`${prefix}/announcements`]: { + get: { + summary: "List released announcements (public news archive)", + description: + "Return every *released* announcement, newest first — the archive behind the product's News page. 'Released' means `enabled === true` and the start gate has elapsed (`startsAt` is null or in the past). Expired entries are deliberately **kept**: `endsAt` only controls the landing popup, never the archive, so this list grows monotonically until an admin disables or deletes something. **Public and unauthenticated** — send no token; a token is ignored if present, and the response is identical for everyone. The payload is anonymous-safe: no `createdBy`, no `enabled`, no window bounds. Both locales are always included, so a client can switch language without refetching. This is not an inbox: announcements are never delivered to `GET /notifications` and carry no per-user read state. There is no pagination and no filtering — the full archive comes back in one response, so cache it client-side. Use `GET /announcements/active` when you only want the single notice to show right now.", + operationId: "listPublishedAnnouncements", + tags: ["Announcements"], + security: publicAuth(), + responses: { + ...jsonResponse( + { + type: "object", + required: ["items"], + properties: { + items: { + type: "array", + items: publicAnnouncementListItemSchema, + description: "Released announcements, ordered by `createdAt` descending. Empty when nothing has been released.", + }, + }, + }, + "The public announcement archive.", + { + example: { + items: [ + { + id: "b1e5f0d2-7c3a-4f8b-9d61-2a0c4e7f5b93", + titleEn: "Skillsets are now in public beta", + titleZh: "技能集公测上线", + bodyMarkdownEn: "Compose several skills into one installable bundle. See the docs for details.", + bodyMarkdownZh: "将多个技能组合为一个可安装的包。详情请查看文档。", + ctaLabelEn: "Read the docs", + ctaLabelZh: null, + ctaUrl: "https://example.com/docs/skillsets", + publishedAt: "2026-08-01T09:00:00.000Z", + }, + ], + }, + }, + ), + ...problemResponses({ + 500: "Internal error (`internal_error`) — the announcements collection could not be read. There is no partial archive: either the whole list comes back or this does. Serve your cached copy and retry with backoff.", + }), + }, + }, + }, + + [`${prefix}/announcements/active`]: { + get: { + summary: "Get the single announcement to show right now", + description: + "Return the one announcement that is live at this instant, or `null`. 'Live' means `enabled === true` and now falls inside `[startsAt, endsAt)` — both bounds are optional, and a null bound is open on that side. When several qualify, the most recently **created** one wins, which is how an admin supersedes a live notice: create a newer enabled one rather than editing the old. **Public and unauthenticated.** Designed for a landing-page popup, so treat `null` as the normal case, not an error — it simply means nothing is scheduled right now. Unlike `GET /announcements` this respects `endsAt`, so an expired notice disappears here while remaining in the archive. Response field `active` is nullable; the surrounding `{ data, error }` envelope is still present, so the empty case is `{ \"data\": { \"active\": null }, \"error\": null }`.", + operationId: "getActiveAnnouncement", + tags: ["Announcements"], + security: publicAuth(), + responses: { + ...jsonResponse( + { + type: "object", + required: ["active"], + properties: { + active: { + oneOf: [publicAnnouncementSchema, { type: "null" }], + description: + "The live announcement, or `null` when none qualifies. `null` is an expected, non-exceptional result.", + }, + }, + }, + "The currently live announcement, if any.", + { + example: { + active: { + id: "b1e5f0d2-7c3a-4f8b-9d61-2a0c4e7f5b93", + titleEn: "Skillsets are now in public beta", + titleZh: "技能集公测上线", + bodyMarkdownEn: "Compose several skills into one installable bundle.", + bodyMarkdownZh: "将多个技能组合为一个可安装的包。", + ctaLabelEn: "Read the docs", + ctaLabelZh: null, + ctaUrl: "https://example.com/docs/skillsets", + }, + }, + }, + ), + ...problemResponses({ + 500: "Internal error (`internal_error`) — the active-announcement lookup failed. Distinct from the empty case: `null` inside a 200 means nothing is scheduled, this means the question could not be answered. Render no popup and retry with backoff.", + }), + }, + }, + }, + + [`${prefix}/admin/announcements`]: { + get: { + summary: "List every announcement (admin)", + description: + "Return all announcements — enabled and disabled, scheduled, live, and expired — newest first, with the scheduling and audit fields the public endpoints strip (`enabled`, `startsAt`, `endsAt`, `createdBy`, `createdAt`, `updatedAt`). This is the admin table; use it to audit what is scheduled and to find the id to PATCH or DELETE. Requires the `ornn:admin:skill` permission scope on the caller's NyxID token — check `permissions` from `GET /me` before calling rather than probing for a 403. No pagination and no filtering: the whole collection comes back, which is fine at the cardinality this surface is designed for (well under a thousand rows).", + operationId: "adminListAnnouncements", + tags: ["Announcements", "Admin"], + security: bearerAuth(), + responses: { + ...jsonResponse( + { + type: "object", + required: ["items"], + properties: { + items: { + type: "array", + items: adminAnnouncementSchema, + description: "Every announcement, ordered by `createdAt` descending. Empty when none exist.", + }, + }, + }, + "Every announcement, with scheduling and audit fields.", + { + example: { + items: [ + { + id: "b1e5f0d2-7c3a-4f8b-9d61-2a0c4e7f5b93", + titleEn: "Scheduled maintenance", + titleZh: "计划维护", + bodyMarkdownEn: "The registry will be read-only on **Sunday 03:00–04:00 UTC**.", + bodyMarkdownZh: "注册表将于 **周日 03:00–04:00 UTC** 进入只读状态。", + ctaLabelEn: "Status page", + ctaLabelZh: "状态页", + ctaUrl: "https://example.com/status", + enabled: true, + startsAt: "2026-08-10T09:00:00.000Z", + endsAt: "2026-08-24T09:00:00.000Z", + createdBy: "usr_01HXYZ2K3M4N5P6Q7R8S9T", + createdAt: "2026-08-07T08:00:00.000Z", + updatedAt: "2026-08-07T08:00:00.000Z", + }, + { + id: "4d2a9f61-8b30-4c7e-a5f2-6e1b0c3d9a47", + titleEn: "Skillsets are now in public beta", + titleZh: "", + bodyMarkdownEn: "Compose several skills into one installable bundle.", + bodyMarkdownZh: "", + ctaLabelEn: null, + ctaLabelZh: null, + ctaUrl: null, + enabled: false, + startsAt: null, + endsAt: null, + createdBy: "usr_01HXYZ2K3M4N5P6Q7R8S9T", + createdAt: "2026-08-01T09:00:00.000Z", + updatedAt: "2026-08-03T11:22:05.310Z", + }, + ], + }, + }, + ), + ...problemResponses(401, { + 403: "Forbidden (`forbidden`) — the caller is authenticated but their token does not carry the `ornn:admin:skill` permission scope.", + 500: "Internal error (`internal_error`) — the announcements collection could not be read. Nothing was mutated; retry with backoff.", + }), + }, + }, + post: { + summary: "Create an announcement (admin)", + description: + "Create a site-wide announcement for the landing popup and the News archive. Requires the `ornn:admin:skill` permission scope. `enabled` is required and has no default, so a draft is created with `enabled: false` and flipped later via PATCH. Scheduling is optional on both sides: omit `startsAt` for 'live immediately', omit `endsAt` for open-ended; when both are set `endsAt` must be strictly after `startsAt`. Creating a second enabled, in-window announcement does not conflict — `GET /announcements/active` simply serves the newest, which is the supported way to supersede a live notice. The response is the full admin shape and `Location` points at the collection-scoped URL for the new record. Announcements are **not** delivered to anyone's inbox; if you want a message in users' notification feeds, create a broadcast (`POST /admin/broadcasts`) instead.", + operationId: "adminCreateAnnouncement", + tags: ["Announcements", "Admin"], + security: bearerAuth(), + requestBody: jsonBody(announcementCreateBody, "The announcement to create.", { + example: { + titleEn: "Scheduled maintenance", + titleZh: "计划维护", + bodyMarkdownEn: "The registry will be read-only on **Sunday 03:00–04:00 UTC**.", + bodyMarkdownZh: "注册表将于 **周日 03:00–04:00 UTC** 进入只读状态。", + ctaLabelEn: "Status page", + ctaLabelZh: "状态页", + ctaUrl: "https://example.com/status", + enabled: true, + startsAt: "2026-08-10T09:00:00Z", + endsAt: "2026-08-24T09:00:00Z", + }, + }), + responses: { + ...jsonResponse(adminAnnouncementSchema, "The announcement was created.", { + status: 201, + headers: { + Location: { + description: + "URL of the created announcement, e.g. `/api/v1/admin/announcements/b1e5f0d2-7c3a-4f8b-9d61-2a0c4e7f5b93`. Note this path only accepts PATCH and DELETE — there is no single-announcement GET.", + schema: { type: "string" }, + }, + }, + // The request-body example above, as it comes back: server-assigned + // `id` / `createdBy` / timestamps, and the window bounds re-serialised + // with milliseconds. + example: { + id: "b1e5f0d2-7c3a-4f8b-9d61-2a0c4e7f5b93", + titleEn: "Scheduled maintenance", + titleZh: "计划维护", + bodyMarkdownEn: "The registry will be read-only on **Sunday 03:00–04:00 UTC**.", + bodyMarkdownZh: "注册表将于 **周日 03:00–04:00 UTC** 进入只读状态。", + ctaLabelEn: "Status page", + ctaLabelZh: "状态页", + ctaUrl: "https://example.com/status", + enabled: true, + startsAt: "2026-08-10T09:00:00.000Z", + endsAt: "2026-08-24T09:00:00.000Z", + createdBy: "usr_01HXYZ2K3M4N5P6Q7R8S9T", + createdAt: "2026-08-07T08:00:00.000Z", + updatedAt: "2026-08-07T08:00:00.000Z", + }, + }), + ...problemResponses( + { + 400: "Bad request — either `invalid_announcement_input` (body is not valid JSON, a required field is missing, a length cap is exceeded, `ctaUrl` is not a URL, a timestamp is not ISO-8601 with a `Z`/offset, or the `ctaLabelEn`/`ctaUrl` both-or-neither rule was broken; `detail` names the field) or `INVALID_ANNOUNCEMENT_WINDOW` (`endsAt` is not strictly after `startsAt`). Nothing is created in either case.", + }, + 401, + { + 403: "Forbidden (`forbidden`) — the token lacks the `ornn:admin:skill` permission scope.", + 500: "Internal error (`internal_error`) — the insert failed. No announcement was created, so retrying is safe; there is no idempotency key, so confirm with `GET /admin/announcements` if you are unsure whether the write landed.", + }, + ), + }, + }, + }, + + [`${prefix}/admin/announcements/{id}`]: { + patch: { + summary: "Update an announcement (admin)", + description: + "Sparsely update an existing announcement — content, CTA, `enabled`, or the schedule window. Requires the `ornn:admin:skill` permission scope. Only the fields you send are written; everything else keeps its stored value, and the record's `updatedAt` moves while `createdBy` and `createdAt` never do. Two validation subtleties matter: the `startsAt < endsAt` ordering check is evaluated against the **merged** result (the bound you did not send is read from storage), whereas the `ctaLabelEn`/`ctaUrl` both-or-neither check is evaluated against the **patch payload alone** — so to change just the CTA URL you must resend the label too. An empty patch is rejected rather than treated as a no-op. Flipping `enabled` to `false` is the retraction path: it hides the announcement from both public endpoints immediately while preserving it for later re-enable, which DELETE cannot do. There is no single-announcement GET; read it back from `GET /admin/announcements` or from this response.", + operationId: "adminUpdateAnnouncement", + tags: ["Announcements", "Admin"], + security: bearerAuth(), + parameters: [ + pathParam( + "id", + "Announcement id (UUID v4) as reported by `GET /admin/announcements` or by the create response's `id`.", + { type: "string", format: "uuid" }, + "b1e5f0d2-7c3a-4f8b-9d61-2a0c4e7f5b93", + ), + ], + requestBody: jsonBody(announcementUpdateBody, "Fields to change. At least one is required.", { + example: { enabled: false, endsAt: "2026-08-20T09:00:00Z" }, + }), + responses: { + ...jsonResponse(adminAnnouncementSchema, "The announcement after the patch was applied.", { + // Result of the request example above: `enabled` and `endsAt` moved, + // every unsent field kept its stored value, `updatedAt` advanced while + // `createdBy` / `createdAt` did not. + example: { + id: "b1e5f0d2-7c3a-4f8b-9d61-2a0c4e7f5b93", + titleEn: "Scheduled maintenance", + titleZh: "计划维护", + bodyMarkdownEn: "The registry will be read-only on **Sunday 03:00–04:00 UTC**.", + bodyMarkdownZh: "注册表将于 **周日 03:00–04:00 UTC** 进入只读状态。", + ctaLabelEn: "Status page", + ctaLabelZh: "状态页", + ctaUrl: "https://example.com/status", + enabled: false, + startsAt: "2026-08-10T09:00:00.000Z", + endsAt: "2026-08-20T09:00:00.000Z", + createdBy: "usr_01HXYZ2K3M4N5P6Q7R8S9T", + createdAt: "2026-08-07T08:00:00.000Z", + updatedAt: "2026-08-09T14:05:37.118Z", + }, + }), + ...problemResponses( + { + 400: "Bad request — `invalid_announcement_input` when the body is not valid JSON, carries no fields at all (detail `No fields to update`), fails a field rule, or breaks the `ctaLabelEn`/`ctaUrl` pairing rule for the payload; `INVALID_ANNOUNCEMENT_WINDOW` when the resulting `endsAt` is not strictly after the resulting `startsAt`. Nothing is written in either case.", + }, + 401, + { + 403: "Forbidden (`forbidden`) — the token lacks the `ornn:admin:skill` permission scope.", + }, + { + 404: "Not found (`announcement_not_found`) — no announcement has this id. Note this is also raised before the write when the patch touches `startsAt`/`endsAt`, because the merged-window check has to read the record first.", + 500: "Internal error (`internal_error`) — the patch could not be completed. The write and the read-back are separate round trips, so the change may still have landed; re-read the record from `GET /admin/announcements` before retrying.", + }, + ), + }, + }, + delete: { + summary: "Delete an announcement (admin)", + description: + "Permanently remove an announcement. Requires the `ornn:admin:skill` permission scope. This is a hard delete with no soft-delete tier and no undo — the record vanishes from the admin table and from the public archive at once. Prefer `PATCH { \"enabled\": false }` when you only want to take a notice down, since that keeps the content and lets you re-enable it later. Deletion is not idempotent from the caller's point of view: the first call answers 200 with the id, and a repeat answers 404. Nothing cascades — announcements have no per-user state to clean up (unlike broadcasts, whose read receipts are cascaded on delete).", + operationId: "adminDeleteAnnouncement", + tags: ["Announcements", "Admin"], + security: bearerAuth(), + parameters: [ + pathParam( + "id", + "Announcement id (UUID v4) to delete.", + { type: "string", format: "uuid" }, + "b1e5f0d2-7c3a-4f8b-9d61-2a0c4e7f5b93", + ), + ], + responses: { + ...jsonResponse(deletedIdSchema, "The announcement was deleted.", { + example: { id: "b1e5f0d2-7c3a-4f8b-9d61-2a0c4e7f5b93" }, + }), + ...problemResponses( + 401, + { + 403: "Forbidden (`forbidden`) — the token lacks the `ornn:admin:skill` permission scope.", + }, + { + 404: "Not found (`announcement_not_found`) — no announcement has this id, or it was already deleted.", + 500: "Internal error (`internal_error`) — the delete could not be performed. The announcement is still stored and still visible wherever it was; retry with backoff.", + }, + ), + }, + }, + }, + + [`${prefix}/admin/broadcasts`]: { + get: { + summary: "List all broadcasts with read statistics (admin)", + description: + "Return every broadcast ever authored, newest first, each enriched with `readCount` — the number of distinct users who have read it. Requires the `ornn:admin:skill` permission scope. This doubles as the broadcast history view, which is why audit fields (`createdBy`, `updatedBy`, `createdAt`, `updatedAt`) and the frozen `recipientUserIds` targeting list are all first-class here. Read counts come from one grouped query over the receipts collection regardless of how many rows are returned, so the listing stays cheap. No pagination or filtering. There is no public counterpart — end users see broadcasts only through `GET /notifications`, where they appear as `source: \"broadcast\"` rows.", + operationId: "adminListBroadcasts", + tags: ["Notifications", "Admin"], + security: bearerAuth(), + responses: { + ...jsonResponse( + { + type: "object", + required: ["items"], + properties: { + items: { + type: "array", + items: adminBroadcastSchema, + description: "Every broadcast, ordered by `createdAt` descending. Empty when none exist.", + }, + }, + }, + "Every broadcast, with per-message read counts.", + { + example: { + items: [ + { + id: "9c8e1a70-5b2d-4e63-8f0a-1d7c4b6e2905", + titleI18n: { en: "Scheduled maintenance", zh: "计划维护" }, + bodyMarkdownI18n: { + en: "The registry will be read-only on **Sunday 03:00–04:00 UTC**.", + zh: "注册表将于 **周日 03:00–04:00 UTC** 进入只读状态。", + }, + createdBy: "usr_01HXYZ2K3M4N5P6Q7R8S9T", + updatedBy: "usr_01HXYZ2K3M4N5P6Q7R8S9T", + recipientUserIds: null, + createdAt: "2026-08-07T08:00:00.000Z", + updatedAt: "2026-08-07T08:00:00.000Z", + readCount: 42, + }, + ], + }, + }, + ), + ...problemResponses(401, { + 403: "Forbidden (`forbidden`) — the caller is authenticated but their token does not carry the `ornn:admin:skill` permission scope.", + 500: "Internal error (`internal_error`) — the broadcasts collection or the grouped read-count query failed. Nothing was mutated; retry with backoff.", + }), + }, + }, + post: { + summary: "Create a broadcast (admin)", + description: + "Author a message that lands in users' notification inboxes. Requires the `ornn:admin:skill` permission scope. Unlike announcements, broadcasts are **delivered**: the moment this returns, every recipient's `GET /notifications` includes a `source: \"broadcast\"` row and their `unread-count` goes up by one. There is no scheduling and no draft state — a broadcast is visible from creation until it is deleted. Both locales of both `titleI18n` and `bodyMarkdownI18n` are required and must be non-empty after trimming; bodies are rendered as Markdown. Targeting is decided **once, here**: omit `recipientUserIds` to reach every user, or pass a non-empty array of NyxID user ids to reach only those. That choice is frozen — PATCH rejects the field outright — because a message cannot be recalled from someone who has already read it. To targeted-out users the broadcast does not exist at all: invisible in the feed and in `unread-count`, and its id 404s on `POST /notifications/{id}/read`. Unknown properties are rejected rather than ignored, so a typo'd field name is a 400 rather than a silent no-op. Not idempotent — a retry after a lost response creates a second broadcast; check `GET /admin/broadcasts` first.", + operationId: "adminCreateBroadcast", + tags: ["Notifications", "Admin"], + security: bearerAuth(), + requestBody: jsonBody( + createBroadcastSchema, + "The broadcast to author. Generated from `createBroadcastSchema` in `domains/broadcasts/schemas.ts`, which is the runtime validator. The object is strict: unknown keys are rejected. Strings are trimmed before the non-empty check, so `\" \"` is rejected.", + { + example: { + titleI18n: { en: "Scheduled maintenance", zh: "计划维护" }, + bodyMarkdownI18n: { + en: "The registry will be read-only on **Sunday 03:00–04:00 UTC**.", + zh: "注册表将于 **周日 03:00–04:00 UTC** 进入只读状态。", + }, + recipientUserIds: ["usr_01HXYZ2K3M4N5P6Q7R8S9T"], + }, + }, + ), + responses: { + ...jsonResponse(adminBroadcastSchema, "The broadcast was created and is immediately visible to its recipients.", { + status: 201, + headers: { + Location: { + description: + "URL of the created broadcast, e.g. `/api/v1/admin/broadcasts/9c8e1a70-5b2d-4e63-8f0a-1d7c4b6e2905`. That path accepts PATCH and DELETE only — there is no single-broadcast GET.", + schema: { type: "string" }, + }, + }, + // The request-body example above, as it comes back. Note the three + // server-derived facts: `readCount` starts at 0, `updatedBy` and + // `updatedAt` are seeded from the create, and `recipientUserIds` is + // echoed as an array (it would be `null` had the field been omitted). + example: { + id: "9c8e1a70-5b2d-4e63-8f0a-1d7c4b6e2905", + titleI18n: { en: "Scheduled maintenance", zh: "计划维护" }, + bodyMarkdownI18n: { + en: "The registry will be read-only on **Sunday 03:00–04:00 UTC**.", + zh: "注册表将于 **周日 03:00–04:00 UTC** 进入只读状态。", + }, + createdBy: "usr_01HADM7N4K2P8Q6R3S1T9V", + updatedBy: "usr_01HADM7N4K2P8Q6R3S1T9V", + recipientUserIds: ["usr_01HXYZ2K3M4N5P6Q7R8S9T"], + createdAt: "2026-08-07T08:00:00.000Z", + updatedAt: "2026-08-07T08:00:00.000Z", + readCount: 0, + }, + }), + ...problemResponses( + { + 400: "Bad request (`invalid_broadcast_input`) — the body is not valid JSON, a locale is missing or blank after trimming, a length cap is exceeded (200 chars per title locale, 20 000 per body locale), `recipientUserIds` is `null`/empty/contains an empty string, or an unknown property was sent. `detail` names the offending path.", + }, + 401, + { + 403: "Forbidden (`forbidden`) — the token lacks the `ornn:admin:skill` permission scope.", + 500: "Internal error (`internal_error`) — the insert failed. No broadcast was created and no inbox was touched, so retrying is safe; there is no idempotency key, so check `GET /admin/broadcasts` first if you are unsure whether the write landed.", + }, + ), + }, + }, + }, + + [`${prefix}/admin/broadcasts/{id}`]: { + patch: { + summary: "Edit a broadcast's content (admin)", + description: + "Correct the text of an already-delivered broadcast. Requires the `ornn:admin:skill` permission scope. Recipients see the new content the next time they load their feed — there is no versioning and no 'edited' marker, and the message keeps its original `createdAt`, so it does not jump to the top of anyone's inbox. **Content only**: `recipientUserIds` is immutable and sending it is a 400, since targeting cannot be widened or narrowed after delivery. Read receipts are untouched — editing does **not** mark the message unread again, so anyone who already read it will not see the correction highlighted. The patch must change something: send at least one of `titleI18n` / `bodyMarkdownI18n`, and any i18n object you send must carry at least one locale (`titleI18n: {}` is rejected). Within an i18n object each locale is independently optional but must be non-empty when present — a locale cannot be blanked, only replaced, because both locales are required at rest. `updatedBy` and `updatedAt` are stamped from the calling admin.", + operationId: "adminUpdateBroadcast", + tags: ["Notifications", "Admin"], + security: bearerAuth(), + parameters: [ + pathParam( + "id", + "Broadcast id (UUID v4) as reported by `GET /admin/broadcasts` or by the create response's `id`. This is the same id recipients see as `_id` on their feed rows.", + { type: "string", format: "uuid" }, + "9c8e1a70-5b2d-4e63-8f0a-1d7c4b6e2905", + ), + ], + requestBody: jsonBody( + patchBroadcastSchema, + "Content changes. Generated from `patchBroadcastSchema` in `domains/broadcasts/schemas.ts`. Two cross-field rules the JSON Schema cannot express are enforced at runtime and surface as 400: the patch must contain at least one of `titleI18n` / `bodyMarkdownI18n`, and a supplied i18n object must contain at least one locale.", + { + example: { + bodyMarkdownI18n: { + en: "Correction: the read-only window is **Sunday 04:00–05:00 UTC**.", + zh: "更正:只读窗口为 **周日 04:00–05:00 UTC**。", + }, + }, + }, + ), + responses: { + ...jsonResponse(adminBroadcastSchema, "The broadcast after the edit, with its current `readCount`.", { + // The broadcast created in the example above, after the patch in the + // request example: only the body changed. `titleI18n`, `createdBy`, + // `createdAt`, and the frozen `recipientUserIds` are untouched; + // `updatedBy` is now the editing admin; `readCount` carries over + // because an edit does not clear read receipts. + example: { + id: "9c8e1a70-5b2d-4e63-8f0a-1d7c4b6e2905", + titleI18n: { en: "Scheduled maintenance", zh: "计划维护" }, + bodyMarkdownI18n: { + en: "Correction: the read-only window is **Sunday 04:00–05:00 UTC**.", + zh: "更正:只读窗口为 **周日 04:00–05:00 UTC**。", + }, + createdBy: "usr_01HADM7N4K2P8Q6R3S1T9V", + updatedBy: "usr_01HADM2C5F8J1L4N7Q0S3V", + recipientUserIds: ["usr_01HXYZ2K3M4N5P6Q7R8S9T"], + createdAt: "2026-08-07T08:00:00.000Z", + updatedAt: "2026-08-07T10:31:52.706Z", + // The single targeted recipient has already read it — receipts + // survive the edit, so this stays 1 rather than resetting to 0. + readCount: 1, + }, + }), + ...problemResponses( + { + 400: "Bad request (`invalid_broadcast_input`) — the body is not valid JSON, contains neither `titleI18n` nor `bodyMarkdownI18n`, contains an i18n object with no locale, contains a blank or over-long locale string, or contains an unknown property. Sending `recipientUserIds` lands here too: the schema is strict and targeting is immutable after create.", + }, + 401, + { + 403: "Forbidden (`forbidden`) — the token lacks the `ornn:admin:skill` permission scope.", + }, + { + 404: "Not found (`broadcast_not_found`) — no broadcast has this id.", + 500: "Internal error (`internal_error`) — the edit could not be completed. The content write and the read-count query are separate round trips, so the new text may already be live in recipients' feeds; re-read the row from `GET /admin/broadcasts` before retrying.", + }, + ), + }, + }, + delete: { + summary: "Delete a broadcast and cascade its read receipts (admin)", + description: + "Permanently remove a broadcast. Requires the `ornn:admin:skill` permission scope. This is the only way to retract an inbox message — there is no disable flag as there is for announcements. The message disappears from every recipient's feed and stops counting toward their `unread-count`; anyone who had not read it effectively never sees it. Hard delete with cascade: the broadcast row is removed first (so a user racing a `mark-read` cannot insert a fresh orphan receipt), then its read receipts are cleaned up. The cascade is best-effort — if receipt cleanup fails after the broadcast is gone, the operation still returns 200 and the failure is logged server-side, because orphan receipts are inert (no broadcast means they are never surfaced) and the user-visible delete genuinely succeeded. Not idempotent from the caller's view: the first call answers 200, a repeat answers 404.", + operationId: "adminDeleteBroadcast", + tags: ["Notifications", "Admin"], + security: bearerAuth(), + parameters: [ + pathParam( + "id", + "Broadcast id (UUID v4) to delete.", + { type: "string", format: "uuid" }, + "9c8e1a70-5b2d-4e63-8f0a-1d7c4b6e2905", + ), + ], + responses: { + ...jsonResponse(deletedIdSchema, "The broadcast was deleted; its read receipts were cascaded on a best-effort basis.", { + example: { id: "9c8e1a70-5b2d-4e63-8f0a-1d7c4b6e2905" }, + }), + ...problemResponses( + 401, + { + 403: "Forbidden (`forbidden`) — the token lacks the `ornn:admin:skill` permission scope.", + }, + { + 404: "Not found (`broadcast_not_found`) — no broadcast has this id, or it was already deleted.", + 500: "Internal error (`internal_error`) — the broadcast row itself could not be deleted, so the message is still in every recipient's feed. A failing receipt cascade is never the cause: that failure is logged and swallowed, and the call still answers 200.", + }, + ), + }, + }, + }, + }; +} diff --git a/ornn-api/src/openapi/paths/searchFormat.ts b/ornn-api/src/openapi/paths/searchFormat.ts new file mode 100644 index 00000000..b3e2e0af --- /dev/null +++ b/ornn-api/src/openapi/paths/searchFormat.ts @@ -0,0 +1,860 @@ +/** + * OpenAPI path definitions for the **search, facets, and skill-format** + * surface (#1214). + * + * Two related but distinct groups live here: + * + * - **Search** (`tags: ["Search"]`) — the registry discovery surface. + * `GET /skill-search` is the one endpoint an agent uses to *find* + * skills (keyword or LLM-ranked semantic). The three + * `/skill-facets/*` endpoints return the distinct filter values + * (tags, authors, NyxID system services) that are actually present + * inside the caller's visibility, so a client can build filter UI / + * filter arguments without guessing. `GET /skill-counts` returns the + * per-scope totals in one round-trip. + * + * - **Format** (`tags: ["Format"]`) — the SKILL.md package contract. + * `GET /skill-format/rules` is the human/LLM-readable rulebook, + * `GET /skill-manifest-schema.json` is the machine-readable JSON + * Schema for the same contract, and `POST /skill-format/validate` is + * the pre-flight check an agent SHOULD run against a freshly built + * ZIP before spending an upload round-trip on `POST /skills`. + * + * Path keys are built from the caller-supplied `prefix` (`/api/v1`) — + * every route below is mounted flat under that prefix by + * `bootstrap.ts` (`apiApp.route("/", searchRoutes | formatRoutes)`), + * so the segment after the prefix matches the Hono registration + * verbatim. + * + * @module openapi/paths/searchFormat + */ + +import type { JsonSchema, PathMap } from "../helpers"; +import { + bearerAuth, + jsonResponse, + optionalAuth, + problemResponses, + publicAuth, + queryParam, + rawJsonResponse, +} from "../helpers"; + +// --------------------------------------------------------------------------- +// Response payload schemas +// +// These are hand-written rather than derived from Zod: the search / +// facet / format handlers assemble their response objects inline +// (`c.json({ data: { ... } })`) from repository aggregation rows and +// `SkillSearchResponse`, and no Zod schema in the tree describes those +// exact wire shapes. The old hand-mirrored `openapi/schemas.ts` carried +// close relatives, but they were pre-#457/#715/#720 snapshots (no `meta`, +// no enrichment fields, narrower `scope` enum) that documented a contract +// the server no longer emits — which is precisely why #1214 deleted it. +// --------------------------------------------------------------------------- + +/** + * One row of `data.items` on `GET /skill-search`. Mirrors + * `SkillSearchItem` in `shared/types/index.ts` *after* the per-caller + * enrichment pass in `search/service.ts` (`enrichItem`). Fields that + * enrichment leaves `undefined` are omitted from the JSON body + * entirely, so they are documented as optional. + */ +const skillSearchItemSchema: JsonSchema = { + type: "object", + required: [ + "guid", + "name", + "description", + "createdBy", + "createdOn", + "updatedOn", + "isPrivate", + "tags", + "isSystemForMe", + "permissionSummary", + "nyxidServiceId", + "nyxidServiceSlug", + "nyxidServiceLabel", + "isSystemSkill", + "hasGithubSource", + ], + properties: { + guid: { + type: "string", + description: + "Stable skill identifier (UUID v4). Use this — not `name` — as the key in any client-side cache; names can be re-used after a skill is deleted.", + }, + name: { + type: "string", + description: + "Kebab-case skill name, unique across the platform. Accepted anywhere `{idOrName}` appears (e.g. `GET /skills/{idOrName}`).", + }, + description: { + type: "string", + description: "Short summary of what the skill does, taken from the SKILL.md frontmatter.", + }, + createdBy: { + type: "string", + description: + "NyxID user_id of the author. Feed this value back as a `createdByAny` filter to narrow a subsequent search to the same author.", + }, + createdByEmail: { + type: "string", + description: "Cached author email. Omitted when the platform never resolved one for this author.", + }, + createdByDisplayName: { + type: "string", + description: "Cached author display name. Omitted when unknown — fall back to `createdByEmail`, then `createdBy`.", + }, + createdOn: { type: "string", format: "date-time", description: "ISO 8601 creation timestamp." }, + updatedOn: { type: "string", format: "date-time", description: "ISO 8601 timestamp of the most recent publish/update." }, + isPrivate: { + type: "boolean", + description: "`true` when the skill is not publicly listed. A private skill in your results means you hold a grant on it.", + }, + tags: { + type: "array", + items: { type: "string" }, + description: "Tags declared in `metadata.tag`. These are the values `GET /skill-facets/tags` aggregates.", + }, + myAccessReason: { + type: "string", + enum: ["owner", "public", "shared-direct", "shared-via-org"], + description: + "Why THIS caller can see this skill, in precedence order: `owner` (you authored it) > `public` > `shared-direct` (granted to your user_id) > `shared-via-org` (granted to one of your orgs). A public result always carries `public`, authenticated or not — only the `owner` branch needs an identity. The field is omitted solely when no reason applies at all (a private skill with no owner, direct, or org match), which the visibility filter makes effectively unreachable in search results.", + }, + sharedViaOrgId: { + type: "string", + description: "Present only when `myAccessReason` is `shared-via-org` — the org user_id that carries the grant.", + }, + isSystemForMe: { + type: "boolean", + description: + "`true` when the skill is a platform system skill (tied to an admin-tier NyxID service). Despite the name it is caller-independent — system ties force the skill public.", + }, + systemForService: { + type: "object", + required: ["id", "slug", "label"], + properties: { + id: { type: "string", description: "NyxID service id." }, + slug: { type: "string", description: "NyxID service slug." }, + label: { type: "string", description: "Human-readable service label; falls back to the slug." }, + }, + description: "The NyxID service this system skill belongs to. Omitted for non-system skills.", + }, + permissionSummary: { + type: "object", + required: ["isPrivate", "sharedUserCount", "sharedOrgCount"], + properties: { + isPrivate: { type: "boolean", description: "Same value as the sibling `isPrivate`." }, + sharedUserCount: { type: "integer", description: "How many individual users hold a direct grant." }, + sharedOrgCount: { type: "integer", description: "How many orgs hold a grant." }, + }, + description: + "Counts only — grantee identities are never exposed in search results. Read `GET /skills/{id}/permissions` (requires ADMIN tier on the skill) for the actual grant list.", + }, + nyxidServiceId: { + type: ["string", "null"], + description: "NyxID service the skill is bound to, or `null` when unbound. Pass back as the `nyxidServiceId` search filter.", + }, + nyxidServiceSlug: { type: ["string", "null"], description: "Cached slug of the bound NyxID service, or `null`." }, + nyxidServiceLabel: { type: ["string", "null"], description: "Cached label of the bound NyxID service, or `null`." }, + isSystemSkill: { + type: "boolean", + description: "Cached flag: the skill is tied to an admin/platform-wide NyxID service. Drives the `systemFilter` query param.", + }, + hasGithubSource: { + type: "boolean", + description: + "`true` when the skill was imported from / is synced with a GitHub repository. The repo URL itself is deliberately NOT exposed here — read the skill detail endpoint for it.", + }, + }, +}; + +/** `data` payload of `GET /skill-search`. */ +const skillSearchPayloadSchema: JsonSchema = { + type: "object", + required: ["searchMode", "searchScope", "total", "totalPages", "page", "pageSize", "items", "meta"], + properties: { + searchMode: { + type: "string", + enum: ["keyword", "semantic"], + description: "The mode that actually ran. Echoed back so a client can confirm a fallback did not happen.", + }, + searchScope: { + type: "string", + enum: ["public", "private", "mixed", "shared-with-me", "mine"], + description: + "The visibility scope that was applied. NOTE: for anonymous callers this is always `public` — the server silently collapses any other requested scope rather than erroring.", + }, + total: { type: "integer", description: "Total matches across all pages within the applied scope + filters." }, + totalPages: { type: "integer", description: "`ceil(total / pageSize)`. Legacy offset-pagination field." }, + page: { type: "integer", description: "1-indexed page that was served (after cursor resolution)." }, + pageSize: { type: "integer", description: "Effective page size — `limit` when supplied, otherwise `pageSize`." }, + items: { + type: "array", + items: skillSearchItemSchema, + description: "The page of results, ordered by relevance in semantic mode and by the repository's default order in keyword mode.", + }, + meta: { + type: "object", + required: ["limit", "hasMore"], + properties: { + limit: { type: "integer", description: "Effective page size for this response — same value as `pageSize`." }, + hasMore: { + type: "boolean", + description: "`true` when at least one more page exists. Stop paginating when this is `false`.", + }, + nextCursor: { + type: "string", + description: + "Opaque base64url token. Pass it back verbatim as `?cursor=` to fetch the next page. MUST NOT be parsed — the payload is server-internal and will change. Emitted whenever this page came back full, which includes an exactly-full *final* page — so it can be present while `hasMore` is `false`. It is omitted only on a short page. Stop on `meta.hasMore`, not on the absence of this token; a `while (nextCursor)` loop costs one extra empty request.", + }, + }, + description: + "Cursor-pagination envelope per CONVENTIONS.md §4.3 (#457). Prefer `meta.nextCursor` over incrementing `page`; the offset fields are retained for backward compatibility and will be sunset.", + }, + }, +}; + +/** `data` payload of `GET /skill-facets/tags`. */ +const tagFacetPayloadSchema: JsonSchema = { + type: "object", + required: ["items"], + properties: { + items: { + type: "array", + description: "Distinct tags, sorted by descending count then ascending tag name. Capped at 200 rows server-side.", + items: { + type: "object", + required: ["name", "count"], + properties: { + name: { type: "string", description: "The tag value, exactly as it must be passed to `GET /skill-search?tags=`." }, + count: { type: "integer", description: "How many skills in this scope carry the tag." }, + }, + }, + }, + }, +}; + +/** `data` payload of `GET /skill-facets/authors`. */ +const authorFacetPayloadSchema: JsonSchema = { + type: "object", + required: ["items"], + properties: { + items: { + type: "array", + description: "Distinct authors, sorted by descending skill count then ascending user_id. Capped at 200 rows server-side.", + items: { + type: "object", + required: ["userId", "email", "displayName", "count"], + properties: { + userId: { + type: "string", + description: "NyxID user_id of the author. This is the value to pass to `GET /skill-search?createdByAny=`.", + }, + email: { type: "string", description: "Cached author email. Empty string when the platform has none cached." }, + displayName: { type: "string", description: "Cached display name. Empty string when unknown — fall back to `email`, then `userId`." }, + count: { type: "integer", description: "How many skills in this scope this author owns." }, + }, + }, + }, + }, +}; + +/** `data` payload of `GET /skill-facets/system-services`. */ +const systemServiceFacetPayloadSchema: JsonSchema = { + type: "object", + required: ["items"], + properties: { + items: { + type: "array", + description: "NyxID services with at least one live system skill, sorted by descending count then ascending id. Capped at 100 rows server-side.", + items: { + type: "object", + required: ["id", "slug", "label", "count"], + properties: { + id: { + type: "string", + description: "NyxID service id. Pass to `GET /skill-search?nyxidServiceId=` to restrict results to this service.", + }, + slug: { type: "string", description: "Cached NyxID service slug. Empty string when the tie predates slug caching." }, + label: { type: "string", description: "Human-readable service label; falls back to the slug when unset." }, + count: { type: "integer", description: "Number of system skills tied to this service." }, + }, + }, + }, + }, +}; + +/** `data` payload of `GET /skill-counts`. */ +const skillCountsPayloadSchema: JsonSchema = { + type: "object", + required: ["public", "mine", "sharedWithMe"], + properties: { + public: { type: "integer", description: "Skills with `isPrivate: false`. Always populated, including for anonymous callers." }, + mine: { type: "integer", description: "Skills authored by the caller, private or public. Always `0` for anonymous callers." }, + sharedWithMe: { + type: "integer", + description: + "Private skills the caller can read but did NOT author — granted directly to their user_id or via one of their orgs. Always `0` for anonymous callers.", + }, + }, +}; + +/** `data` payload of `GET /skill-format/rules`. */ +const formatRulesPayloadSchema: JsonSchema = { + type: "object", + required: ["rules"], + properties: { + rules: { + type: "string", + description: + "The full format rulebook as a Markdown document. Stable for a given server build. Suitable for pasting verbatim into an LLM system prompt when generating a skill package.", + }, + }, +}; + +/** `data` payload of `POST /skill-format/validate`. */ +const formatValidationPayloadSchema: JsonSchema = { + type: "object", + required: ["valid", "violations"], + properties: { + valid: { + type: "boolean", + description: "`true` only when `violations` is empty. Do not infer validity from the HTTP status — a rejected package still returns 200.", + }, + violations: { + type: "array", + description: "Every rule the package breaks, in one shot — the endpoint does not stop at the first failure. Empty array when `valid` is `true`.", + items: { + type: "object", + required: ["rule", "message"], + properties: { + rule: { + type: "string", + description: + "Stable machine-readable rule id (e.g. `skill-md-exists`, `skill-md-exact-case`, `folder-name-kebab-case`, `no-readme-md`, `valid-zip`, `unexpected-error`). Branch on this, not on `message`.", + }, + message: { type: "string", description: "Human-readable explanation of what to fix, naming the offending file where applicable." }, + }, + }, + }, + }, +}; + +/** + * Response body of `GET /skill-manifest-schema.json`. The body IS a + * JSON Schema document (draft 2020-12) — this describes the envelope of + * that document, not the frontmatter it validates. + */ +const manifestSchemaDocumentSchema: JsonSchema = { + type: "object", + description: + "A JSON Schema (draft 2020-12) document describing SKILL.md YAML frontmatter. Generated at server boot from the same Zod schema the upload path validates against, so it cannot drift from the runtime validator.", + properties: { + $schema: { type: "string", description: "Dialect identifier — `https://json-schema.org/draft/2020-12/schema`." }, + type: { type: "string", description: "Always `object` — frontmatter is a YAML mapping." }, + properties: { + type: "object", + description: + "Top-level frontmatter fields: `name`, `description`, `version`, `metadata` (with `category`, `output-type`, `runtime`, `runtime-dependency`, `runtime-env-var`, `tool-list`, `tag`, `depends-on`), the optional `license` and `compatibility`, and the optional Claude-ecosystem fields `disable-model-invocation`, `user-invocable`, `allowed-tools`, `model`, `context`, `agent`, `argument-hint`, and `hooks`. The served document is the authoritative list — read it, not this summary, when generating tooling.", + }, + required: { type: "array", items: { type: "string" }, description: "Names of the mandatory frontmatter fields." }, + }, +}; + +// --------------------------------------------------------------------------- +// Shared response header documentation +// --------------------------------------------------------------------------- + +/** RFC 9239 rate-limit headers emitted by `middleware/rateLimit.ts`. */ +const rateLimitHeaders: Record = { + "RateLimit-Limit": { + description: "Requests allowed in the current window (60 per 60s for search).", + schema: { type: "integer" }, + }, + "RateLimit-Remaining": { + description: "Requests left in the current window. Self-throttle when this approaches 0.", + schema: { type: "integer" }, + }, + "RateLimit-Reset": { + description: "Seconds until the window resets and `RateLimit-Remaining` returns to `RateLimit-Limit`.", + schema: { type: "integer" }, + }, +}; + +// --------------------------------------------------------------------------- +// Search +// --------------------------------------------------------------------------- + +function skillSearchPath(): PathMap[string] { + return { + get: { + summary: "Search skills by keyword or LLM-ranked semantic relevance", + description: [ + "The single discovery entry point for the registry. Two modes:", + "", + "- `mode=keyword` (default) — a MongoDB text/regex match over name, description, and tags. Cheap, deterministic, and the only mode available to anonymous callers. An empty `q` returns everything in the requested scope, which makes this the correct way to *list* skills, not just to search them.", + "- `mode=semantic` — loads every skill in scope (after the cheap filters below are applied) and asks an LLM to score each one against `q` from 0–10, returning only positives, ranked. Slower and paid, so reach for it only when keyword matching genuinely fails. Requires a bearer token AND a non-empty `q`; both are rejected with **400** (not 401) when missing.", + "", + "**Visibility.** An anonymous caller is silently collapsed to `scope=public` regardless of what was requested — the response's `searchScope` tells you what actually ran, so read it rather than assuming. An authenticated caller sees exactly what they may read: skills they authored, public skills, and private skills granted to them directly or through one of their NyxID orgs.", + "", + "**Pagination.** Prefer the cursor envelope: read `data.meta.nextCursor` and send it back as `?cursor=`, stopping when `data.meta.hasMore` is `false`. `page`/`totalPages` still work and are still returned, but they are the legacy offset shape and will be sunset. `cursor` wins over `page` when both are sent; `limit` wins over `pageSize`.", + "", + "**Filters.** `tags`, `sharedWithOrgs`, `sharedWithUsers`, and `createdByAny` are comma-separated lists on this endpoint (a deliberate exception to the repeated-key convention elsewhere in the API). Discover legal values for them from `GET /skill-facets/tags`, `GET /skill-facets/authors`, and `GET /skill-facets/system-services` rather than guessing.", + "", + "**Cost control.** Rate limited to 60 requests / 60 seconds, keyed per user (or per trusted proxy hop when anonymous). Every response — success or 429 — carries the RFC 9239 `RateLimit-*` headers, and the 429 additionally carries `Retry-After` (whole seconds until the window resets); a well-behaved agent reads them instead of retrying blind. Only the 200 enumerates the header block below; the 429 emits the same three headers plus `Retry-After`, described on that response.", + ].join("\n"), + operationId: "searchSkills", + tags: ["Search"], + security: optionalAuth(), + parameters: [ + { + ...queryParam( + "q", + "Free-text query. Matched against name, description, and tags in keyword mode; used as the LLM ranking prompt in semantic mode. Omit or leave empty in keyword mode to list everything in scope. Max 2000 characters. Required (non-empty) when `mode=semantic`.", + { type: "string", maxLength: 2000 }, + ), + example: "pdf extraction", + }, + { + ...queryParam( + "query", + "DEPRECATED legacy alias for `q`, kept for un-upgraded SDK clients during the alpha grace window (#586). Ignored whenever `q` is present. New integrations MUST send `q`.", + { type: "string", maxLength: 2000 }, + ), + deprecated: true, + }, + queryParam( + "mode", + "Search strategy. `keyword` (default) is a fast database match. `semantic` runs an LLM re-rank over the whole in-scope corpus — authenticated callers only, non-empty `q` required, and materially slower/costlier.", + { type: "string", enum: ["keyword", "semantic"], default: "keyword" }, + ), + queryParam( + "scope", + "Visibility slice to search. `public` = published skills; `private` = private skills you may read (authored or granted); `mixed` = the union of those two; `mine` = skills you authored regardless of visibility; `shared-with-me` = private skills granted to you that you did NOT author. Defaults to `private`. Anonymous callers are forced to `public`.", + { + type: "string", + enum: ["public", "private", "mixed", "shared-with-me", "mine"], + default: "private", + }, + ), + queryParam( + "page", + "1-indexed page number for legacy offset pagination. Hard-capped at 10000 to bound the underlying `skip()`; anything above that is a 400. Ignored when `cursor` is supplied.", + { type: "integer", minimum: 1, maximum: 10000, default: 1 }, + ), + queryParam( + "pageSize", + "Results per page, 1–100. Defaults to 9 (the registry grid size), which is smaller than most agents want — set it explicitly. Overridden by `limit` when both are present.", + { type: "integer", minimum: 1, maximum: 100, default: 9 }, + ), + { + ...queryParam( + "cursor", + "Opaque pagination token echoed from a previous response's `data.meta.nextCursor`. Takes precedence over `page`; `pageSize`/`limit` still apply and SHOULD be kept identical across a pagination run. A malformed or stale-format token is rejected with 400 `invalid_cursor` rather than silently restarting at page 1. Max 2048 characters.", + { type: "string", maxLength: 2048 }, + ), + example: "eyJwYWdlIjoyfQ", + }, + queryParam( + "limit", + "Canonical alias for `pageSize` per CONVENTIONS.md §4.3, 1–100. When present it overrides `pageSize`. No default — omit it and `pageSize` applies.", + { type: "integer", minimum: 1, maximum: 100 }, + ), + { + ...queryParam( + "model", + "LLM model id used to rank results in `mode=semantic`. Ignored in keyword mode. Omit to use the platform's default playground model; pass an id from `GET /me/models` to pin a specific one.", + { type: "string" }, + ), + example: "gpt-4o-mini", + }, + queryParam( + "systemFilter", + "Tri-state filter on platform system skills (skills tied to an admin-tier NyxID service). `any` (default) keeps both kinds, `only` returns just system skills, `exclude` drops them.", + { type: "string", enum: ["any", "only", "exclude"], default: "any" }, + ), + { + ...queryParam( + "sharedWithOrgs", + "Comma-separated NyxID org user_ids. Keeps only skills granted to at least one of the listed orgs (OR match). Most useful together with `scope=shared-with-me`.", + { type: "string" }, + ), + example: "org_7f3a,org_91bc", + }, + { + ...queryParam( + "sharedWithUsers", + "Comma-separated NyxID user_ids. Keeps only skills carrying a direct grant to at least one of the listed users (OR match).", + { type: "string" }, + ), + example: "usr_2b91,usr_5d40", + }, + { + ...queryParam( + "createdByAny", + "Comma-separated NyxID user_ids. Keeps only skills authored by one of them (OR match). Values come from `GET /skill-facets/authors` → `items[].userId`.", + { type: "string" }, + ), + example: "usr_2b91", + }, + { + ...queryParam( + "nyxidServiceId", + "Restrict to skills bound to this NyxID service. A SINGLE id — this one is not comma-separated. Values come from `GET /skill-facets/system-services` → `items[].id`.", + { type: "string" }, + ), + example: "svc_ornn_core", + }, + { + ...queryParam( + "tags", + "Comma-separated tag list. AND semantics — a skill must carry EVERY listed tag to match (unlike the other CSV filters, which are OR). Values come from `GET /skill-facets/tags` → `items[].name`.", + { type: "string" }, + ), + example: "pdf,extraction", + }, + ], + responses: { + ...jsonResponse(skillSearchPayloadSchema, "A page of matching skills plus the cursor envelope.", { + headers: rateLimitHeaders, + example: { + searchMode: "keyword", + searchScope: "public", + total: 42, + totalPages: 5, + page: 1, + pageSize: 9, + items: [ + { + guid: "550e8400-e29b-41d4-a716-446655440000", + name: "pdf-extract", + description: "Extract text and tables from PDF documents.", + createdBy: "usr_2b91", + createdByEmail: "author@example.com", + createdByDisplayName: "Ada L.", + createdOn: "2026-04-22T10:00:00.000Z", + updatedOn: "2026-07-01T08:12:00.000Z", + isPrivate: false, + tags: ["pdf", "extraction"], + myAccessReason: "public", + isSystemForMe: false, + permissionSummary: { isPrivate: false, sharedUserCount: 0, sharedOrgCount: 0 }, + nyxidServiceId: null, + nyxidServiceSlug: null, + nyxidServiceLabel: null, + isSystemSkill: false, + hasGithubSource: true, + }, + ], + meta: { limit: 9, hasMore: true, nextCursor: "eyJwYWdlIjoyfQ" }, + }, + }), + ...problemResponses( + { + 400: + "Bad request. `invalid_query` — a query parameter failed validation (e.g. `page` above 10000, `pageSize` above 100), with the offending fields named in `detail`. `invalid_cursor` — the `cursor` token is malformed or from an older API revision; restart pagination without it. `QUERY_REQUIRED` — `mode=semantic` was sent without a non-empty `q`. `AUTH_REQUIRED` — `mode=semantic` was sent without a bearer token (deliberately a 400, not a 401: keyword search on the same path is anonymous-friendly).", + }, + { + 429: + "`rate_limited` — more than 60 requests inside the 60-second window for this key (per user, or per trusted proxy hop when anonymous). The response carries the same `RateLimit-Limit` / `RateLimit-Remaining` / `RateLimit-Reset` headers as a success, plus `Retry-After` — an integer count of seconds until the window resets. Wait that long rather than retrying immediately.", + }, + { + 500: + "Internal error. Reachable when the datastore or, in semantic mode, the default-model/settings lookup behind the LLM re-rank fails. Retry with backoff; falling back to `mode=keyword` is usually the better recovery.", + }, + ), + }, + }, + }; +} + +function skillFacetTagsPath(): PathMap[string] { + return { + get: { + summary: "List distinct skill tags visible in a scope", + description: [ + "Returns every tag that appears on at least one skill the caller can see in the given scope, with a per-tag skill count. Use it to populate a filter UI, or — for an agent — to learn the vocabulary the registry actually uses before issuing `GET /skill-search?tags=`; guessing tag names is the most common cause of an empty result set.", + "", + "The values in `items[].name` are exactly what the search endpoint's `tags` filter expects (comma-separated, AND semantics). Rows are ordered by descending count then alphabetically, and the server caps the response at 200 tags — treat it as a popularity-ranked head, not an exhaustive dictionary.", + "", + "Auth is optional but scope-dependent: `public` and `system` work anonymously, while `mine` and `shared-with-me` require a bearer token and return **401** without one. `private` and `mixed` are accepted anonymously and degrade rather than error, but differently: `private` matches nothing, because its whole match is the private-visibility branch and that branch needs an identity; `mixed` returns exactly the `public` result, because its public branch is unconditional and only the private half drops out.", + ].join("\n"), + operationId: "listSkillTagFacets", + tags: ["Search"], + security: optionalAuth(), + parameters: [ + queryParam( + "scope", + "Visibility slice to aggregate over. `public` (default) = published skills; `system` = platform system skills only; `mine` = skills you authored; `shared-with-me` = private skills granted to you that you did not author; `private` / `mixed` = private-you-can-read / the union. Any other value is a 400.", + { + type: "string", + enum: ["public", "private", "mixed", "shared-with-me", "mine", "system"], + default: "public", + }, + ), + ], + responses: { + ...jsonResponse(tagFacetPayloadSchema, "Distinct tags with per-tag skill counts.", { + example: { + items: [ + { name: "pdf", count: 17 }, + { name: "extraction", count: 9 }, + { name: "web-scraping", count: 4 }, + ], + }, + }), + ...problemResponses( + { 400: "`invalid_scope` — the `scope` value is not one of the six accepted values." }, + { 401: "`AUTH_REQUIRED` — `scope=mine` or `scope=shared-with-me` was requested without a bearer token." }, + ), + }, + }, + }; +} + +function skillFacetAuthorsPath(): PathMap[string] { + return { + get: { + summary: "List distinct skill authors visible in a scope", + description: [ + "Returns every author who owns at least one skill the caller can see in the given scope, with a per-author skill count and their cached email / display name for labelling. Feed `items[].userId` back into `GET /skill-search?createdByAny=` to narrow a search to one or more authors.", + "", + "`email` and `displayName` are best-effort caches written when the skill was last published — either may be an empty string for older or externally-imported skills, so render with a fallback chain of `displayName` → `email` → `userId`. Rows are ordered by descending count then by user_id, capped at 200.", + "", + "Accepts a narrower scope set than the tags facet: only `public`, `system`, `mixed`, and `shared-with-me`. `mine` is deliberately absent — every skill in that scope has the same author, so the facet would be a single row. Anything else is a 400. `shared-with-me` requires a bearer token (**401** without one); the rest work anonymously.", + ].join("\n"), + operationId: "listSkillAuthorFacets", + tags: ["Search"], + security: optionalAuth(), + parameters: [ + queryParam( + "scope", + "Visibility slice to aggregate over. `public` (default) = published skills; `system` = platform system skills only; `mixed` = public plus the private skills you may read; `shared-with-me` = private skills granted to you that you did not author. `mine` and `private` are NOT supported here and return 400.", + { + type: "string", + enum: ["public", "shared-with-me", "system", "mixed"], + default: "public", + }, + ), + ], + responses: { + ...jsonResponse(authorFacetPayloadSchema, "Distinct authors with per-author skill counts.", { + example: { + items: [ + { userId: "usr_2b91", email: "author@example.com", displayName: "Ada L.", count: 12 }, + { userId: "usr_5d40", email: "", displayName: "", count: 3 }, + ], + }, + }), + ...problemResponses( + { 400: "`invalid_scope` — the `scope` value is unknown or is one of the values this facet does not support (`mine`, `private`)." }, + { 401: "`AUTH_REQUIRED` — `scope=shared-with-me` was requested without a bearer token." }, + ), + }, + }, + }; +} + +function skillFacetSystemServicesPath(): PathMap[string] { + return { + get: { + summary: "List NyxID services that own platform system skills", + description: [ + "Returns every NyxID service that has at least one system skill bound to it, with a per-service skill count plus the cached slug and label. `items[].id` is exactly what `GET /skill-search?nyxidServiceId=` expects, so this endpoint is the discovery step before filtering search by service.", + "", + "There is no `scope` parameter: system skills are always public, so the aggregation is caller-independent and identical for anonymous and authenticated callers. Auth is accepted but changes nothing.", + "", + "The counts come from a cached snapshot on each skill document, so the server cross-checks the aggregation against NyxID's live active-service set and drops services NyxID has since deactivated (#715). That cross-check is fail-soft: if NyxID is unreachable or the platform token cannot be minted, the endpoint still returns 200 with the *unfiltered* aggregation rather than erroring — so a stale, deactivated service can occasionally appear. Rows are ordered by descending count then by id, capped at 100.", + ].join("\n"), + operationId: "listSystemServiceFacets", + tags: ["Search"], + security: optionalAuth(), + parameters: [], + responses: { + ...jsonResponse( + systemServiceFacetPayloadSchema, + "NyxID services owning system skills, with per-service counts.", + { + example: { + items: [ + { id: "svc_ornn_core", slug: "ornn-core", label: "Ornn Core", count: 8 }, + { id: "svc_nyxid", slug: "nyxid", label: "NyxID", count: 2 }, + ], + }, + }, + ), + // The NyxID cross-check degrades gracefully, but the underlying + // aggregation is a database call and can still fail. + ...problemResponses(500), + }, + }, + }; +} + +function skillCountsPath(): PathMap[string] { + return { + get: { + summary: "Per-scope skill counts for the current caller", + description: [ + "Returns `{ public, mine, sharedWithMe }` in one round-trip — the three registry tab counts — so a client does not have to fire three `GET /skill-search?pageSize=1` calls just to read totals. This is the sanctioned way to get counts: CONVENTIONS.md §4.3 keeps totals out of cursor pagination, and this is the sibling endpoint that carries them.", + "", + "It lives at `/skill-counts` rather than `/skills/counts` on purpose: the latter would be captured by the `GET /skills/{idOrName}` route and resolve `counts` as a skill name.", + "", + "Auth is optional. Anonymous callers get a real `public` count and hard zeros for `mine` and `sharedWithMe` — an identity is required for either to be meaningful, and the server does not error on its absence. The counts use exactly the same visibility rules as `GET /skill-search`, so `public` here always matches `total` from a `scope=public` search with no filters.", + ].join("\n"), + operationId: "getSkillCounts", + tags: ["Search"], + security: optionalAuth(), + parameters: [], + responses: { + ...jsonResponse(skillCountsPayloadSchema, "Skill counts for the three registry scopes.", { + example: { public: 128, mine: 7, sharedWithMe: 3 }, + }), + // Three concurrent aggregate counts against MongoDB. + ...problemResponses(500), + }, + }, + }; +} + +// --------------------------------------------------------------------------- +// Format +// --------------------------------------------------------------------------- + +function formatRulesPath(): PathMap[string] { + return { + get: { + summary: "Get the SKILL.md package format rulebook (Markdown)", + description: [ + "Returns the canonical Ornn skill-package format rules as a single Markdown document: the required folder layout, the case-sensitive `SKILL.md` filename rule, the allowed root entries, and every frontmatter field with its constraints (`name`, `description`, `metadata.category`, `output-type`, `runtime`, `tool-list`, `tag`, `depends-on`, `license`, `compatibility`).", + "", + "This is the prose form of the contract, intended to be dropped verbatim into an LLM system prompt when an agent is *authoring* a package. For programmatic validation of an already-built manifest, use `GET /skill-manifest-schema.json` (machine-readable JSON Schema) instead; to check a finished ZIP, use `POST /skill-format/validate`.", + "", + "Public, no auth, and the content is static for a given server build — cache it per deployment rather than fetching it before every generation.", + ].join("\n"), + operationId: "getFormatRules", + tags: ["Format"], + security: publicAuth(), + parameters: [], + responses: { + ...jsonResponse(formatRulesPayloadSchema, "The format rulebook as Markdown.", { + example: { rules: "# Ornn Skill Package Format Rules\n\n## Package Structure\n..." }, + }), + }, + }, + }; +} + +function manifestSchemaPath(): PathMap[string] { + return { + get: { + summary: "Get the JSON Schema for SKILL.md frontmatter", + description: [ + "Publishes the canonical JSON Schema (draft 2020-12) for `SKILL.md` YAML frontmatter, generated at server boot from the same Zod schema the upload path validates against — so the published schema cannot drift from the runtime validator.", + "", + "**This response is NOT enveloped.** The schema document sits at the body root with `Content-Type: application/schema+json`, because the consumers (VS Code, Cursor, JetBrains, schemastore.org) expect a bare JSON Schema. Every other endpoint in this API returns `{ data, error }`; this one deliberately does not. Do not send it through your generic envelope-unwrapping client.", + "", + "Public, no auth, and served with `Cache-Control: public, max-age=3600`. Skill authors should point their YAML language server at this URL with a `# yaml-language-server: $schema=...` comment to get autocomplete and inline validation while writing SKILL.md. The frontmatter contract carries a manually-bumped revision (`SKILL_MANIFEST_SCHEMA_VERSION`, currently `1`) that is not yet encoded in the URL — re-fetch on a finite TTL rather than pinning forever.", + ].join("\n"), + operationId: "getFormatSchema", + tags: ["Format"], + security: publicAuth(), + parameters: [], + responses: { + ...rawJsonResponse(manifestSchemaDocumentSchema, "The SKILL.md frontmatter JSON Schema document, un-enveloped.", { + mediaType: "application/schema+json", + headers: { + "Cache-Control": { + description: "Always `public, max-age=3600`. Honour it — the document only changes on deploy.", + schema: { type: "string" }, + }, + }, + }), + }, + }, + }; +} + +function formatValidatePath(): PathMap[string] { + return { + post: { + summary: "Validate a skill package ZIP against the format rules", + description: [ + "Pre-flight check for a built skill package. POST the raw ZIP bytes and get back every format rule the package breaks, in one round-trip — the validator does not stop at the first failure, so an agent can fix the whole list before retrying. Run this before `POST /skills`; the upload path applies the identical rules and will reject the package with a 400 otherwise.", + "", + "**Read `data.valid`, not the HTTP status.** A package that fails validation still returns **200** with `valid: false` and a populated `violations[]` — validation failure is a successful validation *call*, and the 4xx responses below are about the *request*, never about the package contents. An internal error while walking the archive is likewise reported in-band as a single `unexpected-error` violation on a 200.", + "", + "**Body.** Raw binary ZIP — not multipart, not base64, no form field wrapper. `Content-Type` must be `application/zip` or `application/octet-stream`; anything else is rejected with a 400 (`invalid_content_type`) before the body is read, and an empty body is a 400 (`empty_body`).", + "", + "**Limits.** The same zip-bomb guards as the publish path run before any format checking: cumulative uncompressed size, per-entry uncompressed size, entry count, and compression ratio (defaults 50 MiB / 25 MiB / 1000 entries / 100×, all env-tunable per deployment). Tripping one is a **413**. Because the guard opens the archive first, a buffer that is not a parseable ZIP fails there with a **400** `invalid_zip` — only a ZIP that *parses* can reach the 200-with-violations path.", + "", + "Requires a bearer token carrying the `ornn:skill:read` NyxID scope.", + ].join("\n"), + operationId: "validateFormat", + tags: ["Format"], + security: bearerAuth(), + parameters: [], + requestBody: { + required: true, + description: + "Raw ZIP bytes of the skill package. Send as `application/zip` (preferred) or `application/octet-stream`. The archive may be either the package folder at the root or its contents at the root — the validator resolves both.", + content: { + "application/zip": { schema: { type: "string", format: "binary" } }, + "application/octet-stream": { schema: { type: "string", format: "binary" } }, + }, + }, + responses: { + ...jsonResponse( + formatValidationPayloadSchema, + "Validation ran. Inspect `data.valid` — a rejected package is reported here, not as an error status.", + { + example: { + valid: false, + violations: [ + { rule: "skill-md-exact-case", message: 'Found "skill.md" but the file must be exactly "SKILL.md".' }, + { rule: "no-readme-md", message: "The package root must not contain README.md." }, + ], + }, + }, + ), + ...problemResponses( + { + 400: + "Bad request. `invalid_content_type` — `Content-Type` was neither `application/zip` nor `application/octet-stream`. `empty_body` — a zero-length body was sent. `invalid_zip` — the bytes are not a parseable ZIP archive (raised by the zip-bomb guard before format checking).", + }, + 401, + { 403: "`forbidden` — the token is valid but lacks the `ornn:skill:read` scope." }, + { + 413: + "The archive trips a zip-bomb guard: `uncompressed_too_large` (cumulative or per-entry uncompressed size, or a compression ratio above the cap) or `too_many_files` (entry count above the cap). Caps are deployment-configured; the `detail` field states the actual limit that was hit.", + }, + ), + }, + }, + }; +} + +// --------------------------------------------------------------------------- +// Export +// --------------------------------------------------------------------------- + +/** + * All search / facet / format paths, keyed by full path including the + * `/api/v1` prefix. + */ +export function searchFormatPaths(prefix: string): PathMap { + return { + [`${prefix}/skill-search`]: skillSearchPath(), + [`${prefix}/skill-facets/tags`]: skillFacetTagsPath(), + [`${prefix}/skill-facets/authors`]: skillFacetAuthorsPath(), + [`${prefix}/skill-facets/system-services`]: skillFacetSystemServicesPath(), + [`${prefix}/skill-counts`]: skillCountsPath(), + [`${prefix}/skill-format/rules`]: formatRulesPath(), + [`${prefix}/skill-manifest-schema.json`]: manifestSchemaPath(), + [`${prefix}/skill-format/validate`]: formatValidatePath(), + }; +} diff --git a/ornn-api/src/openapi/paths/skillsCrud.ts b/ornn-api/src/openapi/paths/skillsCrud.ts new file mode 100644 index 00000000..c48d281f --- /dev/null +++ b/ornn-api/src/openapi/paths/skillsCrud.ts @@ -0,0 +1,1742 @@ +/** + * OpenAPI paths for the **Skills CRUD & versioning** domain (#1214). + * + * Covers everything an agent needs to own a skill's life-cycle on the + * registry, mirroring `domains/skills/crud/routes.ts` one-for-one: + * + * - **Publish** — `POST /skills` (ZIP upload) and `POST /skills/pull` + * (create straight from a public GitHub folder, keeping a one-way + * GitHub → Ornn link). + * - **Source link** — `PUT /skills/{id}/source` attaches or clears that + * link on an already-uploaded skill; `POST /skills/{id}/refresh` + * re-pulls it (with a `dryRun` preview mode). + * - **Read** — `GET /skills/{idOrName}` (metadata), + * `GET /skills/{idOrName}/json` (every file's text content — the + * agent-preferred read), `.../versions/{version}/download` (raw ZIP + * bytes), and `GET /skills/{idOrName}/closure` (transitive dependency + * graph, deps-first topological order). + * - **Versioning** — immutable `.` versions: list, diff two + * of them, deprecate one, delete a non-latest one, and move npm-style + * dist-tags (`stable`, `beta`, …) around. `latest` is auto-managed. + * - **Governance** — replace the typed grant ACL, transfer ownership, + * bind the skill to a NyxID catalog service, delete the skill. + * + * Two identifier rules run through the whole domain and are worth learning + * once (CONVENTIONS.md §2.2): + * + * - **Reads** accept `{idOrName}` — either the stable GUID or the + * globally-unique skill name. + * - **Writes** accept `{id}` only — the stable GUID. There is no + * polymorphic name resolution on a mutation, so resolve the name via a + * read first if all you have is a name. + * + * That split is why this module emits `` `/skills/{idOrName}` `` (get) and + * `` `/skills/{id}` `` (put, delete) as two separate path items. It is a + * deliberate deviation, not an oversight: OAS 3.1's Paths Object says two + * templated paths of the same hierarchy that differ only in the template + * variable's *name* are identical and MUST NOT both exist, so strict + * validators and some generators flag the pair as a duplicate entry for + * `/api/v1/skills/{*}`. The collision is inherited from the router — Hono + * registers `/skills/:idOrName` for the read and `/skills/:id` for the two + * writes (`domains/skills/crud/routes.ts`) — and + * `tests/contract/openapiRoutes.test.ts` reflects the booted router against + * this table by rewriting `{x}` to `:x`, so the spec cannot rename the + * variable on its own. Merging the two path items is therefore a router + * change, not a documentation one. `openapi/paths/skillsets.ts` carries the + * same pair for the same reason. + * + * Visibility is uniform too: a private skill the caller cannot read answers + * **404, never 403**, so existence is never leaked. + * + * @module openapi/paths/skillsCrud + */ + +import { + bearerAuth, + binaryResponse, + jsonBody, + jsonResponse, + optionalAuth, + pathParam, + problemResponses, + queryParam, + toSchema, + type JsonSchema, + type PathMap, +} from "../helpers"; +import { skillGrantSchema } from "../../domains/skills/crud/grants"; + +// --------------------------------------------------------------------------- +// Shared schema fragments +// +// The skill-detail wire shape lives in `shared/types/index.ts` as a +// TypeScript interface, not a Zod schema — nothing validates it at runtime, +// so there is no Zod source to reuse here and these are hand-written to +// match `SkillService.buildDetailResponse` field-for-field. The one shape +// that DOES have a canonical Zod schema (`skillGrantSchema`) is imported. +// --------------------------------------------------------------------------- + +/** One typed ACL entry. Canonical Zod schema, reused verbatim (#1123). */ +const grantSchema: JsonSchema = { + ...toSchema(skillGrantSchema), + description: + "One access grant. `type: \"user\"` targets a NyxID person user_id; `type: \"org\"` targets a NyxID org user_id and every admin/member of that org inherits the grant. `level: \"read\"` allows view/pull/execute; `level: \"write\"` additionally allows publishing new versions. `write` never confers admin rights (permissions, transfer, delete) — those stay with the owner and platform admins.", +}; + +/** `metadata` block parsed out of SKILL.md frontmatter. */ +const skillMetadataSchema: JsonSchema = { + type: "object", + description: + "Structured metadata extracted from the resolved version's SKILL.md YAML frontmatter. Additional keys may appear over time — treat this object as open.", + properties: { + category: { + type: "string", + description: + "Execution model: `plain` (prompt only), `tool-based` (calls MCP/builtin tools), `runtime-based` (runs code in a sandbox), or `mixed`.", + example: "runtime-based", + }, + outputType: { + type: "string", + enum: ["text", "file"], + description: "`text` returns stdout; `file` returns generated files collected from the sandbox.", + }, + runtimes: { + type: "array", + description: "Sandbox runtimes this skill needs, with their dependencies and required env vars.", + items: { + type: "object", + properties: { + runtime: { type: "string", description: "Runtime id — `node` or `python`.", example: "python" }, + dependencies: { + type: "array", + items: { + type: "object", + properties: { + library: { type: "string", description: "Package name.", example: "pypdf" }, + version: { type: "string", description: "Version constraint, `*` when unpinned.", example: "*" }, + }, + }, + }, + envs: { + type: "array", + description: "Environment variables the caller must supply at execution time.", + items: { + type: "object", + properties: { + var: { type: "string", description: "Variable name.", example: "OPENAI_API_KEY" }, + description: { type: "string", description: "What the value is used for." }, + }, + }, + }, + }, + }, + }, + tools: { + type: "array", + description: "External tools the skill invokes during LLM execution.", + items: { + type: "object", + properties: { + tool: { type: "string", description: "Tool identifier as referenced in the prompt." }, + type: { type: "string", description: "`builtin` (platform-provided) or `mcp` (from an MCP server)." }, + "mcp-servers": { + type: "array", + items: { + type: "object", + properties: { + mcp: { type: "string", description: "MCP server package name." }, + version: { type: "string", description: "MCP server version." }, + }, + }, + }, + }, + }, + }, + tags: { + type: "array", + items: { type: "string" }, + description: "Classification tags, also surfaced as the top-level `tags` array.", + }, + dependsOn: { + type: "array", + items: { type: "string" }, + description: + "Direct skill dependencies (#968), each `@` or `@`. No semver ranges. Resolve the full transitive set with `GET /skills/{idOrName}/closure`.", + example: ["pdf-tools@1.0"], + }, + }, +}; + +/** GitHub origin pointer carried by pulled / linked skills. */ +const skillSourceSchema: JsonSchema = { + type: "object", + description: + "Present when the skill is linked to an upstream GitHub folder. `lastSyncedAt` / `lastSyncedCommit` are absent in the 'linked but never refreshed' state.", + required: ["type", "repo", "ref", "path"], + properties: { + type: { type: "string", enum: ["github"], description: "Source kind. Only `github` exists today." }, + repo: { type: "string", description: "`owner/name`.", example: "ChronoAIProject/ornn-skills" }, + ref: { type: "string", description: "Branch, tag, or commit SHA the folder is read from.", example: "main" }, + path: { + type: "string", + description: "Folder inside the repo holding SKILL.md. Empty string means the repo root.", + example: "skills/pdf-extract", + }, + lastSyncedAt: { type: "string", format: "date-time", description: "ISO 8601 time of the last successful refresh." }, + lastSyncedCommit: { type: "string", description: "Commit SHA the last refresh pulled." }, + upstreamHeadSha: { type: "string", description: "Upstream HEAD observed by the last background drift check." }, + lastCheckedAt: { type: "string", format: "date-time", description: "ISO 8601 time of the last drift check." }, + driftState: { + type: "string", + enum: ["in_sync", "drifted", "changed_unversioned", "broken"], + description: + "Cached drift verdict. `in_sync` — upstream matches. `drifted` — upstream moved and declares a higher version. `changed_unversioned` — upstream changed but SKILL.md's version did not (a refresh would 409). `broken` — upstream folder no longer fetchable.", + }, + }, +}; + +/** AgentSeal advisory trust scan attached to the resolved version. */ +const agentsealScanSchema: JsonSchema = { + type: ["object", "null"], + description: + "Advisory AgentSeal security scan for the resolved version (#253). Null when the version has not been scanned (legacy rows, or the scanner was disabled). Warn-only — a low score never blocks publish or execution.", + properties: { + score: { type: "integer", description: "0–100, severity-weighted. Higher is safer.", example: 92 }, + findings: { + type: "array", + items: { type: "object" }, + description: "Per-file findings from the scan sweep. Shape is scanner-defined; treat entries as opaque objects.", + }, + scannedAt: { type: "string", format: "date-time", description: "ISO 8601 completion time." }, + agentsealVersion: { type: "string", description: "Pinned AgentSeal package version that produced this record." }, + scannedFiles: { type: "integer", description: "How many files were scanned. Absent on older records." }, + }, +}; + +/** + * The canonical skill representation. Returned by every read and by every + * mutation that leaves a skill behind (create, update, refresh, source + * link, permissions, transfer, NyxID bind). + */ +const skillDetailSchema: JsonSchema = { + type: "object", + description: + "Full skill representation at one resolved version. Identity fields (`guid`, `name`, `isPrivate`, `createdBy`, ACL) come from the skill document; package fields (`metadata`, `skillHash`, `license`, `compatibility`, `version`, deprecation, `agentsealScan`) come from the resolved version — so the same skill read at `?version=1.0` and `?version=2.0` differs only in those.", + required: [ + "guid", + "name", + "description", + "license", + "compatibility", + "metadata", + "tags", + "skillHash", + "isPrivate", + "createdBy", + "createdOn", + "updatedOn", + "sharedWithUsers", + "sharedWithOrgs", + "version", + ], + properties: { + guid: { + type: "string", + format: "uuid", + description: "Stable identifier. The only accepted id on write operations.", + example: "550e8400-e29b-41d4-a716-446655440000", + }, + name: { + type: "string", + description: + "Globally unique skill name, read from SKILL.md frontmatter at publish time. Usable in place of the GUID on read paths.", + example: "pdf-extract", + }, + description: { type: "string", description: "One-paragraph summary of what the skill does and when to use it." }, + license: { type: ["string", "null"], description: "SPDX identifier, or null when unspecified.", example: "MIT" }, + compatibility: { + type: ["string", "null"], + description: "Model/platform the author targeted, or null when model-agnostic.", + example: null, + }, + metadata: skillMetadataSchema, + tags: { + type: "array", + items: { type: "string" }, + description: "Convenience copy of `metadata.tags`.", + example: ["pdf", "extraction"], + }, + skillHash: { + type: "string", + description: "SHA-256 (hex) of the resolved version's ZIP bytes. Changes on every publish.", + }, + isPrivate: { + type: "boolean", + description: + "True ⇒ visible only to the owner, platform admins, and grantees. Newly created skills are ALWAYS private; flip via `PUT /skills/{id}/permissions`.", + }, + createdBy: { type: "string", description: "NyxID person user_id of the current owner." }, + createdByEmail: { type: "string", description: "Cached owner email. Absent when never resolved." }, + createdByDisplayName: { type: "string", description: "Cached owner display name. Absent when never resolved." }, + createdOn: { type: "string", format: "date-time", description: "ISO 8601 creation time of the skill." }, + updatedOn: { type: "string", format: "date-time", description: "ISO 8601 time of the most recent mutation." }, + sharedWithUsers: { + type: "array", + items: { type: "string" }, + description: + "Legacy read-only allow-list of person user_ids, kept in lockstep with `grants` for pre-#1123 clients. Prefer `grants`.", + }, + sharedWithOrgs: { + type: "array", + items: { type: "string" }, + description: "Legacy read-only allow-list of org user_ids. Prefer `grants`.", + }, + grants: { + type: "array", + items: grantSchema, + description: + "Canonical typed ACL. Always populated on responses — an un-migrated skill has its legacy lists projected here as `read` grants, so you can rely on this field alone.", + }, + version: { + type: "string", + description: + "The version this payload describes: the requested `?version=` when supplied, otherwise the current latest.", + example: "1.2", + }, + isDeprecated: { type: "boolean", description: "True when the author deprecated this specific version." }, + deprecationNote: { type: ["string", "null"], description: "Author's explanation for the deprecation." }, + source: skillSourceSchema, + nyxidServiceId: { type: ["string", "null"], description: "NyxID catalog service this skill is tied to, or null." }, + nyxidServiceSlug: { type: ["string", "null"], description: "Cached slug of the tied service." }, + nyxidServiceLabel: { type: ["string", "null"], description: "Cached human label of the tied service." }, + isSystemSkill: { + type: "boolean", + description: + "True when tied to an admin/platform NyxID service. System skills are forced public and cannot be flipped private without untying first.", + }, + agentsealScan: agentsealScanSchema, + mirrorSync: { + type: "object", + description: + "GitHub-mirror state for public skills. Absent ⇒ never mirrored. `mirrorSync.version === version` ⇒ mirror is current; otherwise a push is still pending.", + properties: { + version: { type: "string", description: "Version last committed to the mirror." }, + syncedAt: { type: "string", format: "date-time", description: "ISO 8601 time of that commit." }, + commitSha: { type: "string", description: "Mirror commit SHA, suitable for an audit link." }, + }, + }, + distTags: { + type: "object", + additionalProperties: { type: "string" }, + description: + "npm-style tag → version map. `latest` is always present and auto-managed on publish; custom tags are owner-managed via the dist-tag endpoints.", + example: { latest: "1.2", stable: "1.0" }, + }, + }, +}; + +/** Compact, realistic `data` example reused across the skill-returning ops. */ +const SKILL_DETAIL_EXAMPLE = { + guid: "550e8400-e29b-41d4-a716-446655440000", + name: "pdf-extract", + description: "Extract text and tables from a PDF into structured JSON.", + license: "MIT", + compatibility: null, + metadata: { category: "runtime-based", outputType: "text", tags: ["pdf", "extraction"] }, + tags: ["pdf", "extraction"], + skillHash: "9f2c1b8a4d5e6f70819a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f70", + isPrivate: false, + createdBy: "usr_01HQ8Z3K5N", + createdByEmail: "author@example.com", + createdByDisplayName: "Ada Lovelace", + createdOn: "2026-06-01T09:14:22.000Z", + updatedOn: "2026-07-18T16:02:05.000Z", + sharedWithUsers: [], + sharedWithOrgs: [], + grants: [], + version: "1.2", + isDeprecated: false, + deprecationNote: null, + nyxidServiceId: null, + nyxidServiceSlug: null, + nyxidServiceLabel: null, + isSystemSkill: false, + agentsealScan: null, + distTags: { latest: "1.2" }, +}; + +/** One entry of `GET /skills/{idOrName}/versions`. */ +const versionListItemSchema: JsonSchema = { + type: "object", + required: ["version", "skillHash", "integrity", "createdBy", "createdOn", "isDeprecated", "deprecationNote", "releaseNotes"], + properties: { + version: { type: "string", description: "`.` label.", example: "1.2" }, + skillHash: { type: "string", description: "SHA-256 (hex) of this version's ZIP bytes." }, + integrity: { + type: "string", + description: + "npm-style Subresource Integrity string, `sha256-`. Verify a downloaded package against this before installing.", + example: "sha256-nywbik1eb3CBmis8TV5vcIGSo7TF1uf4CRorPE1eb3A=", + }, + createdBy: { type: "string", description: "NyxID person user_id that published this version." }, + createdByEmail: { type: "string", description: "Cached publisher email. Absent when never resolved." }, + createdByDisplayName: { type: "string", description: "Cached publisher display name. Absent when never resolved." }, + createdOn: { type: "string", format: "date-time", description: "ISO 8601 publish time." }, + isDeprecated: { type: "boolean", description: "True when this version is deprecated. Deprecation only warns — the version stays downloadable and can still be `latest`." }, + deprecationNote: { type: ["string", "null"], description: "Author's deprecation note, or null." }, + releaseNotes: { + type: ["string", "null"], + description: "Author-supplied changelog read from SKILL.md frontmatter (`release-notes`). Max 2000 chars. Null when omitted.", + }, + }, +}; + +/** Structured file-level diff between two package ZIPs. */ +const versionDiffSchema: JsonSchema = { + type: "object", + required: ["files"], + description: + "File-level diff. Text files carry both sides' contents (truncated at 64 KiB per side) so a client can render a line-level diff without another fetch; binary files carry hashes and byte counts only.", + properties: { + files: { + type: "object", + required: ["added", "removed", "modified", "unchangedCount"], + properties: { + added: { + type: "array", + description: "Files present only in the `to` version.", + items: { + type: "object", + required: ["path", "bytes", "hash", "isText"], + properties: { + path: { type: "string", description: "Package-relative path, e.g. `scripts/run.py`." }, + bytes: { type: "integer", description: "Uncompressed size in bytes." }, + hash: { type: "string", description: "SHA-256 (hex) of the file contents." }, + content: { type: "string", description: "Text contents. Absent for binary files." }, + truncated: { type: "boolean", description: "True when `content` was cut at the size cap." }, + isText: { type: "boolean", description: "Whether the file was treated as text." }, + }, + }, + }, + removed: { + type: "array", + description: "Files present only in the `from` version.", + items: { + type: "object", + required: ["path", "bytes", "hash", "isText"], + properties: { + path: { type: "string", description: "Package-relative path." }, + bytes: { type: "integer", description: "Uncompressed size in bytes." }, + hash: { type: "string", description: "SHA-256 (hex) of the file contents." }, + content: { type: "string", description: "Text contents. Absent for binary files." }, + truncated: { type: "boolean", description: "True when `content` was cut at the size cap." }, + isText: { type: "boolean", description: "Whether the file was treated as text." }, + }, + }, + }, + modified: { + type: "array", + description: "Files present in both versions with differing contents.", + items: { + type: "object", + required: ["path", "fromBytes", "toBytes", "fromHash", "toHash", "isText"], + properties: { + path: { type: "string", description: "Package-relative path." }, + fromBytes: { type: "integer", description: "Size in the `from` version." }, + toBytes: { type: "integer", description: "Size in the `to` version." }, + fromHash: { type: "string", description: "SHA-256 (hex) in the `from` version." }, + toHash: { type: "string", description: "SHA-256 (hex) in the `to` version." }, + isText: { type: "boolean", description: "Whether the file was treated as text." }, + fromContent: { type: "string", description: "Text contents of the `from` side. Absent for binary." }, + toContent: { type: "string", description: "Text contents of the `to` side. Absent for binary." }, + truncated: { type: "boolean", description: "True when either side was cut at the size cap." }, + }, + }, + }, + unchangedCount: { + type: "integer", + description: "How many files are byte-identical in both versions. Count only — no per-file detail.", + }, + }, + }, + }, +}; + +/** Per-side version summary carried alongside a diff. */ +const diffSideSchema: JsonSchema = { + type: "object", + required: ["version", "hash", "createdOn", "isDeprecated", "releaseNotes"], + properties: { + version: { type: "string", description: "`.` label of this side." }, + hash: { type: "string", description: "SHA-256 (hex) of this side's package." }, + createdOn: { type: "string", format: "date-time", description: "ISO 8601 publish time of this side." }, + isDeprecated: { type: "boolean", description: "Whether this side is deprecated." }, + releaseNotes: { type: ["string", "null"], description: "Release notes for this side, or null." }, + }, +}; + +/** Minimal `{ guid, name }` identity block used by diff / refresh-preview. */ +const skillRefSchema: JsonSchema = { + type: "object", + required: ["guid", "name"], + description: "Identity of the skill the payload is about.", + properties: { + guid: { type: "string", format: "uuid", description: "Skill GUID." }, + name: { type: "string", description: "Skill name." }, + }, +}; + +/** One node of a resolved dependency closure. */ +const closureNodeSchema: JsonSchema = { + type: "object", + required: ["ref", "name", "version", "depth"], + properties: { + ref: { + type: "string", + description: "Canonical `@` ref this node resolved to. Aliases (`@beta`) collapse onto it.", + example: "pdf-tools@1.0", + }, + name: { type: "string", description: "Dependency skill name.", example: "pdf-tools" }, + version: { type: "string", description: "Concrete resolved version.", example: "1.0" }, + guid: { type: "string", format: "uuid", description: "Dependency skill GUID, when the loader resolved one." }, + skillHash: { type: "string", description: "SHA-256 (hex) of that version's package, for integrity pinning." }, + depth: { + type: "integer", + description: + "0 for the skill's direct dependencies; deeper for transitive ones. A node reachable by several paths reports the MAXIMUM depth.", + example: 1, + }, + }, +}; + +/** `{ success: true }` acknowledgement returned by the delete endpoints. */ +const successAckSchema: JsonSchema = { + type: "object", + required: ["success"], + description: "Deletion acknowledgement. `success` is always `true` — failures surface as an RFC 7807 error instead.", + properties: { success: { type: "boolean", description: "Always `true`." } }, +}; + +/** `{ tags }` payload shared by the three dist-tag endpoints. */ +const distTagsPayloadSchema: JsonSchema = { + type: "object", + required: ["tags"], + properties: { + tags: { + type: "object", + additionalProperties: { type: "string" }, + description: + "Complete tag → version map AFTER the operation. `latest` is always present (synthesized from the skill's latest-version pointer for skills that predate dist-tags).", + example: { latest: "2.0", stable: "1.4", beta: "2.0" }, + }, + }, +}; + +/** `{ skill }` wrapper used by the governance endpoints. */ +const skillWrapperSchema: JsonSchema = { + type: "object", + required: ["skill"], + description: "The refreshed skill after the change. Wrapped in a `skill` key so future sibling fields can be added without a breaking change.", + properties: { skill: skillDetailSchema }, +}; + +// --------------------------------------------------------------------------- +// Shared parameters +// --------------------------------------------------------------------------- + +const idOrNameParam = pathParam( + "idOrName", + "Skill GUID **or** globally unique skill name. Reads accept either; writes accept the GUID only. A private skill the caller cannot read answers 404, not 403.", + { type: "string" }, + "pdf-extract", +); + +const skillIdParam = pathParam( + "id", + "Skill GUID. Write operations do NOT resolve names (CONVENTIONS.md §2.2) — read the skill first if you only have its name.", + { type: "string", format: "uuid" }, + "550e8400-e29b-41d4-a716-446655440000", +); + +const versionQueryParam = queryParam( + "version", + "Which version to resolve. Either a literal `.` (e.g. `1.2`) or a dist-tag prefixed with `@` (e.g. `@stable`). **The `@` is required for tags** — a bare `stable` is parsed as a literal version and rejected with 400 `invalid_version`. Omit for the current latest.", + { type: "string", examples: ["1.2", "@stable"] }, +); + +/** + * `skip_validation` is snake_case here because that is what the handlers + * actually read (`c.req.query("skip_validation")`), predating the + * camelCase convention in CONVENTIONS.md §4.1. + */ +const skipValidationQueryParam = queryParam( + "skip_validation", + "Set to `true` to bypass Ornn's package-format and SKILL.md frontmatter validation — the escape hatch for importing third-party packages that do not follow Ornn's frontmatter schema. Defaults to `false`. It never disables the zip-bomb guards (size / entry-count / compression-ratio caps still apply) and YAML that cannot be parsed at all still fails.", + { type: "boolean", default: false }, +); + +// --------------------------------------------------------------------------- +// Paths +// --------------------------------------------------------------------------- + +/** + * Build the Skills CRUD & versioning path table. + * + * @param prefix API mount prefix, `/api/v1`. Path keys are built as + * `` `${prefix}/skills/{id}` `` so the spec and the booted router agree + * (asserted by `tests/contract/openapiRoutes.test.ts`). + */ +export function skillsCrudPaths(prefix: string): PathMap { + return { + // ----------------------------------------------------------------------- + // Publish + // ----------------------------------------------------------------------- + + [`${prefix}/skills`]: { + post: { + summary: "Publish a new skill from a ZIP package", + description: + "Create a brand-new skill by uploading its packaged ZIP as the raw request body (no multipart wrapper — send the bytes directly with `Content-Type: application/zip`). The archive MUST contain a `SKILL.md` whose YAML frontmatter declares at least `name`, `description`, `version` (`.`) and `metadata.category`; everything else in the archive (scripts, templates, reference data) is carried along verbatim.\n\nThe name in the frontmatter becomes the skill's globally unique name, so a collision with an existing skill fails with 409 `skill_name_exists` — this endpoint never overwrites. To publish a NEW VERSION of a skill you already own, use `PUT /skills/{id}` instead. To create from a public GitHub folder without building a ZIP, use `POST /skills/pull`.\n\nThe skill is created **private** with an empty ACL regardless of anything in the package; make it public or share it afterwards via `PUT /skills/{id}/permissions`. Declared `metadata.depends-on` refs are resolved before anything is written, so a missing dependency, a cycle, or two versions of the same dependency fails the publish rather than landing a broken skill.\n\nRequires the `ornn:skill:create` request scope. Rate-limited to 10 uploads per minute per user.", + operationId: "createSkill", + tags: ["Skills"], + security: bearerAuth(), + parameters: [skipValidationQueryParam], + requestBody: { + required: true, + description: + "Raw ZIP bytes of the skill package. Must contain `SKILL.md` at the package root (a single top-level wrapper folder is tolerated and stripped). Rejected with 413 when it exceeds the server's configured max upload size, its uncompressed size / compression ratio trips the zip-bomb guard, or it holds more entries than `MAX_PACKAGE_FILE_COUNT`.", + content: { + "application/zip": { schema: { type: "string", format: "binary" } }, + "application/octet-stream": { schema: { type: "string", format: "binary" } }, + }, + }, + responses: { + ...jsonResponse(skillDetailSchema, "Skill created. The body is the freshly published skill at version 1 of its history.", { + status: 201, + example: { ...SKILL_DETAIL_EXAMPLE, isPrivate: true, version: "1.0", distTags: { latest: "1.0" } }, + headers: { + Location: { + description: "Canonical URL of the created skill.", + schema: { type: "string", example: "/api/v1/skills/550e8400-e29b-41d4-a716-446655440000" }, + }, + }, + }), + ...problemResponses( + { + 400: "Bad request. `invalid_content_type` — `Content-Type` was neither `application/zip` nor `application/octet-stream`. `empty_body` — zero-length body. `missing_skill_md` / `missing_frontmatter` / `INVALID_FRONTMATTER` / `frontmatter_validation_failed` — the package's SKILL.md is missing or malformed. `validation_failed` — package-format rules were violated (retry with `skip_validation=true` if importing a third-party package). `reserved_name` — the declared name collides with a reserved action verb. `invalid_version` — the frontmatter `version` is not `.`.", + }, + 401, + { 403: "Forbidden — the token lacks the `ornn:skill:create` scope." }, + { 404: "Not found — `skill_dependency_not_found`: a ref in `metadata.depends-on` does not resolve to a version you can read." }, + { + 409: "Conflict. `skill_name_exists` — a skill with this name already exists (names are global). `dependency_cycle` / `dependency_conflict` — the declared dependency graph loops or pins one skill to two versions.", + }, + { + 413: "Payload too large. `payload_too_large` — the compressed body exceeds the configured max. `uncompressed_too_large` / `too_many_files` — the zip-bomb guard rejected the archive.", + }, + { 429: "Rate limited — more than 10 uploads in the last minute for this user. Back off and retry." }, + { 500: "Internal error — the package could not be written to object storage." }, + ), + }, + }, + }, + + [`${prefix}/skills/pull`]: { + post: { + summary: "Publish a new skill by pulling a public GitHub folder", + description: + "Create a skill straight from a public GitHub repository folder — no local packaging step. Ornn fetches the folder, builds the ZIP server-side, and publishes it exactly as `POST /skills` would, with identical validation, naming, and dependency rules.\n\nIdentify the source either with `githubUrl` (paste the browser URL; Ornn parses repo, ref, and path out of it) or with the explicit `repo` / `ref` / `path` triple. Supply at least one of the two — a body carrying neither is rejected at validation.\n\nUnlike a ZIP upload this records a durable one-way link GitHub → Ornn on the skill. That link powers `POST /skills/{id}/refresh` (re-pull and publish the upstream changes as a new version) and the background drift check that populates `source.driftState`. As with every create path, the skill starts private.\n\nRequires the `ornn:skill:create` request scope.", + operationId: "pullSkillFromGitHub", + tags: ["Skills"], + security: bearerAuth(), + parameters: [], + requestBody: jsonBody( + { + type: "object", + description: "Provide `githubUrl` (preferred) OR `repo`. Supplying `githubUrl` makes `repo` / `ref` / `path` redundant — they are ignored.", + properties: { + githubUrl: { + type: "string", + minLength: 1, + description: + "A GitHub URL as copied from the browser. `https://github.com//`, `.../tree/`, and `.../tree//` are all understood. Rejected with 400 `invalid_github_url` when it cannot be parsed.", + example: "https://github.com/ChronoAIProject/ornn-skills/tree/main/skills/pdf-extract", + }, + repo: { + type: "string", + minLength: 1, + description: "Explicit `owner/name`. Use instead of `githubUrl` when you already have the parts.", + example: "ChronoAIProject/ornn-skills", + }, + ref: { + type: "string", + description: "Branch, tag, or commit SHA. Defaults to the repository's default branch.", + example: "main", + }, + path: { + type: "string", + description: "Folder inside the repo that contains SKILL.md. Defaults to the repository root.", + example: "skills/pdf-extract", + }, + skip_validation: { + type: "boolean", + default: false, + description: + "Same escape hatch as the `skip_validation` query parameter on `POST /skills`: bypass package-format and frontmatter validation for third-party packages. Sent in the BODY here, not the query string.", + }, + }, + }, + "GitHub source to pull from. At least one of `githubUrl` / `repo` is required.", + { + example: { + githubUrl: "https://github.com/ChronoAIProject/ornn-skills/tree/main/skills/pdf-extract", + }, + }, + ), + responses: { + ...jsonResponse(skillDetailSchema, "Skill created from the GitHub folder. `source` on the body carries the recorded upstream link.", { + status: 201, + example: { + ...SKILL_DETAIL_EXAMPLE, + isPrivate: true, + version: "1.0", + distTags: { latest: "1.0" }, + source: { + type: "github", + repo: "ChronoAIProject/ornn-skills", + ref: "main", + path: "skills/pdf-extract", + lastSyncedAt: "2026-07-18T16:02:05.000Z", + lastSyncedCommit: "3f1a9c2e7b4d5068f1a2b3c4d5e6f708192a3b4c", + }, + }, + headers: { + Location: { + description: "Canonical URL of the created skill.", + schema: { type: "string", example: "/api/v1/skills/550e8400-e29b-41d4-a716-446655440000" }, + }, + }, + }), + ...problemResponses( + { + 400: "Bad request. `invalid_pull_body` — neither `githubUrl` nor `repo` was supplied. `invalid_github_url` — the URL could not be parsed. `pull_failed` — the fetch itself failed (repo/folder missing, private, or GitHub rate-limited the server). Plus every package-validation code `POST /skills` can return (`missing_skill_md`, `frontmatter_validation_failed`, `validation_failed`, `reserved_name`, `invalid_version`).", + }, + 401, + { 403: "Forbidden — the token lacks the `ornn:skill:create` scope." }, + { 404: "Not found — `skill_dependency_not_found`: a ref in the pulled package's `metadata.depends-on` does not resolve." }, + { 409: "Conflict — `skill_name_exists`, `dependency_cycle`, or `dependency_conflict`, exactly as on `POST /skills`." }, + { 413: "Payload too large — the folder built a ZIP that trips the zip-bomb guard (`uncompressed_too_large`, `too_many_files`)." }, + { 500: "Internal error — the built package could not be written to object storage." }, + ), + }, + }, + }, + + // ----------------------------------------------------------------------- + // GitHub source link + // ----------------------------------------------------------------------- + + [`${prefix}/skills/{id}/refresh`]: { + post: { + summary: "Re-pull a linked GitHub source and publish it as a new version", + description: + "Bring a GitHub-linked skill up to date with its upstream folder. Ornn re-fetches the recorded `source`, and — unless `dryRun` is set — publishes the fetched package as a NEW immutable version, moving `latest` to it and stamping `source.lastSyncedAt` / `lastSyncedCommit`.\n\nBecause this goes through the same publish path as `PUT /skills/{id}`, the upstream `SKILL.md` must declare a version strictly greater than the current latest, and any breaking interface change (removed tool, removed runtime, changed output type) requires a major bump. If the upstream folder changed but its version did not, the refresh fails with 409 — bump the version in the repo and retry.\n\n**Preview first.** Send `{\"dryRun\": true}` to fetch, diff against the current latest, and return the result WITHOUT publishing. The preview body reports `hasChanges`, the `pendingVersion` the real refresh would create, and the same structured file diff `GET /versions/{from}/diff/{to}` returns. This is the recommended way to drive a confirm-then-apply flow.\n\nThe skill must already be linked — link it with `PUT /skills/{id}/source`, or create it linked with `POST /skills/pull`. Requires the `ornn:skill:update` request scope AND being the skill's author or a platform admin (a `write` grantee is NOT sufficient here).", + operationId: "refreshSkillFromSource", + tags: ["Skills"], + security: bearerAuth(), + parameters: [skillIdParam], + requestBody: jsonBody( + { + type: "object", + description: "All fields optional. An empty object `{}` performs a normal (non-preview, validating) refresh.", + properties: { + dryRun: { + type: "boolean", + default: false, + description: + "When true, fetch and diff but publish nothing. Changes the response payload to the preview shape described below.", + }, + skipValidation: { + type: "boolean", + default: false, + description: "Bypass package-format / frontmatter validation on the pulled package. Ignored when `dryRun` is true (previews always parse leniently).", + }, + skip_validation: { + type: "boolean", + default: false, + description: "snake_case alias of `skipValidation`, accepted for backward compatibility with the original handler. Either spelling works; both are OR-ed.", + }, + }, + }, + "Refresh options.", + { example: { dryRun: true } }, + ), + responses: { + ...jsonResponse( + { + oneOf: [ + skillDetailSchema, + { + type: "object", + description: "Preview payload — returned when `dryRun` is true. Nothing was persisted.", + required: ["skill", "source", "pendingVersion", "hasChanges", "diff"], + properties: { + skill: skillRefSchema, + source: skillSourceSchema, + pendingVersion: { + type: "string", + description: + "Version label the real refresh would publish, read from the upstream SKILL.md. Falls back to the current latest when the pulled package cannot be parsed.", + example: "1.3", + }, + hasChanges: { + type: "boolean", + description: "False when upstream is byte-identical to the current latest — a real refresh would be a no-op (and would 409 on the version check).", + }, + diff: versionDiffSchema, + }, + }, + ], + description: + "The refreshed skill (normal mode) or the dry-run preview (`dryRun: true`). Branch on the presence of `pendingVersion` / `diff` to tell them apart.", + }, + "Refresh applied (new version published), or — with `dryRun: true` — the preview of what a refresh would do.", + { + example: { + skill: { guid: "550e8400-e29b-41d4-a716-446655440000", name: "pdf-extract" }, + source: { + type: "github", + repo: "ChronoAIProject/ornn-skills", + ref: "main", + path: "skills/pdf-extract", + lastSyncedCommit: "3f1a9c2e7b4d5068f1a2b3c4d5e6f708192a3b4c", + }, + pendingVersion: "1.3", + hasChanges: true, + diff: { files: { added: [], removed: [], modified: [{ path: "SKILL.md", fromBytes: 1204, toBytes: 1288, fromHash: "…", toHash: "…", isText: true }], unchangedCount: 4 } }, + }, + }, + ), + ...problemResponses( + { + 400: "Bad request. `invalid_refresh_body` — the body failed validation. `NO_SOURCE` — the skill has no linked GitHub source; link one with `PUT /skills/{id}/source` first. `refresh_failed` / `refresh_preview_failed` — the upstream fetch or package build failed (folder deleted, repo now private, GitHub unreachable). Plus the usual package-validation codes on the pulled archive.", + }, + 401, + { 403: "Forbidden — the token lacks `ornn:skill:update`, or `not_skill_owner`: only the skill's author or a platform admin may refresh it." }, + { 404: "Not found — no such skill, or a dependency ref in the pulled package does not resolve (`skill_dependency_not_found`)." }, + { + 409: "Conflict. `VERSION_NOT_INCREMENTED` — the upstream SKILL.md version is not strictly greater than the current latest. `BREAKING_CHANGE_WITHOUT_MAJOR_BUMP` — the interface changed without a major bump. `dependency_cycle` / `dependency_conflict`.", + }, + { 413: "Payload too large — the pulled folder trips the zip-bomb guard." }, + { 500: "Internal error — the current latest package could not be downloaded for the diff, or the new package could not be stored." }, + ), + }, + }, + }, + + [`${prefix}/skills/{id}/source`]: { + put: { + summary: "Attach or clear a skill's GitHub source link", + description: + "Point an existing skill at an upstream GitHub folder — or unlink it — WITHOUT pulling anything. Use this to retrofit a link onto a skill that was originally uploaded as a ZIP, or to repoint one at a new repo/branch/folder after a move.\n\nSend `{\"githubUrl\": \"https://github.com/owner/repo/tree/main/skills/x\"}` to link; the URL is parsed into `repo` / `ref` / `path` and stored. `lastSyncedAt` and `lastSyncedCommit` are deliberately left unset — linking is not syncing. Call `POST /skills/{id}/refresh` when you actually want the content. Send `{\"githubUrl\": null}` to unlink; the skill keeps every published version, it just stops being refreshable.\n\nRequires the `ornn:skill:update` request scope AND being the skill's author or a platform admin.", + operationId: "setSkillSource", + tags: ["Skills"], + security: bearerAuth(), + parameters: [skillIdParam], + requestBody: jsonBody( + { + type: "object", + required: ["githubUrl"], + properties: { + githubUrl: { + type: ["string", "null"], + minLength: 1, + description: + "GitHub URL to link (`https://github.com//[/tree/[/]]`), or `null` to unlink. Any other JSON type is rejected with 400.", + example: "https://github.com/ChronoAIProject/ornn-skills/tree/main/skills/pdf-extract", + }, + }, + }, + "The GitHub URL to link, or `null` to clear the link.", + { example: { githubUrl: "https://github.com/ChronoAIProject/ornn-skills/tree/main/skills/pdf-extract" } }, + ), + responses: { + ...jsonResponse(skillDetailSchema, "Source pointer updated. `source` is populated on a link and absent after an unlink.", { + example: { + ...SKILL_DETAIL_EXAMPLE, + source: { + type: "github", + repo: "ChronoAIProject/ornn-skills", + ref: "main", + path: "skills/pdf-extract", + }, + }, + }), + ...problemResponses( + { + 400: "Bad request. `invalid_source_body` — `githubUrl` was missing or neither a non-empty string nor null. `invalid_github_url` — the URL could not be parsed into owner/repo.", + }, + 401, + { 403: "Forbidden — the token lacks `ornn:skill:update`, or `not_skill_owner`: only the skill's author or a platform admin may set its source." }, + 404, + ), + }, + }, + }, + + // ----------------------------------------------------------------------- + // Reads + // ----------------------------------------------------------------------- + + [`${prefix}/skills/{idOrName}/json`]: { + get: { + summary: "Read a skill's full package contents as JSON", + description: + "**The endpoint agents should reach for.** Returns the skill's entire package as a JSON object — no ZIP handling, no unpacking, no storage round-trip on your side. `files` maps each package-relative path (`SKILL.md`, `scripts/run.py`, `reference/schema.json`, …) to that file's full text. Binary entries that cannot be decoded as text are silently omitted.\n\nPin a version with `?version=` (literal `1.2` or dist-tag `@stable`); omit it for the current latest. The resolved version is echoed back as `version` so you can record exactly what you read.\n\nUnlike the anonymous-friendly read endpoints, this one requires authentication and the `ornn:skill:read` scope, and it is the call Ornn counts as a programmatic **pull** in its usage analytics. If you only need metadata, use `GET /skills/{idOrName}`; if you need the archive byte-for-byte (to verify `integrity`, or to install it verbatim), use `.../versions/{version}/download`.", + operationId: "getSkillJson", + tags: ["Skills"], + security: bearerAuth(), + parameters: [idOrNameParam, versionQueryParam], + responses: { + ...jsonResponse( + { + type: "object", + required: ["name", "description", "version", "metadata", "files"], + properties: { + name: { type: "string", description: "Skill name.", example: "pdf-extract" }, + description: { type: "string", description: "Skill description." }, + version: { + type: "string", + description: "The concrete version actually returned, after literal/dist-tag resolution.", + example: "1.2", + }, + metadata: skillMetadataSchema, + files: { + type: "object", + additionalProperties: { type: "string" }, + description: + "Package-relative path → full text content. A single top-level wrapper folder in the archive is stripped, so keys always start at the package root. Undecodable binary files are excluded.", + example: { "SKILL.md": "---\nname: pdf-extract\nversion: '1.2'\n---\n…", "scripts/run.py": "import sys\n…" }, + }, + }, + }, + "The skill's package contents at the resolved version.", + ), + ...problemResponses( + { 400: "Bad request — `invalid_version`: `?version=` was neither a `.` literal nor a resolvable `@tag`. `invalid_dist_tag`: `?version=@` with an empty tag name." }, + 401, + { 403: "Forbidden — the token lacks the `ornn:skill:read` scope." }, + { 404: "Not found — no such skill, the requested version/dist-tag does not exist (`skill_version_not_found`), or the skill is private and this caller cannot read it (existence is not leaked)." }, + { 500: "Internal error — `package_download_failed`: the stored package could not be fetched from object storage." }, + ), + }, + }, + }, + + [`${prefix}/skills/{idOrName}/versions`]: { + get: { + summary: "List a skill's published versions", + description: + "Return every immutable version of the skill, newest first. Each entry carries the version label, its `skillHash`, an npm-style `integrity` string (`sha256-`) you can verify a download against, who published it and when, the deprecation flag plus note, and the author's release notes.\n\nThis is the call to make before pinning: pick a version here, then read or download it with `?version=` / `.../versions/{version}/download`. Deprecated versions are NOT hidden — deprecation only warns, it never removes a version or excludes it from latest-resolution.\n\nAuthentication is optional: anonymous callers see public skills only; authenticated callers additionally see skills they own, were granted, or can reach via a granted org.", + operationId: "listSkillVersions", + tags: ["Skills"], + security: optionalAuth(), + parameters: [idOrNameParam], + responses: { + ...jsonResponse( + { + type: "object", + required: ["items"], + properties: { + items: { + type: "array", + items: versionListItemSchema, + description: "All published versions, newest first. Never paginated — a skill's version history is bounded.", + }, + }, + }, + "The skill's version history.", + { + example: { + items: [ + { + version: "1.2", + skillHash: "9f2c1b8a4d5e6f70819a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f70", + integrity: "sha256-nywbik1eb3CBmis8TV5vcIGSo7TF1uf4CRorPE1eb3A=", + createdBy: "usr_01HQ8Z3K5N", + createdByEmail: "author@example.com", + createdByDisplayName: "Ada Lovelace", + createdOn: "2026-07-18T16:02:05.000Z", + isDeprecated: false, + deprecationNote: null, + releaseNotes: "Handle encrypted PDFs.", + }, + ], + }, + }, + ), + ...problemResponses({ + 404: "Not found — no such skill, or it is private and this caller cannot read it.", + }), + }, + }, + }, + + [`${prefix}/skills/{idOrName}/versions/{fromVersion}/diff/{toVersion}`]: { + get: { + summary: "Diff two published versions of a skill", + description: + "Compute a structured, file-level diff between two versions of the same skill. Ornn downloads both archives server-side and reports which files were added, removed, or modified, plus a count of the byte-identical ones. For text files both sides' contents are inlined (truncated at 64 KiB per side) so you can render or reason about a line-level diff without any further requests; binary files report hashes and byte counts only.\n\nUse it to decide whether a `latest` move is safe to adopt, or to explain to a user what changed between the version they pinned and the one they are being offered. `fromVersion` and `toVersion` must be literal `.` labels — dist-tags are NOT resolved on this route, so resolve them via `GET /skills/{idOrName}/dist-tags` first.\n\nAuthentication is optional; the same visibility rules as the read endpoint apply.", + operationId: "diffSkillVersions", + tags: ["Skills"], + security: optionalAuth(), + parameters: [ + idOrNameParam, + pathParam( + "fromVersion", + "Baseline version — the 'before' side. Literal `.` only; no leading zeroes, no patch digit, no dist-tag.", + { type: "string", pattern: "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$" }, + "1.1", + ), + pathParam( + "toVersion", + "Target version — the 'after' side. Literal `.` only. Must differ from `fromVersion`.", + { type: "string", pattern: "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$" }, + "1.2", + ), + ], + responses: { + ...jsonResponse( + { + type: "object", + required: ["skill", "from", "to", "diff"], + properties: { + skill: skillRefSchema, + from: { ...diffSideSchema, description: "Summary of the baseline version." }, + to: { ...diffSideSchema, description: "Summary of the target version." }, + diff: versionDiffSchema, + }, + }, + "Structured diff between the two versions.", + ), + ...problemResponses( + { + 400: "Bad request. `same_version` — `fromVersion` and `toVersion` are identical. `invalid_version` — one of them is not a well-formed `.` label (dist-tags are not accepted here).", + }, + { + 404: "Not found — no such skill (or not visible to this caller), or `skill_version_not_found`: one of the two versions was never published.", + }, + { 500: "Internal error — `package_download_failed`: one of the two archives could not be fetched from object storage." }, + ), + }, + }, + }, + + [`${prefix}/skills/{idOrName}/versions/{version}/download`]: { + get: { + summary: "Download a skill version's package ZIP", + description: + "Stream the raw ZIP bytes of one skill version. The bytes are proxied through ornn-api from object storage — no presigned URL is ever handed out, so clients never talk to the storage backend and no credential leaks into a browser.\n\nUse this when you need the archive verbatim: to verify it against the `integrity` value from `GET /skills/{idOrName}/versions`, to install it into a sandbox, or to re-publish it elsewhere. If you just want to read the files, `GET /skills/{idOrName}/json` saves you the unzip.\n\n`{version}` accepts a literal `.` or a dist-tag written **with its `@` prefix** (`@stable`, `@latest`) — a bare tag name is parsed as a literal version and rejected with 400. Deliberately NOT counted as a pull in usage analytics: this endpoint also backs the web file viewer, and counting UI views would inflate the metric.\n\nAuthentication is optional; anonymous callers may download public skills only. Errors still use the RFC 7807 `application/problem+json` body — only the 200 is binary.", + operationId: "downloadSkillVersion", + tags: ["Skills"], + security: optionalAuth(), + parameters: [ + idOrNameParam, + pathParam( + "version", + "Literal `.` (e.g. `1.2`) or a dist-tag INCLUDING the `@` prefix (e.g. `@latest`, `@stable`). A bare `latest` is treated as a literal version and fails with 400 `invalid_version`.", + { type: "string", examples: ["1.2", "@latest"] }, + "1.2", + ), + ], + responses: { + ...binaryResponse("The raw skill package ZIP bytes.", "application/zip", { + "Content-Disposition": { + description: + "Attachment filename, `-.zip`. Characters outside `[A-Za-z0-9._-]` in the skill name are replaced with `_`.", + schema: { type: "string", example: 'attachment; filename="pdf-extract-1.2.zip"' }, + }, + }), + ...problemResponses( + { 400: "Bad request — `invalid_version`: `{version}` is neither a well-formed `.` literal nor an `@`-prefixed tag. `invalid_dist_tag`: `@` with an empty tag name." }, + { + 404: "Not found — no such skill (or private and unreadable by this caller), `skill_version_not_found` (unknown version or unset dist-tag), or `skill_package_not_found` (the version row carries no stored package).", + }, + { 500: "Internal error — `package_download_failed`: object storage rejected or dropped the read." }, + ), + }, + }, + }, + + [`${prefix}/skills/{idOrName}/closure`]: { + get: { + summary: "Resolve a skill version's transitive dependency closure", + description: + "Walk the skill's `metadata.depends-on` graph and return **every** transitive dependency it needs — not the skill itself. Items come back in deps-first topological order, so installing them in array order is always safe: every dependency appears before anything that pins it. Nodes shared by several paths (diamonds) appear exactly once, carrying the deepest `depth` at which they were reached.\n\nThis is the one call that turns 'install this skill' into a complete, ordered work list. Pair it with `.../versions/{version}/download` (or `/json`) per node to fetch the packages. A skill declaring no dependencies returns an empty `items` array — that is a success, not a 404.\n\nPin the root with `?version=` (literal or `@tag`); omit for latest. Authentication is optional and the closure is resolved against what the caller may read: a public skill that transitively pins a PRIVATE skill surfaces that node as 404 `skill_dependency_not_found` rather than leaking its existence. The same closure is validated at publish time, so a skill that published successfully had a resolvable graph *for its author* — which is not necessarily resolvable for you.", + operationId: "getSkillClosure", + tags: ["Skills"], + security: optionalAuth(), + parameters: [idOrNameParam, versionQueryParam], + responses: { + ...jsonResponse( + { + type: "object", + required: ["items"], + properties: { + items: { + type: "array", + items: closureNodeSchema, + description: + "The transitive closure in deps-first topological order. Empty when the skill declares no dependencies.", + }, + }, + }, + "The resolved dependency closure.", + { + example: { + items: [ + { ref: "pdf-tools@1.0", name: "pdf-tools", version: "1.0", guid: "6f1c…", skillHash: "ab12…", depth: 1 }, + { ref: "report-gen@2.3", name: "report-gen", version: "2.3", guid: "8a2d…", skillHash: "cd34…", depth: 0 }, + ], + }, + }, + ), + ...problemResponses( + { 400: "Bad request — `invalid_version`: `?version=` is malformed. `invalid_dist_tag`: empty tag name after `@`." }, + { + 404: "Not found — no such root skill (or not visible), `skill_version_not_found` for the requested root version, or `skill_dependency_not_found` when a ref anywhere in the graph does not resolve to a version THIS caller can read.", + }, + { + 409: "Conflict. `dependency_cycle` — the graph loops. `dependency_conflict` — one skill name is pinned to two different versions inside the same closure, or the closure exceeded the 500-node ceiling.", + }, + ), + }, + }, + }, + + [`${prefix}/skills/{idOrName}`]: { + get: { + summary: "Read a skill's metadata", + description: + "Fetch the full metadata record for one skill by GUID or by name: description, license, compatibility, parsed `metadata`, tags, package hash, visibility, the typed grant ACL, GitHub source link, NyxID service tie, AgentSeal trust score, mirror state, and the dist-tag map. It does NOT include the package contents — use `/json` for those or `.../versions/{version}/download` for the archive.\n\nPin with `?version=` (literal `1.2` or dist-tag `@stable`) to see what a specific version looked like; identity fields still come from the skill itself, only the package-shaped fields change.\n\nWhen the resolved version is deprecated the response carries the RFC 8594 `Deprecation: true` header plus a `Link` header with `rel=\"deprecation\"`; the human-readable reason is in `deprecationNote` on the body. Agents should surface that rather than silently installing a deprecated version.\n\nAuthentication is optional: anonymous callers see public skills only. Private skills the caller cannot read answer 404, never 403.", + operationId: "getSkill", + tags: ["Skills"], + security: optionalAuth(), + parameters: [idOrNameParam, versionQueryParam], + responses: { + ...jsonResponse(skillDetailSchema, "The skill at the resolved version.", { + example: SKILL_DETAIL_EXAMPLE, + headers: { + Deprecation: { + description: "RFC 8594. Present and set to `true` only when the resolved version is deprecated.", + schema: { type: "string", example: "true" }, + }, + Link: { + description: + "RFC 8594 companion, present alongside `Deprecation`. Carries `rel=\"deprecation\"` pointing at the deprecation registry entry for this skill.", + schema: { + type: "string", + example: '; rel="deprecation"', + }, + }, + }, + }), + ...problemResponses( + { 400: "Bad request — `invalid_version`: `?version=` is not a `.` literal. `invalid_dist_tag`: empty tag name after `@`." }, + { + 404: "Not found — no such skill, `skill_version_not_found` for the requested version or unset dist-tag, or the skill is private and unreadable by this caller.", + }, + ), + }, + }, + }, + + // ----------------------------------------------------------------------- + // Per-version writes + // ----------------------------------------------------------------------- + + [`${prefix}/skills/{id}/versions/{version}`]: { + patch: { + summary: "Deprecate or un-deprecate a single version", + description: + "Flag one published version as deprecated (or clear the flag), optionally with a note explaining why and what to move to. Deprecation is a **warning, not a removal**: the version stays downloadable, stays in `GET /versions`, and can still be what `latest` points at. Consumers see it via `isDeprecated` / `deprecationNote` on the detail and version-list responses, and via the RFC 8594 `Deprecation` header on `GET /skills/{idOrName}`.\n\nSetting `isDeprecated: false` always clears the stored note, so re-deprecating later requires sending the note again.\n\nWrite path, so `{id}` must be the GUID. Requires the `ornn:skill:update` request scope AND object-ADMIN on the skill (author or platform admin) — a `write` grantee cannot deprecate.", + operationId: "setSkillVersionDeprecation", + tags: ["Skills"], + security: bearerAuth(), + parameters: [ + skillIdParam, + pathParam( + "version", + "Literal `.` version to flag. Dist-tags are not resolved on this route.", + { type: "string", pattern: "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$" }, + "1.1", + ), + ], + requestBody: jsonBody( + { + type: "object", + required: ["isDeprecated"], + properties: { + isDeprecated: { + type: "boolean", + description: "`true` marks the version deprecated; `false` clears the flag AND the stored note.", + }, + deprecationNote: { + type: "string", + maxLength: 1024, + description: + "Why it is deprecated and what to use instead. Max 1024 characters. Only meaningful when `isDeprecated` is true — it is forced to null otherwise.", + example: "Superseded by 2.0 — the text extractor changed output shape.", + }, + }, + }, + "The deprecation state to apply to this version.", + { example: { isDeprecated: true, deprecationNote: "Superseded by 2.0 — the text extractor changed output shape." } }, + ), + responses: { + ...jsonResponse( + { + type: "object", + required: ["skillGuid", "skillName", "version", "isDeprecated", "deprecationNote"], + description: "Lightweight confirmation. Call `GET /skills/{idOrName}?version=…` if you need the full record afterwards.", + properties: { + skillGuid: { type: "string", format: "uuid", description: "The skill's GUID." }, + skillName: { type: "string", description: "The skill's name." }, + version: { type: "string", description: "The version that was updated." }, + isDeprecated: { type: "boolean", description: "The state now stored." }, + deprecationNote: { type: ["string", "null"], description: "The note now stored — null when `isDeprecated` is false." }, + }, + }, + "Deprecation state updated.", + { + example: { + skillGuid: "550e8400-e29b-41d4-a716-446655440000", + skillName: "pdf-extract", + version: "1.1", + isDeprecated: true, + deprecationNote: "Superseded by 2.0 — the text extractor changed output shape.", + }, + }, + ), + ...problemResponses( + { + 400: "Bad request. `invalid_deprecation_patch` — `isDeprecated` missing/not a boolean, or `deprecationNote` longer than 1024 chars. `invalid_version` — `{version}` is not a `.` label.", + }, + 401, + { 403: "Forbidden — the token lacks `ornn:skill:update`, or the caller is not the skill's author / a platform admin." }, + { 404: "Not found — no skill with this GUID (names are not resolved on writes), or `skill_version_not_found`." }, + ), + }, + }, + delete: { + summary: "Delete a single non-latest version", + description: + "Permanently remove one published version. The skill and every other version survive; the stored archive is best-effort deleted from object storage.\n\nTwo versions can never be removed this way, by design: the **only remaining** version (delete the whole skill with `DELETE /skills/{id}` instead) and the **current latest** (publish a newer version first, then prune the old one). Both refuse with 409.\n\nBe aware this is destructive for consumers: anything pinned to the deleted version will start failing with `skill_version_not_found`, and a dist-tag pointing at it becomes dangling. Prefer `PATCH /skills/{id}/versions/{version}` (deprecate) when you only want to discourage use.\n\nRequires the `ornn:skill:delete` request scope AND object-ADMIN on the skill (author or platform admin).", + operationId: "deleteSkillVersion", + tags: ["Skills"], + security: bearerAuth(), + parameters: [ + skillIdParam, + pathParam( + "version", + "Literal `.` version to delete. Must not be the latest, and must not be the only version.", + { type: "string", pattern: "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$" }, + "1.0", + ), + ], + responses: { + ...jsonResponse(successAckSchema, "Version deleted.", { example: { success: true } }), + ...problemResponses( + 401, + { 403: "Forbidden — the token lacks `ornn:skill:delete`, or the caller is not the skill's author / a platform admin." }, + { 404: "Not found — no skill with this GUID, or `skill_version_not_found`." }, + { + 409: "Conflict. `SKILL_VERSION_LAST` — this is the only remaining version; delete the whole skill instead. `SKILL_VERSION_LATEST` — this is the current latest; publish a newer version first.", + }, + ), + }, + }, + }, + + // ----------------------------------------------------------------------- + // Dist-tags (#463) + // ----------------------------------------------------------------------- + + [`${prefix}/skills/{idOrName}/dist-tags`]: { + get: { + summary: "Read a skill's dist-tag map", + description: + "Return the complete npm-style tag → version map for a skill. `latest` is always present — it is auto-managed and moves to every newly published version — and any custom tags the owner set (`stable`, `beta`, `lts`, …) sit alongside it.\n\nResolve a tag here, then pass the concrete version to the read/download endpoints; or pass the tag directly as `?version=@stable` (query) / `@stable` (download path segment) and let the server resolve it. Resolving explicitly is the safer pattern for agents that want to record exactly which version they consumed.\n\nAuthentication is optional; the same visibility rules as the read endpoint apply.", + operationId: "getSkillDistTags", + tags: ["Skills"], + security: optionalAuth(), + parameters: [idOrNameParam], + responses: { + ...jsonResponse(distTagsPayloadSchema, "The skill's current dist-tag map.", { + example: { tags: { latest: "2.0", stable: "1.4", beta: "2.0" } }, + }), + ...problemResponses({ 404: "Not found — no such skill, or it is private and this caller cannot read it." }), + }, + }, + }, + + [`${prefix}/skills/{id}/dist-tags/{tag}`]: { + put: { + summary: "Point a dist-tag at a version", + description: + "Create or move a custom dist-tag so downstream consumers can pin to a moving target (`@stable`) instead of a frozen literal. The target version must already exist — Ornn refuses to create a dangling tag.\n\n`latest` is **reserved and immutable here**: it is maintained automatically by the publish path (`POST /skills`, `PUT /skills/{id}`, refresh) and any attempt to set it explicitly is rejected with 400 `dist_tag_immutable`. Tag names must match `^[a-z][a-z0-9-]{0,49}$` — starting with a letter keeps tags from ever looking like version numbers.\n\nMoving a tag is immediately visible to every consumer resolving through it, including exported skillsets, so treat it as a release action.\n\nRequires the `ornn:skill:update` request scope AND object-ADMIN on the skill (author or platform admin).", + operationId: "setSkillDistTag", + tags: ["Skills"], + security: bearerAuth(), + parameters: [ + skillIdParam, + pathParam( + "tag", + "Tag name. Must match `^[a-z][a-z0-9-]{0,49}$` (lowercase, starts with a letter, hyphens allowed, max 50 chars). `latest` is reserved.", + { type: "string", pattern: "^[a-z][a-z0-9-]{0,49}$" }, + "stable", + ), + ], + requestBody: jsonBody( + { + type: "object", + required: ["version"], + properties: { + version: { + type: "string", + minLength: 1, + maxLength: 20, + pattern: "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$", + description: + "Literal `.` version the tag should resolve to. Must already be published — tags cannot point at a version that does not exist, and cannot reference another tag.", + example: "1.4", + }, + }, + }, + "The version this tag should point at.", + { example: { version: "1.4" } }, + ), + responses: { + ...jsonResponse(distTagsPayloadSchema, "Tag set. The body is the full tag map after the change.", { + example: { tags: { latest: "2.0", stable: "1.4" } }, + }), + ...problemResponses( + { + 400: "Bad request. `invalid_dist_tag_body` — `version` missing or not `.`. `dist_tag_immutable` — `{tag}` is `latest`, which is auto-managed. `invalid_dist_tag` — the tag name violates `^[a-z][a-z0-9-]{0,49}$`. `invalid_version` — the version string is malformed.", + }, + 401, + { 403: "Forbidden — the token lacks `ornn:skill:update`, or the caller is not the skill's author / a platform admin." }, + { 404: "Not found — no skill with this GUID, or `skill_version_not_found`: the target version was never published." }, + ), + }, + }, + delete: { + summary: "Remove a dist-tag", + description: + "Drop a custom dist-tag. The versions it pointed at are untouched — only the alias disappears, and consumers resolving `@` afterwards get 404 `skill_version_not_found`.\n\n`latest` cannot be removed: every skill is guaranteed to have a `latest` pointer, so the request is rejected with 400 `dist_tag_immutable`. Deleting a tag that was never set is a no-op that still returns 200 with the current map.\n\nRequires the `ornn:skill:update` request scope AND object-ADMIN on the skill (author or platform admin).", + operationId: "deleteSkillDistTag", + tags: ["Skills"], + security: bearerAuth(), + parameters: [ + skillIdParam, + pathParam( + "tag", + "Tag name to remove. Must match `^[a-z][a-z0-9-]{0,49}$`. `latest` is reserved and cannot be deleted.", + { type: "string", pattern: "^[a-z][a-z0-9-]{0,49}$" }, + "beta", + ), + ], + responses: { + ...jsonResponse(distTagsPayloadSchema, "Tag removed. The body is the full tag map after the change.", { + example: { tags: { latest: "2.0", stable: "1.4" } }, + }), + ...problemResponses( + { + 400: "Bad request. `dist_tag_immutable` — `{tag}` is `latest`. `invalid_dist_tag` — the tag name violates `^[a-z][a-z0-9-]{0,49}$`.", + }, + 401, + { 403: "Forbidden — the token lacks `ornn:skill:update`, or the caller is not the skill's author / a platform admin." }, + { 404: "Not found — no skill with this GUID." }, + ), + }, + }, + }, + + // ----------------------------------------------------------------------- + // Skill-level writes + // ----------------------------------------------------------------------- + + [`${prefix}/skills/{id}`]: { + put: { + summary: "Publish a new version and/or flip a skill's visibility", + description: + "The update endpoint for an existing skill. It does two independent things and you may do either or both in one call:\n\n1. **Publish a new version** — send the new package. The version in the new `SKILL.md` MUST be strictly greater than the current latest (409 `VERSION_NOT_INCREMENTED` otherwise), and any breaking interface change — a removed tool, a removed runtime, a changed output type — requires a MAJOR bump (409 `BREAKING_CHANGE_WITHOUT_MAJOR_BUMP`). Versions are immutable: this appends to history and moves `latest` (and `distTags.latest`), it never rewrites the old archive.\n2. **Flip visibility** — send `isPrivate`.\n\nThree body encodings are accepted: raw ZIP bytes (`application/zip` / `application/octet-stream`) for a package-only update; `multipart/form-data` with a `package` file part and/or an `isPrivate` field for both at once; or `application/json` with `{ \"isPrivate\": … }` for a visibility-only change. A request that carries neither a package nor `isPrivate` is rejected with 400 `no_update`.\n\nThe two halves have DIFFERENT permission tiers: publishing content needs object-WRITE (author, platform admin, or a `write` grantee), while changing `isPrivate` needs object-ADMIN (author or platform admin only). A `write` grantee sending an unchanged `isPrivate` is fine; actually changing it 403s. Skills tied to an admin NyxID service are forced public and refuse `isPrivate: true` until untied.\n\nRequires the `ornn:skill:update` request scope. Write path, so `{id}` is the GUID.", + operationId: "updateSkill", + tags: ["Skills"], + security: bearerAuth(), + parameters: [skillIdParam, skipValidationQueryParam], + requestBody: { + required: true, + description: + "One of: raw ZIP bytes; a multipart form with `package` and/or `isPrivate`; or a JSON body with `isPrivate`. The `Content-Type` header selects the branch — an unrecognised type is treated as 'nothing supplied' and fails with 400 `no_update`.", + content: { + "application/zip": { schema: { type: "string", format: "binary" } }, + "application/octet-stream": { schema: { type: "string", format: "binary" } }, + "multipart/form-data": { + schema: { + type: "object", + properties: { + package: { + type: "string", + format: "binary", + description: "New skill package ZIP. Omit to change visibility only.", + }, + isPrivate: { + type: "string", + enum: ["true", "false"], + description: + "Visibility flag, sent as a form field. Compared as a string — anything other than the literal `true` is read as `false`.", + }, + }, + }, + }, + "application/json": { + schema: { + type: "object", + description: "Visibility-only update. A package cannot be sent through this branch.", + properties: { + isPrivate: { + type: "boolean", + description: + "`true` restricts the skill to its owner, platform admins, and grantees; `false` publishes it to the whole registry.", + }, + }, + }, + example: { isPrivate: false }, + }, + }, + }, + responses: { + ...jsonResponse(skillDetailSchema, "Skill updated. The body reflects the new latest version and the current visibility.", { + example: SKILL_DETAIL_EXAMPLE, + }), + ...problemResponses( + { + 400: "Bad request. `no_update` — neither a package nor `isPrivate` was supplied. `invalid_body` — the JSON branch received unparseable JSON or a non-boolean `isPrivate`. `SYSTEM_SKILL_MUST_BE_PUBLIC` — the skill is tied to an admin NyxID service and cannot be made private; untie it first. Plus every package-validation code from `POST /skills` (`validation_failed`, `missing_skill_md`, `frontmatter_validation_failed`, `invalid_version`, …).", + }, + 401, + { + 403: "Forbidden — the token lacks `ornn:skill:update`; or the caller has no WRITE tier on this skill; or the caller is a `write` grantee attempting to CHANGE `isPrivate`, which is ADMIN-tier.", + }, + { 404: "Not found — no skill with this GUID, or `skill_dependency_not_found` for a ref declared by the new package." }, + { + 409: "Conflict. `VERSION_NOT_INCREMENTED` — the new version is not strictly greater than the current latest. `BREAKING_CHANGE_WITHOUT_MAJOR_BUMP` — the interface changed without a major bump. `dependency_cycle` / `dependency_conflict`.", + }, + { + 413: "Payload too large — the package exceeds the configured max upload size, or trips the zip-bomb guard (`uncompressed_too_large`, `too_many_files`).", + }, + { 500: "Internal error — the new package could not be written to object storage." }, + ), + }, + }, + delete: { + summary: "Permanently delete a skill and all its versions", + description: + "Hard-delete the skill: every published version, every stored archive, and the skill record itself. There is no soft-delete, no tombstone, and no undo — anything pinned to this skill starts failing with `skill_not_found`, and skillsets referencing it are recomputed and their owners notified.\n\nPrefer the reversible alternatives when you can: `PUT /skills/{id}` with `isPrivate: true` hides it from the registry, and `PATCH /skills/{id}/versions/{version}` deprecates a specific version. Use `DELETE /skills/{id}/versions/{version}` to prune one old version rather than the whole skill.\n\nRequires the `ornn:skill:delete` request scope AND object-ADMIN on the skill (author or platform admin — a `write` grantee cannot delete).", + operationId: "deleteSkill", + tags: ["Skills"], + security: bearerAuth(), + parameters: [skillIdParam], + responses: { + ...jsonResponse(successAckSchema, "Skill and all of its versions deleted.", { example: { success: true } }), + ...problemResponses( + 401, + { 403: "Forbidden — the token lacks `ornn:skill:delete`, or the caller is not the skill's author / a platform admin." }, + { 404: "Not found — no skill with this GUID (names are not resolved on writes)." }, + ), + }, + }, + }, + + // ----------------------------------------------------------------------- + // Governance + // ----------------------------------------------------------------------- + + [`${prefix}/skills/{id}/permissions`]: { + put: { + summary: "Replace a skill's visibility and access-control list", + description: + "Set the skill's complete permission state in one atomic write. This is a **full replace**, not a merge: whatever `grants` you send becomes the entire ACL, so read the current grants off `GET /skills/{idOrName}` and send the modified list back — omitting an entry revokes it.\n\n`grants` is the canonical form: each entry pairs a principal (`type: \"user\"` with a NyxID person user_id, or `type: \"org\"` with a NyxID org user_id) with a `level` of `read` or `write`. A `write` grantee may publish new versions but never gains admin rights (permissions, ownership transfer, deletion, deprecation, dist-tags) — those stay with the owner and platform admins. Org grants cascade to every admin/member of that org. Duplicate `(type, id)` pairs collapse keeping the higher level, and a grant naming the owner is dropped as redundant.\n\n`sharedWithUsers` / `sharedWithOrgs` are the pre-#1123 read-only lists, still accepted for older clients. They are used ONLY when `grants` is omitted, in which case each id becomes a `read` grant. Do not mix the two forms.\n\nSetting `isPrivate: false` publishes the skill registry-wide; the grants are still stored so you can flip back without rebuilding your collaborator list. You may only share into organizations you belong to (platform admins excepted) — sharing into a non-member org is a 403, and a temporarily unresolvable NyxID membership lookup is a retryable 503 rather than a wrong denial.\n\nRequires the `ornn:skill:update` request scope AND object-ADMIN on the skill.", + operationId: "setSkillPermissions", + tags: ["Skills"], + security: bearerAuth(), + parameters: [skillIdParam], + requestBody: jsonBody( + { + type: "object", + required: ["isPrivate"], + properties: { + isPrivate: { + type: "boolean", + description: + "`true` restricts the skill to owner + platform admins + grantees; `false` publishes it to the whole registry. Required — this endpoint replaces the whole permission state.", + }, + grants: { + type: "array", + maxItems: 600, + items: grantSchema, + description: + "Canonical ACL, replacing the previous one wholesale. Max 600 entries. Omit the field entirely to fall back to the legacy lists below; send `[]` to revoke every grant.", + }, + sharedWithUsers: { + type: "array", + maxItems: 500, + default: [], + items: { type: "string", minLength: 1, maxLength: 128 }, + description: + "Legacy read-only allow-list of NyxID person user_ids. Used only when `grants` is absent, where each id becomes a `read` grant. Max 500.", + }, + sharedWithOrgs: { + type: "array", + maxItems: 100, + default: [], + items: { type: "string", minLength: 1, maxLength: 128 }, + description: + "Legacy read-only allow-list of NyxID org user_ids. Used only when `grants` is absent. Max 100. You must be a member of every org listed.", + }, + }, + }, + "The complete permission state to apply.", + { + example: { + isPrivate: true, + grants: [ + { type: "user", id: "usr_01HQ8Z3K5N", level: "read" }, + { type: "org", id: "org_01HQ9A4M2P", level: "write" }, + ], + }, + }, + ), + responses: { + ...jsonResponse(skillWrapperSchema, "Permissions replaced. `skill.grants` is the normalized ACL now in force.", { + example: { + skill: { + ...SKILL_DETAIL_EXAMPLE, + isPrivate: true, + grants: [{ type: "user", id: "usr_01HQ8Z3K5N", level: "read" }], + sharedWithUsers: ["usr_01HQ8Z3K5N"], + }, + }, + }), + ...problemResponses( + { + 400: "Bad request. `invalid_permissions` — the body failed validation (missing `isPrivate`, a malformed grant, or a list over its cap). `SYSTEM_SKILL_MUST_BE_PUBLIC` — the skill is tied to an admin NyxID service and cannot be made private; untie it first.", + }, + 401, + { + 403: "Forbidden — the token lacks `ornn:skill:update`; the caller is not the skill's author / a platform admin; or `not_org_member`: you tried to share into an organization you do not belong to.", + }, + { 404: "Not found — no skill with this GUID." }, + { + 503: "`org_membership_unavailable` — the NyxID org-membership lookup could not be resolved, so a share into an org cannot be safely validated. Retryable; retry with backoff.", + }, + ), + }, + }, + }, + + [`${prefix}/skills/{id}/transfer-ownership`]: { + post: { + summary: "Transfer a skill to another Ornn user", + description: + "Hand ownership of the skill to a different NyxID user. The change is immediate and synchronous: the target becomes `createdBy` (gaining implicit ADMIN over the skill), any grant naming them is dropped as redundant, and the PRIOR owner is retained as a `read` grantee — they keep visibility but lose edit and admin rights, so a transfer is not a lock-out.\n\nThe target must be a known Ornn user: someone who has signed in to Ornn at least once, so the directory can resolve their identity. A user_id that resolves nowhere is rejected with 400 `invalid_transfer_target` before anything is mutated. Transferring to the current owner is a 409 no-op rather than a silent success.\n\nThis is a danger-zone operation: it requires the `ornn:skill:update` request scope AND object-ADMIN (author or platform admin). A `write` grantee can never transfer.", + operationId: "transferSkillOwnership", + tags: ["Skills"], + security: bearerAuth(), + parameters: [skillIdParam], + requestBody: jsonBody( + { + type: "object", + required: ["newOwnerUserId"], + properties: { + newOwnerUserId: { + type: "string", + minLength: 1, + maxLength: 128, + description: + "NyxID person user_id of the new owner. Must be a user who has signed in to Ornn at least once — organization ids are not valid targets.", + example: "usr_01HQ8Z3K5N", + }, + }, + }, + "The new owner.", + { example: { newOwnerUserId: "usr_01HQ8Z3K5N" } }, + ), + responses: { + ...jsonResponse(skillWrapperSchema, "Ownership transferred. `skill.createdBy` is the new owner and the prior owner appears as a `read` grant.", { + example: { + skill: { + ...SKILL_DETAIL_EXAMPLE, + createdBy: "usr_01HQ8Z3K5N", + grants: [{ type: "user", id: "usr_01HPRIOR0WNER", level: "read" }], + }, + }, + }), + ...problemResponses( + { + 400: "Bad request. `invalid_transfer` — `newOwnerUserId` missing, empty, or over 128 chars. `invalid_transfer_target` — the id does not resolve to a known Ornn user; they must sign in to Ornn once before they can receive a skill.", + }, + 401, + { 403: "Forbidden — the token lacks `ornn:skill:update`, or the caller is not the skill's author / a platform admin." }, + { 404: "Not found — no skill with this GUID." }, + { 409: "`ownership_conflict` — the named user already owns this skill." }, + ), + }, + }, + }, + + [`${prefix}/skills/{id}/nyxid-service`]: { + put: { + summary: "Bind or unbind a skill to a NyxID catalog service", + description: + "Tie the skill to a NyxID service so credential brokering and service-scoped discovery (`GET /nyxid-services/{serviceId}/skills`) know where it belongs. Send `{\"nyxidServiceId\": \"\"}` to bind, or `{\"nyxidServiceId\": null}` to unbind.\n\nTwo classes of service can be bound. An **admin/platform service** (NyxID `visibility: \"public\"`) may be used by any caller who can see it — binding to one marks the skill a *system skill* and atomically FORCES `isPrivate: false`, because system skills are always public. A **personal service** (`visibility: \"private\"`) may only be bound by the user who created it; binding to somebody else's personal service is refused even for platform admins, and leaves visibility untouched.\n\nAfter binding to an admin service the skill can no longer be made private — `PUT /skills/{id}` and `PUT /skills/{id}/permissions` both refuse with `SYSTEM_SKILL_MUST_BE_PUBLIC` until you unbind. Unbinding clears the cached service id/slug/label and the system-skill flag but does NOT restore the previous visibility; set that explicitly afterwards.\n\nService ids of the form `synthetic:` come from the platform's configured extra-services list and short-circuit the NyxID lookup; they behave as admin services.\n\nRequires the `ornn:skill:update` request scope AND object-ADMIN on the skill.", + operationId: "setSkillNyxidService", + tags: ["Skills"], + security: bearerAuth(), + parameters: [skillIdParam], + requestBody: jsonBody( + { + type: "object", + required: ["nyxidServiceId"], + properties: { + nyxidServiceId: { + type: ["string", "null"], + minLength: 1, + maxLength: 128, + description: + "NyxID catalog service id to bind to, or `null` to unbind. Also accepts a `synthetic:` id from the platform's configured extras list.", + example: "svc_01HQ8Z3K5N", + }, + }, + }, + "The service to bind to, or `null` to unbind.", + { example: { nyxidServiceId: "svc_01HQ8Z3K5N" } }, + ), + responses: { + ...jsonResponse(skillWrapperSchema, "Binding updated. `skill.isSystemSkill` and `skill.isPrivate` reflect the admin-service side effect when one applied.", { + example: { + skill: { + ...SKILL_DETAIL_EXAMPLE, + isPrivate: false, + nyxidServiceId: "svc_01HQ8Z3K5N", + nyxidServiceSlug: "document-tools", + nyxidServiceLabel: "Document Tools", + isSystemSkill: true, + }, + }, + }), + ...problemResponses( + { 400: "`INVALID_NYXID_SERVICE_PATCH` — `nyxidServiceId` is missing, or is neither a 1–128 character string nor null." }, + 401, + { + 403: "Forbidden — the token lacks `ornn:skill:update`; the caller is not the skill's author / a platform admin; or `NYXID_SERVICE_NOT_ELIGIBLE`: the target is somebody else's personal service.", + }, + { + 404: "Not found — no skill with this GUID, or `NYXID_SERVICE_NOT_FOUND`: the service does not exist, is inactive, or is not visible to this caller's NyxID token.", + }, + ), + }, + }, + }, + + [`${prefix}/nyxid-services/{serviceId}/skills`]: { + get: { + summary: "List the skills bound to a NyxID service", + description: + "Page through the skills tied to one NyxID catalog service — the inverse of `PUT /skills/{id}/nyxid-service`. Use it to discover which capabilities are available under a service before brokering its credentials.\n\nWhat you get back depends on the service's own kind. For an **admin/platform service** (`tier: \"admin\"`) any authenticated caller sees every public skill bound to it — system skills are forced public, so that is the complete set. For a **personal service** (`tier: \"personal\"`) only the service's creator or a platform admin may browse; the listing is then scoped to the skills that caller can actually read. Anyone else gets 404 rather than 403, so a private service's existence is never leaked.\n\nPagination is page/pageSize rather than the cursor style used elsewhere; the response carries `total`, `page`, `pageSize` and `totalPages` so you can drive a pager directly. Out-of-range or non-numeric values are clamped, never rejected.\n\nRequires a bearer token. No extra `ornn:skill:*` scope is checked — visibility is entirely governed by what the caller's NyxID token can see.", + operationId: "listSkillsByNyxidService", + tags: ["Skills"], + security: bearerAuth(), + parameters: [ + pathParam( + "serviceId", + "NyxID catalog service id. Services the caller's token cannot see answer 404 rather than 403.", + { type: "string" }, + "svc_01HQ8Z3K5N", + ), + queryParam( + "page", + "1-based page number. Values below 1, non-numeric values, and omission all clamp to 1.", + { type: "integer", minimum: 1, default: 1, example: 1 }, + ), + queryParam( + "pageSize", + "Items per page. Clamped into 1–100; omission or a non-numeric value yields 20.", + { type: "integer", minimum: 1, maximum: 100, default: 20, example: 20 }, + ), + ], + responses: { + ...jsonResponse( + { + type: "object", + required: ["service", "items", "total", "page", "pageSize", "totalPages"], + properties: { + service: { + type: "object", + required: ["id", "slug", "label", "tier"], + description: "The resolved service, echoed so a client can render a header without a second NyxID call.", + properties: { + id: { type: "string", description: "NyxID service id." }, + slug: { type: "string", description: "URL-safe service slug.", example: "document-tools" }, + label: { type: "string", description: "Human-readable service name.", example: "Document Tools" }, + tier: { + type: "string", + enum: ["admin", "personal"], + description: + "`admin` — a platform-wide service; the listing covers every public skill bound to it. `personal` — a user-owned service; the listing is scoped to what the caller may read.", + }, + }, + }, + items: { + type: "array", + description: "Skill summaries for this page. A trimmed projection — call `GET /skills/{idOrName}` for the full record.", + items: { + type: "object", + required: ["guid", "name", "description", "createdBy", "createdOn", "updatedOn", "isPrivate", "tags", "isSystemSkill"], + properties: { + guid: { type: "string", format: "uuid", description: "Skill GUID." }, + name: { type: "string", description: "Skill name.", example: "pdf-extract" }, + description: { type: "string", description: "Skill description." }, + createdBy: { type: "string", description: "Owner's NyxID person user_id." }, + createdByEmail: { type: "string", description: "Cached owner email. May be absent." }, + createdByDisplayName: { type: "string", description: "Cached owner display name. May be absent." }, + createdOn: { type: "string", format: "date-time", description: "ISO 8601 creation time." }, + updatedOn: { type: "string", format: "date-time", description: "ISO 8601 last-modified time." }, + isPrivate: { type: "boolean", description: "Visibility flag." }, + tags: { type: "array", items: { type: "string" }, description: "Tags from the skill's metadata." }, + nyxidServiceId: { type: ["string", "null"], description: "The bound service id — matches `{serviceId}`." }, + nyxidServiceSlug: { type: ["string", "null"], description: "Cached slug of the bound service." }, + nyxidServiceLabel: { type: ["string", "null"], description: "Cached label of the bound service." }, + isSystemSkill: { type: "boolean", description: "True when bound to an admin/platform service." }, + }, + }, + }, + total: { type: "integer", description: "Total matching skills across all pages.", example: 34 }, + page: { type: "integer", description: "The page actually served, after clamping.", example: 1 }, + pageSize: { type: "integer", description: "The page size actually applied, after clamping.", example: 20 }, + totalPages: { type: "integer", description: "`ceil(total / pageSize)`.", example: 2 }, + }, + }, + "Skills bound to the service, one page at a time.", + ), + ...problemResponses( + 401, + { + 404: "`NYXID_SERVICE_NOT_FOUND` — the service does not exist, is inactive, is not visible to this caller's NyxID token, or is a personal service the caller neither owns nor administers. Existence is deliberately not leaked.", + }, + ), + }, + }, + }, + }; +} diff --git a/ornn-api/src/openapi/paths/skillsets.ts b/ornn-api/src/openapi/paths/skillsets.ts new file mode 100644 index 00000000..6ebc5d7c --- /dev/null +++ b/ornn-api/src/openapi/paths/skillsets.ts @@ -0,0 +1,906 @@ +/** + * OpenAPI operations for the **skillsets** domain (#969, #1214). + * + * A skillset is a named, versioned, owned meta-package that references + * N member skills (2..100) plus a REQUIRED master prompt (`instructions`, + * #978) telling an agent how to operate the set. It is the "bundle" unit of + * Ornn's skill lifecycle: one `GET /skillsets/{idOrName}/closure` call + * resolves every member AND each member's transitive dependency closure + * (#968) into a single deps-first topo-sorted list an agent can install as-is. + * + * Three things about this domain surprise integrators, so they are repeated + * on every operation that they touch: + * + * 1. **Visibility is DERIVED, never set** (#1136). A skillset has no + * owner-controlled privacy switch and there is deliberately NO + * `PUT /skillsets/{id}/permissions`. A caller may read a skillset iff + * they can read *every* one of its members; the moment one member is + * unreadable a non-owner gets a flat `404`, never a `403` and never a + * hint about which member. To widen a skillset's reach you widen its + * member skills. `memberVisibilityState` is the authoritative READ + * signal; `isPrivate` and `sharedWith*` are inert legacy back-compat. + * `grants` is inert for READ as well — but NOT for writes: a `write` + * entry there is the object-tier WRITE ACL that `PUT /skillsets/{id}` + * and `PUT /skillsets/{id}/plugin-export` consult, and it is the only + * way a caller who is neither the owner nor a platform admin gets + * through them. + * 2. **Versions are system-assigned** (#1162). Callers never type a + * version. Create seeds `1.0`; every publish bumps the MINOR (`1.0 → + * 1.1 → 1.2`), and a *member* skill moving version or flipping + * visibility auto-bumps the revision in the background. Treat + * `latestVersion` as a change signal, not something you control. + * 3. **Route scopes are the SKILL scopes.** Skillsets reuse + * `ornn:skill:{create,update,delete}` verbatim (CONVENTIONS.md §5.2); + * there is no `ornn:skillset:*` scope in v1. That reuse is explicitly + * not promised to be permanent. + * + * Two auth layers stack on the write paths: the NyxID *request scope* + * decides whether the caller may reach the handler at all (401/403), and the + * *object tier* (CONVENTIONS.md §5.4 — WRITE for publish/plugin-export, + * ADMIN for delete/transfer) decides whether they may act on this particular + * skillset (403). + * + * @module openapi/paths/skillsets + */ + +import { + bearerAuth, + jsonBody, + jsonResponse, + optionalAuth, + pathParam, + problemResponses, + queryParam, + type JsonSchema, + type PathMap, +} from "../helpers"; +import { MAX_PAGE } from "../../shared/cursor"; +import { + SKILLSET_INITIAL_REVISION, + SKILLSET_INSTRUCTIONS_MAX, + SKILLSET_KINDS, + SKILLSET_MAX_MEMBERS, + SKILLSET_MIN_MEMBERS, + SKILLSET_MIN_PUBLIC_EXPORT_MEMBERS, + createSkillsetSchema, + publishSkillsetSchema, + pluginExportSchema, +} from "../../domains/skillsets/types"; + +// --------------------------------------------------------------------------- +// Shared payload schemas +// +// The skillset domain serializes hand-written TypeScript interfaces +// (`SkillsetDetailResponse`, `SkillsetSearchItem`, `ClosureNode`) rather than +// Zod schemas, so the RESPONSE shapes below are hand-written JSON Schema. +// Every REQUEST body that has a Zod schema uses it directly. +// --------------------------------------------------------------------------- + +const KIND_ENUM = [...SKILLSET_KINDS]; + +const kindSchema: JsonSchema = { + type: "string", + enum: KIND_ENUM, + description: + "`generic` — a plain curated bundle. `consensus-supported` — the author's CLAIM that the members are independent and comparable enough to run agent-side consensus over. The claim is metadata, not a guarantee Ornn verifies: Ornn packages and delivers the set, your runtime decides what to do with it.", +}; + +const memberVisibilityStateSchema: JsonSchema = { + type: "string", + enum: ["all-public", "restricted", "unresolvable"], + description: + "Derived visibility of this version's members (#1136) — the AUTHORITATIVE reach signal for a skillset. `all-public`: every member skill is public, so anyone can read and resolve the set. `restricted`: at least one member is private/shared, so only callers who can read every member see it at all. `unresolvable`: at least one member ref no longer resolves (deleted skill or version) — only the owner and platform admins see it, and closure resolution will fail until it is repaired by publishing a version without the broken ref.", +}; + +const grantSchema: JsonSchema = { + type: "object", + required: ["type", "id", "level"], + description: + "Typed access grant (#1123). Inert for READ on a skillset (#1136): readability is member-derived, so a `read` entry here confers no visibility at all — read `memberVisibilityState` instead. It is NOT inert for WRITES: a `write` entry is the object-tier WRITE ACL consulted by `PUT /skillsets/{id}` (publish) and `PUT /skillsets/{id}/plugin-export`, and it is the only way a caller who is neither the owner nor a platform admin gets through them; a `read` entry gets a 403 there. v1 exposes no endpoint that adds a skillset grant — create starts the array empty, and transfer-ownership only ever adds a `read` entry for the prior owner — so a `write` entry can only originate from pre-#1136 data.", + properties: { + type: { type: "string", enum: ["user", "org"], description: "Principal kind." }, + id: { type: "string", description: "NyxID person or org user_id." }, + level: { type: "string", enum: ["read", "write"], description: "Granted permission level." }, + }, +}; + +const pluginConfigSchema: JsonSchema = { + type: "object", + description: + "Owner-supplied listing overrides for the exported Claude Code plugin (#1157). Absent when the owner never set overrides — the mirror then falls back to the skillset's own `name` / `description` / `tags`. The install NAME and the plugin VERSION are never overridable: they are the skillset's name and its system-managed revision.", + properties: { + displayName: { type: "string", description: "Overrides the plugin's display name (defaults to the skillset `name`)." }, + description: { type: "string", description: "Overrides the plugin's description (defaults to the skillset `description`)." }, + keywords: { + type: "array", + items: { type: "string" }, + description: "Overrides the plugin's keywords (defaults to the skillset `tags`). Kebab-case, ≤ 20 entries.", + }, + }, +}; + +/** + * `SkillsetDetailResponse` — the payload returned by create, read, publish, + * plugin-export, and (nested under `skillset`) transfer-ownership. + */ +const skillsetDetailSchema: JsonSchema = { + type: "object", + description: + "Full skillset detail AT ONE VERSION. `description` / `instructions` / `kind` / `tags` / `members` come from the returned version document; `guid` / `name` / `latestVersion` / ownership come from the skillset identity document. Compare `version` with `latestVersion` to tell whether you are looking at the head revision.", + required: [ + "guid", + "name", + "description", + "instructions", + "kind", + "tags", + "members", + "version", + "latestVersion", + "isPrivate", + "createdBy", + "sharedWithUsers", + "sharedWithOrgs", + "memberVisibilityState", + "exportAsPlugin", + "publicMemberCount", + "unreadableMembers", + "createdOn", + "updatedOn", + ], + properties: { + guid: { + type: "string", + format: "uuid", + description: "Stable skillset id. Never changes, including across ownership transfer. Use this — not `name` — as your durable key.", + }, + name: { + type: "string", + description: "Globally unique kebab-case handle, e.g. `pdf-review-set`. Fixed at create; a publish can never rename a skillset.", + }, + description: { type: "string", description: "Short human-readable summary of this version (≤ 1024 chars)." }, + instructions: { + type: "string", + description: `The master prompt (#978) for THIS version — up to ${SKILLSET_INSTRUCTIONS_MAX} chars of markdown telling an agent how to orchestrate the members (ordering, which member to pick when, how to combine outputs). Stored and returned verbatim: Ornn never renders, sanitizes, templates, lints, or search-indexes it. Feed it to your model as-is alongside the resolved closure.`, + }, + kind: kindSchema, + tags: { + type: "array", + items: { type: "string" }, + description: "Kebab-case discovery tags for this version, e.g. `[\"review\", \"pdf\"]`. Filterable via `GET /skillset-search?tags=`.", + }, + members: { + type: "array", + items: { type: "string" }, + description: `The AUTHORED member refs of this version, e.g. \`["pdf-tools@1.0", "csv-tools@latest"]\`. Each is \`@\` or \`@\` — the same grammar skill \`depends-on\` uses. ${SKILLSET_MIN_MEMBERS}..${SKILLSET_MAX_MEMBERS} entries. These are refs, not resolved packages: call \`/closure\` to turn them into concrete versions plus their transitive dependencies.`, + }, + version: { + type: "string", + description: "The revision this response describes, `.` (e.g. `1.3`). Echoes the `version` query param when one was supplied, otherwise the latest.", + }, + latestVersion: { + type: "string", + description: "The skillset's current head revision. System-assigned (#1162): it advances on every publish AND whenever a member skill's resolved version or visibility moves, so a change here is your signal to re-resolve the closure.", + }, + isPrivate: { + type: "boolean", + description: "INERT legacy field (#1136). A skillset's real reach is `memberVisibilityState`; do not gate UI or client logic on this.", + }, + createdBy: { type: "string", description: "Owner's NyxID person user_id. Changes only via transfer-ownership." }, + createdByEmail: { type: "string", description: "Owner's email, when the directory knows it." }, + createdByDisplayName: { type: "string", description: "Owner's human-readable name, when the directory knows it." }, + sharedWithUsers: { + type: "array", + items: { type: "string" }, + description: "INERT legacy per-user allow-list (#1136). Retained for back-compat only.", + }, + sharedWithOrgs: { + type: "array", + items: { type: "string" }, + description: "INERT legacy per-org allow-list (#1136). Retained for back-compat only.", + }, + grants: { + type: "array", + items: grantSchema, + description: + "The effective typed ACL (#1123). Inert for READ (#1136) — visibility is member-derived — but still load-bearing for writes: a `write` entry is what lets a caller who is neither the owner nor a platform admin publish a revision or toggle plugin export. Normally `[]`; see the item schema for where a non-empty array can come from.", + }, + memberVisibilityState: memberVisibilityStateSchema, + exportAsPlugin: { + type: "boolean", + description: "Whether the owner opted this skillset into export as a curated multi-skill Claude Code plugin in the public mirror (#1155). Toggle it with `PUT /skillsets/{id}/plugin-export`; create always starts it `false`.", + }, + publicMemberCount: { + type: "integer", + description: `How many of THIS version's members are currently public AND resolvable, counted under the system actor and de-duplicated by skill name (#1161). Only this public subset is ever bundled into the exported plugin, so \`members.length - publicMemberCount\` is the number of members the export drops. Enabling plugin export requires this to be ≥ ${SKILLSET_MIN_PUBLIC_EXPORT_MEMBERS}.`, + }, + pluginConfig: pluginConfigSchema, + unreadableMembers: { + type: "array", + items: { type: "string" }, + description: "Member refs THIS caller cannot read at this version (#1136). Request-scoped, never stored. Always `[]` for non-owners — they receive a 404 instead of a partial set — so a non-empty array means you are the owner or a platform admin and the set is broken for you: re-grant access on the listed skills, or publish a version without them.", + }, + createdOn: { type: "string", format: "date-time", description: "ISO-8601 timestamp the skillset was created." }, + updatedOn: { type: "string", format: "date-time", description: "ISO-8601 timestamp of the last owner-driven change to the identity document." }, + }, +}; + +/** One entry of `GET /skillsets/{idOrName}/versions`. */ +const skillsetVersionItemSchema: JsonSchema = { + type: "object", + required: ["version", "kind", "memberCount", "createdBy", "createdOn"], + properties: { + version: { type: "string", description: "Revision string, `.` (e.g. `1.2`). Pass it back as the `version` query param on the read or closure endpoints." }, + kind: kindSchema, + memberCount: { type: "integer", description: "Number of authored member refs in this revision." }, + createdBy: { type: "string", description: "NyxID person user_id that cut this revision. For a system auto-bump (#1162) this carries the prior revision's author forward." }, + createdByEmail: { type: "string", description: "Author's email, when known." }, + createdByDisplayName: { type: "string", description: "Author's display name, when known." }, + createdOn: { type: "string", format: "date-time", description: "ISO-8601 timestamp this revision was cut." }, + }, +}; + +/** One node of the resolved delivery closure (the shared #968 `ClosureNode`). */ +const closureNodeSchema: JsonSchema = { + type: "object", + required: ["ref", "name", "version", "depth"], + properties: { + ref: { type: "string", description: "Canonical `@` for this node, e.g. `pdf-tools@1.0`. Dist-tags and aliases are already resolved, so equivalent refs collapse onto one node." }, + name: { type: "string", description: "Skill name. Unique across the closure — one name resolves to exactly one version, otherwise the request fails with `dependency_conflict`." }, + version: { type: "string", description: "Concrete `.` version to install." }, + guid: { type: "string", format: "uuid", description: "Stable skill GUID, when the loader knew it. Prefer it over `name` when downloading." }, + skillHash: { type: "string", description: "Package content hash for the resolved version, when known. Use it to skip re-downloading a package you already hold." }, + depth: { type: "integer", description: "Maximum distance from any root member: `0` for the skillset's own members, `1+` for transitive dependencies. Informational only — the array order is already install-safe." }, + }, +}; + +/** One row of `GET /skillset-search`. */ +const skillsetSearchItemSchema: JsonSchema = { + type: "object", + required: [ + "guid", + "name", + "description", + "kind", + "tags", + "memberCount", + "latestVersion", + "isPrivate", + "memberVisibilityState", + "createdBy", + "createdOn", + "updatedOn", + ], + properties: { + guid: { type: "string", format: "uuid", description: "Stable skillset id — feed it to the read / closure endpoints." }, + name: { type: "string", description: "Unique kebab-case handle." }, + description: { type: "string", description: "Short summary, taken from the skillset identity document (i.e. the latest revision's description)." }, + kind: kindSchema, + tags: { type: "array", items: { type: "string" }, description: "Discovery tags of the latest revision." }, + memberCount: { + type: "integer", + description: "ALWAYS `0` on search results. Member lists live on the version document and search deliberately avoids a per-row extra read; fetch `GET /skillsets/{idOrName}` (or `/closure`) for the real member set. Do not render this value.", + }, + latestVersion: { type: "string", description: "Head revision, `.`." }, + isPrivate: { type: "boolean", description: "INERT legacy field (#1136) — read `memberVisibilityState` instead." }, + memberVisibilityState: memberVisibilityStateSchema, + createdBy: { type: "string", description: "Owner's NyxID person user_id." }, + createdByEmail: { type: "string", description: "Owner's email, when known." }, + createdByDisplayName: { type: "string", description: "Owner's display name, when known." }, + createdOn: { type: "string", format: "date-time", description: "ISO-8601 creation timestamp." }, + updatedOn: { type: "string", format: "date-time", description: "ISO-8601 timestamp of the last identity-document change." }, + }, +}; + +const skillsetSearchPayloadSchema: JsonSchema = { + type: "object", + required: ["items", "total", "page", "pageSize", "totalPages", "meta"], + description: + "A page of discovery results. Both pagination styles are present: `page` / `pageSize` / `total` / `totalPages` for offset paging, and `meta.nextCursor` for the cursor style CONVENTIONS.md §4.3 prescribes. Prefer the cursor — it is the forward-compatible one.", + properties: { + items: { type: "array", items: skillsetSearchItemSchema, description: "Matching skillsets, newest first." }, + total: { + type: "integer", + description: "Total matches. Exact for `scope=public` / `scope=mine` (counted in MongoDB); for the live-filtered scopes (`private`, `mixed`, `shared-with-me`) it is the size of the post-filter candidate set, which is capped at 500 candidates per request — treat it as a lower bound there.", + }, + page: { type: "integer", description: "1-indexed page actually served — the decoded `cursor` page when a cursor was supplied, otherwise `page`." }, + pageSize: { type: "integer", description: "Effective page size: `limit` when supplied, otherwise `pageSize`." }, + totalPages: { type: "integer", description: "`ceil(total / pageSize)`." }, + meta: { + type: "object", + required: ["limit", "hasMore"], + description: "Cursor-pagination metadata per CONVENTIONS.md §4.3.", + properties: { + limit: { type: "integer", description: "Effective page size for this response." }, + hasMore: { type: "boolean", description: "Whether at least one more page exists." }, + nextCursor: { + type: "string", + description: "Opaque token for the next page — pass it back as `?cursor=`. Omitted on the last page. NEVER parse or construct it; the encoding is server-internal and will change.", + }, + }, + }, + }, +}; + +/** + * RFC 9239 headers `middleware/rateLimit` emits on EVERY `/skillset-search` + * response, success or 429. The limiter is mounted with a fixed 60-request / + * 60-second window under the `skillset-search` label, keyed per authenticated + * user and otherwise per trusted proxy hop. + */ +const rateLimitHeaders: Record = { + "RateLimit-Limit": { + description: "Requests allowed in the current window — 60 per 60 seconds for this endpoint.", + schema: { type: "integer", examples: [60] }, + }, + "RateLimit-Remaining": { + description: "Requests left in the current window for this caller. Self-throttle as it approaches 0.", + schema: { type: "integer", examples: [59] }, + }, + "RateLimit-Reset": { + description: "Seconds until the window resets and `RateLimit-Remaining` returns to `RateLimit-Limit`.", + schema: { type: "integer", examples: [42] }, + }, +}; + +/** The same three headers, plus the `Retry-After` only a 429 carries. */ +const rateLimitedHeaders: Record = { + ...rateLimitHeaders, + "Retry-After": { + description: "Seconds to wait before retrying — the same value as `RateLimit-Reset`. Sent only on the 429.", + schema: { type: "integer", examples: [42] }, + }, +}; + +/** + * Attach response headers to the RFC 7807 responses `problemResponses` built. + * The problem body stays exactly as the shared helper declares it; only the + * `headers` map is added, so an error response is never hand-rolled here. + */ +function withHeaders( + responses: Record, + headers: Record, +): Record { + return Object.fromEntries( + Object.entries(responses).map(([status, response]) => [ + status, + { ...(response as Record), headers }, + ]), + ); +} + +/** + * Body for `POST /skillsets/{id}/transfer-ownership`. Hand-written: the + * route's Zod schema is a module-private const inside + * `domains/skillsets/routes.ts` and is not exported. + */ +const transferOwnershipBodySchema: JsonSchema = { + type: "object", + required: ["newOwnerUserId"], + properties: { + newOwnerUserId: { + type: "string", + minLength: 1, + maxLength: 128, + description: + "NyxID person user_id of the new owner. Must be a user who has signed in to Ornn at least once (Ornn resolves them against its own user directory, not NyxID directly) and must differ from the current owner.", + }, + }, +}; + +const SKILLSET_ID_EXAMPLE = "3f1c0a4e-9c2b-4a1e-9e3a-6b5d2f7c8a10"; + +const detailExample = { + guid: SKILLSET_ID_EXAMPLE, + name: "pdf-review-set", + description: "Extract, diff, and summarise PDF contracts.", + instructions: + "Run `pdf-tools` first to extract text, feed its output to `contract-diff`, then summarise with `report-writer`. Never call `report-writer` on raw PDF bytes.", + kind: "consensus-supported", + tags: ["review", "pdf"], + members: ["pdf-tools@1.0", "contract-diff@2.1", "report-writer@latest"], + version: "1.0", + latestVersion: "1.0", + isPrivate: true, + createdBy: "usr_01HQ8F3K2N", + sharedWithUsers: [], + sharedWithOrgs: [], + grants: [], + memberVisibilityState: "all-public", + exportAsPlugin: false, + publicMemberCount: 3, + unreadableMembers: [], + createdOn: "2026-07-14T09:31:02.481Z", + updatedOn: "2026-07-14T09:31:02.481Z", +}; + +// --------------------------------------------------------------------------- +// Paths +// --------------------------------------------------------------------------- + +/** + * Build the `/skillsets/*` + `/skillset-search` path map. + * + * @param prefix API mount prefix — always `/api/v1` (see `buildSpec`). + */ +export function skillsetsPaths(prefix: string): PathMap { + return { + [`${prefix}/skillsets`]: { + post: { + summary: "Create a skillset", + description: [ + "Create a new skillset — a curated bundle of 2..100 member skills plus the master prompt that explains how to use them together.", + `Every member ref is validated BEFORE anything is written: each must resolve to an existing skill version, and the union of all members' dependency closures must be conflict-free and acyclic. A bad ref therefore fails the whole request with \`skill_dependency_not_found\` (404) rather than creating a half-broken set. Validation runs as the system actor, so you may legitimately bundle a private skill you own or were granted — the closure READ is scoped to the caller separately.`, + `You do NOT choose a version: the system seeds \`${SKILLSET_INITIAL_REVISION}\` and auto-bumps the minor from then on (#1162). You also do not choose visibility — a skillset's reach is derived from its members (#1136), so a set of public skills is immediately public and a set containing one private skill is \`restricted\`. Plugin export always starts OFF; enable it afterwards via \`PUT /skillsets/{id}/plugin-export\`.`, + "Names are globally unique and reserved verbs are rejected, so treat 409 as \"pick another name\" and the `reserved_name` 400 as \"this word is taken by the routing grammar\". The response body is the same detail object `GET /skillsets/{idOrName}` returns, and `Location` points at the canonical URL of the new skillset.", + "Requires the `ornn:skill:create` request scope — skillsets reuse the skill scopes verbatim (CONVENTIONS.md §5.2).", + ].join("\n\n"), + operationId: "createSkillset", + tags: ["Skillsets"], + security: bearerAuth(), + requestBody: jsonBody( + createSkillsetSchema, + [ + "The skillset to create. `instructions` (the master prompt) is REQUIRED. `kind` defaults to `generic` and `tags` to `[]`.", + `Each entry of \`members\` must be \`@\` (e.g. \`pdf-tools@1.0\`) or \`@\` (e.g. \`pdf-tools@latest\`) — the same grammar skill \`depends-on\` uses. Semver ranges (\`^1.0\`) and patch digits (\`1.2.3\`) are rejected, as is a \`skillset:\`-prefixed ref: v1 has no nested skillsets, members are skills only. That grammar is enforced by refinements that do not survive into this JSON Schema, so validate it client-side rather than trusting \`maxLength\` alone.`, + "There is deliberately no `version` and no visibility field in this body; both are system-derived. Unknown properties are stripped rather than rejected.", + ].join(" "), + { + example: { + name: "pdf-review-set", + description: "Extract, diff, and summarise PDF contracts.", + instructions: + "Run `pdf-tools` first to extract text, feed its output to `contract-diff`, then summarise with `report-writer`.", + kind: "consensus-supported", + tags: ["review", "pdf"], + members: ["pdf-tools@1.0", "contract-diff@2.1", "report-writer@latest"], + }, + }, + ), + responses: { + ...jsonResponse(skillsetDetailSchema, `Skillset created at revision ${SKILLSET_INITIAL_REVISION}.`, { + status: 201, + example: detailExample, + headers: { + Location: { + description: "Canonical URL of the created skillset, e.g. `/api/v1/skillsets/3f1c0a4e-9c2b-4a1e-9e3a-6b5d2f7c8a10`.", + schema: { type: "string" }, + }, + }, + }), + ...problemResponses( + { + 400: "Bad request — the body failed validation (`invalid_skillset`; see `detail`), or the requested name is a reserved routing verb (`reserved_name`).", + }, + 401, + { 403: "Forbidden — the token lacks the `ornn:skill:create` request scope." }, + { + 404: "Not found — a member ref does not resolve to an existing, readable skill version (`skill_dependency_not_found`). The offending ref is named in `detail`. Nothing was created.", + }, + { + 409: "Conflict — the name is already taken (`skillset_name_exists`), two members pin different versions of the same skill (`dependency_conflict`), the closure exceeds 500 nodes (`dependency_conflict`), or the member graph contains a cycle (`dependency_cycle`).", + }, + ), + }, + }, + }, + + [`${prefix}/skillset-search`]: { + get: { + summary: "Discover skillsets by kind, tags, keyword, or scope", + description: [ + "Filter-based discovery over the skillset registry. Deliberately plain: exact `kind` equality, an ALL-match on `tags`, and a case-insensitive substring match on name + description. There is no semantic/LLM ranking, no facets, and no popularity signal — use `GET /skill-search` when you want semantic retrieval over individual skills.", + "Visibility is enforced live, not cached. `scope=public` and `scope=mine` are answered straight from MongoDB using the denormalized `memberVisibilityState`, so they paginate exactly. `private`, `mixed`, and `shared-with-me` instead fetch up to 500 candidates and then re-check, per candidate, whether YOU can read every member — restricted skillsets are never leaked, but `total` becomes a lower bound and deep pages may be incomplete on a very large registry.", + "Anonymous calls are allowed and are silently forced to `scope=public`; sending any other scope without a token does not error, it just returns public results.", + "Rate limited to 60 requests per minute per user (per source IP when anonymous). Responses always carry `RateLimit-Limit` / `RateLimit-Remaining` / `RateLimit-Reset`; a 429 additionally carries `Retry-After`.", + ].join("\n\n"), + operationId: "searchSkillsets", + tags: ["Skillsets"], + security: optionalAuth(), + parameters: [ + queryParam( + "q", + "Free-text keyword. Case-insensitive substring match against name and description only — not tags, not instructions, not member names. Max 200 characters. Omit to match everything in scope.", + { type: "string", maxLength: 200, examples: ["pdf"] }, + ), + queryParam( + "kind", + "Exact match on skillset kind. Omit to match both kinds.", + { type: "string", enum: KIND_ENUM }, + ), + queryParam( + "tags", + "Comma-separated tag list; a skillset matches only if it carries ALL listed tags (AND, not OR). Whitespace around entries is trimmed and empty entries are dropped. Note this is the one CSV-shaped parameter in the API — repeated keys are NOT supported here.", + { type: "string", examples: ["review,pdf"] }, + ), + queryParam( + "scope", + "Visibility slice to search. `public` (default) — skillsets whose members are all public. `mine` — skillsets you own, any state. `shared-with-me` / `private` / `mixed` — live-checked slices that additionally include restricted skillsets you can read every member of. Anonymous callers are forced to `public` regardless of what they send.", + { + type: "string", + enum: ["public", "private", "mixed", "shared-with-me", "mine"], + default: "public", + }, + ), + queryParam( + "cursor", + "Opaque pagination token from a previous response's `meta.nextCursor`. When present it OVERRIDES `page`. Never parse or construct it — a malformed or stale-format token is rejected with 400 `invalid_cursor` rather than silently restarting at page 1. Max 2048 characters.", + { type: "string", maxLength: 2048 }, + ), + queryParam( + "limit", + "Page size, 1..100. Takes precedence over `pageSize` when both are sent. This is the CONVENTIONS.md §4.3 spelling — prefer it.", + { type: "integer", minimum: 1, maximum: 100 }, + ), + queryParam( + "pageSize", + "Legacy page-size spelling, 1..100, default 20. Used only when `limit` is absent.", + { type: "integer", minimum: 1, maximum: 100, default: 20 }, + ), + queryParam( + "page", + `Legacy 1-indexed offset page, 1..${MAX_PAGE}, default 1. Ignored when \`cursor\` is supplied. The upper bound exists so a forged deep page cannot drive an unbounded collection scan.`, + { type: "integer", minimum: 1, maximum: MAX_PAGE, default: 1 }, + ), + ], + responses: { + ...jsonResponse(skillsetSearchPayloadSchema, "A page of matching skillsets.", { + headers: rateLimitHeaders, + }), + ...problemResponses({ + 400: "Bad request — a query parameter failed validation (`invalid_query`; see `detail`), or `cursor` is malformed / from a previous API version (`invalid_cursor`).", + }), + ...withHeaders( + problemResponses({ + 429: "Rate limited (`rate_limited`) — more than 60 searches in the last minute for this caller. Back off for `Retry-After` seconds; `RateLimit-Reset` carries the same number.", + }), + rateLimitedHeaders, + ), + }, + }, + }, + + [`${prefix}/skillsets/{idOrName}/closure`]: { + get: { + summary: "Resolve a skillset's full delivery closure", + description: [ + "The one call an agent needs to install a skillset. Returns the version's master prompt plus the union of every member skill AND each member's transitive dependency closure (#968) — deduplicated by canonical `@` and topologically sorted so dependencies always precede the nodes that pin them. Install `items` in array order and you can never install a dependent before its dependency.", + "`instructions` is a ROOT sibling of `items`, not a node property: it is the version's master prompt, returned verbatim, and it is what you put in your agent's system context before executing anything from the set.", + "Resolution is scoped to the CALLER, node by node. Anonymous callers can resolve a fully public skillset; the moment any node — a member or one of its transitive dependencies — is not readable by you, the whole request fails with `skill_dependency_not_found` (404) naming that ref. Existence is never leaked as a 403, and a partial closure is never returned.", + "This endpoint does not download packages. Take each node's `name`/`guid` and `version` and fetch `GET /skills/{idOrName}/versions/{version}/download`, or `GET /skills/{idOrName}/json` when you want file contents rather than a ZIP. Use `GET /skillsets/{idOrName}` first if you only need metadata — this call is heavier because it walks the whole graph.", + ].join("\n\n"), + operationId: "getSkillsetClosure", + tags: ["Skillsets"], + security: optionalAuth(), + parameters: [ + pathParam( + "idOrName", + "Skillset GUID or unique kebab-case name. GUID is tried first, then name — so a name that looks like a UUID resolves as a GUID.", + { type: "string" }, + "pdf-review-set", + ), + queryParam( + "version", + "Revision to resolve, `.` (e.g. `1.2`). Defaults to the skillset's `latestVersion`. An empty value is treated as absent. Unlike the read endpoint, this one PARSES the version, so a syntactically invalid value (e.g. `1.2.3`, `^1.0`, or `01.2` — both parts must be non-negative integers with no leading zeroes) is a 400, not a 404.", + { type: "string", pattern: "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)$", examples: ["1.2"] }, + ), + ], + responses: { + ...jsonResponse( + { + type: "object", + required: ["instructions", "items"], + properties: { + instructions: { + type: "string", + description: "The resolved version's master prompt (#978), verbatim. Load this into agent context before running any member.", + }, + items: { + type: "array", + items: closureNodeSchema, + description: "The deduplicated closure in deps-first topological order. Members appear at `depth: 0`; their transitive dependencies at `depth >= 1`.", + }, + }, + }, + "The resolved delivery closure plus the version's master prompt.", + { + example: { + instructions: "Run `pdf-tools` first, then feed its output to `contract-diff`.", + items: [ + { ref: "text-utils@1.4", name: "text-utils", version: "1.4", guid: "8c2e1b90-77aa-4d1e-b6f4-1c0d9a3e2f55", depth: 1 }, + { ref: "pdf-tools@1.0", name: "pdf-tools", version: "1.0", guid: "b1f4a7c2-2d55-4c8e-9a01-7f3e5c6d8b11", depth: 0 }, + { ref: "contract-diff@2.1", name: "contract-diff", version: "2.1", guid: "d9e0c3a6-5b41-4f27-8a3d-2e6b9c4f1a77", depth: 0 }, + ], + }, + }, + ), + ...problemResponses( + { 400: "Bad request — the `version` query parameter is not a valid `.` string (`invalid_version`). Semver ranges, patch digits, and leading zeroes (`01.2`, `1.02`) are all rejected." }, + { + 404: "Not found — no such skillset (`skillset_not_found`), the requested revision does not exist (`skillset_version_not_found`), or some node of the closure does not exist or is not readable by you (`skill_dependency_not_found`). All three are flat 404s: a private member is never distinguished from a missing one.", + }, + { + 409: "Conflict — the member graph cannot be delivered: two nodes pin different versions of the same skill (`dependency_conflict`), the closure exceeds 500 nodes (`dependency_conflict`), or there is a cycle (`dependency_cycle`). Only the skillset owner can fix this, by publishing a revision with a compatible member set.", + }, + ), + }, + }, + }, + + [`${prefix}/skillsets/{idOrName}/versions`]: { + get: { + summary: "List a skillset's published revisions", + description: [ + "List every published revision of a skillset, newest first. Each entry is a light summary (revision string, kind, member count, author, timestamp) — fetch `GET /skillsets/{idOrName}?version=` for a revision's full member list and master prompt.", + "Expect more revisions than you published. Revisions are system-assigned (#1162): the minor auto-bumps on every owner publish AND whenever a member skill's resolved version moves or its visibility flips, so a set you publish once may accumulate revisions on its own. That is the mechanism that makes downstream consumers (e.g. an exported Claude Code plugin) see an update signal.", + "The same member-derived read gate as the read endpoint applies, evaluated against the LATEST revision: if you cannot read every current member, you get a flat 404 — identical to a missing skillset — even for older revisions you once could read.", + ].join("\n\n"), + operationId: "listSkillsetVersions", + tags: ["Skillsets"], + security: optionalAuth(), + parameters: [ + pathParam( + "idOrName", + "Skillset GUID or unique kebab-case name. GUID is tried first, then name.", + { type: "string" }, + "pdf-review-set", + ), + ], + responses: { + ...jsonResponse( + { + type: "object", + required: ["items"], + properties: { + items: { + type: "array", + items: skillsetVersionItemSchema, + description: "Published revisions, newest first. Never empty for a readable skillset — every skillset has at least its create revision.", + }, + }, + }, + "The skillset's published revisions, newest first.", + { + example: { + items: [ + { version: "1.1", kind: "consensus-supported", memberCount: 3, createdBy: "usr_01HQ8F3K2N", createdByDisplayName: "Ada Lovelace", createdOn: "2026-07-20T11:04:55.002Z" }, + { version: "1.0", kind: "generic", memberCount: 2, createdBy: "usr_01HQ8F3K2N", createdByDisplayName: "Ada Lovelace", createdOn: "2026-07-14T09:31:02.481Z" }, + ], + }, + }, + ), + ...problemResponses({ + 404: "Not found — no such skillset, its latest revision is missing, or you cannot read every member of that revision (`skillset_not_found` / `skillset_version_not_found`). Restricted skillsets are indistinguishable from missing ones by design.", + }), + }, + }, + }, + + [`${prefix}/skillsets/{idOrName}`]: { + get: { + summary: "Read a skillset by GUID or name", + description: [ + "Fetch a skillset's metadata at one revision: the master prompt, the authored member refs, the derived visibility state, and the plugin-export status. This is the cheap metadata read — it does NOT resolve members into concrete versions or walk their dependencies. Call `GET /skillsets/{idOrName}/closure` when you actually need to install the set.", + "Access is member-derived (#1136), not owner-set. You may read a skillset iff you may read every one of its members at the requested revision. Non-owners who fail that test get a flat 404 — never a 403, and never a partial member list — so the existence of a private member is not leaked. Owners and platform admins always see the skillset and additionally get `unreadableMembers` populated with the refs THEY have lost access to, which is the repair signal.", + "Both a GUID and the unique kebab-case name work as `idOrName`; the GUID lookup is tried first. Ask for a historical revision with `?version=`; anything unrecognised is a 404 rather than a validation error on this endpoint.", + ].join("\n\n"), + operationId: "getSkillset", + tags: ["Skillsets"], + security: optionalAuth(), + parameters: [ + pathParam( + "idOrName", + "Skillset GUID or unique kebab-case name. GUID is tried first, then name.", + { type: "string" }, + "pdf-review-set", + ), + queryParam( + "version", + "Revision to read, `.` (e.g. `1.2`). Defaults to the skillset's `latestVersion`; an empty value is treated as absent. This endpoint does not validate the shape — an unknown or malformed revision simply yields 404 `skillset_version_not_found`.", + { type: "string", examples: ["1.2"] }, + ), + ], + responses: { + ...jsonResponse(skillsetDetailSchema, "The skillset at the requested (or latest) revision.", { example: detailExample }), + ...problemResponses({ + 404: "Not found — no such skillset (`skillset_not_found`), the requested revision does not exist (`skillset_version_not_found`), or you cannot read every member at that revision. All three are the same flat 404 on purpose.", + }), + }, + }, + }, + + [`${prefix}/skillsets/{id}`]: { + put: { + summary: "Publish a new skillset revision", + description: [ + "Append a new immutable revision. Published revisions are never mutated — this writes a new version document and advances the skillset's `latestVersion`, leaving every prior revision byte-identical for anyone who pinned it.", + "The revision number is NOT yours to choose (#1162): the system bumps the minor off the current latest (`1.0 → 1.1 → 1.2`; the major never auto-bumps). Any `version` field you send is ignored by validation.", + "`instructions` and `members` are REQUIRED on every publish. `instructions` has no carry-forward — each revision must state its own master prompt explicitly — whereas `description`, `kind`, and `tags` inherit the previous values when omitted. `name` is immutable and cannot be published over. Members are re-validated exactly as at create: every ref must resolve, and the union closure must be conflict-free and acyclic, checked BEFORE any write.", + "Publishing does not touch plugin export — `exportAsPlugin` and `pluginConfig` are only ever changed by `PUT /skillsets/{id}/plugin-export`. It does, however, re-derive the visibility state from the new member set, so swapping in a private member can flip a public skillset to `restricted` and drop it out of other people's search results.", + "Requires the `ornn:skill:update` request scope PLUS the object WRITE tier (CONVENTIONS.md §5.4): the owner, a platform admin, or a `write` grantee. A `read` grantee gets 403.", + ].join("\n\n"), + operationId: "publishSkillsetVersion", + tags: ["Skillsets"], + security: bearerAuth(), + parameters: [ + pathParam( + "id", + "Skillset GUID. Unlike the read endpoints, the write paths accept the GUID only — a name is not resolved here.", + { type: "string", format: "uuid" }, + SKILLSET_ID_EXAMPLE, + ), + ], + requestBody: jsonBody( + publishSkillsetSchema, + [ + "The new revision's content. `instructions` and `members` are required; `description`, `kind`, and `tags` inherit from the previous revision when omitted.", + `Each entry of \`members\` must be \`@\` or \`@\`; semver ranges, patch digits, and \`skillset:\`-prefixed refs are rejected. That grammar comes from refinements that do not survive into this JSON Schema, so validate it client-side.`, + "Sending `name` or `version` has no effect — the name is immutable and the revision is system-assigned. Unknown properties are stripped rather than rejected.", + ].join(" "), + { + example: { + description: "Extract, diff, and summarise PDF contracts.", + instructions: "Run `pdf-tools`, then `contract-diff`, then `report-writer`. Stop after `contract-diff` when no differences are found.", + kind: "consensus-supported", + tags: ["review", "pdf"], + members: ["pdf-tools@1.1", "contract-diff@2.1", "report-writer@latest"], + }, + }, + ), + responses: { + ...jsonResponse(skillsetDetailSchema, "The skillset at its newly published revision.", { + example: { ...detailExample, version: "1.1", latestVersion: "1.1" }, + }), + ...problemResponses( + { 400: "Bad request — the body failed validation (`invalid_skillset`; see `detail`), e.g. a missing `instructions`, fewer than 2 members, or a member ref using a semver range." }, + 401, + { 403: "Forbidden — the token lacks the `ornn:skill:update` scope, or you hold only READ on this skillset (`forbidden`). Publishing needs the WRITE tier." }, + { 404: "Not found — no skillset with this GUID (`skillset_not_found`), or a member ref does not resolve to an existing skill version (`skill_dependency_not_found`). Nothing was written." }, + { + 409: "Conflict — the member graph is undeliverable (`dependency_conflict` / `dependency_cycle`), or a concurrent publish already claimed the next revision (`skillset_version_exists`). Re-read `latestVersion` and retry.", + }, + ), + }, + }, + delete: { + summary: "Delete a skillset and all its revisions", + description: [ + "Permanently delete the skillset identity document and cascade-delete every published revision. This is irreversible and there is no soft-delete or restore.", + "Member skills are NOT touched — a skillset is pure metadata over refs, so deleting it removes the bundle and its master prompt, never any skill package. Anyone who had pinned a revision loses the ability to resolve it; re-creating a skillset with the same name produces a fresh GUID starting at revision `1.0`.", + `If the skillset was exported as a Claude Code plugin it drops out of the public mirror shortly after this call (the reconcile is fire-and-forget, so the removal is not synchronous with this response).`, + "Returns 200 with `{ success: true }`, not 204. Requires the `ornn:skill:delete` request scope PLUS the object ADMIN tier (CONVENTIONS.md §5.4): only the owner or a platform admin. A `write` grantee is deliberately not enough.", + ].join("\n\n"), + operationId: "deleteSkillset", + tags: ["Skillsets"], + security: bearerAuth(), + parameters: [ + pathParam( + "id", + "Skillset GUID. The write paths do not resolve names.", + { type: "string", format: "uuid" }, + SKILLSET_ID_EXAMPLE, + ), + ], + responses: { + ...jsonResponse( + { + type: "object", + required: ["success"], + properties: { success: { type: "boolean", description: "Always `true`. A failure arrives as an RFC 7807 problem body instead." } }, + }, + "Skillset and all of its revisions were deleted.", + { example: { success: true } }, + ), + ...problemResponses( + 401, + { 403: "Forbidden — the token lacks the `ornn:skill:delete` scope, or you are neither the owner nor a platform admin (`forbidden`). Deletion is ADMIN-tier; a `write` grant does not qualify." }, + { 404: "Not found — no skillset with this GUID (`skillset_not_found`). Deletion is not idempotent: a second call on the same GUID returns 404." }, + ), + }, + }, + }, + + [`${prefix}/skillsets/{id}/plugin-export`]: { + put: { + summary: "Enable or disable Claude Code plugin export", + description: [ + "Toggle whether this skillset is published into Ornn's public mirror as ONE curated multi-skill Claude Code plugin (#1155), and persist the owner's listing overrides (#1157).", + `Only the PUBLIC, resolvable subset of the members is ever bundled — private or broken members are silently dropped from the export, so a \`restricted\` skillset can still export its public part. Because a bundle below the floor is not a meaningful set, enabling requires at least ${SKILLSET_MIN_PUBLIC_EXPORT_MEMBERS} public resolvable members in the latest revision; check \`publicMemberCount\` on the detail response before calling with \`enabled: true\`. Disabling is always permitted and additionally clears any stored overrides.`, + "`displayName`, `description`, and `keywords` are OPTIONAL listing overrides. Omit them (or send blank strings) and the mirror falls back to the skillset's own `name` / `description` / `tags`. Two fields are deliberately NOT overridable: the install name (always the skillset name, which is what makes `/plugin install @` collision-free) and the plugin version (always the system-managed skillset revision, which is what gives Claude Code its update signal).", + "This is a separate endpoint from publish on purpose — a publish never flips the opt-in, and this call never cuts a revision. The mirror reconcile it triggers is fire-and-forget, so the plugin appears or disappears shortly AFTER this response, not with it.", + "Requires the `ornn:skill:update` request scope PLUS the object WRITE tier (CONVENTIONS.md §5.4).", + ].join("\n\n"), + operationId: "setSkillsetPluginExport", + tags: ["Skillsets"], + security: bearerAuth(), + parameters: [ + pathParam( + "id", + "Skillset GUID. The write paths do not resolve names.", + { type: "string", format: "uuid" }, + SKILLSET_ID_EXAMPLE, + ), + ], + requestBody: jsonBody( + pluginExportSchema, + "`enabled` is required; the three override fields are optional and only meaningful when enabling. Sending `enabled: false` clears any stored overrides.", + { + example: { + enabled: true, + displayName: "PDF Review Set", + description: "Extract, diff, and summarise PDF contracts.", + keywords: ["pdf", "review", "contracts"], + }, + }, + ), + responses: { + ...jsonResponse(skillsetDetailSchema, "The skillset detail with the updated `exportAsPlugin` flag and `pluginConfig` overrides.", { + example: { + ...detailExample, + exportAsPlugin: true, + pluginConfig: { displayName: "PDF Review Set", description: "Extract, diff, and summarise PDF contracts.", keywords: ["pdf", "review", "contracts"] }, + }, + }), + ...problemResponses( + { 400: "Bad request — the body failed validation (`invalid_plugin_export`; see `detail`), e.g. a missing `enabled`, or a keyword that is not kebab-case." }, + 401, + { 403: "Forbidden — the token lacks the `ornn:skill:update` scope, or you hold only READ on this skillset (`forbidden`)." }, + { 404: "Not found — no skillset with this GUID (`skillset_not_found`)." }, + { + 409: `Conflict — \`skillset_too_few_public_members\`: enabling was refused because the latest revision has fewer than ${SKILLSET_MIN_PUBLIC_EXPORT_MEMBERS} public, resolvable members. Make more members public (or publish a revision that includes them) and retry.`, + }, + ), + }, + }, + }, + + [`${prefix}/skillsets/{id}/transfer-ownership`]: { + post: { + summary: "Transfer skillset ownership to another user", + description: [ + "Hand a skillset to another Ornn user (#1123). The transfer is immediate and irreversible from the caller's side — only the NEW owner (or a platform admin) can transfer it back.", + "After the transfer: `createdBy` and the owner labels point at the target; the prior owner is recorded as an explicit READ grantee, for parity with the skill transfer path; and any pre-existing grant held by the new owner is dropped, since implicit ownership supersedes it. The skillset's GUID, name, revisions, and member set are all unchanged, and no new revision is cut.", + "Do not read that READ grant as preserved access: a skillset's readability is member-derived (#1136) and never consults grants, so the prior owner keeps visibility only for as long as they can read every member skill — and they lose the owner-only view (`unreadableMembers`, plus seeing the set at all while a member is unreadable) immediately.", + "The target must be a known Ornn user — someone who has signed in at least once, so Ornn's own user directory has a row for them. The lookup happens INSIDE the authorization boundary: a caller who is not the owner gets 403 before any lookup runs, so this endpoint cannot be used to probe whether a given user id exists.", + "Requires the `ornn:skill:update` request scope PLUS the object ADMIN tier (CONVENTIONS.md §5.4): owner or platform admin only. A `write` grantee gets 403 — matching the equivalent skill endpoint.", + ].join("\n\n"), + operationId: "transferSkillsetOwnership", + tags: ["Skillsets"], + security: bearerAuth(), + parameters: [ + pathParam( + "id", + "Skillset GUID. The write paths do not resolve names.", + { type: "string", format: "uuid" }, + SKILLSET_ID_EXAMPLE, + ), + ], + requestBody: jsonBody(transferOwnershipBodySchema, "The new owner's NyxID person user_id.", { + example: { newOwnerUserId: "usr_01HQ9M7B4T" }, + }), + responses: { + ...jsonResponse( + { + type: "object", + required: ["skillset"], + properties: { + skillset: skillsetDetailSchema, + }, + }, + "Ownership transferred. The payload nests the updated skillset detail under `skillset` — note this differs from the other write endpoints, which return the detail object directly.", + { example: { skillset: { ...detailExample, createdBy: "usr_01HQ9M7B4T" } } }, + ), + ...problemResponses( + { + 400: "Bad request — the body failed validation (`invalid_transfer`; `newOwnerUserId` must be a 1..128-char string), or the target is not a known Ornn user (`invalid_transfer_target`) because they have never signed in to Ornn.", + }, + 401, + { 403: "Forbidden — the token lacks the `ornn:skill:update` scope, or you are neither the owner nor a platform admin (`forbidden`). Transfer is ADMIN-tier." }, + { 404: "Not found — no skillset with this GUID (`skillset_not_found`)." }, + { 409: "Conflict — `ownership_conflict`: the target already owns this skillset. Nothing was changed." }, + ), + }, + }, + }, + }; +} diff --git a/ornn-api/src/openapi/paths/system.ts b/ornn-api/src/openapi/paths/system.ts new file mode 100644 index 00000000..01465e0f --- /dev/null +++ b/ornn-api/src/openapi/paths/system.ts @@ -0,0 +1,150 @@ +/** + * Service-level endpoints: the machine-readable contract itself and the + * Kubernetes probes (#1214). + * + * These are the only operations in the spec that do not live behind the + * `/api/v1` prefix — the probes are registered on the root Hono app in + * `bootstrap.ts`, deliberately outside the versioned surface so an API + * version bump never moves a liveness URL out from under a running + * deployment. + * + * They are also the only operations whose bodies are NOT wrapped in the + * `{ data, error }` envelope, and `/readyz` is the one endpoint whose + * failure response is plain `application/json` rather than RFC 7807 — + * it returns its 503 directly instead of throwing through the global + * error handler. Documented as it actually behaves, not as convention + * would prefer. + * + * @module openapi/paths/system + */ + +import { + publicAuth, + rawJsonResponse, + type JsonSchema, + type PathMap, +} from "../helpers"; + +const probeBody: JsonSchema = { + type: "object", + required: ["status", "service", "version", "timestamp"], + properties: { + status: { type: "string", enum: ["ok"], description: "Always `ok` — the handler only runs if the process is alive." }, + service: { type: "string", enum: ["ornn-api"], description: "Service identity, so a probe pointed at the wrong pod is obvious." }, + version: { type: "string", description: "Running ornn-api package version.", examples: ["0.16.1"] }, + timestamp: { type: "string", format: "date-time", description: "Server clock at the moment of the check (ISO 8601, UTC)." }, + }, +}; + +const readyBody: JsonSchema = { + type: "object", + required: ["status", "service", "mongoLatencyMs"], + properties: { + status: { type: "string", enum: ["ready"] }, + service: { type: "string", enum: ["ornn-api"] }, + mongoLatencyMs: { + type: "integer", + description: "Round-trip time of the MongoDB `ping` this probe just issued, in milliseconds.", + }, + }, +}; + +const notReadyBody: JsonSchema = { + type: "object", + required: ["status", "reason"], + properties: { + status: { type: "string", enum: ["not_ready"] }, + reason: { type: "string", enum: ["mongo_unreachable"], description: "Which dependency failed the readiness check." }, + }, +}; + +function livenessOperation(operationId: string, summary: string, description: string): Record { + return { + summary, + description, + operationId, + tags: ["System"], + security: publicAuth(), + responses: rawJsonResponse(probeBody, "The process is alive and serving."), + }; +} + +export function systemPaths(prefix: string): PathMap { + return { + [`${prefix}/openapi.json`]: { + get: { + summary: "Fetch this OpenAPI document", + description: + "Returns the complete OpenAPI 3.1 description of the `/api/v1` surface — the same document you are reading. This is the contract's source of truth (CONVENTIONS.md §10): it is generated at boot from the server's own Zod schemas, so it cannot drift from the running validators. Agents and code generators should fetch this at integration time rather than pinning a vendored copy. The body is the raw OpenAPI document at the root — it is not wrapped in the `{ data, error }` envelope that the rest of the API uses. Public; no authentication required. `info.version` reports the running ornn-api release and `servers[0].url` reports this deployment's public base URL, so the document is self-locating.\n\nThe document is around a megabyte — every schema is inlined rather than `$ref`-ed, so each operation is self-contained and no generator has to resolve pointers. It is serialized once at boot and served with a strong `ETag` plus `Cache-Control: public, max-age=300`. Send `If-None-Match` with the stored `ETag` on subsequent fetches: the answer is a bodyless `304` for as long as the deployment is unchanged, which is the difference between a hash comparison and a megabyte of transfer. The `ETag` changes only when the API does, so it is also a cheap way to detect that a deployment has been upgraded.", + operationId: "getOpenApiSpec", + tags: ["System"], + security: publicAuth(), + responses: { + ...rawJsonResponse( + { type: "object", description: "An OpenAPI 3.1 document." }, + "The OpenAPI 3.1 document describing this API.", + { + headers: { + ETag: { + description: + "Strong validator over the serialized document. Stable for the lifetime of a deployment; changes when the API changes. Echo it back in `If-None-Match`.", + schema: { type: "string" }, + }, + "Cache-Control": { + description: "`public, max-age=300` — the document is public and static between deployments.", + schema: { type: "string" }, + }, + }, + }, + ), + 304: { + description: + "Your `If-None-Match` matched the current `ETag` — the document is unchanged since you last fetched it. No body is returned; keep using your cached copy.", + }, + }, + }, + }, + + "/livez": { + get: livenessOperation( + "getLiveness", + "Liveness probe", + "Kubernetes liveness probe. Returns 200 as long as the process can serve a request; it performs **no** dependency checks, so it stays green while MongoDB or the LLM gateway are down. Use `/readyz` to decide whether a pod should receive traffic, and this one only to decide whether it should be restarted. Public, uncached, cheap enough to poll on a one-second interval.", + ), + }, + + "/health": { + get: livenessOperation( + "getHealth", + "Liveness probe (deprecated alias)", + "Backward-compatible alias for `/livez`, serving the identical handler and body. Retained for deployments whose manifests still point here. New integrations should use `/livez`; this path is kept for compatibility and may be removed in a future major version.", + ), + }, + + "/readyz": { + get: { + summary: "Readiness probe", + description: + "Kubernetes readiness probe. Issues a MongoDB `ping` with a 2-second timeout and reports whether this instance can actually serve dependent traffic. Returns 200 with the observed ping latency when the database answers, and 503 when it does not, so the pod is drained from the load balancer until it recovers. Unlike every other endpoint, the 503 body here is plain `application/json` with `{ status, reason }` — it is returned directly rather than raised through the global RFC 7807 error handler. Public and unauthenticated.", + operationId: "getReadiness", + tags: ["System"], + security: publicAuth(), + responses: { + ...rawJsonResponse(readyBody, "MongoDB answered the ping — this instance is ready for traffic."), + 503: { + description: + "A required dependency is unreachable (MongoDB did not answer within 2 seconds). Not RFC 7807 — this response is emitted directly by the probe handler.", + content: { "application/json": { schema: notReadyBody } }, + }, + }, + }, + }, + }; +} + +/** + * Exported for the contract test: the probe paths intentionally sit outside + * `/api/v1`, so the "every documented path carries the version prefix" + * assertion has to know about them explicitly rather than inferring it. + */ +export const UNVERSIONED_SYSTEM_PATHS: readonly string[] = ["/livez", "/health", "/readyz"]; diff --git a/ornn-api/src/openapi/paths/usersMirror.ts b/ornn-api/src/openapi/paths/usersMirror.ts new file mode 100644 index 00000000..d6e46c1b --- /dev/null +++ b/ornn-api/src/openapi/paths/usersMirror.ts @@ -0,0 +1,517 @@ +/** + * User directory + GitHub mirror coordinates (#1214). + * + * Two small, unrelated-looking surfaces that share one property: both are + * *lookup* endpoints an integrator needs before they can do the thing they + * actually came for. + * + * - `/users/search` and `/users/resolve` translate between the two + * identifiers a human and the API disagree about. Every access-control + * endpoint in this API speaks NyxID `user_id`; every human speaks email. + * These two routes are the only bridge, in both directions. + * - `/github/repo` reports (and, for admins, sets) the repository Ornn + * mirrors published skills into. The read side is public because the + * `npx skills add //` install snippet on a public + * skill page has to render for anonymous visitors. + * + * Hand-written JSON Schemas are used throughout rather than the domain Zod + * schemas, deliberately and in each case for a concrete reason recorded at + * the definition site: the directory route's query schema is module-private + * (`domains/users/routes.ts`) and its result rows have no Zod definition at + * all, while the mirror route validates its body as a bare + * `z.record(z.string(), z.unknown())` and its responses are a *projection* + * of `mirrorSchema` — a different field set on the way out, with + * `appPrivateKey` mid-masked. Deriving from those schemas would document + * shapes the handlers do not actually produce. + * + * The two admin mirror *operations* (`POST /admin/mirror/reconcile`, + * `GET /admin/mirror/status`) live in the same Hono router but are + * documented with the rest of the admin surface, not here. + * + * @module openapi/paths/usersMirror + */ + +import { + bearerAuth, + jsonBody, + jsonResponse, + problemResponses, + publicAuth, + queryParam, + type JsonSchema, + type PathMap, +} from "../helpers"; + +// --------------------------------------------------------------------------- +// Shared fragments +// --------------------------------------------------------------------------- + +/** + * RFC 9239 headers emitted by `middleware/rateLimit` on *every* response + * from the directory surface, success or 429. Both directory routes mount + * the same limiter instance under the `users-directory` label, so these + * numbers describe one shared bucket rather than a per-route allowance. + */ +const directoryRateLimitHeaders: Record = { + "RateLimit-Limit": { + description: + "Requests allowed per window across the whole user-directory surface. Default 30 per 60 seconds; operators may retune it via `ORNN_USER_DIRECTORY_RATELIMIT_PER_MIN`, so read the header rather than hardcoding 30.", + schema: { type: "integer", examples: [30] }, + }, + "RateLimit-Remaining": { + description: + "Requests left in the current window for this caller. Shared between `GET /users/search` and `GET /users/resolve` — spending it on one reduces it for the other. Self-throttle as it approaches 0.", + schema: { type: "integer", examples: [29] }, + }, + "RateLimit-Reset": { + description: + "Seconds until the window resets and `RateLimit-Remaining` returns to `RateLimit-Limit`. A 429 additionally repeats this value in `Retry-After`.", + schema: { type: "integer", examples: [42] }, + }, +}; + +/** + * `Retry-After` — emitted only on the 429. `middleware/rateLimit` sets it to + * the same second count as `RateLimit-Reset` just before throwing, and Hono + * carries prepared headers through the error handler onto the + * `application/problem+json` body. + */ +const retryAfterHeader: Record = { + "Retry-After": { + description: + "Seconds to wait before retrying. Always the same value as `RateLimit-Reset` on the same response.", + schema: { type: "integer", examples: [42] }, + }, +}; + +/** + * Attach the rate-limit headers to a 429 built by `problemResponses()`. + * + * `problemResponses()` has no per-status header slot, so declaring them has + * to happen here. Skipping it leaves a generated client told to back off + * with no declared header to read the back-off interval from — the headers + * are on the wire either way. + */ +function withRateLimitHeaders(responses: Record): Record { + const rateLimited = responses["429"]; + if (rateLimited !== undefined && typeof rateLimited === "object" && rateLimited !== null) { + (rateLimited as Record).headers = { + ...directoryRateLimitHeaders, + ...retryAfterHeader, + }; + } + return responses; +} + +/** + * One row of the user directory, as both `/users/search` and + * `/users/resolve` return it. + * + * Hand-written: `UserDirectoryRepository.searchByEmailPrefix` and + * `findByUserIds` both project down to this three-field shape in plain + * TypeScript (`domains/users/repository.ts`). There is no Zod definition + * of it anywhere to reuse — the collection's `UserDirectoryDoc` is a wider + * interface that includes `firstSeenAt` / `lastSeenAt` / `activityCount` / + * `isAdmin`, none of which these two routes expose. + */ +const userDirectoryEntrySchema: JsonSchema = { + type: "object", + required: ["userId", "email", "displayName"], + properties: { + userId: { + type: "string", + description: + "NyxID `user_id` — the opaque identifier every access-control endpoint in this API expects (`PUT /skills/{id}/permissions`, ownership transfer, quota administration). Treat it as an opaque string; the format is NyxID's to change.", + examples: ["usr_2b91c7d4"], + }, + email: { + type: "string", + format: "email", + description: + "Last-known email address for this user, refreshed on every authenticated request Ornn sees from them. This is the field `GET /users/search` matches its prefix against.", + examples: ["ada@example.com"], + }, + displayName: { + type: "string", + description: + "Last-known human label, taken from the identity token's `name` claim and falling back to the email address (then the `userId`) when that claim is absent. Display only — never match or key on it.", + examples: ["Ada Lovelace"], + }, + }, +}; + +/** `{ items: [...] }` — the envelope `data` payload both directory routes return. */ +const userDirectoryListSchema: JsonSchema = { + type: "object", + required: ["items"], + properties: { + items: { + type: "array", + description: + "Matching directory rows. May be shorter than requested and may be empty; an empty array is a normal answer, not an error.", + items: userDirectoryEntrySchema, + }, + }, +}; + +const directoryExample = { + items: [ + { userId: "usr_2b91c7d4", email: "ada@example.com", displayName: "Ada Lovelace" }, + { userId: "usr_7c04f118", email: "alan@example.com", displayName: "Alan Turing" }, + ], +}; + +// --------------------------------------------------------------------------- +// Mirror payloads +// --------------------------------------------------------------------------- + +/** + * Public projection of the mirror settings section. + * + * Hand-written rather than derived from `mirrorSchema` + * (`domains/settings/sections/mirror.ts`) because the handler returns a + * strict *subset* of that section — credentials and `reconcileSchedule` are + * intentionally withheld from the anonymous read — and because `mirrorSchema` + * carries no `.describe()` text, which is the entire point of this document. + */ +const mirrorPublicConfigSchema: JsonSchema = { + type: "object", + required: ["owner", "repo", "branch", "enabled"], + properties: { + owner: { + type: "string", + description: + "GitHub account or organisation that owns the mirror repository. Empty string on a deployment that has never configured the mirror.", + examples: ["ChronoAIProject"], + }, + repo: { + type: "string", + description: "Repository name inside `owner`. Empty string when unconfigured.", + examples: ["ornn-skills"], + }, + branch: { + type: "string", + description: + "Branch the mirror commits published skills to. Empty string when unconfigured — an empty branch leaves the mirror inoperable even if `enabled` is `true`.", + examples: ["main"], + }, + enabled: { + type: "boolean", + description: + "Master kill switch. Check this BEFORE using the coordinates: when `false`, `owner`/`repo`/`branch` may still hold stale values from a previous configuration and no install snippet should be advertised.", + examples: [true], + }, + }, +}; + +/** + * Admin projection returned by `POST /github/repo` — every editable field + * of the mirror section with `appPrivateKey` mid-masked. + * + * Also hand-written, and for a stronger reason than the public read: the + * value in `appPrivateKey` here is NOT what `mirrorSchema` describes. It is + * the output of `midMaskSecret()`, a cosmetic string built from the bullet + * character, so documenting it as the section's raw `z.string()` would tell + * a client the response contains a usable key. It does not. + */ +const mirrorAdminConfigSchema: JsonSchema = { + type: "object", + required: ["enabled", "owner", "repo", "branch", "appId", "installationId", "appPrivateKey"], + properties: { + enabled: { + type: "boolean", + description: "The kill switch as now stored.", + }, + owner: { type: "string", description: "Mirror repository owner as now stored. Empty string means cleared.", examples: ["ChronoAIProject"] }, + repo: { type: "string", description: "Mirror repository name as now stored. Empty string means cleared.", examples: ["ornn-skills"] }, + branch: { type: "string", description: "Mirror branch as now stored. Empty string means cleared.", examples: ["main"] }, + appId: { + type: "string", + description: "GitHub App id as now stored, as a decimal string. Empty string means cleared.", + examples: ["1234567"], + }, + installationId: { + type: "string", + description: "GitHub App installation id as now stored, as a decimal string. Empty string means cleared.", + examples: ["87654321"], + }, + appPrivateKey: { + type: "string", + description: + "The stored private key, MID-MASKED for display: first four and last four characters kept, everything between replaced by the bullet character `•` (anything eight characters or shorter is fully blurred, and a cleared key is `\"\"`). This is never a usable key. It is, however, the sentinel: posting a string containing `•` back to this endpoint means \"keep the key you already have\", which is what makes a round-trip through an admin form safe.", + examples: ["----••••••••••••••••••••••••----"], + }, + }, +}; + +/** + * Request body for `POST /github/repo`. + * + * The route's `validateBody` middleware uses `z.record(z.string(), + * z.unknown())` on purpose — it only gates "is this a JSON object at all", + * so a `SyntaxError` becomes a clean 400 (#438). Every per-field check is + * hand-rolled in the handler afterwards. Emitting the record schema would + * publish `additionalProperties: {}` and document nothing, so the real + * accepted shape is spelled out here instead. `additionalProperties` is left + * open because the handler genuinely ignores unknown keys rather than + * rejecting them. + */ +const mirrorConfigPatchBodySchema: JsonSchema = { + type: "object", + description: + "Every field is optional. A key you omit is preserved exactly as stored; a key you send as an empty string is CLEARED. Unknown keys are ignored. `reconcileSchedule` is part of the mirror settings section but is not editable here — it is carried through untouched; use the platform settings API to change it.", + properties: { + enabled: { + type: "boolean", + description: + "Master kill switch. `false` halts mirroring — `POST /admin/mirror/reconcile` starts answering 503 `mirror_disabled` — without discarding coordinates or credentials, so it is the safe way to pause. Must be a real JSON boolean; the string `\"true\"` is rejected with 400 `invalid_setting`.", + examples: [true], + }, + owner: { + type: "string", + description: + "GitHub account or organisation owning the mirror repository. 1–39 characters of letters, digits, and dashes, with no leading or trailing dash. Whitespace is trimmed. Empty string clears it. Changing this arms the abandon-confirm guard — see the 409 response.", + pattern: "^$|^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$", + examples: ["ChronoAIProject"], + }, + repo: { + type: "string", + description: + "Repository name inside `owner`. 1–100 characters of letters, digits, dot, dash, or underscore. Whitespace is trimmed. Empty string clears it. Changing this arms the abandon-confirm guard — see the 409 response.", + pattern: "^$|^[A-Za-z0-9._-]{1,100}$", + examples: ["ornn-skills"], + }, + branch: { + type: "string", + description: + "Branch the mirror commits to. Any non-empty string up to 250 characters; C0 control characters and DEL are rejected. Whitespace is trimmed. Empty string clears it, which leaves the mirror inoperable. Unlike `owner`/`repo`, changing the branch does NOT arm the abandon-confirm guard and does not clear sync stamps.", + maxLength: 250, + examples: ["main"], + }, + appId: { + type: "string", + description: + "GitHub App id, sent as a decimal STRING of 1–15 digits (`\"1234567\"`, not `1234567`) — a JSON number is rejected with 400 `invalid_setting`. Empty string clears it.", + pattern: "^$|^[0-9]{1,15}$", + examples: ["1234567"], + }, + installationId: { + type: "string", + description: + "Installation id of that App on `owner`, sent as a decimal STRING of 1–20 digits. Empty string clears it.", + pattern: "^$|^[0-9]{1,20}$", + examples: ["87654321"], + }, + appPrivateKey: { + type: "string", + description: + "PEM-encoded private key for the GitHub App. Accepts PKCS#1 (`-----BEGIN RSA PRIVATE KEY-----`, what GitHub hands you) or PKCS#8 (`-----BEGIN PRIVATE KEY-----`), at most 8192 bytes, containing no control bytes other than tab/CR/LF; it is normalised to LF, structurally parsed, and encrypted at rest. Three special cases, in this order: any string containing the mid-mask bullet `•` PRESERVES the stored key (round-trip the value from the response and nothing changes), `\"\"` CLEARS it, and anything else must parse as a private key or the request fails with 400 `invalid_setting`.", + maxLength: 8192, + }, + confirmAbandonOldRepo: { + type: "boolean", + description: + "Acknowledgement that changing `owner`/`repo` abandons the repository Ornn has been mirroring into and wipes every skill's sync stamp. Only consulted when the coordinates actually change AND at least one skill is currently stamped; ignored otherwise. Strictly compared against `true` — `\"true\"` or `1` count as NOT confirmed.", + examples: [true], + }, + }, +}; + +// --------------------------------------------------------------------------- +// Paths +// --------------------------------------------------------------------------- + +export function usersMirrorPaths(prefix: string): PathMap { + return { + [`${prefix}/users/search`]: { + get: { + summary: "Find users by email prefix", + description: [ + "Typeahead lookup that turns an email prefix into the NyxID `user_id` values the rest of this API expects. It is the resolution step before granting access: `PUT /skills/{id}/permissions`, skill and skillset ownership transfer, and the quota-administration endpoints all take `user_id`s, never email addresses, and this is the only way an ordinary caller can obtain one.", + "The directory contains only users Ornn has actually seen authenticate — a row is lazily upserted on every authenticated request. Somebody who has never signed in to this deployment is not searchable and cannot be granted access until they do; that is the expected explanation for an empty result, not a bug. Matching is an ANCHORED, case-insensitive prefix on `email` only — display names are not searched, and `ada` will not find `not-ada@example.com`. Regex metacharacters in `q` are escaped server-side, so a literal `.` matches a `.`. Rows come back most-recently-active first and are truncated to `limit`.", + "Any authenticated caller may search; there is deliberately no admin gate, because sharing a skill requires being able to find the person to share it with. Two guards keep that from becoming a directory dump: `q` must be at least 2 characters (a deployment can raise the floor with `ORNN_USER_SEARCH_MIN_Q`, never lower it), and the whole directory surface is rate limited.", + "Rate limit: 30 requests per 60 seconds by default, keyed on the authenticated `userId`. The budget is SHARED with `GET /users/resolve` — one bucket, one label — so alternating between the two endpoints does not double your allowance. Every response carries `RateLimit-Limit` / `RateLimit-Remaining` / `RateLimit-Reset`; read them instead of retrying blind.", + "Use `GET /users/resolve` instead when you already hold `user_id`s — for example the `sharedWithUsers` array on a skill — and want labels for them. A prefix search over emails can never match an opaque id.", + ].join("\n\n"), + operationId: "searchUserDirectory", + tags: ["Users"], + security: bearerAuth(), + parameters: [ + { + ...queryParam( + "q", + "Email prefix to match, case-insensitive and anchored at the start of the address. Leading and trailing whitespace is trimmed before validation. Required, and must be at least 2 characters after trimming — an empty or 1-character value is rejected with 400 and never reaches the database, so this endpoint cannot be walked one letter at a time. Maximum 256 characters.", + { type: "string", minLength: 2, maxLength: 256 }, + true, + ), + example: "ada", + }, + { + ...queryParam( + "limit", + "Maximum number of rows to return, 1–50. Coerced from the query string, so `limit=5` works; a non-numeric or out-of-range value is a 400 rather than a silent clamp. Defaults to 10 when omitted. There is no pagination here — raise `limit` or narrow `q`.", + { type: "integer", minimum: 1, maximum: 50, default: 10 }, + ), + example: 10, + }, + ], + responses: { + ...jsonResponse( + userDirectoryListSchema, + "Directory rows whose email starts with `q`, most recently active first. An empty `items` array means nobody matching that prefix has ever authenticated against this deployment.", + { headers: directoryRateLimitHeaders, example: directoryExample }, + ), + ...withRateLimitHeaders( + problemResponses( + { + 400: + "Bad request (`invalid_query`) — `q` is absent, shorter than the 2-character minimum after trimming, or longer than 256 characters; or `limit` is not an integer in 1–50. `detail` names the offending field. No database query is issued.", + }, + 401, + { + 429: + "Rate limited (`rate_limited`) — the shared `users-directory` budget (30 requests / 60 s per user by default) is spent. `Retry-After` and `RateLimit-Reset` both give the seconds to wait. `GET /users/resolve` draws from the same bucket, so switching endpoints does not help.", + }, + // The directory query goes straight to MongoDB with no + // retry or fallback, so a driver-level failure surfaces here. + 500, + ), + ), + }, + }, + }, + + [`${prefix}/users/resolve`]: { + get: { + summary: "Resolve user_ids to directory labels", + description: [ + "Batch id → label lookup: hand it NyxID `user_id`s, get back the last-known `email` and `displayName` for each. It is the exact inverse of `GET /users/search`, and it exists because a skill's `sharedWithUsers` array stores bare `user_id`s — an email-prefix search can never match an opaque id, so rendering, auditing, or reasoning about an existing grant requires this call.", + "Unknown ids are silently DROPPED rather than returned as nulls, so the response can be shorter than your input and can be empty. Read a missing id as \"this user has never authenticated against this deployment\" (directory rows are created lazily on first authenticated request), not as an error. Order is NOT guaranteed either — build your own map keyed on `userId` instead of zipping the response positionally against what you sent.", + "At most 100 ids are honoured per call: the list is split on commas, entries are trimmed, empty entries are dropped, and everything past the hundredth survivor is ignored WITHOUT an error. Page your input yourself if you have more. Duplicate ids collapse to one row.", + "This route is the one place in the directory surface with no query validation: an absent or empty `ids` parameter returns `{ \"items\": [] }` with 200, and no input shape produces a 400. It does share the `users-directory` rate-limit budget with `GET /users/search` (30 requests / 60 s per user by default, one bucket), and emits the same `RateLimit-*` headers.", + ].join("\n\n"), + operationId: "resolveUserDirectoryEntries", + tags: ["Users"], + security: bearerAuth(), + parameters: [ + { + ...queryParam( + "ids", + "Comma-separated NyxID `user_id`s to resolve. Entries are trimmed and blank entries dropped; only the first 100 survivors are looked up and the remainder are ignored silently. Omitting the parameter, or sending an empty string, yields an empty `items` array with 200 — this parameter is never validated and never causes a 400, and the API imposes no length limit of its own on it. Note this is a CSV parameter: repeating `?ids=` is not supported.", + { type: "string" }, + ), + example: "usr_2b91c7d4,usr_7c04f118", + }, + ], + responses: { + ...jsonResponse( + userDirectoryListSchema, + "The subset of the requested ids that exist in the directory, in unspecified order. Ids Ornn has never seen are omitted entirely.", + { headers: directoryRateLimitHeaders, example: directoryExample }, + ), + ...withRateLimitHeaders( + problemResponses( + 401, + { + 429: + "Rate limited (`rate_limited`) — the shared `users-directory` budget (30 requests / 60 s per user by default) is spent. Because the budget is shared with `GET /users/search`, batching more ids into fewer calls is the correct fix, up to the 100-id ceiling. `Retry-After` and `RateLimit-Reset` both give the seconds to wait.", + }, + // The `$in` lookup goes straight to MongoDB with no retry or + // fallback, so a driver-level failure surfaces here. + 500, + ), + ), + }, + }, + }, + + [`${prefix}/github/repo`]: { + get: { + summary: "Read the public GitHub mirror coordinates", + description: [ + "Returns the repository Ornn mirrors published skills into, plus the kill switch that says whether mirroring is currently on. Public and unauthenticated by design: an anonymous visitor on a public skill page must be able to render the `npx skills add //` install snippet, and that snippet is built from these three coordinates.", + "Check `enabled` before you use anything else. When it is `false` the coordinate fields may still hold values left over from a previous configuration — do not advertise an install command in that state. Treat empty strings the same way: a deployment that has never configured the mirror returns `owner`, `repo`, and `branch` as `\"\"`, and an empty `branch` makes the mirror inoperable even when `enabled` is `true`.", + "GitHub App credentials (`appId`, `installationId`, `appPrivateKey`) are deliberately absent from this response — it is the anonymous read. Platform admins who need the full configuration together with sync counts and the last scheduled-reconcile outcome should call `GET /admin/mirror/status`; writes go through `POST /github/repo` on this same path.", + "The value is served from the platform-settings cache. A write through `POST /github/repo` busts that cache on the pod that handled it, so the change is visible there immediately and elsewhere within the settings cache TTL. Do not poll this endpoint to confirm a write landed — trust the write's own response body.", + ].join("\n\n"), + operationId: "getMirrorRepoConfig", + tags: ["Mirror"], + security: publicAuth(), + responses: { + ...jsonResponse( + mirrorPublicConfigSchema, + "The mirror's public coordinates and its enabled flag. Always 200, including when the mirror is disabled or entirely unconfigured — read `enabled` and the empty-string fields to tell those cases apart.", + { + example: { owner: "ChronoAIProject", repo: "ornn-skills", branch: "main", enabled: true }, + }, + ), + // Reads the platform-settings section, which is a database-backed + // cache lookup and can fail even though the endpoint is public. + ...problemResponses(500), + }, + }, + + post: { + summary: "Update the GitHub mirror configuration", + description: [ + "Partial update of the mirror configuration: the kill switch, the repository coordinates, and the GitHub App credentials Ornn authenticates to GitHub with. Requires the **`ornn:admin:skill`** permission on top of a valid bearer token. Semantics are PATCH-like — a key you omit is preserved exactly as stored, unknown keys are ignored, and `reconcileSchedule` (part of the same settings section but not editable here) is carried through untouched. An empty body is a no-op that returns the current configuration.", + "Two behaviours differ from a naive merge. First, an empty string is a REAL value meaning \"clear this field\": `{\"owner\": \"\"}` unsets the owner rather than leaving it alone. Second, `appPrivateKey` carries a sentinel — the value this API hands back is mid-masked with the bullet character `•`, and posting any string containing `•` means \"keep the stored key\". That is what lets an admin form round-trip the masked display value without wiping the credential. Posting a real PEM replaces the key; posting `\"\"` clears it.", + "Changing `owner` or `repo` abandons the repository Ornn has been mirroring into. If any skill still carries a `mirrorSync` stamp, the request is REFUSED with 409 `old_repo_not_confirmed` and nothing is written; resend the identical body with `confirmAbandonOldRepo: true` to proceed. On a confirmed change every skill's stamp is cleared — those stamps point at commit SHAs in the old repository, so keeping them would produce audit links to the wrong place — and the whole registry reports \"never synced\" until the next reconcile lands a real commit. The old repository is NOT cleaned up; delete its contents yourself if that matters. Changing only `branch`, `enabled`, or the credentials does not trigger any of this.", + "The response echoes the stored configuration with `appPrivateKey` mid-masked; never treat that string as a usable key. To apply the new configuration immediately rather than waiting for the schedule, follow with `POST /admin/mirror/reconcile`, then poll `GET /admin/mirror/status` for counts and the outcome.", + ].join("\n\n"), + operationId: "updateMirrorRepoConfig", + tags: ["Mirror"], + security: bearerAuth(), + requestBody: jsonBody( + mirrorConfigPatchBodySchema, + "Any subset of the editable mirror fields. Omitted keys are preserved, empty strings clear, unknown keys are ignored.", + { + example: { + enabled: true, + owner: "ChronoAIProject", + repo: "ornn-skills", + branch: "main", + confirmAbandonOldRepo: true, + }, + }, + ), + responses: { + ...jsonResponse( + mirrorAdminConfigSchema, + "The mirror configuration as now stored, with `appPrivateKey` mid-masked. Note this is 200, not 201 — the settings section is updated in place, never created as a new resource.", + { + example: { + enabled: true, + owner: "ChronoAIProject", + repo: "ornn-skills", + branch: "main", + appId: "1234567", + installationId: "87654321", + appPrivateKey: "----••••••••••••••••••••••••----", + }, + }, + ), + ...problemResponses( + { + 400: + "Bad request — the body was not a JSON object (`invalid_body`), or a field failed its shape check: `invalid_owner`, `invalid_repo`, `INVALID_BRANCH` (that one really is upper-case — a legacy code kept for compatibility), or `invalid_setting` for `enabled`, `appId`, `installationId`, and `appPrivateKey`. Validation is all-or-nothing: when any field is rejected, nothing at all is persisted.", + }, + 401, + { + 403: + "Forbidden (`forbidden`) — the caller is authenticated but lacks the `ornn:admin:skill` permission this operation requires. NyxID is authoritative for that permission; a user flagged `isAdmin` in the user directory is not necessarily granted it.", + }, + { + 409: + "Conflict (`old_repo_not_confirmed`) — the request changes `owner`/`repo` while skills still carry sync stamps pointing at the current repository. Nothing was written. `detail` names the old coordinates, the new coordinates, and how many skills are affected. Resend the identical body with `confirmAbandonOldRepo: true` to proceed.", + }, + { + 500: + "Internal server error — the settings write, or the follow-up stamp reset, failed. The ordering matters here: the configuration is persisted BEFORE the `mirrorSync` stamps are cleared, so a failure at the second step leaves the new coordinates live with stale stamps still pointing at the abandoned repository. Do not blindly retry — check `GET /admin/mirror/status`, then run `POST /admin/mirror/reconcile` to re-stamp against the new repository.", + }, + ), + }, + }, + }, + }; +} diff --git a/ornn-api/src/openapi/schemas.ts b/ornn-api/src/openapi/schemas.ts deleted file mode 100644 index b40f24c8..00000000 --- a/ornn-api/src/openapi/schemas.ts +++ /dev/null @@ -1,216 +0,0 @@ -/** - * Zod schemas for OpenAPI spec generation. - * Mirrors the request/response types used across all routes. - * @module openapi/schemas - */ - -import { z } from "zod"; - -// --------------------------------------------------------------------------- -// Common -// --------------------------------------------------------------------------- - -export const apiErrorSchema = z.object({ - code: z.string().describe("Machine-readable error code (e.g. SKILL_NOT_FOUND, VALIDATION_ERROR)"), - message: z.string().describe("Human-readable error description"), -}); - -function apiResponse(dataSchema: T) { - return z.object({ - data: dataSchema.nullable(), - error: apiErrorSchema.nullable(), - }); -} - -export const successResponseSchema = apiResponse(z.object({ success: z.boolean() })); - -// --------------------------------------------------------------------------- -// Skill Metadata -// --------------------------------------------------------------------------- - -export const skillMetadataSchema = z.object({ - category: z.string().describe("Skill category determining execution model: 'plain' (prompt-only), 'tool-based' (uses MCP tools), 'runtime-based' (executes code in sandbox), 'mixed' (combines tools and runtime)"), - outputType: z.enum(["text", "file"]).optional().describe("Output type: 'text' returns stdout content, 'file' returns generated files retrieved via glob pattern from sandbox"), - runtimes: z.array(z.object({ - runtime: z.string().describe("Runtime environment identifier: 'node' (Node.js) or 'python'"), - dependencies: z.array(z.object({ - library: z.string().describe("Package name (e.g. 'axios', 'numpy')"), - version: z.string().describe("Semver version constraint (e.g. '^1.0.0', '>=2.0')"), - })).optional().describe("Runtime dependencies to install before execution"), - envs: z.array(z.object({ - var: z.string().describe("Environment variable name (e.g. 'OPENAI_API_KEY')"), - description: z.string().describe("Human-readable description of what this env var is used for"), - })).optional().describe("Environment variables required at execution time, provided by the user"), - })).optional().describe("Runtime configurations for sandbox execution. Each entry defines a runtime with its dependencies and required env vars"), - tools: z.array(z.object({ - tool: z.string().describe("Tool identifier as referenced in the skill prompt"), - type: z.string().describe("Tool type: 'builtin' (provided by platform) or 'mcp' (from MCP server)"), - "mcp-servers": z.array(z.object({ - mcp: z.string().describe("MCP server package name"), - version: z.string().describe("MCP server version"), - })).optional().describe("MCP server dependencies required for this tool"), - })).optional().describe("External tools the skill invokes during LLM execution"), - tags: z.array(z.string()).optional().describe("Classification tags for search and discovery"), -}); - -// --------------------------------------------------------------------------- -// Skill CRUD -// --------------------------------------------------------------------------- - -export const skillDetailResponseSchema = z.object({ - guid: z.string().describe("Unique identifier (UUID) of the skill"), - name: z.string().describe("Skill name, unique across the platform. Used as the human-readable identifier in URLs and references"), - description: z.string().describe("Brief description of what the skill does and when to use it"), - license: z.string().nullable().describe("SPDX license identifier (e.g. 'MIT', 'Apache-2.0'), or null if not specified"), - compatibility: z.string().nullable().describe("Compatible AI model or platform (e.g. 'claude', 'gpt-4'), or null if model-agnostic"), - metadata: z.record(z.string(), z.unknown()).describe("Structured skill metadata including category, outputType, runtimes, tools, and tags. See skill format spec for full schema"), - tags: z.array(z.string()).describe("List of tag names for categorization and search filtering"), - skillHash: z.string().describe("SHA-256 hash of the skill package contents. Changes when the skill is updated"), - isPrivate: z.boolean().describe("If true, only the owner can view and use this skill. If false, the skill is publicly listed in the registry"), - createdBy: z.string().describe("Email address of the user who created the skill"), - createdOn: z.string().describe("ISO 8601 timestamp of when the skill was created"), - updatedOn: z.string().describe("ISO 8601 timestamp of the most recent update"), -}); - -export const skillDetailApiResponse = apiResponse(skillDetailResponseSchema); - -export const skillJsonResponseSchema = z.object({ - name: z.string().describe("Skill name"), - description: z.string().describe("Skill description"), - metadata: z.record(z.string(), z.unknown()).describe("Structured skill metadata (category, outputType, runtimes, tools, tags)"), - files: z.record(z.string(), z.string()).describe("Map of relative file path to file content string. Keys are paths like 'skill.md', 'scripts/run.py'. Values are the full text content of each file. Binary files are excluded"), -}); - -export const skillJsonApiResponse = apiResponse(skillJsonResponseSchema); - -export const updateSkillJsonBodySchema = z.object({ - isPrivate: z.boolean().optional().describe("Set to true to make the skill private (owner-only), or false to make it publicly visible in the registry"), -}); - -// --------------------------------------------------------------------------- -// Skill Search -// --------------------------------------------------------------------------- - -export const searchQuerySchema = z.object({ - query: z.string().max(2000).optional().default("").describe("Free-text search query. For keyword mode, matches against skill name, description, and tags. For semantic mode, uses LLM embeddings to find conceptually related skills. Max 2000 characters. Empty string returns all skills"), - mode: z.enum(["keyword", "semantic"]).optional().default("keyword").describe("Search strategy: 'keyword' performs text matching (fast, exact), 'semantic' uses LLM to find conceptually similar skills (slower, requires model)"), - scope: z.enum(["public", "private", "mixed"]).optional().default("private").describe("Visibility filter: 'public' searches only public skills, 'private' searches only the authenticated user's private skills, 'mixed' searches both"), - page: z.coerce.number().int().min(1).optional().default(1).describe("Page number for pagination, starting from 1"), - pageSize: z.coerce.number().int().min(1).max(100).optional().default(9).describe("Number of results per page (1-100, default 9)"), - model: z.string().optional().describe("LLM model identifier to use for semantic search embedding. Only applicable when mode is 'semantic'. If omitted, uses the platform default model"), -}); - -export const skillSearchItemSchema = z.object({ - guid: z.string().describe("Unique identifier (UUID) of the skill"), - name: z.string().describe("Skill name"), - description: z.string().describe("Brief description of the skill"), - createdBy: z.string().describe("Email of the skill author"), - createdOn: z.string().describe("ISO 8601 creation timestamp"), - updatedOn: z.string().describe("ISO 8601 last-updated timestamp"), - isPrivate: z.boolean().describe("Whether the skill is private (owner-only) or publicly visible"), - tags: z.array(z.string()).describe("Tag names for categorization"), -}); - -export const skillSearchResponseSchema = z.object({ - searchMode: z.string().describe("The search mode that was used: 'keyword' or 'semantic'"), - searchScope: z.string().describe("The visibility scope that was applied: 'public', 'private', or 'mixed'"), - total: z.number().describe("Total number of matching skills across all pages"), - totalPages: z.number().describe("Total number of pages available"), - page: z.number().describe("Current page number"), - pageSize: z.number().describe("Number of items per page"), - items: z.array(skillSearchItemSchema).describe("Array of skill summaries for the current page"), -}); - -export const skillSearchApiResponse = apiResponse(skillSearchResponseSchema); - -// --------------------------------------------------------------------------- -// Skill Generation -// --------------------------------------------------------------------------- - -export const generateJsonBodySchema = z.object({ - messages: z.array(z.object({ - role: z.string().describe("Message role: 'user' or 'assistant'"), - content: z.string().describe("Message text content"), - })).optional().describe("Multi-turn conversation history for iterative skill generation. Use this to refine a skill across multiple exchanges. Mutually exclusive with 'prompt'"), - prompt: z.string().optional().describe("Single-turn natural language prompt describing the skill to generate (e.g. 'Create a skill that summarizes web pages'). Mutually exclusive with 'messages'. Use 'messages' instead for multi-turn refinement"), - model: z.string().optional().describe("LLM model identifier to use for generation. If omitted, uses the platform default model"), -}); - -export const generationStreamEventSchema = z.discriminatedUnion("type", [ - z.object({ type: z.literal("generation_start") }).describe("Emitted when generation begins"), - z.object({ type: z.literal("token"), content: z.string().describe("Incremental text token from the LLM") }).describe("Streamed token from the LLM during generation"), - z.object({ type: z.literal("generation_complete"), raw: z.string().describe("Complete raw LLM output containing the full generated skill in markdown format") }).describe("Emitted when the LLM finishes generating. The 'raw' field contains the full skill package content"), - z.object({ type: z.literal("validation_error"), message: z.string().describe("Description of the validation failure"), retrying: z.boolean().describe("If true, the system is automatically retrying generation with corrected constraints") }).describe("Emitted when the generated skill fails format validation"), - z.object({ type: z.literal("error"), message: z.string().describe("Error description") }).describe("Emitted on unrecoverable generation failure"), -]); - -// --------------------------------------------------------------------------- -// Skill Format -// --------------------------------------------------------------------------- - -export const formatRulesResponseSchema = apiResponse(z.object({ rules: z.string() })); - -export const formatValidationResponseSchema = apiResponse(z.object({ - valid: z.boolean(), - violations: z.array(z.object({ rule: z.string(), message: z.string() })).optional(), -})); - -// --------------------------------------------------------------------------- -// Playground -// --------------------------------------------------------------------------- - -export const chatRequestBodySchema = z.object({ - messages: z.array(z.object({ - role: z.enum(["user", "assistant", "tool", "system"]), - content: z.string(), - toolCalls: z.array(z.object({ - id: z.string(), - name: z.string(), - args: z.record(z.string(), z.unknown()), - })).optional(), - toolCallId: z.string().optional(), - })).min(1).max(100), - skillId: z.string().optional(), - envVars: z.record(z.string(), z.string()).optional(), -}); - -export const playgroundChatEventSchema = z.discriminatedUnion("type", [ - z.object({ type: z.literal("text-delta"), delta: z.string() }), - z.object({ type: z.literal("tool-call"), toolCall: z.object({ id: z.string(), name: z.string(), args: z.record(z.string(), z.unknown()) }) }), - z.object({ type: z.literal("tool-result"), toolCallId: z.string(), result: z.string() }), - z.object({ type: z.literal("error"), message: z.string() }), - z.object({ type: z.literal("finish"), finishReason: z.string() }), -]); - -// --------------------------------------------------------------------------- -// Assistant (#970) — repo-aware Q&A chatbot -// --------------------------------------------------------------------------- - -export const assistantChatRequestBodySchema = z.object({ - messages: z - .array( - z.object({ - role: z.enum(["user", "assistant"]), - content: z.string(), - }), - ) - .min(1) - .max(100), - modelId: z.string().optional(), -}); - -export const assistantChatEventSchema = z.discriminatedUnion("type", [ - z.object({ type: z.literal("chat_start"), model: z.string() }), - z.object({ type: z.literal("chat_text_delta"), delta: z.string() }), - z.object({ type: z.literal("chat_error"), code: z.string(), message: z.string() }), - z.object({ - type: z.literal("chat_finish"), - usage: z - .object({ - inputTokens: z.number().optional(), - outputTokens: z.number().optional(), - totalTokens: z.number().optional(), - }) - .optional(), - }), -]); diff --git a/ornn-api/src/openapi/specBuilder.ts b/ornn-api/src/openapi/specBuilder.ts index 51531e06..b44fad92 100644 --- a/ornn-api/src/openapi/specBuilder.ts +++ b/ornn-api/src/openapi/specBuilder.ts @@ -1,358 +1,43 @@ /** - * OpenAPI 3.1 spec builder. Generates web and agent specs from Zod schemas. + * OpenAPI 3.1 spec builder. + * + * The document is assembled from one module per domain under `paths/`, + * each of which derives its request and response schemas from the same + * Zod definitions the running handlers validate against. Shared response + * shapes, the RFC 7807 error body, and the parameter helpers live in + * `helpers.ts`. + * + * Two invariants are enforced by contract tests in `tests/contract/`: + * + * - *documented ⇒ registered* — no path in this document is absent + * from the booted Hono router (no phantom endpoints). + * - *registered ⇒ documented* — no route on the booted router is + * absent from this document (no undocumented endpoints). + * + * Adding a route therefore means adding it to the matching `paths/` + * module in the same change, or CI fails. That pairing is the whole + * point: before #1214 the table here was hand-maintained and had drifted + * to describing 13 of 104 routes. + * * @module openapi/specBuilder */ -import { zodToJsonSchema } from "zod-to-json-schema"; -import type { ZodTypeAny } from "zod"; -import * as S from "./schemas"; +import type { PathMap } from "./helpers"; +import { accountPaths } from "./paths/account"; +import { adminPaths } from "./paths/admin"; +import { adminQuotaPaths } from "./paths/adminQuota"; +import { adminSettingsPaths } from "./paths/adminSettings"; +import { auditAnalyticsPaths } from "./paths/auditAnalytics"; +import { generationPaths } from "./paths/generation"; +import { messagingPaths } from "./paths/messaging"; +import { searchFormatPaths } from "./paths/searchFormat"; +import { skillsCrudPaths } from "./paths/skillsCrud"; +import { skillsetsPaths } from "./paths/skillsets"; +import { systemPaths } from "./paths/system"; +import { usersMirrorPaths } from "./paths/usersMirror"; -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -type JsonSchema = Record; -type PathItem = Record; type OpenApiSpec = Record; -function toSchema(zodSchema: ZodTypeAny): JsonSchema { - // Zod 4 changed the public ZodType signature; zod-to-json-schema's - // type guards still target the v3 shape. Cast through `any` at the - // boundary — the runtime shape is unchanged, this is purely the - // type bridge. - const result = zodToJsonSchema(zodSchema as unknown as Parameters[0], { target: "openApi3", $refStrategy: "none" }) as JsonSchema; - // Remove top-level $schema key (not valid in OpenAPI component schemas) - delete result.$schema; - return result; -} - -function jsonResponse(schema: ZodTypeAny, description = "Successful response"): Record { - return { - "200": { - description, - content: { "application/json": { schema: toSchema(schema) } }, - }, - }; -} - -function sseResponse(description: string): Record { - return { - "200": { - description, - content: { "text/event-stream": { schema: { type: "string" } } }, - }, - }; -} - -function errorResponses(...codes: number[]): Record { - const errorSchema = toSchema(S.apiErrorSchema); - const envelope = { - type: "object", - properties: { - data: { type: "null" }, - error: errorSchema, - }, - }; - const map: Record = {}; - const descriptions: Record = { - 400: "Bad request — invalid input, missing required fields, or validation failure. Check the error message for details", - 401: "Unauthorized — missing, expired, or invalid JWT token. Obtain a new token from NyxID and retry", - 403: "Forbidden — authenticated but insufficient permissions (e.g. trying to modify another user's skill)", - 404: "Not found — the requested skill does not exist or is not accessible with current permissions", - 409: "Conflict — resource already exists (e.g. duplicate skill name)", - 413: "Payload too large — the uploaded ZIP exceeds the maximum allowed size", - 500: "Internal server error — unexpected failure. Retry or contact support", - }; - for (const code of codes) { - map[String(code)] = { - description: descriptions[code] ?? `Error ${code}`, - content: { "application/json": { schema: envelope } }, - }; - } - return map; -} - -function bearerAuth(): Record[] { - return [{ BearerAuth: [] }]; -} - -function queryParams(schema: ZodTypeAny): unknown[] { - const jsonSchema = toSchema(schema) as { properties?: Record; required?: string[] }; - if (!jsonSchema.properties) return []; - const required = new Set(jsonSchema.required ?? []); - return Object.entries(jsonSchema.properties).map(([name, prop]) => ({ - name, - in: "query", - required: required.has(name), - schema: prop, - description: (prop as Record).description ?? undefined, - })); -} - -function pathParam(name: string, description: string): Record { - return { name, in: "path", required: true, schema: { type: "string" }, description }; -} - -// --------------------------------------------------------------------------- -// Shared path definitions -// --------------------------------------------------------------------------- - -function skillUploadPath(_prefix: string): PathItem { - return { - post: { - summary: "Upload a skill package", - description: "Upload a ZIP-packaged skill to the registry. The ZIP must contain at least a 'skill.md' file with valid YAML frontmatter defining the skill metadata (name, description, category, etc.). Optionally include supporting files such as scripts, templates, or configuration. The package is validated against format rules unless skip_validation is set. On success, the skill is stored and becomes available for search and retrieval. If a skill with the same name already exists for this user, it will be updated (new version).", - operationId: "uploadSkill", - tags: ["Skills"], - security: bearerAuth(), - parameters: [{ - name: "skip_validation", - in: "query", - required: false, - schema: { type: "boolean" }, - description: "If true, skip format validation rules (useful for importing legacy packages). Default is false — validation is enforced", - }], - requestBody: { - required: true, - description: "ZIP file containing the skill package. Must include a 'skill.md' file with YAML frontmatter. Max size depends on server configuration (typically 10MB).", - content: { "application/zip": { schema: { type: "string", format: "binary" } } }, - }, - responses: { ...jsonResponse(S.skillDetailApiResponse, "Skill created"), ...errorResponses(400, 401, 413) }, - }, - }; -} - -function skillReadPath(_prefix: string): PathItem { - return { - get: { - summary: "Get skill by GUID or name", - description: "Retrieve full details of a single skill by its UUID or unique name. Returns metadata, tags, visibility status, and timestamps. To download the raw ZIP package use GET /skills/{idOrName}/versions/{version}/download; for individual file contents without downloading the ZIP, use the /json endpoint instead.", - operationId: "getSkill", - tags: ["Skills"], - security: bearerAuth(), - parameters: [pathParam("idOrName", "Skill UUID (e.g. '550e8400-e29b-41d4-a716-446655440000') or unique skill name (e.g. 'web-summarizer')")], - responses: { ...jsonResponse(S.skillDetailApiResponse), ...errorResponses(401, 404) }, - }, - }; -} - -function skillJsonPath(_prefix: string): PathItem { - return { - get: { - summary: "Get skill package as JSON with all file contents", - description: "Retrieve a skill's complete package contents as a JSON object without downloading the ZIP file. Returns the skill name, description, metadata, and a 'files' map where each key is a relative file path (e.g. 'skill.md', 'scripts/run.py') and each value is the full text content of that file. Binary files are excluded. This is the preferred endpoint for AI agents that need to read and understand skill contents programmatically.", - operationId: "getSkillJson", - tags: ["Skills"], - security: bearerAuth(), - parameters: [pathParam("idOrName", "Skill UUID (e.g. '550e8400-e29b-41d4-a716-446655440000') or unique skill name (e.g. 'web-summarizer')")], - responses: { ...jsonResponse(S.skillJsonApiResponse), ...errorResponses(401, 404) }, - }, - }; -} - -function skillDownloadPath(_prefix: string): PathItem { - return { - get: { - summary: "Download a skill version's package ZIP", - description: "Stream the raw ZIP package for a specific skill version. Bytes are proxied from object storage through ornn-api — clients never talk to the storage backend directly. `version` may be a literal (e.g. '1.2') or a dist-tag (e.g. 'latest'). Returns application/zip on success; a private skill the caller cannot read returns 404 (existence is not leaked).", - operationId: "downloadSkillPackage", - tags: ["Skills"], - security: bearerAuth(), - parameters: [ - pathParam("idOrName", "Skill UUID or unique skill name"), - pathParam("version", "Version literal (e.g. '1.2') or dist-tag (e.g. 'latest')"), - ], - responses: { - 200: { - description: "The raw skill package ZIP bytes", - content: { "application/zip": { schema: { type: "string", format: "binary" } } }, - }, - ...errorResponses(401, 404), - }, - }, - }; -} - -function skillUpdatePath(_prefix: string): PathItem { - return { - put: { - summary: "Update a skill (ZIP, metadata, or privacy flag)", - operationId: "updateSkill", - tags: ["Skills"], - security: bearerAuth(), - parameters: [ - pathParam("id", "Skill GUID"), - { name: "skip_validation", in: "query", required: false, schema: { type: "boolean" } }, - ], - requestBody: { - content: { - "application/zip": { schema: { type: "string", format: "binary" } }, - "application/json": { schema: toSchema(S.updateSkillJsonBodySchema) }, - }, - }, - responses: { ...jsonResponse(S.skillDetailApiResponse), ...errorResponses(400, 401, 403, 404, 413) }, - }, - }; -} - -function skillDeletePath(_prefix: string): PathItem { - return { - delete: { - summary: "Delete a skill", - operationId: "deleteSkill", - tags: ["Skills"], - security: bearerAuth(), - parameters: [pathParam("id", "Skill GUID")], - responses: { ...jsonResponse(S.successResponseSchema), ...errorResponses(401, 403, 404) }, - }, - }; -} - -function skillSearchPath(_prefix: string): PathItem { - return { - get: { - summary: "Search skills by keyword or semantic similarity", - description: "Search the skill registry using keyword matching or AI-powered semantic search. Keyword mode performs text matching against skill names, descriptions, and tags — fast and precise. Semantic mode uses LLM embeddings to find conceptually related skills even when exact terms don't match — slower but understands intent. Results are paginated. Use 'scope' to filter by visibility: 'public' for community skills, 'private' for your own skills, 'mixed' for both. An empty query with keyword mode returns all skills in the given scope.", - operationId: "searchSkills", - tags: ["Search"], - security: bearerAuth(), - parameters: queryParams(S.searchQuerySchema), - responses: { ...jsonResponse(S.skillSearchApiResponse), ...errorResponses(400, 401) }, - }, - }; -} - -function skillGeneratePath(_prefix: string): PathItem { - return { - post: { - summary: "Generate a skill via AI (SSE stream)", - description: "Use AI to generate a complete skill package from a natural language description. Returns a Server-Sent Events (SSE) stream with real-time generation progress. Supports two modes: (1) Single-turn via 'prompt' field — describe the skill you want in one message. (2) Multi-turn via 'messages' array — provide a conversation history for iterative refinement (e.g. 'make it also handle PDFs'). The stream emits events: 'generation_start' when LLM begins, 'token' for incremental output, 'generation_complete' with the full generated skill content, 'validation_error' if the output fails format checks (may auto-retry), and 'error' on failure. Alternatively, use multipart/form-data with an existing skill package ZIP to modify or extend an existing skill based on the prompt.", - operationId: "generateSkill", - tags: ["Generation"], - security: bearerAuth(), - requestBody: { - required: true, - description: "Either JSON with a prompt/messages for generation, or multipart/form-data with a prompt and optional existing skill package ZIP for modification.", - content: { - "application/json": { schema: toSchema(S.generateJsonBodySchema) }, - "multipart/form-data": { - schema: { - type: "object", - properties: { - prompt: { type: "string", description: "Natural language description of the skill to generate or the modification to apply to the attached package" }, - package: { type: "string", format: "binary", description: "Optional existing skill package ZIP to use as a base for modification. When provided, the AI will modify this package according to the prompt rather than generating from scratch" }, - }, - required: ["prompt"], - }, - }, - }, - }, - responses: { ...sseResponse("SSE stream of generation events. Event types: 'generation_start', 'token' (incremental LLM output), 'generation_complete' (full result), 'validation_error' (format check failed), 'error' (unrecoverable failure). Connect via EventSource or fetch with ReadableStream."), ...errorResponses(400, 401) }, - }, - }; -} - -// --------------------------------------------------------------------------- -// Web-only path definitions -// --------------------------------------------------------------------------- - -function formatRulesPath(): PathItem { - return { - get: { - summary: "Get skill format specification rules", - operationId: "getFormatRules", - tags: ["Format"], - responses: jsonResponse(S.formatRulesResponseSchema), - }, - }; -} - -function formatValidatePath(): PathItem { - return { - post: { - summary: "Validate a ZIP package against format rules", - operationId: "validateFormat", - tags: ["Format"], - security: bearerAuth(), - requestBody: { - required: true, - content: { "application/zip": { schema: { type: "string", format: "binary" } } }, - }, - responses: { ...jsonResponse(S.formatValidationResponseSchema), ...errorResponses(400, 401) }, - }, - }; -} - -/** - * JSON Schema for SKILL.md frontmatter (#464). Unlike the other format - * endpoints this one returns a raw JSON Schema document — no envelope — - * so external tooling (IDEs, schemastore.org) consumes it directly. - */ -function formatSchemaPath(): PathItem { - return { - get: { - summary: "JSON Schema for SKILL.md frontmatter", - description: - "Canonical JSON Schema (draft-7) for `SKILL.md` YAML frontmatter, generated from the server's Zod schema. Public, long-cacheable. Returns the schema document at the body root with `Content-Type: application/schema+json` — not the standard `{ data, error }` envelope, since consumers (VS Code, Cursor, schemastore.org) expect a raw JSON Schema.", - operationId: "getFormatSchema", - tags: ["Format"], - responses: { - 200: { - description: "JSON Schema document", - content: { - "application/schema+json": { - schema: { type: "object" }, - }, - }, - }, - }, - }, - }; -} - -function playgroundChatPath(): PathItem { - return { - post: { - summary: "Multi-turn playground chat (SSE stream)", - operationId: "playgroundChat", - tags: ["Playground"], - security: bearerAuth(), - requestBody: { - required: true, - content: { "application/json": { schema: toSchema(S.chatRequestBodySchema) } }, - }, - responses: { ...sseResponse("SSE stream of chat events"), ...errorResponses(400, 401) }, - }, - }; -} - -function assistantChatPath(): PathItem { - return { - post: { - summary: "Ornn Assistant — repo-aware Q&A chat (SSE stream)", - description: - "Pure, non-agentic Q&A about Ornn and the skills the caller may see. Grounds answers in a curated knowledge-base digest plus a visibility-scoped skill retrieval (SAFE fields only). SSE event types: 'chat_start', 'chat_text_delta', 'chat_error', 'chat_finish' (+ keepalive comment frames). No tools / no execution.", - operationId: "assistantChat", - tags: ["Assistant"], - security: bearerAuth(), - requestBody: { - required: true, - content: { - "application/json": { schema: toSchema(S.assistantChatRequestBodySchema) }, - }, - }, - responses: { - ...sseResponse("SSE stream of assistant chat events"), - ...errorResponses(400, 401, 429, 503), - }, - }, - }; -} - -// --------------------------------------------------------------------------- -// Spec builders -// --------------------------------------------------------------------------- - /** * Deployment-specific values the spec advertises. Both are caller-supplied * so nothing environment-shaped is baked into the builder (CLAUDE.md: @@ -369,65 +54,173 @@ export interface SpecOptions { readonly version: string; } -function baseSpec(title: string, description: string, options: SpecOptions): OpenApiSpec { +/** + * Tag vocabulary. Every tag an operation declares must appear here — + * a tag with no entry renders as an unlabelled group in Swagger UI and + * most generators fold it into a nameless client namespace. + */ +const TAGS: ReadonlyArray<{ name: string; description: string }> = [ + { + name: "Skills", + description: + "The core resource. Upload, pull, inspect, update, version, tag, and delete skill packages, and manage who may read them.", + }, + { + name: "Skillsets", + description: + "Named, versioned bundles of skills. Resolve a skillset to its transitive closure of skill versions, or export it as an agent plugin.", + }, + { + name: "Search", + description: + "Discovery over the registry: keyword and semantic skill search, plus the facet and count endpoints that back filter UIs.", + }, + { + name: "Generation", + description: + "Author skills with an LLM — from a prompt, from an existing source repository, or from an OpenAPI document. All stream over SSE.", + }, + { + name: "Format", + description: + "The skill package format itself: the human-readable rules, the machine-readable SKILL.md JSON Schema, and a validator for a candidate ZIP.", + }, + { + name: "Audit", + description: + "LLM safety review of a specific skill version — dimension scores, a green/yellow/red verdict, and the audit history behind it.", + }, + { + name: "Analytics", + description: "Per-skill usage aggregates: execution outcomes and latency, and time-bucketed pull counts by surface.", + }, + { + name: "Playground", + description: "Multi-turn chat that executes skills in a sandbox, for trying a skill before wiring it into an agent.", + }, + { + name: "Assistant", + description: "Grounded, non-agentic Q&A about Ornn itself and the skills the caller can see. No tools, no execution.", + }, + { + name: "Account", + description: + "The authenticated caller's own view: profile, organisations, bound services, quota, model picker, and redemption codes.", + }, + { name: "Notifications", description: "The caller's notification inbox and its read state." }, + { name: "Announcements", description: "Platform-wide announcements — the public read side and the admin authoring side." }, + { + name: "Admin", + description: + "Platform operator surface: skill moderation, user and quota administration, redemption codes, platform settings, and the GitHub mirror. Every operation requires an admin permission.", + }, + { name: "Users", description: "User directory lookups used to resolve a handle or email to a user before granting access." }, + { name: "Mirror", description: "GitHub repository mirroring — inspect a repo and register it as a skill source." }, + { + name: "System", + description: + "Service-level endpoints: this OpenAPI document and the Kubernetes liveness/readiness probes. The probes sit outside the /api/v1 prefix by design.", + }, +]; + +const DESCRIPTION = `Ornn is an agent-facing skill-lifecycle API: agents call it directly to +search, pull, install, execute, build, upload, and share skills. Think of it as an npm +registry and npm CLI fused into one HTTP surface, and model-agnostic — nothing here is +tied to a particular model runtime. + +## Conventions + +**Base path.** Every endpoint except the Kubernetes probes lives under \`/api/v1\`. +Prepend the server URL above. + +**Success envelope.** Every 2xx JSON body is \`{ "data": , "error": null }\`. +Read your payload from \`data\`. Two endpoints deliberately opt out and return their +document at the body root: \`GET /api/v1/skill-manifest-schema.json\` and +\`GET /api/v1/openapi.json\`. + +**Errors.** Every 4xx and 5xx response is RFC 7807 \`application/problem+json\`, with +fields at the **body root** — not inside the success envelope: + + { + "type": "https://.../errors/skill_not_found", + "title": "Resource not found", + "status": 404, + "detail": "Skill 'web-summarizer' does not exist", + "instance": "/api/v1/skills/web-summarizer", + "code": "skill_not_found", + "requestId": "01J..." + } + +Branch on \`code\`, never on \`detail\`. On a validation failure (400), \`detail\` carries the +rejected fields as \`: \` pairs joined with \`; \` — there is no separate +per-field array. + +**Auth.** Send a NyxID JWT as \`Authorization: Bearer \`. Operations marked with +no security requirement are public. Operations that accept optional auth return a wider, +visibility-scoped result when a token is present. + +**Visibility.** A private resource the caller may not read answers \`404\`, never \`403\`, +so existence is never leaked. A \`403\` means the resource is visible but the action is not +permitted. + +**Correlation.** Every response carries \`X-Request-ID\`; it is echoed in the problem body +as \`requestId\`. Quote it in bug reports. + +**Streaming.** Generation, playground, and assistant endpoints reply with +\`text/event-stream\`. The frame layout differs by surface, so check the operation you are +calling: the generation and playground streams send bare \`data: \\n\\n\` frames with +**no** \`event:\` line — dispatch on the JSON body's own \`type\` field — while the assistant +stream sends both an \`event: \` line and the \`data:\` line. Periodic keep-alive frames +hold the connection open and must be ignored. + +Once a stream has opened with 200, later failures arrive **in-band** as an error event, +not as an HTTP status. Read the terminal event, not just the status code. + +This document is generated at server boot from the same Zod schemas the handlers validate +against, so it cannot drift from the running API.`; + +export function buildSpec(options: SpecOptions): OpenApiSpec { + // MUST match the mount prefix in `bootstrap.ts` (`app.route("/api/v1", + // apiApp)`) and CONVENTIONS.md §3. This is asserted against the booted + // router in `tests/contract/openapiRoutes.test.ts` — do not change one + // without the other. + const prefix = "/api/v1"; + + const paths: PathMap = { + ...skillsCrudPaths(prefix), + ...skillsetsPaths(prefix), + ...searchFormatPaths(prefix), + ...generationPaths(prefix), + ...auditAnalyticsPaths(prefix), + ...accountPaths(prefix), + ...messagingPaths(prefix), + ...adminPaths(prefix), + ...adminQuotaPaths(prefix), + ...adminSettingsPaths(prefix), + ...usersMirrorPaths(prefix), + ...systemPaths(prefix), + }; + return { openapi: "3.1.0", - info: { title, version: options.version, description }, - servers: [{ url: options.serverUrl }], + info: { + title: "Ornn API", + version: options.version, + description: DESCRIPTION, + }, + servers: [{ url: options.serverUrl, description: "This deployment." }], + tags: TAGS, components: { securitySchemes: { BearerAuth: { type: "http", scheme: "bearer", bearerFormat: "JWT", - description: "NyxID JWT access token. Obtain by authenticating with NyxID OAuth flow or API key exchange. Pass as 'Authorization: Bearer ' header. Tokens expire after a configurable period and must be refreshed via NyxID", + description: + "NyxID JWT access token. Obtain one through the NyxID OAuth flow or by exchanging an API key, then send it as `Authorization: Bearer `. Tokens expire on a deployment-configured interval and are refreshed through NyxID, not through this API.", }, }, }, - }; -} - -export function buildSpec(options: SpecOptions): OpenApiSpec { - // MUST match the mount prefix in `bootstrap.ts` (`app.route("/api/v1", - // apiApp)`) and CONVENTIONS.md §3. This is asserted against the booted - // router in `tests/contract/openapiRoutes.test.ts` — do not change one - // without the other. - const prefix = "/api/v1"; - return { - ...baseSpec( - "ornn API", - "API for ornn — the end-to-end skill life-cycle manager for AI agents. Covers the full life-cycle: skill CRUD, search, AI-powered generation, playground, audit, and admin endpoints — from spec to ship. All endpoints require NyxID authentication via Bearer token. Responses follow a uniform envelope: { data: T | null, error: { code, message } | null }.", - options, - ), - tags: [ - { name: "Skills", description: "Upload, retrieve, update, delete, and inspect AI skill packages." }, - { name: "Search", description: "Find skills by keyword text matching or AI-powered semantic similarity." }, - { name: "Generation", description: "Generate complete skill packages from natural language descriptions using AI." }, - { name: "Format", description: "Skill format rules and validation" }, - { name: "Playground", description: "Multi-turn chat playground" }, - ], - paths: { - // Skills CRUD - [`${prefix}/skills`]: skillUploadPath(prefix), - [`${prefix}/skills/{idOrName}`]: skillReadPath(prefix), - [`${prefix}/skills/{idOrName}/json`]: skillJsonPath(prefix), - [`${prefix}/skills/{idOrName}/versions/{version}/download`]: skillDownloadPath(prefix), - [`${prefix}/skills/{id}`]: { - ...skillUpdatePath(prefix), - ...skillDeletePath(prefix), - }, - // Search - [`${prefix}/skill-search`]: skillSearchPath(prefix), - // Generation - [`${prefix}/skills/generate`]: skillGeneratePath(prefix), - // Format - [`${prefix}/skill-format/rules`]: formatRulesPath(), - [`${prefix}/skill-format/validate`]: formatValidatePath(), - [`${prefix}/skill-manifest-schema.json`]: formatSchemaPath(), - // Playground - [`${prefix}/playground/chat`]: playgroundChatPath(), - // Assistant (#970) - [`${prefix}/assistant/chat`]: assistantChatPath(), - }, + paths, }; } diff --git a/ornn-api/src/regression/hardcodeSweep.test.ts b/ornn-api/src/regression/hardcodeSweep.test.ts index d39d995f..67e4f1df 100644 --- a/ornn-api/src/regression/hardcodeSweep.test.ts +++ b/ornn-api/src/regression/hardcodeSweep.test.ts @@ -40,6 +40,20 @@ const SKIP_PATH_REGEXES: RegExp[] = [ /\.test\.ts$/, /[\\/]regression[\\/]/, /[\\/]infra[\\/]config\.ts$/, + // `openapi/` is a documentation surface, not a configuration one. Its + // string literals are `description` and `examples` values written to be + // read by a human or an agent — `https://api.openai.com/v1` as an example + // LLM gateway, `gpt-4o` as an example model id, a GitHub URL showing what + // a `repoUrl` looks like. Nothing here is ever connected to, parsed, or + // used to configure the server; these modules have no runtime behaviour + // beyond returning a JSON document. Same rubric line as a test fixture + // (Architecture §8): exempt by file path. + // + // The one genuinely deployment-shaped value the spec carries — the + // advertised server URL — is NOT hardcoded: `buildSpec` takes it as + // `options.serverUrl` from `config.ornnApiBaseUrl`, which is what the + // rest of this sweep is protecting. + /[\\/]openapi[\\/]/, ]; // URLs that are intentionally hardcoded in source for legitimate diff --git a/ornn-api/tests/contract/openapi.test.ts b/ornn-api/tests/contract/openapi.test.ts index 92e82ef6..d151570b 100644 --- a/ornn-api/tests/contract/openapi.test.ts +++ b/ornn-api/tests/contract/openapi.test.ts @@ -1,264 +1,416 @@ /** - * OpenAPI contract tests (#462). + * OpenAPI spec quality contract (#462, #1214). * - * CONVENTIONS.md §10 + §11.7: "every handler in code appears in the - * spec with complete metadata". This test pins the structural - * properties of the spec generated by `buildSpec()` so that: + * CONVENTIONS.md §10: "Every route declares security, request content + * types, all documented error responses". §11.7: "every handler in code + * appears in the spec with complete metadata". This file enforces the + * *metadata* half of that promise by inspecting the generated document; + * the *coverage* half (every registered route is documented, and nothing + * documented is unregistered) is enforced against the booted router in + * `openapiRoutes.test.ts`. * - * 1. The spec never silently regresses to a state where a declared - * path has no methods, or a method has no responses / tags. - * 2. Adding a new path to the spec without a tag / response / summary - * fails CI immediately rather than landing as a half-documented - * endpoint. - * 3. Error-response declarations (4xx / 5xx) carry the - * `application/problem+json` content type from #456 — anyone - * reverting that wire shape gets a test failure. + * Three classes of regression are pinned here, each one having actually + * shipped at some point: * - * Out of scope for this PR (tracked as follow-ups on #462): - * - Reflection over the live Hono app to assert every registered - * route has a spec entry. The current spec covers ~12 of the ~50 - * real routes; closing that gap is a separate, larger sweep that - * needs each missing route to land with its own per-route schema. - * - Cross-checking declared error codes against handler `throw` - * statements — needs a code-walker, also follow-up. - * - * This test enforces what the spec already promises. The coverage - * gap stays open as #462a until someone documents the missing routes. + * 1. Empty schemas. `zod-to-json-schema@3` returns `{}` for every Zod 4 + * schema without erroring. The spec published `parameters: []` for + * `GET /skill-search` and `schema: {}` for every body until #1214 + * moved `toSchema` onto zod 4's built-in `z.toJSONSchema`. The + * "no empty schema" tests below fail loudly if that regresses. + * 2. Wrong error media type. Errors are RFC 7807 + * `application/problem+json` with fields at the body root (#456); + * the spec described them as `application/json` wrapping the legacy + * `{ data, error }` envelope, so generated clients read error fields + * at the wrong depth. + * 3. Thin metadata — an operation with no description, a parameter with + * no description, an undeclared tag. * * @module tests/contract/openapi */ import { describe, expect, test } from "bun:test"; import { buildSpec } from "../../src/openapi/specBuilder"; +import { UNVERSIONED_SYSTEM_PATHS } from "../../src/openapi/paths/system"; -/** - * Deployment-specific values `buildSpec` now takes as arguments (#1213). - * Fixed here so assertions are about spec structure, not about whatever - * the ambient environment happens to be. - */ const SPEC_OPTIONS = { serverUrl: "https://api.test.invalid", version: "1.2.3" } as const; -type Spec = ReturnType; -type PathItem = Record; -type Operation = { +const HTTP_METHODS = ["get", "post", "put", "patch", "delete"] as const; +type HttpMethod = (typeof HTTP_METHODS)[number]; + +interface Parameter { + name: string; + in: string; + required?: boolean; + description?: string; + schema?: Record; +} + +interface Response { + description?: string; + content?: Record }>; +} + +interface Operation { summary?: string; + description?: string; operationId?: string; tags?: string[]; - responses?: Record; security?: unknown[]; - requestBody?: unknown; -}; + parameters?: Parameter[]; + requestBody?: { description?: string; required?: boolean; content?: Record }; + responses?: Record; +} -const HTTP_METHODS = ["get", "post", "put", "patch", "delete"] as const; -type HttpMethod = (typeof HTTP_METHODS)[number]; +const spec = buildSpec(SPEC_OPTIONS); +const paths = spec.paths as Record>; function isOperation(method: string): method is HttpMethod { return (HTTP_METHODS as readonly string[]).includes(method); } -function eachOperation( - spec: Spec, - fn: (path: string, method: HttpMethod, op: Operation) => void, -): void { - const paths = spec.paths as Record; - for (const [path, pathItem] of Object.entries(paths)) { - for (const [method, op] of Object.entries(pathItem)) { - if (!isOperation(method)) continue; - fn(path, method, op as Operation); - } +interface Entry { + path: string; + method: HttpMethod; + op: Operation; + label: string; +} + +const operations: Entry[] = []; +for (const [path, pathItem] of Object.entries(paths)) { + for (const [method, op] of Object.entries(pathItem)) { + if (!isOperation(method)) continue; + operations.push({ path, method, op: op as Operation, label: `${method.toUpperCase()} ${path}` }); } } -describe("OpenAPI spec — structural integrity (#462)", () => { - const spec = buildSpec(SPEC_OPTIONS); +/** + * `/readyz` returns its 503 straight from the probe handler rather than + * raising through the global RFC 7807 error handler, so it is the one + * error response in the document that is plain `application/json`. + * Documented as it behaves; pinned here so the exemption stays deliberate. + */ +const RAW_ERROR_RESPONSES = new Set(["GET /readyz 503"]); + +/** + * Operations with no failure mode to document. + * + * The liveness probes return a constant object — if the process cannot + * serve them it cannot respond at all — and the spec endpoint serves a + * value computed once at boot. Declaring a speculative 500 on these would + * be padding, and padding is what this file exists to prevent. Keep this + * list tiny and justified; anything that touches a dependency does not + * belong on it. + */ +const NO_FAILURE_MODE = new Set([ + "GET /livez", + "GET /health", + "GET /api/v1/openapi.json", + // Both serve a module-level constant computed once at import: the format + // rulebook string and the SKILL.md JSON Schema. No I/O, no auth, no + // parameters — there is nothing that can return a 4xx or 5xx. + "GET /api/v1/skill-format/rules", + "GET /api/v1/skill-manifest-schema.json", +]); + +/** A schema object carrying no information — the zod 4 conversion bug. */ +function isEmptySchema(schema: unknown): boolean { + return ( + typeof schema === "object" && + schema !== null && + !Array.isArray(schema) && + Object.keys(schema as Record).length === 0 + ); +} - test("spec is OpenAPI 3.1", () => { +describe("OpenAPI spec — document structure", () => { + test("is an OpenAPI 3.1 document", () => { expect(spec.openapi).toBe("3.1.0"); }); - test("spec declares title, version, description", () => { + test("declares title, semver version, and a substantial description", () => { const info = spec.info as { title: string; version: string; description: string }; expect(info.title).toBeTruthy(); expect(info.version).toMatch(/^\d+\.\d+\.\d+/); - expect(info.description.length).toBeGreaterThan(50); + // The description carries the envelope / error / auth conventions an + // integrator needs before reading a single operation. + expect(info.description.length).toBeGreaterThan(500); }); - test("spec has at least one server URL", () => { + test("advertises the deployment's server URL", () => { const servers = spec.servers as Array<{ url: string }>; expect(servers.length).toBeGreaterThan(0); - expect(servers[0]!.url).toMatch(/^https?:\/\//); + expect(servers[0]!.url).toBe(SPEC_OPTIONS.serverUrl); }); - test("server URL and version come from the caller, not the builder", () => { - // Both were hardcoded until #1213 — `http://localhost:3802` and a - // frozen `"2.0.0"` — so the published spec advertised a server no - // client could reach and a version that never moved. - expect((spec.servers as Array<{ url: string }>)[0]!.url).toBe(SPEC_OPTIONS.serverUrl); - expect((spec.info as { version: string }).version).toBe(SPEC_OPTIONS.version); - }); - - test("BearerAuth security scheme is declared", () => { + test("declares the BearerAuth security scheme", () => { const components = spec.components as { securitySchemes: Record }; expect(components.securitySchemes.BearerAuth).toBeDefined(); }); - test("every declared path carries the /api/v1 mount prefix", () => { - // The spec's path table is hand-maintained while the router lives in - // `bootstrap.ts`. They drifted once already: the spec published - // `/api/*` for months after the router moved to `/api/v1/*` in #101, - // so every URL NyxID rendered from this spec was wrong. CONVENTIONS.md - // §3 makes `/api/v1/` normative — pin it here so a stale prefix fails - // fast, without needing a booted app. - const offenders = Object.keys(spec.paths as Record) - .filter((p) => !p.startsWith("/api/v1/")); - expect(offenders).toEqual([]); + test("every declared path has at least one operation", () => { + const orphans = Object.entries(paths) + .filter(([, item]) => Object.keys(item).filter(isOperation).length === 0) + .map(([path]) => path); + expect(orphans).toEqual([]); + }); + + test("every path is under /api/v1 except the K8s probes", () => { + const strays = Object.keys(paths).filter( + (p) => !p.startsWith("/api/v1") && !UNVERSIONED_SYSTEM_PATHS.includes(p), + ); + expect(strays).toEqual([]); + }); + + test("the document is JSON-serialisable", () => { + const json = JSON.stringify(spec); + expect(() => JSON.parse(json)).not.toThrow(); + }); + + test("no $schema keyword leaks into a Schema Object", () => { + // `$schema` is valid at the root of a standalone JSON Schema document + // but not as a keyword inside an OpenAPI Schema Object — `toSchema` + // strips it. It IS legal as a *property name*, though: the response + // body of `GET /skill-manifest-schema.json` is itself a JSON Schema + // document and declares a `$schema` field. So this walks the tree and + // only flags occurrences outside a `properties` map, rather than + // grepping the serialised text and failing on the legitimate case. + const leaks: string[] = []; + const walk = (node: unknown, trail: string, inPropertiesMap: boolean): void => { + if (Array.isArray(node)) { + node.forEach((child, i) => walk(child, `${trail}[${i}]`, false)); + return; + } + if (typeof node !== "object" || node === null) return; + for (const [key, value] of Object.entries(node as Record)) { + if (key === "$schema" && !inPropertiesMap) leaks.push(`${trail}.${key}`); + walk(value, `${trail}.${key}`, key === "properties"); + } + }; + walk(spec, "", false); + expect(leaks).toEqual([]); + }); +}); + +describe("OpenAPI spec — per-operation metadata", () => { + test("every operation declares a summary", () => { + const violations = operations.filter((e) => !e.op.summary).map((e) => e.label); + expect(violations).toEqual([]); + }); + + test("every operation declares a description that says something", () => { + // The bar is deliberately more than "non-empty": the reason this file + // exists is that integrators reported the spec was too thin to build + // against. One sentence restating the summary is not a description. + const violations = operations + .filter((e) => (e.op.description ?? "").length < 120) + .map((e) => `${e.label} (${(e.op.description ?? "").length} chars)`); + expect(violations).toEqual([]); }); - test("every declared path has at least one HTTP method", () => { - const orphans: string[] = []; - const paths = spec.paths as Record; - for (const [path, pathItem] of Object.entries(paths)) { - const methods = Object.keys(pathItem).filter(isOperation); - if (methods.length === 0) orphans.push(path); + test("every operation declares a unique operationId", () => { + const missing = operations.filter((e) => !e.op.operationId).map((e) => e.label); + expect(missing).toEqual([]); + + const byId = new Map(); + for (const e of operations) { + const id = e.op.operationId!; + byId.set(id, [...(byId.get(id) ?? []), e.label]); } - expect(orphans).toEqual([]); + const duplicates = [...byId.entries()].filter(([, labels]) => labels.length > 1); + // Generators derive client method names from operationId; a collision + // silently drops one of the two methods. + expect(duplicates).toEqual([]); + }); + + test("every operation declares at least one tag, and every tag is declared at the top level", () => { + const untagged = operations.filter((e) => !e.op.tags?.length).map((e) => e.label); + expect(untagged).toEqual([]); + + const declared = new Set((spec.tags as Array<{ name: string }>).map((t) => t.name)); + const undeclared = [ + ...new Set(operations.flatMap((e) => e.op.tags ?? []).filter((t) => !declared.has(t))), + ]; + expect(undeclared).toEqual([]); + }); + + test("every operation makes an explicit security declaration", () => { + // `security: []` (public) is an answer; omitting the key is not, because + // the operation then silently inherits whatever the document declares. + const violations = operations.filter((e) => e.op.security === undefined).map((e) => e.label); + expect(violations).toEqual([]); }); }); -describe("OpenAPI spec — per-operation metadata (#462)", () => { - const spec = buildSpec(SPEC_OPTIONS); +describe("OpenAPI spec — parameters", () => { + test("every templated path parameter is declared in parameters", () => { + const violations: string[] = []; + for (const { path, op, label } of operations) { + const templated = [...path.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]!); + const declared = (op.parameters ?? []).filter((p) => p.in === "path").map((p) => p.name); + for (const name of templated) { + if (!declared.includes(name)) violations.push(`${label}: {${name}}`); + } + } + expect(violations).toEqual([]); + }); - test("every operation declares at least one tag", () => { + test("every parameter carries a description and a non-empty schema", () => { const violations: string[] = []; - eachOperation(spec, (path, method, op) => { - if (!op.tags || op.tags.length === 0) { - violations.push(`${method.toUpperCase()} ${path}`); + for (const { op, label } of operations) { + for (const p of op.parameters ?? []) { + if (!p.description) violations.push(`${label}: '${p.name}' has no description`); + if (!p.schema) violations.push(`${label}: '${p.name}' has no schema`); + else if (isEmptySchema(p.schema)) violations.push(`${label}: '${p.name}' has an EMPTY schema`); } - }); + } expect(violations).toEqual([]); }); - test("every operation declares either a summary or an operationId", () => { + test("path parameters are marked required", () => { const violations: string[] = []; - eachOperation(spec, (path, method, op) => { - if (!op.summary && !op.operationId) { - violations.push(`${method.toUpperCase()} ${path}`); + for (const { op, label } of operations) { + for (const p of (op.parameters ?? []).filter((x) => x.in === "path")) { + if (p.required !== true) violations.push(`${label}: '${p.name}'`); } - }); + } expect(violations).toEqual([]); }); +}); - test("every operation declares at least one response", () => { +describe("OpenAPI spec — request bodies", () => { + test("every request body declares a description and non-empty content", () => { const violations: string[] = []; - eachOperation(spec, (path, method, op) => { - if (!op.responses || Object.keys(op.responses).length === 0) { - violations.push(`${method.toUpperCase()} ${path}`); + for (const { op, label } of operations) { + const body = op.requestBody; + if (!body) continue; + if (!body.description) violations.push(`${label}: requestBody has no description`); + const types = Object.keys(body.content ?? {}); + if (types.length === 0) violations.push(`${label}: requestBody has no content`); + for (const type of types) { + const schema = body.content![type]!.schema; + if (isEmptySchema(schema)) violations.push(`${label}: requestBody ${type} has an EMPTY schema`); } - }); + } expect(violations).toEqual([]); }); +}); - test("every operation declares at least one 2xx response", () => { +describe("OpenAPI spec — responses", () => { + test("every operation declares a described 2xx with content (except 204)", () => { const violations: string[] = []; - eachOperation(spec, (path, method, op) => { + for (const { op, label } of operations) { const responses = op.responses ?? {}; - const has2xx = Object.keys(responses).some((code) => code.startsWith("2")); - if (!has2xx) { - violations.push(`${method.toUpperCase()} ${path}`); + const success = Object.keys(responses).filter((c) => c.startsWith("2")); + if (success.length === 0) { + violations.push(`${label}: no 2xx response`); + continue; + } + for (const code of success) { + const body = responses[code]!; + if (!body.description) violations.push(`${label}: ${code} has no description`); + if (code === "204") continue; + const types = Object.keys(body.content ?? {}); + if (types.length === 0) violations.push(`${label}: ${code} declares no content`); + for (const type of types) { + if (isEmptySchema(body.content![type]!.schema)) { + violations.push(`${label}: ${code} ${type} has an EMPTY schema`); + } + } } - }); + } expect(violations).toEqual([]); }); -}); -describe("OpenAPI spec — RFC 7807 error responses (#456 + #462)", () => { - const spec = buildSpec(SPEC_OPTIONS); - - test("every declared 4xx/5xx response uses application/problem+json", () => { - // Per #456: all error responses are RFC 7807 problem+json. The - // spec must reflect that — anyone reverting to the legacy - // `{ data, error }` envelope without updating the spec gets a - // test failure here. - // - // Allowlist for paths whose error-shape predates #456 and is - // tracked separately: - const known4xxLegacy = new Set([ - // (empty — #456 finished the migration; allowlist exists as a - // forward-compat seam if a partial revert ever lands) - ]); + test("every operation declares at least one error response", () => { + const violations = operations + .filter((e) => !NO_FAILURE_MODE.has(e.label)) + .filter((e) => !Object.keys(e.op.responses ?? {}).some((c) => /^[45]/.test(c))) + .map((e) => e.label); + expect(violations).toEqual([]); + }); + test("every 4xx/5xx response is RFC 7807 application/problem+json", () => { const violations: string[] = []; - eachOperation(spec, (path, method, op) => { - if (known4xxLegacy.has(`${method.toUpperCase()} ${path}`)) return; - const responses = op.responses ?? {}; - for (const [code, body] of Object.entries(responses)) { + for (const { op, label } of operations) { + for (const [code, body] of Object.entries(op.responses ?? {})) { if (!/^[45]/.test(code)) continue; - const content = (body as { content?: Record }).content; - if (!content) continue; // some error responses are documented without a body shape - // Accept either `application/problem+json` (#456 standard) or - // legacy envelope IF the operation declares both. The bar is - // "no error response that ONLY declares the legacy envelope". - const types = Object.keys(content); - const hasProblemJson = types.some((t) => - t.toLowerCase().includes("problem+json") || t.toLowerCase().includes("application/json"), - ); - if (!hasProblemJson) { - violations.push(`${method.toUpperCase()} ${path} ${code}: ${types.join(", ")}`); + if (!body.description) violations.push(`${label}: ${code} has no description`); + const types = Object.keys(body.content ?? {}); + if (types.length === 0) continue; + if (RAW_ERROR_RESPONSES.has(`${label} ${code}`)) continue; + if (!types.includes("application/problem+json")) { + violations.push(`${label}: ${code} is ${types.join(", ")}, expected application/problem+json`); } } - }); + } expect(violations).toEqual([]); }); -}); -describe("OpenAPI spec — operation security declarations (#462)", () => { - const spec = buildSpec(SPEC_OPTIONS); - - /** - * Paths that are explicitly designed to be public (no auth). New - * paths added here MUST be intentional — a route that should be - * authenticated but ends up here is a CONVENTIONS.md §5 violation. - * - * Keep this list short and review-gated. - */ - const publicPaths = new Set([ - "/api/v1/skill-format/rules", - "/api/v1/skill-manifest-schema.json", - ]); - - test("every operation outside the public allowlist declares BearerAuth security", () => { + test("the problem body documents fields at the root, not inside an envelope", () => { + // Guards the specific #456 regression: describing errors with the + // legacy `{ data, error }` envelope makes every generated client read + // `err.error.message` and get undefined. const violations: string[] = []; - eachOperation(spec, (path, method, op) => { - if (publicPaths.has(path)) return; - const sec = op.security as Array> | undefined; - const hasBearer = Array.isArray(sec) && sec.some((s) => "BearerAuth" in s); - if (!hasBearer) { - violations.push(`${method.toUpperCase()} ${path}`); + for (const { op, label } of operations) { + for (const [code, body] of Object.entries(op.responses ?? {})) { + if (!/^[45]/.test(code)) continue; + const schema = body.content?.["application/problem+json"]?.schema as + | { properties?: Record } + | undefined; + if (!schema) continue; + const props = schema.properties ?? {}; + for (const required of ["type", "title", "status", "detail", "code"]) { + if (!(required in props)) violations.push(`${label}: ${code} problem body lacks '${required}'`); + } + if ("data" in props) violations.push(`${label}: ${code} problem body still uses the legacy envelope`); } - }); + } expect(violations).toEqual([]); }); }); -describe("OpenAPI spec — schema reference integrity", () => { - const spec = buildSpec(SPEC_OPTIONS); - - test("declared paths cover the foundational skill CRUD surface", () => { - // This is a regression test, not a coverage test. The full - // coverage gap (≈40 missing routes) is tracked as a follow-up on - // #462. Here we just pin the routes that have been documented so - // a refactor doesn't accidentally drop them. - const required = [ - "/api/v1/skills", - "/api/v1/skills/{idOrName}", - "/api/v1/skills/{id}", - "/api/v1/skill-search", - "/api/v1/skill-format/rules", - "/api/v1/skill-manifest-schema.json", - ]; - const paths = spec.paths as Record; - const present = new Set(Object.keys(paths)); - const missing = required.filter((r) => !present.has(r)); - expect(missing).toEqual([]); +describe("OpenAPI spec — schema generation is not silently empty (#1214)", () => { + // `zod-to-json-schema@3` produced `{}` for every zod 4 schema. That is + // the failure this whole issue traced back to, and it is invisible + // unless something asserts on it: the document stayed structurally + // valid, it just described nothing. + test("no operation anywhere in the document carries an empty schema object", () => { + const violations: string[] = []; + const visit = (node: unknown, trail: string): void => { + if (Array.isArray(node)) { + node.forEach((child, i) => visit(child, `${trail}[${i}]`)); + return; + } + if (typeof node !== "object" || node === null) return; + for (const [key, value] of Object.entries(node as Record)) { + if (key === "schema" && isEmptySchema(value)) violations.push(`${trail}.${key}`); + visit(value, `${trail}.${key}`); + } + }; + visit(paths, "paths"); + expect(violations).toEqual([]); + }); + + test("a representative Zod-derived response actually carries properties", () => { + // Belt and braces: if the converter regresses to returning `{}` the + // test above catches it, but this pins one known-good shape so the + // failure message points straight at the cause. + const op = paths["/api/v1/skills/{idOrName}"]?.get as Operation | undefined; + expect(op).toBeDefined(); + const schema = op!.responses?.["200"]?.content?.["application/json"]?.schema as + | { properties?: { data?: { properties?: Record } } } + | undefined; + const dataProps = schema?.properties?.data?.properties ?? {}; + expect(Object.keys(dataProps).length).toBeGreaterThan(0); + }); + + test("GET /skill-search publishes its query parameters", () => { + // The most user-visible symptom of the converter bug: the primary + // discovery endpoint advertised `parameters: []`. + const op = paths["/api/v1/skill-search"]?.get as Operation | undefined; + expect(op).toBeDefined(); + const queryNames = (op!.parameters ?? []).filter((p) => p.in === "query").map((p) => p.name); + expect(queryNames.length).toBeGreaterThan(0); + expect(queryNames).toContain("q"); }); }); diff --git a/ornn-api/tests/contract/openapiRoutes.test.ts b/ornn-api/tests/contract/openapiRoutes.test.ts index 2d412d5c..18461aee 100644 --- a/ornn-api/tests/contract/openapiRoutes.test.ts +++ b/ornn-api/tests/contract/openapiRoutes.test.ts @@ -1,24 +1,29 @@ /** - * OpenAPI ↔ router reflection test (#1213). + * OpenAPI ↔ router reflection test (#1213, #1214). * - * The spec's path table in `src/openapi/specBuilder.ts` is hand-written - * while the routes it describes are registered in `src/bootstrap.ts`. - * Nothing structurally ties the two together, and they drifted badly: + * The spec's path table is assembled in `src/openapi/paths/*.ts` while the + * routes it describes are registered in `src/bootstrap.ts`. Nothing in the + * type system ties the two together, and historically they drifted badly: * - * - every path was published under `/api/` while the router had moved - * to `/api/v1/` (#101), so no URL in the spec resolved; + * - every path was published under `/api/` while the router had moved to + * `/api/v1/` (#101), so no URL in the spec resolved; * - four `/admin/{categories,tags}` paths described endpoints that had - * been deleted from the codebase entirely. + * been deleted from the codebase entirely; + * - and in the other direction, 91 of 104 registered routes were absent + * from the document altogether (#1214). * - * Both are the same failure: the spec claimed something the router does - * not serve. The other contract tests could not catch it because they - * only inspect the spec against itself. This one boots the real app and - * checks each documented operation against the live route table. + * This file closes the loop in **both** directions, which is what makes + * the spec trustworthy as the contract CONVENTIONS.md §10 claims it is: * - * Direction matters. This asserts *documented ⇒ registered* — no phantom - * endpoints. The converse (*registered ⇒ documented*, i.e. closing the - * coverage gap) is tracked as #1214 and deliberately not enforced here; - * turning it on today would fail on ~90 legitimately-undocumented routes. + * - *documented ⇒ registered* — the spec never advertises an endpoint + * the API does not serve. Agents following it never get a 404. + * - *registered ⇒ documented* — the API never serves an endpoint the + * spec does not describe. Adding a route without documenting it + * fails CI here, so the coverage gap cannot silently reopen. + * + * There is deliberately no allowlist. #1214 burned the last of it down; + * reintroducing one would restore exactly the ratchet that let coverage + * decay to 13% in the first place. * * @module tests/contract/openapiRoutes */ @@ -49,46 +54,70 @@ beforeAll(async () => { afterAll(async () => { await harness.cleanup(); - // Explicit timeout: `cleanup()` stops a MongoMemoryServer, which under - // a loaded full-suite run regularly exceeds bun's 5s hook default. The - // existing integration files omit this and flake because of it (#1215) - // — this one does not pile on. + // Explicit timeout: `cleanup()` stops a MongoMemoryServer, which under a + // loaded full-suite run regularly exceeds bun's 5s hook default (#1215). }, 30_000); -describe("OpenAPI spec — every documented operation is a real route (#1213)", () => { - test("no declared path+method is missing from the booted router", () => { - // `app.routes` carries one entry per handler in each chain, so the - // same path appears once per middleware. Dedupe, and drop the `ALL` - // entries — those are middleware mounts, not endpoints. - const registered = new Set( - harness.app.routes - .filter((r) => r.method !== "ALL") - .map((r) => `${r.method.toUpperCase()} ${r.path}`), - ); - - const spec = buildSpec(SPEC_OPTIONS); - const paths = spec.paths as Record>; +/** + * Every endpoint the booted router actually serves, as `METHOD /path`. + * + * `app.routes` carries one entry per handler in each chain, so a path with + * three middlewares appears three times — dedupe. `ALL` entries are + * middleware mounts (`app.use("*", ...)`), not endpoints. + */ +function registeredRoutes(): Set { + return new Set( + harness.app.routes + .filter((r) => r.method !== "ALL") + .map((r) => `${r.method.toUpperCase()} ${r.path}`), + ); +} - const missing: string[] = []; - for (const [specPath, pathItem] of Object.entries(paths)) { - for (const method of Object.keys(pathItem)) { - if (!isOperation(method)) continue; - const key = `${method.toUpperCase()} ${toHonoPath(specPath)}`; - if (!registered.has(key)) missing.push(key); - } +/** Every operation the spec declares, keyed the same way. */ +function documentedRoutes(): Set { + const spec = buildSpec(SPEC_OPTIONS); + const paths = spec.paths as Record>; + const keys = new Set(); + for (const [specPath, pathItem] of Object.entries(paths)) { + for (const method of Object.keys(pathItem)) { + if (!isOperation(method)) continue; + keys.add(`${method.toUpperCase()} ${toHonoPath(specPath)}`); } + } + return keys; +} - // A non-empty list means the spec is advertising an endpoint the API - // does not serve — agents following it get a 404. +describe("OpenAPI spec ↔ router (#1213, #1214)", () => { + test("no documented operation is missing from the booted router", () => { + const registered = registeredRoutes(); + const missing = [...documentedRoutes()].filter((key) => !registered.has(key)).sort(); + // Non-empty means the spec advertises an endpoint the API does not + // serve — agents following it get a 404. expect(missing).toEqual([]); }); + test("no registered route is missing from the spec", () => { + const documented = documentedRoutes(); + const undocumented = [...registeredRoutes()].filter((key) => !documented.has(key)).sort(); + // Non-empty means a route shipped without documentation. Add it to the + // matching module under `src/openapi/paths/` — do NOT add an allowlist + // here; see this file's header. + expect(undocumented).toEqual([]); + }); + test("the router actually serves the /api/v1 prefix the spec declares", () => { // Guards the specific regression: if the mount prefix in bootstrap.ts - // and `prefix` in specBuilder.ts ever diverge again, the assertion - // above goes red — but only if the router really is on /api/v1. Pin - // that independently so a matched-but-wrong pair can't pass silently. + // and `prefix` in specBuilder.ts ever diverge again, the assertions + // above go red — but only if the router really is on /api/v1. Pin that + // independently so a matched-but-wrong pair cannot pass silently. const v1Routes = harness.app.routes.filter((r) => r.path.startsWith("/api/v1/")); expect(v1Routes.length).toBeGreaterThan(0); }); + + test("the spec covers the whole surface, not a sample of it", () => { + // A blunt floor. If someone deletes a path module and the two set + // comparisons above are somehow both satisfied, this still fails. + expect(documentedRoutes().size).toBe(registeredRoutes().size); + expect(documentedRoutes().size).toBeGreaterThan(100); + }); });