From f6c16f41fb337e463e58b5ce87b801790f64bf5b Mon Sep 17 00:00:00 2001 From: Shining Date: Thu, 14 May 2026 23:27:03 +0800 Subject: [PATCH 001/128] docs(skill): refresh ornn-agent-manual-cli to v1.2 against develop API (#560) (#561) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-syncs SKILL.md + references/api-reference.md against the current /api/v1/* surface on develop. No ornn-api / ornn-web code change — this is a registry-published skill manual bump only. Agents pick up the new contract by pulling GET /api/v1/skills/ornn-agent-manual-cli/json after the registry-side GitHub sync runs. SKILL.md - frontmatter: version "1.1" -> "1.2", lastUpdated 2026-05-14 - replaced the now-expired "this file will be removed" banner with a positive Scope framing (CLI variant is the smaller-footprint companion to chrono-ai-service-manual) - §0.5 versions response: added the missing { data: ... } envelope - §1.3 permission table: removed defunct ornn:admin:category row, added ornn:quota:admin for the real quota-admin surface - §2.13 notifications recipe: documented the discriminated source: "user" | "broadcast" feed shape that landed with broadcasts - §2.15 (new): recipe for /me/quota + /me/models?surface=... so an agent can resolve a valid modelId and short-circuit on remaining: 0 before any SSE call; mentions /me/redemption-codes/redeem as the top-up path - §4 references: added pointers for /me/quota, /me/models, and /announcements/active references/api-reference.md - §1.5 permission catalogue: dropped ornn:admin:category, added ornn:quota:admin - §1.7 HTTP status mapping: added 410 (gone) and 429 (QUOTA_EXCEEDED); fixed the 201 trigger examples to point at the real creating endpoints (announcement / broadcast / redemption-code create) - §1.8 error legend: added INVALID_SURFACE, QUOTA_EXCEEDED, the three MODEL_* codes, NOTIFICATION_NOT_FOUND, and the four REDEMPTION_CODE_* codes with their real status codes - §9.1 notifications: rewrote the response shape to show both variants of the discriminated union (user + bilingual broadcast) - §9.3: renamed NOT_FOUND -> NOTIFICATION_NOT_FOUND and clarified that the handler routes by id-lookup across both collections - §9.4: clarified that `updated` is a combined count across per-user notifications + broadcast read-receipts - §11 me: added §11.9 /me/quota, §11.10 /me/models, §11.11 /me/redemption-codes/redeem, §11.12 /me/redemption-codes/history; bumped the header endpoint count from 8 -> 12 - §13 admin: full rewrite. Removed six ghost sections (/admin/stats, /admin/activities, four /admin/categories/* routes, three /admin/tags/* routes). Replaced with compact tables for the real surface: /admin/dashboard/stats, /admin/users, admin skill browse + delete, AgentSeal rescan, /admin/quota/*, /admin/redemption-codes/*, /admin/mirror/{reconcile,status} with the correct 202/409/503 contract, announcements (public + admin), broadcasts - §14 platform settings: full rewrite covering the four real route groups — legacy single-document /admin/settings, sectioned /admin/settings/{section} with the seven real publicPath values (playground, skill-generation, mirror, integrations/nyxid, skill-audit, posthog, extras), the seven LLM-provider routes including the per-model surface-flags PATCH, and export/import - appendix: corrected the registry skill name from ornn-agent-manual to ornn-agent-manual-cli .changeset/ornn-agent-manual-cli-v1-2.md - empty changeset (no semver bump) per CLAUDE.md guidance for docs-only PRs; satisfies check-changeset.yml gate Closes #560 --- .changeset/ornn-agent-manual-cli-v1-2.md | 4 + skills/ornn-agent-manual-cli/SKILL.md | 57 ++- .../references/api-reference.md | 476 ++++++++++-------- 3 files changed, 310 insertions(+), 227 deletions(-) create mode 100644 .changeset/ornn-agent-manual-cli-v1-2.md diff --git a/.changeset/ornn-agent-manual-cli-v1-2.md b/.changeset/ornn-agent-manual-cli-v1-2.md new file mode 100644 index 00000000..a7c05546 --- /dev/null +++ b/.changeset/ornn-agent-manual-cli-v1-2.md @@ -0,0 +1,4 @@ +--- +--- + +Refresh `ornn-agent-manual-cli` skill manual to v1.2 (#560). Re-syncs `SKILL.md` and `references/api-reference.md` against the current `/api/v1/*` surface on `develop` — fixes the `GET /skills/:idOrName/versions` envelope in §0.5, drops the defunct `ornn:admin:category` permission row, teaches §2.13 the discriminated `source: "user" | "broadcast"` notification feed, adds the §2.15 quota / model-picker recipe, removes six ghost admin sections (`/admin/stats`, `/admin/activities`, categories CRUD, tags CRUD) and replaces them with the real surface (`/admin/dashboard/stats`, `/admin/quota/*`, `/admin/redemption-codes/*`, `/admin/mirror/*`, AgentSeal rescan, announcements, broadcasts), rewrites §14 platform settings around the sectioned routes + LLM-provider CRUD + export/import, adds §11.9–§11.12 for `/me/quota`, `/me/models`, `/me/redemption-codes/*`, and corrects the appendix skill name. No `ornn-api` / `ornn-web` code change — empty changeset satisfies the gate; agents pick up the new contract by pulling `GET /api/v1/skills/ornn-agent-manual-cli/json` after the registry-side sync. diff --git a/skills/ornn-agent-manual-cli/SKILL.md b/skills/ornn-agent-manual-cli/SKILL.md index e95f08bc..e1fde1fb 100644 --- a/skills/ornn-agent-manual-cli/SKILL.md +++ b/skills/ornn-agent-manual-cli/SKILL.md @@ -9,13 +9,13 @@ metadata: - manual - skill-lifecycle - cli -version: "1.1" -lastUpdated: 2026-04-29 +version: "1.2" +lastUpdated: 2026-05-14 --- # Agent Manual (NyxID CLI variant) -> **DEPRECATED — replaced by `chrono-ai-service-manual`.** This skill is kept for one minor release while consumers migrate. The unified manual folds NyxID identity / CLI / proxy AND the Ornn skill lifecycle into a single skill so an agent that wants to drive both halves of the stack only needs one install. **New work should pull `chrono-ai-service-manual` instead** (Ornn skill name; same auth flow, same registry, identical API contract — strict superset of this file). This file will be removed in the release after the unified manual lands. +> **Scope.** This skill is the CLI-only operational manual for the Ornn half of the Chrono AI stack. If you also need to drive NyxID (identity, services, orgs, OAuth clients, proxy) — for example to register a new service or rotate a client — pull `chrono-ai-service-manual` instead. It is a strict superset of this file, with the same Ornn API contract plus the NyxID half folded in. This skill remains the right pick when the agent only needs Ornn skill-lifecycle operations and wants a smaller context payload. > **You are an AI agent reading this manual to learn how to use Ornn.** Throughout this document, *"you"* always refers to **the agent itself** — not the human user driving you. > @@ -45,8 +45,9 @@ lastUpdated: 2026-04-29 > - **Bind a skill to a NyxID service** (system / personal) — §2.9. > - **Delete a skill** entirely — §2.11. > - **Find skills** (by tag, author, system, shared, etc.) — §2.12. -> - **Pull your Ornn notifications** (audit fan-out, etc.) — §2.13. +> - **Pull your Ornn notifications** (audit fan-out, broadcasts, etc.) — §2.13. > - **Link a skill to GitHub** or **trigger a sync** from the linked source — §2.14. +> - **Check your monthly quota** or **pick a valid LLM model** before calling an SSE endpoint — §2.15. > > Without this manual loaded, you do not know which endpoint to call, how to authenticate, or how to read the response shapes. > @@ -124,7 +125,7 @@ nyxid proxy request ornn-api \ For public skills you can drop the auth and call the same endpoint anonymously — see §2.1 step 3 for fetch alternatives. -The response is `{ items: [{ version, skillHash, createdOn, isDeprecated, deprecationNote, releaseNotes, ... }, ...] }` sorted newest-first. Compare `items[0].version` to the `installedVersion` on the matching record in `~/.ornn/installed-skills.json` and act: +The response is `{ data: { items: [{ version, skillHash, createdOn, isDeprecated, deprecationNote, releaseNotes, ... }, ...] }, error: null }` sorted newest-first. Compare `data.items[0].version` to the `installedVersion` on the matching record in `~/.ornn/installed-skills.json` and act: - **Same version** → execute as-is. - **Newer version available** → tell the user `"Skill has a newer version (you have ). Release notes: . Update? (y/n)"`. If yes, re-fetch the package (§2.1 step 3), overwrite the local copy, update `installedVersion` + `installedAt` in `~/.ornn/installed-skills.json`, then execute. @@ -179,10 +180,10 @@ Expected output includes `user_id`, `email`, `roles`, and `permissions`. Confirm | Generate a skill with AI (`POST /skills/generate*`) | `ornn:skill:build` | | Use the Playground (`POST /playground/chat`) | `ornn:playground:use` | | Trigger an audit (`POST /skills/:idOrName/audit`) | none (owner or `ornn:admin:skill`) | -| Admin operations (`/admin/*`, force-audit, platform settings) | `ornn:admin:skill` | -| Manage categories (`/admin/categories/*`) | `ornn:admin:category` | +| Admin operations (`/admin/*`, force-audit, sectioned platform settings) | `ornn:admin:skill` | +| Admin quota / redemption-codes operations (`/admin/quota/*`, `/admin/redemption-codes/*`, `/admin/dashboard/stats`) | `ornn:quota:admin` | -Most read operations — browsing public skills, version listings, skill format rules, audit verdicts on visible skills, notifications — **need no scalar permission**; they're open to any authenticated caller (and some are anonymous). The exact gates for every endpoint live in `references/api-reference.md`. +Most read operations — browsing public skills, version listings, skill format rules, audit verdicts on visible skills, notifications, your own quota — **need no scalar permission**; they're open to any authenticated caller (and some are anonymous). The exact gates for every endpoint live in `references/api-reference.md`. ### 1.4 Discover the Ornn service @@ -580,28 +581,32 @@ Combine query params freely. The full schema (every supported filter, every resp Ornn sends notifications on events like audit completion (own + risky-for-consumer fan-out) and other state changes: ```bash -# Cheap badge count +# Cheap badge count (covers per-user notifications AND admin-authored broadcasts) nyxid proxy request ornn-api "/api/v1/notifications/unread-count" \ --method GET --output json -# Fetch unread notifications +# Fetch unread items (mixed feed — per-user + broadcasts) nyxid proxy request ornn-api "/api/v1/notifications?unread=true&limit=50" \ --method GET --output json -# Mark one notification as read +# Mark one item as read (accepts either a per-user notification id or a broadcast id) nyxid proxy request ornn-api "/api/v1/notifications//read" \ --method POST --data '{}' --output json -# Mark all as read +# Mark every unread item as read nyxid proxy request ornn-api "/api/v1/notifications/mark-all-read" \ --method POST --data '{}' --output json ``` -Two notification categories are emitted today: +The feed is a **discriminated union**: each item carries `source: "user"` or `source: "broadcast"`. Branch on `source` before reading category-specific fields — `category`, `title`, `body`, `link`, `data` live on `source: "user"` rows only; `source: "broadcast"` rows carry bilingual `titleI18n` / `bodyMarkdownI18n` instead. Both shapes share `_id`, `readAt`, `createdAt`. + +Per-user (`source: "user"`) categories emitted today: - `audit.completed` — sent to the skill owner on every audit completion. - `audit.risky_for_consumer` — fanned out to every consumer of the skill (everyone in `sharedWithUsers` + members of every org in `sharedWithOrgs`) when a verdict comes back `yellow` or `red`. **Treat this as a hard signal to stop using the skill** until you've reviewed the findings; surface it to the user and ask before continuing. +Broadcasts (`source: "broadcast"`) are platform-wide markdown notices authored by platform admins. They have no category — treat them as informational and surface them verbatim (use `titleI18n.en` / `bodyMarkdownI18n.en` unless the user has a `zh` locale). + ### 2.14 Link a skill to GitHub or trigger a sync — *spec: `api-reference.md` §3.2 + §3.3 + §3.15* **When:** the user wants their Ornn skill to live in (or co-exist with) a public GitHub repo so updates flow from there into Ornn one-click. Three flows depending on starting state: @@ -665,6 +670,29 @@ Apply response: the refreshed `SkillDetail`. `source.lastSyncedAt` and `source.l - `REFRESH_FAILED` (400) on apply, `REFRESH_PREVIEW_FAILED` (400) on dry-run — the upstream folder no longer exists, or the pulled package failed validation. If the upstream is trusted and the failure is validation, retry apply with `skipValidation: true`. - `NOT_SKILL_OWNER` (403) — the caller isn't the author and lacks `ornn:admin:skill`. +### 2.15 Check your monthly quota or pick a valid LLM model — *spec: `api-reference.md` §11 Me — caller scope* + +The SSE endpoints (`POST /skills/generate*`, `POST /playground/chat`) both meter against a **monthly quota** and require a valid `modelId`. Two cheap reads let you avoid hitting `429 QUOTA_EXCEEDED` or `400 MODEL_NOT_ENABLED` mid-stream: + +```bash +# Your current month's allotments + remaining counts for both metered surfaces +nyxid proxy request ornn-api "/api/v1/me/quota" \ + --method GET --output json + +# Pick a model the deployment has enabled for the surface you're about to call +nyxid proxy request ornn-api "/api/v1/me/models?surface=playground" \ + --method GET --output json + +nyxid proxy request ornn-api "/api/v1/me/models?surface=skillGen" \ + --method GET --output json +``` + +`/me/quota` response shape: `{ data: { isAdmin, monthMarker, monthStart, monthEnd, nextMonthlyResetAt, playground: { defaultAllotment, adminGrant, used, remaining, warningThreshold, warning }, skillGen: { ... } }, error: null }`. Admins bypass quota — `isAdmin: true` means every charge is free; the per-surface numbers are still populated but never block. + +`/me/models` response shape: `{ data: { items: [{ modelId, displayName, isDefault }, ...], defaultModelId }, error: null }`. Pass `defaultModelId` into the generate / playground body when the user hasn't expressed a preference. The list is platform-controlled — if it's empty the admin has not enabled any model for that surface, and SSE calls will fail with `MODEL_UNAVAILABLE`. + +Quota refills automatically at `nextMonthlyResetAt`. If a user is low and needs more before then, they can redeem a code via `POST /api/v1/me/redemption-codes/redeem` with `{"code":""}` — the response carries the updated grants. Don't redeem codes the user hasn't given you. + --- ## §3. Conventions & Pitfalls @@ -689,5 +717,8 @@ Apply response: the refreshed `SkillDetail`. `source.lastSyncedAt` and `source.l - `GET /api/v1/skill-format/rules` — canonical skill package format spec, always up-to-date with what the validator enforces. - `GET /api/v1/openapi.json` — auto-generated OpenAPI 3 schema. Every endpoint mentioned in this manual is in here with full Zod-derived request/response types. - `GET /api/v1/me` — your current identity snapshot (userId, email, displayName, roles, permissions). Useful when debugging a 403. +- `GET /api/v1/me/quota` — monthly allotment + remaining counts for both metered surfaces (`playground`, `skillGen`). Read before SSE calls so you don't hit `429 QUOTA_EXCEEDED` mid-stream (§2.15). +- `GET /api/v1/me/models?surface=playground|skillGen` — platform-enabled LLM picker. Pass `defaultModelId` into generate / playground bodies (§2.15). +- `GET /api/v1/announcements/active` — public, anonymous platform-wide notice (separate from `/notifications`). Useful when you want to know about maintenance windows or pricing changes before kicking off long workflows. If you find a discrepancy between this manual and the actual API behaviour, the API is right and the manual is stale — re-pull the skill (§0) before assuming a bug. diff --git a/skills/ornn-agent-manual-cli/references/api-reference.md b/skills/ornn-agent-manual-cli/references/api-reference.md index 3741c9c6..8da637da 100644 --- a/skills/ornn-agent-manual-cli/references/api-reference.md +++ b/skills/ornn-agent-manual-cli/references/api-reference.md @@ -106,8 +106,8 @@ Permissions are issued by NyxID as part of the proxy-forwarded identity. Roles m | `ornn:skill:delete` | `ornn-user` | `DELETE /skills/:id`, `DELETE /skills/:idOrName/versions/:version` | | `ornn:skill:build` | `ornn-user` | `POST /skills/generate`, `POST /skills/generate/from-source`, `POST /skills/generate/from-openapi` | | `ornn:playground:use` | `ornn-user` | `POST /playground/chat` | -| `ornn:admin:skill` | `ornn-admin` | All `/admin/*` skill-scoped routes; admin force-audit; platform settings | -| `ornn:admin:category` | `ornn-admin` | `GET/POST/PUT/DELETE /admin/categories/*` | +| `ornn:admin:skill` | `ornn-admin` | All `/admin/*` skill-scoped routes; admin force-audit; sectioned platform settings; mirror config; announcements / broadcasts | +| `ornn:quota:admin` | `ornn-admin` | `GET /admin/dashboard/stats`; all `/admin/quota/*`; all `/admin/redemption-codes/*` | A few endpoints (`POST /skills/:idOrName/audit`, the various caller-scoped reads) gate on **ownership** instead of (or in addition to) a permission — those are documented per-endpoint. @@ -137,13 +137,15 @@ Writing / managing a skill (`canManageSkill`) collapses to: **author OR platform | Status | Used for | |---|---| | 200 | Successful read or write | -| 201 | Resource created (admin category / tag create) | +| 201 | Resource created (admin announcement / broadcast / redemption-code create) | | 400 | Validation error, malformed body, bad query param | | 401 | `AUTH_MISSING` — no usable identity from the proxy | | 403 | `FORBIDDEN` — authed but missing permission, ownership check failed, or trying to mutate someone else's skill | | 404 | `*_NOT_FOUND` — resource missing or hidden under visibility rules | -| 409 | Conflict (e.g. duplicate version on publish) | +| 409 | Conflict (e.g. `REDEMPTION_CODE_ALREADY_REDEEMED`) | +| 410 | Gone (e.g. `REDEMPTION_CODE_EXPIRED`, `REDEMPTION_CODE_INVALIDATED`) | | 413 | `PAYLOAD_TOO_LARGE` — ZIP exceeds `MAX_PACKAGE_SIZE_BYTES` (default 50 MiB) | +| 429 | `QUOTA_EXCEEDED` — caller has burned this month's allotment on the metered surface. Check `/me/quota`. | | 500 | `INTERNAL_ERROR` or domain-specific 500 — retry with backoff and include `X-Request-ID` if reporting | | 503 | `/readyz` only — Mongo unreachable | @@ -181,6 +183,14 @@ The codes below appear across many endpoints. Per-endpoint sections list any add | `NYXID_SERVICE_NOT_ELIGIBLE` | 403 | Caller is not allowed to tie a skill to that service (would tie to another user's personal service). | | `SYSTEM_SKILL_MUST_BE_PUBLIC` | 400 | Skill tied to an admin service cannot be made private. Untie first. | | `QUERY_REQUIRED` / `AUTH_REQUIRED` | 400 | `/skill-search` invariant violated (semantic mode needs both query and auth). | +| `INVALID_SURFACE` | 400 | `/me/models` `surface` query param is not `playground` or `skillGen`. | +| `QUOTA_EXCEEDED` | 429 | Monthly allotment burned on the metered surface (`playground` or `skillGen`). Read `/me/quota` for the snapshot. | +| `MODEL_UNAVAILABLE` / `MODEL_NOT_ENABLED` / `MODEL_NOT_FOUND` | 400 | SSE pre-stream: requested `modelId` is unknown or not enabled for this surface. Call `/me/models` to discover valid ids. | +| `NOTIFICATION_NOT_FOUND` | 404 | `POST /notifications/:id/read` — the id is neither a per-user notification nor a broadcast the caller can see. | +| `REDEMPTION_CODE_NOT_FOUND` | 404 | Redemption code does not exist. | +| `REDEMPTION_CODE_EXPIRED` | 410 | Past `expiresAt`. | +| `REDEMPTION_CODE_INVALIDATED` | 410 | Admin revoked the code. | +| `REDEMPTION_CODE_ALREADY_REDEEMED` | 409 | Already consumed (codes are single-use). | | `PAYLOAD_TOO_LARGE` | 413 | Upload exceeds `MAX_PACKAGE_SIZE_BYTES`. | | `PACKAGE_DOWNLOAD_FAILED` | 500 | The backend could not retrieve the skill ZIP from object storage. | | `NYXID_ORG_LOOKUP_FAILED` | 500 | NyxID returned a non-OK response when the backend tried to resolve an org on the caller's behalf. | @@ -1442,14 +1452,18 @@ The backend runs up to 5 rounds of tool-use; when the LLM emits a `function_call Four endpoints. All require auth. All scoped to the caller — no cross-user reads. -Two notification categories are emitted today; both come from the audit pipeline: +After [#500](https://github.com/ChronoAIProject/Ornn/pull/500) the feed is a **discriminated union** of per-user notifications and admin-authored broadcasts. Each item carries a `source` field — `"user"` for the audit-pipeline notifications below, `"broadcast"` for platform-wide markdown notices. The two variants share `_id`, `readAt`, `createdAt`; everything else is variant-specific. `unread-count` and `mark-all-read` cover both sources in one shot. `POST /:id/read` accepts either kind of id and routes by lookup. + +Per-user (`source: "user"`) categories emitted today; both come from the audit pipeline: | Category | Sent to | Trigger | |---|---|---| | `audit.completed` | The skill **owner** | Every audit completion. Body distinguishes `green` from `yellow`/`red` and links to the audit history page. | | `audit.risky_for_consumer` | Every consumer of a `yellow`/`red`-verdict skill — every user in `sharedWithUsers`, plus the membership of every org in `sharedWithOrgs` (resolved via NyxID at fan-out time) | Same audit completion. Skipped for `green`. | -There are no other categories; share / waiver / review notifications were removed when the audit-gated share workflow was retired. +There are no other per-user categories; share / waiver / review notifications were removed when the audit-gated share workflow was retired. + +Broadcasts (`source: "broadcast"`) are bilingual platform-wide notices authored via `POST /admin/broadcasts` (admin-only). They have no `category` — every row in this lane is treated as informational. ### 9.1 List notifications — `GET /api/v1/notifications` @@ -1457,10 +1471,10 @@ There are no other categories; share / waiver / review notifications were remove | Query param | Type | Notes | |---|---|---| -| `unread` | `"true"` | Optional. Restrict to unread. | -| `limit` | int 1–200 | Default 50. | +| `unread` | `"true"` | Optional. Restrict to unread items (across both sources). | +| `limit` | int 1–200 | Default 50. Caps the merged feed length. | -Response 200: +Response 200 — the `items` array is a discriminated union on `source`: ```jsonc { @@ -1468,6 +1482,7 @@ Response 200: "items": [ { "_id": "ntf_...", + "source": "user", "userId": "user_...", "category": "audit.risky_for_consumer", "title": "Audit found risk in 'csv-tools' (yellow)", @@ -1476,6 +1491,14 @@ Response 200: "data": { "skillGuid": "skl_...", "version": "1.3", "verdict": "yellow" }, "readAt": null, "createdAt": "2026-04-28T..." + }, + { + "_id": "bc_...", + "source": "broadcast", + "titleI18n": { "en": "Scheduled maintenance", "zh": "计划维护" }, + "bodyMarkdownI18n": { "en": "We will be down at...", "zh": "我们将在..." }, + "readAt": null, + "createdAt": "2026-04-29T..." } ] }, @@ -1483,6 +1506,8 @@ Response 200: } ``` +Switch on `source` before reading variant-specific fields. `body` / `link` / `data` exist only on `source: "user"`; `titleI18n` / `bodyMarkdownI18n` exist only on `source: "broadcast"`. + ### 9.2 Unread count — `GET /api/v1/notifications/unread-count` **Auth: required.** Cheap to poll for badge counts. @@ -1495,24 +1520,24 @@ Response 200: ### 9.3 Mark one read — `POST /api/v1/notifications/:id/read` -**Auth: required.** Sets `readAt` to `now()` and returns the updated row. +**Auth: required.** Sets `readAt` to `now()`. The handler routes by id-lookup: a `:id` that matches a per-user notification updates that row; one that matches a broadcast inserts (or updates) a read-receipt for the caller. Unknown ids surface `NOTIFICATION_NOT_FOUND`. Path param: `:id`. Body: ignored (send `{}`). Response 200: ```jsonc -{ "data": , "error": null } +{ "data": , "error": null } ``` | Code | Status | Cause | |---|---|---| | `AUTH_MISSING` | 401 | Standard. | -| `NOT_FOUND` | 404 | Notification does not belong to the caller (or does not exist). | +| `NOTIFICATION_NOT_FOUND` | 404 | The id is neither a per-user notification belonging to the caller nor a broadcast the caller can see. | ### 9.4 Mark all read — `POST /api/v1/notifications/mark-all-read` -**Auth: required.** Marks every unread notification belonging to the caller as read. +**Auth: required.** Marks every unread per-user notification belonging to the caller as read AND inserts read receipts for every currently-unread broadcast in one shot. Body: ignored. @@ -1522,6 +1547,8 @@ Response 200: { "data": { "updated": 12 }, "error": null } ``` +`updated` is the total count across both sources. + --- ## 10. Analytics @@ -1610,7 +1637,7 @@ Response 200: ## 11. Me — caller scope -Eight endpoints. All require auth and are scoped to the caller — there is no "read another user's …" surface here. +Twelve endpoints. All require auth and are scoped to the caller — there is no "read another user's …" surface here. ### 11.1 Identity snapshot — `GET /api/v1/me` @@ -1743,43 +1770,59 @@ Display names are resolved best-effort (NyxID for orgs, Ornn's activity director Response 200: same shape as 11.7. ---- - -## 12. Users directory - -Two endpoints. Backed by Ornn's `activities` collection — anyone who has ever signed into Ornn has a directory row keyed by `userId` + `email` + `displayName`. - -### 12.1 Search — `GET /api/v1/users/search` +### 11.9 Monthly quota — `GET /api/v1/me/quota` -**Auth: required.** Any authed caller may search; the result set is scoped to users with at least one Ornn activity row. - -| Query param | Type | Notes | -|---|---|---| -| `q` | string ≤ 256 | Email prefix. Empty returns the most recent N. | -| `limit` | int 1–50 | Default 10. | +**Auth: required.** Caller-scoped snapshot of the metered surfaces (`playground`, `skillGen`). Read before any SSE call so you can short-circuit on `remaining: 0` instead of burning the request to a 429. Response 200: ```jsonc { "data": { - "items": [ - { "userId": "user_...", "email": "alice@…", "displayName": "Alice" } - ] + "isAdmin": false, + "monthMarker": "2026-05", + "monthStart": "2026-05-01T00:00:00Z", + "monthEnd": "2026-06-01T00:00:00Z", + "nextMonthlyResetAt": "2026-06-01T00:00:00Z", + "playground": { + "defaultAllotment": 50, + "adminGrant": 10, + "used": 37, + "remaining": 23, + "warningThreshold": 0.8, + "warning": false + }, + "skillGen": { + "defaultAllotment": 20, + "adminGrant": 0, + "used": 20, + "remaining": 0, + "warningThreshold": 0.8, + "warning": true + } }, "error": null } ``` -| Code | Status | Cause | -|---|---|---| -| `INVALID_QUERY` | 400 | Query failed Zod validation. | +Field semantics: -### 12.2 Resolve — `GET /api/v1/users/resolve` +- `isAdmin: true` — caller has admin bypass; every charge is free. The per-surface counters are still populated (for visibility) but never block. +- `defaultAllotment` — platform-wide monthly base for the surface (set in `/admin/settings/quota`). +- `adminGrant` — additive top-up granted to this user (via `/admin/quota/grant` or by redeeming a code). +- `remaining = defaultAllotment + adminGrant − used`, clamped to ≥ 0. A successful chargeable call decrements `remaining` on completion. +- `warning: true` when `used / (defaultAllotment + adminGrant) ≥ warningThreshold`. UI surfaces a soft chip; agents can treat it as "consider stopping". +- Buckets reset at `nextMonthlyResetAt` (first day of next UTC month). Unused balance does not roll over. -**Auth: required.** Batch-resolve a list of `userId`s to `{ email, displayName }` rows. Used to render labels for `sharedWithUsers` entries that were saved as bare ids. +No error codes beyond the standard `AUTH_MISSING`. -Query: `ids=id1,id2,id3` (max 100 per call). Unknown ids are silently dropped — no 404. +### 11.10 Model picker — `GET /api/v1/me/models` + +**Auth: required.** List the LLM models the deployment has enabled for one of the SSE surfaces, sorted with that surface's default first. + +| Query param | Values | Notes | +|---|---|---| +| `surface` | `playground` \| `skillGen` | **Required.** Mismatched values → `INVALID_SURFACE`. | Response 200: @@ -1787,51 +1830,69 @@ Response 200: { "data": { "items": [ - { "userId": "user_a", "email": "a@…", "displayName": "Alice" }, - { "userId": "user_b", "email": "b@…", "displayName": "Bob" } - ] + { "modelId": "claude-3-5-sonnet-latest", "displayName": "Claude 3.5 Sonnet", "isDefault": true }, + { "modelId": "gpt-4o", "displayName": "GPT-4o", "isDefault": false } + ], + "defaultModelId": "claude-3-5-sonnet-latest" }, "error": null } ``` ---- +`items[].modelId` is what you pass as `modelId` to `POST /skills/generate*` or `POST /playground/chat`. Empty list means the admin has not enabled any model for this surface — SSE calls will pre-stream `MODEL_UNAVAILABLE`. `defaultModelId` mirrors the `isDefault: true` row; absent when the list is empty. -## 13. Admin +| Code | Status | Cause | +|---|---|---| +| `INVALID_SURFACE` | 400 | Query param missing or not in the enum. | +| `AUTH_MISSING` | 401 | Standard. | -Thirteen endpoints. All under `/api/v1/admin/*`. All require auth. Most agents never call these; they exist for the platform-admin UI. +### 11.11 Redeem a code — `POST /api/v1/me/redemption-codes/redeem` -### 13.1 Stats — `GET /api/v1/admin/stats` +**Auth: required.** Consume a one-time redemption code; lands additive grants on the caller's quota buckets for the current month. -**Auth: required.** **Permission: `ornn:admin:skill`.** +Request body (`application/json`): + +```jsonc +{ "code": "ORNN-XXXX-YYYY-ZZZZ" } +``` + +Schema: `z.object({ code: z.string().min(1).max(128) })`. Response 200: ```jsonc { "data": { - "totalUsers": 124, - "totalSkills": 412, - "publicSkills": 271, - "privateSkills": 141, - "recentActivities": 87 + "codeId": "rdm_...", + "redeemedAt": "2026-05-14T12:00:00Z", + "grants": [ + { "surface": "playground", "amount": 50, "monthMarker": "2026-05", "newAdminGrant": 60 }, + { "surface": "skillGen", "amount": 10, "monthMarker": "2026-05", "newAdminGrant": 10 } + ] }, "error": null } ``` -`recentActivities` is the count of activity-log rows in the last 24 hours. +`newAdminGrant` is the post-redeem total for that surface, useful for echoing to the user. Codes are single-use across all users; consuming one invalidates it everywhere. -### 13.2 Activities — `GET /api/v1/admin/activities` +| Code | Status | Cause | +|---|---|---| +| `INVALID_REDEEM_BODY` | 400 | Body failed Zod validation. | +| `REDEMPTION_CODE_NOT_FOUND` | 404 | Code string doesn't exist. | +| `REDEMPTION_CODE_EXPIRED` | 410 | Past `expiresAt`. | +| `REDEMPTION_CODE_INVALIDATED` | 410 | Admin revoked the code. | +| `REDEMPTION_CODE_ALREADY_REDEEMED` | 409 | Already consumed (codes are single-use). | +| `AUTH_MISSING` | 401 | Standard. | -**Auth: required.** **Permission: `ornn:admin:skill`.** +### 11.12 Redemption history — `GET /api/v1/me/redemption-codes/history` -| Query param | Type | Notes | +**Auth: required.** Caller's own redemption log, newest first. + +| Query param | Values | Default | |---|---|---| -| `page` | int ≥ 1 | Default 1. | -| `pageSize` | int 1–100 | Default 20. | -| `action` | activity action | Filter by action type (`login`, `skill:create`, `skill:update`, `skill:delete`, …). | -| `userId` | string | Filter by user. | +| `page` | int ≥ 1 | 1 | +| `pageSize` | int 1–100 | 20 | Response 200: @@ -1840,66 +1901,61 @@ Response 200: "data": { "items": [ { - "id": "act_...", - "userId": "user_...", - "userEmail": "...", - "userDisplayName": "...", - "action": "skill:create", - "details": { "skillId": "skl_...", "skillName": "my-skill" }, - "createdAt": "2026-04-28T..." + "id": "rdm_...", + "code": "ORNN-XXXX-YYYY-ZZZZ", + "grants": [{ "surface": "playground", "amount": 50 }], + "note": null, + "redeemedAt": "2026-05-14T12:00:00Z", + "expiresAt": "2026-06-30T23:59:59Z", + "createdAt": "2026-05-01T00:00:00Z" } ], - "total": 412, + "total": 3, "page": 1, "pageSize": 20, - "totalPages": 21 + "totalPages": 1 }, "error": null } ``` -### 13.3 Users — `GET /api/v1/admin/users` +--- -**Auth: required.** **Permission: `ornn:admin:skill`.** +## 12. Users directory + +Two endpoints. Backed by Ornn's `activities` collection — anyone who has ever signed into Ornn has a directory row keyed by `userId` + `email` + `displayName`. + +### 12.1 Search — `GET /api/v1/users/search` + +**Auth: required.** Any authed caller may search; the result set is scoped to users with at least one Ornn activity row. | Query param | Type | Notes | |---|---|---| -| `page` | int ≥ 1 | Default 1. | -| `pageSize` | int 1–100 | Default 20. | +| `q` | string ≤ 256 | Email prefix. Empty returns the most recent N. | +| `limit` | int 1–50 | Default 10. | -Aggregates the activity collection into a per-user roster. Each item is enriched with skill counts: +Response 200: ```jsonc { "data": { "items": [ - { - "userId": "user_...", - "email": "...", - "displayName": "...", - "lastActiveAt": "2026-04-28T...", - "activityCount": 87, - "skillCount": 5 - } - ], - "total": 124, - "page": 1, - "pageSize": 20, - "totalPages": 7 + { "userId": "user_...", "email": "alice@…", "displayName": "Alice" } + ] }, "error": null } ``` -### 13.4 Skills (admin-wide) — `GET /api/v1/admin/skills` +| Code | Status | Cause | +|---|---|---| +| `INVALID_QUERY` | 400 | Query failed Zod validation. | -**Auth: required.** **Permission: `ornn:admin:skill`.** +### 12.2 Resolve — `GET /api/v1/users/resolve` -| Query param | Type | Notes | -|---|---|---| -| `page`, `pageSize` | as above | | -| `q` | string | Substring match against name and description. | -| `userId` | string | Filter by author. | +**Auth: required.** Batch-resolve a list of `userId`s to `{ email, displayName }` rows. Used to render labels for `sharedWithUsers` entries that were saved as bare ids. + +Query: `ids=id1,id2,id3` (max 100 per call). Unknown ids are silently dropped — no 404. Response 200: @@ -1907,195 +1963,187 @@ Response 200: { "data": { "items": [ - { - "guid": "skl_...", - "name": "my-skill", - "description": "...", - "createdBy": "user_...", - "createdByEmail": "...", - "createdByDisplayName": "...", - "createdOn": "2026-04-28T...", - "updatedOn": "2026-04-28T...", - "isPrivate": true, - "tags": ["..."] - } - ], - "total": 412, - "page": 1, - "pageSize": 20, - "totalPages": 21 + { "userId": "user_a", "email": "a@…", "displayName": "Alice" }, + { "userId": "user_b", "email": "b@…", "displayName": "Bob" } + ] }, "error": null } ``` -Admin browse — no visibility filtering. Every skill in the registry is reachable here regardless of `isPrivate` / share-list. +--- -### 13.5 Admin delete — `DELETE /api/v1/admin/skills/:id` +## 13. Admin -**Auth: required.** **Permission: `ornn:admin:skill`.** Same effect as `DELETE /skills/:id` (§3.11) but bypasses the author check. +All endpoints under `/api/v1/admin/*`. All require auth. Most agents never call these — they back the platform-admin UI. This section gives the path + permission + intent so an agent invoked as a "platform-admin assistant" knows where to look; full request/response shapes live in `GET /api/v1/openapi.json`. -Response 200: `{ "data": { "success": true }, "error": null }`. +The legacy `GET /admin/stats` and `GET /admin/activities` endpoints were removed in [#271](https://github.com/ChronoAIProject/Ornn/pull/271) — admin totals moved to §13.1 below, and activity feeds live in PostHog now (the admin UI deep-links into the PostHog dashboard). The legacy `/admin/categories/*` and `/admin/tags/*` CRUD also no longer exist — tags now flow through `metadata.tag` on the skill frontmatter and are exposed read-only via `/skill-facets/tags` (§5.2); the `category` concept collapsed into a free-form `metadata.category` string. -| Code | Status | Cause | -|---|---|---| -| `SKILL_NOT_FOUND` | 404 | Skill missing. | -| `FORBIDDEN` | 403 | Missing `ornn:admin:skill`. | - -### 13.6 List categories — `GET /api/v1/admin/categories` +### 13.1 Dashboard tiles — `GET /api/v1/admin/dashboard/stats` -**Auth: required.** **Permission: `ornn:admin:category`.** +**Auth: required. Permission: `ornn:quota:admin`.** -Response 200: +User + skill totals for the admin dashboard. ```jsonc { - "data": [ - { - "_id": "cat_...", - "name": "tool-based", - "slug": "tool-based", - "description": "...", - "order": 2, - "createdAt": "...", - "updatedAt": "..." - } - ], + "data": { + "users": { "total": 124, "admin": 3, "normal": 121 }, + "skills": { "total": 412, "system": 8, "public": 263, "private": 141 } + }, "error": null } ``` -### 13.7 Create category — `POST /api/v1/admin/categories` +Skill partition is disjoint (`total = system + public + private`): `system` = `isSystemSkill: true`; `public` = `isPrivate: false ∧ isSystemSkill !== true`; `private` = `isPrivate: true`. -**Auth: required.** **Permission: `ornn:admin:category`.** +### 13.2 User directory — `GET /api/v1/admin/users` -Body: +**Auth: required. Permission: `ornn:admin:skill`.** -```jsonc -{ - "name": "tool-based", // one of: plain | tool-based | runtime-based | mixed - "slug": "tool-based", // 1–50 chars, lowercase alphanumeric + hyphens - "description": "...", // 1–500 chars - "order": 2 // optional, ≥ 0 -} -``` - -Response 201: the new `CategoryDocument`. - -| Code | Status | Cause | -|---|---|---| -| `VALIDATION_ERROR` | 400 | Body failed Zod validation. | - -### 13.8 Update category — `PUT /api/v1/admin/categories/:id` +| Query param | Notes | +|---|---| +| `page` | int ≥ 1, default 1 | +| `pageSize` | int 1–100, default 20 | -**Auth: required.** **Permission: `ornn:admin:category`.** +Response: `{ data: { items: [{ userId, email, displayName, lastActiveAt, activityCount, skillCount }], total, page, pageSize, totalPages }, error: null }`. -Path param: `:id` — category id. +### 13.3 Skill admin browse + delete -Body (any subset): +Cross-user browse with no visibility filtering, plus force-delete bypassing the author check. -```jsonc -{ - "description": "...", // 1–500 chars - "order": 3 // ≥ 0 -} -``` +| Method | Path | Permission | Notes | +|---|---|---|---| +| `GET` | `/admin/skills` | `ornn:admin:skill` | Query: `page`, `pageSize`, `q` (name+description substring), `userId`. Returns the same `SkillSummary` shape `/skill-search` does. | +| `DELETE` | `/admin/skills/:id` | `ornn:admin:skill` | Same effect as `DELETE /skills/:id` (§3.11). Returns `{ data: { success: true }, error: null }`. | -Response 200: the updated `CategoryDocument`. +### 13.4 Force-audit — `POST /api/v1/admin/skills/:idOrName/audit` -| Code | Status | Cause | -|---|---|---| -| `VALIDATION_ERROR` | 400 | Body failed validation. | -| `NOT_FOUND` | 404 | No such category. | +Cross-ref §4.5. -### 13.9 Delete category — `DELETE /api/v1/admin/categories/:id` +### 13.5 AgentSeal rescan — `POST /api/v1/admin/skills/:idOrName/versions/:version/agentseal-rescan` -**Auth: required.** **Permission: `ornn:admin:category`.** +**Auth: required. Permission: `ornn:admin:skill`.** -Response 200: `{ "data": { "success": true }, "error": null }`. +Re-runs the AgentSeal scanner against a specific version's package and refreshes the cached verdict on that version. Body: ignored. Returns the refreshed scan record. -### 13.10 List tags — `GET /api/v1/admin/tags` +### 13.6 Quota administration -**Auth: required.** **Permission: `ornn:admin:skill`.** +All under `/admin/quota/*`. **Auth: required. Permission: `ornn:quota:admin`.** -Query: `type` (`predefined` | `custom`) — optional filter. +| Method | Path | Body | Notes | +|---|---|---|---| +| `GET` | `/admin/quota/users?surface=playground\|skillGen&page=...&pageSize=...` | — | Per-user quota tiles for the requested surface. | +| `GET` | `/admin/quota/users/:userId/lifetime?surface=...` | — | Lifetime grant audit + usage for one user, one surface. | +| `GET` | `/admin/quota/grants?page=...&pageSize=...` | — | Paginated grant audit log across all users. | +| `POST` | `/admin/quota/grant` | `{ userId, surface, amount, note? }` | Additive grant onto current-month bucket. | +| `POST` | `/admin/quota/grant/bulk` | `{ userIds: [], surface, amount, note? }` | Same, multi-user. | -Response 200: +### 13.7 Redemption codes -```jsonc -{ "data": [{ "_id": "tag_...", "name": "translation", "createdAt": "..." }], "error": null } -``` +All under `/admin/redemption-codes/*`. **Auth: required. Permission: `ornn:quota:admin`.** Caller-side `redeem` and `history` live at `/me/redemption-codes/*` (§11.11, §11.12). -### 13.11 Create tag — `POST /api/v1/admin/tags` +| Method | Path | Body | Notes | +|---|---|---|---| +| `GET` | `/admin/redemption-codes?page=...&pageSize=...` | — | Paginated admin list. | +| `POST` | `/admin/redemption-codes` | `{ code?, grants: [{ surface, amount }], expiresAt, note? }` | Create. `code` auto-generated when omitted. Returns 201. | +| `DELETE` | `/admin/redemption-codes/:id` | — | Hard delete (unredeemed only). | +| `POST` | `/admin/redemption-codes/:id/invalidate` | — | Soft-revoke. Future `/me/redemption-codes/redeem` calls return `REDEMPTION_CODE_INVALIDATED`. | -**Auth: required.** **Permission: `ornn:admin:skill`.** +### 13.8 Mirror admin -Body: +| Method | Path | Permission | Notes | +|---|---|---|---| +| `POST` | `/admin/mirror/reconcile` | `ornn:admin:skill` | Fire-and-forget reconcile run. **202** with `{ data: { status: "running", startedAt }, error: null }` on accept. Refusals: `503 MIRROR_DISABLED` (kill-switch off or required GitHub App creds missing), `409 RECONCILE_ALREADY_RUNNING` (another reconcile is in progress on this pod). | +| `GET` | `/admin/mirror/status` | `ornn:admin:skill` | Counts + last-run summary. Response combines DB-side mirror counts, the persisted scheduled-run status (cluster-wide), and the current `mirror` settings section (App private key mid-masked) so the admin UI can render without a second round-trip. | -```jsonc -{ "name": "translation" } -``` +The mirror **config** (repo, branch, GitHub App id, encrypted private key, enabled flag, cadence) lives under the sectioned platform settings (§14.2, `mirror`). The two endpoints above only operate the scheduler. Manual `reconcile` runs are tracked in pod-local state and do not update `scheduledRun` in the status response. -`name` must be 1–30 chars and match `/^[a-z0-9-_]+$/`. +### 13.9 Announcements -Response 201: the new `TagDocument`. +Public anonymous reads + admin CRUD. Bilingual EN + ZH content; ZH falls back to EN at render time when empty. -| Code | Status | Cause | -|---|---|---| -| `VALIDATION_ERROR` | 400 | Body failed validation. | +| Method | Path | Auth | Notes | +|---|---|---|---| +| `GET` | `/announcements` | anon | Listed published announcements (News page). | +| `GET` | `/announcements/active` | anon | Currently active row(s) for the landing-page popup. | +| `GET` | `/admin/announcements` | `ornn:admin:skill` | Admin browse — every row including disabled / future. | +| `POST` | `/admin/announcements` | `ornn:admin:skill` | Create. Body: `{ titleEn, titleZh?, bodyMarkdownEn, bodyMarkdownZh?, ctaLabelEn?, ctaLabelZh?, ctaUrl?, enabled, startsAt?, endsAt? }`. Returns 201. | +| `PATCH` | `/admin/announcements/:id` | `ornn:admin:skill` | Partial update. | +| `DELETE` | `/admin/announcements/:id` | `ornn:admin:skill` | Hard delete. | -### 13.12 Delete tag — `DELETE /api/v1/admin/tags/:id` +CTA pairing rule: `ctaUrl` and `ctaLabelEn` must both be set or both null (validated server-side as `INVALID_ANNOUNCEMENT_INPUT`). `ctaLabelZh` is independently optional. -**Auth: required.** **Permission: `ornn:admin:skill`.** Response: `{ "data": { "success": true }, "error": null }`. +### 13.10 Broadcasts -### 13.13 Admin force-audit — `POST /api/v1/admin/skills/:idOrName/audit` +Admin-authored notifications that fan out into every user's `/notifications` feed (§9) as `source: "broadcast"` rows. -Documented as §4.5 above (audit section). +| Method | Path | Permission | Notes | +|---|---|---|---| +| `GET` | `/admin/broadcasts` | `ornn:admin:skill` | List all broadcasts. | +| `POST` | `/admin/broadcasts` | `ornn:admin:skill` | Create. Body carries bilingual `titleI18n` / `bodyMarkdownI18n`. Returns 201. | +| `PATCH` | `/admin/broadcasts/:id` | `ornn:admin:skill` | Partial update. | +| `DELETE` | `/admin/broadcasts/:id` | `ornn:admin:skill` | Hard delete (read receipts cascade). | --- ## 14. Platform settings -Two endpoints. Editable per-deployment thresholds the platform admin controls. `auditWaiverThreshold` is the only field at the moment; new settings will be added here as features land. +The platform-admin configuration lives in four parallel route groups under `/api/v1/admin/settings/*`. **Every endpoint here requires `ornn:admin:skill`** (the LLM-provider routes too). Agents rarely call these; they back the Settings tab of the admin UI. -### 14.1 Read settings — `GET /api/v1/admin/settings` +### 14.1 Legacy single-document settings — `GET / PATCH /api/v1/admin/settings` -**Auth: required.** **Permission: `ornn:admin:skill`.** +Mostly historical. The only remaining live field is `auditWaiverThreshold` (0–10 number; informational only — kept on the document because earlier designs surfaced a "low risk → auto-share" UI hint that the current sharing path does not consume). -Response 200: +`PATCH` body accepts a partial update; out-of-range values surface `INVALID_SETTING` (400). All real configuration lands in the sectioned routes below — not here. -```jsonc -{ - "data": { - "auditWaiverThreshold": 7.5 - }, - "error": null -} -``` +### 14.2 Sectioned settings — `GET / PUT /api/v1/admin/settings/{section}` -`auditWaiverThreshold` is a 0–10 number; it is currently informational only — kept on the document because earlier designs surfaced a "low risk → auto-share" UI hint. The current sharing path does not consume it. +Each section is its own row in the `platform_settings` collection. Reads mid-mask secret fields; writes accept plaintext, redaction sentinels, or mid-mask sentinels for those same fields (the service resolves "preserve DB" sentinels). -### 14.2 Patch settings — `PATCH /api/v1/admin/settings` +| `{section}` (URL path) | Covers | +|---|---| +| `playground` | Playground SSE knobs (per-call budgets, sandbox image, etc.) | +| `skill-generation` | Skill-generation SSE knobs (max output tokens, validation retries, etc.) | +| `mirror` | GitHub mirror — repo, branch, App ID, installation id, encrypted private key, enabled flag, reconcile cadence | +| `integrations/nyxid` | NyxID proxy + service ids the backend talks to | +| `skill-audit` | Audit pipeline — risk threshold, LLM choice, cache TTL | +| `posthog` | PostHog telemetry — host, project key, ingestion toggles | +| `extras` | Catch-all for one-off feature flags | -**Auth: required.** **Permission: `ornn:admin:skill`.** +Response shape on read: `{ data: , error: null }`. On write: `{ data: , error: null, meta: { changedFields: [...] } }` — no-op writes still 200 with an empty `changedFields`. -Request body (`application/json`, partial): +| Code | Status | Cause | +|---|---|---| +| `INVALID_BODY` | 400 | Body not a JSON object. | +| `VALIDATION_ERROR` | 400 | Body failed the section's Zod schema. | +| `SECTION_NOT_FOUND` | 404 | Unknown `{section}` path. | -```jsonc -{ "auditWaiverThreshold": 7.5 } -``` +### 14.3 LLM providers — `/admin/settings/llm-providers[/:id]` -`auditWaiverThreshold` must be a number in `[0, 10]`. Values are rounded to one decimal. +Multi-provider model catalog. Each provider row carries credentials + per-model toggles for the two SSE surfaces. -Response 200: the updated settings document. +| Method | Path | Body | Notes | +|---|---|---|---| +| `GET` | `/admin/settings/llm-providers` | — | List all providers. Secrets mid-masked. | +| `POST` | `/admin/settings/llm-providers` | `{ name, gatewayUrl, auth, maxOutputTokens, defaultTemperature, ... }` | Create. Returns 201. | +| `GET` | `/admin/settings/llm-providers/:id` | — | Read one. | +| `PUT` | `/admin/settings/llm-providers/:id` | partial | Update. Accepts plaintext or mid-mask sentinel for secret fields. | +| `DELETE` | `/admin/settings/llm-providers/:id` | — | Hard delete + cascade through model catalog. | +| `POST` | `/admin/settings/llm-providers/:id/sync` | — | Re-pull model list from the provider gateway. Response `{ data: { synced: int }, error: null }`. | +| `PATCH` | `/admin/settings/llm-providers/:id/models/:modelId` | `{ enabledForPlayground?, enabledForSkillGen?, defaultForPlayground?, defaultForSkillGen? }` | Per-row surface flags. Setting `defaultFor*: true` unsets the previous default atomically. | -| Code | Status | Cause | +The picker `GET /me/models` (§11.10) reads through this catalog. + +### 14.4 Export / import — `/admin/settings/export` and `/admin/settings/import` + +| Method | Path | Notes | |---|---|---| -| `INVALID_SETTING` | 400 | Field out of range or no recognised fields in body. | -| `FORBIDDEN` | 403 | Missing `ornn:admin:skill`. | +| `GET` | `/admin/settings/export` | Returns full settings + LLM-provider snapshot as JSON. Secrets are redacted. | +| `POST` | `/admin/settings/import` | Body: a prior export JSON. Replays into `platform_settings` + `llm_providers`. Diff is reported in `meta.changedFields`. | --- ## Appendix — staying in sync with this file -When the API surface changes, the change lands in `ornn-api/src/domains/**/routes.ts` first. This file should be updated in the same PR. The `version:` field in the parent `SKILL.md` is bumped on every doc-affecting change; agents pulling the skill will see the bump on `GET /api/v1/skills/ornn-agent-manual/json` and can refresh their context. There is no out-of-band changelog — `git log skills/ornn-agent-manual/` is the changelog. +When the API surface changes, the change lands in `ornn-api/src/domains/**/routes.ts` first. This file should be updated in the same PR. The `version:` field in the parent `SKILL.md` is bumped on every doc-affecting change; agents pulling the skill will see the bump on `GET /api/v1/skills/ornn-agent-manual-cli/json` and can refresh their context (see SKILL.md §0). There is no out-of-band changelog — `git log skills/ornn-agent-manual-cli/` is the changelog. From 522c22e2d6324900bf16bc6b90cc91eff7564c0f Mon Sep 17 00:00:00 2001 From: Shining Date: Mon, 18 May 2026 12:40:58 +0800 Subject: [PATCH 002/128] docs(readme): surface Ornn official website link (#572) (#573) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a centered line — "Ornn official website — ornn.chrono-ai.fun" — right under the tagline and above the section nav in the root README. The README already linked to `ornn.chrono-ai.fun/docs` in two places, but the bare homepage was nowhere to be found. First-time readers landing on the GitHub repo had no one-click path to the product surface; this puts the website at parity with the badges and the docs link without restructuring the rest of the file. Docs-only change. Empty changeset attached to satisfy the gate. --- .changeset/readme-website-link.md | 4 ++++ README.md | 4 ++++ 2 files changed, 8 insertions(+) create mode 100644 .changeset/readme-website-link.md diff --git a/.changeset/readme-website-link.md b/.changeset/readme-website-link.md new file mode 100644 index 00000000..9a8725e4 --- /dev/null +++ b/.changeset/readme-website-link.md @@ -0,0 +1,4 @@ +--- +--- + +Surface the Ornn official website (https://ornn.chrono-ai.fun) right under the tagline in the root `README.md` (#572). Previously the only mentions of the domain were buried in the `/docs` sub-path link — first-time readers had no obvious entry point to the product homepage. No `ornn-api` / `ornn-web` code change; empty changeset satisfies the gate. diff --git a/README.md b/README.md index 945545c5..40081704 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,10 @@

The agent-facing skill-lifecycle API for AI agents.

+

+ Ornn official website — ornn.chrono-ai.fun +

+

What is Ornn · How it works · From cbd4342d5c7b88b881fd643c01e9ce0d266709e7 Mon Sep 17 00:00:00 2001 From: Wang Shining Date: Tue, 19 May 2026 13:58:11 +0800 Subject: [PATCH 003/128] chore(sdk): add discovery keywords + npm publish fields to TS SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #468 — sets keywords aligned to agent-API positioning (ornn, agent, skill-registry, skill-lifecycle, ai-agents, model-agnostic, mcp, llm, sdk) and the four npm publish fields that show up on the package page (homepage, bugs, repository, license). private:true is preserved here; npm publish strategy is the separate scope of #473. --- sdk/typescript/package.json | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index e0dc5e6b..309cc074 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -3,6 +3,27 @@ "version": "0.2.1", "private": true, "description": "TypeScript client for Ornn — the end-to-end skill life-cycle manager for AI agents", + "keywords": [ + "ornn", + "agent", + "skill-registry", + "skill-lifecycle", + "ai-agents", + "model-agnostic", + "mcp", + "llm", + "sdk" + ], + "homepage": "https://github.com/ChronoAIProject/Ornn#readme", + "bugs": { + "url": "https://github.com/ChronoAIProject/Ornn/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/ChronoAIProject/Ornn.git", + "directory": "sdk/typescript" + }, + "license": "MIT", "type": "module", "main": "./src/index.ts", "types": "./src/index.ts", From 7149b81bad7c3572a0f5af8c513be06aa2741b0d Mon Sep 17 00:00:00 2001 From: Wang Shining Date: Tue, 19 May 2026 13:58:16 +0800 Subject: [PATCH 004/128] chore(sdk): align Python SDK keywords with agent-API positioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #468 — replaces the legacy keyword set (skills, ai, agents, nyxid) with the same vocabulary used across the TS SDK so PyPI search surfaces the same intent: ornn, agent, skill-registry, skill-lifecycle, ai-agents, model-agnostic, mcp, llm, sdk. --- sdk/python/pyproject.toml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 85f4935f..85c88377 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -10,7 +10,17 @@ readme = "README.md" requires-python = ">=3.10" license = { text = "MIT" } authors = [{ name = "Chrono AI" }] -keywords = ["ornn", "skills", "ai", "agents", "nyxid"] +keywords = [ + "ornn", + "agent", + "skill-registry", + "skill-lifecycle", + "ai-agents", + "model-agnostic", + "mcp", + "llm", + "sdk", +] classifiers = [ "Development Status :: 3 - Alpha", "Programming Language :: Python :: 3", From ad221364972caef531dda6a77e0b5b65b9cd5a28 Mon Sep 17 00:00:00 2001 From: Wang Shining Date: Tue, 19 May 2026 13:58:17 +0800 Subject: [PATCH 005/128] docs: changeset for #468 --- .changeset/sdk-keywords-and-topics-468.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .changeset/sdk-keywords-and-topics-468.md diff --git a/.changeset/sdk-keywords-and-topics-468.md b/.changeset/sdk-keywords-and-topics-468.md new file mode 100644 index 00000000..21969e22 --- /dev/null +++ b/.changeset/sdk-keywords-and-topics-468.md @@ -0,0 +1,4 @@ +--- +--- + +Discovery metadata pass for the SDKs (#468). Adds `keywords` + npm publish fields (`homepage`, `bugs`, `repository`, `license`) to `@chronoai/ornn-sdk` and expands Python SDK `keywords` to align with the agent-API positioning (`ornn`, `agent`, `skill-registry`, `skill-lifecycle`, `ai-agents`, `model-agnostic`, `mcp`, `llm`, `sdk`). GitHub repo topics set out-of-band via `gh repo edit`. No `ornn-api` / `ornn-web` code change — empty changeset satisfies the gate. From 8493a7d48adeeba78f35bf9ccdfe248017d23823 Mon Sep 17 00:00:00 2001 From: Wang Shining Date: Tue, 19 May 2026 14:00:30 +0800 Subject: [PATCH 006/128] docs(readme): add SDK quickstart section (#470) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inserts a new `## SDK quickstart` section between `## How it works` and the existing agent-side `## Quickstart`, with copy-paste TS + Python snippets that match the real OrnnClient constructor (baseUrl + token) and .search({ q }) shape. Top nav gets a new anchor. The npm install line is aspirational while #473 (TS SDK private:true, publish strategy) is unresolved — that caveat is called out inline so a reader hitting the npm 404 isn't surprised. The Python line works because ornn-sdk is reserved on PyPI. Closes #470. --- .changeset/readme-sdk-quickstart-470.md | 4 +++ README.md | 44 +++++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 .changeset/readme-sdk-quickstart-470.md diff --git a/.changeset/readme-sdk-quickstart-470.md b/.changeset/readme-sdk-quickstart-470.md new file mode 100644 index 00000000..e3089e1e --- /dev/null +++ b/.changeset/readme-sdk-quickstart-470.md @@ -0,0 +1,4 @@ +--- +--- + +Adds an `## SDK quickstart` section to the main README (#470) — copy-paste-ready TypeScript and Python snippets that match the real `OrnnClient` constructor + `.search({ q })` shape. Sits between `## How it works` and the existing agent-side `## Quickstart`, with a nav link. Install lines reference `@chronoai/ornn-sdk` / `ornn-sdk`; until #473 lands the npm side is install-from-source, called out inline. diff --git a/README.md b/README.md index 40081704..605bdc6c 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@

What is Ornn · How it works · + SDK quickstart · Quickstart · Docs · Roadmap · @@ -74,6 +75,49 @@ The agent talks to `ornn-api` through `nyxid`, which brokers authentication and For a deeper view, see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). +## SDK quickstart + +Call Ornn directly from code. The SDKs wrap `/api/v1/*` and handle auth header injection, response-envelope unwrapping, and structured errors. + +**TypeScript** ([`sdk/typescript`](sdk/typescript)) + +```bash +# Pre-publish — install from this monorepo via your package manager's +# workspace / git subdirectory mechanism. See #473 for npm publish status. +npm install @chronoai/ornn-sdk +``` + +```ts +import { OrnnClient } from "@chronoai/ornn-sdk"; + +const ornn = new OrnnClient({ + baseUrl: "https://ornn.chrono-ai.fun", + token: process.env.ORNN_TOKEN, +}); +const result = await ornn.search({ q: "pdf parsing" }); +console.log(result.items[0]); +``` + +**Python** ([`sdk/python`](sdk/python)) + +```bash +pip install ornn-sdk +``` + +```python +import os +from ornn_sdk import OrnnClient + +ornn = OrnnClient( + base_url="https://ornn.chrono-ai.fun", + token=os.environ["ORNN_TOKEN"], +) +result = ornn.search(q="pdf parsing") +print(result.items[0]) +``` + +Token sources are pluggable — for dynamic refresh flows, pass `getToken` (TS) / `token_resolver` (Python) instead of a static `token`. See [`sdk/typescript/README.md`](sdk/typescript/README.md) and [`sdk/python/README.md`](sdk/python/README.md) for the full reference. + ## Quickstart > **Status:** alpha. Surfaces and schemas can change before v1. Pin a release tag. From 32ab0490a4d2862db40c8e5aeb3208fc72bc6454 Mon Sep 17 00:00:00 2001 From: Wang Shining Date: Tue, 19 May 2026 14:02:08 +0800 Subject: [PATCH 007/128] docs(readme): add positioning / comparison table (#472) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inserts a "How Ornn compares" section after `## Quickstart` with a feature matrix against MCP servers, Smithery, and npm registry, followed by short prose on what each comparison actually means. Owns the CLI gap honestly via a footnote pointing at the Phase 2 roadmap, instead of marking ✓ on a CLI we don't ship today. This addresses the strategic-positioning callout in the issue: trim the marketing to match what's actually built today, not what's planned. Closes #472. --- .changeset/readme-positioning-table-472.md | 4 ++++ README.md | 25 ++++++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 .changeset/readme-positioning-table-472.md diff --git a/.changeset/readme-positioning-table-472.md b/.changeset/readme-positioning-table-472.md new file mode 100644 index 00000000..fd0fbd8f --- /dev/null +++ b/.changeset/readme-positioning-table-472.md @@ -0,0 +1,4 @@ +--- +--- + +Adds a `## How Ornn compares` positioning section to the main README (#472) — comparison matrix vs MCP servers, Smithery, and npm registry, followed by a "what this means in practice" paragraph that owns the differences honestly (incl. the CLI gap, footnoted to the roadmap). Top nav gets a new anchor. diff --git a/README.md b/README.md index 40081704..70f6b9ca 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ What is Ornn · How it works · Quickstart · + How Ornn compares · Docs · Roadmap · Community · @@ -92,6 +93,30 @@ nyxid proxy request ornn-api GET /api/v1/skills?q=summarize Full per-endpoint reference: [ornn.chrono-ai.fun/docs](https://ornn.chrono-ai.fun/docs). +## How Ornn compares + +The space of agent skill / tool registries is crowded. Quick orientation: + +| | **Ornn** | MCP servers | Smithery | npm registry | +|---------------------------------------|:--------:|:-----------:|:--------:|:------------:| +| Agent-callable HTTP API | ✓ | ✓ (RPC) | ✗ | ✓ | +| Model-agnostic (Claude / GPT / …) | ✓ | ✓ | ✓ | n/a | +| Execution sandbox | ✓ | ✗ | ✓ | ✗ | +| Searchable registry (semantic + tag) | ✓ | partial | ✓ | keyword | +| Versioning + immutable artifacts | ✓ | ✗ | ? | ✓ | +| Skill build pipeline (lint + AgentSeal)| ✓ | ✗ | ✗ | ✗ | +| CLI | * | ✗ | ✓ | ✓ | + +\* CLI is on the roadmap (Phase 2); today the registry-side CLI is `nyxid proxy request ornn-api …`. The web UI at [ornn.chrono-ai.fun](https://ornn.chrono-ai.fun) covers human flows. + +**What this means in practice** + +- **vs MCP servers** — MCP is a protocol for calling tools the agent already has access to; Ornn is the registry + lifecycle around those tools (discover, version, sandbox, build, publish). The two compose: an Ornn-hosted skill can expose an MCP transport. +- **vs Smithery** — Smithery is a curated UI registry for MCP servers; Ornn is an API-first registry callable directly by agents, with build/execute primitives included. +- **vs npm registry** — npm versions and ships code; it doesn't know about models, sandboxes, or skill manifests. Ornn does. + +Treat the table as a working draft — corrections welcome via [Discussions → Ideas](https://github.com/ChronoAIProject/Ornn/discussions/categories/ideas). + ## Documentation - **Product docs** — [ornn.chrono-ai.fun/docs](https://ornn.chrono-ai.fun/docs) From 25ecc1056311f1f9c9513460e2878b733be91656 Mon Sep 17 00:00:00 2001 From: Wang Shining Date: Tue, 19 May 2026 14:03:56 +0800 Subject: [PATCH 008/128] docs: add API_STABILITY.md commitment + deprecation policy (#474) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Specifies what currently has no public contract: - Alpha caveat: 0.x line makes no compatibility guarantees; pinning exact versions is the only insulator during alpha. - Post-v1 semver policy (patch / minor / major) and what each bump is allowed to change. - Three stability tiers (stable, beta, experimental) declared in OpenAPI via x-stability. Beta + experimental require the Accept-Stability request header so SDK clients don't reach them by accident. - RFC 8594 deprecation policy: ≥ 2 minor releases of lead time + ≥ 90 days for SDK-affecting deprecations, with synchronized signals on response headers, CHANGELOG, DEPRECATIONS.md, and GitHub release notes. - Breaking-change PR checklist. Adopting these tiers in OpenAPI + SDK is a follow-up; this doc is the contract those follow-ups conform to. --- docs/API_STABILITY.md | 111 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 docs/API_STABILITY.md diff --git a/docs/API_STABILITY.md b/docs/API_STABILITY.md new file mode 100644 index 00000000..90011e6f --- /dev/null +++ b/docs/API_STABILITY.md @@ -0,0 +1,111 @@ +# API Stability & Deprecation + +This document is the public commitment about what Ornn's `/api/v1/*` surface guarantees, what it does not guarantee, and how breaking changes are communicated. + +It is a normative companion to [`docs/CONVENTIONS.md`](CONVENTIONS.md) — when a route is added or removed, its stability tier MUST be declared here (and in the OpenAPI spec) before the PR lands. + +--- + +## Current state + +> **Status: alpha (v0.x).** +> +> We make **no compatibility guarantees** for the `0.x` release line. Any minor version (`0.7 → 0.8`) MAY contain breaking changes to routes, response shapes, error codes, headers, or SDK signatures. +> +> Pinning to an exact version (`@chronoai/ornn-sdk@0.7.3`, `ornn-sdk==0.7.3`) is the only way to insulate downstream code from churn during alpha. + +CHANGELOG entries for each release call out breaking changes inline; see [`ornn-api/CHANGELOG.md`](../ornn-api/CHANGELOG.md) and [`ornn-web/CHANGELOG.md`](../ornn-web/CHANGELOG.md). + +The exit criteria from alpha to **v1.0** are tracked in milestone [M5 — QA Pre-Launch Gate](https://github.com/ChronoAIProject/Ornn/milestones). + +--- + +## Versioning policy (post-v1) + +Once `v1.0.0` ships, Ornn follows [Semantic Versioning](https://semver.org/) for both the HTTP API and the SDK packages: + +| Bump | Example | Allowed changes | +|---|---|---| +| **patch** | `1.2.3 → 1.2.4` | Bug fixes. No observable behaviour change. | +| **minor** | `1.2 → 1.3` | Backward-compatible additions: new endpoints, new optional request fields, new response fields, new tiered features. | +| **major** | `1.x → 2.0` | Breaking changes. Preceded by ≥ 2 minor releases marking the affected routes deprecated (see below). | + +The API path version (`/api/v1/...`) is bumped only on a major release that requires it. Most major releases will keep `/v1/` and rely on per-route deprecation signalling. + +--- + +## Stability tiers + +Every route declares one of three tiers in the OpenAPI spec via the `x-stability` extension. The SDK exposes the same tier so clients can opt in or warn. + +### `stable` + +- Covered by the post-v1 semver commitment above. +- Breaking changes only at a major version bump, after deprecation. +- Default tier for `/api/v1/*` routes in the canonical surface. + +### `beta` + +- Subject to change in any minor release. +- Useful for features we want feedback on before locking the contract. +- Clients opt in by passing the header `Accept-Stability: beta`. Without that header the SDK refuses to call beta endpoints. + +### `experimental` + +- May change or disappear in any release, including patch. +- Not exposed via the default SDK clients; reached only by raw HTTP or by passing `Accept-Stability: experimental`. +- Used for shipping prototypes alongside a stable surface. + +--- + +## Deprecation policy + +When a stable route or response field is deprecated, Ornn follows [RFC 8594](https://www.rfc-editor.org/rfc/rfc8594.html): + +```http +HTTP/1.1 200 OK +Deprecation: true +Sunset: Wed, 01 Jan 2027 00:00:00 GMT +Link: ; rel="deprecation" +``` + +### Lead time + +- **Minimum:** the `Sunset` date is set at least **two minor releases** in the future from the release that introduces the `Deprecation: true` header. +- For routes called by published SDK versions, the sunset date is also at least **90 days** in the future. + +### Signal channels + +Every deprecation is announced through **all** of the following at the same time: + +1. **`Deprecation` + `Sunset` headers** on every response from the deprecated route, from the release that flips the deprecation flag until the route is removed. +2. **CHANGELOG entry** in the release that introduces the deprecation, under a `### Deprecated` heading. +3. **Entry in [`docs/DEPRECATIONS.md`](DEPRECATIONS.md)** with route, sunset date, migration path, and the `Link: rel="deprecation"` anchor used in the header. +4. **GitHub release notes** linking to the `DEPRECATIONS.md` entry. + +### Removal + +- A deprecated route is removed only after its `Sunset` date has passed and a major version has shipped. +- Removal lands in a separate PR titled `chore(api): remove deprecated (sunset )` that closes the deprecation entry. + +--- + +## Breaking-change checklist + +Before merging a PR that breaks a `stable` route: + +- [ ] PR title contains `BREAKING` or the commit footer carries `BREAKING CHANGE:` +- [ ] CHANGELOG `### Breaking` entry written, with migration guidance +- [ ] `docs/DEPRECATIONS.md` entry added (even if removal is far out) +- [ ] OpenAPI `x-stability` updated if the route is moving tiers +- [ ] If the SDK surface changes, the SDK READMEs and `examples/` snippets are updated +- [ ] Linked issue carries the `breaking-change` label + +The `check-changeset` workflow enforces that every PR has a changeset; breaking-change PRs MUST use a `major` bump on the affected package. + +--- + +## Open questions + +1. **API version negotiation header.** `Accept-Stability` is currently a draft. If we adopt a different name pre-v1, both the convention and the SDKs change together — track in a separate issue before v1. +2. **`/api/v2/`.** Will exist if and only if a breaking change can't be safely signalled via per-route deprecation. The current expectation is that it never ships during 1.x. From d9a0faff2c5fc7b9d7480659e5ad7fb603ccf872 Mon Sep 17 00:00:00 2001 From: Wang Shining Date: Tue, 19 May 2026 14:03:59 +0800 Subject: [PATCH 009/128] docs(readme,conventions): cross-link to API_STABILITY.md (#474) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README "Documentation" list adds a row for the new doc. CONVENTIONS.md §7 (Deprecation) gets a one-line lede pointing at API_STABILITY.md for the stability commitment + lead-time policy — keeps CONVENTIONS focused on header shape and avoids duplicating the same policy in two places. Closes #474. --- README.md | 1 + docs/CONVENTIONS.md | 2 ++ 2 files changed, 3 insertions(+) diff --git a/README.md b/README.md index 40081704..5a53b9c0 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ Full per-endpoint reference: [ornn.chrono-ai.fun/docs](https://ornn.chrono-ai.fu - **Product docs** — [ornn.chrono-ai.fun/docs](https://ornn.chrono-ai.fun/docs) - **Architecture** — [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) - **Conventions** — [`docs/CONVENTIONS.md`](docs/CONVENTIONS.md) +- **API stability & deprecation** — [`docs/API_STABILITY.md`](docs/API_STABILITY.md) - **Design system** — [`docs/DESIGN.md`](docs/DESIGN.md) ## Roadmap diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md index d68a4e4a..c778d749 100644 --- a/docs/CONVENTIONS.md +++ b/docs/CONVENTIONS.md @@ -340,6 +340,8 @@ Endpoints pick a subset and MAY add endpoint-specific events with the same prefi ## 7. Deprecation +See [`docs/API_STABILITY.md`](API_STABILITY.md) for the public stability commitment, per-route tiers, and the full deprecation lead-time policy. This section codifies the header shape only. + Per RFC 8594 on deprecated endpoints and representations: ``` From 3c4f3cd8f5a5d460c356d5e693fe754b4824db4b Mon Sep 17 00:00:00 2001 From: Wang Shining Date: Tue, 19 May 2026 14:04:00 +0800 Subject: [PATCH 010/128] docs: changeset for #474 --- .changeset/api-stability-doc-474.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .changeset/api-stability-doc-474.md diff --git a/.changeset/api-stability-doc-474.md b/.changeset/api-stability-doc-474.md new file mode 100644 index 00000000..70423e7e --- /dev/null +++ b/.changeset/api-stability-doc-474.md @@ -0,0 +1,4 @@ +--- +--- + +Publishes `docs/API_STABILITY.md` (#474) — the public stability commitment for `/api/v1/*`. Codifies the alpha caveat, the post-v1 semver policy, three stability tiers (`stable` / `beta` / `experimental` declared via OpenAPI `x-stability`), and the deprecation policy (RFC 8594 headers, two-minor-release lead time, signal channels, breaking-change checklist). Linked from README docs section; `CONVENTIONS.md §7` cross-links here. From 1d6cab7941f19acf253adbc23a6c3fba183abd54 Mon Sep 17 00:00:00 2001 From: Wang Shining Date: Tue, 19 May 2026 14:07:47 +0800 Subject: [PATCH 011/128] docs: create ERRORS.md + DEPRECATIONS.md (#576) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONVENTIONS.md §1.6 / §7 reference these two files but neither existed — every error response's RFC 7807 `type` URL was a 404, and there was no public log for deprecation Link headers to anchor to. docs/ERRORS.md - One `##` heading per target lowercase_snake_case code from §1.4 so anchors resolve (`#validation_error`, `#authentication_required`, …). - Each section documents: HTTP status, current pre-#585 codes that map here, a sample response, and recommended client action. - Appendix maps every SCREAMING_SNAKE code emitted today by the server to its lowercase target — exhaustive at time of writing, owned by #585 (case migration) when it lands. docs/DEPRECATIONS.md - Empty active section while in alpha (per API_STABILITY.md breaking changes go straight into the next minor, no deprecation cycle until v1). - Entry template + sample headers ready for the first deprecation. --- docs/DEPRECATIONS.md | 50 +++++++++++ docs/ERRORS.md | 204 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 254 insertions(+) create mode 100644 docs/DEPRECATIONS.md create mode 100644 docs/ERRORS.md diff --git a/docs/DEPRECATIONS.md b/docs/DEPRECATIONS.md new file mode 100644 index 00000000..ca43b0fa --- /dev/null +++ b/docs/DEPRECATIONS.md @@ -0,0 +1,50 @@ +# Ornn API Deprecations + +The public log of every deprecated route, response field, or header on `/api/v1/*`. The companion to [`docs/API_STABILITY.md`](API_STABILITY.md) (policy) and [`docs/CONVENTIONS.md`](CONVENTIONS.md) §7 (header shape). + +Per `docs/API_STABILITY.md` § Deprecation policy, every entry below carries: + +- **Anchor** — used in the `Link: <…>; rel="deprecation"` header so clients can deep-link from the response. +- **Deprecated in** — the release that first set `Deprecation: true`. +- **Sunset on** — the date the route MAY be removed (at the earliest). +- **Replacement** — the route or field clients should migrate to. +- **Migration notes** — what changes for the caller. + +When a deprecated route is removed, its entry stays in this file but moves to the [Removed](#removed) section so the anchor URL keeps resolving. + +--- + +## Active deprecations + +*None.* While Ornn is in alpha (`0.x`), breaking changes go straight into the next minor release without a deprecation cycle — see [`docs/API_STABILITY.md`](API_STABILITY.md) § Current state. This section starts populating once the API exits alpha and the post-v1 policy kicks in. + +--- + +## Removed + +*None.* + +--- + +## How an entry will look + +When the first deprecation lands, it will look like this: + +```markdown +### skill-version-v1 + +- **Anchor:** `skill-version-v1` +- **Deprecated in:** `1.4.0` (2026-09-15) +- **Sunset on:** `1.6.0` or 2026-12-15, whichever is later +- **Replacement:** `GET /api/v1/skills/:id/versions/:version` → use `GET /api/v1/skills/:id/versions/:version?manifest=v2` +- **Migration notes:** the response gains a `runtime: { type, entrypoint }` block and drops the flat `runtimeType` / `entrypoint` fields. Clients reading the v1 shape continue to work until sunset; the SDK auto-upgrades when `Accept-Stability: stable` is set. +``` + +Response headers from a deprecated route would carry: + +```http +HTTP/1.1 200 OK +Deprecation: true +Sunset: Tue, 15 Dec 2026 00:00:00 GMT +Link: ; rel="deprecation" +``` diff --git a/docs/ERRORS.md b/docs/ERRORS.md new file mode 100644 index 00000000..972a48b1 --- /dev/null +++ b/docs/ERRORS.md @@ -0,0 +1,204 @@ +# Ornn API Error Catalog + +Every error response from `/api/v1/*` carries a stable `code` (string) and the matching `type` URL points back to a section in this document via GitHub anchor link: + +``` +type: https://github.com/ChronoAIProject/Ornn/blob/main/docs/ERRORS.md# +``` + +The catalog is normative — handlers MUST NOT invent new codes. Adding or renaming a code requires updating this doc and `docs/CONVENTIONS.md` §1.4 in the same PR. + +> **Note — code-case migration.** [`docs/CONVENTIONS.md`](CONVENTIONS.md) §1.4 mandates `lowercase_snake_case` codes; the current implementation still emits `SCREAMING_SNAKE_CASE`. Tracked in [#585](https://github.com/ChronoAIProject/Ornn/issues/585). Until that lands, headings below show **both** forms so an anchor link works no matter which the server emitted today. + +--- + +## Table of Contents + +**By HTTP status** + +- 400 — [`validation_error`](#validation_error) +- 401 — [`authentication_required`](#authentication_required) +- 403 — [`permission_denied`](#permission_denied) +- 404 — [`resource_not_found`](#resource_not_found) +- 409 — [`resource_conflict`](#resource_conflict) +- 413 — [`payload_too_large`](#payload_too_large) +- 415 — [`unsupported_media_type`](#unsupported_media_type) +- 429 — [`rate_limited`](#rate_limited) +- 500 — [`internal_error`](#internal_error) +- 502 / 503 — [`upstream_unavailable`](#upstream_unavailable) + +**Implementation-code appendix** — every `SCREAMING_SNAKE_CASE` code currently emitted by the server, with the target lowercase code it maps to: [Appendix](#appendix-current-implementation-codes). + +--- + +## validation_error + +**HTTP:** `400 Bad Request` +**Current code (pre-#585):** `INVALID_BODY`, `INVALID_QUERY`, `INVALID_PARAMS`, `INVALID_CONTENT_TYPE`, `INVALID_*`, `EMPTY_BODY`, `MISSING_*`, `FRONTMATTER_VALIDATION_FAILED`, `INVALID_PERMISSIONS`, … + +Request body, query string, or path parameter failed validation. Per-field details are in `errors[]`. + +```http +HTTP/1.1 400 Bad Request +Content-Type: application/problem+json + +{ + "type": "https://github.com/ChronoAIProject/Ornn/blob/main/docs/ERRORS.md#validation_error", + "title": "Validation failed", + "status": 400, + "detail": "Request body failed validation", + "instance": "/v1/skills/abc/permissions", + "requestId": "req_01HXYZ...", + "errors": [ + { "path": "sharedWithUsers[3]", "code": "invalid_user_id", "message": "..." } + ] +} +``` + +**Client action:** fix the offending field(s) listed in `errors[]` and retry. Do not retry the same payload without changes. + +--- + +## authentication_required + +**HTTP:** `401 Unauthorized` +**Current code (pre-#585):** `AUTH_MISSING`, `AUTH_INVALID` + +No identity could be resolved from the request — either no `Authorization` header, an unparseable header, or the token is expired / revoked. + +**Client action:** refresh the access token (via NyxID) and retry once. If refresh fails, the user needs to re-authenticate. + +--- + +## permission_denied + +**HTTP:** `403 Forbidden` +**Current code (pre-#585):** `FORBIDDEN`, `INSUFFICIENT_PERMISSIONS` + +The caller is authenticated but lacks the permission required for this resource or action. The response `detail` names the missing permission when safe to disclose. + +**Client action:** check the user's roles / org membership against [`docs/CONVENTIONS.md`](CONVENTIONS.md) §5. Surface the gap to the user; do not silently retry. + +--- + +## resource_not_found + +**HTTP:** `404 Not Found` +**Current code (pre-#585):** `SKILL_NOT_FOUND`, `SKILL_VERSION_NOT_FOUND`, `ORG_NOT_FOUND`, `PROVIDER_NOT_FOUND`, `ANNOUNCEMENT_NOT_FOUND`, `BROADCAST_NOT_FOUND`, `NOTIFICATION_NOT_FOUND`, `AUDIT_NOT_FOUND`, `REDEMPTION_CODE_NOT_FOUND`, … + +The target resource does not exist, **or** it exists but is not visible to the caller (private skill outside their access scope). The two cases are intentionally not distinguished — disclosing existence is itself information. + +**Client action:** for known-good identifiers, this likely means a visibility issue. For typed identifiers, verify the GUID / name. + +--- + +## resource_conflict + +**HTTP:** `409 Conflict` +**Current code (pre-#585):** `SKILL_NAME_EXISTS`, `NAME_CONFLICT`, `VERSION_CONFLICT`, `RECONCILE_ALREADY_RUNNING`, … + +The request collides with current state — a duplicate skill name on create, a concurrent modification, a job that's already running, etc. + +**Client action:** read `detail` to decide. For duplicates, prompt the user for a different value. For concurrent modifications, refetch and retry. + +--- + +## payload_too_large + +**HTTP:** `413 Payload Too Large` +**Current code (pre-#585):** `PAYLOAD_TOO_LARGE` + +The upload exceeds the per-endpoint size cap (currently 5 MB on `/skills` upload; see `ornn-api/src/middleware/uploadLimit.ts`). + +**Client action:** trim the payload (smaller ZIP, fewer attachments) or split into multiple requests where the endpoint supports it. + +--- + +## unsupported_media_type + +**HTTP:** `415 Unsupported Media Type` +**Current code (pre-#585):** `INVALID_CONTENT_TYPE` + +The `Content-Type` header is missing or names a representation this endpoint does not accept. Skill upload requires `application/zip`; most write endpoints require `application/json`. + +**Client action:** set the correct `Content-Type` header and retry. + +--- + +## rate_limited + +**HTTP:** `429 Too Many Requests` +**Current code (pre-#585):** *not yet emitted — see [#439](https://github.com/ChronoAIProject/Ornn/issues/439) (rate limit middleware).* + +Caller exceeded a rate limit (per-IP for unauthenticated routes, per-user for authenticated). Once [#460](https://github.com/ChronoAIProject/Ornn/issues/460) lands, response will also include the standard RFC 9239 headers (`RateLimit-Limit`, `RateLimit-Remaining`, `Retry-After`). + +**Client action:** honour `Retry-After` (seconds). SDK retry wrappers should back off exponentially with jitter, capped at the `Retry-After` value. + +--- + +## internal_error + +**HTTP:** `500 Internal Server Error` +**Current code (pre-#585):** `INTERNAL_ERROR`, `INTERNAL` + +Unhandled server error — should never appear under normal operation. The `requestId` is the load-bearing field for log correlation. + +**Client action:** capture `requestId` and report. Safe to retry once; do not retry tighter than every few seconds. + +--- + +## upstream_unavailable + +**HTTP:** `502 Bad Gateway` / `503 Service Unavailable` +**Current code (pre-#585):** `UPSTREAM_DOWN`, `MIRROR_DISABLED`, `AGENTSEAL_DISABLED`, `PULL_FAILED`, `REPO_FETCH_FAILED`, … + +A dependency Ornn relies on (NyxID, OpenSandbox, LLM provider, mirror target, …) is unavailable or refused the request. Distinct from `internal_error` — Ornn itself is fine but couldn't complete the work. + +**Client action:** retry with exponential backoff. If the failure persists, check [status.chrono-ai.fun](https://status.chrono-ai.fun) (when published) or [Discussions → Q&A](https://github.com/ChronoAIProject/Ornn/discussions/categories/q-a). + +--- + +## Appendix: current implementation codes + +The table below maps every `SCREAMING_SNAKE_CASE` code currently emitted by the server to its target lowercase code from `docs/CONVENTIONS.md` §1.4. Tracking renames lives in [#585](https://github.com/ChronoAIProject/Ornn/issues/585). + +| Current code | HTTP | Target | +|---|---|---| +| `AGENTSEAL_DISABLED` | 503 | `upstream_unavailable` | +| `ANNOUNCEMENT_NOT_FOUND` | 404 | `resource_not_found` | +| `AUDIT_NOT_FOUND` | 404 | `resource_not_found` | +| `AUTH_INVALID` | 401 | `authentication_required` | +| `AUTH_MISSING` | 401 | `authentication_required` | +| `BROADCAST_NOT_FOUND` | 404 | `resource_not_found` | +| `EMPTY_BODY` | 400 | `validation_error` | +| `EMPTY_SOURCE` | 400 | `validation_error` | +| `FORBIDDEN` | 403 | `permission_denied` | +| `FRONTMATTER_VALIDATION_FAILED` | 400 | `validation_error` | +| `INSUFFICIENT_PERMISSIONS` | 403 | `permission_denied` | +| `INTERNAL` | 500 | `internal_error` | +| `INTERNAL_ERROR` | 500 | `internal_error` | +| `INVALID_*` (`_ANNOUNCEMENT_INPUT`, `_BODY`, `_CONTENT_TYPE`, `_GRANT_AMOUNT`, `_PERMISSIONS`, `_PROVIDER_INPUT`, `_RANGE`, `_REDEMPTION_CODE_BODY`, `_REDEMPTION_CODE_ID`, `_ROLE`, `_SCOPE`, `_SETTING`, `_SURFACE`, `_USER_ID`, `_VERSION`, `_PARAMS`, `_QUERY`) | 400 / 415 | `validation_error` / `unsupported_media_type` | +| `MIRROR_DISABLED` | 503 | `upstream_unavailable` | +| `MISSING_FRONTMATTER` | 400 | `validation_error` | +| `MISSING_PROMPT` | 400 | `validation_error` | +| `MISSING_SKILL_MD` | 400 | `validation_error` | +| `MISSING_SPEC` | 400 | `validation_error` | +| `NOTIFICATION_NOT_FOUND` | 404 | `resource_not_found` | +| `NO_UPDATE` | 400 | `validation_error` | +| `ORG_NOT_FOUND` | 404 | `resource_not_found` | +| `PAYLOAD_TOO_LARGE` | 413 | `payload_too_large` | +| `PROVIDER_NOT_FOUND` | 404 | `resource_not_found` | +| `PULL_FAILED` | 502 | `upstream_unavailable` | +| `QUOTA_EXCEEDED` | 429 | `rate_limited` | +| `RECONCILE_ALREADY_RUNNING` | 409 | `resource_conflict` | +| `REDEMPTION_CODE_EXPIRED` | 409 | `resource_conflict` | +| `REDEMPTION_CODE_NOT_FOUND` | 404 | `resource_not_found` | +| `REFRESH_FAILED` | 502 | `upstream_unavailable` | +| `REFRESH_PREVIEW_FAILED` | 502 | `upstream_unavailable` | +| `REPO_FETCH_FAILED` | 502 | `upstream_unavailable` | +| `SKILL_NAME_EXISTS` | 409 | `resource_conflict` | +| `SKILL_NOT_FOUND` | 404 | `resource_not_found` | +| `SKILL_VERSION_NOT_FOUND` | 404 | `resource_not_found` | +| `UPSTREAM_DOWN` | 502 | `upstream_unavailable` | + +This list is exhaustive at the time of writing — if you spot a code that's emitted in production but missing here, open a `[Docs]` issue. From a6dd03a7f048cc46062c547147d689046e7e8a86 Mon Sep 17 00:00:00 2001 From: Wang Shining Date: Tue, 19 May 2026 14:07:51 +0800 Subject: [PATCH 012/128] docs(conventions): point type + Link URLs at ERRORS/DEPRECATIONS.md (#576) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three URL renames in CONVENTIONS.md so the sample responses and deprecation header in the doc match the actual on-disk filenames (uppercase ERRORS.md / DEPRECATIONS.md, per #576): - §1.3 sample error response `type` field - §1.6 `type` URL template + matching catalog reference - §7 sample `Link: rel="deprecation"` Closes #576. --- docs/CONVENTIONS.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md index d68a4e4a..24409495 100644 --- a/docs/CONVENTIONS.md +++ b/docs/CONVENTIONS.md @@ -68,7 +68,7 @@ Content-Type: application/problem+json X-Request-ID: req_01HXYZ... { - "type": "https://github.com/ChronoAIProject/Ornn/blob/main/docs/errors.md#validation_error", + "type": "https://github.com/ChronoAIProject/Ornn/blob/main/docs/ERRORS.md#validation_error", "title": "Validation failed", "status": 400, "detail": "Request body failed validation", @@ -112,10 +112,10 @@ New codes require convention-doc update. Handlers MUST NOT invent ad-hoc codes. Point to GitHub markdown anchors in this repository: ``` -https://github.com/ChronoAIProject/Ornn/blob/main/docs/errors.md# +https://github.com/ChronoAIProject/Ornn/blob/main/docs/ERRORS.md# ``` -A matching `docs/errors.md` must exist with `##` headings per error code (GitHub auto-generates anchors). Zero infra cost; resolves day one. Future migration to a docs domain (`docs.ornn.xyz`) is a one-time redirect configuration; no client changes required. +The catalog lives in [`docs/ERRORS.md`](ERRORS.md) with `##` headings per code (GitHub auto-generates anchors). Zero infra cost; resolves day one. Future migration to a docs domain (`docs.ornn.xyz`) is a one-time redirect configuration; no client changes required. --- @@ -345,7 +345,7 @@ Per RFC 8594 on deprecated endpoints and representations: ``` Deprecation: true Sunset: Wed, 01 Jan 2027 00:00:00 GMT -Link: ; rel="deprecation" +Link: ; rel="deprecation" ``` Free-form notes go in response body, not custom headers. No `X-Skill-Deprecated` style custom headers. From 90443250ca717447c16f1081a04dd782865504a9 Mon Sep 17 00:00:00 2001 From: Wang Shining Date: Tue, 19 May 2026 14:07:51 +0800 Subject: [PATCH 013/128] docs: changeset for #576 --- .changeset/errors-deprecations-docs-576.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .changeset/errors-deprecations-docs-576.md diff --git a/.changeset/errors-deprecations-docs-576.md b/.changeset/errors-deprecations-docs-576.md new file mode 100644 index 00000000..5056a1e5 --- /dev/null +++ b/.changeset/errors-deprecations-docs-576.md @@ -0,0 +1,4 @@ +--- +--- + +Publishes `docs/ERRORS.md` and `docs/DEPRECATIONS.md` (#576) — the GitHub-anchored catalogs that `application/problem+json` `type` URLs and `Link: rel="deprecation"` headers point at. `ERRORS.md` documents all ten target `lowercase_snake_case` codes with `##` headings (so anchors resolve) and lists every `SCREAMING_SNAKE_CASE` code currently emitted in an appendix mapping table, owned by the #585 case migration. `DEPRECATIONS.md` ships empty (alpha = no deprecation cycle yet) with the entry template ready for v1. `CONVENTIONS.md` `type` and `Link` example URLs updated to point at the new paths. From cc77c2e59cd7d90253c7534c58a29cc880d82976 Mon Sep 17 00:00:00 2001 From: Wang Shining Date: Tue, 19 May 2026 14:09:52 +0800 Subject: [PATCH 014/128] chore(api): delete dead skill repository remnant (#577) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit domains/skills/crud/repositories/ was a 218-line SkillRepository implementation + its interface + its colocated test, none of which were imported by any route, service, or test outside the directory. Self-contained dead code from a refactor that never finished. The live skill repository (915 lines) at domains/skills/crud/repository.ts is untouched — every existing import already points there. Closes #577. --- .changeset/delete-dead-skill-repo-577.md | 5 + .../repositories/skillRepository.interface.ts | 112 --------- .../crud/repositories/skillRepository.test.ts | 203 ---------------- .../crud/repositories/skillRepository.ts | 218 ------------------ 4 files changed, 5 insertions(+), 533 deletions(-) create mode 100644 .changeset/delete-dead-skill-repo-577.md delete mode 100644 ornn-api/src/domains/skills/crud/repositories/skillRepository.interface.ts delete mode 100644 ornn-api/src/domains/skills/crud/repositories/skillRepository.test.ts delete mode 100644 ornn-api/src/domains/skills/crud/repositories/skillRepository.ts diff --git a/.changeset/delete-dead-skill-repo-577.md b/.changeset/delete-dead-skill-repo-577.md new file mode 100644 index 00000000..f4988279 --- /dev/null +++ b/.changeset/delete-dead-skill-repo-577.md @@ -0,0 +1,5 @@ +--- +"ornn-api": patch +--- + +Delete dead `domains/skills/crud/repositories/` subdirectory (#577) — a 218-line `SkillRepository` impl + interface + test that no route, service, or test outside the directory itself imported. The live skill repository (915 lines) lives at `domains/skills/crud/repository.ts` and is unaffected. diff --git a/ornn-api/src/domains/skills/crud/repositories/skillRepository.interface.ts b/ornn-api/src/domains/skills/crud/repositories/skillRepository.interface.ts deleted file mode 100644 index e9353235..00000000 --- a/ornn-api/src/domains/skills/crud/repositories/skillRepository.interface.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** - * Interface definitions for skill repository operations. - * Aligned with design spec: uses guid, name, description, license, - * compatibility, metadata (nested), skillHash, s3Url, createdBy, - * createdOn, updatedBy, updatedOn, isPrivate. - * @module repositories/skillRepository.interface - */ - -/** The MongoDB document shape for a skill, matching the design spec. */ -export interface SkillDocument { - guid: string; - name: string; - description: string; - license: string | null; - compatibility: string | null; - metadata: SkillMetadata; - skillHash: string; - s3Url: string; - createdBy: string; - createdOn: Date; - updatedBy: string; - updatedOn: Date; - isPrivate: boolean; -} - -/** Nested metadata object per spec. */ -export interface SkillMetadata { - category: string; - runtimes?: Array<{ - runtime: string; - dependencies?: Array<{ library: string; version: string }>; - envs?: Array<{ var: string; description: string }>; - }>; - tools?: Array<{ - tool: string; - type: string; - "mcp-servers"?: Array<{ mcp: string; version: string }>; - }>; - tags?: string[]; -} - -export interface CreateSkillData { - guid: string; - name: string; - description: string; - license?: string; - compatibility?: string; - metadata: SkillMetadata; - skillHash: string; - s3Url: string; - createdBy: string; - isPrivate?: boolean; -} - -export interface UpdateSkillData { - name?: string; - description?: string; - license?: string; - compatibility?: string; - metadata?: SkillMetadata; - skillHash?: string; - s3Url?: string; - isPrivate?: boolean; - updatedBy: string; -} - -export interface SkillFilters { - /** Text query for keyword search (GUID exact match or name/description contains). */ - q?: string; - /** Filter by scope: public, private (by ownerId), or mixed. */ - scope?: "public" | "private" | "mixed"; - /** The current user ID (needed for private/mixed scope). */ - currentUserId?: string; - page: number; - pageSize: number; -} - -export interface ISkillRepository { - findByGuid(guid: string): Promise; - findByName(name: string): Promise; - findAll(filters: SkillFilters): Promise<{ skills: SkillDocument[]; total: number }>; - create(data: CreateSkillData): Promise; - update(guid: string, data: UpdateSkillData): Promise; - hardDelete(guid: string): Promise; - - /** - * Keyword search: exact GUID match + name/description contains. - * Filters by scope. - */ - keywordSearch( - query: string, - scope: "public" | "private" | "mixed", - currentUserId: string, - page: number, - pageSize: number, - ): Promise<{ skills: SkillDocument[]; total: number }>; - - /** - * Scope-filtered find for paginated listing (empty query = all in scope). - */ - findByScope( - scope: "public" | "private" | "mixed", - currentUserId: string, - page: number, - pageSize: number, - ): Promise<{ skills: SkillDocument[]; total: number }>; - - /** - * Find skills by a list of GUIDs (used for similarity search post-filter). - */ - findByGuids(guids: string[]): Promise; -} diff --git a/ornn-api/src/domains/skills/crud/repositories/skillRepository.test.ts b/ornn-api/src/domains/skills/crud/repositories/skillRepository.test.ts deleted file mode 100644 index b75ae85a..00000000 --- a/ornn-api/src/domains/skills/crud/repositories/skillRepository.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { describe, test, expect, beforeEach, mock } from "bun:test"; -import { SkillRepository } from "./skillRepository"; -import type { Db } from "mongodb"; - -/** - * Unit tests for the MongoDB-backed SkillRepository. - * Uses mocked MongoDB Collection to verify query construction. - */ - -function createMockCursor(docs: any[] = []) { - return { - sort: mock(function (this: any) { return this; }), - skip: mock(function (this: any) { return this; }), - limit: mock(function (this: any) { return this; }), - toArray: mock(async () => docs), - }; -} - -function createMockCollection() { - return { - insertOne: mock(async () => ({ acknowledged: true })), - // Explicitly broaden the mock's return type so tests can override with - // any shaped document via `.mockResolvedValue(...)`. - findOne: mock(async (): Promise | null> => null), - find: mock(() => createMockCursor()), - updateOne: mock(async () => ({ modifiedCount: 1 })), - deleteOne: mock(async () => ({ deletedCount: 1 })), - countDocuments: mock(async () => 0), - }; -} - -function createMockDb(): { db: Db; skillsCol: ReturnType } { - const skillsCol = createMockCollection(); - const db = { - collection: mock((name: string) => { - if (name === "skills") return skillsCol; - return createMockCollection(); - }), - } as unknown as Db; - return { db, skillsCol }; -} - -/** Build a minimal valid MongoDB document for a skill. */ -function makeSkillDoc(overrides: Record = {}): Record { - return { - _id: "test-guid-1", - name: "my-skill", - description: "A test skill", - license: null, - compatibility: null, - metadata: { category: "plain" }, - skillHash: "abc123", - s3Url: "s3://bucket/skills/test-guid-1.zip", - createdBy: "user-1", - createdOn: new Date("2026-01-01"), - updatedBy: "user-1", - updatedOn: new Date("2026-01-01"), - isPrivate: true, - ...overrides, - }; -} - -describe("SkillRepository", () => { - let db: Db; - let skillsCol: ReturnType; - let repo: SkillRepository; - - beforeEach(() => { - const mocks = createMockDb(); - db = mocks.db; - skillsCol = mocks.skillsCol; - repo = new SkillRepository(db); - }); - - describe("create", () => { - test("create_ValidData_CallsInsertOne", async () => { - skillsCol.findOne.mockResolvedValue(makeSkillDoc()); - - const result = await repo.create({ - guid: "test-guid-1", - name: "my-skill", - description: "A test skill", - metadata: { category: "plain" }, - skillHash: "abc123", - s3Url: "s3://bucket/skills/test-guid-1.zip", - createdBy: "user-1", - }); - - expect(skillsCol.insertOne).toHaveBeenCalled(); - expect(result.guid).toBe("test-guid-1"); - expect(result.name).toBe("my-skill"); - }); - }); - - describe("findByGuid", () => { - test("findByGuid_ExistingGuid_ReturnsSkill", async () => { - skillsCol.findOne.mockResolvedValue(makeSkillDoc()); - - const found = await repo.findByGuid("test-guid-1"); - expect(found).not.toBeNull(); - expect(found!.name).toBe("my-skill"); - }); - - test("findByGuid_NonExistentGuid_ReturnsNull", async () => { - const found = await repo.findByGuid("nonexistent"); - expect(found).toBeNull(); - }); - }); - - describe("findByName", () => { - test("findByName_ExistingName_ReturnsSkill", async () => { - skillsCol.findOne.mockResolvedValue(makeSkillDoc()); - - const found = await repo.findByName("my-skill"); - expect(found).not.toBeNull(); - expect(found!.guid).toBe("test-guid-1"); - }); - - test("findByName_NonExistentName_ReturnsNull", async () => { - const found = await repo.findByName("nonexistent"); - expect(found).toBeNull(); - }); - }); - - describe("hardDelete", () => { - test("hardDelete_CallsDeleteOne", async () => { - await repo.hardDelete("test-guid-1"); - expect(skillsCol.deleteOne).toHaveBeenCalled(); - }); - }); - - describe("keywordSearch", () => { - test("keywordSearch_WithQuery_CallsFindAndReturnsResults", async () => { - const mockDoc = makeSkillDoc(); - skillsCol.find.mockReturnValue(createMockCursor([mockDoc])); - skillsCol.countDocuments.mockResolvedValue(1); - - const results = await repo.keywordSearch("my-skill", "public", "", 1, 10); - - expect(skillsCol.find).toHaveBeenCalled(); - expect(results.skills).toHaveLength(1); - expect(results.total).toBe(1); - expect(results.skills[0].name).toBe("my-skill"); - }); - - test("keywordSearch_NoMatches_ReturnsEmpty", async () => { - const results = await repo.keywordSearch("nonexistent", "public", "", 1, 10); - expect(results.skills).toEqual([]); - expect(results.total).toBe(0); - }); - }); - - describe("findByScope", () => { - test("findByScope_PublicScope_CallsFindWithIsPrivateFalse", async () => { - const mockDoc = makeSkillDoc({ isPrivate: false }); - skillsCol.find.mockReturnValue(createMockCursor([mockDoc])); - skillsCol.countDocuments.mockResolvedValue(1); - - const results = await repo.findByScope("public", "", 1, 10); - - expect(skillsCol.find).toHaveBeenCalled(); - expect(results.skills).toHaveLength(1); - expect(results.total).toBe(1); - }); - - test("findByScope_NoMatches_ReturnsEmpty", async () => { - const results = await repo.findByScope("public", "", 1, 10); - expect(results.skills).toEqual([]); - expect(results.total).toBe(0); - }); - }); - - describe("findByGuids", () => { - test("findByGuids_EmptyList_ReturnsEmpty", async () => { - const results = await repo.findByGuids([]); - expect(results).toEqual([]); - }); - - test("findByGuids_WithGuids_CallsFind", async () => { - const mockDoc = makeSkillDoc(); - skillsCol.find.mockReturnValue(createMockCursor([mockDoc])); - - const results = await repo.findByGuids(["test-guid-1"]); - - expect(skillsCol.find).toHaveBeenCalled(); - expect(results).toHaveLength(1); - }); - }); - - describe("update", () => { - test("update_ValidData_CallsUpdateOneAndReturnsUpdated", async () => { - skillsCol.findOne.mockResolvedValue(makeSkillDoc({ name: "updated-skill" })); - - const result = await repo.update("test-guid-1", { - name: "updated-skill", - updatedBy: "user-1", - }); - - expect(skillsCol.updateOne).toHaveBeenCalled(); - expect(result.name).toBe("updated-skill"); - }); - }); -}); diff --git a/ornn-api/src/domains/skills/crud/repositories/skillRepository.ts b/ornn-api/src/domains/skills/crud/repositories/skillRepository.ts deleted file mode 100644 index e895db10..00000000 --- a/ornn-api/src/domains/skills/crud/repositories/skillRepository.ts +++ /dev/null @@ -1,218 +0,0 @@ -/** - * MongoDB-backed skill repository implementation. - * Aligned with design spec schema: guid, name, description, license, - * compatibility, metadata, skillHash, s3Url, createdBy, createdOn, - * updatedBy, updatedOn, isPrivate. - * @module repositories/skillRepository - */ - -import type { Collection, Db, Document } from "mongodb"; -import type { - ISkillRepository, - SkillDocument, - SkillMetadata, - CreateSkillData, - UpdateSkillData, - SkillFilters, -} from "./skillRepository.interface"; -import { isDuplicateKeyError, AppError } from "../../../../shared/types/index"; - -// Re-export interfaces -export type { ISkillRepository, SkillDocument, SkillMetadata, CreateSkillData, UpdateSkillData, SkillFilters }; - -export class SkillRepository implements ISkillRepository { - private readonly collection: Collection; - - constructor(db: Db) { - this.collection = db.collection("skills"); - } - - async findByGuid(guid: string): Promise { - const doc = await this.collection.findOne({ _id: guid as any }); - return mapDoc(doc); - } - - async findByName(name: string): Promise { - const doc = await this.collection.findOne({ name }); - return mapDoc(doc); - } - - async findAll(filters: SkillFilters): Promise<{ skills: SkillDocument[]; total: number }> { - const matchStage: Record = {}; - applyScope(matchStage, filters.scope ?? "private", filters.currentUserId ?? ""); - - if (filters.q) { - matchStage.$or = [ - { _id: filters.q }, - { name: { $regex: escapeRegex(filters.q), $options: "i" } }, - { description: { $regex: escapeRegex(filters.q), $options: "i" } }, - ]; - } - - const total = await this.collection.countDocuments(matchStage); - const offset = (filters.page - 1) * filters.pageSize; - const docs = await this.collection - .find(matchStage) - .sort({ createdOn: -1 }) - .skip(offset) - .limit(filters.pageSize) - .toArray(); - - return { skills: docs.map((d) => mapDoc(d)!), total }; - } - - async create(data: CreateSkillData): Promise { - const now = new Date(); - const doc: Record = { - _id: data.guid as any, - name: data.name, - description: data.description, - license: data.license ?? null, - compatibility: data.compatibility ?? null, - metadata: data.metadata, - skillHash: data.skillHash, - s3Url: data.s3Url, - createdBy: data.createdBy, - createdOn: now, - updatedBy: data.createdBy, - updatedOn: now, - isPrivate: data.isPrivate ?? true, - }; - - try { - await this.collection.insertOne(doc); - } catch (err) { - if (isDuplicateKeyError(err)) { - throw AppError.conflict("SKILL_NAME_EXISTS", `Skill '${data.name}' already exists`); - } - throw err; - } - - return mapDoc(doc)!; - } - - async update(guid: string, data: UpdateSkillData): Promise { - const setFields: Record = { - updatedBy: data.updatedBy, - updatedOn: new Date(), - }; - - if (data.name !== undefined) setFields.name = data.name; - if (data.description !== undefined) setFields.description = data.description; - if (data.license !== undefined) setFields.license = data.license; - if (data.compatibility !== undefined) setFields.compatibility = data.compatibility; - if (data.metadata !== undefined) setFields.metadata = data.metadata; - if (data.skillHash !== undefined) setFields.skillHash = data.skillHash; - if (data.s3Url !== undefined) setFields.s3Url = data.s3Url; - if (data.isPrivate !== undefined) setFields.isPrivate = data.isPrivate; - - await this.collection.updateOne({ _id: guid as any }, { $set: setFields }); - return (await this.findByGuid(guid))!; - } - - async hardDelete(guid: string): Promise { - await this.collection.deleteOne({ _id: guid as any }); - } - - async keywordSearch( - query: string, - scope: "public" | "private" | "mixed", - currentUserId: string, - page: number, - pageSize: number, - ): Promise<{ skills: SkillDocument[]; total: number }> { - const matchStage: Record = {}; - applyScope(matchStage, scope, currentUserId); - - matchStage.$or = [ - { _id: query }, - { name: { $regex: escapeRegex(query), $options: "i" } }, - { description: { $regex: escapeRegex(query), $options: "i" } }, - ]; - - const total = await this.collection.countDocuments(matchStage); - const offset = (page - 1) * pageSize; - const docs = await this.collection - .find(matchStage) - .sort({ createdOn: -1 }) - .skip(offset) - .limit(pageSize) - .toArray(); - - return { skills: docs.map((d) => mapDoc(d)!), total }; - } - - async findByScope( - scope: "public" | "private" | "mixed", - currentUserId: string, - page: number, - pageSize: number, - ): Promise<{ skills: SkillDocument[]; total: number }> { - const matchStage: Record = {}; - applyScope(matchStage, scope, currentUserId); - - const total = await this.collection.countDocuments(matchStage); - const offset = (page - 1) * pageSize; - const docs = await this.collection - .find(matchStage) - .sort({ createdOn: -1 }) - .skip(offset) - .limit(pageSize) - .toArray(); - - return { skills: docs.map((d) => mapDoc(d)!), total }; - } - - async findByGuids(guids: string[]): Promise { - if (guids.length === 0) return []; - const docs = await this.collection.find({ _id: { $in: guids } as any }).toArray(); - return docs.map((d) => mapDoc(d)!); - } -} - -// ========================================================================== -// Helper functions -// ========================================================================== - -/** Apply scope filter to a MongoDB match stage. */ -function applyScope( - matchStage: Record, - scope: "public" | "private" | "mixed", - currentUserId: string, -): void { - if (scope === "public") { - matchStage.isPrivate = false; - } else if (scope === "private") { - matchStage.createdBy = currentUserId; - } else if (scope === "mixed") { - matchStage.$or = [ - { isPrivate: false }, - { createdBy: currentUserId }, - ]; - } -} - -/** Map a MongoDB document to a SkillDocument. */ -function mapDoc(doc: Document | null): SkillDocument | null { - if (!doc) return null; - return { - guid: doc._id as string, - name: doc.name, - description: doc.description, - license: doc.license ?? null, - compatibility: doc.compatibility ?? null, - metadata: doc.metadata ?? { category: "plain" }, - skillHash: doc.skillHash ?? "", - s3Url: doc.s3Url ?? "", - createdBy: doc.createdBy ?? "", - createdOn: doc.createdOn ?? doc.created_at ?? new Date(), - updatedBy: doc.updatedBy ?? "", - updatedOn: doc.updatedOn ?? doc.updated_at ?? new Date(), - isPrivate: doc.isPrivate ?? true, - }; -} - -/** Escape special regex characters for safe use in RegExp constructor. */ -function escapeRegex(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} From 3effe5cbeff0b9264294c0444b1f31e3077fb22c Mon Sep 17 00:00:00 2001 From: Wang Shining Date: Tue, 19 May 2026 14:15:04 +0800 Subject: [PATCH 015/128] fix(api): fail-fast on bad env vars in quota migration script (#447) Number(process.env.X ?? "200") silently returns NaN on garbage input (non-numeric, trailing letters, fractions, negatives, empty string), and the migration then writes NaN into the quota_buckets documents. Quota math downstream uses these defaults; once they're NaN, every comparison goes silently wrong. Replaces the two call sites with parseNonNegativeInt(name, fallback) that: - trims surrounding whitespace - rejects anything that doesn't match /^[0-9]+$/ with an error that names the env var and shows the offending value - returns a non-negative integer Migration runs rarely and on prod, so failing loudly at startup beats silently corrupting documents that nothing else validates. --- ornn-api/scripts/migrate-quota-to-buckets.ts | 26 ++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/ornn-api/scripts/migrate-quota-to-buckets.ts b/ornn-api/scripts/migrate-quota-to-buckets.ts index f936454e..211fc2ab 100644 --- a/ornn-api/scripts/migrate-quota-to-buckets.ts +++ b/ornn-api/scripts/migrate-quota-to-buckets.ts @@ -284,6 +284,28 @@ async function sumActiveGrants( return rows[0]?.total ?? 0; } +/** + * Reads `process.env[name]` (or `fallback` when unset) and parses it as + * a base-10 integer. Throws if the value is not a finite, non-negative + * integer — `Number(env)` silently returns `NaN` on garbage input, which + * a migration must never write into Mongo. + */ +export function parseNonNegativeInt(name: string, fallback: string): number { + const raw = (process.env[name] ?? fallback).trim(); + if (!/^[0-9]+$/.test(raw)) { + throw new Error( + `${name} must be a non-negative integer (got: ${JSON.stringify(raw)})`, + ); + } + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 0) { + throw new Error( + `${name} must be a non-negative integer (got: ${JSON.stringify(raw)})`, + ); + } + return parsed; +} + // CLI entrypoint ----------------------------------------------------------- if (import.meta.main) { const dryRun = process.argv.includes("--dry-run"); @@ -293,8 +315,8 @@ if (import.meta.main) { console.error("MONGODB_URI is required"); process.exit(1); } - const defaultPlayground = Number(process.env.DEFAULT_PLAYGROUND_MONTHLY ?? "200"); - const defaultSkillGen = Number(process.env.DEFAULT_SKILLGEN_MONTHLY ?? "20"); + const defaultPlayground = parseNonNegativeInt("DEFAULT_PLAYGROUND_MONTHLY", "200"); + const defaultSkillGen = parseNonNegativeInt("DEFAULT_SKILLGEN_MONTHLY", "20"); const client = new MongoClient(uri); await client.connect(); try { From 40e06396cb37f3c4f7c90bf6d5bf5bd75018d976 Mon Sep 17 00:00:00 2001 From: Wang Shining Date: Tue, 19 May 2026 14:15:12 +0800 Subject: [PATCH 016/128] test(api): unit-test parseNonNegativeInt edge cases (#447) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the failure modes Number(env) silently swallowed: - unset env → fallback wins - whitespace-padded valid value - explicit zero - non-numeric (abc) - trailing garbage (200abc) — the silent-truncation case - negatives, fractions, empty string Each rejection path asserts the thrown error message contains "must be a non-negative integer" so the helper's contract is load-bearing in CI. --- .../scripts/migrate-quota-to-buckets.test.ts | 58 ++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/ornn-api/scripts/migrate-quota-to-buckets.test.ts b/ornn-api/scripts/migrate-quota-to-buckets.test.ts index 8486cefd..bf2fdc04 100644 --- a/ornn-api/scripts/migrate-quota-to-buckets.test.ts +++ b/ornn-api/scripts/migrate-quota-to-buckets.test.ts @@ -8,7 +8,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; import { MongoMemoryServer } from "mongodb-memory-server"; import { MongoClient, type Db } from "mongodb"; -import { migrate } from "./migrate-quota-to-buckets"; +import { migrate, parseNonNegativeInt } from "./migrate-quota-to-buckets"; let mongo: MongoMemoryServer; let client: MongoClient; @@ -258,3 +258,59 @@ describe("IT-QUOTA-MIGRATION end-to-end seeded old world", () => { expect(notifier.calls[0].targetUserId).toBe("u1"); }); }); + +describe("parseNonNegativeInt — fail-fast on garbage env input (#447)", () => { + const ENV_NAME = "ORNN_TEST_INT"; + + afterAll(() => { + delete process.env[ENV_NAME]; + }); + + beforeEach(() => { + delete process.env[ENV_NAME]; + }); + + test("uses fallback when env var is unset", () => { + expect(parseNonNegativeInt(ENV_NAME, "200")).toBe(200); + }); + + test("parses a valid integer", () => { + process.env[ENV_NAME] = "42"; + expect(parseNonNegativeInt(ENV_NAME, "0")).toBe(42); + }); + + test("trims surrounding whitespace", () => { + process.env[ENV_NAME] = " 17 "; + expect(parseNonNegativeInt(ENV_NAME, "0")).toBe(17); + }); + + test("accepts zero", () => { + process.env[ENV_NAME] = "0"; + expect(parseNonNegativeInt(ENV_NAME, "99")).toBe(0); + }); + + test("rejects non-numeric input", () => { + process.env[ENV_NAME] = "abc"; + expect(() => parseNonNegativeInt(ENV_NAME, "200")).toThrow(/must be a non-negative integer/); + }); + + test("rejects trailing garbage that Number(env) would silently truncate", () => { + process.env[ENV_NAME] = "200abc"; + expect(() => parseNonNegativeInt(ENV_NAME, "0")).toThrow(/must be a non-negative integer/); + }); + + test("rejects negative values", () => { + process.env[ENV_NAME] = "-1"; + expect(() => parseNonNegativeInt(ENV_NAME, "0")).toThrow(/must be a non-negative integer/); + }); + + test("rejects fractional values", () => { + process.env[ENV_NAME] = "1.5"; + expect(() => parseNonNegativeInt(ENV_NAME, "0")).toThrow(/must be a non-negative integer/); + }); + + test("rejects empty string", () => { + process.env[ENV_NAME] = ""; + expect(() => parseNonNegativeInt(ENV_NAME, "200")).toThrow(/must be a non-negative integer/); + }); +}); From 3cb8e1e4a7e91a321a6c1e3faa186d393ad8cbe6 Mon Sep 17 00:00:00 2001 From: Wang Shining Date: Tue, 19 May 2026 14:15:12 +0800 Subject: [PATCH 017/128] docs: changeset for #447 --- .changeset/parseint-env-fallback-447.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/parseint-env-fallback-447.md diff --git a/.changeset/parseint-env-fallback-447.md b/.changeset/parseint-env-fallback-447.md new file mode 100644 index 00000000..f9d8193b --- /dev/null +++ b/.changeset/parseint-env-fallback-447.md @@ -0,0 +1,5 @@ +--- +"ornn-api": patch +--- + +Replace `Number(process.env.X ?? "200")` with a fail-fast `parseNonNegativeInt(name, fallback)` helper in `scripts/migrate-quota-to-buckets.ts` (#447). `Number()` silently returns `NaN` on garbage input, baking `NaN` quotas into Mongo when an env file has a typo. The new helper rejects non-numeric input, trailing garbage (`"200abc"`), fractions, negatives, and empty strings at startup with a clear error naming the offending env var. Covered by 9 unit tests in the colocated test file. From c264ca1e1185dc66b9e2f3a6d24ecfdced7f4104 Mon Sep 17 00:00:00 2001 From: Wang Shining Date: Tue, 19 May 2026 14:19:24 +0800 Subject: [PATCH 018/128] feat(api): validateGitHubAppPrivateKey helper (#441) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure module that validates the shape of a PEM-encoded GitHub App private key before it lands in mirror settings. Checks performed in order: - typeof string + non-empty - byte cap (8 KB — real RSA 4096 PEMs are ~3.2 KB) - C0 control bytes rejected (allow tab/CR/LF only) - CRLF normalised to LF + outer whitespace trimmed - BEGIN/END line markers (PKCS#1 or PKCS#8) - base64-only body - crypto.createPrivateKey round-trip — catches keys that pass every shape check but are corrupted, truncated, or for a different algorithm Returns a discriminated { ok, value | reason } result instead of throwing, so the calling route picks its own error code. --- .../skills/mirror/privateKeyValidation.ts | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 ornn-api/src/domains/skills/mirror/privateKeyValidation.ts diff --git a/ornn-api/src/domains/skills/mirror/privateKeyValidation.ts b/ornn-api/src/domains/skills/mirror/privateKeyValidation.ts new file mode 100644 index 00000000..e1d9f031 --- /dev/null +++ b/ornn-api/src/domains/skills/mirror/privateKeyValidation.ts @@ -0,0 +1,105 @@ +/** + * Validate the shape of a GitHub App private key before it lands in + * settings (#441). + * + * Admins paste these PEM-encoded keys by hand into the platform + * settings UI. The original endpoint only checked `typeof === "string"`, + * which let malformed values (random whitespace, embedded NULs, + * truncated copy-paste) write garbage that surfaced much later as + * opaque crypto failures during a mirror run. + * + * This module fails fast at write time with a clear error. + * + * @module domains/skills/mirror/privateKeyValidation + */ + +import { createPrivateKey } from "node:crypto"; + +/** Cap on raw PEM size. A real RSA 4096 PEM is ~3.2 KB; 8 KB is comfortable headroom. */ +export const MAX_PRIVATE_KEY_BYTES = 8 * 1024; + +/** Accepted PEM headers — PKCS#1 (`RSA PRIVATE KEY`) is what GitHub App downloads emit; + * PKCS#8 (`PRIVATE KEY`) is what `openssl pkcs8 -topk8` produces from the same key. */ +const PEM_HEADER_RX = /^-----BEGIN (?:RSA |EC )?PRIVATE KEY-----\s*$/; +const PEM_FOOTER_RX = /^-----END (?:RSA |EC )?PRIVATE KEY-----\s*$/; + +/** Result type — narrowed `ok` discriminator instead of throwing keeps callers free to pick the error code. */ +export type ValidationResult = + | { readonly ok: true; readonly value: string } + | { readonly ok: false; readonly reason: string }; + +/** + * Returns a normalised PEM string (LF line endings, no leading/trailing + * whitespace) when the input passes every shape check, or a reason + * string otherwise. Does not throw. + */ +export function validateGitHubAppPrivateKey(raw: unknown): ValidationResult { + if (typeof raw !== "string") { + return { ok: false, reason: "must be a string" }; + } + + if (raw.length === 0) { + return { ok: false, reason: "must be a non-empty PEM string" }; + } + + if (raw.length > MAX_PRIVATE_KEY_BYTES) { + return { + ok: false, + reason: `must be at most ${MAX_PRIVATE_KEY_BYTES} bytes (got ${raw.length})`, + }; + } + + // Reject NUL and other C0 control bytes that have no business in a + // PEM. Allow tab/CR/LF; everything else under 0x20 is junk. + for (let i = 0; i < raw.length; i++) { + const ch = raw.charCodeAt(i); + if (ch < 0x20 && ch !== 0x09 && ch !== 0x0a && ch !== 0x0d) { + return { ok: false, reason: `contains forbidden control byte 0x${ch.toString(16).padStart(2, "0")}` }; + } + } + + // Normalise CRLF → LF and strip outer whitespace so the BEGIN/END + // checks don't have to think about line endings. + const normalised = raw.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim(); + const lines = normalised.split("\n"); + if (lines.length < 3) { + return { ok: false, reason: "must include a BEGIN line, base64 body, and END line" }; + } + + const first = lines[0]!.trim(); + const last = lines[lines.length - 1]!.trim(); + if (!PEM_HEADER_RX.test(first)) { + return { + ok: false, + reason: "must start with `-----BEGIN RSA PRIVATE KEY-----` (PKCS#1) or `-----BEGIN PRIVATE KEY-----` (PKCS#8)", + }; + } + if (!PEM_FOOTER_RX.test(last)) { + return { + ok: false, + reason: "must end with the matching `-----END … PRIVATE KEY-----` line", + }; + } + + // Body must be non-empty and base64-only (with whitespace between + // 64-char lines tolerated). + const body = lines.slice(1, -1).join("").replace(/\s+/g, ""); + if (body.length === 0) { + return { ok: false, reason: "PEM body is empty" }; + } + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(body)) { + return { ok: false, reason: "PEM body must be base64" }; + } + + // Final correctness check: actually parse it via Node's crypto. + // This catches keys that pass the shape check but are corrupted / + // truncated / for a different algorithm. + try { + createPrivateKey({ key: normalised, format: "pem" }); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + return { ok: false, reason: `crypto.createPrivateKey rejected the value: ${detail}` }; + } + + return { ok: true, value: normalised }; +} From fd74d805f631f15589e02ca83d549ee2f713fd90 Mon Sep 17 00:00:00 2001 From: Wang Shining Date: Tue, 19 May 2026 14:19:30 +0800 Subject: [PATCH 019/128] test(api): unit-test validateGitHubAppPrivateKey (#441) 12 tests cover the contract. Happy paths: PKCS#1 RSA-2048, PKCS#8, CRLF normalisation, whitespace stripping. Rejections: non-string, empty, oversize, embedded NUL, missing BEGIN, missing END, non-base64 body, shape-passing-but-truncated. Uses generateKeyPairSync so the test is hermetic; never reads a fixture file or hits the network. --- .../mirror/privateKeyValidation.test.ts | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 ornn-api/src/domains/skills/mirror/privateKeyValidation.test.ts diff --git a/ornn-api/src/domains/skills/mirror/privateKeyValidation.test.ts b/ornn-api/src/domains/skills/mirror/privateKeyValidation.test.ts new file mode 100644 index 00000000..b5820d02 --- /dev/null +++ b/ornn-api/src/domains/skills/mirror/privateKeyValidation.test.ts @@ -0,0 +1,113 @@ +/** + * Tests for validateGitHubAppPrivateKey (#441). + * + * Uses a freshly-generated RSA-2048 PEM (PKCS#1) as the happy-path + * input so the test is hermetic and the `createPrivateKey` round-trip + * actually exercises against a real key. + */ + +import { describe, expect, test } from "bun:test"; +import { generateKeyPairSync } from "node:crypto"; +import { + MAX_PRIVATE_KEY_BYTES, + validateGitHubAppPrivateKey, +} from "./privateKeyValidation"; + +function makePkcs1Pem(): string { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + return privateKey.export({ type: "pkcs1", format: "pem" }).toString(); +} + +function makePkcs8Pem(): string { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + return privateKey.export({ type: "pkcs8", format: "pem" }).toString(); +} + +describe("validateGitHubAppPrivateKey — happy paths", () => { + test("PKCS#1 RSA PRIVATE KEY parses + round-trips through crypto", () => { + const pem = makePkcs1Pem(); + const result = validateGitHubAppPrivateKey(pem); + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toBe(pem.trim()); + }); + + test("PKCS#8 PRIVATE KEY accepted", () => { + const pem = makePkcs8Pem(); + const result = validateGitHubAppPrivateKey(pem); + expect(result.ok).toBe(true); + }); + + test("CRLF line endings normalised to LF", () => { + const pem = makePkcs1Pem(); + const crlf = pem.replace(/\n/g, "\r\n"); + const result = validateGitHubAppPrivateKey(crlf); + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).not.toContain("\r"); + }); + + test("leading + trailing whitespace stripped", () => { + const pem = makePkcs1Pem(); + const padded = `\n ${pem} \n`; + const result = validateGitHubAppPrivateKey(padded); + expect(result.ok).toBe(true); + }); +}); + +describe("validateGitHubAppPrivateKey — rejections", () => { + test("non-string", () => { + const result = validateGitHubAppPrivateKey(42); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toMatch(/must be a string/); + }); + + test("empty string", () => { + const result = validateGitHubAppPrivateKey(""); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toMatch(/non-empty/); + }); + + test("oversize input fails before crypto sees it", () => { + const big = "A".repeat(MAX_PRIVATE_KEY_BYTES + 1); + const result = validateGitHubAppPrivateKey(big); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toMatch(/must be at most/); + }); + + test("embedded NUL rejected", () => { + const pem = makePkcs1Pem(); + const polluted = pem.slice(0, 50) + "\0" + pem.slice(50); + const result = validateGitHubAppPrivateKey(polluted); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toMatch(/forbidden control byte/); + }); + + test("missing BEGIN line", () => { + const result = validateGitHubAppPrivateKey("blah\nMIIBOgIBAAJB...\n-----END RSA PRIVATE KEY-----"); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toMatch(/BEGIN/); + }); + + test("missing END line", () => { + const result = validateGitHubAppPrivateKey("-----BEGIN RSA PRIVATE KEY-----\nMIIBOgIBAA==\nstray"); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toMatch(/END/); + }); + + test("non-base64 body", () => { + const result = validateGitHubAppPrivateKey( + "-----BEGIN RSA PRIVATE KEY-----\nnot!base?64\n-----END RSA PRIVATE KEY-----", + ); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toMatch(/base64/); + }); + + test("truncated key body fails the crypto round-trip", () => { + const pem = makePkcs1Pem(); + const lines = pem.split("\n"); + // Drop ~half the body lines so the base64 still parses but the key doesn't. + const truncated = [lines[0]!, lines[1]!, lines[lines.length - 2]!, lines[lines.length - 1]!].join("\n"); + const result = validateGitHubAppPrivateKey(truncated); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toMatch(/crypto\.createPrivateKey rejected/); + }); +}); From c12895c014979666152478aa1945e58ced5a6a78 Mon Sep 17 00:00:00 2001 From: Wang Shining Date: Tue, 19 May 2026 14:19:33 +0800 Subject: [PATCH 020/128] feat(api): plug validateGitHubAppPrivateKey into POST /github/repo (#441) Three-way branch on the incoming appPrivateKey: 1. empty string still clears the key 2. mid-mask sentinel still preserves the stored value (UI round-trip of the masked display) 3. anything else goes through validateGitHubAppPrivateKey; failures throw INVALID_SETTING with the helper's human-readable reason Closes #441. --- ornn-api/src/domains/skills/mirror/routes.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/ornn-api/src/domains/skills/mirror/routes.ts b/ornn-api/src/domains/skills/mirror/routes.ts index c3387652..cf9a88df 100644 --- a/ornn-api/src/domains/skills/mirror/routes.ts +++ b/ornn-api/src/domains/skills/mirror/routes.ts @@ -43,6 +43,7 @@ import type { MirrorScheduler, ScheduledRunStatus } from "./scheduler"; import type { SettingsService, SettingsActor } from "../../settings/types"; import type { MirrorSection } from "../../settings/sections/mirror"; import type { SkillRepository } from "../crud/repository"; +import { validateGitHubAppPrivateKey } from "./privateKeyValidation"; const logger = pino({ level: "info" }).child({ module: "mirrorRoutes" }); @@ -242,8 +243,18 @@ export function createMirrorRoutes( if (isMidMaskSentinel(v)) { // Round-trip of the mid-masked display value — keep stored key. appPrivateKey = current.appPrivateKey; - } else { + } else if (v === "") { + // Explicit clear. appPrivateKey = v; + } else { + const validated = validateGitHubAppPrivateKey(v); + if (!validated.ok) { + throw AppError.badRequest( + "INVALID_SETTING", + `'appPrivateKey' ${validated.reason}.`, + ); + } + appPrivateKey = validated.value; } } From ef508cec7e9513df2edf18af47f025031fc12f39 Mon Sep 17 00:00:00 2001 From: Wang Shining Date: Tue, 19 May 2026 14:19:34 +0800 Subject: [PATCH 021/128] docs: changeset for #441 --- .changeset/private-key-validation-441.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/private-key-validation-441.md diff --git a/.changeset/private-key-validation-441.md b/.changeset/private-key-validation-441.md new file mode 100644 index 00000000..5c44b520 --- /dev/null +++ b/.changeset/private-key-validation-441.md @@ -0,0 +1,5 @@ +--- +"ornn-api": patch +--- + +Validate the shape of `appPrivateKey` on `POST /github/repo` (#441). The mirror settings endpoint previously accepted any non-empty string; pastes with stray whitespace, embedded NULs, missing BEGIN/END markers, or a truncated body wrote garbage into settings and surfaced much later as opaque crypto errors during mirror runs. The new `validateGitHubAppPrivateKey` helper enforces an 8 KB cap, rejects C0 control bytes, requires PKCS#1 / PKCS#8 PEM markers, and round-trips through `crypto.createPrivateKey` to catch shape-passing-but-broken keys before they're persisted. Empty string still clears the key. 12 unit tests cover happy + rejection paths. From 7a1aa135265b4b6be50e9de1b349490999964027 Mon Sep 17 00:00:00 2001 From: Wang Shining Date: Tue, 19 May 2026 14:24:06 +0800 Subject: [PATCH 022/128] docs: add examples/ with three starter skills (#469) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three minimal copy-paste-ready skills under examples/ at repo root: text-summarizer/ — TS, ~50 lines, calls Claude. Demonstrates the LLM ↔ skill boundary: stdin JSON in, stdout JSON out, env-based API key. csv-processor/ — Python 3, stdlib only, ~80 lines. The offline / deterministic case; useful as a control when debugging agent ↔ skill plumbing. Ships sample.csv so it runs out of the box. api-fetch-wrapper/ — TS, ~90 lines, hits Open-Meteo (keyless). The production-shaped baseline for any "call external HTTP" skill: bounded retry on transient failure, error normalisation, and explicit no-leak handling of upstream response bodies. Each skill has a SKILL.md with valid frontmatter (per the skillFrontmatter Zod schema in ornn-api), a 5-line README, and runnable code. examples/README.md documents the anatomy of an Ornn skill + how to adapt one. These cover the three failure-mode archetypes (LLM, pure-local, external HTTP); a real skill that mixes more than one of these patterns is a good candidate to split. --- examples/README.md | 54 ++++++++++++ examples/api-fetch-wrapper/README.md | 9 ++ examples/api-fetch-wrapper/SKILL.md | 59 +++++++++++++ examples/api-fetch-wrapper/package.json | 11 +++ examples/api-fetch-wrapper/src/index.ts | 105 ++++++++++++++++++++++++ examples/csv-processor/README.md | 9 ++ examples/csv-processor/SKILL.md | 58 +++++++++++++ examples/csv-processor/sample.csv | 6 ++ examples/csv-processor/src/main.py | 86 +++++++++++++++++++ examples/text-summarizer/README.md | 9 ++ examples/text-summarizer/SKILL.md | 56 +++++++++++++ examples/text-summarizer/package.json | 14 ++++ examples/text-summarizer/src/index.ts | 62 ++++++++++++++ 13 files changed, 538 insertions(+) create mode 100644 examples/README.md create mode 100644 examples/api-fetch-wrapper/README.md create mode 100644 examples/api-fetch-wrapper/SKILL.md create mode 100644 examples/api-fetch-wrapper/package.json create mode 100644 examples/api-fetch-wrapper/src/index.ts create mode 100644 examples/csv-processor/README.md create mode 100644 examples/csv-processor/SKILL.md create mode 100644 examples/csv-processor/sample.csv create mode 100644 examples/csv-processor/src/main.py create mode 100644 examples/text-summarizer/README.md create mode 100644 examples/text-summarizer/SKILL.md create mode 100644 examples/text-summarizer/package.json create mode 100644 examples/text-summarizer/src/index.ts diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 00000000..de91fa02 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,54 @@ +# examples/ + +Minimal, copy-paste-ready starter skills that double as references for the SKILL.md frontmatter and the agent execution contract. Each one is small enough to read in one sitting and small enough to fork as the starting point for a real skill. + +| Skill | Language | What it does | +|---|---|---| +| [`text-summarizer/`](text-summarizer) | TypeScript (Bun) | Calls an LLM to summarise the input string. Demonstrates: skill ↔ LLM provider boundary, structured input via stdin JSON, structured output via stdout JSON. | +| [`csv-processor/`](csv-processor) | Python 3 | Parses CSV from a file path argument, computes per-column min/mean/max for every numeric column. Demonstrates: stdlib-only Python skill, file-path inputs, deterministic numeric output. | +| [`api-fetch-wrapper/`](api-fetch-wrapper) | TypeScript (Bun) | Wraps a public weather API. Demonstrates: env-var-based API key handling, error normalisation, retry-aware HTTP. | + +## Anatomy of an Ornn skill + +Each example is laid out the same way: + +``` +examples// +├── SKILL.md ← frontmatter the registry indexes on + agent-facing prose +├── README.md ← "what this does" + "how to adapt" (5 lines max) +├── src/ +│ └── index.{ts,py} ← the skill entrypoint +└── package.json | pyproject.toml ← runtime deps +``` + +`SKILL.md` is the contract: every field in its YAML frontmatter is validated against `ornn-api`'s [skill format schema](../docs/CONVENTIONS.md). The prose body is what the agent reads at runtime to decide whether the skill is relevant and how to call it. + +## Running an example locally + +Once the [TS SDK](../sdk/typescript) is installed: + +```bash +# From repo root +cd examples/text-summarizer +bun install +echo '{"text": "Once upon a time …", "length": 30}' | bun run src/index.ts +``` + +For the agent-driven workflow (pull from the registry, run in sandbox), see the [SDK quickstart](../README.md#sdk-quickstart) in the main README. + +## Adapting an example into your own skill + +1. Copy the directory. +2. Update `SKILL.md` `name`, `description`, and `metadata.tag` for your use case. +3. Edit the entrypoint to do your thing — keep the stdin-JSON-in / stdout-JSON-out contract so the sandbox can stream events back to the agent. +4. Bump `version` and publish via `POST /api/v1/skills` (see [`docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md)). + +## Why these three? + +They cover the three failure-mode-archetypes a real skill author needs to handle: + +- **`text-summarizer`** — LLM-backed work, network latency, model-vendor error surface. +- **`csv-processor`** — pure local computation, file I/O, deterministic output (the easy case — useful as a control). +- **`api-fetch-wrapper`** — external HTTP, secret handling, retries — the case that breaks first in production. + +If a real skill mixes more than one of these patterns, it's worth splitting. diff --git a/examples/api-fetch-wrapper/README.md b/examples/api-fetch-wrapper/README.md new file mode 100644 index 00000000..8adb3d1d --- /dev/null +++ b/examples/api-fetch-wrapper/README.md @@ -0,0 +1,9 @@ +# api-fetch-wrapper + +Call a public HTTP API with retries, structured errors, and no-leak credential handling. Demo target is Open-Meteo (keyless, so the example runs out of the box). + +**Run:** `echo '{"latitude":52.52,"longitude":13.41}' | bun run src/index.ts` + +**Adapt:** swap the upstream URL + shape; add `Authorization` header reading from env. The retry-and-error-mapping skeleton is the load-bearing part — don't drop it. + +See `SKILL.md` for the full contract. diff --git a/examples/api-fetch-wrapper/SKILL.md b/examples/api-fetch-wrapper/SKILL.md new file mode 100644 index 00000000..78d9a3f9 --- /dev/null +++ b/examples/api-fetch-wrapper/SKILL.md @@ -0,0 +1,59 @@ +--- +name: api-fetch-wrapper +description: Wrap a public HTTP API (Open-Meteo weather as the demo) with credential handling, error normalisation, and a single retry on transient network failures. Demonstrates the production-shaped baseline for any "skill that calls an external service" — env-based secrets, structured error output, no leaked API keys in logs, and a deliberate retry policy. The Open-Meteo endpoint used here is keyless on purpose so the example runs without setup; replace `OPEN_METEO_URL` with your own host and add `process.env.MY_API_KEY` for an auth'd version. +version: "1.0" +license: MIT +metadata: + category: integration + tag: + - example + - http + - external-api + - typescript +--- + +# api-fetch-wrapper + +The case that breaks first in production — a skill that hits an external service. This example covers the four things every such skill needs: secret handling, retry policy, error normalisation, and no-leak-on-failure logging. + +## Contract + +**Input** (stdin, JSON): + +```json +{ "latitude": 52.52, "longitude": 13.41 } +``` + +**Output** (stdout, JSON): + +```json +{ + "temperatureC": 18.4, + "windSpeedKmh": 12.6, + "fetchedAt": "2026-05-19T08:00:00.000Z" +} +``` + +**Errors** — written to stderr as `{"error": "...", "cause": "..."}` and exit code `1`. Error messages NEVER include the raw upstream response body (it might echo a secret); they include the upstream status code and a short canonical reason. + +## Required environment + +| Var | Purpose | +|---|---| +| `OPEN_METEO_URL` | Optional override of the upstream host. Defaults to `https://api.open-meteo.com`. | +| *(none for auth)* | Open-Meteo is keyless. For an auth'd API, the same skeleton reads `process.env.YOUR_API_KEY` and passes it via `Authorization` header. | + +## Run locally + +```bash +cd examples/api-fetch-wrapper +bun install +echo '{"latitude":52.52,"longitude":13.41}' | bun run src/index.ts +``` + +## Adapt this + +- **Different API** — replace the URL, query params, and response shape. The retry / error-mapping skeleton stays. +- **API-key auth** — read `process.env.YOUR_API_KEY` once at the top, pass it via `Authorization: Bearer ${apiKey}`, and ensure the key never appears in error messages. +- **Stronger retry** — bump `MAX_ATTEMPTS`, add exponential backoff with jitter, or distinguish 5xx (retry) vs 4xx (do not retry) explicitly. +- **Cache** — wrap the fetch in a TTL cache when the upstream rate-limits. diff --git a/examples/api-fetch-wrapper/package.json b/examples/api-fetch-wrapper/package.json new file mode 100644 index 00000000..522bc8c2 --- /dev/null +++ b/examples/api-fetch-wrapper/package.json @@ -0,0 +1,11 @@ +{ + "name": "@ornn-examples/api-fetch-wrapper", + "version": "1.0.0", + "private": true, + "description": "Minimal HTTP-API-wrapping skill — example for the Ornn skill format", + "type": "module", + "main": "src/index.ts", + "scripts": { + "start": "bun run src/index.ts" + } +} diff --git a/examples/api-fetch-wrapper/src/index.ts b/examples/api-fetch-wrapper/src/index.ts new file mode 100644 index 00000000..f26508ac --- /dev/null +++ b/examples/api-fetch-wrapper/src/index.ts @@ -0,0 +1,105 @@ +/** + * api-fetch-wrapper example skill. + * + * Reads `{ latitude, longitude }` from stdin, fetches the current + * conditions from Open-Meteo, writes `{ temperatureC, windSpeedKmh, + * fetchedAt }` to stdout. One retry on transient network failure. + * + * Demonstrates the four production-shaped patterns every "skill that + * calls an external service" needs: + * + * 1. Secrets via env (none here — Open-Meteo is keyless — but the + * reading pattern is shown via OPEN_METEO_URL). + * 2. Retry policy: one bounded retry on transient failure, no + * exponential blow-up. + * 3. Error normalisation: stderr always carries a structured + * `{ error, cause }` blob, never the raw upstream body. + * 4. No-leak on failure: the raw upstream response is never logged. + */ + +const OPEN_METEO_URL = process.env.OPEN_METEO_URL ?? "https://api.open-meteo.com"; +const MAX_ATTEMPTS = 2; +const RETRY_DELAY_MS = 500; + +interface Input { + latitude: number; + longitude: number; +} + +interface OpenMeteoResponse { + current?: { temperature_2m?: number; wind_speed_10m?: number }; +} + +async function readStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) chunks.push(chunk as Buffer); + return Buffer.concat(chunks).toString("utf8"); +} + +function isTransient(status: number): boolean { + // 408 timeout, 429 throttling, 5xx server side — all worth one retry. + return status === 408 || status === 429 || (status >= 500 && status < 600); +} + +async function fetchWeather(input: Input): Promise { + const url = new URL("/v1/forecast", OPEN_METEO_URL); + url.searchParams.set("latitude", String(input.latitude)); + url.searchParams.set("longitude", String(input.longitude)); + url.searchParams.set("current", "temperature_2m,wind_speed_10m"); + + let lastStatus = 0; + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + let res: Response; + try { + res = await fetch(url); + } catch (err) { + if (attempt === MAX_ATTEMPTS) { + throw new Error("network failure after retry", { cause: err }); + } + await new Promise((r) => setTimeout(r, RETRY_DELAY_MS)); + continue; + } + + if (res.ok) { + return (await res.json()) as OpenMeteoResponse; + } + + lastStatus = res.status; + if (!isTransient(res.status) || attempt === MAX_ATTEMPTS) { + // Drain the body so the socket can be reused, but DO NOT include + // it in the thrown error — upstream APIs sometimes echo secrets. + await res.text().catch(() => undefined); + throw new Error(`upstream returned ${res.status}`); + } + await res.text().catch(() => undefined); + await new Promise((r) => setTimeout(r, RETRY_DELAY_MS)); + } + throw new Error(`upstream returned ${lastStatus}`); +} + +async function main(): Promise { + const raw = (await readStdin()).trim(); + if (!raw) throw new Error("expected JSON `{ latitude, longitude }` on stdin"); + const input = JSON.parse(raw) as Input; + if (typeof input.latitude !== "number" || typeof input.longitude !== "number") { + throw new Error("`latitude` and `longitude` must be numbers"); + } + + const upstream = await fetchWeather(input); + const current = upstream.current ?? {}; + + process.stdout.write( + JSON.stringify({ + temperatureC: current.temperature_2m ?? null, + windSpeedKmh: current.wind_speed_10m ?? null, + fetchedAt: new Date().toISOString(), + }) + "\n", + ); +} + +main().catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err); + const cause = err instanceof Error && err.cause instanceof Error ? err.cause.message : undefined; + process.stderr.write(JSON.stringify({ error: message, cause }) + "\n"); + process.exit(1); +}); diff --git a/examples/csv-processor/README.md b/examples/csv-processor/README.md new file mode 100644 index 00000000..4735ea7f --- /dev/null +++ b/examples/csv-processor/README.md @@ -0,0 +1,9 @@ +# csv-processor + +Parse a CSV file, compute per-column min/mean/max for every numeric column. + +**Run:** `python src/main.py sample.csv` + +**Adapt:** add more aggregations (median, p95, stddev), or stream large files with running-mean updates. The in/out shape (`path → { rowCount, columns: { ... } }`) is intentionally fixed so the skill stays composable. + +Stdlib only — no pandas, no numpy. See `SKILL.md` for the full contract. diff --git a/examples/csv-processor/SKILL.md b/examples/csv-processor/SKILL.md new file mode 100644 index 00000000..9082f0fe --- /dev/null +++ b/examples/csv-processor/SKILL.md @@ -0,0 +1,58 @@ +--- +name: csv-processor +description: Read a CSV file from disk, compute per-column min/mean/max for every numeric column, emit the result as JSON. Stdlib-only Python; no pandas, no numpy. Demonstrates the simplest possible "give me a file path, get back structured analysis" skill — a deliberate baseline for any skill that processes tabular data locally without an LLM in the loop. +version: "1.0" +license: MIT +metadata: + category: data + tag: + - example + - csv + - statistics + - python +--- + +# csv-processor + +A deterministic, network-free skill — the easiest case. Useful as a control when debugging the agent ↔ skill plumbing: if this fails, the failure is in the runner, not the skill. + +## Contract + +**Input** (single CLI argument): + +``` +python src/main.py /path/to/data.csv +``` + +The script reads `argv[1]` as a filesystem path. CSV must have a header row. + +**Output** (stdout, JSON): + +```json +{ + "rowCount": 1234, + "columns": { + "price": { "min": 1.23, "mean": 42.0, "max": 999.99, "count": 1234 }, + "quantity": { "min": 0, "mean": 7.5, "max": 100, "count": 1230 } + } +} +``` + +Only numeric columns appear under `columns`. `count` is the number of cells that parsed successfully (numeric); non-numeric / blank cells are skipped. + +**Errors** — written to stderr as `{"error": "..."}` and exit code `1`. + +## Run locally + +```bash +cd examples/csv-processor +python src/main.py sample.csv +``` + +A `sample.csv` is bundled so the example runs out of the box. + +## Adapt this + +- **Different aggregations** — add median, p95, stddev; same shape, more keys per column. +- **Streaming** — for huge files, replace the in-memory accumulation with a running-mean update; one extra variable per column, same output shape. +- **Source other than disk** — accept a URL or stdin instead of `argv[1]`. The aggregation core doesn't care. diff --git a/examples/csv-processor/sample.csv b/examples/csv-processor/sample.csv new file mode 100644 index 00000000..b5714d9a --- /dev/null +++ b/examples/csv-processor/sample.csv @@ -0,0 +1,6 @@ +product,price,quantity,region +apple,1.50,120,west +banana,0.75,300,west +cherry,3.20,80,east +date,2.10,55,east +elderberry,4.50,12,north diff --git a/examples/csv-processor/src/main.py b/examples/csv-processor/src/main.py new file mode 100644 index 00000000..408abb98 --- /dev/null +++ b/examples/csv-processor/src/main.py @@ -0,0 +1,86 @@ +"""CSV processor example skill. + +Reads a CSV file path from `argv[1]`, computes per-column min/mean/max +for every column where at least one cell parses as a number, writes a +JSON summary to stdout. On any failure: `{ "error": "..." }` on stderr ++ exit code 1. + +Stdlib only; deterministic; offline. The control case for debugging +agent ↔ skill plumbing. +""" + +from __future__ import annotations + +import csv +import json +import sys +from typing import Optional + + +def parse_number(raw: str) -> Optional[float]: + """Best-effort numeric parse — empty / non-numeric returns None.""" + s = raw.strip() + if not s: + return None + try: + return float(s) + except ValueError: + return None + + +def summarise(path: str) -> dict: + with open(path, newline="", encoding="utf-8") as fh: + reader = csv.DictReader(fh) + fieldnames = reader.fieldnames or [] + sums: dict[str, float] = {name: 0.0 for name in fieldnames} + counts: dict[str, int] = {name: 0 for name in fieldnames} + mins: dict[str, float] = {} + maxs: dict[str, float] = {} + row_count = 0 + + for row in reader: + row_count += 1 + for name in fieldnames: + value = parse_number(row.get(name, "")) + if value is None: + continue + sums[name] += value + counts[name] += 1 + if name not in mins or value < mins[name]: + mins[name] = value + if name not in maxs or value > maxs[name]: + maxs[name] = value + + columns: dict[str, dict[str, float | int]] = {} + for name in fieldnames: + if counts[name] == 0: + # Skip columns where no cell parsed as a number. + continue + columns[name] = { + "min": mins[name], + "mean": sums[name] / counts[name], + "max": maxs[name], + "count": counts[name], + } + + return {"rowCount": row_count, "columns": columns} + + +def main(argv: list[str]) -> int: + if len(argv) < 2: + sys.stderr.write(json.dumps({"error": "usage: main.py "}) + "\n") + return 1 + try: + result = summarise(argv[1]) + except FileNotFoundError as e: + sys.stderr.write(json.dumps({"error": f"file not found: {e.filename}"}) + "\n") + return 1 + except OSError as e: + sys.stderr.write(json.dumps({"error": f"could not read CSV: {e}"}) + "\n") + return 1 + sys.stdout.write(json.dumps(result) + "\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/examples/text-summarizer/README.md b/examples/text-summarizer/README.md new file mode 100644 index 00000000..9df81b82 --- /dev/null +++ b/examples/text-summarizer/README.md @@ -0,0 +1,9 @@ +# text-summarizer + +Summarise input text using Claude. ~50 lines of TypeScript; pure stdin/stdout JSON. + +**Run:** `ANTHROPIC_API_KEY=sk-ant-... echo '{"text":"..."}' | bun run src/index.ts` + +**Adapt:** swap `Anthropic` SDK for another vendor; or wrap the call in retry/streaming. The I/O contract (`{ text, length? }` → `{ summary }`) is intentionally fixed so the skill stays composable. + +See `SKILL.md` for the full agent-facing contract. diff --git a/examples/text-summarizer/SKILL.md b/examples/text-summarizer/SKILL.md new file mode 100644 index 00000000..45443d30 --- /dev/null +++ b/examples/text-summarizer/SKILL.md @@ -0,0 +1,56 @@ +--- +name: text-summarizer +description: Summarise a chunk of text down to roughly `length` words using the agent's configured LLM provider. Input shape `{ text: string, length?: number }` on stdin, JSON; output shape `{ summary: string }` on stdout, JSON. Minimal: ~50 lines, no streaming, no retries — a deliberate baseline so the wiring is visible. For a production summariser, fork this and add retries, prompt-injection sanitisation, length validation, and per-model cost tracking. +version: "1.0" +license: MIT +metadata: + category: text + tag: + - example + - llm + - summarization + - typescript +--- + +# text-summarizer + +A minimum-viable LLM skill — the smallest amount of code that takes structured input, calls an LLM, and emits structured output. + +## Contract + +**Input** (stdin, JSON): + +```json +{ "text": "...long input...", "length": 60 } +``` + +- `text` (string, required) — what to summarise. +- `length` (number, optional, default 60) — target word count for the summary. + +**Output** (stdout, JSON): + +```json +{ "summary": "..." } +``` + +**Errors** — written to stderr as `{ "error": "...message..." }` and exit code `1`. + +## Required environment + +| Var | Purpose | +|---|---| +| `ANTHROPIC_API_KEY` | Talks to Claude. Swap the SDK call to point at OpenAI / Gemini / your own backend; nothing else changes. | + +## Run locally + +```bash +cd examples/text-summarizer +bun install +ANTHROPIC_API_KEY=sk-ant-... echo '{"text":"...","length":40}' | bun run src/index.ts +``` + +## Adapt this + +- **Different model vendor** — replace `Anthropic` with the SDK of your choice; the I/O shape stays. +- **Stream output** — emit one JSON line per token instead of one final blob. +- **Sanitise input** — the current code passes `text` to the model verbatim; for untrusted callers, strip control characters and cap length before the API call. diff --git a/examples/text-summarizer/package.json b/examples/text-summarizer/package.json new file mode 100644 index 00000000..888bbfae --- /dev/null +++ b/examples/text-summarizer/package.json @@ -0,0 +1,14 @@ +{ + "name": "@ornn-examples/text-summarizer", + "version": "1.0.0", + "private": true, + "description": "Minimal LLM-backed summarizer skill — example for the Ornn skill format", + "type": "module", + "main": "src/index.ts", + "scripts": { + "start": "bun run src/index.ts" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.30.1" + } +} diff --git a/examples/text-summarizer/src/index.ts b/examples/text-summarizer/src/index.ts new file mode 100644 index 00000000..97b5be07 --- /dev/null +++ b/examples/text-summarizer/src/index.ts @@ -0,0 +1,62 @@ +/** + * text-summarizer example skill. + * + * Reads `{ text, length? }` from stdin (single JSON blob), asks Claude + * for a summary of roughly `length` words, writes `{ summary }` to + * stdout. On any failure: `{ error }` on stderr + exit code 1. + * + * Intentionally minimal — no retries, no streaming, no input + * sanitisation. See SKILL.md "Adapt this" for production hardening. + */ + +import Anthropic from "@anthropic-ai/sdk"; + +interface Input { + text: string; + length?: number; +} + +async function readStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) chunks.push(chunk as Buffer); + return Buffer.concat(chunks).toString("utf8"); +} + +async function main(): Promise { + const raw = (await readStdin()).trim(); + if (!raw) { + throw new Error("expected JSON `{ text, length? }` on stdin"); + } + + const input = JSON.parse(raw) as Input; + if (typeof input.text !== "string" || input.text.length === 0) { + throw new Error("`text` is required and must be a non-empty string"); + } + const length = typeof input.length === "number" && input.length > 0 ? input.length : 60; + + const client = new Anthropic(); + const message = await client.messages.create({ + model: "claude-opus-4-7", + max_tokens: Math.max(256, length * 4), + messages: [ + { + role: "user", + content: `Summarise the following text in approximately ${length} words. Reply with only the summary, no preamble.\n\n${input.text}`, + }, + ], + }); + + const summary = message.content + .filter((block): block is Anthropic.TextBlock => block.type === "text") + .map((block) => block.text) + .join("\n") + .trim(); + + process.stdout.write(JSON.stringify({ summary }) + "\n"); +} + +main().catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err); + process.stderr.write(JSON.stringify({ error: message }) + "\n"); + process.exit(1); +}); From 5278d83ffd0a5d0361e8277e38cc16987fc14ea0 Mon Sep 17 00:00:00 2001 From: Wang Shining Date: Tue, 19 May 2026 14:24:12 +0800 Subject: [PATCH 023/128] docs(readme): link to examples/ from README + nav (#469) New `## Examples` section after `## How Ornn compares` with a 3-row table pointing at each starter skill plus a one-line takeaway for what each one demonstrates. Top nav gains `#examples` anchor. --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index 832e8569..0876fc40 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ SDK quickstart · Quickstart · How Ornn compares · + Examples · Docs · Roadmap · Community · @@ -161,6 +162,18 @@ The space of agent skill / tool registries is crowded. Quick orientation: Treat the table as a working draft — corrections welcome via [Discussions → Ideas](https://github.com/ChronoAIProject/Ornn/discussions/categories/ideas). +## Examples + +Three minimal starter skills under [`examples/`](examples) — fork as the starting point for your own. Each one is ~60 lines, runs locally, and demonstrates one of the failure-mode archetypes you'll hit in production: + +| Skill | What it shows | +|---|---| +| [`text-summarizer`](examples/text-summarizer) | LLM-backed work — model API call, structured I/O | +| [`csv-processor`](examples/csv-processor) | Pure local computation — file in, JSON out, deterministic | +| [`api-fetch-wrapper`](examples/api-fetch-wrapper) | External HTTP — secret handling, retries, no-leak errors | + +See [`examples/README.md`](examples/README.md) for the anatomy of an Ornn skill + how to adapt one. + ## Documentation - **Product docs** — [ornn.chrono-ai.fun/docs](https://ornn.chrono-ai.fun/docs) From 3785d6dd6b2caa20be76e90612eed008aba58d40 Mon Sep 17 00:00:00 2001 From: Wang Shining Date: Tue, 19 May 2026 14:24:12 +0800 Subject: [PATCH 024/128] docs: changeset for #469 Closes #469. --- .changeset/examples-dir-469.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .changeset/examples-dir-469.md diff --git a/.changeset/examples-dir-469.md b/.changeset/examples-dir-469.md new file mode 100644 index 00000000..a7900ddd --- /dev/null +++ b/.changeset/examples-dir-469.md @@ -0,0 +1,4 @@ +--- +--- + +Adds `examples/` with three minimal copy-paste starter skills (#469): `text-summarizer` (TS / LLM-backed), `csv-processor` (Python / stdlib-only deterministic), `api-fetch-wrapper` (TS / external HTTP with retries + no-leak errors). Each has a working `SKILL.md` with valid frontmatter, a short README, and an entrypoint that runs locally. Main README gains an `## Examples` section + nav anchor. Each example documents how to adapt it — the three failure-mode archetypes (LLM, pure-local, external HTTP) cover the patterns a real skill author actually has to handle. From 67533fd3a9fcb61387828b677c9d25a363c06d13 Mon Sep 17 00:00:00 2001 From: Wang Shining Date: Tue, 19 May 2026 14:26:48 +0800 Subject: [PATCH 025/128] fix(api): Zod-validate LLM re-ranker output (#444) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The semantic-search rerank path previously cast `JSON.parse(jsonMatch[0])` to `Array<{ id, score, reason? }>` — a runtime no-op. A malformed model response slipped through the GUID filter and broke downstream score arithmetic with NaN / undefined. Replaces the cast with a Zod schema (rerankResponseSchema) that enforces id is a non-empty string and score is a finite number before the row reaches the existing filter. Schema failures log a warning with the first three Zod issues and return an empty batch — same fail-safe as before, just observable now (the previous code silently propagated bad data). Closes #444. --- .changeset/rerank-zod-444.md | 5 +++ ornn-api/src/domains/skills/search/service.ts | 31 +++++++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 .changeset/rerank-zod-444.md diff --git a/.changeset/rerank-zod-444.md b/.changeset/rerank-zod-444.md new file mode 100644 index 00000000..2c2273a3 --- /dev/null +++ b/.changeset/rerank-zod-444.md @@ -0,0 +1,5 @@ +--- +"ornn-api": patch +--- + +Validate the LLM re-ranker response with Zod instead of an `as Array<...>` cast (#444). `JSON.parse(...) as Array<...>` is a runtime no-op; a malformed model response previously slipped through the GUID filter and broke downstream score arithmetic with `NaN`/`undefined`. The new `rerankResponseSchema` enforces `id` is a non-empty string and `score` is a finite number before the row reaches the filter; schema failures log a warning with the first three Zod issues and return the empty batch (same fail-safe as before, just observable now). diff --git a/ornn-api/src/domains/skills/search/service.ts b/ornn-api/src/domains/skills/search/service.ts index f4cdd1d9..240b8706 100644 --- a/ornn-api/src/domains/skills/search/service.ts +++ b/ornn-api/src/domains/skills/search/service.ts @@ -9,6 +9,25 @@ import type { SkillRepository } from "../crud/repository"; import type { NyxLlmClient } from "../../../clients/nyxid/llm"; import type { SkillDocument, SkillSearchItem, SkillSearchResponse } from "../../../shared/types/index"; import pino from "pino"; +import { z } from "zod"; + +/** + * Shape the LLM re-ranker contract promises: an array of + * `{ id, score, reason? }` rows. Parsed with Zod (#444) instead of an + * `as` cast — `JSON.parse(...) as Array<...>` is a runtime no-op and + * a malformed model response previously slipped through the GUID + * filter and broke downstream score arithmetic with `NaN`. + * + * Rows with `score <= 0` are still dropped, but that filter is now + * applied after schema validation; the schema itself only enforces + * the shape. + */ +const rerankRowSchema = z.object({ + id: z.string().min(1), + score: z.number().finite(), + reason: z.string().optional(), +}); +const rerankResponseSchema = z.array(rerankRowSchema); /** * Per-item response enrichment context. The system-skill predicate is @@ -323,12 +342,20 @@ ${JSON.stringify(skillList, null, 2)}`; return []; } - const parsed = JSON.parse(jsonMatch[0]) as Array<{ id: string; score: number; reason?: string }>; + const parseResult = rerankResponseSchema.safeParse(JSON.parse(jsonMatch[0])); + if (!parseResult.success) { + logger.warn( + { batchSize: batch.length, issues: parseResult.error.issues.slice(0, 3) }, + "Semantic search: LLM output failed schema validation", + ); + return []; + } + const parsed = parseResult.data; // Validate and map const validGuids = new Set(batch.map((s) => s.guid)); return parsed - .filter((r) => validGuids.has(r.id) && typeof r.score === "number" && r.score > 0) + .filter((r) => validGuids.has(r.id) && r.score > 0) .map((r) => ({ guid: r.id, score: Math.min(10, Math.max(0, r.score)), From 42957ad573aba12d101748147810cc22bbcddd06 Mon Sep 17 00:00:00 2001 From: Wang Shining Date: Tue, 19 May 2026 14:46:15 +0800 Subject: [PATCH 026/128] fix(web): harden ZIP validator with size cap + zip-slip check (#443) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSZip handles path traversal internally for .file() lookups, but the validator's path logic is string-prefix based; if JSZip's protection lapses or we switch unzip library, the existing code would happily resolve '../escape.txt' outside the skill directory. Adds two guards before the extraction loop: 1. isUnsafeEntryPath() — rejects entries with .. segments, leading slash, backslashes, or Windows drive letters. Fails the whole ZIP with errors.zip.unsafePath. 2. cumulative uncompressed-size cap (MAX_TOTAL_UNCOMPRESSED_BYTES = 50 MB). Read off the internal _data.uncompressedSize during forEach so a zip-bomb never enters the extraction loop. New i18n keys errors.zip.unsafePath and errors.zip.uncompressedTooLarge added in en.json + zh.json. Tests use JSZip to build hostile fixtures and assert each guard fires. 6/6 pass via vitest. Closes #443. --- .changeset/zip-validator-443.md | 5 ++ ornn-web/src/i18n/en.json | 4 +- ornn-web/src/i18n/zh.json | 4 +- ornn-web/src/utils/zipValidator.test.ts | 111 ++++++++++++++++++++++++ ornn-web/src/utils/zipValidator.ts | 80 ++++++++++++++++- 5 files changed, 201 insertions(+), 3 deletions(-) create mode 100644 .changeset/zip-validator-443.md create mode 100644 ornn-web/src/utils/zipValidator.test.ts diff --git a/.changeset/zip-validator-443.md b/.changeset/zip-validator-443.md new file mode 100644 index 00000000..46fe7db3 --- /dev/null +++ b/.changeset/zip-validator-443.md @@ -0,0 +1,5 @@ +--- +"ornn-web": patch +--- + +Harden the client-side ZIP validator with two new guards (#443): (1) explicit zip-slip / unsafe-path rejection (entries with `..` segments, leading `/`, backslashes, or Windows drive letters) — defence-in-depth on top of JSZip's internal handling so a future unzip-library swap doesn't quietly turn into a path-traversal bug; (2) cumulative uncompressed-size cap (50 MB) read off `_data.uncompressedSize` during `forEach` so a zip-bomb never enters the extraction loop. New i18n keys `errors.zip.unsafePath` and `errors.zip.uncompressedTooLarge` (EN + ZH). 6 unit tests cover both guards. diff --git a/ornn-web/src/i18n/en.json b/ornn-web/src/i18n/en.json index f77623f9..94c2419e 100644 --- a/ornn-web/src/i18n/en.json +++ b/ornn-web/src/i18n/en.json @@ -1537,7 +1537,9 @@ "unrecognizedDirs": "Unrecognized directories found: {{dirs}}. Only scripts/, references/, and assets/ are standard.", "frontmatterUnparseable": "Could not extract metadata from SKILL.md frontmatter. You may need to add or fix the YAML frontmatter block.", "onlyZipAccepted": "Only .zip files are accepted.", - "fileTooLarge": "File too large ({{size}})." + "fileTooLarge": "File too large ({{size}}).", + "unsafePath": "Unsafe path in ZIP entry: '{{path}}'. Paths may not contain '..', leading slashes, backslashes, or drive-letter prefixes.", + "uncompressedTooLarge": "ZIP expands to {{actual}} bytes, which exceeds the limit of {{max}} bytes." }, "frontmatter": { "panelTitle": "Frontmatter Validation Errors", diff --git a/ornn-web/src/i18n/zh.json b/ornn-web/src/i18n/zh.json index e3d6e1cc..7a02cbd8 100644 --- a/ornn-web/src/i18n/zh.json +++ b/ornn-web/src/i18n/zh.json @@ -1537,7 +1537,9 @@ "unrecognizedDirs": "存在未识别目录:{{dirs}}。仅允许 scripts/、references/、assets/。", "frontmatterUnparseable": "无法从 SKILL.md frontmatter 解析元数据。请检查并修复 YAML frontmatter 块。", "onlyZipAccepted": "仅接受 .zip 文件。", - "fileTooLarge": "文件过大({{size}})。" + "fileTooLarge": "文件过大({{size}})。", + "unsafePath": "ZIP 条目路径不安全:'{{path}}'。路径不允许包含 '..'、前导斜杠、反斜杠或盘符前缀。", + "uncompressedTooLarge": "ZIP 解压后达到 {{actual}} 字节,超过限制 {{max}} 字节。" }, "frontmatter": { "panelTitle": "Frontmatter 校验错误", diff --git a/ornn-web/src/utils/zipValidator.test.ts b/ornn-web/src/utils/zipValidator.test.ts new file mode 100644 index 00000000..105cdbfb --- /dev/null +++ b/ornn-web/src/utils/zipValidator.test.ts @@ -0,0 +1,111 @@ +/** + * Tests for the ZIP validator hardening landed in #443: + * + * - cumulative uncompressed-size cap (zip-bomb defence) + * - explicit zip-slip / unsafe-path rejection + * + * Pre-existing happy-path behaviour is exercised via the + * SkillUploadPage component tests; this file is scoped to the two + * new guards. + */ + +import { describe, expect, test } from "vitest"; +import JSZip from "jszip"; +import { + MAX_TOTAL_UNCOMPRESSED_BYTES, + validateSkillZip, +} from "./zipValidator"; + +function fileFromZip(zip: JSZip, name = "skill.zip"): File { + // jsdom doesn't provide a real File constructor without a Blob shim + // — we construct from the underlying Uint8Array and trust the + // function only calls arrayBuffer() on it. + return new File([], name) as File & { arrayBuffer: () => Promise } & + { _zip: JSZip }; +} + +async function zipToFile(zip: JSZip): Promise { + const blob = await zip.generateAsync({ type: "blob" }); + return new File([blob], "skill.zip"); +} + +describe("validateSkillZip — zip-slip defence (#443)", () => { + test("rejects a path with .. segment", async () => { + const zip = new JSZip(); + zip.file("SKILL.md", "---\nname: x\n---\nbody"); + zip.file("../escape.txt", "i should not exist outside the skill dir"); + const file = await zipToFile(zip); + + const result = await validateSkillZip(file); + // JSZip normalises `../` at archive-write time, so the entry + // surfaces with the leading-slash form — the unsafe-path check + // still has to fire on the normalised path. + expect(result.status).toBe("invalid"); + expect(result.errors[0]?.key).toBe("errors.zip.unsafePath"); + }); + + test("rejects a path starting with /", async () => { + const zip = new JSZip(); + zip.file("SKILL.md", "---\nname: x\n---\nbody"); + zip.file("/etc/passwd", "root:x:0:0"); + const file = await zipToFile(zip); + + const result = await validateSkillZip(file); + expect(result.status).toBe("invalid"); + expect(result.errors[0]?.key).toBe("errors.zip.unsafePath"); + }); + + test("rejects a backslash path (Windows path separator)", async () => { + const zip = new JSZip(); + zip.file("SKILL.md", "---\nname: x\n---\nbody"); + zip.file("scripts\\nested\\evil.bat", "echo pwned"); + const file = await zipToFile(zip); + + const result = await validateSkillZip(file); + expect(result.status).toBe("invalid"); + expect(result.errors[0]?.key).toBe("errors.zip.unsafePath"); + }); + + test("rejects a Windows drive-letter prefix", async () => { + const zip = new JSZip(); + zip.file("SKILL.md", "---\nname: x\n---\nbody"); + // Use a backslash-free version since the backslash check fires first. + zip.file("C:/Windows/system32/evil.bat", "echo pwned"); + const file = await zipToFile(zip); + + const result = await validateSkillZip(file); + expect(result.status).toBe("invalid"); + expect(result.errors[0]?.key).toBe("errors.zip.unsafePath"); + }); +}); + +describe("validateSkillZip — uncompressed-size cap (#443)", () => { + test("rejects when cumulative uncompressed bytes exceed the cap", async () => { + const zip = new JSZip(); + zip.file("SKILL.md", "---\nname: x\n---\nbody"); + // Single file just over the cap (highly compressible so the ZIP + // itself stays tiny — that's the bomb pattern). + const huge = "A".repeat(MAX_TOTAL_UNCOMPRESSED_BYTES + 1); + zip.file("references/huge.txt", huge); + const file = await zipToFile(zip); + + const result = await validateSkillZip(file); + expect(result.status).toBe("invalid"); + expect(result.errors[0]?.key).toBe("errors.zip.uncompressedTooLarge"); + expect((result.errors[0]?.params?.max as number) ?? 0).toBe( + MAX_TOTAL_UNCOMPRESSED_BYTES, + ); + }); + + test("accepts a ZIP comfortably under the cap", async () => { + const zip = new JSZip(); + zip.file("SKILL.md", "---\nname: x\nversion: \"1.0\"\nmetadata:\n category: text\n---\nbody"); + zip.file("references/note.md", "small file"); + const file = await zipToFile(zip); + + const result = await validateSkillZip(file); + // The frontmatter parse warning is allowed; what we care about + // here is that the cap didn't fire. + expect(result.errors.find((e) => e.key === "errors.zip.uncompressedTooLarge")).toBeUndefined(); + }); +}); diff --git a/ornn-web/src/utils/zipValidator.ts b/ornn-web/src/utils/zipValidator.ts index 8320a702..d068458c 100644 --- a/ornn-web/src/utils/zipValidator.ts +++ b/ornn-web/src/utils/zipValidator.ts @@ -30,6 +30,37 @@ export interface ZipValidationResult { /** Recognized top-level directories in a skill package */ const RECOGNIZED_DIRS = new Set(["scripts", "references", "assets"]); +/** + * Cumulative uncompressed-size cap for a skill ZIP (#443). 50 MB is + * larger than any plausible legit skill package today; it caps zip- + * bomb expansion before we try to decompress every entry into memory. + */ +export const MAX_TOTAL_UNCOMPRESSED_BYTES = 50 * 1024 * 1024; + +/** + * Explicit zip-slip / unsafe-path predicate (#443). JSZip handles + * traversal internally for `.file()` lookups, but the validator's path + * logic is string-prefix based; if JSZip's protection ever lapses or + * we switch unzip library, the existing code would happily resolve + * `../escape.txt` outside the skill directory. Cheap, explicit defence: + * + * - reject any segment equal to `..` + * - reject any path that starts with `/` (absolute) or contains `:` + * (Windows drive prefix — `C:\\evil.bat`) + * - reject backslash anywhere (PKZip allows them; some tooling treats + * them as path separators after extraction) + */ +function isUnsafeEntryPath(rawPath: string): boolean { + if (rawPath.startsWith("/")) return true; + if (rawPath.includes("\\")) return true; + if (/^[A-Za-z]:/.test(rawPath)) return true; + const segments = rawPath.split("/"); + for (const seg of segments) { + if (seg === "..") return true; + } + return false; +} + /** OS-generated junk files to filter out */ const JUNK_FILE_NAMES = new Set([ ".DS_Store", @@ -152,13 +183,60 @@ export async function validateSkillZip( }; } - // Collect all entries, filtering out OS junk files + // Collect all entries, filtering out OS junk files. Reject any entry + // whose path tries to escape the skill directory (#443) and bail + // when the cumulative uncompressed size exceeds the cap. Both checks + // run during forEach so we never enter the extraction loop on a + // hostile ZIP. const rawEntries: Array<{ path: string; dir: boolean }> = []; + let unsafePath: string | null = null; + let totalUncompressed = 0; zip.forEach((relativePath, entry) => { if (isJunkPath(relativePath)) return; + if (isUnsafeEntryPath(relativePath)) { + unsafePath = relativePath; + return; + } + // JSZip exposes uncompressed size through `_data.uncompressedSize` + // on the internal CompressedObject. Public API doesn't surface it + // pre-decompression; this is the only way to refuse a zip-bomb + // without first decompressing it. + const entryWithInternal = entry as unknown as { + _data?: { uncompressedSize?: number }; + }; + const uncompressed = entryWithInternal._data?.uncompressedSize ?? 0; + totalUncompressed += uncompressed; rawEntries.push({ path: relativePath, dir: entry.dir }); }); + if (unsafePath !== null) { + return { + status: "invalid", + files: [], + metadata: null, + errors: [{ key: "errors.zip.unsafePath", params: { path: unsafePath } }], + warnings: [], + }; + } + + if (totalUncompressed > MAX_TOTAL_UNCOMPRESSED_BYTES) { + return { + status: "invalid", + files: [], + metadata: null, + errors: [ + { + key: "errors.zip.uncompressedTooLarge", + params: { + actual: totalUncompressed, + max: MAX_TOTAL_UNCOMPRESSED_BYTES, + }, + }, + ], + warnings: [], + }; + } + const { normalized, prefix } = normalizeEntries(rawEntries); // Find all SKILL.md files From c0b47161eaf9e4cf19010c4660cca257ec9419d0 Mon Sep 17 00:00:00 2001 From: Wang Shining Date: Tue, 19 May 2026 14:50:08 +0800 Subject: [PATCH 027/128] fix(web): document or fix React keys on Array.map lists (#451) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every key={i} site flagged in the audit was reviewed: - Positional lists that never reorder (skeleton cards, OTP cells, code-editor line numbers, table skeletons) keep key={i} and now carry a one-line // … (#451) comment so a future audit doesn't re-churn the file. - Data-driven lists that can re-shape: * RootLayout breadcrumbs → key = `${to}-${label}` * CreateSkillFreePage validation list → key = `err-${i}-${err.key}` Now reconciliation preserves hover/focus on the right cell when the source array changes shape. Closes #451. --- .changeset/react-keys-451.md | 5 +++++ ornn-web/src/components/auth/OtpInput.tsx | 3 +++ ornn-web/src/components/editor/CodeEditor.tsx | 2 ++ ornn-web/src/components/layout/RootLayout.tsx | 6 +++++- ornn-web/src/components/skill/SkillGrid.tsx | 1 + ornn-web/src/components/ui/NeonSkeleton.tsx | 3 +++ ornn-web/src/pages/ExplorePage.tsx | 1 + ornn-web/src/pages/skill/CreateSkillFreePage.tsx | 7 +++++-- ornn-web/src/pages/skill/MySkillsPage.tsx | 1 + 9 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 .changeset/react-keys-451.md diff --git a/.changeset/react-keys-451.md b/.changeset/react-keys-451.md new file mode 100644 index 00000000..2d85732c --- /dev/null +++ b/.changeset/react-keys-451.md @@ -0,0 +1,5 @@ +--- +"ornn-web": patch +--- + +Document or replace `key={i}` on `Array.map` lists across the web app (#451). Every site flagged in the audit was reviewed: positional lists that never reorder (skeleton cards, OTP cells, code-editor line numbers, table skeletons) keep `key={i}` but now carry a one-line comment so a future audit doesn't churn the file. Data-driven lists that can re-shape (RootLayout breadcrumbs, CreateSkillFreePage validation messages) switch to composite keys that include the data identity, so reconciliation doesn't preserve hover/focus state on the wrong element when the source array changes shape. diff --git a/ornn-web/src/components/auth/OtpInput.tsx b/ornn-web/src/components/auth/OtpInput.tsx index d5fd2d50..62506800 100644 --- a/ornn-web/src/components/auth/OtpInput.tsx +++ b/ornn-web/src/components/auth/OtpInput.tsx @@ -25,6 +25,9 @@ export function OtpInput({ length = 6, onComplete, disabled, error }: OtpInputPr return (

{Array.from({ length }).map((_, i) => ( + // OTP cells are positional (cell N is always cell N); the parent + // never re-renders with a different `length`, so key={i} is + // intentional and reconciliation-stable (#451). {Array.from({ length: lineCount }).map((_, i) => ( + // Line numbers are positional (line N == row index N); never + // reorder, key={i} is intentional (#451).
{ const isLast = i === crumbs.length - 1; return ( - + // Breadcrumbs can re-shape when the route changes — use + // the crumb destination as a stable key so reconciliation + // doesn't preserve hover/focus state on the wrong segment + // when the trail length changes (#451). + {i > 0 && ( / )} diff --git a/ornn-web/src/components/skill/SkillGrid.tsx b/ornn-web/src/components/skill/SkillGrid.tsx index 4f5a066a..c9098d63 100644 --- a/ornn-web/src/components/skill/SkillGrid.tsx +++ b/ornn-web/src/components/skill/SkillGrid.tsx @@ -33,6 +33,7 @@ export function SkillGrid({ skills, isLoading, className = "" }: SkillGridProps) return (
{Array.from({ length: 6 }).map((_, i) => ( + // Positional list — never reorders, key={i} is intentional (#451). ))}
diff --git a/ornn-web/src/components/ui/NeonSkeleton.tsx b/ornn-web/src/components/ui/NeonSkeleton.tsx index 1ff02645..284bb993 100644 --- a/ornn-web/src/components/ui/NeonSkeleton.tsx +++ b/ornn-web/src/components/ui/NeonSkeleton.tsx @@ -67,6 +67,7 @@ export function NeonSkeleton({ return (
{Array.from({ length: lines }).map((_, i) => ( + // Positional list — never reorders, key={i} is intentional (#451).
{Array.from({ length: columns }).map((_, i) => ( + // Positional list — never reorders, key={i} is intentional (#451). {Array.from({ length: count }).map((_, i) => ( + // Positional list — never reorders, key={i} is intentional (#451). ))}
diff --git a/ornn-web/src/pages/ExplorePage.tsx b/ornn-web/src/pages/ExplorePage.tsx index 6a173999..71eb19dc 100644 --- a/ornn-web/src/pages/ExplorePage.tsx +++ b/ornn-web/src/pages/ExplorePage.tsx @@ -281,6 +281,7 @@ export function ExplorePage() { {activeLoading ? (
{Array.from({ length: 6 }).map((_, i) => ( + // Positional list — never reorders, key={i} is intentional (#451). ))}
diff --git a/ornn-web/src/pages/skill/CreateSkillFreePage.tsx b/ornn-web/src/pages/skill/CreateSkillFreePage.tsx index c4e84d0b..47c9a2bf 100644 --- a/ornn-web/src/pages/skill/CreateSkillFreePage.tsx +++ b/ornn-web/src/pages/skill/CreateSkillFreePage.tsx @@ -398,12 +398,15 @@ export function CreateSkillFreePage() { }`} > {validationResult.errors.map((err, i) => ( -

+ // Each error key is unique per validation run; combining + // key with index defends against duplicate keys when the + // same error fires twice (#451). +

{t(err.key, err.params ?? {})}

))} {validationResult.warnings.map((warn, i) => ( -

+

{t(warn.key, warn.params ?? {})}

))} diff --git a/ornn-web/src/pages/skill/MySkillsPage.tsx b/ornn-web/src/pages/skill/MySkillsPage.tsx index 03ef557b..318c20f3 100644 --- a/ornn-web/src/pages/skill/MySkillsPage.tsx +++ b/ornn-web/src/pages/skill/MySkillsPage.tsx @@ -102,6 +102,7 @@ export function MySkillsPage() { {isLoading ? (
{Array.from({ length: 6 }).map((_, i) => ( + // Positional list — never reorders, key={i} is intentional (#451). ))}
From 86c955eb72781f8bcf64b39d00487374bff99d44 Mon Sep 17 00:00:00 2001 From: Wang Shining Date: Tue, 19 May 2026 14:52:45 +0800 Subject: [PATCH 028/128] fix(web): silence auth/analytics/apiClient/activityApi logs in prod (#584) authStore, activityApi, apiClient, and analytics each defined a local logger that called console.log / console.error directly. In production these persisted in browser devtools across the session and leaked auth lifecycle metadata (refresh timing, token expiry, user ids). Introduces ornn-web/src/lib/logger.ts with a createLogger(tag) factory: - In development (import.meta.env.MODE !== 'production') it forwards to console.log/warn/error/debug so DX is unchanged. - In production every level is a no-op. The four call sites are migrated to the shared factory. No behaviour change in dev; nothing reaches devtools in prod. Closes #584. --- .changeset/frontend-logger-584.md | 5 ++++ ornn-web/src/lib/analytics.ts | 10 ++----- ornn-web/src/lib/logger.ts | 44 ++++++++++++++++++++++++++++ ornn-web/src/services/activityApi.ts | 9 ++---- ornn-web/src/services/apiClient.ts | 7 ++--- ornn-web/src/stores/authStore.ts | 8 ++--- 6 files changed, 57 insertions(+), 26 deletions(-) create mode 100644 .changeset/frontend-logger-584.md create mode 100644 ornn-web/src/lib/logger.ts diff --git a/.changeset/frontend-logger-584.md b/.changeset/frontend-logger-584.md new file mode 100644 index 00000000..138cc5d1 --- /dev/null +++ b/.changeset/frontend-logger-584.md @@ -0,0 +1,5 @@ +--- +"ornn-web": patch +--- + +Production-mode console silencing for auth + analytics + apiClient + activityApi (#584). Introduces `ornn-web/src/lib/logger.ts` — a `createLogger(tag)` factory that emits via `console` in development and no-ops in production. Replaces the four ad-hoc per-module loggers that previously leaked auth lifecycle metadata (refresh timing, token expiry, user ids) to browser devtools where it persisted across the session. Dev experience unchanged. diff --git a/ornn-web/src/lib/analytics.ts b/ornn-web/src/lib/analytics.ts index d5fdfae7..f7fa205b 100644 --- a/ornn-web/src/lib/analytics.ts +++ b/ornn-web/src/lib/analytics.ts @@ -23,15 +23,9 @@ import posthog from "posthog-js"; import { config } from "@/config"; import { hasConsent, onConsentChange } from "./cookieConsent"; +import { createLogger } from "./logger"; -const logger = { - info: (msg: string, data?: Record) => - console.log(`[analytics] ${msg}`, data ?? ""), - warn: (msg: string, data?: Record) => - console.warn(`[analytics] ${msg}`, data ?? ""), - error: (msg: string, data?: Record) => - console.error(`[analytics] ${msg}`, data ?? ""), -}; +const logger = createLogger("analytics"); /** * Strongly-typed event names. Matches the spec in #252 — every call site diff --git a/ornn-web/src/lib/logger.ts b/ornn-web/src/lib/logger.ts new file mode 100644 index 00000000..b90cff1a --- /dev/null +++ b/ornn-web/src/lib/logger.ts @@ -0,0 +1,44 @@ +/** + * Frontend logger (#584). + * + * In **development** (`import.meta.env.MODE !== "production"`) every + * level forwards to the corresponding `console` method so debugging + * still works as before. + * + * In **production** all levels are no-ops. Auth lifecycle events, + * analytics ticks, and apiClient errors previously leaked token + * metadata (refresh timing, token expiry, auth user ids) to browser + * devtools where it persisted across the session. + * + * `error` is the only level that always logs in dev; in prod even + * errors are dropped — the right place for production error reporting + * is a real sink (Sentry-style) wired in separately, not browser + * devtools. + * + * Tags are required so the module of origin is visible in dev logs. + * + * @module lib/logger + */ + +const IS_DEV = import.meta.env.MODE !== "production"; + +export interface ScopedLogger { + info: (msg: string, data?: unknown) => void; + warn: (msg: string, data?: unknown) => void; + error: (msg: string, data?: unknown) => void; + debug: (msg: string, data?: unknown) => void; +} + +export function createLogger(tag: string): ScopedLogger { + const prefix = `[${tag}]`; + if (!IS_DEV) { + const noop = () => {}; + return { info: noop, warn: noop, error: noop, debug: noop }; + } + return { + info: (msg, data) => console.log(`${prefix} ${msg}`, data ?? ""), + warn: (msg, data) => console.warn(`${prefix} ${msg}`, data ?? ""), + error: (msg, data) => console.error(`${prefix} ${msg}`, data ?? ""), + debug: (msg, data) => console.debug(`${prefix} ${msg}`, data ?? ""), + }; +} diff --git a/ornn-web/src/services/activityApi.ts b/ornn-web/src/services/activityApi.ts index a264f17d..24a66ef0 100644 --- a/ornn-web/src/services/activityApi.ts +++ b/ornn-web/src/services/activityApi.ts @@ -6,15 +6,10 @@ import { useAuthStore } from "@/stores/authStore"; import { config } from "@/config"; +import { createLogger } from "@/lib/logger"; const API_BASE = config.apiBaseUrl; - -const logger = { - info: (msg: string, data?: Record) => - console.log(`[activityApi] ${msg}`, data ?? ""), - warn: (msg: string, data?: Record) => - console.warn(`[activityApi] ${msg}`, data ?? ""), -}; +const logger = createLogger("activityApi"); /** * Log a user activity (login or logout). diff --git a/ornn-web/src/services/apiClient.ts b/ornn-web/src/services/apiClient.ts index c95a4b98..e01d787a 100644 --- a/ornn-web/src/services/apiClient.ts +++ b/ornn-web/src/services/apiClient.ts @@ -8,12 +8,9 @@ import type { ApiResponse } from "@/types/api"; import { useAuthStore } from "@/stores/authStore"; import { config } from "@/config"; +import { createLogger } from "@/lib/logger"; -const logger = { - error: (msg: string, data?: Record) => - console.error(`[apiClient] ${msg}`, data ?? ""), -}; - +const logger = createLogger("apiClient"); const API_BASE = config.apiBaseUrl; /** diff --git a/ornn-web/src/stores/authStore.ts b/ornn-web/src/stores/authStore.ts index 4eb6c6f2..63bfde96 100644 --- a/ornn-web/src/stores/authStore.ts +++ b/ornn-web/src/stores/authStore.ts @@ -8,13 +8,9 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import type { AuthUser, NyxIDTokenResponse, NyxIDJwtClaims, NyxIDIdTokenClaims } from "@/types/auth"; import { config } from "@/config"; +import { createLogger } from "@/lib/logger"; -const logger = { - info: (msg: string, data?: Record) => - console.log(`[auth] ${msg}`, data ?? ""), - error: (msg: string, data?: Record) => - console.error(`[auth] ${msg}`, data ?? ""), -}; +const logger = createLogger("auth"); /** Refresh token 1 minute before expiry. */ const TOKEN_REFRESH_BUFFER_MS = 60 * 1000; From 8e696acc5acc33da5e9bf0c19145d854ab4369ae Mon Sep 17 00:00:00 2001 From: Wang Shining Date: Tue, 19 May 2026 14:54:35 +0800 Subject: [PATCH 029/128] fix(api): enforce visibility check on /skills/:idOrName/json (#567) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /skills/:idOrName/json previously gated only on ornn:skill:read — a caller who knew a private skill's name could fetch its full package contents through this route, broader than GET /skills/:idOrName which applies canReadSkill against createdBy / sharedWithUsers / sharedWithOrgs + platform-admin. Adds the same visibility check before the package download: 1. Load the skill metadata. 2. If private, build an actor with the caller's memberships + ornn:admin:skill permission. 3. canReadSkill(skill, actor) — on failure throw SKILL_NOT_FOUND (same shape the metadata endpoint uses for inaccessible private skills). Closes the leak surfaced by the aevatar /v1/responses Ornn bridge described in the issue. Closes #567. --- .changeset/json-visibility-567.md | 5 +++++ ornn-api/src/domains/skills/crud/routes.ts | 23 ++++++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 .changeset/json-visibility-567.md diff --git a/.changeset/json-visibility-567.md b/.changeset/json-visibility-567.md new file mode 100644 index 00000000..7b8abd92 --- /dev/null +++ b/.changeset/json-visibility-567.md @@ -0,0 +1,5 @@ +--- +"ornn-api": patch +--- + +Enforce per-skill visibility on `GET /skills/:idOrName/json` (#567). The endpoint previously gated only on `ornn:skill:read`, so a caller who knew a private skill's name could fetch its full package contents through this route — broader than `/skills/:idOrName`, which applies `canReadSkill`. Now the JSON route loads the skill first and runs the same visibility check (`canReadSkill` against `createdBy` / `sharedWithUsers` / `sharedWithOrgs` + platform-admin permission), returning `SKILL_NOT_FOUND` for inaccessible private skills. Closes the leak surfaced by the `aevatar` `/v1/responses` Ornn bridge. diff --git a/ornn-api/src/domains/skills/crud/routes.ts b/ornn-api/src/domains/skills/crud/routes.ts index 447ebeb9..596f3b30 100644 --- a/ornn-api/src/domains/skills/crud/routes.ts +++ b/ornn-api/src/domains/skills/crud/routes.ts @@ -503,6 +503,29 @@ export function createSkillRoutes(config: SkillRoutesConfig): Hono<{ Variables: async (c) => { const idOrName = c.req.param("idOrName"); logger.info({ idOrName }, "Skill jsonize request"); + + // Visibility check (#567) — the package contents endpoint must + // not be more permissive than the metadata endpoint. Load the + // skill first and reject inaccessible private skills with the + // same `SKILL_NOT_FOUND` shape `/skills/:idOrName` uses. + const skill = await skillService.getSkill(idOrName); + if (skill.isPrivate) { + const authCtx = c.get("auth"); + // `requirePermission` above guarantees authCtx is set. + if (!authCtx) { + throw AppError.notFound("SKILL_NOT_FOUND", `Skill '${idOrName}' not found`); + } + const memberships = await readUserOrgMemberships(c); + const actor = { + userId: authCtx.userId, + memberships, + isPlatformAdmin: authCtx.permissions.includes("ornn:admin:skill"), + }; + if (!canReadSkill(skill, actor)) { + throw AppError.notFound("SKILL_NOT_FOUND", `Skill '${idOrName}' not found`); + } + } + const result = await skillService.getSkillJson(idOrName); // Programmatic pull — closest signal to the north-star metric. // Fire-and-forget; the analytics service swallows its own errors. From 924b88daf0fe4bec930a121ebeba12af9e0a3682 Mon Sep 17 00:00:00 2001 From: Wang Shining Date: Tue, 19 May 2026 14:57:10 +0800 Subject: [PATCH 030/128] fix(web): render Mermaid SVG inside a sandboxed iframe (#440) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DocsPage previously injected the rendered Mermaid SVG via dangerouslySetInnerHTML. Mermaid is the only producer today and its input is trusted in-repo markdown, so this is purely defence-in-depth — but if a future code path ever feeds user-controlled diagram source (e.g. user-authored skill READMEs with mermaid blocks), the attack surface flips to active XSS. Adds a SandboxedSvg helper that drops the SVG into an iframe with sandbox="" (the strictest form): - no scripts - no forms - no top navigation - no popups - no same-origin The lightbox pan/zoom is owned by the parent
transform, so the iframe inside just renders the SVG and the interaction model is unchanged. Closes #440. --- .changeset/mermaid-iframe-440.md | 5 +++ ornn-web/src/pages/DocsPage.tsx | 56 ++++++++++++++++++++++++++++---- 2 files changed, 55 insertions(+), 6 deletions(-) create mode 100644 .changeset/mermaid-iframe-440.md diff --git a/.changeset/mermaid-iframe-440.md b/.changeset/mermaid-iframe-440.md new file mode 100644 index 00000000..4b3be468 --- /dev/null +++ b/.changeset/mermaid-iframe-440.md @@ -0,0 +1,5 @@ +--- +"ornn-web": patch +--- + +Render Mermaid SVG inside a sandboxed iframe (#440). The DocsPage previously injected the rendered SVG via `dangerouslySetInnerHTML`. Mermaid is the only producer today and its input is trusted in-repo markdown, so this is purely defence-in-depth — but if any future code path ever feeds user-controlled diagram source (e.g. user-authored skill READMEs with mermaid blocks), the strict `sandbox=""` boundary already prevents script execution, form submission, navigation, and storage access. The lightbox pan/zoom transform is owned by the parent `
`, so interaction is unchanged. diff --git a/ornn-web/src/pages/DocsPage.tsx b/ornn-web/src/pages/DocsPage.tsx index 6cdd7d9d..c18fceef 100644 --- a/ornn-web/src/pages/DocsPage.tsx +++ b/ornn-web/src/pages/DocsPage.tsx @@ -256,6 +256,39 @@ function getMermaidConfig(theme: "dark" | "light") { // Initial init with current theme mermaid.initialize(getMermaidConfig(useThemeStore.getState().theme)); +/* ──────────────── Mermaid sandboxed renderer ──────────────── */ + +/** + * Render an SVG string inside a sandboxed iframe (#440). + * + * Mermaid is the only producer today, and the diagram source comes + * from trusted in-repo markdown — so this is purely defence-in-depth. + * If any future code path ever feeds user-controlled diagram source + * (e.g. user-authored skill READMEs embedding ` ```mermaid `), the + * iframe `sandbox=""` boundary prevents script execution, form + * submission, navigation, and storage access without us having to + * audit Mermaid's output for XSS first. + * + * Layout: the iframe fills its container; the parent owns the + * transform/pan/zoom, so the existing lightbox interactions keep + * working unchanged. + */ +function SandboxedSvg({ svg, className }: { svg: string; className?: string }) { + // Strict sandbox: no scripts, no forms, no top navigation, no + // popups, no same-origin. The iframe can render the SVG and that's + // all. + const srcDoc = `${svg}`; + return ( +