diff --git a/.changeset/assistant-mascot-ux.md b/.changeset/assistant-mascot-ux.md
new file mode 100644
index 00000000..d146e336
--- /dev/null
+++ b/.changeset/assistant-mascot-ux.md
@@ -0,0 +1,5 @@
+---
+"ornn-web": minor
+---
+
+Ornn Assistant is now a branded, always-available presence: the widget appears for anonymous visitors (including the landing page) as a draggable, animated Ornn-mascot launcher, auto-expands once on a first visit, and prompts sign-in when a logged-out visitor tries to send. Backend remains authenticated-only.
diff --git a/.changeset/auto-816-user-directory-enumeration.md b/.changeset/auto-816-user-directory-enumeration.md
new file mode 100644
index 00000000..77a0237b
--- /dev/null
+++ b/.changeset/auto-816-user-directory-enumeration.md
@@ -0,0 +1,6 @@
+---
+"ornn-api": patch
+"ornn-web": patch
+---
+
+Harden the user-directory endpoints against enumeration: /users/search now rejects empty and single-character queries, and both /users/search and /users/resolve are rate-limited per user. The collaborator typeahead asks for at least 2 characters before searching.
diff --git a/.changeset/big-moments-unite.md b/.changeset/big-moments-unite.md
new file mode 100644
index 00000000..a845151c
--- /dev/null
+++ b/.changeset/big-moments-unite.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/.changeset/chat-completion-tool-calls-608.md b/.changeset/chat-completion-tool-calls-608.md
new file mode 100644
index 00000000..6d362e9f
--- /dev/null
+++ b/.changeset/chat-completion-tool-calls-608.md
@@ -0,0 +1,27 @@
+---
+"ornn-api": patch
+---
+
+NyxLlmClient now normalizes Chat Completions tool-call deltas into the same Responses-API `response.output_item.done` / `function_call` events the playground tool loop already consumes (#608).
+
+Background: #574 routed chat-completion providers to `/chat/completions` and translated text deltas, but the stream parser ignored `choices[].delta.tool_calls`. The playground tool-use loop in `chatService.ts` only matches on Responses-API `response.output_item.done` with `item.type === "function_call"`, so when a chat-completion provider (DeepSeek, Together, any OpenAI-compat gateway) responded with a tool call, no `function_call` event ever reached the loop. The model's `execute_in_sandbox(...)` invocation arrived as plain assistant text and got rendered as JSON in the chat instead of being executed — runtime-based and mixed skills appeared to "respond" without ever running the sandbox.
+
+Fix: `parseChatCompletionStream` keeps a per-index `Map
- The skill lifecycle API for AI agents, not another marketplace.
+
+
+
+
+
+
+
The agent-facing skill-lifecycle API for AI agents.
-Ornn is an **agent-facing skill-lifecycle API**. AI agents call Ornn directly — over HTTPS — to manage the full lifecycle of their skills: ++ Ornn official website — ornn.chrono-ai.fun +
-``` -search → pull → install → execute → audit → build → upload → share -``` ++ What is Ornn · + How it works · + SDK quickstart · + Quickstart · + Try Ornn free · + How Ornn compares · + Examples · + Docs · + Roadmap · + Community · + Contributing +
-Closest analog: **npm registry + npm CLI, fused, model-agnostic.** It works for Claude, GPT, Gemini, or any custom agent runtime. Not locked to a single model. +--- -### Why we built it +## What is Ornn -Modern AI agents do real work by composing **skills** — packaged prompts, scripts, and tools the agent invokes on demand. As soon as you build more than one agent, the same gaps show up: +Ornn is an **agent-facing skill-lifecycle API**, not a human marketplace. -- **No shared registry.** Skills live in private repos, gists, and one-off config files. There's no way for an agent to discover one it doesn't already know about. -- **Model-locked alternatives.** Anthropic Skills, OpenAI custom GPTs, and Gemini Gems each ship a registry — but only for their own runtime. Skills don't cross. -- **No lifecycle.** Versioning, sandboxed execution, security audit, publish — every team rebuilds these from scratch. +- 🤖 **Agents call it directly** — over HTTP or MCP, no human-in-the-loop UI required. +- 🌐 **Model + runtime agnostic** — Claude, GPT, Gemini, or your own runtime; stable schemas so swapping doesn't break the stack. +- 🔁 **Whole lifecycle in one API** — `search → pull → install → execute → build → upload → share`. -Ornn closes the gaps. One model-agnostic registry, one API surface, and a CLI (`nyxid`) every agent can drive end-to-end. The web UI at [ornn.chrono-ai.fun](https://ornn.chrono-ai.fun) is a thin admin layer for skill owners; the API is the product. +Closest analog: **npm registry + npm CLI fused, model-agnostic**. The primary consumer is the AI agent developer / agentic-system builder; `ornn-web` is a secondary surface for skill owners and platform admins. ## How it works @@ -111,7 +129,17 @@ Open [**`ornn-agent-manual-cli`**](https://ornn.chrono-ai.fun/skills/ornn-agent- Partway through setup, your agent will prompt you to install [**`nyxid`**](https://github.com/ChronoAIProject/NyxID) — the CLI Ornn calls under the hood to broker authenticated requests. Approve the prompt; the agent finishes onboarding itself. -### 3. Talk to your agent +## Try Ornn free + +Early-user perk to test the full Playground + Skill Generation flow without a credit card: + +1. ⭐ Star this repo +2. Sign in to [ornn.chrono-ai.fun](https://ornn.chrono-ai.fun) with the same GitHub account +3. On first sign-in, enter NyxID invite code **`NYX-2XXJI08A`** + +Your redemption code lands in the Ornn notification inbox within 24h. **First 500 users · 400 free GPT-5.5 conversations** (200 Playground + 200 Skill Generation, no card, no expiry). + +## How Ornn compares That's it. Your agent now has the full Ornn lifecycle. Try any of these in plain language — no special syntax, no flags to memorise: @@ -148,6 +176,10 @@ For the full API contract (every endpoint, every error code), see [**ornn.chrono - **Support guide** → [SUPPORT.md](SUPPORT.md) - **Pull requests** → read [CONTRIBUTING.md](CONTRIBUTING.md) first — it covers the issue-first workflow, branching, commit decomposition, and the changeset rule (CI blocks PRs without one). By participating you agree to follow our [Code of Conduct](CODE_OF_CONDUCT.md). +## Like what you see? + +⭐ If Ornn looks like the primitive your agent stack has been missing, a star helps a lot at this stage — it tells us we're solving a real problem, and it's the threshold most awesome-list maintainers check before accepting a project. + ## License [Apache License 2.0](LICENSE) diff --git a/bun.lock b/bun.lock index ed5878f1..e5a7438f 100644 --- a/bun.lock +++ b/bun.lock @@ -8,25 +8,25 @@ "@changesets/changelog-github": "^0.7.0", "@changesets/cli": "^2.30.0", "@eslint/js": "^10.0.1", - "eslint": "^10.3.0", + "eslint": "^10.4.1", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", - "typescript-eslint": "^8.58.1", + "typescript-eslint": "^8.61.0", }, }, "ornn-api": { "name": "ornn-api", - "version": "0.6.0", + "version": "0.11.0", "dependencies": { "@agendajs/mongo-backend": "^4.0.2", "agenda": "^6.2.5", "cron-parser": "^5.5.0", - "hono": "^4.12.18", + "hono": "^4.12.25", "jszip": "^3.10.1", - "mongodb": "^7.0.0", + "mongodb": "^7.3.0", "pino": "^10.3.1", "pino-pretty": "^13.1.3", - "posthog-node": "^5.33.2", + "posthog-node": "^5.36.9", "yaml": "^2.9.0", "zod": "^4.4.3", "zod-to-json-schema": "^3.25.1", @@ -34,38 +34,39 @@ "devDependencies": { "@types/bun": "latest", "bun-types": "^1.3.9", - "mongodb-memory-server": "^11.0.1", + "mongodb-memory-server": "^11.2.0", "typescript": "^6.0.0", }, }, "ornn-web": { "name": "ornn-web", - "version": "0.6.0", + "version": "0.11.0", "dependencies": { - "@hookform/resolvers": "^5.2.2", - "@tanstack/react-query": "^5.62.0", + "@hookform/resolvers": "^5.4.0", + "@tanstack/react-query": "^5.101.0", + "@xyflow/react": "^12.11.0", "cron-parser": "^5.5.0", "diff": "^9.0.0", - "framer-motion": "^12.38.0", + "framer-motion": "^12.40.0", "highlight.js": "^11.10.0", - "i18next": "^26.1.0", + "i18next": "^26.3.1", "jszip": "^3.10.1", "mermaid": "^11.15.0", - "posthog-js": "^1.373.2", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "react-hook-form": "^7.54.0", - "react-i18next": "^17.0.7", + "posthog-js": "^1.384.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-hook-form": "^7.78.0", + "react-i18next": "^17.0.8", "react-markdown": "^10.1.0", - "react-router": "^7.1.0", - "react-router-dom": "^7.1.0", + "react-router": "^7.17.0", + "react-router-dom": "^7.17.0", "recharts": "^3.8.1", "rehype-highlight": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.0", "yaml": "^2.9.0", "zod": "^4.4.3", - "zustand": "^5.0.0", + "zustand": "^5.0.14", }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", @@ -74,24 +75,24 @@ "@testing-library/user-event": "^14.6.1", "@types/diff": "^8.0.0", "@types/jszip": "^3.4.1", - "@types/react": "^19.0.0", + "@types/react": "^19.2.17", "@types/react-dom": "^19.0.0", - "@vitejs/plugin-react": "^6.0.1", - "@vitest/coverage-v8": "^4.1.5", + "@vitejs/plugin-react": "^6.0.2", + "@vitest/coverage-v8": "^4.1.8", "jsdom": "^29.0.2", "tailwindcss": "^4.0.0", "typescript": "^6.0.0", - "vite": "^8.0.12", - "vitest": "^4.1.5", + "vite": "^8.0.16", + "vitest": "^4.1.8", }, }, "sdk/typescript": { "name": "@chronoai/ornn-sdk", - "version": "0.2.1", + "version": "0.3.1", "devDependencies": { "@types/bun": "latest", "typescript": "^6.0.0", - "vitest": "^4.1.5", + "vitest": "^4.1.8", }, }, }, @@ -236,7 +237,7 @@ "@exodus/bytes": ["@exodus/bytes@1.15.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ=="], - "@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="], + "@hookform/resolvers": ["@hookform/resolvers@5.4.0", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw=="], "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], @@ -280,89 +281,47 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], - - "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], - - "@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="], - - "@opentelemetry/exporter-logs-otlp-http": ["@opentelemetry/exporter-logs-otlp-http@0.208.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.208.0", "@opentelemetry/core": "2.2.0", "@opentelemetry/otlp-exporter-base": "0.208.0", "@opentelemetry/otlp-transformer": "0.208.0", "@opentelemetry/sdk-logs": "0.208.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg=="], - - "@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.208.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/otlp-transformer": "0.208.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA=="], - - "@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.208.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.208.0", "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/sdk-logs": "0.208.0", "@opentelemetry/sdk-metrics": "2.2.0", "@opentelemetry/sdk-trace-base": "2.2.0", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ=="], - - "@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="], - - "@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.208.0", "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA=="], - - "@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw=="], - - "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw=="], - - "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - - "@oxc-project/types": ["@oxc-project/types@0.129.0", "", {}, "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg=="], + "@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="], "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], - "@posthog/core": ["@posthog/core@1.28.7", "", { "dependencies": { "@posthog/types": "1.373.2" } }, "sha512-JmV2wN5sE7u2JWxwNNw6CBrPu5xDzIAMWR9zKBar8Pk/8TRrvbFPlXehap8xOtDslfnilY+/urpHeVHpbXMo4w=="], - - "@posthog/types": ["@posthog/types@1.373.2", "", {}, "sha512-6o0AARB7OakxsrQiVeMow/m1QPnsI0Cdm7g0o5mNjVSLH/sU1MuTqckNQDLzImv++MzW0+Gyvq44cgwt3wP/Pw=="], - - "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], - - "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], - - "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="], - - "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="], - - "@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="], - - "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + "@posthog/core": ["@posthog/core@1.31.0", "", { "dependencies": { "@posthog/types": "1.384.0" } }, "sha512-tsZ/pyDy7AXUoPe4Skg/ybozerNPzmHxTzE8gyr8CSajkN0/YXRj8BVEaR8hoqpq7G5B3lUFxbqriNvV6NdPJw=="], - "@protobufjs/inquire": ["@protobufjs/inquire@1.1.1", "", {}, "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew=="], - - "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], - - "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], - - "@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="], + "@posthog/types": ["@posthog/types@1.384.0", "", {}, "sha512-A/KtSocfu6h8ocwOQ1WUme32xdmCFftUN7ziRSYvAahgXaJl3QGizjiRo+Kh60zS8k2tJgzlvCbXB3TqSuY5eg=="], "@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="], - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0", "", { "os": "android", "cpu": "arm64" }, "sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.3", "", { "os": "android", "cpu": "arm64" }, "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw=="], - "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew=="], + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA=="], - "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ=="], + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg=="], - "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ=="], + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g=="], - "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0", "", { "os": "linux", "cpu": "arm" }, "sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A=="], + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw=="], - "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ=="], + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw=="], - "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA=="], + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q=="], - "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg=="], + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg=="], - "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA=="], + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg=="], - "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA=="], + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg=="], - "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0", "", { "os": "linux", "cpu": "x64" }, "sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw=="], + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow=="], - "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0", "", { "os": "none", "cpu": "arm64" }, "sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig=="], + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.3", "", { "os": "none", "cpu": "arm64" }, "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg=="], - "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg=="], + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.3", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg=="], - "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow=="], + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g=="], - "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0", "", { "os": "win32", "cpu": "x64" }, "sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg=="], + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA=="], - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0", "", {}, "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -398,9 +357,9 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.3.0", "", { "dependencies": { "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "tailwindcss": "4.3.0" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw=="], - "@tanstack/query-core": ["@tanstack/query-core@5.100.10", "", {}, "sha512-8UR0yJR+GiQ40m3lPhUr0xbfAupe6GSQiksSBSa9SM2NjezFyxXCIA69/lz8cSoNKZLrw1/PktIyQBJcVeMi3w=="], + "@tanstack/query-core": ["@tanstack/query-core@5.101.0", "", {}, "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow=="], - "@tanstack/react-query": ["@tanstack/react-query@5.100.10", "", { "dependencies": { "@tanstack/query-core": "5.100.10" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-FLaZf2RCrA/Zgp4aiu5tG3TyasTRO7aZ99skxQpr3Hg/zXOhu6yq5FZCYQ/tRaJtM9ylnoK8tFK7PolXQadv6Q=="], + "@tanstack/react-query": ["@tanstack/react-query@5.101.0", "", { "dependencies": { "@tanstack/query-core": "5.101.0" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg=="], "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], @@ -414,7 +373,7 @@ "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], - "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], @@ -506,7 +465,7 @@ "@types/node": ["@types/node@25.7.0", "", { "dependencies": { "undici-types": "~7.21.0" } }, "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg=="], - "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -520,47 +479,51 @@ "@types/whatwg-url": ["@types/whatwg-url@13.0.0", "", { "dependencies": { "@types/webidl-conversions": "*" } }, "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.3", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/type-utils": "8.59.3", "@typescript-eslint/utils": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.3", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.61.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/type-utils": "8.61.0", "@typescript-eslint/utils": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.61.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.3", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.61.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/types": "8.61.0", "@typescript-eslint/typescript-estree": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.3", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.3", "@typescript-eslint/types": "^8.59.3", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.61.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.61.0", "@typescript-eslint/types": "^8.61.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3" } }, "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.61.0", "", { "dependencies": { "@typescript-eslint/types": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.0" } }, "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.3", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.61.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3", "@typescript-eslint/utils": "8.59.3", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.61.0", "", { "dependencies": { "@typescript-eslint/types": "8.61.0", "@typescript-eslint/typescript-estree": "8.61.0", "@typescript-eslint/utils": "8.61.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.59.3", "", {}, "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.61.0", "", {}, "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.3", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.3", "@typescript-eslint/tsconfig-utils": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.61.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.61.0", "@typescript-eslint/tsconfig-utils": "8.61.0", "@typescript-eslint/types": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.61.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/types": "8.61.0", "@typescript-eslint/typescript-estree": "8.61.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.61.0", "", { "dependencies": { "@typescript-eslint/types": "8.61.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="], "@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="], - "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.2", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.0" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg=="], + + "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.8", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.8", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.8", "vitest": "4.1.8" }, "optionalPeers": ["@vitest/browser"] }, "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw=="], + + "@vitest/expect": ["@vitest/expect@4.1.8", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.8", "@vitest/utils": "4.1.8", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ=="], - "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.6", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.6", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.6", "vitest": "4.1.6" }, "optionalPeers": ["@vitest/browser"] }, "sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ=="], + "@vitest/mocker": ["@vitest/mocker@4.1.8", "", { "dependencies": { "@vitest/spy": "4.1.8", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw=="], - "@vitest/expect": ["@vitest/expect@4.1.6", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.6", "@vitest/utils": "4.1.6", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg=="], + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.8", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA=="], - "@vitest/mocker": ["@vitest/mocker@4.1.6", "", { "dependencies": { "@vitest/spy": "4.1.6", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ=="], + "@vitest/runner": ["@vitest/runner@4.1.8", "", { "dependencies": { "@vitest/utils": "4.1.8", "pathe": "^2.0.3" } }, "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg=="], - "@vitest/pretty-format": ["@vitest/pretty-format@4.1.6", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw=="], + "@vitest/snapshot": ["@vitest/snapshot@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "@vitest/utils": "4.1.8", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ=="], - "@vitest/runner": ["@vitest/runner@4.1.6", "", { "dependencies": { "@vitest/utils": "4.1.6", "pathe": "^2.0.3" } }, "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA=="], + "@vitest/spy": ["@vitest/spy@4.1.8", "", {}, "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA=="], - "@vitest/snapshot": ["@vitest/snapshot@4.1.6", "", { "dependencies": { "@vitest/pretty-format": "4.1.6", "@vitest/utils": "4.1.6", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw=="], + "@vitest/utils": ["@vitest/utils@4.1.8", "", { "dependencies": { "@vitest/pretty-format": "4.1.8", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg=="], - "@vitest/spy": ["@vitest/spy@4.1.6", "", {}, "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg=="], + "@xyflow/react": ["@xyflow/react@12.11.0", "", { "dependencies": { "@xyflow/system": "0.0.77", "classcat": "^5.0.3", "zustand": "^4.4.0" }, "peerDependencies": { "@types/react": ">=17", "@types/react-dom": ">=17", "react": ">=17", "react-dom": ">=17" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-na4IO33FSs2OS72hASgZDmTYwFAkef7Z74uBUVrong3ARmQQHfnRUVaCFn1kTt5LbS6pK03TbYjCPGLjLFfziA=="], - "@vitest/utils": ["@vitest/utils@4.1.6", "", { "dependencies": { "@vitest/pretty-format": "4.1.6", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ=="], + "@xyflow/system": ["@xyflow/system@0.0.77", "", { "dependencies": { "@types/d3-drag": "^3.0.7", "@types/d3-interpolate": "^3.0.4", "@types/d3-selection": "^3.0.10", "@types/d3-transition": "^3.0.8", "@types/d3-zoom": "^3.0.8", "d3-drag": "^3.0.0", "d3-interpolate": "^3.0.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" } }, "sha512-qCDCMCQAAgUu8yHnhloHG9F5mwPX5E+Wl8McpYIOPSSXfzFJJoZcwOcsDiAjitVKIg2de1WmJbCHfpcvxprsgg=="], "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], @@ -624,9 +587,7 @@ "bson": ["bson@7.2.0", "", {}, "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ=="], - "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], - - "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], @@ -646,6 +607,8 @@ "chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="], + "classcat": ["classcat@5.0.5", "", {}, "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], @@ -876,7 +839,7 @@ "follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], - "framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="], + "framer-motion": ["framer-motion@12.40.0", "", { "dependencies": { "motion-dom": "^12.40.0", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg=="], "fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], @@ -912,7 +875,7 @@ "highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="], - "hono": ["hono@4.12.18", "", {}, "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ=="], + "hono": ["hono@4.12.25", "", {}, "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ=="], "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], @@ -928,7 +891,7 @@ "human-interval": ["human-interval@2.0.1", "", { "dependencies": { "numbered": "^1.1.0" } }, "sha512-r4Aotzf+OtKIGQCB3odUowy4GfUDTy3aTWTfLd7ZF2gBCy3XW3v/dJLRefZnOFFnjqs5B1TypvS8WarpBkYUNQ=="], - "i18next": ["i18next@26.1.0", "", { "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-dIU6td04DvQuIqVst5S9g0GviTmhZ0DYD4b9ociVGJmuCa5vZ2de/t+Enf4olvj87mF8Y2lwjNQBwC9QZsvzKQ=="], + "i18next": ["i18next@26.3.1", "", { "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ=="], "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], @@ -1048,8 +1011,6 @@ "lodash.startcase": ["lodash.startcase@4.4.0", "", {}, "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg=="], - "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], - "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], "lowlight": ["lowlight@3.3.0", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "highlight.js": "~11.11.0" } }, "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ=="], @@ -1172,17 +1133,17 @@ "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], - "mongodb": ["mongodb@7.2.0", "", { "dependencies": { "@mongodb-js/saslprep": "^1.3.0", "bson": "^7.2.0", "mongodb-connection-string-url": "^7.0.0" }, "peerDependencies": { "@aws-sdk/credential-providers": "^3.806.0", "@mongodb-js/zstd": "^7.0.0", "gcp-metadata": "^7.0.1", "kerberos": "^7.0.0", "mongodb-client-encryption": ">=7.0.0 <7.1.0", "snappy": "^7.3.2", "socks": "^2.8.6" }, "optionalPeers": ["@aws-sdk/credential-providers", "@mongodb-js/zstd", "gcp-metadata", "kerberos", "mongodb-client-encryption", "snappy", "socks"] }, "sha512-F/2+BMZtLVhY30ioZp0dAmZ+IRZMBqI+nrv6t5+9/1AIwCa8sMRC3jBf81lpxMhnZgqq8CoUD503Z1oZWq1/sw=="], + "mongodb": ["mongodb@7.3.0", "", { "dependencies": { "@mongodb-js/saslprep": "^1.3.0", "bson": "^7.2.0", "mongodb-connection-string-url": "^7.0.0" }, "peerDependencies": { "@aws-sdk/credential-providers": "^3.806.0", "@mongodb-js/zstd": "^7.0.0", "gcp-metadata": "^7.0.1", "kerberos": "^7.0.0", "mongodb-client-encryption": ">=7.0.0 <7.1.0", "snappy": "^7.3.2", "socks": "^2.8.6" }, "optionalPeers": ["@aws-sdk/credential-providers", "@mongodb-js/zstd", "gcp-metadata", "kerberos", "mongodb-client-encryption", "snappy", "socks"] }, "sha512-WpCqSx7JAU9vcyjm/SU7ydnHls2YrfU3Y3sx4Ml9D7sPe4mXPlaapndiurDXrQ7/VvJkB4/i7b7WovHb8bd8sg=="], "mongodb-connection-string-url": ["mongodb-connection-string-url@7.0.1", "", { "dependencies": { "@types/whatwg-url": "^13.0.0", "whatwg-url": "^14.1.0" } }, "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ=="], - "mongodb-memory-server": ["mongodb-memory-server@11.1.0", "", { "dependencies": { "mongodb-memory-server-core": "11.1.0", "tslib": "^2.8.1" } }, "sha512-x9psV1KXRgG5t14AmsrfcWCqlNXvPOzcyroMSeRU5vkAm8jxEF5WiLGdGCONLOgeCNjRnpg6igyDum/eTwiooA=="], + "mongodb-memory-server": ["mongodb-memory-server@11.2.0", "", { "dependencies": { "mongodb-memory-server-core": "11.2.0", "tslib": "^2.8.1" } }, "sha512-506AD8qvClVx8Raw/WhAUUWBgIXPyi856iC01aa5vAzHmn6WOXC6ulvudkTF7oTMzJxkyA0A84VpD4BpyfqJ9w=="], - "mongodb-memory-server-core": ["mongodb-memory-server-core@11.1.0", "", { "dependencies": { "async-mutex": "^0.5.0", "camelcase": "^6.3.0", "debug": "^4.4.3", "find-cache-dir": "^3.3.2", "follow-redirects": "^1.16.0", "https-proxy-agent": "^7.0.6", "mongodb": "^7.2.0", "new-find-package-json": "^2.0.0", "semver": "^7.7.3", "tar-stream": "^3.1.8", "tslib": "^2.8.1", "yauzl": "^3.3.0" } }, "sha512-GwpnJVIiUyXdi5BoTsExrvLupSt3sJzCSX5P6fxlr0dCrJkhumiq8SQIqtTBqTu2mMpFMCHdjSS0QMUvFMpbWw=="], + "mongodb-memory-server-core": ["mongodb-memory-server-core@11.2.0", "", { "dependencies": { "async-mutex": "^0.5.0", "camelcase": "^6.3.0", "debug": "^4.4.3", "find-cache-dir": "^3.3.2", "follow-redirects": "^1.16.0", "https-proxy-agent": "^7.0.6", "mongodb": "^7.2.0", "new-find-package-json": "^2.0.0", "semver": "^7.7.3", "tar-stream": "^3.1.8", "tslib": "^2.8.1", "yauzl": "^3.3.1" } }, "sha512-vOoDtn0JiLrHvZY81Rp/UtKXXK0rtJHZGZFVnccvJwYitPLNspO0Ty0grqFQOe7iAET8+GI4zAQcphg+R3vxQg=="], - "motion-dom": ["motion-dom@12.38.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="], + "motion-dom": ["motion-dom@12.40.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg=="], - "motion-utils": ["motion-utils@12.36.0", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="], + "motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="], "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], @@ -1266,9 +1227,9 @@ "postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="], - "posthog-js": ["posthog-js@1.373.2", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.208.0", "@opentelemetry/exporter-logs-otlp-http": "^0.208.0", "@opentelemetry/resources": "^2.2.0", "@opentelemetry/sdk-logs": "^0.208.0", "@posthog/core": "1.28.7", "@posthog/types": "1.373.2", "core-js": "^3.38.1", "dompurify": "^3.3.2", "fflate": "^0.4.8", "preact": "^10.28.2", "query-selector-shadow-dom": "^1.0.1", "web-vitals": "^5.1.0" } }, "sha512-wi9LjL+67iQsUPE4PtGp3SASWksYy0Nmo1F0Te9jDGn0wTAK5oIIFF+JxgM8II518wH5xJ2kSlyGqcrjcNFFAw=="], + "posthog-js": ["posthog-js@1.384.0", "", { "dependencies": { "@posthog/core": "1.31.0", "@posthog/types": "1.384.0", "core-js": "^3.38.1", "dompurify": "^3.3.2", "fflate": "^0.4.8", "preact": "^10.28.2", "query-selector-shadow-dom": "^1.0.1", "web-vitals": "^5.1.0" } }, "sha512-XXItua9oTjo8AIikSMIQJgOCaiX0MwRq2wIZQjp14MWLmLKWuTjC19fpmWlrEUNc8D5wICdEOsoB2fMwSN6uOQ=="], - "posthog-node": ["posthog-node@5.33.7", "", { "dependencies": { "@posthog/core": "1.28.7" }, "peerDependencies": { "rxjs": "^7.0.0" }, "optionalPeers": ["rxjs"] }, "sha512-jxDLWJ6eMk93cAaZYeTHSGyHAIH2wPPm9EFOnoJb/GfJfjqIZe89NvSDqOYOqclTkImW3M/Js92yZVo1TKpYXA=="], + "posthog-node": ["posthog-node@5.36.9", "", { "dependencies": { "@posthog/core": "1.31.0" }, "peerDependencies": { "rxjs": "^7.0.0" }, "optionalPeers": ["rxjs"] }, "sha512-2EpDTF8peAJzJSFLUWfJu7MT5XRSoXd8ifsUCrk/sys+qRniTnrLK8f/7wRypwkV8RLwh86hqvzFay55qL6ASA=="], "preact": ["preact@10.29.1", "", {}, "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg=="], @@ -1284,8 +1245,6 @@ "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], - "protobufjs": ["protobufjs@7.5.7", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.1", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-NGnrxS/nLKUo5nkbVQxlC71sB4hdfImdYIbFeSCidxtwATx0AHRPcANSLd0q5Bb2BkoSWo2iisQhGg5/r+ihbA=="], - "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], @@ -1298,13 +1257,13 @@ "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], - "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], + "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], - "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], + "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], - "react-hook-form": ["react-hook-form@7.75.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-Ovv94H+0p3sJ7B9B5QxPuCP1u8V/cHuVGyH55cSwodYDtoJwK+fqk3vjfIgSX59I2U/bU4z0nRJ9HMLpNiWEmw=="], + "react-hook-form": ["react-hook-form@7.78.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-EEZqc+N23moyzTlz61Pj+JvcXo76ICkpfOZo8JZw+sM4+wLQGh6nI2Ms+PdMOYNluFu0ghlM7B8mCzhRYtJCnA=="], - "react-i18next": ["react-i18next@17.0.7", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.0.10", "react": ">= 16.8.0", "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-rwtPXsb/zwzDafN+gytcjF5YnqGQQIRmCQ6DctBC1VSipRB8GD/MWEVrFP42vjMyuYydxWxM8CZRt+yiNuuoHg=="], + "react-i18next": ["react-i18next@17.0.8", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.2.0", "react": ">= 16.8.0", "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw=="], "react-is": ["react-is@19.2.6", "", {}, "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw=="], @@ -1312,9 +1271,9 @@ "react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="], - "react-router": ["react-router@7.15.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ=="], + "react-router": ["react-router@7.17.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ=="], - "react-router-dom": ["react-router-dom@7.15.0", "", { "dependencies": { "react-router": "7.15.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-VcrVg64Fo8nwBvDscajG8gRTLIuTC6N50nb22l2HOOV4PTOHgoGp8mUjy9wLiHYoYTSYI36tUnXZgasSRFZorQ=="], + "react-router-dom": ["react-router-dom@7.17.0", "", { "dependencies": { "react-router": "7.17.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw=="], "read-yaml-file": ["read-yaml-file@1.1.0", "", { "dependencies": { "graceful-fs": "^4.1.5", "js-yaml": "^3.6.1", "pify": "^4.0.1", "strip-bom": "^3.0.0" } }, "sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA=="], @@ -1352,7 +1311,7 @@ "robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="], - "rolldown": ["rolldown@1.0.0", "", { "dependencies": { "@oxc-project/types": "=0.129.0", "@rolldown/pluginutils": "1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0", "@rolldown/binding-darwin-arm64": "1.0.0", "@rolldown/binding-darwin-x64": "1.0.0", "@rolldown/binding-freebsd-x64": "1.0.0", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0", "@rolldown/binding-linux-arm64-gnu": "1.0.0", "@rolldown/binding-linux-arm64-musl": "1.0.0", "@rolldown/binding-linux-ppc64-gnu": "1.0.0", "@rolldown/binding-linux-s390x-gnu": "1.0.0", "@rolldown/binding-linux-x64-gnu": "1.0.0", "@rolldown/binding-linux-x64-musl": "1.0.0", "@rolldown/binding-openharmony-arm64": "1.0.0", "@rolldown/binding-wasm32-wasi": "1.0.0", "@rolldown/binding-win32-arm64-msvc": "1.0.0", "@rolldown/binding-win32-x64-msvc": "1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA=="], + "rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="], "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], @@ -1450,7 +1409,7 @@ "tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], - "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], @@ -1478,7 +1437,7 @@ "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], - "typescript-eslint": ["typescript-eslint@8.59.3", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.3", "@typescript-eslint/parser": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3", "@typescript-eslint/utils": "8.59.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg=="], + "typescript-eslint": ["typescript-eslint@8.61.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.61.0", "@typescript-eslint/parser": "8.61.0", "@typescript-eslint/typescript-estree": "8.61.0", "@typescript-eslint/utils": "8.61.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw=="], "undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="], @@ -1516,9 +1475,9 @@ "victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="], - "vite": ["vite@8.0.12", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.14", "rolldown": "1.0.0", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg=="], + "vite": ["vite@8.0.16", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw=="], - "vitest": ["vitest@4.1.6", "", { "dependencies": { "@vitest/expect": "4.1.6", "@vitest/mocker": "4.1.6", "@vitest/pretty-format": "4.1.6", "@vitest/runner": "4.1.6", "@vitest/snapshot": "4.1.6", "@vitest/spy": "4.1.6", "@vitest/utils": "4.1.6", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.6", "@vitest/browser-preview": "4.1.6", "@vitest/browser-webdriverio": "4.1.6", "@vitest/coverage-istanbul": "4.1.6", "@vitest/coverage-v8": "4.1.6", "@vitest/ui": "4.1.6", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ=="], + "vitest": ["vitest@4.1.8", "", { "dependencies": { "@vitest/expect": "4.1.8", "@vitest/mocker": "4.1.8", "@vitest/pretty-format": "4.1.8", "@vitest/runner": "4.1.8", "@vitest/snapshot": "4.1.8", "@vitest/spy": "4.1.8", "@vitest/utils": "4.1.8", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.8", "@vitest/browser-preview": "4.1.8", "@vitest/browser-webdriverio": "4.1.8", "@vitest/coverage-istanbul": "4.1.8", "@vitest/coverage-v8": "4.1.8", "@vitest/ui": "4.1.8", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig=="], "void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="], @@ -1548,7 +1507,7 @@ "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], - "yauzl": ["yauzl@3.3.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "pend": "~1.2.0" } }, "sha512-PtGEvEP30p7sbIBJKUBjUnqgTVOyMURc4dLo9iNyAJnNIEz9pm88cCXF21w94Kg3k6RXkeZh5DHOGS0qEONvNQ=="], + "yauzl": ["yauzl@3.4.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], @@ -1558,7 +1517,7 @@ "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], - "zustand": ["zustand@5.0.13", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ=="], + "zustand": ["zustand@5.0.14", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g=="], "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], @@ -1584,16 +1543,6 @@ "@manypkg/get-packages/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], - "@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="], - - "@opentelemetry/resources/@opentelemetry/core": ["@opentelemetry/core@2.7.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw=="], - - "@opentelemetry/sdk-logs/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="], - - "@opentelemetry/sdk-metrics/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="], - - "@opentelemetry/sdk-trace-base/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="], - "@reduxjs/toolkit/immer": ["immer@11.1.8", "", {}, "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], @@ -1614,6 +1563,10 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + "@typescript-eslint/typescript-estree/tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + + "@xyflow/react/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], + "cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="], "d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], @@ -1646,8 +1599,6 @@ "read-yaml-file/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], - "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0", "", {}, "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ=="], - "thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="], "@manypkg/find-root/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md index 1e93d66e..3e708563 100644 --- a/docs/CONVENTIONS.md +++ b/docs/CONVENTIONS.md @@ -159,6 +159,73 @@ Reserved action verbs per resource documented in `ornn-api/src/shared/reservedVe Both return the same collection shape (`{ items, meta }`). +### 2.5 Skill dependency closure (#968) + +``` +GET /v1/skills/{idOrName}/closure[?version=
+
+
+
+
+
+
+
+
The agent-facing skill-lifecycle API for AI agents.
+ ++ Ornn official website — ornn.chrono-ai.fun +
+ ++ What is Ornn · + How it works · + SDK quickstart · + Quickstart · + Try Ornn free · + How Ornn compares · + Examples · + Docs · + Roadmap · + Community · + Contributing +
+ +--- + +## What is Ornn + +Ornn is an **agent-facing skill-lifecycle API**, not a human marketplace. + +- 🤖 **Agents call it directly** — over HTTP or MCP, no human-in-the-loop UI required. +- 🌐 **Model + runtime agnostic** — Claude, GPT, Gemini, or your own runtime; stable schemas so swapping doesn't break the stack. +- 🔁 **Whole lifecycle in one API** — `search → pull → install → execute → build → upload → share`. + +Closest analog: **npm registry + npm CLI fused, model-agnostic**. The primary consumer is the AI agent developer / agentic-system builder; `ornn-web` is a secondary surface for skill owners and platform admins. + +## How it works + +```mermaid +%%{init: { + "theme": "base", + "themeVariables": { + "background": "#0B0907", + "primaryColor": "#1A1610", + "primaryTextColor": "#F1ECDE", + "primaryBorderColor": "#3A3328", + "lineColor": "#7E776B", + "secondaryColor": "#221E16", + "tertiaryColor": "#14110B", + "edgeLabelBackground": "#0B0907", + "clusterBkg": "#14110B", + "clusterBorder": "#3A3328", + "fontFamily": "JetBrains Mono, ui-monospace, SFMono-Regular, Menlo, monospace", + "fontSize": "13px" + } +}}%% +flowchart LR + subgraph local["[ § YOUR MACHINE ]"] + direction TB + Agent["AI agent"] + CLI["nyxid CLI"] + Skill["Pulled skills"] + end + subgraph cloud["[ § ORNN CLOUD ]"] + direction TB + API["ornn-api"] + Auth["NyxID"] + Store[("Skill registry")] + Sandbox["Sandbox"] + end + + Agent -->|invoke| CLI + CLI ==>|HTTPS| API + API -->|verify| Auth + API -->|r/w| Store + API -->|exec| Sandbox + API -.->|artifact| Agent + Agent -->|run| Skill + + classDef ember fill:#FF7322,stroke:#C9460D,color:#14130E,stroke-width:2px,font-weight:bold + classDef arc fill:#5BC8E8,stroke:#3A8FB8,color:#14130E,stroke-width:2px,font-weight:bold + classDef forged fill:#1A1610,stroke:#3A3328,color:#F1ECDE,stroke-width:1.5px + classDef storage fill:#221E16,stroke:#3A3328,color:#C9BFAD,stroke-width:1.5px + + class Agent forged + class CLI forged + class Skill forged + class Sandbox forged + class API ember + class Auth arc + class Store storage + + style local fill:#221E16,stroke:#3A3328,color:#F1ECDE,stroke-width:1.5px + style cloud fill:#14110B,stroke:#3A3328,color:#F1ECDE,stroke-width:1.5px + + linkStyle 1 stroke:#FF7322,stroke-width:2.5px +``` + +Every API call is mediated by [`nyxid`](https://github.com/ChronoAIProject/NyxID) — the shared identity + brokering layer ChronoAI uses across products. The agent never holds a long-lived token: `nyxid` refreshes credentials transparently and brokers per-service access for each request. + +## Quickstart + +> **Status:** alpha. The API surface can still change before v1 — pin a release tag if you ship to production. + +### 1. Create a NyxID account + +Sign up at [**nyx.chrono-ai.fun**](https://nyx.chrono-ai.fun) with invite code **`NYX-2XXJI08A`**. Sign in with **GitHub**, **Google**, or **Apple** — NyxID is the identity layer that authenticates every Ornn API call. One account covers every ChronoAI service. + +### 2. Install the Ornn agent manual into your AI agent + +Open [**`ornn-agent-manual-cli`**](https://ornn.chrono-ai.fun/skills/ornn-agent-manual-cli) and follow the install instructions for your agent runtime (Claude Code, OpenAI Codex, Cursor, …). This skill is the **operational manual Ornn ships for AI agents**: once it's loaded into your agent, the agent knows how to drive the full `search → pull → execute → build → upload → share` lifecycle on its own — no further hand-holding required. + +Partway through setup, your agent will prompt you to install [**`nyxid`**](https://github.com/ChronoAIProject/NyxID) — the CLI Ornn calls under the hood to broker authenticated requests. Approve the prompt; the agent finishes onboarding itself. + +## Try Ornn free + +Early-user perk to test the full Playground + Skill Generation flow without a credit card: + +1. ⭐ Star this repo +2. Sign in to [ornn.chrono-ai.fun](https://ornn.chrono-ai.fun) with the same GitHub account +3. On first sign-in, enter NyxID invite code **`NYX-2XXJI08A`** + +Your redemption code lands in the Ornn notification inbox within 24h. **First 500 users · 400 free GPT-5.5 conversations** (200 Playground + 200 Skill Generation, no card, no expiry). + +## How Ornn compares + +That's it. Your agent now has the full Ornn lifecycle. Try any of these in plain language — no special syntax, no flags to memorise: + +- **Search the registry.** + > *"Find me a skill that converts CSV to JSON."* + + Hits semantic + keyword search across every public skill. + +- **Pull and install a skill.** + > *"Pull and install the skill `pdf-extractor`, then use it on `report.pdf`."* + + Fetches the latest versioned artifact into your local runtime and runs it. + +- **Trigger a security audit.** + > *"Run a security audit on the skill `web-scraper`."* + + Kicks the AgentSeal pipeline against a published version — static analysis, sandbox probe, dependency scan. + +- **Build and publish a new skill.** + > *"Build me a skill that summarises RSS feeds and upload it under my account."* + + Drives `ornn-build` to generate the skill, packages it, and publishes a new version through your NyxID identity. + +For the full API contract (every endpoint, every error code), see [**ornn.chrono-ai.fun/docs**](https://ornn.chrono-ai.fun/docs). + +## Community and Contributing + +- **Questions / how-to** → [Discussions → Q&A](https://github.com/ChronoAIProject/Ornn/discussions/categories/q-a) +- **Ideas / RFCs** → [Discussions → Ideas](https://github.com/ChronoAIProject/Ornn/discussions/categories/ideas) +- **Show off your agent integration** → [Discussions → Show & Tell](https://github.com/ChronoAIProject/Ornn/discussions/categories/show-and-tell) +- **Bug or feature** → [open an issue](https://github.com/ChronoAIProject/Ornn/issues/new/choose) +- **Roadmap** → [Issues](https://github.com/ChronoAIProject/Ornn/issues) · [Milestones](https://github.com/ChronoAIProject/Ornn/milestones) · [Releases](https://github.com/ChronoAIProject/Ornn/releases) +- **Security report** → [Private Vulnerability Reporting](https://github.com/ChronoAIProject/Ornn/security/advisories/new) (see [SECURITY.md](SECURITY.md)) +- **Support guide** → [SUPPORT.md](SUPPORT.md) +- **Pull requests** → read [CONTRIBUTING.md](CONTRIBUTING.md) first — it covers the issue-first workflow, branching, commit decomposition, and the changeset rule (CI blocks PRs without one). By participating you agree to follow our [Code of Conduct](CODE_OF_CONDUCT.md). + +## Like what you see? + +⭐ If Ornn looks like the primitive your agent stack has been missing, a star helps a lot at this stage — it tells us we're solving a real problem, and it's the threshold most awesome-list maintainers check before accepting a project. + +## License + +[Apache License 2.0](LICENSE) + +--- + +## Product Positioning + +## Product Positioning + +**Ornn is an agent-facing skill-lifecycle API, not a human marketplace.** + +The primary customer is the AI agent developer / agentic-system builder. Agents call Ornn directly — over HTTP or MCP — to manage their own skill lifecycle: search → pull → install → execute → build → upload → share. Closest analog: **npm registry + npm CLI fused, model-agnostic** (works for Claude / GPT / Gemini / custom — not locked to one model runtime). + +Implications when proposing or building features: + +- Lead with the **agent-API contract** (REST / MCP ergonomics, stable schemas, model-agnostic guarantees) before any human-UX angle. +- `ornn-web` is a *secondary* surface for skill owners and platform admins — it is not the primary product. UI features that don't translate into agent-API value are deprioritized. +- Avoid feature framing that drifts toward "another skill marketplace" (social ranking, browse-style discovery, recommendation feeds, leaderboards) unless we deliberately decide to. When a feature looks marketplace-shaped, surface that tension before building. + +--- + +## Architecture + +## Project Overview + +chrono-ornn is an AI skill platform. Users create, publish, search, and execute AI skills (packaged prompts + scripts) via a web UI or API. Authentication and LLM calls go through NyxID. Script execution runs in chrono-sandbox. + +## External Services + +| Service | How ornn-api talks to it | +|---------|---------------------------| +| NyxID | JWT verification (JWKS), API key introspection, LLM Gateway (Responses API) | +| chrono-sandbox | `POST /execute` — script execution with env vars, dependencies, file retrieval | +| chrono-storage | Upload/download/delete skill packages (presigned URLs) | + +## Skill Format + +- Available runtimes: `node`, `python` +- Frontmatter field for dependencies: `runtime-dependency` +- Category types: `plain`, `tool-based`, `runtime-based`, `mixed` +- Output types: `text` (stdout), `file` (generated files retrieved via glob) + +--- + +## Using Ornn from an AI Agent (HTTP API) + +--- +name: ornn-agent-manual-http +description: 'Operational manual for AI agents using the Ornn skill-lifecycle API via direct HTTPS with a NyxID bearer token (`curl -H "Authorization: Bearer $TOKEN" …`). Once loaded, the host agent can search / pull / execute / build / upload / share skills end-to-end. Authoritative contract between Ornn and the agent. Pair this file with references/api-reference.md (the full per-endpoint catalogue + error legend) — both ship together as one Ornn skill.' +metadata: + category: plain + tag: + - ornn-api + - agent + - manual + - skill-lifecycle + - http +version: "1.1" +lastUpdated: 2026-04-29 +--- + +# Agent Manual (HTTPS 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 / 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. The CLI vs HTTP distinction is preserved as a §-level switch inside `chrono-ai-service-manual` (§0.6 "Transport choice"), so HTTP-only agents lose nothing by switching. **New work should pull `chrono-ai-service-manual` instead.** This file will be removed in the release after the unified manual lands. + +> **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. +> +> ## First: install this skill locally +> +> Try to install this skill into your runtime's local skills directory: +> +> - **Claude Code:** `~/.claude/skills/ornn-agent-manual-http/` +> - **OpenAI Codex CLI:** `~/.codex/skills/ornn-agent-manual-http/` +> - **Cursor:** `.cursor/rules/ornn-agent-manual-http.md` +> - **Other agents:** whatever skill-discovery directory your platform uses. +> +> If your runtime has no local skills mechanism, load `SKILL.md` (and `references/api-reference.md`) into your system context and keep them there for the rest of the session. Either way, the manual must be in your context whenever you do anything Ornn-related. +> +> **And in *all* cases — whether you wrote the skill to disk or only into context — append an entry for it to `~/.ornn/installed-skills.json`.** That file is the persistent registry of every Ornn skill you (or any prior session on this machine) have installed; it's how the next session, or a different agent on the same box, knows what's already loaded and which versions to re-check. Schema + workflow detailed in §0.5. +> +> ## Always load this manual for any Ornn operation +> +> You **must** have this manual loaded whenever the user asks you to: +> +> - **Find, pull, and execute an existing Ornn skill** — or **build a new one and upload** when nothing matches — §2.1. +> - **Update a skill's visibility** (private / shared / public) — §2.2. +> - **Publish a new version** of a skill you own — §2.3. +> - **Trigger an audit** or **review the audit history** for a skill — §2.4 / §2.5. +> - **Pull a non-latest version**, **compare two versions**, or **delete / deprecate a version** — §2.6 / §2.7 / §2.10. +> - **Check usage analytics** for a skill — §2.8. +> - **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. +> - **Link a skill to GitHub** or **trigger a sync** from the linked source — §2.14. +> +> Without this manual loaded, you do not know which endpoint to call, how to authenticate, or how to read the response shapes. +> +> Ornn's product is **Skill-as-a-Service for AI agents.** Skills are packaged AI capabilities (a `SKILL.md` prompt + optional scripts + YAML metadata) that any agent can pull and execute. This manual is the contract between Ornn and **you, the agent**. + +## §0. Updating this manual + +This manual is itself an Ornn skill (`name: ornn-agent-manual-http`). Its source of truth is the Ornn registry, not a static docs page — pull a fresh copy through the same API every other skill flows through. + +> **Base URL for every example below:** `https://ornn.chrono-ai.fun/api`. The `ornn-web` nginx in front of that domain routes any `/api/*` request through to the NyxID proxy, which authenticates with the bearer token you pass and forwards to `ornn-api`. You never call NyxID directly. Throughout this manual, `$TOKEN` stands for your NyxID bearer access token. + +**Whenever you want to check for an update, follow these steps verbatim:** + +1. Pull the latest version of this skill from Ornn: + + ```bash + curl -H "Authorization: Bearer $TOKEN" \ + "https://ornn.chrono-ai.fun/api/v1/skills/ornn-agent-manual-http/json" + ``` + +2. The response is `{ data: { name, description, metadata, files: { "SKILL.md": "...", "references/api-reference.md": "..." } } }`. Read the YAML frontmatter at the top of `SKILL.md` and find the `version:` field. + +3. Compare it to the `version:` in **your loaded copy** (the frontmatter at the top of this file). If they match, you are current — stop here. + +4. If the published version is newer: + - List every version available with `curl -H "Authorization: Bearer $TOKEN" "https://ornn.chrono-ai.fun/api/v1/skills/ornn-agent-manual-http/versions"`. The response has one row per version, newest first. + - Ask the user which version they want to load (they may want to pin to an older one for reproducibility). + - Once the user picks, fetch `https://ornn.chrono-ai.fun/api/v1/skills/ornn-agent-manual-http/json?version=
+```
+
+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.
+
+---
+
+## 2. URL structure
+
+### 2.1 Versioning
+
+All endpoints live under `/api/v1/`. Breaking changes ship under `/api/v2/`. Additive changes ship under `v1`.
+
+### 2.2 Resource paths
+
+- Plural resource nouns: `/skills`, `/categories`, `/tags`, `/users`, `/activities`.
+- Canonical URL uses the stable ID (GUID). **No polymorphic `:idOrName` on write operations.**
+- Name→ID resolution via `GET /v1/{resource}/lookup?name=` (returns `{ id }`).
+- Caller-scoped resources under `/v1/me/*`.
+
+### 2.3 Non-CRUD actions — sub-resource
+
+Custom actions as sub-resource paths:
+
+```
+POST /v1/skills/generate
+POST /v1/skills/generate/from-openapi
+POST /v1/skills/validate
+POST /v1/skills/search
+POST /v1/playground/chat
+```
+
+Router config MUST declare static action segments with priority over `:id` params (Hono / Express / Rails default behavior). Skill / category names that collide with reserved action verbs are rejected at create time.
+
+Reserved action verbs per resource documented in `ornn-api/src/shared/reservedVerbs.ts`.
+
+### 2.4 Search — dual-track
+
+- `GET /v1/{resource}?q=...&` — simple keyword filter over URL params (cacheable, bookmarkable).
+- `POST /v1/{resource}/search` — complex queries with structured body (semantic mode, long queries, compound filters).
+
+Both return the same collection shape (`{ items, meta }`).
+
+### 2.5 Skill dependency closure (#968)
+
+```
+GET /v1/skills/{idOrName}/closure[?version=|]
+```
+
+Resolves the full **transitive** dependency closure of a skill version. A skill declares its direct dependencies in SKILL.md frontmatter via `metadata.depends-on` — an array of `@` or `@` refs (no semver ranges). The endpoint walks that graph and returns every transitive dependency.
+
+- **Auth:** optional. Anonymous callers resolve against public skills only; a public skill that transitively depends on a private skill the caller can't read surfaces that node as `skill_dependency_not_found` (existence not leaked).
+- **Order:** items are returned in deps-first **topological order** — every dependency precedes the dependents that pin it, so installing in array order is always safe. Shared nodes (diamonds) appear exactly once.
+- **Response:** standard collection envelope.
+
+```json
+{
+ "data": {
+ "items": [
+ { "guid": "…", "name": "pdf-tools", "version": "1.0", "skillHash": "…", "depth": 1 },
+ { "guid": "…", "name": "report-gen", "version": "2.3", "skillHash": "…", "depth": 0 }
+ ]
+ },
+ "error": null
+}
+```
+
+- **Errors:** `dependency_cycle` (409) when the graph loops; `dependency_conflict` (409) when one skill is pinned to two versions in the same closure; `skill_dependency_not_found` (404) when a ref doesn't resolve or isn't visible. See `docs/ERRORS.md`.
+
+The same closure is validated at **publish time**: declaring a `depends-on` ref that can't be resolved, forms a cycle, or conflicts fails the create/update before the version is committed.
+
+SDK helpers: `client.resolveClosure(idOrName, { version })` / `client.pullClosure(...)` (TypeScript), `client.resolve_closure(...)` / `client.pull_closure(...)` (Python).
+
+### 2.6 Skillsets (#969)
+
+A **skillset** is a named, versioned, owned, visibility-scoped meta-package that references N member skills and carries a `kind`. One call resolves + delivers the whole set — including each member's dependency closure (§2.5). The ownership / visibility / immutable-versioning model mirrors skills verbatim; permission scopes **reuse** the existing `ornn:skill:{create,read,update,delete}` (see §5.2 — a dedicated `ornn:skillset:*` scope split is a tracked follow-up).
+
+```
+POST /v1/skillsets — create (ornn:skill:create; private by default)
+GET /v1/skillsets/{idOrName} — read (optional auth; anon sees public only)
+GET /v1/skillsets/{idOrName}/versions — list versions (optional auth)
+GET /v1/skillsets/{idOrName}/closure — one-call resolve (optional auth)
+PUT /v1/skillsets/{id} — publish a new immutable version (ornn:skill:update)
+PUT /v1/skillsets/{id}/permissions — visibility / sharing (ornn:skill:update)
+DELETE /v1/skillsets/{id} — delete + cascade versions (ornn:skill:delete)
+GET /v1/skillset-search — discovery by kind / tags / scope (optional auth)
+```
+
+- **`kind`:** enum, v1 `{ "generic", "consensus-supported" }` (extensible). Default `generic`. `consensus-supported` is an author **claim** that the members are an independent, comparable set suitable for agent-side consensus — **not a guarantee** (stated honestly; Ornn packages + delivers the set, the agent runs any consensus in its own runtime).
+- **`members`:** 2..N skill refs, each `@` or `@` (the **same** grammar as `depends-on`, §2.5). No nested skillsets in v1 — a skillset references skills only. Validated on publish: every member must resolve to a readable skill version, and the union dependency closure must be conflict-free.
+- **`instructions` (master prompt, #978):** a **REQUIRED**, versioned markdown body telling an agent **HOW** to use the set (orchestration, ordering, which member to pick when). 1..8000 chars (trimmed server-side; a whitespace-only body is rejected). Distinct from `description` (a short ≤1024-char human summary). **Required on BOTH create and publish, with NO carry-forward** — unlike `description`/`kind`/`tags` (which a publish may omit to inherit the prior version's value), every published version must explicitly state its own master prompt. Stored opaque — Ornn does not render, sanitize, template, lint, or search-index it. Surfaced verbatim on `GET /v1/skillsets/{idOrName}` and as a root field on `/closure`.
+- **Create / publish bodies (JSON):**
+
+```json
+POST /v1/skillsets
+{ "name": "review-set", "description": "…",
+ "instructions": "Run pdf-tools first, then feed its output to csv-tools…",
+ "kind": "consensus-supported",
+ "tags": ["review"], "members": ["pdf-tools@1.0", "csv-tools@2.1"], "version": "1.0" }
+```
+
+`GET /v1/skillsets/{idOrName}` returns the detail object including the version's `instructions`.
+
+- **Closure:** `GET /v1/skillsets/{idOrName}/closure` resolves `roots = members` through the **same** §2.5 resolver — the union of all members plus each member's transitive dependency closure, deduplicated and topo-sorted (deps-first). The success body carries the version's master prompt as a **root sibling** of `items`: `{ "data": { "instructions": "…", "items": [ … ] }, "error": null }` (the skill `/skills/:id/closure` envelope stays `{ items }`, unchanged). Same error codes as §2.5: `dependency_cycle` (409), `dependency_conflict` (409), `skill_dependency_not_found` (404). Anonymous callers resolving a public skillset whose member transitively pins a private skill get `skill_dependency_not_found`
+
+---
+
+## Design System (Overview)
+
+## Product Context
+- **What this is:** A Skill-as-a-Service platform for discovering, installing, publishing, and operating AI agent skills through a web UI, docs, and API-adjacent tooling.
+- **Who it is for:** Agent developers, platform builders, technical teams, and operators who expect tools to feel composed and credible rather than playful or trend-driven.
+- **Scope of this document:** Whole app, landing-led. The landing page is the flagship expression, and app shell, registry, docs, admin, forms, and data views inherit the same language.
+- **Canonical source of truth:** **This document is canonical.** It defines the intended state of the design system. Two reference builds are kept aligned with it for visual sanity-checking:
+ - `design-preview/Ornn-Landing-v3.html` (deployed at `chrono-ornn.surge.sh/Ornn-Landing-v3.html`) — standalone Forge Workshop reference
+ - The live ornn-web implementation (deployed at `chrono-ornn-web.surge.sh`) — production application
+- **When this doc and an implementation disagree, the implementation is wrong.** Bring the implementation back into alignment, then re-verify the build. Do not silently update DESIGN.md to match drifted code; instead, propose the change explicitly (PR description: "DESIGN.md change + impl follows" or "DESIGN.md unchanged, impl regression fix"). This protects the system from lossy round-trips between code and doc.
+
+## Design Thesis
+Ornn should feel like a registry, workshop, and publishing desk for skills. The product is not a generic SaaS dashboard and not a cyberpunk toy. Its visual language is a controlled blend of:
+
+- **Paper:** editorial warmth, legible reading surfaces, quiet hierarchy
+- **Metal:** forged structure, thin separators, instrument-like controls
+- **Ember:** selective heat, action emphasis, and directional energy
+
+The result should read as warm, tactile, precise, industrial, and composed. Interfaces should feel authored, not templated.
diff --git a/ornn-api/src/domains/assistant/kb/distiller.test.ts b/ornn-api/src/domains/assistant/kb/distiller.test.ts
new file mode 100644
index 00000000..c8816715
--- /dev/null
+++ b/ornn-api/src/domains/assistant/kb/distiller.test.ts
@@ -0,0 +1,165 @@
+/**
+ * UT-KB-DISTILL-* — DeterministicKbDistiller + extractSections (#970).
+ *
+ * @module domains/assistant/kb/distiller.test
+ */
+
+import { describe, expect, it } from "bun:test";
+import {
+ DeterministicKbDistiller,
+ extractSections,
+ type KbSourceDoc,
+} from "./distiller";
+import { CHARS_PER_TOKEN, estimateTokens } from "./tokens";
+
+const distiller = new DeterministicKbDistiller();
+
+function repeat(token: string, times: number): string {
+ return Array.from({ length: times }, () => token).join(" ");
+}
+
+describe("DeterministicKbDistiller", () => {
+ it("UT-KB-DISTILL-001: concatenates sources in manifest order under titles", () => {
+ const sources: KbSourceDoc[] = [
+ { id: "a", title: "Alpha", text: "alpha body" },
+ { id: "b", title: "Bravo", text: "bravo body" },
+ ];
+ const digest = distiller.distill(sources, { budgetTokens: 1_000 });
+ expect(digest.text.indexOf("## Alpha")).toBeLessThan(
+ digest.text.indexOf("## Bravo"),
+ );
+ expect(digest.text).toContain("alpha body");
+ expect(digest.text).toContain("bravo body");
+ expect(digest.sources.map((s) => s.id)).toEqual(["a", "b"]);
+ });
+
+ it("UT-KB-DISTILL-002: per-source cap clips an oversized doc", () => {
+ // ~400 chars ≈ 100 tokens, cap to 10 tokens (~40 chars).
+ const big = repeat("word", 80);
+ const digest = distiller.distill(
+ [{ id: "big", title: "Big", text: big, maxTokens: 10 }],
+ { budgetTokens: 10_000 },
+ );
+ const stat = digest.sources[0]!;
+ expect(stat.truncated).toBe(true);
+ expect(stat.estimatedTokens).toBeLessThanOrEqual(10);
+ });
+
+ it("UT-KB-DISTILL-003: global budget clamps the whole digest", () => {
+ const sources: KbSourceDoc[] = [
+ { id: "a", title: "Alpha", text: repeat("aaaa", 200) },
+ { id: "b", title: "Bravo", text: repeat("bbbb", 200) },
+ { id: "c", title: "Charlie", text: repeat("cccc", 200) },
+ ];
+ const budgetTokens = 50;
+ const digest = distiller.distill(sources, { budgetTokens });
+ // Hard invariant: the produced grounding never exceeds the budget.
+ expect(digest.estimatedTokens).toBeLessThanOrEqual(budgetTokens);
+ expect(digest.text.length).toBeLessThanOrEqual(budgetTokens * CHARS_PER_TOKEN);
+ expect(digest.budgetTokens).toBe(budgetTokens);
+ });
+
+ it("UT-KB-DISTILL-004: tail source dropped by global clamp is marked truncated", () => {
+ const sources: KbSourceDoc[] = [
+ { id: "a", title: "Alpha", text: repeat("aaaa", 100) },
+ { id: "z", title: "Zulu", text: repeat("zzzz", 100) },
+ ];
+ // Budget only fits the first block — Zulu's content shouldn't survive.
+ const digest = distiller.distill(sources, { budgetTokens: 30 });
+ expect(digest.text).not.toContain("## Zulu");
+ const zulu = digest.sources.find((s) => s.id === "z")!;
+ expect(zulu.truncated).toBe(true);
+ });
+
+ it("UT-KB-DISTILL-005: deterministic — identical inputs yield identical output", () => {
+ const sources: KbSourceDoc[] = [
+ { id: "a", title: "Alpha", text: "one two three", maxTokens: 100 },
+ { id: "b", title: "Bravo", text: "four five six" },
+ ];
+ const first = distiller.distill(sources, { budgetTokens: 500 });
+ const second = distiller.distill(sources, { budgetTokens: 500 });
+ expect(first.text).toBe(second.text);
+ expect(first.estimatedTokens).toBe(second.estimatedTokens);
+ });
+
+ it("UT-KB-DISTILL-006: empty / whitespace source contributes nothing", () => {
+ const digest = distiller.distill(
+ [
+ { id: "empty", title: "Empty", text: " \n " },
+ { id: "real", title: "Real", text: "real content" },
+ ],
+ { budgetTokens: 1_000 },
+ );
+ expect(digest.text).not.toContain("## Empty");
+ expect(digest.text).toContain("## Real");
+ const empty = digest.sources.find((s) => s.id === "empty")!;
+ expect(empty.chars).toBe(0);
+ });
+
+ it("UT-KB-DISTILL-007: estimatedTokens matches estimateTokens(text)", () => {
+ const digest = distiller.distill(
+ [{ id: "a", title: "A", text: "some grounding text here" }],
+ { budgetTokens: 1_000 },
+ );
+ expect(digest.estimatedTokens).toBe(estimateTokens(digest.text));
+ });
+
+ it("UT-KB-DISTILL-008: generatedFrom defaults + honours override", () => {
+ const def = distiller.distill([{ id: "a", title: "A", text: "x" }], {
+ budgetTokens: 100,
+ });
+ expect(def.generatedFrom).toBe("DeterministicKbDistiller");
+ const overridden = distiller.distill([{ id: "a", title: "A", text: "x" }], {
+ budgetTokens: 100,
+ generatedFrom: "custom-note",
+ });
+ expect(overridden.generatedFrom).toBe("custom-note");
+ });
+});
+
+describe("extractSections", () => {
+ const doc = [
+ "# Title",
+ "intro line",
+ "",
+ "## Keep Me",
+ "kept body 1",
+ "kept body 2",
+ "",
+ "### Nested Under Keep",
+ "still kept (deeper heading)",
+ "",
+ "## Drop Me",
+ "dropped body",
+ "",
+ "## Also Keep",
+ "second kept body",
+ ].join("\n");
+
+ it("UT-KB-EXTRACT-001: keeps only named sections, in document order", () => {
+ const out = extractSections(doc, ["Keep Me", "Also Keep"]);
+ expect(out).toContain("## Keep Me");
+ expect(out).toContain("kept body 1");
+ expect(out).toContain("## Also Keep");
+ expect(out).toContain("second kept body");
+ expect(out).not.toContain("## Drop Me");
+ expect(out).not.toContain("dropped body");
+ });
+
+ it("UT-KB-EXTRACT-002: a kept section includes its deeper subsections", () => {
+ const out = extractSections(doc, ["Keep Me"]);
+ expect(out).toContain("### Nested Under Keep");
+ expect(out).toContain("still kept (deeper heading)");
+ // …but stops at the next same-level heading.
+ expect(out).not.toContain("## Drop Me");
+ });
+
+ it("UT-KB-EXTRACT-003: heading match is case-insensitive + trimmed", () => {
+ const out = extractSections(doc, [" keep me "]);
+ expect(out).toContain("kept body 1");
+ });
+
+ it("UT-KB-EXTRACT-004: unmatched headings degrade to empty, never throw", () => {
+ expect(extractSections(doc, ["Does Not Exist"])).toBe("");
+ });
+});
diff --git a/ornn-api/src/domains/assistant/kb/distiller.ts b/ornn-api/src/domains/assistant/kb/distiller.ts
new file mode 100644
index 00000000..36f0e71c
--- /dev/null
+++ b/ornn-api/src/domains/assistant/kb/distiller.ts
@@ -0,0 +1,208 @@
+/**
+ * Knowledge-base distillation (#970).
+ *
+ * A *distiller* turns a set of raw repo documents into a single,
+ * size-budgeted grounding digest for the Ornn Assistant. v1 ships
+ * {@link DeterministicKbDistiller} — pure, repeatable curation:
+ *
+ * 1. optionally extract only the relevant markdown sections of a doc
+ * (so e.g. CLAUDE.md contributes its "Product Positioning" section,
+ * not its release-process boilerplate),
+ * 2. clip each doc to its per-source token cap,
+ * 3. render each as a titled block and concatenate in manifest order,
+ * 4. clamp the whole thing to the global token budget.
+ *
+ * The {@link KbDistiller} interface is the documented extension point for
+ * the "big model reads the repo at build time" idea: an `LlmKbDistiller`
+ * would implement the same contract but replace steps 1–2 with a
+ * model-driven summarization pass, then reuse the same budget clamp. The
+ * build script depends on the interface, not the implementation, so
+ * swapping distillers is a one-line change with no downstream churn.
+ *
+ * Distillation is deterministic by construction — no clocks, no RNG, no
+ * network. The same inputs always produce the same digest, which is what
+ * lets the committed artifact be diff-reviewable and the loader cache be
+ * trusted.
+ *
+ * @module domains/assistant/kb/distiller
+ */
+
+import { clampToTokenBudget, estimateTokens } from "./tokens";
+
+/** Raw input document for distillation. */
+export interface KbSourceDoc {
+ /** Stable id (used in stats + provenance). */
+ readonly id: string;
+ /** Human-facing section title rendered into the digest. */
+ readonly title: string;
+ /** Full document text (already read from disk by the caller). */
+ readonly text: string;
+ /**
+ * Optional per-source token cap. When omitted the source is bounded
+ * only by the global budget.
+ */
+ readonly maxTokens?: number;
+ /**
+ * Optional list of markdown headings (exact text, without leading `#`s)
+ * to extract from the source. When set, only those sections survive —
+ * everything else in the doc is dropped before budgeting. When omitted
+ * the whole document is used.
+ */
+ readonly headings?: ReadonlyArray;
+}
+
+/** Per-source accounting in the produced digest. */
+export interface KbSourceStat {
+ readonly id: string;
+ readonly title: string;
+ readonly chars: number;
+ readonly estimatedTokens: number;
+ /** True if this source was clipped by its per-source cap or the global budget. */
+ readonly truncated: boolean;
+}
+
+/** The distilled grounding digest. */
+export interface KbDigest {
+ /** The grounding text fed to the model as system context. */
+ readonly text: string;
+ readonly estimatedTokens: number;
+ readonly budgetTokens: number;
+ readonly sources: ReadonlyArray;
+ /** Provenance note (e.g. which builder + when), for the artifact header. */
+ readonly generatedFrom: string;
+}
+
+export interface KbDistillOptions {
+ readonly budgetTokens: number;
+ /** Free-text provenance note copied into {@link KbDigest.generatedFrom}. */
+ readonly generatedFrom?: string;
+}
+
+/**
+ * Contract every distiller honours. Implementations MUST be deterministic
+ * for a given input + options.
+ */
+export interface KbDistiller {
+ distill(
+ sources: ReadonlyArray,
+ opts: KbDistillOptions,
+ ): KbDigest;
+}
+
+const BLOCK_SEPARATOR = "\n\n---\n\n";
+
+/**
+ * Deterministic, dependency-free distiller (v1). See module doc for the
+ * pipeline. No LLM calls — this is the baseline grounding everyone gets.
+ */
+export class DeterministicKbDistiller implements KbDistiller {
+ distill(
+ sources: ReadonlyArray,
+ opts: KbDistillOptions,
+ ): KbDigest {
+ const budgetTokens = Math.max(0, Math.floor(opts.budgetTokens));
+ const blocks: string[] = [];
+ const stats: KbSourceStat[] = [];
+
+ for (const src of sources) {
+ // 1. section-extract (optional) → 2. per-source clip.
+ const selected =
+ src.headings && src.headings.length > 0
+ ? extractSections(src.text, src.headings)
+ : src.text;
+ const normalized = selected.trim();
+ if (normalized.length === 0) {
+ stats.push({
+ id: src.id,
+ title: src.title,
+ chars: 0,
+ estimatedTokens: 0,
+ truncated: src.text.trim().length > 0,
+ });
+ continue;
+ }
+ const capped =
+ src.maxTokens !== undefined
+ ? clampToTokenBudget(normalized, src.maxTokens)
+ : { text: normalized, truncated: false };
+ blocks.push(`## ${src.title}\n\n${capped.text}`);
+ stats.push({
+ id: src.id,
+ title: src.title,
+ chars: capped.text.length,
+ estimatedTokens: estimateTokens(capped.text),
+ truncated: capped.truncated,
+ });
+ }
+
+ // 3. concatenate → 4. global budget clamp.
+ const joined = blocks.join(BLOCK_SEPARATOR);
+ const clamped = clampToTokenBudget(joined, budgetTokens);
+
+ return {
+ text: clamped.text,
+ estimatedTokens: estimateTokens(clamped.text),
+ budgetTokens,
+ // If the global clamp trimmed the tail, the last source(s) lost
+ // content beyond what their own stat recorded — flag globally.
+ sources: clamped.truncated ? markTailTruncated(stats, clamped.text) : stats,
+ generatedFrom: opts.generatedFrom ?? "DeterministicKbDistiller",
+ };
+ }
+}
+
+/**
+ * Extract the named markdown sections from `markdown`, in document order.
+ * A "section" is a heading line (`#`..`######`) whose trimmed text matches
+ * one of `headings`, plus every line up to (but excluding) the next
+ * heading at the same or shallower level. Unmatched headings are skipped
+ * silently — a renamed doc heading degrades to less grounding, never a
+ * crash.
+ */
+export function extractSections(
+ markdown: string,
+ headings: ReadonlyArray,
+): string {
+ const wanted = new Set(headings.map((h) => h.trim().toLowerCase()));
+ const lines = markdown.split("\n");
+ const out: string[] = [];
+ let capturing = false;
+ let captureLevel = 0;
+
+ for (const line of lines) {
+ const m = /^(#{1,6})\s+(.*)$/.exec(line);
+ if (m) {
+ const level = m[1]!.length;
+ const title = m[2]!.trim().toLowerCase();
+ if (capturing && level <= captureLevel) {
+ // A heading at the same or shallower level closes the section.
+ capturing = false;
+ }
+ if (!capturing && wanted.has(title)) {
+ capturing = true;
+ captureLevel = level;
+ out.push(line);
+ continue;
+ }
+ }
+ if (capturing) out.push(line);
+ }
+
+ return out.join("\n").trim();
+}
+
+/**
+ * After a global-budget clip, mark sources whose content fell entirely
+ * outside the surviving text as truncated, so the stats don't claim
+ * content the digest no longer carries.
+ */
+function markTailTruncated(
+ stats: ReadonlyArray,
+ survivingText: string,
+): KbSourceStat[] {
+ return stats.map((s) => {
+ if (s.truncated || s.chars === 0) return { ...s };
+ const present = survivingText.includes(`## ${s.title}`);
+ return present ? { ...s } : { ...s, truncated: true };
+ });
+}
diff --git a/ornn-api/src/domains/assistant/kb/loader.test.ts b/ornn-api/src/domains/assistant/kb/loader.test.ts
new file mode 100644
index 00000000..ac12679f
--- /dev/null
+++ b/ornn-api/src/domains/assistant/kb/loader.test.ts
@@ -0,0 +1,122 @@
+/**
+ * UT-KB-LOAD-* — AssistantKbLoader, token helpers, and a guard test on
+ * the committed digest artifact (#970).
+ *
+ * @module domains/assistant/kb/loader.test
+ */
+
+import { describe, expect, it } from "bun:test";
+import { AssistantKbLoader, stripMetadataBlock } from "./loader";
+import {
+ CHARS_PER_TOKEN,
+ DEFAULT_KB_TOKEN_BUDGET,
+ clampToTokenBudget,
+ estimateTokens,
+ resolveKbTokenBudget,
+} from "./tokens";
+
+describe("token helpers", () => {
+ it("UT-KB-TOKEN-001: estimateTokens ~ chars/4", () => {
+ expect(estimateTokens("")).toBe(0);
+ expect(estimateTokens("a".repeat(4))).toBe(1);
+ expect(estimateTokens("a".repeat(5))).toBe(2);
+ });
+
+ it("UT-KB-TOKEN-002: clampToTokenBudget never exceeds budget", () => {
+ const text = "word ".repeat(1_000);
+ const { text: clipped, truncated } = clampToTokenBudget(text, 20);
+ expect(truncated).toBe(true);
+ expect(clipped.length).toBeLessThanOrEqual(20 * CHARS_PER_TOKEN);
+ });
+
+ it("UT-KB-TOKEN-003: under-budget text is returned untouched", () => {
+ const { text, truncated } = clampToTokenBudget("short", 1_000);
+ expect(text).toBe("short");
+ expect(truncated).toBe(false);
+ });
+
+ it("UT-KB-TOKEN-004: resolveKbTokenBudget honours env, falls back on garbage", () => {
+ expect(resolveKbTokenBudget({})).toBe(DEFAULT_KB_TOKEN_BUDGET);
+ expect(resolveKbTokenBudget({ ASSISTANT_KB_TOKEN_BUDGET: "5000" })).toBe(5_000);
+ expect(resolveKbTokenBudget({ ASSISTANT_KB_TOKEN_BUDGET: "nope" })).toBe(
+ DEFAULT_KB_TOKEN_BUDGET,
+ );
+ expect(resolveKbTokenBudget({ ASSISTANT_KB_TOKEN_BUDGET: "-5" })).toBe(
+ DEFAULT_KB_TOKEN_BUDGET,
+ );
+ });
+});
+
+describe("stripMetadataBlock", () => {
+ it("UT-KB-LOAD-001: removes a single leading HTML comment block", () => {
+ const raw = "\n\n## Body\n\ncontent";
+ const out = stripMetadataBlock(raw);
+ expect(out.startsWith("## Body")).toBe(true);
+ expect(out).not.toContain("meta: here");
+ });
+
+ it("UT-KB-LOAD-002: leaves a digest without a header untouched", () => {
+ const raw = "## Body\n\ncontent";
+ expect(stripMetadataBlock(raw)).toBe(raw);
+ });
+});
+
+describe("AssistantKbLoader", () => {
+ const HEADER = "\n\n";
+
+ it("UT-KB-LOAD-003: loads, strips header, and caches (reads once)", () => {
+ let reads = 0;
+ const loader = new AssistantKbLoader({
+ budgetTokens: 10_000,
+ readDigest: () => {
+ reads += 1;
+ return `${HEADER}## Ornn\n\nOrnn is a skill-lifecycle API.`;
+ },
+ });
+ const first = loader.load();
+ const second = loader.load();
+ expect(reads).toBe(1); // cached
+ expect(first).toBe(second);
+ expect(first.text.startsWith("## Ornn")).toBe(true);
+ expect(first.text).not.toContain("meta");
+ expect(first.estimatedTokens).toBe(estimateTokens(first.text));
+ expect(first.truncated).toBe(false);
+ });
+
+ it("UT-KB-LOAD-004: budget enforcement — oversized artifact is clamped on load", () => {
+ const body = "word ".repeat(5_000); // ~6250 tokens
+ const loader = new AssistantKbLoader({
+ budgetTokens: 100,
+ readDigest: () => `${HEADER}${body}`,
+ });
+ const kb = loader.load();
+ expect(kb.truncated).toBe(true);
+ expect(kb.estimatedTokens).toBeLessThanOrEqual(100);
+ expect(kb.text.length).toBeLessThanOrEqual(100 * CHARS_PER_TOKEN);
+ });
+
+ it("UT-KB-LOAD-005: read failure degrades to empty grounding (no throw)", () => {
+ const loader = new AssistantKbLoader({
+ readDigest: () => {
+ throw new Error("ENOENT");
+ },
+ });
+ const kb = loader.load();
+ expect(kb.text).toBe("");
+ expect(kb.estimatedTokens).toBe(0);
+ });
+
+ it("UT-KB-LOAD-006: invalidate() forces a re-read", () => {
+ let reads = 0;
+ const loader = new AssistantKbLoader({
+ readDigest: () => {
+ reads += 1;
+ return `${HEADER}content`;
+ },
+ });
+ loader.load();
+ loader.invalidate();
+ loader.load();
+ expect(reads).toBe(2);
+ });
+});
diff --git a/ornn-api/src/domains/assistant/kb/loader.ts b/ornn-api/src/domains/assistant/kb/loader.ts
new file mode 100644
index 00000000..d93ba4ed
--- /dev/null
+++ b/ornn-api/src/domains/assistant/kb/loader.ts
@@ -0,0 +1,124 @@
+/**
+ * Runtime loader for the Ornn Assistant knowledge base (#970).
+ *
+ * The KB digest is a *committed build artifact* (`digest.generated.md`)
+ * produced by `scripts/build-assistant-kb.ts`. The loader's only job is to
+ * read that single file, strip its provenance header, defensively clamp it
+ * to the token budget, and cache the result in-process. It deliberately
+ * does NOT re-read the repo's source docs at runtime — those don't ship in
+ * the container, and re-distilling on every boot would be non-deterministic
+ * and slow. Build-time produces; runtime consumes.
+ *
+ * Deterministic + cached: the first `load()` reads + parses the artifact;
+ * every subsequent call returns the cached value. A failed read degrades to
+ * empty grounding (logged) rather than crashing the assistant — the skill
+ * retrieval + the user's question still produce a useful answer.
+ *
+ * @module domains/assistant/kb/loader
+ */
+
+import { join } from "node:path";
+import { readFileSync } from "node:fs";
+import { createLogger } from "../../../shared/logger";
+import {
+ clampToTokenBudget,
+ estimateTokens,
+ resolveKbTokenBudget,
+} from "./tokens";
+
+const logger = createLogger("assistantKb");
+
+/** Loaded grounding, ready to drop into the LLM system context. */
+export interface AssistantKb {
+ /** Grounding text (provenance header stripped, budget-clamped). */
+ readonly text: string;
+ readonly estimatedTokens: number;
+ readonly budgetTokens: number;
+ /** True if the artifact exceeded the budget and was clamped on load. */
+ readonly truncated: boolean;
+}
+
+/** Filename of the committed digest artifact, colocated with this module. */
+export const DIGEST_ARTIFACT_FILENAME = "digest.generated.md";
+
+/** Default reader — reads the colocated committed artifact. */
+export function defaultDigestReader(): string {
+ return readFileSync(join(import.meta.dir, DIGEST_ARTIFACT_FILENAME), "utf-8");
+}
+
+export interface AssistantKbLoaderDeps {
+ /** Token budget; defaults to the env-resolved value. */
+ readonly budgetTokens?: number;
+ /** Digest source; injectable for tests. Defaults to the artifact file. */
+ readonly readDigest?: () => string;
+}
+
+/**
+ * Reads + caches the assistant KB digest. One instance per process is the
+ * intended usage (constructed in bootstrap); the cache lives for the
+ * process lifetime since the artifact is immutable at runtime.
+ */
+export class AssistantKbLoader {
+ private readonly budgetTokens: number;
+ private readonly readDigest: () => string;
+ private cached: AssistantKb | null = null;
+
+ constructor(deps: AssistantKbLoaderDeps = {}) {
+ this.budgetTokens = deps.budgetTokens ?? resolveKbTokenBudget();
+ this.readDigest = deps.readDigest ?? defaultDigestReader;
+ }
+
+ load(): AssistantKb {
+ if (this.cached) return this.cached;
+
+ let raw = "";
+ try {
+ raw = this.readDigest();
+ } catch (err) {
+ logger.error(
+ { err: (err as Error).message },
+ "assistant KB digest read failed — grounding degrades to empty",
+ );
+ }
+
+ const body = stripMetadataBlock(raw).trim();
+ const { text, truncated } = clampToTokenBudget(body, this.budgetTokens);
+ if (truncated) {
+ logger.warn(
+ { budgetTokens: this.budgetTokens },
+ "assistant KB digest exceeded token budget on load — clamped defensively",
+ );
+ }
+
+ const kb: AssistantKb = {
+ text,
+ estimatedTokens: estimateTokens(text),
+ budgetTokens: this.budgetTokens,
+ truncated,
+ };
+ this.cached = kb;
+ logger.info(
+ {
+ estimatedTokens: kb.estimatedTokens,
+ budgetTokens: kb.budgetTokens,
+ truncated,
+ },
+ "assistant KB digest loaded",
+ );
+ return kb;
+ }
+
+ /** Ops/test hook: drop the cache so the next `load()` re-reads. */
+ invalidate(): void {
+ this.cached = null;
+ }
+}
+
+/**
+ * Strip a single leading HTML comment block — the generated-artifact
+ * provenance header — so build metadata never reaches the model context.
+ * A BOM, if present, is tolerated before the comment.
+ */
+export function stripMetadataBlock(raw: string): string {
+ return raw.replace(/^\uFEFF?\s*\s*/, "");
+}
diff --git a/ornn-api/src/domains/assistant/kb/sources.ts b/ornn-api/src/domains/assistant/kb/sources.ts
new file mode 100644
index 00000000..121ab43b
--- /dev/null
+++ b/ornn-api/src/domains/assistant/kb/sources.ts
@@ -0,0 +1,110 @@
+/**
+ * Build-time source manifest for the Ornn Assistant knowledge base (#970).
+ *
+ * Declares WHICH repo docs feed the grounding digest, in priority order,
+ * with per-source token caps and (where a doc is mostly irrelevant to
+ * Q&A) a heading allow-list so only the useful sections survive.
+ *
+ * This manifest is consumed ONLY by `scripts/build-assistant-kb.ts` at
+ * build time — paths are relative to the repo root and the files don't
+ * ship in the runtime container. The runtime loads the produced artifact,
+ * not these sources. Curation policy lives here so adding/retuning a
+ * source is a one-line data change, not code.
+ *
+ * Ground rules:
+ * - Docs only. Never list source code, configs, or anything that could
+ * carry secrets — the digest is fed verbatim to a model and streamed
+ * to users.
+ * - Order = priority. Earlier sources win the budget if the global cap
+ * bites; the assistant's identity ("what is Ornn") leads.
+ *
+ * @module domains/assistant/kb/sources
+ */
+
+/** A planned source: where to read it and how much of it to keep. */
+export interface KbSourceSpec {
+ readonly id: string;
+ readonly title: string;
+ /** Path relative to the repo root. */
+ readonly repoRelPath: string;
+ /** Per-source token cap applied after section extraction. */
+ readonly maxTokens: number;
+ /** Optional markdown heading allow-list (exact heading text). */
+ readonly headings?: ReadonlyArray;
+}
+
+/**
+ * Curated, priority-ordered manifest. Tuned so the sum of caps lands a
+ * little under the default 18k budget, leaving headroom for the global
+ * clamp — see `scripts/build-assistant-kb.ts`.
+ */
+export const KB_SOURCE_MANIFEST: ReadonlyArray = [
+ {
+ // The single best "what is Ornn / why / how it works" doc.
+ id: "readme",
+ title: "Ornn — Overview (README)",
+ repoRelPath: "README.md",
+ maxTokens: 2_800,
+ },
+ {
+ // Positioning only — skip the release-process / deploy boilerplate.
+ id: "claude-positioning",
+ title: "Product Positioning",
+ repoRelPath: "CLAUDE.md",
+ maxTokens: 1_500,
+ headings: ["Product Positioning"],
+ },
+ {
+ // User-relevant architecture only: what Ornn is, the high-level
+ // external-service overview, and the skill format. The internal infra
+ // sections (PostHog/telemetry internals, env-var catalogs, internal
+ // request-header names like X-NyxID-*/X-Ornn-Caller-*, the user
+ // directory) are EXCLUDED via this allow-list — they're needless
+ // internal-recon surface for an assistant any authenticated user can
+ // query (security review #970, finding #1).
+ id: "architecture",
+ title: "Architecture",
+ repoRelPath: "docs/ARCHITECTURE.md",
+ maxTokens: 1_800,
+ headings: ["Project Overview", "External Services", "Skill Format"],
+ },
+ {
+ // The authoritative agent contract: search → pull → execute → build →
+ // upload → share over HTTP. The most-asked "how do I …" answers live
+ // here. HTTP manual is the live path (no CLI shipped yet).
+ id: "agent-manual-http",
+ title: "Using Ornn from an AI Agent (HTTP API)",
+ repoRelPath: "skills/ornn-agent-manual-http/SKILL.md",
+ maxTokens: 5_500,
+ },
+ {
+ // User-relevant /api/v1 contract sections only: response/error
+ // envelope, URL structure, HTTP semantics, query params, SSE. The
+ // §5 Authentication section carries an INTERNAL transport note
+ // (`X-NyxID-*` proxy headers, "not part of the public contract"), and
+ // §7–§12 are deprecation/caching/observability/architecture
+ // internals — all EXCLUDED via this allow-list so the same internal
+ // header names the architecture source dropped don't re-enter the
+ // digest here (security review #970, finding #1).
+ id: "conventions",
+ title: "API Conventions",
+ repoRelPath: "docs/CONVENTIONS.md",
+ maxTokens: 2_600,
+ headings: [
+ "1. Response & error format",
+ "2. URL structure",
+ "3. HTTP semantics",
+ "4. Query parameters",
+ "6. SSE streaming",
+ ],
+ },
+ {
+ // Visual spec is mostly irrelevant to Q&A; keep only the opening
+ // philosophy/overview so "what does Ornn look/feel like" has an anchor.
+ id: "design-overview",
+ title: "Design System (Overview)",
+ repoRelPath: "docs/DESIGN.md",
+ maxTokens: 700,
+ headings: ["Product Context", "Design Thesis"],
+ },
+];
diff --git a/ornn-api/src/domains/assistant/kb/tokens.ts b/ornn-api/src/domains/assistant/kb/tokens.ts
new file mode 100644
index 00000000..3312cc07
--- /dev/null
+++ b/ornn-api/src/domains/assistant/kb/tokens.ts
@@ -0,0 +1,78 @@
+/**
+ * Token-budget arithmetic for the Ornn Assistant knowledge base (#970).
+ *
+ * The assistant grounds every answer in a curated, size-budgeted digest
+ * of the repo's knowledge-bearing docs. We never want that grounding to
+ * blow the model's context window, so the digest is bounded by a *token
+ * budget* both at build time (the curation pass) and at load time (a
+ * defensive clamp).
+ *
+ * Token counts here are deliberately a cheap, deterministic heuristic
+ * (chars ÷ 4) rather than a real tokenizer: the digest is model-agnostic
+ * (Claude / GPT / Gemini all differ), so an exact count for one model is
+ * meaningless for another. ~4 chars/token is the well-known English
+ * average and is conservative enough for budgeting headroom. Determinism
+ * matters more than precision — the same input must always yield the same
+ * digest so the loader cache and CI artifact stay stable.
+ *
+ * @module domains/assistant/kb/tokens
+ */
+
+/** Conservative average characters-per-token for English prose. */
+export const CHARS_PER_TOKEN = 4;
+
+/**
+ * Default grounding budget in tokens (~15–20k target per #970). Kept well
+ * under any modern model's context window so the retrieved-skills block +
+ * the conversation still fit comfortably alongside it.
+ */
+export const DEFAULT_KB_TOKEN_BUDGET = 18_000;
+
+/** Env var name for overriding the digest token budget (build + load). */
+export const KB_TOKEN_BUDGET_ENV = "ASSISTANT_KB_TOKEN_BUDGET";
+
+/**
+ * Resolve the active token budget from the environment, falling back to
+ * {@link DEFAULT_KB_TOKEN_BUDGET}. Invalid / non-positive values are
+ * ignored (fall back to the default) rather than throwing — a misconfig
+ * must never take the assistant offline.
+ */
+export function resolveKbTokenBudget(
+ env: Record = process.env,
+): number {
+ const raw = env[KB_TOKEN_BUDGET_ENV];
+ if (raw === undefined || raw.trim() === "") return DEFAULT_KB_TOKEN_BUDGET;
+ const parsed = Number(raw);
+ if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_KB_TOKEN_BUDGET;
+ return Math.floor(parsed);
+}
+
+/** Estimate the token count of `text` using the chars-per-token heuristic. */
+export function estimateTokens(text: string): number {
+ if (text.length === 0) return 0;
+ return Math.ceil(text.length / CHARS_PER_TOKEN);
+}
+
+/**
+ * Clip `text` so its estimated token count does not exceed `budgetTokens`.
+ * Clips on a whitespace boundary near the limit when possible so the digest
+ * doesn't end mid-word. Returns the (possibly clipped) text and whether a
+ * clip happened.
+ */
+export function clampToTokenBudget(
+ text: string,
+ budgetTokens: number,
+): { readonly text: string; readonly truncated: boolean } {
+ if (budgetTokens <= 0) return { text: "", truncated: text.length > 0 };
+ const maxChars = budgetTokens * CHARS_PER_TOKEN;
+ if (text.length <= maxChars) return { text, truncated: false };
+ const hardCut = text.slice(0, maxChars);
+ // Prefer the last newline, then the last space, to avoid cutting a word.
+ const lastBreak = Math.max(hardCut.lastIndexOf("\n"), hardCut.lastIndexOf(" "));
+ // Only honour the soft break if it's reasonably close to the limit
+ // (within the last 20%) — otherwise a doc with no whitespace near the
+ // cut would throw away too much content.
+ const softCut =
+ lastBreak >= maxChars * 0.8 ? hardCut.slice(0, lastBreak) : hardCut;
+ return { text: softCut.trimEnd(), truncated: true };
+}
diff --git a/ornn-api/src/domains/assistant/retrieval.test.ts b/ornn-api/src/domains/assistant/retrieval.test.ts
new file mode 100644
index 00000000..ca363cef
--- /dev/null
+++ b/ornn-api/src/domains/assistant/retrieval.test.ts
@@ -0,0 +1,168 @@
+/**
+ * UT-ASST-RETR-* — ScopedSkillRetriever + projectSafeSkill (#970).
+ *
+ * The data-safety boundary: these tests pin that retrieval is
+ * visibility-scoped at BOTH layers and that the projection NEVER carries
+ * a PII / secret / private-membership field into the result.
+ *
+ * @module domains/assistant/retrieval.test
+ */
+
+import { describe, expect, it } from "bun:test";
+import type { SkillDocument } from "../../shared/types/index";
+import type { ActorContext } from "../skills/crud/authorize";
+import {
+ ScopedSkillRetriever,
+ projectSafeSkill,
+ type SkillSearchPort,
+} from "./retrieval";
+
+function skillDoc(overrides: Partial = {}): SkillDocument {
+ return {
+ guid: "g-1",
+ name: "slack-poster",
+ description: "Post messages to Slack",
+ license: "MIT",
+ compatibility: null,
+ metadata: { category: "messaging", tags: ["slack", "chat"] },
+ skillHash: "sha256:DEADBEEFsecrethash",
+ storageKey: "skills/g-1/1.0.0.zip",
+ createdBy: "user-author",
+ createdByEmail: "author@secret.example",
+ createdByDisplayName: "Author Secret Name",
+ createdOn: new Date("2026-01-02T03:04:05.000Z"),
+ updatedBy: "user-author",
+ updatedOn: new Date("2026-01-02T03:04:05.000Z"),
+ isPrivate: false,
+ sharedWithUsers: ["secret-grantee"],
+ sharedWithOrgs: ["secret-org"],
+ latestVersion: "1.0.0",
+ ...overrides,
+ };
+}
+
+const ACTOR: ActorContext = {
+ userId: "u-caller",
+ memberships: [{ userId: "org-a", role: "member", displayName: "Org A" }],
+ isPlatformAdmin: false,
+ membershipsResolved: true,
+};
+
+class FakeSearch implements SkillSearchPort {
+ lastArgs: unknown[] = [];
+ next: SkillDocument[] = [];
+ async keywordSearch(
+ query: string,
+ scope: string,
+ currentUserId: string,
+ userOrgIds: string[],
+ page: number,
+ pageSize: number,
+ ) {
+ this.lastArgs = [query, scope, currentUserId, userOrgIds, page, pageSize];
+ return { skills: this.next, total: this.next.length };
+ }
+}
+
+describe("projectSafeSkill", () => {
+ it("UT-ASST-RETR-001: keeps only SAFE fields, drops all PII/secret fields", () => {
+ const projected = projectSafeSkill(skillDoc());
+ expect(projected).toEqual({
+ name: "slack-poster",
+ description: "Post messages to Slack",
+ tags: ["slack", "chat"],
+ category: "messaging",
+ createdOn: "2026-01-02T03:04:05.000Z",
+ createdBy: "user-author",
+ });
+ // Belt: the serialized projection must not carry any forbidden field.
+ const json = JSON.stringify(projected);
+ for (const forbidden of [
+ "author@secret.example",
+ "Author Secret Name",
+ "DEADBEEF",
+ "storage",
+ "secret-grantee",
+ "secret-org",
+ "isPrivate",
+ "skillHash",
+ ]) {
+ expect(json.includes(forbidden)).toBe(false);
+ }
+ });
+
+ it("UT-ASST-RETR-002: missing tags → empty array, never undefined", () => {
+ const projected = projectSafeSkill(
+ skillDoc({ metadata: { category: "misc" } }),
+ );
+ expect(projected.tags).toEqual([]);
+ expect(projected.category).toBe("misc");
+ });
+});
+
+describe("ScopedSkillRetriever", () => {
+ it("UT-ASST-RETR-003: queries with the 'mixed' scope + actor org ids", async () => {
+ const search = new FakeSearch();
+ const retriever = new ScopedSkillRetriever({ search, maxResults: 5 });
+ await retriever.retrieve("how do I post to slack", ACTOR);
+ expect(search.lastArgs[1]).toBe("mixed");
+ expect(search.lastArgs[2]).toBe("u-caller");
+ expect(search.lastArgs[3]).toEqual(["org-a"]);
+ expect(search.lastArgs[5]).toBe(5); // pageSize == maxResults
+ });
+
+ it("UT-ASST-RETR-004: blank query → no search call, empty result", async () => {
+ const search = new FakeSearch();
+ const retriever = new ScopedSkillRetriever({ search });
+ expect(await retriever.retrieve(" ", ACTOR)).toEqual([]);
+ // FakeSearch records args only when called — empty means never invoked.
+ expect(search.lastArgs).toEqual([]);
+ });
+
+ it("UT-ASST-RETR-005: projection-layer canReadSkill drops an unreadable doc", async () => {
+ // Simulate a query-layer regression that returned a private skill the
+ // actor cannot read. The projection-layer guard MUST drop it.
+ const search = new FakeSearch();
+ search.next = [
+ skillDoc({ guid: "pub", name: "public-skill", isPrivate: false }),
+ skillDoc({
+ guid: "priv",
+ name: "someone-elses-private",
+ isPrivate: true,
+ createdBy: "other-user",
+ sharedWithUsers: [],
+ sharedWithOrgs: [],
+ }),
+ ];
+ const retriever = new ScopedSkillRetriever({ search });
+ const result = await retriever.retrieve("anything", ACTOR);
+ expect(result.map((r) => r.name)).toEqual(["public-skill"]);
+ });
+
+ it("UT-ASST-RETR-006: caps results at maxResults", async () => {
+ const search = new FakeSearch();
+ search.next = Array.from({ length: 10 }, (_, i) =>
+ skillDoc({ guid: `g${i}`, name: `skill-${i}`, isPrivate: false }),
+ );
+ const retriever = new ScopedSkillRetriever({ search, maxResults: 3 });
+ const result = await retriever.retrieve("x", ACTOR);
+ expect(result.length).toBe(3);
+ });
+
+ it("UT-ASST-RETR-007: private skill shared with actor's org IS readable", async () => {
+ const search = new FakeSearch();
+ search.next = [
+ skillDoc({
+ guid: "shared",
+ name: "org-shared-skill",
+ isPrivate: true,
+ createdBy: "other",
+ sharedWithUsers: [],
+ sharedWithOrgs: ["org-a"], // actor is a member of org-a
+ }),
+ ];
+ const retriever = new ScopedSkillRetriever({ search });
+ const result = await retriever.retrieve("x", ACTOR);
+ expect(result.map((r) => r.name)).toEqual(["org-shared-skill"]);
+ });
+});
diff --git a/ornn-api/src/domains/assistant/retrieval.ts b/ornn-api/src/domains/assistant/retrieval.ts
new file mode 100644
index 00000000..05ae5a93
--- /dev/null
+++ b/ornn-api/src/domains/assistant/retrieval.ts
@@ -0,0 +1,133 @@
+/**
+ * Visibility-scoped skill retrieval for the Ornn Assistant (#970).
+ *
+ * Given the caller's latest question, return up to N skills the caller is
+ * allowed to see, projected down to SAFE fields only. This is the most
+ * security-sensitive part of the assistant: the retrieved skills are fed
+ * verbatim into the LLM context and streamed back to the user, so the
+ * scoping + projection here is the data-safety boundary.
+ *
+ * Two independent guards (belt-and-suspenders, per the issue):
+ * 1. QUERY layer — `keywordSearch(..., scope: "mixed", ...)` runs
+ * `applyScope`, which restricts the Mongo match to public skills +
+ * private skills the actor authored / was shared / is an org member
+ * of. A private skill the actor can't see never leaves the DB.
+ * 2. PROJECTION layer — every surviving doc is re-checked with
+ * `canReadSkill(actor)` and then stripped to SAFE fields. Even if a
+ * future query-layer regression widened the match, the projection
+ * gate drops anything the actor can't read and never copies a
+ * PII/secret field.
+ *
+ * Deterministic: same (query, actor, corpus) → same result set (the repo
+ * sorts by `createdOn desc`).
+ *
+ * @module domains/assistant/retrieval
+ */
+
+import { createLogger } from "../../shared/logger";
+import type { SkillDocument } from "../../shared/types/index";
+import { canReadSkill, type ActorContext } from "../skills/crud/authorize";
+import type { RetrievedSkill } from "./types";
+
+const logger = createLogger("assistantRetrieval");
+
+/** Default top-N skills injected into the grounding. */
+export const DEFAULT_MAX_RETRIEVED_SKILLS = 5;
+
+/**
+ * Cap the keyword query length. The latest user message is used verbatim
+ * as the (escaped) search term; bounding it keeps the regex sane and the
+ * query cheap regardless of how long the user's message is.
+ */
+const MAX_QUERY_CHARS = 256;
+
+/**
+ * Narrow port over the one `SkillRepository` method we use. Keeping the
+ * dependency surface tiny makes the retriever trivially fakeable in tests
+ * and decouples it from the full repository.
+ */
+export interface SkillSearchPort {
+ keywordSearch(
+ query: string,
+ scope: "public" | "private" | "mixed" | "shared-with-me" | "mine",
+ currentUserId: string,
+ userOrgIds: string[],
+ page: number,
+ pageSize: number,
+ ): Promise<{ skills: SkillDocument[]; total: number }>;
+}
+
+export interface ScopedSkillRetrieverDeps {
+ readonly search: SkillSearchPort;
+ readonly maxResults?: number;
+}
+
+export class ScopedSkillRetriever {
+ private readonly search: SkillSearchPort;
+ private readonly maxResults: number;
+
+ constructor(deps: ScopedSkillRetrieverDeps) {
+ this.search = deps.search;
+ this.maxResults = deps.maxResults ?? DEFAULT_MAX_RETRIEVED_SKILLS;
+ }
+
+ /**
+ * Retrieve up to `maxResults` SAFE-projected skills the actor may see,
+ * matching the query. Empty / blank query → no retrieval.
+ */
+ async retrieve(query: string, actor: ActorContext): Promise {
+ const q = query.trim().slice(0, MAX_QUERY_CHARS);
+ if (q.length === 0) return [];
+
+ const orgIds = actor.memberships.map((m) => m.userId);
+ // QUERY-layer visibility: "mixed" = public + private-the-actor-can-read.
+ const { skills } = await this.search.keywordSearch(
+ q,
+ "mixed",
+ actor.userId,
+ orgIds,
+ 1,
+ this.maxResults,
+ );
+
+ // PROJECTION-layer enforcement: re-check readability, strip to SAFE
+ // fields. A doc that somehow slipped past the scope filter but fails
+ // `canReadSkill` is dropped and logged — it must never reach context.
+ const safe: RetrievedSkill[] = [];
+ for (const s of skills) {
+ if (!canReadSkill(s, actor)) {
+ logger.warn(
+ { actor: actor.userId, skill: s.name },
+ "skill passed query scope but failed canReadSkill — dropping (data-safety)",
+ );
+ continue;
+ }
+ safe.push(projectSafeSkill(s));
+ if (safe.length >= this.maxResults) break;
+ }
+ logger.debug(
+ { actor: actor.userId, matched: skills.length, returned: safe.length },
+ "assistant skill retrieval complete",
+ );
+ return safe;
+ }
+}
+
+/**
+ * Strip a full skill document to the SAFE projection (#970). This is the
+ * ONLY place a `SkillDocument` becomes assistant-visible — by listing
+ * fields explicitly (never spreading) a newly-added sensitive field on
+ * `SkillDocument` can't silently leak into the grounding.
+ */
+export function projectSafeSkill(s: SkillDocument): RetrievedSkill {
+ const tags = Array.isArray(s.metadata?.tags) ? [...s.metadata.tags] : [];
+ return {
+ name: s.name,
+ description: s.description,
+ tags,
+ category: s.metadata?.category ?? "",
+ createdOn:
+ s.createdOn instanceof Date ? s.createdOn.toISOString() : String(s.createdOn),
+ createdBy: s.createdBy,
+ };
+}
diff --git a/ornn-api/src/domains/assistant/routes.test.ts b/ornn-api/src/domains/assistant/routes.test.ts
new file mode 100644
index 00000000..6e39070c
--- /dev/null
+++ b/ornn-api/src/domains/assistant/routes.test.ts
@@ -0,0 +1,276 @@
+/**
+ * IT-ASST-* — POST /api/v1/assistant/chat route integration (#970).
+ *
+ * Covers the CONVENTIONS pipeline (auth → validate → model → quota →
+ * SSE), the wire-contract SSE framing, and — mandatory — the end-to-end
+ * data-safety guarantee that no private skill / PII / secret reaches the
+ * streamed context.
+ *
+ * @module domains/assistant/routes.test
+ */
+
+import { describe, expect, it } from "bun:test";
+import { Hono } from "hono";
+import { buildProblemJsonBody } from "../../shared/types/index";
+import type {
+ NyxLlmStreamParams,
+ ResponsesApiStreamEvent,
+} from "../../clients/nyxid/llm";
+import type { SkillDocument } from "../../shared/types/index";
+import type { ModelResolution } from "../settings/llmProviders/service";
+import type { ChargeOutcome } from "../quota/types";
+import { AssistantChatService } from "./chatService";
+import { ScopedSkillRetriever, type SkillSearchPort } from "./retrieval";
+import { createAssistantRoutes } from "./routes";
+import type { AssistantChatEvent } from "./types";
+
+const AUTH = {
+ userId: "u-caller",
+ email: "caller@test.local",
+ displayName: "Caller",
+ permissions: [] as string[],
+};
+
+// ---- fakes -----------------------------------------------------------------
+
+class FakeQuota {
+ allow = true;
+ charges: Array<{ surface: string; outcome: ChargeOutcome }> = [];
+ private chargeResolvers: Array<() => void> = [];
+ async checkAllowed(p: { surface: string }) {
+ return this.allow
+ ? { allowed: true as const, isAdminBypass: false as const }
+ : {
+ allowed: false as const,
+ isAdminBypass: false as const,
+ surface: p.surface as never,
+ message: "over limit",
+ };
+ }
+ async chargeOnCompletion(p: { surface: string; outcome: ChargeOutcome }) {
+ this.charges.push({ surface: p.surface, outcome: p.outcome });
+ this.chargeResolvers.splice(0).forEach((r) => r());
+ }
+ /** Resolves once chargeOnCompletion has been invoked. */
+ charged(): Promise {
+ if (this.charges.length > 0) return Promise.resolve();
+ return new Promise((r) => this.chargeResolvers.push(r));
+ }
+}
+
+class FakeProviders {
+ resolution: ModelResolution = {
+ kind: "ok",
+ modelId: "m-1",
+ displayName: "M1",
+ providerId: "p-1",
+ };
+ async resolveModel(): Promise {
+ return this.resolution;
+ }
+}
+
+/** Chat service that yields a fixed event list. */
+class FixedChat {
+ constructor(private readonly events: AssistantChatEvent[]) {}
+ async *chat(): AsyncGenerator {
+ for (const e of this.events) yield e;
+ }
+}
+
+function makeApp(opts: {
+ withAuth?: boolean;
+ chatService: unknown;
+ quota?: FakeQuota;
+ providers?: FakeProviders;
+}) {
+ const quota = opts.quota ?? new FakeQuota();
+ const providers = opts.providers ?? new FakeProviders();
+ const routes = createAssistantRoutes({
+ chatService: opts.chatService as never,
+ quotaService: quota as never,
+ llmProvidersService: providers as never,
+ keepAliveIntervalMsResolver: async () => 15_000,
+ });
+ const app = new Hono();
+ if (opts.withAuth !== false) {
+ app.use("*", async (c, next) => {
+ c.set("auth" as never, AUTH as never);
+ await next();
+ });
+ }
+ app.route("/api/v1", routes);
+ app.onError((err, c) => {
+ const code = (err as { code?: string }).code ?? "internal_error";
+ const status = (err as { statusCode?: number }).statusCode ?? 500;
+ return c.json(
+ buildProblemJsonBody({
+ statusCode: status,
+ code,
+ message: err.message,
+ instance: c.req.path,
+ requestId: null,
+ }),
+ status as never,
+ );
+ });
+ return { app, quota, providers };
+}
+
+async function postChat(app: Hono, body: unknown) {
+ return app.request("/api/v1/assistant/chat", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+}
+
+// ---- tests -----------------------------------------------------------------
+
+describe("POST /assistant/chat", () => {
+ it("IT-ASST-001: streams chat_start/text_delta/finish with event: + data: framing", async () => {
+ const chat = new FixedChat([
+ { type: "chat_start", model: "m-1" },
+ { type: "chat_text_delta", delta: "Ornn is an API." },
+ { type: "chat_finish", usage: { totalTokens: 5 } },
+ ]);
+ const { app } = makeApp({ chatService: chat });
+ const res = await postChat(app, { messages: [{ role: "user", content: "hi" }] });
+ expect(res.status).toBe(200);
+ expect(res.headers.get("content-type")).toContain("text/event-stream");
+ const text = await res.text();
+ expect(text).toContain("event: chat_start");
+ expect(text).toContain('data: {"type":"chat_start","model":"m-1"}');
+ expect(text).toContain("event: chat_text_delta");
+ expect(text).toContain("Ornn is an API.");
+ expect(text).toContain("event: chat_finish");
+ });
+
+ it("IT-ASST-002: charges quota with the assistant surface + success outcome", async () => {
+ const chat = new FixedChat([
+ { type: "chat_start", model: "m-1" },
+ { type: "chat_text_delta", delta: "hello" },
+ { type: "chat_finish" },
+ ]);
+ const quota = new FakeQuota();
+ const { app } = makeApp({ chatService: chat, quota });
+ const res = await postChat(app, { messages: [{ role: "user", content: "hi" }] });
+ await res.text();
+ await quota.charged();
+ expect(quota.charges).toEqual([{ surface: "assistant", outcome: "success" }]);
+ });
+
+ it("IT-ASST-003: 401 when unauthenticated", async () => {
+ const { app } = makeApp({ chatService: new FixedChat([]), withAuth: false });
+ const res = await postChat(app, { messages: [{ role: "user", content: "hi" }] });
+ expect(res.status).toBe(401);
+ });
+
+ it("IT-ASST-004: 400 on empty messages array", async () => {
+ const { app } = makeApp({ chatService: new FixedChat([]) });
+ const res = await postChat(app, { messages: [] });
+ expect(res.status).toBe(400);
+ });
+
+ it("IT-ASST-005: 503 when no model is enabled for the assistant surface", async () => {
+ const providers = new FakeProviders();
+ providers.resolution = { kind: "no-models-enabled", surface: "assistant" };
+ const { app } = makeApp({ chatService: new FixedChat([]), providers });
+ const res = await postChat(app, { messages: [{ role: "user", content: "hi" }] });
+ expect(res.status).toBe(503);
+ });
+
+ it("IT-ASST-006: 429 when over quota", async () => {
+ const quota = new FakeQuota();
+ quota.allow = false;
+ const { app } = makeApp({ chatService: new FixedChat([]), quota });
+ const res = await postChat(app, { messages: [{ role: "user", content: "hi" }] });
+ expect(res.status).toBe(429);
+ });
+
+ // ---- data-safety through the REAL pipeline -------------------------------
+
+ it("IT-ASST-007: private skill + PII never reach the streamed context", async () => {
+ // A fake LLM that echoes the assembled developer grounding back as a
+ // text delta. Whatever the model "sees" is what the SSE body carries —
+ // so the SSE output IS the assembled context, asserted directly.
+ class EchoLlm {
+ async *stream(p: NyxLlmStreamParams): AsyncIterable {
+ const grounding = String(p.input[0]?.content ?? "");
+ yield { type: "response.output_text.delta", delta: grounding };
+ }
+ }
+
+ const publicSkill: SkillDocument = baseSkill({
+ guid: "pub",
+ name: "public-weather-skill",
+ isPrivate: false,
+ });
+ const privateSkill: SkillDocument = baseSkill({
+ guid: "priv",
+ name: "TOP-SECRET-private-skill",
+ isPrivate: true,
+ createdBy: "someone-else",
+ createdByEmail: "victim@private.example",
+ sharedWithUsers: [],
+ sharedWithOrgs: [],
+ });
+
+ const search: SkillSearchPort = {
+ // Simulate a query-layer that returned BOTH (regression scenario):
+ // the projection-layer canReadSkill must still drop the private one.
+ async keywordSearch() {
+ return { skills: [publicSkill, privateSkill], total: 2 };
+ },
+ };
+ const chatService = new AssistantChatService({
+ llmClient: new EchoLlm(),
+ kbLoader: { load: () => ({ text: "Ornn KB.", estimatedTokens: 1, budgetTokens: 100, truncated: false }) },
+ retriever: new ScopedSkillRetriever({ search }),
+ defaultsResolver: async () => ({ model: "m-1", maxOutputTokens: 1000, temperature: 0.3 }),
+ });
+
+ const { app } = makeApp({ chatService });
+ const res = await postChat(app, {
+ messages: [{ role: "user", content: "what skills can I use?" }],
+ });
+ const body = await res.text();
+
+ // Public skill IS present; private skill + every PII/secret marker is NOT.
+ expect(body).toContain("public-weather-skill");
+ expect(body).not.toContain("TOP-SECRET-private-skill");
+ for (const forbidden of [
+ "victim@private.example",
+ "author@secret.example",
+ "storage/key",
+ "sha256:SECRETHASH",
+ "someone-else",
+ ]) {
+ expect(body.includes(forbidden)).toBe(false);
+ }
+ });
+});
+
+function baseSkill(overrides: Partial): SkillDocument {
+ return {
+ guid: "g",
+ name: "skill",
+ description: "a skill",
+ license: null,
+ compatibility: null,
+ metadata: { category: "misc", tags: ["t"] },
+ skillHash: "sha256:SECRETHASH",
+ storageKey: "storage/key/zip",
+ createdBy: "u-author",
+ createdByEmail: "author@secret.example",
+ createdByDisplayName: "Author Name",
+ createdOn: new Date("2026-01-01T00:00:00.000Z"),
+ updatedBy: "u-author",
+ updatedOn: new Date("2026-01-01T00:00:00.000Z"),
+ isPrivate: false,
+ sharedWithUsers: [],
+ sharedWithOrgs: [],
+ latestVersion: "1.0.0",
+ ...overrides,
+ };
+}
diff --git a/ornn-api/src/domains/assistant/routes.ts b/ornn-api/src/domains/assistant/routes.ts
new file mode 100644
index 00000000..25b5eb08
--- /dev/null
+++ b/ornn-api/src/domains/assistant/routes.ts
@@ -0,0 +1,244 @@
+/**
+ * Ornn Assistant routes (#970).
+ *
+ * POST /assistant/chat — AUTH REQUIRED, SSE.
+ *
+ * Pipeline (mirrors the playground reference, CONVENTIONS-compliant):
+ * nyxidAuth → rateLimit → validateBody → resolveModel(assistant) →
+ * buildActorContext → quota reserve(assistant) → stream → charge.
+ *
+ * Model resolution + the quota reserve run BEFORE the stream opens, so a
+ * misconfig / cap-hit returns a clean RFC 7807 JSON error (never a broken
+ * SSE stream). Once the stream opens, in-stream failures surface as a
+ * `chat_error` event. Everything from the quota reserve to the producer's
+ * `finally` is await-safe, so a reserved slot is always reconciled.
+ *
+ * SSE frames carry BOTH the native `event:` line and a JSON `data:` line
+ * whose `type` equals the event name (CONVENTIONS §6.3).
+ *
+ * @module domains/assistant/routes
+ */
+
+import { Hono } from "hono";
+import { z } from "zod";
+import {
+ type AuthVariables,
+ nyxidAuthMiddleware,
+ getAuth,
+} from "../../middleware/nyxidAuth";
+import { validateBody, getValidatedBody } from "../../middleware/validate";
+import { rateLimit } from "../../middleware/rateLimit";
+import { createLogger } from "../../shared/logger";
+import { buildActorContext } from "../skills/crud/authorize";
+import { throwQuotaError } from "../quota/routes";
+import { throwModelResolutionError } from "../settings/llmProviders/routes";
+import type { ChargeOutcome } from "../quota/types";
+import type { QuotaService } from "../quota/service";
+import type { LlmProvidersService } from "../settings/llmProviders/service";
+import type { AssistantChatService } from "./chatService";
+import { ASSISTANT_SURFACE, type AssistantChatRequest } from "./types";
+
+const logger = createLogger("assistantRoutes");
+
+/**
+ * Per-message content cap — mirrors the playground's `MAX_CHAT_MESSAGE_CHARS`
+ * (~8k tokens at 4 chars/token). The backend enforces it independently of
+ * any frontend `maxLength` so a non-browser client can't slip past.
+ */
+const MAX_CHAT_MESSAGE_CHARS = 32_000;
+
+const assistantMessageSchema = z.object({
+ role: z.enum(["user", "assistant"]),
+ content: z
+ .string()
+ .max(
+ MAX_CHAT_MESSAGE_CHARS,
+ `Message content exceeds ${MAX_CHAT_MESSAGE_CHARS} character limit`,
+ ),
+});
+
+export const assistantChatRequestSchema = z.object({
+ messages: z.array(assistantMessageSchema).min(1).max(100),
+ modelId: z.string().optional(),
+});
+
+export interface AssistantRoutesConfig {
+ readonly chatService: AssistantChatService;
+ readonly quotaService: QuotaService;
+ readonly llmProvidersService: LlmProvidersService;
+ /** SSE keep-alive interval (ms); resolved per-request from settings. */
+ readonly keepAliveIntervalMsResolver: () => Promise;
+}
+
+export function createAssistantRoutes(
+ config: AssistantRoutesConfig,
+): Hono<{ Variables: AuthVariables }> {
+ const { chatService, quotaService, llmProvidersService, keepAliveIntervalMsResolver } =
+ config;
+ const app = new Hono<{ Variables: AuthVariables }>();
+ const auth = nyxidAuthMiddleware();
+
+ app.post(
+ "/assistant/chat",
+ auth,
+ // Per-user rate limit (#809 class). Assistant Q&A is one completion
+ // per request — cheaper than the playground tool loop — but still an
+ // LLM call, so it's capped. Mounted before validateBody so a flood of
+ // malformed bodies 429s before Zod and before any LLM cost.
+ rateLimit({ windowMs: 60_000, max: 30, label: "assistant-chat" }),
+ validateBody(assistantChatRequestSchema, "VALIDATION_ERROR"),
+ async (c) => {
+ const authCtx = getAuth(c);
+ const parsed = getValidatedBody>(c);
+
+ logger.info(
+ { userId: authCtx.userId, messageCount: parsed.messages.length },
+ "Assistant chat request",
+ );
+
+ // Resolve model (assistant surface) BEFORE the quota reserve so a
+ // model/config failure can't strand a reserved slot. Pure read — no
+ // LLM cost — so "429 before LLM cost" still holds.
+ const resolution = await llmProvidersService.resolveModel({
+ surface: ASSISTANT_SURFACE,
+ ...(parsed.modelId !== undefined ? { requested: parsed.modelId } : {}),
+ });
+ if (resolution.kind !== "ok") throwModelResolutionError(resolution);
+ const resolvedModelId = resolution.modelId;
+
+ // Object-level actor (org memberships resolved via the lookup
+ // middleware mounted ahead of these routes in bootstrap). Used by
+ // the scoped skill retrieval inside the chat service.
+ const actor = await buildActorContext(c);
+
+ // Quota reserve (assistant surface) — atomic cap-guarded claim,
+ // rejects with 429 BEFORE any LLM cost. Admins bypass inside the
+ // service. Capture the instant so the eventual charge lands in the
+ // same month bucket the slot was reserved against (#827).
+ const reservedAt = new Date();
+ const decision = await quotaService.checkAllowed({
+ userId: authCtx.userId,
+ permissions: authCtx.permissions,
+ surface: ASSISTANT_SURFACE,
+ now: reservedAt,
+ });
+ if (!decision.allowed) throwQuotaError(decision);
+
+ // Outcome defaults to system_error (refundable); flips to success
+ // on a clean finish. `chargeableStarted` flips on the first real
+ // text delta — once tokens stream the LLM has billed, so an
+ // abort/error after that commits instead of refunding (#766).
+ let outcome: ChargeOutcome = "system_error";
+ let chargeableStarted = false;
+
+ const encoder = new TextEncoder();
+ const signal = c.req.raw.signal;
+ const chatRequest: AssistantChatRequest = parsed;
+
+ const { readable, writable } = new TransformStream();
+ const writer = writable.getWriter();
+
+ let writerClosed = false;
+ const closeOnce = async () => {
+ if (writerClosed) return;
+ writerClosed = true;
+ try {
+ await writer.close();
+ } catch {
+ /* already closed */
+ }
+ };
+ const writeFrame = async (frame: string) => {
+ if (writerClosed) return;
+ try {
+ await writer.write(encoder.encode(frame));
+ } catch {
+ writerClosed = true;
+ }
+ };
+ // Each SSE frame carries the native `event:` line + a JSON `data:`
+ // line whose `type` equals the event name (CONVENTIONS §6.3).
+ const writeEvent = (event: { type: string; [k: string]: unknown }) =>
+ writeFrame(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
+
+ // Pre-flush a padded comment so headers + first chunk hit the wire
+ // immediately and proxies that buffer until ~2-4KB release early.
+ const padding = " ".repeat(2048);
+ void writeFrame(`: stream-open ${Date.now()} ${padding}\n\n`);
+
+ let keepAliveMs = 15_000;
+ try {
+ const resolved = await keepAliveIntervalMsResolver();
+ if (Number.isFinite(resolved) && resolved > 0) keepAliveMs = resolved;
+ } catch (err) {
+ logger.warn(
+ { err: (err as Error).message },
+ "Failed to resolve assistant sseKeepAliveMs; using 15s default",
+ );
+ }
+ const keepAlive = setInterval(() => {
+ void writeFrame(`: keepalive ${Date.now()}\n\n`);
+ }, keepAliveMs);
+
+ const onAbort = () => {
+ clearInterval(keepAlive);
+ void closeOnce();
+ };
+ signal.addEventListener("abort", onAbort);
+
+ void (async () => {
+ try {
+ for await (const event of chatService.chat(actor, chatRequest, signal, {
+ modelId: resolvedModelId,
+ })) {
+ await writeEvent(event);
+ if (event.type === "chat_text_delta" && event.delta.length > 0) {
+ chargeableStarted = true;
+ }
+ if (event.type === "chat_finish") outcome = "success";
+ }
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "Assistant stream failed";
+ logger.error({ userId: authCtx.userId, err: message }, "Assistant stream error");
+ await writeEvent({ type: "chat_error", code: "upstream_unavailable", message });
+ } finally {
+ signal.removeEventListener("abort", onAbort);
+ clearInterval(keepAlive);
+ await closeOnce();
+ if (chargeableStarted && outcome === "system_error") {
+ // Tokens already streamed (billed) before an abort/error —
+ // commit the reserved slot instead of refunding it (#766).
+ outcome = "skill_error";
+ }
+ await quotaService
+ .chargeOnCompletion({
+ userId: authCtx.userId,
+ permissions: authCtx.permissions,
+ surface: ASSISTANT_SURFACE,
+ outcome,
+ modelId: resolvedModelId,
+ now: reservedAt,
+ })
+ .catch((err) => {
+ logger.warn(
+ { userId: authCtx.userId, err: (err as Error).message },
+ "Quota charge after assistant chat failed",
+ );
+ });
+ }
+ })();
+
+ return new Response(readable, {
+ status: 200,
+ headers: {
+ "Content-Type": "text/event-stream; charset=utf-8",
+ "Cache-Control": "no-cache, no-transform",
+ Connection: "keep-alive",
+ "X-Accel-Buffering": "no",
+ },
+ });
+ },
+ );
+
+ return app;
+}
diff --git a/ornn-api/src/domains/assistant/types.ts b/ornn-api/src/domains/assistant/types.ts
new file mode 100644
index 00000000..59318c7a
--- /dev/null
+++ b/ornn-api/src/domains/assistant/types.ts
@@ -0,0 +1,69 @@
+/**
+ * Ornn Assistant domain types (#970).
+ *
+ * The assistant is a pure, non-agentic Q&A chatbot: it answers questions
+ * about Ornn (grounded in the curated KB) and about skills the caller is
+ * allowed to see (grounded in a visibility-scoped retrieval). It never
+ * runs tools, executes skills, or mutates state.
+ *
+ * The SSE event union here is the WIRE CONTRACT the frontend is built
+ * against — `chat_start` / `chat_text_delta` / `chat_error` / `chat_finish`
+ * (+ keepalive comment frames). The route serializes each event to an SSE
+ * frame whose `event:` line equals the `type` field (CONVENTIONS §6).
+ *
+ * @module domains/assistant/types
+ */
+
+/** The LLM surface key this domain reserves/charges/resolves against. */
+export const ASSISTANT_SURFACE = "assistant" as const;
+
+/** Inbound chat turn. Only user/assistant roles — no tool/system turns. */
+export interface AssistantMessage {
+ readonly role: "user" | "assistant";
+ readonly content: string;
+}
+
+export interface AssistantChatRequest {
+ readonly messages: ReadonlyArray;
+ /**
+ * Optional admin-curated model id; falls back to the surface default.
+ * Widened to `| undefined` so a Zod `.optional()`-inferred body assigns
+ * cleanly under exactOptionalPropertyTypes (#657).
+ */
+ readonly modelId?: string | undefined;
+}
+
+/** Optional token-usage report attached to `chat_finish`. */
+export interface AssistantUsage {
+ readonly inputTokens?: number;
+ readonly outputTokens?: number;
+ readonly totalTokens?: number;
+}
+
+/**
+ * SSE event union (the wire contract). Every event carries `type`; the
+ * route mirrors it onto the SSE `event:` line.
+ */
+export type AssistantChatEvent =
+ | { readonly type: "chat_start"; readonly model: string }
+ | { readonly type: "chat_text_delta"; readonly delta: string }
+ | { readonly type: "chat_error"; readonly code: string; readonly message: string }
+ | { readonly type: "chat_finish"; readonly usage?: AssistantUsage };
+
+/**
+ * SAFE projection of a skill for grounding (#970 data-safety). ONLY these
+ * fields ever reach the LLM context or the user. Deliberately excludes
+ * every PII / secret / private-membership field on the source document:
+ * createdByEmail, createdByDisplayName, storageKey, skillHash,
+ * sharedWithUsers, sharedWithOrgs, isPrivate, license, and so on.
+ */
+export interface RetrievedSkill {
+ readonly name: string;
+ readonly description: string;
+ readonly tags: ReadonlyArray;
+ readonly category: string;
+ /** ISO-8601 string. */
+ readonly createdOn: string;
+ /** Author person user_id only — never an email/display name. */
+ readonly createdBy: string;
+}
diff --git a/ornn-api/src/domains/launchPromo/bootstrap.ts b/ornn-api/src/domains/launchPromo/bootstrap.ts
new file mode 100644
index 00000000..e5b2fd6a
--- /dev/null
+++ b/ornn-api/src/domains/launchPromo/bootstrap.ts
@@ -0,0 +1,55 @@
+/**
+ * Launch-promo domain bootstrap (#724) — repo + service + routes.
+ *
+ * The cron loop + GitHub stargazers HTTP client + NyxID GH-login
+ * resolver land in a follow-up PR. This bootstrap exposes everything
+ * needed for the admin manual-award + caller-status endpoints to work
+ * today.
+ *
+ * @module domains/launchPromo/bootstrap
+ */
+
+import type { Hono } from "hono";
+import type { Db } from "mongodb";
+import type { AuthVariables } from "../../middleware/nyxidAuth";
+import { LaunchPromoRepository } from "./repository";
+import { LaunchPromoService } from "./service";
+import { createLaunchPromoRoutes } from "./routes";
+import type { SettingsService } from "../settings/types";
+import type { RedemptionCodeService } from "../redemption-codes/service";
+import type { NotificationRepository } from "../notifications/repository";
+import type { UserDirectoryRepository } from "../users/repository";
+
+export interface LaunchPromoWiring {
+ readonly service: LaunchPromoService;
+ readonly routes: Hono<{ Variables: AuthVariables }>;
+}
+
+export interface LaunchPromoWiringDeps {
+ db: Db;
+ settingsService: SettingsService;
+ redemptionCodeService: RedemptionCodeService;
+ notificationRepo: NotificationRepository;
+ userDirectoryRepo: UserDirectoryRepository;
+}
+
+export async function wireLaunchPromo(
+ deps: LaunchPromoWiringDeps,
+): Promise {
+ const repo = new LaunchPromoRepository(deps.db);
+ await repo.ensureIndexes().catch(() => {
+ /* index creation is best-effort; first write still succeeds without it */
+ });
+
+ const service = new LaunchPromoService({
+ repo,
+ userDirectoryRepo: deps.userDirectoryRepo,
+ settingsService: deps.settingsService,
+ redemptionCodeService: deps.redemptionCodeService,
+ notificationRepo: deps.notificationRepo,
+ });
+
+ const routes = createLaunchPromoRoutes({ service });
+
+ return { service, routes };
+}
diff --git a/ornn-api/src/domains/launchPromo/repository.ts b/ornn-api/src/domains/launchPromo/repository.ts
new file mode 100644
index 00000000..1e917c2d
--- /dev/null
+++ b/ornn-api/src/domains/launchPromo/repository.ts
@@ -0,0 +1,78 @@
+/**
+ * Launch-promo claims repository (#724).
+ *
+ * Wraps the single `launch_promo_claims` collection. A claim doc is
+ * the source-of-truth for "this Ornn user has been awarded the launch
+ * promo grant" — its presence alone is the idempotency gate; we never
+ * mint twice for the same user.
+ *
+ * @module domains/launchPromo/repository
+ */
+
+import type { Collection, Db } from "mongodb";
+import { createLogger } from "../../shared/logger";
+import type { LaunchPromoClaimDoc } from "./types";
+
+const logger = createLogger("launchPromoRepository");
+
+export class LaunchPromoRepository {
+ private readonly collection: Collection;
+
+ constructor(db: Db) {
+ this.collection = db.collection("launch_promo_claims");
+ }
+
+ async ensureIndexes(): Promise {
+ // `_id` is auto-indexed; the second index sorts the admin overview
+ // by award order without paying for a doc scan.
+ await this.collection.createIndex(
+ { awardedAt: -1 },
+ { name: "launch_promo_awardedAt_desc" },
+ );
+ }
+
+ /** Has this user already been awarded? Primary-key lookup. */
+ async hasClaimed(userId: string): Promise {
+ const doc = await this.collection.findOne(
+ { _id: userId },
+ { projection: { _id: 1 } },
+ );
+ return !!doc;
+ }
+
+ /** Read the full claim doc (or null if no claim). */
+ async findByUserId(userId: string): Promise {
+ return this.collection.findOne({ _id: userId });
+ }
+
+ /**
+ * Insert a claim row. Throws on duplicate-key (caller treats that
+ * as "someone else's race won" and skips).
+ */
+ async insert(doc: LaunchPromoClaimDoc): Promise {
+ await this.collection.insertOne(doc);
+ logger.info(
+ {
+ userId: doc._id,
+ rank: doc.eligibilityRank,
+ redemptionCodeId: doc.redemptionCodeId,
+ awardedBy: doc.awardedBy,
+ },
+ "Launch-promo claim recorded",
+ );
+ }
+
+ /** Count of awarded claims — the slot-utilisation gate. */
+ async countAwarded(): Promise {
+ return this.collection.countDocuments({});
+ }
+
+ /** Most-recent claims, for admin observability. */
+ async listRecent(limit: number): Promise {
+ return this.collection
+ .find({})
+ .sort({ awardedAt: -1 })
+ .limit(Math.max(1, Math.min(limit, 500)))
+ .toArray();
+ }
+}
diff --git a/ornn-api/src/domains/launchPromo/routes.ts b/ornn-api/src/domains/launchPromo/routes.ts
new file mode 100644
index 00000000..8f0e4598
--- /dev/null
+++ b/ornn-api/src/domains/launchPromo/routes.ts
@@ -0,0 +1,136 @@
+/**
+ * Launch-promo HTTP routes (#724).
+ *
+ * GET /me/launch-promo — caller's claim status
+ * POST /admin/launch-promo/award/:userId — admin manually award a user
+ * GET /admin/launch-promo/recent — admin observability
+ *
+ * The cron-poll endpoint will land in a follow-up PR with the GitHub
+ * stargazers + NyxID GH-login pieces. The manual admin endpoint is
+ * enough to honour the launch-promo promise today.
+ *
+ * @module domains/launchPromo/routes
+ */
+
+import { Hono } from "hono";
+import {
+ type AuthVariables,
+ nyxidAuthMiddleware,
+ getAuth,
+ requirePermission,
+} from "../../middleware/nyxidAuth";
+import { AppError } from "../../shared/types/index";
+import { createLogger } from "../../shared/logger";
+import type { LaunchPromoService } from "./service";
+import { LAUNCH_PROMO_ERROR_PREFIXES } from "./service";
+
+const logger = createLogger("launchPromoRoutes");
+
+export interface LaunchPromoRoutesConfig {
+ service: LaunchPromoService;
+}
+
+/**
+ * Translate service-layer error sentinels into the right HTTP status +
+ * AppError code. Kept here (route-layer) so the service stays free of
+ * HTTP concerns.
+ */
+function mapServiceError(err: unknown): AppError {
+ const msg = err instanceof Error ? err.message : String(err);
+ for (const prefix of LAUNCH_PROMO_ERROR_PREFIXES) {
+ if (msg.startsWith(`${prefix}:`)) {
+ switch (prefix) {
+ case "PROMO_DISABLED":
+ return AppError.badRequest("PROMO_DISABLED", msg);
+ case "ALREADY_CLAIMED":
+ return AppError.conflict("ALREADY_CLAIMED", msg);
+ case "RANK_EXCEEDED":
+ return AppError.forbidden("RANK_EXCEEDED", msg);
+ case "SLOTS_EXHAUSTED":
+ return AppError.conflict("SLOTS_EXHAUSTED", msg);
+ case "USER_NOT_FOUND":
+ return AppError.notFound("USER_NOT_FOUND", msg);
+ }
+ }
+ }
+ // Unmapped — bubble as 500 via the global error middleware.
+ return AppError.internalError("LAUNCH_PROMO_ERROR", msg);
+}
+
+export function createLaunchPromoRoutes(
+ config: LaunchPromoRoutesConfig,
+): Hono<{ Variables: AuthVariables }> {
+ const { service } = config;
+ const app = new Hono<{ Variables: AuthVariables }>();
+ const auth = nyxidAuthMiddleware();
+
+ // ---- Caller-scoped --------------------------------------------------
+
+ app.get("/me/launch-promo", auth, async (c) => {
+ const { userId } = getAuth(c);
+ const status = await service.getStatusForUser(userId);
+ return c.json({ data: status, error: null });
+ });
+
+ // ---- Admin ---------------------------------------------------------
+
+ app.post(
+ "/admin/launch-promo/award/:userId",
+ auth,
+ requirePermission("ornn:admin:skill"),
+ async (c) => {
+ const targetUserId = c.req.param("userId");
+ const { userId: adminId } = getAuth(c);
+ try {
+ const result = await service.awardUser({
+ userId: targetUserId,
+ awardedBy: adminId,
+ });
+ logger.info(
+ { adminId, targetUserId, redemptionCodeId: result.claim.redemptionCodeId },
+ "Launch-promo manual award succeeded",
+ );
+ return c.json({
+ data: {
+ claim: {
+ userId: result.claim._id,
+ eligibilityRank: result.claim.eligibilityRank,
+ redemptionCodeId: result.claim.redemptionCodeId,
+ redemptionCode: result.redemptionCode,
+ awardedAt: result.claim.awardedAt.toISOString(),
+ awardedBy: result.claim.awardedBy,
+ },
+ },
+ error: null,
+ });
+ } catch (err) {
+ throw mapServiceError(err);
+ }
+ },
+ );
+
+ app.get(
+ "/admin/launch-promo/recent",
+ auth,
+ requirePermission("ornn:admin:skill"),
+ async (c) => {
+ const limit = Math.max(1, Math.min(500, Number(c.req.query("limit") ?? 50) || 50));
+ const items = await service.repoListRecent(limit);
+ return c.json({
+ data: {
+ items: items.map((c) => ({
+ userId: c._id,
+ eligibilityRank: c.eligibilityRank,
+ redemptionCodeId: c.redemptionCodeId,
+ awardedAt: c.awardedAt.toISOString(),
+ awardedBy: c.awardedBy,
+ githubLogin: c.githubLogin ?? null,
+ })),
+ },
+ error: null,
+ });
+ },
+ );
+
+ return app;
+}
diff --git a/ornn-api/src/domains/launchPromo/service.test.ts b/ornn-api/src/domains/launchPromo/service.test.ts
new file mode 100644
index 00000000..214ae03e
--- /dev/null
+++ b/ornn-api/src/domains/launchPromo/service.test.ts
@@ -0,0 +1,250 @@
+/**
+ * Tests for #724: LaunchPromoService.awardUser orchestrates the
+ * eligibility gate, redemption-code mint, notification drop, and
+ * claim insert as one atomic-ish unit. Cover the happy path + every
+ * service-level error sentinel so the route layer's HTTP mapping
+ * stays correct.
+ */
+
+import { beforeEach, describe, expect, it } from "bun:test";
+import { LaunchPromoService, LAUNCH_PROMO_ERROR_PREFIXES } from "./service";
+import type { LaunchPromoClaimDoc } from "./types";
+import type { LaunchPromoRepository } from "./repository";
+import type { UserDirectoryRepository } from "../users/repository";
+import type { SettingsService } from "../settings/types";
+import type { LaunchPromoSection } from "../settings/sections";
+import type { RedemptionCodeService } from "../redemption-codes/service";
+import type { NotificationRepository } from "../notifications/repository";
+
+const DEFAULT_SECTION: LaunchPromoSection = {
+ enabled: true,
+ repoOwner: "ChronoAIProject",
+ repoName: "Ornn",
+ totalSlots: 500,
+ awardPlayground: 200,
+ awardSkillGen: 200,
+ pollIntervalMs: 600_000,
+ codeExpiryDays: 90,
+ nyxidInviteCode: "NYX-TEST-123",
+};
+
+function makeService(opts: {
+ section?: Partial;
+ hasClaimed?: boolean;
+ rank?: number | null;
+ awarded?: number;
+ mintShouldFail?: boolean;
+ insertShouldFail?: "duplicate" | "other" | undefined;
+}): {
+ service: LaunchPromoService;
+ claims: LaunchPromoClaimDoc[];
+ notifications: Array<{ userId: string; title: string }>;
+ mintCalls: Array<{ grants: unknown }>;
+} {
+ const merged: LaunchPromoSection = { ...DEFAULT_SECTION, ...(opts.section ?? {}) };
+ const claims: LaunchPromoClaimDoc[] = [];
+ const notifications: Array<{ userId: string; title: string }> = [];
+ const mintCalls: Array<{ grants: unknown }> = [];
+
+ const repo: LaunchPromoRepository = {
+ ensureIndexes: async () => {},
+ hasClaimed: async () => opts.hasClaimed ?? false,
+ findByUserId: async (id: string) => claims.find((c) => c._id === id) ?? null,
+ insert: async (doc: LaunchPromoClaimDoc) => {
+ if (opts.insertShouldFail === "duplicate") {
+ const err: Error & { code?: number } = new Error("dup");
+ err.code = 11000;
+ throw err;
+ }
+ if (opts.insertShouldFail === "other") {
+ throw new Error("mongo died");
+ }
+ claims.push(doc);
+ },
+ countAwarded: async () => opts.awarded ?? 0,
+ listRecent: async () => claims.slice(),
+ } as unknown as LaunchPromoRepository;
+
+ // `??` collapses both `undefined` and `null` to the default, but the
+ // null case is meaningful here (user not in directory). Use an
+ // explicit-key check so `rank: null` propagates as null.
+ const rankValue: number | null = "rank" in opts ? (opts.rank ?? null) : 42;
+ const userDirectoryRepo: UserDirectoryRepository = {
+ getRegistrationRank: async () => rankValue,
+ } as unknown as UserDirectoryRepository;
+
+ const settingsService: SettingsService = {
+ getLaunchPromo: async () => merged,
+ } as unknown as SettingsService;
+
+ const redemptionCodeService: RedemptionCodeService = {
+ mint: async (p: { grants: unknown }) => {
+ mintCalls.push({ grants: p.grants });
+ if (opts.mintShouldFail) throw new Error("mint blew up");
+ return {
+ _id: "code-id-1",
+ code: "LAUNCH-PROMO-TEST",
+ grants: p.grants,
+ createdAt: new Date(),
+ createdBy: { userId: "x", email: "x", displayName: "x" },
+ expiresAt: new Date(Date.now() + 86400_000),
+ status: "active",
+ } as never;
+ },
+ } as unknown as RedemptionCodeService;
+
+ const notificationRepo: NotificationRepository = {
+ create: async (input: { userId: string; title: string; data?: unknown }) => {
+ notifications.push({ userId: input.userId, title: input.title });
+ return { _id: "n1", ...input, data: input.data ?? {}, readAt: null, createdAt: new Date() } as never;
+ },
+ } as unknown as NotificationRepository;
+
+ const service = new LaunchPromoService({
+ repo,
+ userDirectoryRepo,
+ settingsService,
+ redemptionCodeService,
+ notificationRepo,
+ });
+ return { service, claims, notifications, mintCalls };
+}
+
+describe("LaunchPromoService.awardUser", () => {
+ let now: Date;
+ beforeEach(() => {
+ now = new Date();
+ void now;
+ });
+
+ it("happy path: mints code + records claim + drops notification", async () => {
+ const fx = makeService({ rank: 7, awarded: 3 });
+ const out = await fx.service.awardUser({ userId: "u-7", awardedBy: "admin-1" });
+
+ expect(out.claim._id).toBe("u-7");
+ expect(out.claim.eligibilityRank).toBe(7);
+ expect(out.claim.redemptionCodeId).toBe("code-id-1");
+ expect(out.claim.awardedBy).toBe("admin-1");
+ expect(out.redemptionCode).toBe("LAUNCH-PROMO-TEST");
+ expect(fx.claims).toHaveLength(1);
+ expect(fx.notifications).toHaveLength(1);
+ expect(fx.notifications[0]!.userId).toBe("u-7");
+ expect(fx.notifications[0]!.title).toContain("LAUNCH-PROMO-TEST");
+ expect(fx.mintCalls[0]!.grants).toEqual([
+ { surface: "playground", amount: 200 },
+ { surface: "skillGen", amount: 200 },
+ ]);
+ });
+
+ it("PROMO_DISABLED when section.enabled is false", async () => {
+ const fx = makeService({ section: { enabled: false } });
+ await expect(fx.service.awardUser({ userId: "u", awardedBy: "a" })).rejects.toThrow(
+ /^PROMO_DISABLED:/,
+ );
+ expect(fx.claims).toHaveLength(0);
+ expect(fx.notifications).toHaveLength(0);
+ });
+
+ it("PROMO_DISABLED when both grant amounts are zero", async () => {
+ const fx = makeService({ section: { awardPlayground: 0, awardSkillGen: 0 } });
+ await expect(fx.service.awardUser({ userId: "u", awardedBy: "a" })).rejects.toThrow(
+ /^PROMO_DISABLED:/,
+ );
+ });
+
+ it("ALREADY_CLAIMED short-circuits before mint", async () => {
+ const fx = makeService({ hasClaimed: true });
+ await expect(fx.service.awardUser({ userId: "u", awardedBy: "a" })).rejects.toThrow(
+ /^ALREADY_CLAIMED:/,
+ );
+ expect(fx.mintCalls).toHaveLength(0);
+ });
+
+ it("USER_NOT_FOUND when directory has no rank", async () => {
+ const fx = makeService({ rank: null });
+ await expect(fx.service.awardUser({ userId: "u", awardedBy: "a" })).rejects.toThrow(
+ /^USER_NOT_FOUND:/,
+ );
+ });
+
+ it("RANK_EXCEEDED when user rank is past totalSlots", async () => {
+ const fx = makeService({ rank: 600 }); // > totalSlots 500
+ await expect(fx.service.awardUser({ userId: "u", awardedBy: "a" })).rejects.toThrow(
+ /^RANK_EXCEEDED:/,
+ );
+ });
+
+ it("SLOTS_EXHAUSTED when awarded already met totalSlots", async () => {
+ const fx = makeService({ rank: 1, awarded: 500 });
+ await expect(fx.service.awardUser({ userId: "u", awardedBy: "a" })).rejects.toThrow(
+ /^SLOTS_EXHAUSTED:/,
+ );
+ });
+
+ it("duplicate-key race during insert maps to ALREADY_CLAIMED", async () => {
+ const fx = makeService({ rank: 5, insertShouldFail: "duplicate" });
+ await expect(fx.service.awardUser({ userId: "u", awardedBy: "a" })).rejects.toThrow(
+ /^ALREADY_CLAIMED:/,
+ );
+ });
+
+ it("notification failure does NOT throw — claim still recorded", async () => {
+ const fx = makeService({ rank: 5 });
+ // Sabotage notification repo with a throwing create.
+ void fx; // fx used for claims assertion below
+ const svc = new LaunchPromoService({
+ repo: {
+ ensureIndexes: async () => {},
+ hasClaimed: async () => false,
+ findByUserId: async () => null,
+ insert: async (doc: LaunchPromoClaimDoc) => fx.claims.push(doc),
+ countAwarded: async () => 0,
+ listRecent: async () => fx.claims.slice(),
+ } as unknown as LaunchPromoRepository,
+ userDirectoryRepo: { getRegistrationRank: async () => 5 } as unknown as UserDirectoryRepository,
+ settingsService: { getLaunchPromo: async () => DEFAULT_SECTION } as unknown as SettingsService,
+ redemptionCodeService: {
+ mint: async () => ({ _id: "c", code: "X", grants: [], createdAt: new Date(), createdBy: {} as never, expiresAt: new Date(), status: "active" } as never),
+ } as unknown as RedemptionCodeService,
+ notificationRepo: {
+ create: async () => { throw new Error("notif down"); },
+ } as unknown as NotificationRepository,
+ });
+ const out = await svc.awardUser({ userId: "u", awardedBy: "a" });
+ expect(out.claim).toBeTruthy();
+ expect(fx.claims).toHaveLength(1);
+ });
+
+ it("error sentinels list stays exhaustive", () => {
+ expect(LAUNCH_PROMO_ERROR_PREFIXES).toEqual([
+ "PROMO_DISABLED",
+ "RANK_EXCEEDED",
+ "SLOTS_EXHAUSTED",
+ "ALREADY_CLAIMED",
+ "USER_NOT_FOUND",
+ ]);
+ });
+});
+
+describe("LaunchPromoService.getStatusForUser", () => {
+ it("composes promo enabled + claim + rank + slots remaining", async () => {
+ const fx = makeService({ rank: 12, awarded: 100 });
+ const status = await fx.service.getStatusForUser("u-12");
+ expect(status).toEqual({
+ promoEnabled: true,
+ claimed: false,
+ rank: 12,
+ totalSlots: 500,
+ slotsRemaining: 400,
+ awardedAt: null,
+ });
+ });
+
+ it("claimed=true with awardedAt set when user has claim doc", async () => {
+ const fx = makeService({ rank: 4 });
+ await fx.service.awardUser({ userId: "u-4", awardedBy: "admin" });
+ const status = await fx.service.getStatusForUser("u-4");
+ expect(status.claimed).toBe(true);
+ expect(status.awardedAt).not.toBeNull();
+ });
+});
diff --git a/ornn-api/src/domains/launchPromo/service.ts b/ornn-api/src/domains/launchPromo/service.ts
new file mode 100644
index 00000000..ced03b72
--- /dev/null
+++ b/ornn-api/src/domains/launchPromo/service.ts
@@ -0,0 +1,227 @@
+/**
+ * Launch-promo service (#724) — eligibility + award orchestration.
+ *
+ * Public surface:
+ *
+ * - `getStatusForUser(userId)` — compose the `/me/launch-promo`
+ * response (promo on/off, claimed?, rank, slots remaining).
+ * - `awardUser({ userId, awardedBy, githubLogin? })` — the single
+ * award flow: gate on enabled + rank ≤ totalSlots + slot
+ * availability + not-already-claimed, mint a redemption code via
+ * the redemption-codes domain, drop a `launchPromo.codeDelivered`
+ * notification carrying the code, and record the claim.
+ *
+ * Idempotent: a second `awardUser` for the same user short-circuits
+ * on the claim-row primary-key check. Race-safe: the claim insert
+ * uses `_id = userId` so a duplicate-key error cleanly resolves the
+ * "two callers tried to award the same user at the same instant"
+ * case (one wins, the other gets `ALREADY_CLAIMED`).
+ *
+ * Out of scope here (follow-up PR): GitHub stargazers polling, the
+ * cron loop, and the NyxID → GitHub login lookup. This service exposes
+ * `awardUser` cleanly so the cron just calls it once it knows who
+ * starred.
+ *
+ * @module domains/launchPromo/service
+ */
+
+import type { LaunchPromoRepository } from "./repository";
+import type { LaunchPromoClaimDoc, LaunchPromoStatus } from "./types";
+import type { UserDirectoryRepository } from "../users/repository";
+import type { SettingsService } from "../settings/types";
+import type { RedemptionCodeService } from "../redemption-codes/service";
+import type { NotificationRepository } from "../notifications/repository";
+import { createLogger } from "../../shared/logger";
+
+const logger = createLogger("launchPromoService");
+
+/** Sentinel `awardedBy` value the cron job uses; differentiates from
+ * human admin user-ids in the claim audit trail. */
+export const CRON_ACTOR = "system:cron";
+
+export const LAUNCH_PROMO_ERROR_PREFIXES = [
+ "PROMO_DISABLED",
+ "RANK_EXCEEDED",
+ "SLOTS_EXHAUSTED",
+ "ALREADY_CLAIMED",
+ "USER_NOT_FOUND",
+] as const;
+
+export interface AwardUserParams {
+ userId: string;
+ awardedBy: string;
+ /** Known when the cron matched the user via stargazer list. Stored
+ * on the claim doc for the audit trail. */
+ githubLogin?: string;
+}
+
+export interface AwardUserResult {
+ claim: LaunchPromoClaimDoc;
+ /** The minted redemption code string — caller decides whether to
+ * surface in the notification body. */
+ redemptionCode: string;
+}
+
+export interface LaunchPromoServiceDeps {
+ repo: LaunchPromoRepository;
+ userDirectoryRepo: UserDirectoryRepository;
+ settingsService: SettingsService;
+ redemptionCodeService: RedemptionCodeService;
+ notificationRepo: NotificationRepository;
+}
+
+export class LaunchPromoService {
+ private readonly repo: LaunchPromoRepository;
+ private readonly userDirectoryRepo: UserDirectoryRepository;
+ private readonly settingsService: SettingsService;
+ private readonly redemptionCodeService: RedemptionCodeService;
+ private readonly notificationRepo: NotificationRepository;
+
+ constructor(deps: LaunchPromoServiceDeps) {
+ this.repo = deps.repo;
+ this.userDirectoryRepo = deps.userDirectoryRepo;
+ this.settingsService = deps.settingsService;
+ this.redemptionCodeService = deps.redemptionCodeService;
+ this.notificationRepo = deps.notificationRepo;
+ }
+
+ /** Pass-through for the admin observability endpoint. */
+ async repoListRecent(limit: number): Promise {
+ return this.repo.listRecent(limit);
+ }
+
+ async getStatusForUser(userId: string): Promise {
+ const [section, rank, awarded, claim] = await Promise.all([
+ this.settingsService.getLaunchPromo(),
+ this.userDirectoryRepo.getRegistrationRank(userId),
+ this.repo.countAwarded(),
+ this.repo.findByUserId(userId),
+ ]);
+
+ return {
+ promoEnabled: section.enabled,
+ claimed: !!claim,
+ rank,
+ totalSlots: section.totalSlots,
+ slotsRemaining: Math.max(0, section.totalSlots - awarded),
+ awardedAt: claim ? claim.awardedAt.toISOString() : null,
+ };
+ }
+
+ async awardUser(params: AwardUserParams): Promise {
+ const section = await this.settingsService.getLaunchPromo();
+ if (!section.enabled) {
+ throw new Error("PROMO_DISABLED: launch promo is not enabled");
+ }
+
+ // Idempotency: short-circuit on existing claim row before any
+ // expensive lookup / mint.
+ if (await this.repo.hasClaimed(params.userId)) {
+ throw new Error(`ALREADY_CLAIMED: user '${params.userId}' has already claimed the launch promo`);
+ }
+
+ const rank = await this.userDirectoryRepo.getRegistrationRank(params.userId);
+ if (rank === null) {
+ throw new Error(`USER_NOT_FOUND: user '${params.userId}' is not in the directory`);
+ }
+ if (rank > section.totalSlots) {
+ throw new Error(
+ `RANK_EXCEEDED: user rank ${rank} is past the ${section.totalSlots}-slot cap`,
+ );
+ }
+
+ const awarded = await this.repo.countAwarded();
+ if (awarded >= section.totalSlots) {
+ throw new Error(
+ `SLOTS_EXHAUSTED: ${awarded}/${section.totalSlots} slots already awarded`,
+ );
+ }
+
+ // Mint a redemption code that the user redeems themselves through
+ // the existing /me/redeem UI. The promised "delivered within 24h"
+ // is satisfied by the notification we drop below.
+ const grants: Array<{ surface: "playground" | "skillGen"; amount: number }> = [];
+ if (section.awardPlayground > 0) {
+ grants.push({ surface: "playground", amount: section.awardPlayground });
+ }
+ if (section.awardSkillGen > 0) {
+ grants.push({ surface: "skillGen", amount: section.awardSkillGen });
+ }
+ if (grants.length === 0) {
+ // Misconfiguration: enabled with both grants = 0. Don't mint a
+ // useless code.
+ throw new Error("PROMO_DISABLED: launch promo has zero grants configured");
+ }
+
+ const expiresAt = new Date(
+ Date.now() + section.codeExpiryDays * 24 * 60 * 60 * 1000,
+ );
+ const codeDoc = await this.redemptionCodeService.mint({
+ admin: { userId: "system:launchPromo", email: "launch-promo@ornn", displayName: "Launch Promo" },
+ grants,
+ note: `launch-promo award for ${params.userId} (rank ${rank})`,
+ expiresAt,
+ });
+
+ // Record the claim BEFORE the notification so a notification
+ // failure can't leave us in "code minted but no claim row" state
+ // that would let a retry double-mint.
+ const claim: LaunchPromoClaimDoc = {
+ _id: params.userId,
+ eligibilityRank: rank,
+ redemptionCodeId: codeDoc._id,
+ awardedAt: new Date(),
+ awardedBy: params.awardedBy,
+ ...(params.githubLogin ? { githubLogin: params.githubLogin } : {}),
+ };
+ try {
+ await this.repo.insert(claim);
+ } catch (err) {
+ const code = (err as { code?: number }).code;
+ if (code === 11000) {
+ // Race: someone else awarded in between our two queries.
+ throw new Error(`ALREADY_CLAIMED: user '${params.userId}' claim landed in a race`, { cause: err });
+ }
+ throw err;
+ }
+
+ // Best-effort notification — claim is already recorded, so a
+ // notification failure doesn't cost the user the grant. Admins can
+ // resend via the notifications UI later.
+ try {
+ await this.notificationRepo.create({
+ userId: params.userId,
+ category: "launchPromo.codeDelivered",
+ title: `Your launch promo is ready: ${codeDoc.code}`,
+ body: [
+ `You're in the first ${section.totalSlots} Ornn users — thank you for the early support!`,
+ ``,
+ `Redeem the code below in Settings → Redeem to add ${section.awardPlayground} Playground + ${section.awardSkillGen} Skill Generation credits to your account:`,
+ ``,
+ ` ${codeDoc.code}`,
+ ``,
+ section.nyxidInviteCode
+ ? `The promo also bundles a NyxID invite code: ${section.nyxidInviteCode}`
+ : "",
+ ]
+ .filter((line) => line !== "")
+ .join("\n"),
+ link: "/settings#redeem",
+ data: {
+ redemptionCodeId: codeDoc._id,
+ redemptionCode: codeDoc.code,
+ nyxidInviteCode: section.nyxidInviteCode || null,
+ awardPlayground: section.awardPlayground,
+ awardSkillGen: section.awardSkillGen,
+ },
+ });
+ } catch (err) {
+ logger.warn(
+ { userId: params.userId, err: (err as Error).message },
+ "Launch-promo notification delivery failed — claim is recorded",
+ );
+ }
+
+ return { claim, redemptionCode: codeDoc.code };
+ }
+}
diff --git a/ornn-api/src/domains/launchPromo/types.ts b/ornn-api/src/domains/launchPromo/types.ts
new file mode 100644
index 00000000..3f0208e6
--- /dev/null
+++ b/ornn-api/src/domains/launchPromo/types.ts
@@ -0,0 +1,49 @@
+/**
+ * Launch-promo domain types (#724).
+ *
+ * One claim doc per Ornn user that's been awarded the launch-promo
+ * grant. Append-only: a user is either present (already awarded, code
+ * delivered) or absent. The doc id is the Ornn user id so the
+ * idempotency gate is a single `findOne` on a primary-key lookup; no
+ * scan needed.
+ *
+ * @module domains/launchPromo/types
+ */
+
+export interface LaunchPromoClaimDoc {
+ /** Ornn user id (NyxID user.userId). Primary key. */
+ _id: string;
+ /** Cached Ornn registration rank when the claim was awarded
+ * (1-based; 1 == the very first Ornn user). Stored so an admin
+ * audit can answer "why was this user eligible" without re-running
+ * the rank query. */
+ eligibilityRank: number;
+ /** Redemption-codes domain id of the minted code (admin can pull
+ * the actual code string via that id). */
+ redemptionCodeId: string;
+ /** UTC timestamp of the award. */
+ awardedAt: Date;
+ /** Who triggered the award — admin user id for manual flows,
+ * `"system:cron"` for the GH stargazers cron loop. */
+ awardedBy: string;
+ /** GitHub login at award time, when known. Optional — the cron
+ * path populates it (it knows: that's how it matched the user);
+ * the admin manual-award path may not. */
+ githubLogin?: string;
+}
+
+/** Caller-facing status for `GET /me/launch-promo`. */
+export interface LaunchPromoStatus {
+ /** Whether the promo section is enabled in admin settings. */
+ promoEnabled: boolean;
+ /** Whether the caller has already claimed (and code was delivered). */
+ claimed: boolean;
+ /** Caller's 1-based Ornn registration rank, or null if unknown. */
+ rank: number | null;
+ /** Total slots configured (e.g. 500). */
+ totalSlots: number;
+ /** Slots remaining (totalSlots - awarded count). */
+ slotsRemaining: number;
+ /** ISO timestamp of the claim, if claimed. */
+ awardedAt: string | null;
+}
diff --git a/ornn-api/src/domains/notifications/bootstrap.ts b/ornn-api/src/domains/notifications/bootstrap.ts
index 19b65eed..946f421c 100644
--- a/ornn-api/src/domains/notifications/bootstrap.ts
+++ b/ornn-api/src/domains/notifications/bootstrap.ts
@@ -26,6 +26,9 @@ import type { BroadcastRepository } from "../broadcasts/repository";
export interface NotificationsWiring {
readonly service: NotificationService;
readonly routes: Hono<{ Variables: AuthVariables }>;
+ /** Exposed so other domains (e.g. launch-promo) can publish per-user
+ * notifications without re-instantiating the repo. */
+ readonly repo: NotificationRepository;
}
export async function wireNotifications(deps: {
@@ -55,5 +58,5 @@ export async function wireNotifications(deps: {
broadcastRepo: deps.broadcastRepo,
});
const routes = createNotificationRoutes({ notificationService: service });
- return { service, routes };
+ return { service, routes, repo };
}
diff --git a/ornn-api/src/domains/notifications/types.ts b/ornn-api/src/domains/notifications/types.ts
index c8d9b1dc..2c533249 100644
--- a/ornn-api/src/domains/notifications/types.ts
+++ b/ornn-api/src/domains/notifications/types.ts
@@ -23,7 +23,8 @@
export type NotificationCategory =
| "audit.completed"
| "audit.risky_for_consumer"
- | "quota.credits_granted";
+ | "quota.credits_granted"
+ | "launchPromo.codeDelivered";
export interface NotificationDocument {
readonly _id: string;
diff --git a/ornn-api/src/domains/quota/bootstrap.ts b/ornn-api/src/domains/quota/bootstrap.ts
index 2fe59b49..67ad860f 100644
--- a/ornn-api/src/domains/quota/bootstrap.ts
+++ b/ornn-api/src/domains/quota/bootstrap.ts
@@ -45,13 +45,15 @@ export function wireQuota(deps: {
// inside QuotaService; this resolver just hands it the current
// section values whenever it asks.
getQuotaDefaults: async () => {
- const [pg, sg] = await Promise.all([
+ const [pg, sg, asst] = await Promise.all([
deps.settingsService.getPlayground(),
deps.settingsService.getSkillGen(),
+ deps.settingsService.getAssistant(),
]);
return {
defaultPlaygroundMonthly: pg.defaultMonthlyQuota,
defaultSkillGenMonthly: sg.defaultMonthlyQuota,
+ defaultAssistantMonthly: asst.defaultMonthlyQuota,
};
},
},
diff --git a/ornn-api/src/domains/quota/service.ts b/ornn-api/src/domains/quota/service.ts
index 498d8ffb..46a3944e 100644
--- a/ornn-api/src/domains/quota/service.ts
+++ b/ornn-api/src/domains/quota/service.ts
@@ -24,6 +24,7 @@ import type { QuotaRepository } from "./repository";
import {
DEFAULT_WARNING_THRESHOLD,
type ChargeOutcome,
+ type GrantableSurface,
type QuotaBucketDoc,
type QuotaDecision,
type QuotaSnapshot,
@@ -38,6 +39,14 @@ const logger = createLogger("quotaService");
export interface QuotaDefaults {
defaultPlaygroundMonthly: number;
defaultSkillGenMonthly: number;
+ /**
+ * Ornn Assistant monthly default (#970). Optional so existing
+ * `QuotaDefaultsResolver` mocks keep compiling; the production resolver
+ * always supplies it from `assistant.defaultMonthlyQuota`. When absent,
+ * the assistant surface resolves to a 0 allotment (fail-closed: every
+ * non-admin assistant call is denied until the default is wired).
+ */
+ defaultAssistantMonthly?: number;
}
export interface QuotaDefaultsResolver {
@@ -75,9 +84,14 @@ export class QuotaService {
private async resolveDefault(surface: Surface): Promise {
const def = await this.defaults.getQuotaDefaults();
- return surface === "playground"
- ? def.defaultPlaygroundMonthly
- : def.defaultSkillGenMonthly;
+ switch (surface) {
+ case "playground":
+ return def.defaultPlaygroundMonthly;
+ case "skillGen":
+ return def.defaultSkillGenMonthly;
+ case "assistant":
+ return def.defaultAssistantMonthly ?? 0;
+ }
}
/**
@@ -193,7 +207,7 @@ export class QuotaService {
async grant(params: {
admin: { userId: string; email: string; displayName: string };
targetUserId: string;
- surface: Surface;
+ surface: GrantableSurface;
amount: number;
note?: string;
now?: Date;
@@ -264,7 +278,7 @@ export class QuotaService {
async bulkGrant(params: {
admin: { userId: string; email: string; displayName: string };
targetUserIds: readonly string[];
- surface: Surface;
+ surface: GrantableSurface;
amount: number;
note?: string;
now?: Date;
@@ -363,6 +377,11 @@ export class QuotaService {
}
function buildOverLimitMessage(surface: Surface): string {
- const surfaceLabel = surface === "playground" ? "playground" : "skill-generation";
+ const surfaceLabel =
+ surface === "playground"
+ ? "playground"
+ : surface === "skillGen"
+ ? "skill-generation"
+ : "assistant";
return `You've hit your monthly ${surfaceLabel} limit — contact admin for credits, or upgrade when paid plans launch.`;
}
diff --git a/ornn-api/src/domains/quota/types.ts b/ornn-api/src/domains/quota/types.ts
index 23d3b939..ece3623d 100644
--- a/ornn-api/src/domains/quota/types.ts
+++ b/ornn-api/src/domains/quota/types.ts
@@ -11,10 +11,28 @@
* @module domains/quota/types
*/
-export type Surface = "playground" | "skillGen";
+export type Surface = "playground" | "skillGen" | "assistant";
+/**
+ * Admin-grantable / redeemable surfaces. The Ornn Assistant (#970) is a
+ * billed surface that reserves + charges like the others, but in v1 it is
+ * NOT admin-grantable and NOT redeemable — its allotment comes solely from
+ * the `assistant.defaultMonthlyQuota` section default. So `Surface` (the
+ * reserve/charge type) includes `assistant`, but this list — which drives
+ * the admin-grant + redemption-code surface enums and the quota snapshot
+ * UI — deliberately does not. Add `assistant` here only when those flows
+ * gain assistant support.
+ */
export const SURFACES = ["playground", "skillGen"] as const;
+/**
+ * Surfaces an admin can grant credits to / a redemption code can target.
+ * Narrower than {@link Surface}: the assistant surface (#970) reserves +
+ * charges but isn't grantable in v1, so grant/bulk-grant accept only this
+ * subset (and stay assignable to the notification layer's narrow type).
+ */
+export type GrantableSurface = (typeof SURFACES)[number];
+
export const QUOTA_ADMIN_PERMISSION = "ornn:admin:skill" as const;
/**
diff --git a/ornn-api/src/domains/redemption-codes/types.ts b/ornn-api/src/domains/redemption-codes/types.ts
index 75c8938b..6439e27a 100644
--- a/ornn-api/src/domains/redemption-codes/types.ts
+++ b/ornn-api/src/domains/redemption-codes/types.ts
@@ -15,7 +15,7 @@
*/
import { z } from "zod";
-import { SURFACES, type Surface } from "../quota/types";
+import { SURFACES, type GrantableSurface } from "../quota/types";
/**
* Length of the random portion of a redemption code. 16 chars over a
@@ -41,7 +41,9 @@ export type RedemptionCodeStatus = "active" | "redeemed" | "invalidated";
* the redeem path can apply each grant independently.
*/
export interface RedemptionGrantEntry {
- surface: Surface;
+ // Redemption codes target only admin-grantable surfaces (the assistant
+ // surface isn't redeemable in v1 — see quota/types `GrantableSurface`).
+ surface: GrantableSurface;
amount: number;
}
diff --git a/ornn-api/src/domains/settings/exportImport/exporter.test.ts b/ornn-api/src/domains/settings/exportImport/exporter.test.ts
index 9cafb7da..11837185 100644
--- a/ornn-api/src/domains/settings/exportImport/exporter.test.ts
+++ b/ornn-api/src/domains/settings/exportImport/exporter.test.ts
@@ -58,8 +58,10 @@ function fakeSettingsService(): SettingsService {
displayName: "GPT-4o",
enabledForPlayground: true,
enabledForSkillGen: true,
+ enabledForAssistant: false,
defaultForPlayground: true,
defaultForSkillGen: false,
+ defaultForAssistant: false,
removed: false,
firstSeenAt: new Date("2026-01-01"),
lastSyncedAt: new Date("2026-04-01"),
@@ -79,6 +81,7 @@ function fakeSettingsService(): SettingsService {
return {
getPlayground: () => make("playground"),
getSkillGen: () => make("skillGen"),
+ getAssistant: () => make("assistant"),
getMirror: () => make("mirror"),
getNyxid: () => make("nyxid"),
getSkillAudit: () => make("skillAudit"),
@@ -88,6 +91,7 @@ function fakeSettingsService(): SettingsService {
putSection: async () => ({ value: {} as never, changedFields: [] }),
listLlmProviders: async () => providers,
getLlmProvider: async (id: string) => providers.find((p) => p._id === id) ?? null,
+ getLaunchPromo: () => make("launchPromo"),
invalidateCache: () => {},
};
}
diff --git a/ornn-api/src/domains/settings/exportImport/importer.test.ts b/ornn-api/src/domains/settings/exportImport/importer.test.ts
index 5d592111..69085235 100644
--- a/ornn-api/src/domains/settings/exportImport/importer.test.ts
+++ b/ornn-api/src/domains/settings/exportImport/importer.test.ts
@@ -32,6 +32,7 @@ function fakeSettingsService(initial?: Partial store.get("playground") as never,
getSkillGen: async () => store.get("skillGen") as never,
+ getAssistant: async () => store.get("assistant") as never,
getMirror: async () => store.get("mirror") as never,
getNyxid: async () => store.get("nyxid") as never,
getSkillAudit: async () => store.get("skillAudit") as never,
@@ -47,6 +48,7 @@ function fakeSettingsService(initial?: Partial [],
getLlmProvider: async () => null,
+ getLaunchPromo: async () => store.get("launchPromo") as never,
invalidateCache: () => {},
};
return Object.assign(svc, {
diff --git a/ornn-api/src/domains/settings/exportImport/routes.test.ts b/ornn-api/src/domains/settings/exportImport/routes.test.ts
index b643eede..adacc5b9 100644
--- a/ornn-api/src/domains/settings/exportImport/routes.test.ts
+++ b/ornn-api/src/domains/settings/exportImport/routes.test.ts
@@ -27,6 +27,7 @@ function fakeSettingsService(): SettingsService {
return {
getPlayground: async () => store.get("playground") as never,
getSkillGen: async () => store.get("skillGen") as never,
+ getAssistant: async () => store.get("assistant") as never,
getMirror: async () => store.get("mirror") as never,
getNyxid: async () => store.get("nyxid") as never,
getSkillAudit: async () => store.get("skillAudit") as never,
@@ -41,6 +42,7 @@ function fakeSettingsService(): SettingsService {
},
listLlmProviders: async () => [],
getLlmProvider: async () => null,
+ getLaunchPromo: async () => store.get("launchPromo") as never,
invalidateCache: () => {},
};
}
diff --git a/ornn-api/src/domains/settings/llmProviders/repository.test.ts b/ornn-api/src/domains/settings/llmProviders/repository.test.ts
index d63c7ce0..2e1189e3 100644
--- a/ornn-api/src/domains/settings/llmProviders/repository.test.ts
+++ b/ornn-api/src/domains/settings/llmProviders/repository.test.ts
@@ -61,8 +61,10 @@ function model(
displayName: id,
enabledForPlayground: false,
enabledForSkillGen: false,
+ enabledForAssistant: false,
defaultForPlayground: false,
defaultForSkillGen: false,
+ defaultForAssistant: false,
removed: false,
firstSeenAt: NOW,
lastSyncedAt: NOW,
@@ -245,6 +247,10 @@ describe("LlmProvidersRepository.normalizeModel (read shim)", () => {
expect(m.enabledForSkillGen).toBe(true);
expect(m.defaultForPlayground).toBe(false);
expect(m.defaultForSkillGen).toBe(false);
+ // #970 — a legacy doc predating the assistant surface reads back
+ // with both assistant flags defaulted to false (never auto-routes).
+ expect(m.enabledForAssistant).toBe(false);
+ expect(m.defaultForAssistant).toBe(false);
expect(m.removed).toBe(false);
});
diff --git a/ornn-api/src/domains/settings/llmProviders/repository.ts b/ornn-api/src/domains/settings/llmProviders/repository.ts
index d516d6fc..48e16e01 100644
--- a/ornn-api/src/domains/settings/llmProviders/repository.ts
+++ b/ornn-api/src/domains/settings/llmProviders/repository.ts
@@ -45,7 +45,7 @@ export interface StoredProvider {
}
/** Surface key — must match the in-store field naming convention. */
-export type SurfaceKey = "Playground" | "SkillGen";
+export type SurfaceKey = "Playground" | "SkillGen" | "Assistant";
export class LlmProvidersRepository {
private readonly collection: Collection;
@@ -172,8 +172,13 @@ function normalizeModel(raw: LlmProviderModel & { enabled?: boolean }): LlmProvi
typeof raw.enabledForSkillGen === "boolean"
? raw.enabledForSkillGen
: raw.enabled === true,
+ // #970 — assistant is a net-new surface; pre-#970 docs lack the
+ // flag entirely. Default `false` so an existing model never
+ // auto-routes to the assistant until an admin opts it in.
+ enabledForAssistant: raw.enabledForAssistant === true,
defaultForPlayground: raw.defaultForPlayground === true,
defaultForSkillGen: raw.defaultForSkillGen === true,
+ defaultForAssistant: raw.defaultForAssistant === true,
removed: raw.removed === true,
firstSeenAt: raw.firstSeenAt instanceof Date ? raw.firstSeenAt : new Date(raw.firstSeenAt),
lastSyncedAt: raw.lastSyncedAt instanceof Date ? raw.lastSyncedAt : new Date(raw.lastSyncedAt),
diff --git a/ornn-api/src/domains/settings/llmProviders/routes.test.ts b/ornn-api/src/domains/settings/llmProviders/routes.test.ts
index 3046107f..df53230c 100644
--- a/ornn-api/src/domains/settings/llmProviders/routes.test.ts
+++ b/ornn-api/src/domains/settings/llmProviders/routes.test.ts
@@ -55,11 +55,10 @@ class FakeRepo {
// patchModel needs this when a default flag is flipped on (matches the
// in-memory implementation used by service.test.ts).
async clearDefaultsForSurfaceExcept(
- surface: "Playground" | "SkillGen",
+ surface: "Playground" | "SkillGen" | "Assistant",
keep: { providerId: string; modelId: string } | null,
): Promise {
- const defKey =
- surface === "Playground" ? "defaultForPlayground" : "defaultForSkillGen";
+ const defKey = `defaultFor${surface}` as const;
for (const [id, doc] of this.rows) {
const isKeeper = keep && id === keep.providerId;
const nextModels = doc.models.map((m) => {
diff --git a/ornn-api/src/domains/settings/llmProviders/routes.ts b/ornn-api/src/domains/settings/llmProviders/routes.ts
index 3c740c11..a0191b11 100644
--- a/ornn-api/src/domains/settings/llmProviders/routes.ts
+++ b/ornn-api/src/domains/settings/llmProviders/routes.ts
@@ -37,7 +37,14 @@ import { validateBody, getValidatedBody } from "../../../middleware/validate";
import type { SettingsActor } from "../types";
import type { LlmProvidersService, ModelResolution, Surface } from "./service";
-const surfaceSchema = z.enum(["playground", "skillGen"]);
+const surfaceSchema = z.enum(["playground", "skillGen", "assistant"]);
+
+/** Human-facing surface labels for resolution-error messages. */
+const SURFACE_LABEL: Record = {
+ playground: "playground",
+ skillGen: "skill-generation",
+ assistant: "assistant",
+};
/**
* Translate a `ModelResolution` failure into an HTTP error. Shared
@@ -49,8 +56,7 @@ export function throwModelResolutionError(resolution: ModelResolution): never {
throw new Error("throwModelResolutionError called on ok resolution");
}
if (resolution.kind === "no-models-enabled") {
- const surfaceLabel =
- resolution.surface === "playground" ? "playground" : "skill-generation";
+ const surfaceLabel = SURFACE_LABEL[resolution.surface];
throw AppError.serviceUnavailable(
"MODEL_UNAVAILABLE",
`${surfaceLabel} is temporarily unavailable — contact admin to enable a model.`,
@@ -201,7 +207,7 @@ export function createLlmPickerRoutes(
if (!parsed.success) {
throw AppError.badRequest(
"invalid_surface",
- "Query param 'surface' must be 'playground' or 'skillGen'",
+ "Query param 'surface' must be 'playground', 'skillGen', or 'assistant'",
);
}
const surface: Surface = parsed.data;
diff --git a/ornn-api/src/domains/settings/llmProviders/service.test.ts b/ornn-api/src/domains/settings/llmProviders/service.test.ts
index e1c4e81f..fee3b999 100644
--- a/ornn-api/src/domains/settings/llmProviders/service.test.ts
+++ b/ornn-api/src/domains/settings/llmProviders/service.test.ts
@@ -38,11 +38,10 @@ class FakeRepo {
return this.rows.delete(id);
}
async clearDefaultsForSurfaceExcept(
- surface: "Playground" | "SkillGen",
+ surface: "Playground" | "SkillGen" | "Assistant",
keep: { providerId: string; modelId: string } | null,
): Promise {
- const defKey =
- surface === "Playground" ? "defaultForPlayground" : "defaultForSkillGen";
+ const defKey = `defaultFor${surface}` as const;
for (const [id, doc] of this.rows) {
const isKeeper = keep && id === keep.providerId;
const nextModels = doc.models.map((m) => {
@@ -416,6 +415,146 @@ describe("LlmProvidersService", () => {
expect(isMidMaskSentinel(masked.auth.apiKey)).toBe(true);
});
+ // ──────────────── #970 — assistant surface ────────────────
+
+ it("UT-LLM-ASST-001: resolveModel(assistant) → no-models-enabled until a model opts in", async () => {
+ // baseInput enables gpt-4o for playground + skillGen but NOT for the
+ // assistant — a brand-new surface must start with zero routable
+ // models so it never silently borrows another surface's default.
+ const { svc } = makeService();
+ await svc.create(
+ { ...baseInput, auth: { kind: "apiKey", apiKey: "k" } },
+ ACTOR,
+ );
+ const resolution = await svc.resolveModel({ surface: "assistant" });
+ expect(resolution.kind).toBe("no-models-enabled");
+ });
+
+ it("UT-LLM-ASST-002: patchModel(defaultForAssistant) auto-enables + resolveModel picks it", async () => {
+ const { svc } = makeService();
+ const created = await svc.create(
+ { ...baseInput, auth: { kind: "apiKey", apiKey: "k" } },
+ ACTOR,
+ );
+ const after = await svc.patchModel(
+ created._id,
+ "gpt-4o",
+ { defaultForAssistant: true },
+ ACTOR,
+ );
+ const gpt4o = after.models.find((m) => m.id === "gpt-4o")!;
+ expect(gpt4o.defaultForAssistant).toBe(true);
+ expect(gpt4o.enabledForAssistant).toBe(true);
+
+ const resolution = await svc.resolveModel({ surface: "assistant" });
+ expect(resolution.kind).toBe("ok");
+ if (resolution.kind === "ok") expect(resolution.modelId).toBe("gpt-4o");
+ });
+
+ it("UT-LLM-ASST-003: resolveModel(assistant) prefers default over first-enabled", async () => {
+ const { svc } = makeService();
+ await svc.create(
+ {
+ ...baseInput,
+ name: "asst",
+ auth: { kind: "apiKey", apiKey: "k" },
+ models: [
+ {
+ id: "alpha",
+ displayName: "Alpha",
+ enabledForAssistant: true,
+ },
+ {
+ id: "bravo",
+ displayName: "Bravo",
+ enabledForAssistant: true,
+ defaultForAssistant: true,
+ },
+ ],
+ },
+ ACTOR,
+ );
+ const resolution = await svc.resolveModel({ surface: "assistant" });
+ expect(resolution.kind).toBe("ok");
+ // bravo is the surface default even though alpha sorts first by name.
+ if (resolution.kind === "ok") expect(resolution.modelId).toBe("bravo");
+ });
+
+ it("UT-LLM-ASST-004: requested model not enabled for assistant → not-enabled", async () => {
+ const { svc } = makeService();
+ await svc.create(
+ { ...baseInput, auth: { kind: "apiKey", apiKey: "k" } },
+ ACTOR,
+ );
+ // gpt-4o is enabled for playground/skillGen but not assistant.
+ const resolution = await svc.resolveModel({
+ surface: "assistant",
+ requested: "gpt-4o",
+ });
+ expect(resolution.kind).toBe("not-enabled");
+ });
+
+ it("UT-LLM-ASST-005: assistant default flip is surface-isolated (#970)", async () => {
+ // baseInput marks gpt-4o as the playground AND skillGen default.
+ // Setting gpt-3.5 as the assistant default must NOT disturb either
+ // of gpt-4o's other-surface defaults — surfaces are independent.
+ const { svc } = makeService();
+ const created = await svc.create(
+ { ...baseInput, auth: { kind: "apiKey", apiKey: "k" } },
+ ACTOR,
+ );
+ const after = await svc.patchModel(
+ created._id,
+ "gpt-3.5",
+ { defaultForAssistant: true },
+ ACTOR,
+ );
+ const gpt4o = after.models.find((m) => m.id === "gpt-4o")!;
+ expect(gpt4o.defaultForPlayground).toBe(true);
+ expect(gpt4o.defaultForSkillGen).toBe(true);
+ expect(gpt4o.defaultForAssistant).toBe(false);
+ const gpt35 = after.models.find((m) => m.id === "gpt-3.5")!;
+ expect(gpt35.defaultForAssistant).toBe(true);
+ expect(gpt35.enabledForAssistant).toBe(true);
+ });
+
+ it("UT-LLM-ASST-006: assistant default is at-most-one across providers (#970)", async () => {
+ const { svc } = makeService();
+ const a = await svc.create(
+ {
+ ...baseInput,
+ name: "alpha",
+ auth: { kind: "apiKey", apiKey: "k1" },
+ models: [
+ {
+ id: "m-a",
+ displayName: "M-A",
+ enabledForAssistant: true,
+ defaultForAssistant: true,
+ },
+ ],
+ },
+ ACTOR,
+ );
+ const b = await svc.create(
+ {
+ ...baseInput,
+ name: "beta",
+ auth: { kind: "apiKey", apiKey: "k2" },
+ models: [{ id: "m-b", displayName: "M-B", enabledForAssistant: true }],
+ },
+ ACTOR,
+ );
+ // Promote m-b on provider beta → m-a's default must clear.
+ await svc.patchModel(b._id, "m-b", { defaultForAssistant: true }, ACTOR);
+ const alpha = await svc.get(a._id);
+ const mA = alpha!.models.find((m) => m.id === "m-a")!;
+ expect(mA.defaultForAssistant).toBe(false);
+ const beta = await svc.get(b._id);
+ const mB = beta!.models.find((m) => m.id === "m-b")!;
+ expect(mB.defaultForAssistant).toBe(true);
+ });
+
it("UT-LLM-013: sentinel apiKey on update preserves DB value", async () => {
const { svc } = makeService();
const created = await svc.create(
diff --git a/ornn-api/src/domains/settings/llmProviders/service.ts b/ornn-api/src/domains/settings/llmProviders/service.ts
index ac26bf5f..3b149da1 100644
--- a/ornn-api/src/domains/settings/llmProviders/service.ts
+++ b/ornn-api/src/domains/settings/llmProviders/service.ts
@@ -62,13 +62,23 @@ import type {
const logger = createLogger("llmProvidersService");
/** Surfaces the picker / resolver care about. Mirror of `quota/types.ts:Surface`. */
-export type Surface = "playground" | "skillGen";
+export type Surface = "playground" | "skillGen" | "assistant";
const SURFACE_KEY: Record = {
playground: "Playground",
skillGen: "SkillGen",
+ assistant: "Assistant",
};
+/**
+ * Canonical surface list. Loops that must touch every surface (model
+ * coherence rules, default-clearing) iterate this so adding a surface
+ * is a single-line change to `SURFACE_KEY` + this array.
+ */
+export const ALL_SURFACES: ReadonlyArray = Object.keys(
+ SURFACE_KEY,
+) as Surface[];
+
// ---------------------------------------------------------------------------
// Input schemas
// ---------------------------------------------------------------------------
@@ -109,8 +119,10 @@ const modelInputSchema = z.object({
displayName: z.string().min(1),
enabledForPlayground: z.boolean().optional(),
enabledForSkillGen: z.boolean().optional(),
+ enabledForAssistant: z.boolean().optional(),
defaultForPlayground: z.boolean().optional(),
defaultForSkillGen: z.boolean().optional(),
+ defaultForAssistant: z.boolean().optional(),
removed: z.boolean().optional(),
});
@@ -150,8 +162,10 @@ export const modelFlagsPatchSchema = z
.object({
enabledForPlayground: z.boolean().optional(),
enabledForSkillGen: z.boolean().optional(),
+ enabledForAssistant: z.boolean().optional(),
defaultForPlayground: z.boolean().optional(),
defaultForSkillGen: z.boolean().optional(),
+ defaultForAssistant: z.boolean().optional(),
})
.refine((v) => Object.keys(v).length > 0, {
message: "At least one flag must be provided",
@@ -399,8 +413,10 @@ export class LlmProvidersService {
displayName: m.displayName,
enabledForPlayground: m.enabledForPlayground === true,
enabledForSkillGen: m.enabledForSkillGen === true,
+ enabledForAssistant: m.enabledForAssistant === true,
defaultForPlayground: m.defaultForPlayground === true,
defaultForSkillGen: m.defaultForSkillGen === true,
+ defaultForAssistant: m.defaultForAssistant === true,
removed: m.removed === true,
firstSeenAt: now,
lastSyncedAt: now,
@@ -449,10 +465,14 @@ export class LlmProvidersService {
m.enabledForPlayground ?? prev?.enabledForPlayground ?? false,
enabledForSkillGen:
m.enabledForSkillGen ?? prev?.enabledForSkillGen ?? false,
+ enabledForAssistant:
+ m.enabledForAssistant ?? prev?.enabledForAssistant ?? false,
defaultForPlayground:
m.defaultForPlayground ?? prev?.defaultForPlayground ?? false,
defaultForSkillGen:
m.defaultForSkillGen ?? prev?.defaultForSkillGen ?? false,
+ defaultForAssistant:
+ m.defaultForAssistant ?? prev?.defaultForAssistant ?? false,
removed: m.removed ?? prev?.removed ?? false,
firstSeenAt: prev?.firstSeenAt ?? now,
lastSyncedAt: prev?.lastSyncedAt ?? now,
@@ -532,7 +552,7 @@ export class LlmProvidersService {
// Compute the new flags, applying coherence rules.
let next: LlmProviderModel = { ...current };
- for (const surface of ["playground", "skillGen"] as const) {
+ for (const surface of ALL_SURFACES) {
const enKey = enabledFieldFor(surface);
const defKey = defaultFieldFor(surface);
if (flags[enKey] !== undefined) {
@@ -554,7 +574,7 @@ export class LlmProvidersService {
// Cross-provider clears: for each surface where this row is now
// the default, blow away the flag on every other model first.
- for (const surface of ["playground", "skillGen"] as const) {
+ for (const surface of ALL_SURFACES) {
const defKey = defaultFieldFor(surface);
if (next[defKey] === true) {
await this.repo.clearDefaultsForSurfaceExcept(SURFACE_KEY[surface], {
@@ -574,8 +594,10 @@ export class LlmProvidersService {
...m,
enabledForPlayground: next.enabledForPlayground,
enabledForSkillGen: next.enabledForSkillGen,
+ enabledForAssistant: next.enabledForAssistant,
defaultForPlayground: next.defaultForPlayground,
defaultForSkillGen: next.defaultForSkillGen,
+ defaultForAssistant: next.defaultForAssistant,
}
: m,
);
@@ -640,8 +662,10 @@ export class LlmProvidersService {
displayName: u.displayName,
enabledForPlayground: false,
enabledForSkillGen: false,
+ enabledForAssistant: false,
defaultForPlayground: false,
defaultForSkillGen: false,
+ defaultForAssistant: false,
removed: false,
firstSeenAt: now,
lastSyncedAt: now,
@@ -657,8 +681,10 @@ export class LlmProvidersService {
displayName: u.displayName,
enabledForPlayground: prev.enabledForPlayground,
enabledForSkillGen: prev.enabledForSkillGen,
+ enabledForAssistant: prev.enabledForAssistant,
defaultForPlayground: prev.defaultForPlayground,
defaultForSkillGen: prev.defaultForSkillGen,
+ defaultForAssistant: prev.defaultForAssistant,
removed: false,
firstSeenAt: prev.firstSeenAt,
lastSyncedAt: now,
@@ -675,6 +701,7 @@ export class LlmProvidersService {
removed: true,
defaultForPlayground: false,
defaultForSkillGen: false,
+ defaultForAssistant: false,
lastSyncedAt: now,
});
if (!wasRemoved) removed += 1;
@@ -804,12 +831,12 @@ export class LlmProvidersService {
// Helpers
// ---------------------------------------------------------------------------
-export function enabledFieldFor(surface: Surface): "enabledForPlayground" | "enabledForSkillGen" {
- return surface === "playground" ? "enabledForPlayground" : "enabledForSkillGen";
+export function enabledFieldFor(surface: Surface): `enabledFor${SurfaceKey}` {
+ return `enabledFor${SURFACE_KEY[surface]}`;
}
-export function defaultFieldFor(surface: Surface): "defaultForPlayground" | "defaultForSkillGen" {
- return surface === "playground" ? "defaultForPlayground" : "defaultForSkillGen";
+export function defaultFieldFor(surface: Surface): `defaultFor${SurfaceKey}` {
+ return `defaultFor${SURFACE_KEY[surface]}`;
}
function safeDecrypt(blob: string, key: string): string {
diff --git a/ornn-api/src/domains/settings/llmProviders/types.ts b/ornn-api/src/domains/settings/llmProviders/types.ts
index faa5698b..5d22379c 100644
--- a/ornn-api/src/domains/settings/llmProviders/types.ts
+++ b/ornn-api/src/domains/settings/llmProviders/types.ts
@@ -26,6 +26,8 @@ export interface LlmProviderModel {
*/
readonly enabledForPlayground: boolean;
readonly enabledForSkillGen: boolean;
+ /** #970 — Ornn Assistant surface (repo-aware Q&A chatbot). */
+ readonly enabledForAssistant: boolean;
/**
* Per-surface default flags. Server enforces at-most-one-true
* across **all providers** — setting a default on one model clears
@@ -36,6 +38,8 @@ export interface LlmProviderModel {
*/
readonly defaultForPlayground: boolean;
readonly defaultForSkillGen: boolean;
+ /** #970 — Ornn Assistant surface default. */
+ readonly defaultForAssistant: boolean;
/**
* `removed` flips to true when a previously-known model disappears
* from the upstream catalog. Kept for history / lifetime breakdowns;
diff --git a/ornn-api/src/domains/settings/sections/assistant.ts b/ornn-api/src/domains/settings/sections/assistant.ts
new file mode 100644
index 00000000..ed03dd7c
--- /dev/null
+++ b/ornn-api/src/domains/settings/sections/assistant.ts
@@ -0,0 +1,49 @@
+/**
+ * Ornn Assistant section schema (#970).
+ *
+ * The Assistant is the third LLM surface (after `playground` and
+ * `skillGen`). It powers the repo-aware Q&A chatbot — a pure,
+ * non-agentic completion grounded in a curated knowledge base plus a
+ * visibility-scoped skill retrieval. This section owns the same knobs
+ * every LLM surface owns so the resolver / quota / SSE machinery can
+ * treat it uniformly:
+ *
+ * - Default LLM provider + model (picker seed + execute-path fallback)
+ * - SSE keep-alive cadence for the streaming chat
+ * - Default monthly quota for non-admin users
+ *
+ * Mirrors `playground.ts` / `skillGen.ts` field-for-field so a new
+ * surface is purely additive: one section schema + one `getXxx()`
+ * accessor + the per-model surface flags. No other surface's behaviour
+ * changes.
+ *
+ * @module domains/settings/sections/assistant
+ */
+import { z } from "zod";
+import type { SectionMeta } from "./index";
+
+export const assistantSchema = z.object({
+ defaultProviderId: z.string().nullable(),
+ defaultModelId: z.string().nullable(),
+ sseKeepAliveMs: z.number().int().min(1000).max(600_000),
+ defaultMonthlyQuota: z.number().int().min(0).max(1_000_000),
+});
+
+export type AssistantSection = z.infer;
+
+export const assistantDefaults: AssistantSection = {
+ defaultProviderId: null,
+ defaultModelId: null,
+ sseKeepAliveMs: 15_000,
+ // Q&A turns are cheaper + more frequent than skill generation but the
+ // surface is still LLM-billed; seed a middle-ground monthly allotment.
+ defaultMonthlyQuota: 100,
+};
+
+export const assistantSection: SectionMeta = {
+ id: "assistant",
+ publicPath: "assistant",
+ schema: assistantSchema,
+ secretFields: [],
+ defaults: assistantDefaults,
+};
diff --git a/ornn-api/src/domains/settings/sections/index.ts b/ornn-api/src/domains/settings/sections/index.ts
index 3909a3a7..0a3b976c 100644
--- a/ornn-api/src/domains/settings/sections/index.ts
+++ b/ornn-api/src/domains/settings/sections/index.ts
@@ -9,6 +9,7 @@
* @module domains/settings/sections
*/
+import { assistantSection, type AssistantSection } from "./assistant";
import { mirrorSection, type MirrorSection } from "./mirror";
import { nyxidSection, type NyxidSection } from "./nyxid";
import { playgroundSection, type PlaygroundSection } from "./playground";
@@ -16,8 +17,10 @@ import { skillAuditSection, type SkillAuditSection } from "./skillAudit";
import { skillGenSection, type SkillGenSection } from "./skillGen";
import { telemetrySection, type TelemetrySection } from "./telemetry";
import { extrasSection, type ExtrasSection } from "./extras";
+import { launchPromoSection, type LaunchPromoSection } from "./launchPromo";
export {
+ assistantSection,
mirrorSection,
nyxidSection,
playgroundSection,
@@ -25,9 +28,11 @@ export {
skillGenSection,
telemetrySection,
extrasSection,
+ launchPromoSection,
};
export type {
+ AssistantSection,
MirrorSection,
NyxidSection,
PlaygroundSection,
@@ -35,16 +40,19 @@ export type {
SkillGenSection,
TelemetrySection,
ExtrasSection,
+ LaunchPromoSection,
};
export type SectionId =
| "playground"
| "skillGen"
+ | "assistant"
| "mirror"
| "nyxid"
| "skillAudit"
| "telemetry"
- | "extras";
+ | "extras"
+ | "launchPromo";
export interface SectionMeta {
/** Stable section id, also the Mongo `_id` of the section row. */
@@ -62,9 +70,11 @@ export interface SectionMeta {
export const sections = {
playground: playgroundSection,
skillGen: skillGenSection,
+ assistant: assistantSection,
mirror: mirrorSection,
nyxid: nyxidSection,
skillAudit: skillAuditSection,
telemetry: telemetrySection,
extras: extrasSection,
+ launchPromo: launchPromoSection,
} as const;
diff --git a/ornn-api/src/domains/settings/sections/launchPromo.ts b/ornn-api/src/domains/settings/sections/launchPromo.ts
new file mode 100644
index 00000000..6fc4926d
--- /dev/null
+++ b/ornn-api/src/domains/settings/sections/launchPromo.ts
@@ -0,0 +1,81 @@
+/**
+ * Launch-promo section schema (#724).
+ *
+ * Drives the GitHub-star → Ornn-credit promo announced on the landing /
+ * news page. The cron job (and admin manual-award endpoint) read this
+ * section to decide whether the promo is active, where to look for
+ * stargazers, how many slots are still available, and what the per-claim
+ * grants are.
+ *
+ * Defaults are deliberately conservative: `enabled: false` and zero
+ * grants. An admin has to opt in + configure the slot count + grant
+ * amounts explicitly before any claim can land.
+ *
+ * @module domains/settings/sections/launchPromo
+ */
+
+import { z } from "zod";
+import type { SectionMeta } from "./index";
+
+/** GitHub `owner/repo` slug regex. */
+const REPO_SEGMENT_RE = /^[A-Za-z0-9._-]{1,100}$/;
+
+export const launchPromoSchema = z.object({
+ enabled: z.boolean(),
+ /** GitHub repo owner (login). */
+ repoOwner: z.string().regex(REPO_SEGMENT_RE).or(z.literal("")),
+ /** GitHub repo name. */
+ repoName: z.string().regex(REPO_SEGMENT_RE).or(z.literal("")),
+ /**
+ * Maximum number of Ornn users that can ever claim this promo. The
+ * service refuses to award if `claimed >= totalSlots`. Per the design
+ * decision: "first 500 by Ornn registration order".
+ */
+ totalSlots: z.number().int().min(0).max(100000),
+ /** Per-claim grant — Playground surface (monthly credits). */
+ awardPlayground: z.number().int().min(0).max(1_000_000),
+ /** Per-claim grant — Skill Generation surface. */
+ awardSkillGen: z.number().int().min(0).max(1_000_000),
+ /**
+ * Cron poll interval. Set to 0 to disable the auto-poll loop entirely
+ * (admin still gets the manual award endpoints). 5–10 min is the
+ * sweet spot per the #724 design call.
+ */
+ pollIntervalMs: z.number().int().min(0).max(24 * 60 * 60 * 1000),
+ /**
+ * Days a minted launch-promo redemption code stays valid before
+ * expiry. The promo announcement promises "delivered within 24h"; the
+ * code itself sticks around longer so users can redeem at their
+ * leisure.
+ */
+ codeExpiryDays: z.number().int().min(1).max(365),
+ /**
+ * Static NyxID invite code shown alongside the Ornn redemption code
+ * in the per-claim notification body. Mirrors the code printed on
+ * landing/news. Editable here so a rotation doesn't require a
+ * redeploy.
+ */
+ nyxidInviteCode: z.string().max(64).or(z.literal("")),
+});
+
+export type LaunchPromoSection = z.infer;
+
+export const launchPromoDefaults: LaunchPromoSection = {
+ enabled: false,
+ repoOwner: "",
+ repoName: "",
+ totalSlots: 500,
+ awardPlayground: 200,
+ awardSkillGen: 200,
+ pollIntervalMs: 10 * 60 * 1000,
+ codeExpiryDays: 90,
+ nyxidInviteCode: "",
+};
+
+export const launchPromoSection: SectionMeta = {
+ id: "launchPromo",
+ publicPath: "launch-promo",
+ schema: launchPromoSchema,
+ secretFields: [],
+ defaults: launchPromoDefaults,
+};
diff --git a/ornn-api/src/domains/settings/sections/sections.test.ts b/ornn-api/src/domains/settings/sections/sections.test.ts
index b97eb35f..259564f3 100644
--- a/ornn-api/src/domains/settings/sections/sections.test.ts
+++ b/ornn-api/src/domains/settings/sections/sections.test.ts
@@ -6,6 +6,7 @@
import { describe, expect, it } from "bun:test";
import {
+ assistantSection,
extrasSection,
mirrorSection,
nyxidSection,
@@ -105,6 +106,60 @@ describe("section schemas", () => {
).toBe(false);
});
+ // -------- assistant (#970) --------
+ it("UT-SCHEMA-ASST-001: assistant defaults are valid + nullable provider/model", () => {
+ expect(
+ assistantSection.schema.safeParse(assistantSection.defaults).success,
+ ).toBe(true);
+ expect(assistantSection.defaults.defaultProviderId).toBeNull();
+ expect(assistantSection.defaults.defaultModelId).toBeNull();
+ expect(assistantSection.id).toBe("assistant");
+ expect(assistantSection.publicPath).toBe("assistant");
+ expect(assistantSection.secretFields).toEqual([]);
+ });
+
+ it("UT-SCHEMA-ASST-002: assistant sseKeepAliveMs bounds", () => {
+ expect(
+ assistantSection.schema.safeParse({
+ ...assistantSection.defaults,
+ sseKeepAliveMs: 15_000,
+ }).success,
+ ).toBe(true);
+ expect(
+ assistantSection.schema.safeParse({
+ ...assistantSection.defaults,
+ sseKeepAliveMs: 999,
+ }).success,
+ ).toBe(false);
+ expect(
+ assistantSection.schema.safeParse({
+ ...assistantSection.defaults,
+ sseKeepAliveMs: 600_001,
+ }).success,
+ ).toBe(false);
+ });
+
+ it("UT-SCHEMA-ASST-003: assistant defaultMonthlyQuota bounds", () => {
+ expect(
+ assistantSection.schema.safeParse({
+ ...assistantSection.defaults,
+ defaultMonthlyQuota: 0,
+ }).success,
+ ).toBe(true);
+ expect(
+ assistantSection.schema.safeParse({
+ ...assistantSection.defaults,
+ defaultMonthlyQuota: -1,
+ }).success,
+ ).toBe(false);
+ expect(
+ assistantSection.schema.safeParse({
+ ...assistantSection.defaults,
+ defaultMonthlyQuota: 1_000_001,
+ }).success,
+ ).toBe(false);
+ });
+
// -------- nyxid --------
it("UT-SCHEMA-NYX-001: tokenUrl must be http(s) or empty", () => {
expect(
diff --git a/ornn-api/src/domains/settings/service.ts b/ornn-api/src/domains/settings/service.ts
index d7ac959c..935939db 100644
--- a/ornn-api/src/domains/settings/service.ts
+++ b/ornn-api/src/domains/settings/service.ts
@@ -31,7 +31,9 @@ import type { LlmProvider } from "./llmProviders/types";
import type { SettingsRepository } from "./repository";
import {
sections,
+ type AssistantSection,
type ExtrasSection,
+ type LaunchPromoSection,
type MirrorSection,
type NyxidSection,
type PlaygroundSection,
@@ -98,6 +100,9 @@ export class SettingsServiceImpl implements SettingsService {
async getSkillGen(): Promise {
return this.getSection("skillGen");
}
+ async getAssistant(): Promise {
+ return this.getSection("assistant");
+ }
async getMirror(): Promise {
return this.getSection("mirror");
}
@@ -113,6 +118,9 @@ export class SettingsServiceImpl implements SettingsService {
async getExtras(): Promise {
return this.getSection("extras");
}
+ async getLaunchPromo(): Promise {
+ return this.getSection("launchPromo");
+ }
async getSection(id: SectionId): Promise {
const cached = this.cache.get(id);
diff --git a/ornn-api/src/domains/settings/types.ts b/ornn-api/src/domains/settings/types.ts
index dd08eb31..a9f47c69 100644
--- a/ornn-api/src/domains/settings/types.ts
+++ b/ornn-api/src/domains/settings/types.ts
@@ -13,7 +13,9 @@
*/
import type {
+ AssistantSection,
ExtrasSection,
+ LaunchPromoSection,
MirrorSection,
NyxidSection,
PlaygroundSection,
@@ -51,11 +53,13 @@ export interface SettingsService {
// ---- Per-section typed accessors ----
getPlayground(): Promise;
getSkillGen(): Promise;
+ getAssistant(): Promise;
getMirror(): Promise;
getNyxid(): Promise;
getSkillAudit(): Promise;
getTelemetry(): Promise;
getExtras(): Promise;
+ getLaunchPromo(): Promise;
/**
* Read a section by id. Returns the typed payload, applying defaults
diff --git a/ornn-api/src/domains/skills/closure/resolver.test.ts b/ornn-api/src/domains/skills/closure/resolver.test.ts
new file mode 100644
index 00000000..103ca91e
--- /dev/null
+++ b/ornn-api/src/domains/skills/closure/resolver.test.ts
@@ -0,0 +1,169 @@
+/**
+ * Pure dependency-closure resolver tests (#968).
+ *
+ * The resolver is deliberately DB-free: it takes a `loadVersion(ref)`
+ * loader and walks the dependency graph with a three-color DFS. These
+ * tests exercise the five contract cases against an in-memory graph:
+ *
+ * - linear chain → topo order, deps before dependents
+ * - diamond → shared node deduped, still correctly ordered
+ * - cycle → `dependency_cycle` (409)
+ * - version conflict → `dependency_conflict` (409) when the same skill
+ * is pinned to two different versions in one graph
+ * - missing dep → `skill_dependency_not_found` (404)
+ *
+ * @module domains/skills/closure/resolver.test
+ */
+
+import { describe, expect, it } from "bun:test";
+import { resolveClosure, type ResolvedVersion, type LoadVersion } from "./resolver";
+import { AppError } from "../../../shared/types/index";
+
+/**
+ * Build a `loadVersion` over a static graph keyed by the canonical
+ * `@` ref. Each node declares the names+versions of its
+ * direct deps. `loadVersion` returns null for any ref not in the graph
+ * (the "missing dependency" signal). Dist-tag refs are resolved through
+ * an optional alias map so tests can exercise tag → version pinning.
+ */
+function makeLoader(
+ graph: Record,
+ aliases: Record = {},
+): LoadVersion {
+ return async (ref: string): Promise => {
+ const resolvedRef = aliases[ref] ?? ref;
+ const node = graph[resolvedRef];
+ if (!node) return null;
+ return {
+ ref: `${node.name}@${node.version}`,
+ name: node.name,
+ version: node.version,
+ dependsOn: node.deps,
+ };
+ };
+}
+
+async function catchErr(fn: () => Promise): Promise {
+ try {
+ await fn();
+ } catch (err) {
+ return err as AppError;
+ }
+ throw new Error("expected the call to throw");
+}
+
+describe("resolveClosure (#968)", () => {
+ it("resolves a linear chain in deps-before-dependents order", async () => {
+ // a → b → c
+ const loadVersion = makeLoader({
+ "a@1.0": { name: "a", version: "1.0", deps: ["b@1.0"] },
+ "b@1.0": { name: "b", version: "1.0", deps: ["c@1.0"] },
+ "c@1.0": { name: "c", version: "1.0", deps: [] },
+ });
+ const result = await resolveClosure(["a@1.0"], { loadVersion });
+ const names = result.map((n) => n.name);
+ // c before b before a (reverse-postorder topological sort).
+ expect(names.indexOf("c")).toBeLessThan(names.indexOf("b"));
+ expect(names.indexOf("b")).toBeLessThan(names.indexOf("a"));
+ expect(names).toHaveLength(3);
+ // depth: roots at 0, deeper deps higher.
+ const byName = Object.fromEntries(result.map((n) => [n.name, n.depth]));
+ expect(byName.a).toBe(0);
+ expect(byName.b).toBe(1);
+ expect(byName.c).toBe(2);
+ });
+
+ it("dedupes a shared node in a diamond graph", async () => {
+ // a → b → d ; a → c → d. d appears once, before b and c.
+ const loadVersion = makeLoader({
+ "a@1.0": { name: "a", version: "1.0", deps: ["b@1.0", "c@1.0"] },
+ "b@1.0": { name: "b", version: "1.0", deps: ["d@1.0"] },
+ "c@1.0": { name: "c", version: "1.0", deps: ["d@1.0"] },
+ "d@1.0": { name: "d", version: "1.0", deps: [] },
+ });
+ const result = await resolveClosure(["a@1.0"], { loadVersion });
+ const names = result.map((n) => n.name);
+ expect(names).toHaveLength(4);
+ expect(names.filter((n) => n === "d")).toHaveLength(1);
+ expect(names.indexOf("d")).toBeLessThan(names.indexOf("b"));
+ expect(names.indexOf("d")).toBeLessThan(names.indexOf("c"));
+ expect(names.indexOf("b")).toBeLessThan(names.indexOf("a"));
+ expect(names.indexOf("c")).toBeLessThan(names.indexOf("a"));
+ });
+
+ it("throws dependency_cycle (409) on a back-edge", async () => {
+ // a → b → a
+ const loadVersion = makeLoader({
+ "a@1.0": { name: "a", version: "1.0", deps: ["b@1.0"] },
+ "b@1.0": { name: "b", version: "1.0", deps: ["a@1.0"] },
+ });
+ const err = await catchErr(() => resolveClosure(["a@1.0"], { loadVersion }));
+ expect(err).toBeInstanceOf(AppError);
+ expect(err.statusCode).toBe(409);
+ expect(err.code).toBe("dependency_cycle");
+ });
+
+ it("throws dependency_cycle on a self-loop reached via GUID ref", async () => {
+ // a depends on itself — the frontmatter self-ref guard can't catch
+ // a GUID-form self-ref, so the resolver's cycle check must.
+ const loadVersion = makeLoader({
+ "a@1.0": { name: "a", version: "1.0", deps: ["a@1.0"] },
+ });
+ const err = await catchErr(() => resolveClosure(["a@1.0"], { loadVersion }));
+ expect(err.code).toBe("dependency_cycle");
+ });
+
+ it("throws dependency_conflict (409) when one skill is pinned to two versions", async () => {
+ // a → b@1.0 ; a → c → b@2.0. Same skill `b`, two versions.
+ const loadVersion = makeLoader({
+ "a@1.0": { name: "a", version: "1.0", deps: ["b@1.0", "c@1.0"] },
+ "b@1.0": { name: "b", version: "1.0", deps: [] },
+ "b@2.0": { name: "b", version: "2.0", deps: [] },
+ "c@1.0": { name: "c", version: "1.0", deps: ["b@2.0"] },
+ });
+ const err = await catchErr(() => resolveClosure(["a@1.0"], { loadVersion }));
+ expect(err).toBeInstanceOf(AppError);
+ expect(err.statusCode).toBe(409);
+ expect(err.code).toBe("dependency_conflict");
+ });
+
+ it("throws skill_dependency_not_found (404) when a dep cannot be loaded", async () => {
+ // a → missing@1.0 (not in graph).
+ const loadVersion = makeLoader({
+ "a@1.0": { name: "a", version: "1.0", deps: ["missing@1.0"] },
+ });
+ const err = await catchErr(() => resolveClosure(["a@1.0"], { loadVersion }));
+ expect(err).toBeInstanceOf(AppError);
+ expect(err.statusCode).toBe(404);
+ expect(err.code).toBe("skill_dependency_not_found");
+ });
+
+ it("throws skill_dependency_not_found when a root ref cannot be loaded", async () => {
+ const loadVersion = makeLoader({});
+ const err = await catchErr(() => resolveClosure(["ghost@1.0"], { loadVersion }));
+ expect(err.code).toBe("skill_dependency_not_found");
+ });
+
+ it("resolves a dist-tag dependency to its concrete version", async () => {
+ // a → b@beta, where beta aliases to b@1.0.
+ const loadVersion = makeLoader(
+ {
+ "a@1.0": { name: "a", version: "1.0", deps: ["b@beta"] },
+ "b@1.0": { name: "b", version: "1.0", deps: [] },
+ },
+ { "b@beta": "b@1.0" },
+ );
+ const result = await resolveClosure(["a@1.0"], { loadVersion });
+ const b = result.find((n) => n.name === "b");
+ expect(b?.version).toBe("1.0");
+ });
+
+ it("returns an empty closure for a dependency-free root", async () => {
+ const loadVersion = makeLoader({
+ "a@1.0": { name: "a", version: "1.0", deps: [] },
+ });
+ const result = await resolveClosure(["a@1.0"], { loadVersion });
+ expect(result).toHaveLength(1);
+ expect(result[0]!.name).toBe("a");
+ });
+});
diff --git a/ornn-api/src/domains/skills/closure/resolver.ts b/ornn-api/src/domains/skills/closure/resolver.ts
new file mode 100644
index 00000000..567b04fb
--- /dev/null
+++ b/ornn-api/src/domains/skills/closure/resolver.ts
@@ -0,0 +1,232 @@
+/**
+ * Pure skill dependency-closure resolver (#968).
+ *
+ * Given a set of root dependency refs and a `loadVersion(ref)` loader,
+ * walks the dependency graph and returns the full transitive closure in
+ * topological order (every dependency appears before the dependents that
+ * pin it). The module is DELIBERATELY pure — it imports no database, no
+ * storage, no Hono. The only side-effect surface is the injected
+ * `loadVersion`, which the caller wires to whatever source it has
+ * (Mongo at publish time, an authorized read at request time, an
+ * in-memory map in tests). That keeps the graph algorithm trivially
+ * unit-testable and reusable across every closure-shaped feature.
+ *
+ * Algorithm: a three-color (WHITE / GRAY / BLACK) depth-first search.
+ * - WHITE — not yet visited.
+ * - GRAY — on the current DFS stack (being explored).
+ * - BLACK — fully explored; its subtree is closed.
+ * A GRAY node reached again is a back-edge ⇒ a cycle. Nodes are emitted
+ * in DFS postorder (a node is appended only after all of its children
+ * have been fully explored), which is itself a valid topological order
+ * with dependencies before dependents — no reversal needed.
+ *
+ * Three failure modes map to the three lowercase error codes the
+ * contract mandates (see docs/ERRORS.md §11-13):
+ * - cycle → `dependency_cycle` (409)
+ * - same skill, two versions → `dependency_conflict` (409)
+ * - ref the loader can't find → `skill_dependency_not_found` (404)
+ *
+ * @module domains/skills/closure/resolver
+ */
+
+import { AppError } from "../../../shared/types/index";
+import { createLogger } from "../../../shared/logger";
+
+const logger = createLogger("closureResolver");
+
+/**
+ * A concrete, loaded skill version. The loader resolves a (possibly
+ * dist-tagged) ref into this shape; `ref` is re-canonicalized to
+ * `@` so two equivalent refs (e.g. `pdf@latest` and
+ * `pdf@1.0`) collapse onto one graph node.
+ */
+export interface ResolvedVersion {
+ /** Canonical `@` for this loaded node. */
+ ref: string;
+ /** Skill name. Used as the conflict key — one name may appear once. */
+ name: string;
+ /** Concrete `.` version. */
+ version: string;
+ /** Stable skill GUID, when known. Surfaced in the closure output. */
+ guid?: string;
+ /** Package hash for the resolved version, when known. */
+ skillHash?: string;
+ /** Direct dependency refs declared by this version. */
+ dependsOn: string[];
+}
+
+/**
+ * Loader contract. Resolves a dependency ref (`@`
+ * or `@`) to a {@link ResolvedVersion}, or `null` when no
+ * such version exists / is visible. Async so DB / network loaders fit.
+ */
+export type LoadVersion = (ref: string) => Promise;
+
+/** One node in the resolved closure, carrying its BFS-style depth. */
+export interface ClosureNode {
+ ref: string;
+ name: string;
+ version: string;
+ guid?: string;
+ skillHash?: string;
+ /** 0 for roots; max distance from any root for deeper deps. */
+ depth: number;
+}
+
+export interface ResolveClosureOptions {
+ loadVersion: LoadVersion;
+}
+
+export interface ResolveClosureSettings {
+ /**
+ * Hard ceiling on the number of distinct nodes in the closure. Guards
+ * against a pathological graph blowing up memory / time. Default 500.
+ */
+ maxNodes?: number;
+}
+
+const DEFAULT_MAX_NODES = 500;
+
+enum Color {
+ WHITE,
+ GRAY,
+ BLACK,
+}
+
+/**
+ * Resolve the full transitive dependency closure of `roots`.
+ *
+ * Returns nodes in DFS postorder (dependencies first), deduplicated by
+ * canonical ref. Each node carries the MAXIMUM depth at which it was
+ * reached, so a shared diamond node reports the deeper of its paths.
+ *
+ * Throws:
+ * - `dependency_cycle` (409) on a back-edge.
+ * - `dependency_conflict` (409) when one skill name resolves to two
+ * distinct versions anywhere in the graph.
+ * - `skill_dependency_not_found` (404) when `loadVersion` returns null
+ * for any ref reached (root or transitive).
+ */
+export async function resolveClosure(
+ roots: string[],
+ options: ResolveClosureOptions,
+ settings: ResolveClosureSettings = {},
+): Promise {
+ const { loadVersion } = options;
+ const maxNodes = settings.maxNodes ?? DEFAULT_MAX_NODES;
+
+ // Canonical-ref → resolved version (graph node cache). One async load
+ // per distinct ref; later refs to the same node are served from here.
+ const loaded = new Map();
+ const color = new Map();
+ // name → version, to detect a same-skill / different-version conflict.
+ const pinnedVersion = new Map();
+ // Canonical ref → max depth seen.
+ const depthOf = new Map();
+ // Postorder accumulation (canonical refs).
+ const postorder: string[] = [];
+
+ /**
+ * Load a ref into a canonical {@link ResolvedVersion}, caching by both
+ * the requested ref AND the canonical ref so a dist-tag and its
+ * concrete version share one node. Throws `skill_dependency_not_found`
+ * when the loader can't resolve it.
+ */
+ async function resolve(ref: string): Promise {
+ const cached = loaded.get(ref);
+ if (cached) return cached;
+ const node = await loadVersion(ref);
+ if (!node) {
+ logger.error({ ref }, "Dependency ref could not be resolved");
+ throw AppError.notFound(
+ "skill_dependency_not_found",
+ `Skill dependency '${ref}' was not found or is not accessible.`,
+ );
+ }
+ // Cache under both the requested ref and the canonical ref so
+ // distinct aliases (e.g. `@beta` and `@1.0`) collapse onto one node.
+ loaded.set(ref, node);
+ loaded.set(node.ref, node);
+ return node;
+ }
+
+ async function visit(ref: string, depth: number): Promise {
+ const node = await resolve(ref);
+ const canonical = node.ref;
+
+ // Conflict check: a single skill name may resolve to exactly one
+ // version across the whole closure. Two different versions of the
+ // same skill cannot be installed side by side.
+ const priorVersion = pinnedVersion.get(node.name);
+ if (priorVersion !== undefined && priorVersion !== node.version) {
+ logger.error(
+ { name: node.name, versions: [priorVersion, node.version] },
+ "Conflicting versions of the same skill in dependency closure",
+ );
+ throw AppError.conflict(
+ "dependency_conflict",
+ `Dependency '${node.name}' is pinned to conflicting versions ` +
+ `'${priorVersion}' and '${node.version}' within the same closure.`,
+ );
+ }
+ pinnedVersion.set(node.name, node.version);
+
+ // Track the deepest reach so diamond-shared nodes sort correctly.
+ const prevDepth = depthOf.get(canonical);
+ depthOf.set(canonical, prevDepth === undefined ? depth : Math.max(prevDepth, depth));
+
+ const state = color.get(canonical) ?? Color.WHITE;
+ if (state === Color.GRAY) {
+ logger.error({ ref: canonical }, "Cycle detected in dependency closure");
+ throw AppError.conflict(
+ "dependency_cycle",
+ `A dependency cycle was detected involving '${canonical}'.`,
+ );
+ }
+ if (state === Color.BLACK) {
+ // Already fully explored — nothing more to do (dedup).
+ return canonical;
+ }
+
+ color.set(canonical, Color.GRAY);
+ for (const childRef of node.dependsOn) {
+ await visit(childRef, depth + 1);
+ if (loaded.size > maxNodes) {
+ throw AppError.conflict(
+ "dependency_conflict",
+ `Dependency closure exceeded the maximum of ${maxNodes} nodes.`,
+ );
+ }
+ }
+ color.set(canonical, Color.BLACK);
+ postorder.push(canonical);
+ return canonical;
+ }
+
+ for (const root of roots) {
+ await visit(root, 0);
+ }
+
+ // Postorder already places dependencies before dependents (a node is
+ // pushed only after all its children). That IS the deps-first topo
+ // order — no reversal needed. Dedup is implicit: each canonical ref is
+ // pushed exactly once (guarded by the BLACK check).
+ const result: ClosureNode[] = postorder.map((canonical) => {
+ const node = loaded.get(canonical)!;
+ const out: ClosureNode = {
+ ref: node.ref,
+ name: node.name,
+ version: node.version,
+ depth: depthOf.get(canonical) ?? 0,
+ };
+ if (node.guid !== undefined) out.guid = node.guid;
+ if (node.skillHash !== undefined) out.skillHash = node.skillHash;
+ return out;
+ });
+
+ logger.info(
+ { roots, nodeCount: result.length },
+ "Dependency closure resolved",
+ );
+ return result;
+}
diff --git a/ornn-api/src/domains/skills/crud/repository.ts b/ornn-api/src/domains/skills/crud/repository.ts
index fb723ae1..2a5f6929 100644
--- a/ornn-api/src/domains/skills/crud/repository.ts
+++ b/ornn-api/src/domains/skills/crud/repository.ts
@@ -7,6 +7,12 @@ import type { Collection, Db, Document } from "mongodb";
import type { SkillDocument, SkillMetadata } from "../../../shared/types/index";
import { AppError } from "../../../shared/types/index";
import { createLogger } from "../../../shared/logger";
+// `applyScope` / `applyExtraFilters` were lifted into `scopeFilter.ts`
+// (#969) so the skillsets repository can reuse the exact same visibility
+// matrix + registry-chip filters. Re-import them here — pure move, no
+// behaviour change.
+import { applyScope, applyExtraFilters } from "./scopeFilter";
+import type { ExtraFilters as ScopeExtraFilters } from "./scopeFilter";
/**
* Coerce a string GUID into the shape MongoDB's driver expects for
* `_id` queries on the skills collection (#448). The collection uses
@@ -102,29 +108,10 @@ export interface SkillFilters {
/**
* Additional registry-filter constraints passed by the search route
- * when the UI chips are active. `sharedWithOrgsAny` requires
- * `skill.sharedWithOrgs` to intersect the list; `sharedWithUsersAny`
- * is the analog for direct per-user grants; `createdByAny` narrows
- * the skill's author (used by the Shared-with-me tab's "from which
- * user" chip row).
+ * when the UI chips are active. Re-exported from `scopeFilter.ts` (#969)
+ * so existing importers of `./repository` keep working unchanged.
*/
-export interface ExtraFilters {
- // Optionals widen to `T | undefined` for exactOptionalPropertyTypes (#657).
- sharedWithOrgsAny?: string[] | undefined;
- sharedWithUsersAny?: string[] | undefined;
- createdByAny?: string[] | undefined;
- /**
- * Tri-state system-skill filter applied at the DB match level.
- * `"only"` → `isSystemSkill: true`.
- * `"exclude"` → `isSystemSkill !== true` (covers absent / false / null).
- * `"any"` / undefined → no constraint.
- */
- systemFilter?: "any" | "only" | "exclude" | undefined;
- /** Restrict to skills tied to this exact NyxID service id. */
- nyxidServiceId?: string | undefined;
- /** Skills must have ALL listed tags (AND match against `metadata.tags`). */
- tagsAll?: string[] | undefined;
-}
+export type ExtraFilters = ScopeExtraFilters;
export class SkillRepository {
private readonly collection: Collection;
@@ -831,130 +818,6 @@ export class SkillRepository {
}
}
-/**
- * Build the visibility match stage for a scoped query.
- *
- * Visibility model (matches `canReadSkill` in authorize.ts):
- * - `public` scope → `!isPrivate`.
- * - `private` scope → every private skill the caller can see: author,
- * any skill whose `sharedWithUsers` contains the caller's user_id, or
- * any skill whose `sharedWithOrgs` overlaps the caller's org user_ids.
- * - `mixed` scope → union of the two above.
- *
- * Anonymous callers (empty `currentUserId` + empty `userOrgIds`) correctly
- * match nothing for the private branch.
- */
-function applyScope(
- matchStage: Record,
- scope: "public" | "private" | "mixed" | "shared-with-me" | "mine",
- currentUserId: string,
- userOrgIds: string[],
-): void {
- if (scope === "mine") {
- // Skills authored by the caller, regardless of visibility. Strict
- // "skills I own", distinct from "private skills I can read" which
- // would also include skills shared with me.
- if (!currentUserId) {
- matchStage._id = { $in: [] };
- return;
- }
- matchStage.createdBy = currentUserId;
- return;
- }
- const privateVisibility: Array> = [];
- if (currentUserId) {
- privateVisibility.push({ createdBy: currentUserId });
- privateVisibility.push({ sharedWithUsers: currentUserId });
- }
- if (userOrgIds.length > 0) {
- privateVisibility.push({ sharedWithOrgs: { $in: userOrgIds } });
- }
-
- if (scope === "public") {
- matchStage.isPrivate = false;
- return;
- }
-
- if (scope === "private") {
- if (privateVisibility.length === 0) {
- // Anonymous caller with no orgs — nothing to match.
- matchStage._id = { $in: [] };
- return;
- }
- matchStage.isPrivate = true;
- matchStage.$or = privateVisibility;
- return;
- }
-
- if (scope === "shared-with-me") {
- // Private skills the caller can read but did NOT author.
- // By construction this excludes anonymous callers (no orgs, no user id).
- const grants: Array> = [];
- if (currentUserId) {
- grants.push({ sharedWithUsers: currentUserId });
- }
- if (userOrgIds.length > 0) {
- grants.push({ sharedWithOrgs: { $in: userOrgIds } });
- }
- if (grants.length === 0) {
- matchStage._id = { $in: [] };
- return;
- }
- matchStage.isPrivate = true;
- matchStage.$and = [
- { $or: grants },
- // `createdBy` excluded explicitly — a skill the caller authored is
- // never "shared with" them in the UI sense.
- ...(currentUserId ? [{ createdBy: { $ne: currentUserId } }] : []),
- ];
- return;
- }
-
- // mixed
- const clauses: Array> = [{ isPrivate: false }];
- if (privateVisibility.length > 0) {
- clauses.push({ isPrivate: true, $or: privateVisibility });
- }
- matchStage.$or = clauses;
-}
-
-/**
- * Merge the registry chip filters into an existing match stage.
- * Appended as additional clauses on `$and` so they compose cleanly
- * with whatever `applyScope` already set up.
- */
-function applyExtraFilters(matchStage: Record, filters: ExtraFilters | undefined): void {
- if (!filters) return;
- const extra: Array> = [];
- if (filters.sharedWithOrgsAny && filters.sharedWithOrgsAny.length > 0) {
- extra.push({ sharedWithOrgs: { $in: filters.sharedWithOrgsAny } });
- }
- if (filters.sharedWithUsersAny && filters.sharedWithUsersAny.length > 0) {
- extra.push({ sharedWithUsers: { $in: filters.sharedWithUsersAny } });
- }
- if (filters.createdByAny && filters.createdByAny.length > 0) {
- extra.push({ createdBy: { $in: filters.createdByAny } });
- }
- if (filters.systemFilter === "only") {
- extra.push({ isSystemSkill: true });
- } else if (filters.systemFilter === "exclude") {
- // Treat absent / null as "not a system skill" — that's how every
- // pre-feature skill in the registry looks.
- extra.push({ isSystemSkill: { $ne: true } });
- }
- if (filters.nyxidServiceId) {
- extra.push({ nyxidServiceId: filters.nyxidServiceId });
- }
- if (filters.tagsAll && filters.tagsAll.length > 0) {
- // AND-match: every requested tag must be in `metadata.tags`. Mongo's
- // `$all` is the right shape here.
- extra.push({ "metadata.tags": { $all: filters.tagsAll } });
- }
- if (extra.length === 0) return;
- const existingAnd = (matchStage.$and as Array> | undefined) ?? [];
- matchStage.$and = [...existingAnd, ...extra];
-}
-
function mapDoc(doc: Document | null): SkillDocument | null {
if (!doc) return null;
return {
diff --git a/ornn-api/src/domains/skills/crud/routes.test.ts b/ornn-api/src/domains/skills/crud/routes.test.ts
index 9186d3d7..676c66e0 100644
--- a/ornn-api/src/domains/skills/crud/routes.test.ts
+++ b/ornn-api/src/domains/skills/crud/routes.test.ts
@@ -501,6 +501,129 @@ describe("GET /skills/:idOrName/versions", () => {
});
});
+// ======================================================================
+// GET /skills/:idOrName/closure (#968)
+// ======================================================================
+
+describe("GET /skills/:idOrName/closure", () => {
+ const linear = [
+ { guid: "g-c", name: "c", version: "1.0", skillHash: "h-c", depth: 1 },
+ { guid: "g-b", name: "b", version: "1.0", skillHash: "h-b", depth: 0 },
+ ];
+
+ test("200 returns the topo-ordered items envelope (linear chain)", async () => {
+ const captured: { idOrName: string | undefined; version: string | undefined; anon: boolean | undefined } = {
+ idOrName: undefined,
+ version: undefined,
+ anon: undefined,
+ };
+ const app = buildApp({
+ authenticated: true,
+ permissions: [READ],
+ service: {
+ resolveSkillClosure: async (...args: unknown[]) => {
+ captured.idOrName = args[0] as string;
+ captured.version = args[2] as string | undefined;
+ captured.anon = (args[1] as { userId: string }).userId === "";
+ return linear;
+ },
+ },
+ });
+ const res = await app.request("/api/v1/skills/demo-skill/closure?version=1.0");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { data: { items: typeof linear }; error: null };
+ expect(body.error).toBeNull();
+ expect(body.data.items.map((i) => i.name)).toEqual(["c", "b"]);
+ expect(captured.idOrName).toBe("demo-skill");
+ expect(captured.version).toBe("1.0");
+ expect(captured.anon).toBe(false);
+ // Regression guard (#978): the skillset master prompt is a SKILLSET
+ // concept only — the shared skill closure envelope stays `{ items }`,
+ // with NO `instructions` key leaking onto this path.
+ expect("instructions" in body.data).toBe(false);
+ expect(Object.keys(body.data)).toEqual(["items"]);
+ });
+
+ test("200 with a deduped diamond closure", async () => {
+ const diamond = [
+ { guid: "g-d", name: "d", version: "1.0", skillHash: "h-d", depth: 2 },
+ { guid: "g-b", name: "b", version: "1.0", skillHash: "h-b", depth: 1 },
+ { guid: "g-c", name: "c", version: "1.0", skillHash: "h-c", depth: 1 },
+ ];
+ const app = buildApp({
+ authenticated: false,
+ service: { resolveSkillClosure: async () => diamond },
+ });
+ const res = await app.request("/api/v1/skills/demo-skill/closure");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { data: { items: typeof diamond } };
+ // d (the shared leaf) appears once and sorts before its dependents.
+ expect(body.data.items.filter((i) => i.name === "d")).toHaveLength(1);
+ expect(body.data.items.map((i) => i.name).indexOf("d")).toBe(0);
+ });
+
+ test("passes an anonymous actor (userId='') when unauthenticated", async () => {
+ let anon: boolean | undefined;
+ const app = buildApp({
+ authenticated: false,
+ service: {
+ resolveSkillClosure: async (...args: unknown[]) => {
+ anon = (args[1] as { userId: string }).userId === "";
+ return [];
+ },
+ },
+ });
+ const res = await app.request("/api/v1/skills/public-skill/closure");
+ expect(res.status).toBe(200);
+ expect(anon).toBe(true);
+ });
+
+ test("409 dependency_cycle propagates from the service", async () => {
+ const { AppError } = await import("../../../shared/types/index");
+ const app = buildApp({
+ authenticated: false,
+ service: {
+ resolveSkillClosure: async () => {
+ throw AppError.conflict("dependency_cycle", "cycle at a@1.0");
+ },
+ },
+ });
+ const res = await app.request("/api/v1/skills/demo-skill/closure");
+ expect(res.status).toBe(409);
+ expect(((await res.json()) as { code: string }).code).toBe("dependency_cycle");
+ });
+
+ test("409 dependency_conflict propagates from the service", async () => {
+ const { AppError } = await import("../../../shared/types/index");
+ const app = buildApp({
+ authenticated: false,
+ service: {
+ resolveSkillClosure: async () => {
+ throw AppError.conflict("dependency_conflict", "b pinned to 1.0 and 2.0");
+ },
+ },
+ });
+ const res = await app.request("/api/v1/skills/demo-skill/closure");
+ expect(res.status).toBe(409);
+ expect(((await res.json()) as { code: string }).code).toBe("dependency_conflict");
+ });
+
+ test("404 skill_dependency_not_found propagates from the service", async () => {
+ const { AppError } = await import("../../../shared/types/index");
+ const app = buildApp({
+ authenticated: false,
+ service: {
+ resolveSkillClosure: async () => {
+ throw AppError.notFound("skill_dependency_not_found", "missing dep");
+ },
+ },
+ });
+ const res = await app.request("/api/v1/skills/demo-skill/closure");
+ expect(res.status).toBe(404);
+ expect(((await res.json()) as { code: string }).code).toBe("skill_dependency_not_found");
+ });
+});
+
// ======================================================================
// GET /skills/:idOrName/versions/:from/diff/:to
// ======================================================================
diff --git a/ornn-api/src/domains/skills/crud/routes.ts b/ornn-api/src/domains/skills/crud/routes.ts
index b28d1a3f..8014e407 100644
--- a/ornn-api/src/domains/skills/crud/routes.ts
+++ b/ornn-api/src/domains/skills/crud/routes.ts
@@ -673,6 +673,54 @@ export function createSkillRoutes(config: SkillRoutesConfig): Hono<{ Variables:
},
);
+ /**
+ * GET /skills/:idOrName/closure — Resolve the full transitive
+ * dependency closure of a skill version (#968).
+ *
+ * Query params:
+ * - `version` (optional) — literal `.` or a dist-tag.
+ * When omitted, the skill's latest version is used.
+ *
+ * Returns the closure in deps-first topological order:
+ * `{ data: { items: [{ guid, name, version, skillHash, depth }] }, error: null }`
+ *
+ * Auth: Optional. Anonymous callers resolve against public skills only —
+ * a public skill that transitively depends on a PRIVATE skill surfaces
+ * that node as `skill_dependency_not_found` rather than leaking it.
+ *
+ * Errors: `dependency_cycle` (409), `dependency_conflict` (409),
+ * `skill_dependency_not_found` (404), `skill_not_found` (404).
+ *
+ * Registered ABOVE `/skills/:idOrName` so the literal `/closure` segment
+ * wins the route match (mirrors `/skills/:idOrName/versions`).
+ */
+ app.get(
+ "/skills/:idOrName/closure",
+ optionalAuth,
+ async (c) => {
+ const idOrName = c.req.param("idOrName");
+ const version = c.req.query("version") || undefined;
+ const authCtx = c.get("auth");
+
+ // Build the visibility-scoped actor. Authenticated callers get their
+ // full org/admin context; anonymous callers get a read-only actor
+ // that can see public skills only.
+ const actor = authCtx
+ ? await buildActorContext(c)
+ : {
+ userId: "",
+ memberships: [],
+ isPlatformAdmin: false,
+ membershipsResolved: true,
+ };
+
+ logger.info({ idOrName, version: version ?? null, anon: !authCtx }, "Skill closure request");
+
+ const items = await skillService.resolveSkillClosure(idOrName, actor, version);
+ return c.json({ data: { items }, error: null });
+ },
+ );
+
/**
* GET /skills/:idOrName — Read a skill by GUID or name.
* Query params:
diff --git a/ornn-api/src/domains/skills/crud/scopeFilter.test.ts b/ornn-api/src/domains/skills/crud/scopeFilter.test.ts
new file mode 100644
index 00000000..041f0f19
--- /dev/null
+++ b/ornn-api/src/domains/skills/crud/scopeFilter.test.ts
@@ -0,0 +1,99 @@
+/**
+ * Unit tests for the extracted scope + filter match-stage builders (#969).
+ *
+ * These pin the visibility matrix + registry-chip filters in isolation so
+ * the skillsets repository — which reuses the same two functions — inherits
+ * a verified contract. The repository integration tests
+ * (`crud/repository.test.ts`) still exercise them against a real Mongo.
+ *
+ * @module domains/skills/crud/scopeFilter.test
+ */
+
+import { describe, expect, it } from "bun:test";
+import { applyScope, applyExtraFilters } from "./scopeFilter";
+
+describe("applyScope (#969 extract)", () => {
+ it("public scope matches only public docs", () => {
+ const m: Record = {};
+ applyScope(m, "public", "u1", []);
+ expect(m).toEqual({ isPrivate: false });
+ });
+
+ it("mine scope for an anonymous caller matches nothing", () => {
+ const m: Record = {};
+ applyScope(m, "mine", "", []);
+ expect(m).toEqual({ _id: { $in: [] } });
+ });
+
+ it("mine scope narrows to the caller's authored docs", () => {
+ const m: Record = {};
+ applyScope(m, "mine", "u1", ["org-a"]);
+ expect(m).toEqual({ createdBy: "u1" });
+ });
+
+ it("private scope for an anonymous caller matches nothing", () => {
+ const m: Record = {};
+ applyScope(m, "private", "", []);
+ expect(m).toEqual({ _id: { $in: [] } });
+ });
+
+ it("private scope unions author / shared-user / shared-org grants", () => {
+ const m: Record = {};
+ applyScope(m, "private", "u1", ["org-a"]);
+ expect(m.isPrivate).toBe(true);
+ expect(m.$or).toEqual([
+ { createdBy: "u1" },
+ { sharedWithUsers: "u1" },
+ { sharedWithOrgs: { $in: ["org-a"] } },
+ ]);
+ });
+
+ it("shared-with-me excludes the caller's own authored docs", () => {
+ const m: Record = {};
+ applyScope(m, "shared-with-me", "u1", ["org-a"]);
+ expect(m.isPrivate).toBe(true);
+ expect(m.$and).toEqual([
+ { $or: [{ sharedWithUsers: "u1" }, { sharedWithOrgs: { $in: ["org-a"] } }] },
+ { createdBy: { $ne: "u1" } },
+ ]);
+ });
+
+ it("mixed scope unions public OR readable-private", () => {
+ const m: Record = {};
+ applyScope(m, "mixed", "u1", []);
+ expect(m.$or).toEqual([
+ { isPrivate: false },
+ { isPrivate: true, $or: [{ createdBy: "u1" }, { sharedWithUsers: "u1" }] },
+ ]);
+ });
+});
+
+describe("applyExtraFilters (#969 extract)", () => {
+ it("is a no-op when filters are undefined", () => {
+ const m: Record = { isPrivate: false };
+ applyExtraFilters(m, undefined);
+ expect(m).toEqual({ isPrivate: false });
+ });
+
+ it("appends a tags $all AND-clause", () => {
+ const m: Record = {};
+ applyExtraFilters(m, { tagsAll: ["alpha", "beta"] });
+ expect(m.$and).toEqual([{ "metadata.tags": { $all: ["alpha", "beta"] } }]);
+ });
+
+ it("systemFilter only / exclude map to the right predicate", () => {
+ const only: Record = {};
+ applyExtraFilters(only, { systemFilter: "only" });
+ expect(only.$and).toEqual([{ isSystemSkill: true }]);
+
+ const exclude: Record = {};
+ applyExtraFilters(exclude, { systemFilter: "exclude" });
+ expect(exclude.$and).toEqual([{ isSystemSkill: { $ne: true } }]);
+ });
+
+ it("composes onto an existing $and rather than clobbering it", () => {
+ const m: Record = { $and: [{ name: "x" }] };
+ applyExtraFilters(m, { createdByAny: ["u1"] });
+ expect(m.$and).toEqual([{ name: "x" }, { createdBy: { $in: ["u1"] } }]);
+ });
+});
diff --git a/ornn-api/src/domains/skills/crud/scopeFilter.ts b/ornn-api/src/domains/skills/crud/scopeFilter.ts
new file mode 100644
index 00000000..c3d1ed05
--- /dev/null
+++ b/ornn-api/src/domains/skills/crud/scopeFilter.ts
@@ -0,0 +1,174 @@
+/**
+ * Shared Mongo match-stage builders for scoped + filtered skill queries.
+ *
+ * Extracted from `crud/repository.ts` (#969) so the skillsets repository
+ * (`domains/skillsets/repository.ts`) can reuse the exact same visibility
+ * model and registry-chip filters without copy-pasting the logic. Both the
+ * `skills` and `skillsets` collections carry the same ownership shape
+ * (`isPrivate` / `sharedWithUsers` / `sharedWithOrgs` / `createdBy`), so the
+ * scope predicates are identical — keeping them in one module guarantees the
+ * two collections can never drift on who-can-see-what.
+ *
+ * PURE move — no behaviour change. `crud/repository.ts` re-imports both
+ * functions; its existing tests pin the matrix.
+ *
+ * @module domains/skills/crud/scopeFilter
+ */
+
+export type SkillScope =
+ | "public"
+ | "private"
+ | "mixed"
+ | "shared-with-me"
+ | "mine";
+
+/**
+ * Additional registry-filter constraints. `sharedWithOrgsAny` requires
+ * `sharedWithOrgs` to intersect the list; `sharedWithUsersAny` is the
+ * analog for direct per-user grants; `createdByAny` narrows the author
+ * (used by the Shared-with-me tab's "from which user" chip row).
+ */
+export interface ExtraFilters {
+ // Optionals widen to `T | undefined` for exactOptionalPropertyTypes (#657).
+ sharedWithOrgsAny?: string[] | undefined;
+ sharedWithUsersAny?: string[] | undefined;
+ createdByAny?: string[] | undefined;
+ /**
+ * Tri-state system-skill filter applied at the DB match level.
+ * `"only"` → `isSystemSkill: true`.
+ * `"exclude"` → `isSystemSkill !== true` (covers absent / false / null).
+ * `"any"` / undefined → no constraint.
+ */
+ systemFilter?: "any" | "only" | "exclude" | undefined;
+ /** Restrict to skills tied to this exact NyxID service id. */
+ nyxidServiceId?: string | undefined;
+ /** Skills must have ALL listed tags (AND match against `metadata.tags`). */
+ tagsAll?: string[] | undefined;
+}
+
+/**
+ * Build the visibility match stage for a scoped query.
+ *
+ * Visibility model (matches `canReadSkill` in authorize.ts):
+ * - `public` scope → `!isPrivate`.
+ * - `private` scope → every private skill the caller can see: author,
+ * any skill whose `sharedWithUsers` contains the caller's user_id, or
+ * any skill whose `sharedWithOrgs` overlaps the caller's org user_ids.
+ * - `mixed` scope → union of the two above.
+ *
+ * Anonymous callers (empty `currentUserId` + empty `userOrgIds`) correctly
+ * match nothing for the private branch.
+ */
+export function applyScope(
+ matchStage: Record,
+ scope: SkillScope,
+ currentUserId: string,
+ userOrgIds: string[],
+): void {
+ if (scope === "mine") {
+ // Skills authored by the caller, regardless of visibility. Strict
+ // "skills I own", distinct from "private skills I can read" which
+ // would also include skills shared with me.
+ if (!currentUserId) {
+ matchStage._id = { $in: [] };
+ return;
+ }
+ matchStage.createdBy = currentUserId;
+ return;
+ }
+ const privateVisibility: Array> = [];
+ if (currentUserId) {
+ privateVisibility.push({ createdBy: currentUserId });
+ privateVisibility.push({ sharedWithUsers: currentUserId });
+ }
+ if (userOrgIds.length > 0) {
+ privateVisibility.push({ sharedWithOrgs: { $in: userOrgIds } });
+ }
+
+ if (scope === "public") {
+ matchStage.isPrivate = false;
+ return;
+ }
+
+ if (scope === "private") {
+ if (privateVisibility.length === 0) {
+ // Anonymous caller with no orgs — nothing to match.
+ matchStage._id = { $in: [] };
+ return;
+ }
+ matchStage.isPrivate = true;
+ matchStage.$or = privateVisibility;
+ return;
+ }
+
+ if (scope === "shared-with-me") {
+ // Private skills the caller can read but did NOT author.
+ // By construction this excludes anonymous callers (no orgs, no user id).
+ const grants: Array> = [];
+ if (currentUserId) {
+ grants.push({ sharedWithUsers: currentUserId });
+ }
+ if (userOrgIds.length > 0) {
+ grants.push({ sharedWithOrgs: { $in: userOrgIds } });
+ }
+ if (grants.length === 0) {
+ matchStage._id = { $in: [] };
+ return;
+ }
+ matchStage.isPrivate = true;
+ matchStage.$and = [
+ { $or: grants },
+ // `createdBy` excluded explicitly — a skill the caller authored is
+ // never "shared with" them in the UI sense.
+ ...(currentUserId ? [{ createdBy: { $ne: currentUserId } }] : []),
+ ];
+ return;
+ }
+
+ // mixed
+ const clauses: Array> = [{ isPrivate: false }];
+ if (privateVisibility.length > 0) {
+ clauses.push({ isPrivate: true, $or: privateVisibility });
+ }
+ matchStage.$or = clauses;
+}
+
+/**
+ * Merge the registry chip filters into an existing match stage.
+ * Appended as additional clauses on `$and` so they compose cleanly
+ * with whatever `applyScope` already set up.
+ */
+export function applyExtraFilters(
+ matchStage: Record,
+ filters: ExtraFilters | undefined,
+): void {
+ if (!filters) return;
+ const extra: Array> = [];
+ if (filters.sharedWithOrgsAny && filters.sharedWithOrgsAny.length > 0) {
+ extra.push({ sharedWithOrgs: { $in: filters.sharedWithOrgsAny } });
+ }
+ if (filters.sharedWithUsersAny && filters.sharedWithUsersAny.length > 0) {
+ extra.push({ sharedWithUsers: { $in: filters.sharedWithUsersAny } });
+ }
+ if (filters.createdByAny && filters.createdByAny.length > 0) {
+ extra.push({ createdBy: { $in: filters.createdByAny } });
+ }
+ if (filters.systemFilter === "only") {
+ extra.push({ isSystemSkill: true });
+ } else if (filters.systemFilter === "exclude") {
+ // Treat absent / null as "not a system skill" — that's how every
+ // pre-feature skill in the registry looks.
+ extra.push({ isSystemSkill: { $ne: true } });
+ }
+ if (filters.nyxidServiceId) {
+ extra.push({ nyxidServiceId: filters.nyxidServiceId });
+ }
+ if (filters.tagsAll && filters.tagsAll.length > 0) {
+ // AND-match: every requested tag must be in `metadata.tags`. Mongo's
+ // `$all` is the right shape here.
+ extra.push({ "metadata.tags": { $all: filters.tagsAll } });
+ }
+ if (extra.length === 0) return;
+ const existingAnd = (matchStage.$and as Array> | undefined) ?? [];
+ matchStage.$and = [...existingAnd, ...extra];
+}
diff --git a/ornn-api/src/domains/skills/crud/service.test.ts b/ornn-api/src/domains/skills/crud/service.test.ts
index 12f1ebdd..55ddd7fc 100644
--- a/ornn-api/src/domains/skills/crud/service.test.ts
+++ b/ornn-api/src/domains/skills/crud/service.test.ts
@@ -497,18 +497,24 @@ function versionDoc(overrides: Partial = {}): SkillVersion
}
/** Build a valid SKILL.md ZIP as raw bytes (for createSkill / updateSkill). */
-async function validSkillZip(opts: { name?: string; version?: string } = {}): Promise {
- const { name = "demo-skill", version = "1.0" } = opts;
+async function validSkillZip(
+ opts: { name?: string; version?: string; dependsOn?: string[] } = {},
+): Promise {
+ const { name = "demo-skill", version = "1.0", dependsOn } = opts;
const zip = new JSZip();
const folder = zip.folder(name)!;
+ const metaLines = ["metadata:", " category: plain"];
+ if (dependsOn && dependsOn.length > 0) {
+ metaLines.push(" depends-on:");
+ for (const dep of dependsOn) metaLines.push(` - ${dep}`);
+ }
folder.file(
"SKILL.md",
[
"---",
`name: ${name}`,
"description: A demo skill used by service tests.",
- "metadata:",
- " category: plain",
+ ...metaLines,
`version: "${version}"`,
"---",
`# ${name}`,
@@ -586,12 +592,22 @@ function makeFakeDeps(seed?: Partial): { deps: SkillServiceDeps; stat
} as unknown as SkillRepository;
const skillVersionRepo = {
- create: async (data: { version: string; majorVersion: number; minorVersion: number }) => {
+ create: async (data: {
+ skillGuid?: string;
+ version: string;
+ majorVersion: number;
+ minorVersion: number;
+ metadata?: SkillVersionDocument["metadata"];
+ }) => {
const v = versionDoc({
- _id: `guid-1@${data.version}`,
+ _id: `${data.skillGuid ?? "guid-1"}@${data.version}`,
+ ...(data.skillGuid ? { skillGuid: data.skillGuid } : {}),
version: data.version,
majorVersion: data.majorVersion,
minorVersion: data.minorVersion,
+ // Capture the metadata the service passed so dependsOn round-trips
+ // (#968) — the previous fake discarded it.
+ ...(data.metadata ? { metadata: data.metadata } : {}),
});
state.versions.push(v);
return v;
@@ -790,6 +806,340 @@ describe("SkillService.updateSkill", () => {
});
});
+describe("SkillService skill dependencies — persistence + publish validation (#968)", () => {
+ /**
+ * Seed an already-published dependency skill `pdf-tools@1.0` so the
+ * closure loader can resolve refs against it. Returns the seed maps for
+ * `makeFakeDeps`.
+ */
+ function seedDep(opts: { dependsOn?: string[]; isPrivate?: boolean } = {}) {
+ const depSkill = makeSkillDoc({
+ guid: "dep-guid",
+ name: "pdf-tools",
+ latestVersion: "1.0",
+ isPrivate: opts.isPrivate ?? false,
+ });
+ const depVersion = versionDoc({
+ _id: "dep-guid@1.0",
+ skillGuid: "dep-guid",
+ version: "1.0",
+ majorVersion: 1,
+ minorVersion: 0,
+ metadata: { category: "plain", ...(opts.dependsOn ? { dependsOn: opts.dependsOn } : {}) },
+ });
+ return {
+ skills: new Map([["dep-guid", depSkill]]),
+ byName: new Map([["pdf-tools", depSkill]]),
+ versions: [depVersion],
+ };
+ }
+
+ it("round-trips depends-on from frontmatter into the persisted version metadata", async () => {
+ const { deps, state } = makeFakeDeps(seedDep());
+ const service = new SkillService(deps);
+ await service.createSkill(
+ await validSkillZip({ name: "report-gen", dependsOn: ["pdf-tools@1.0"] }),
+ "owner-1",
+ );
+ const created = state.versions.find((v) => v.skillGuid !== "dep-guid");
+ expect(created?.metadata.dependsOn).toEqual(["pdf-tools@1.0"]);
+ });
+
+ it("a version published without deps reads back with dependsOn absent (legacy-clean)", async () => {
+ const { deps, state } = makeFakeDeps();
+ const service = new SkillService(deps);
+ await service.createSkill(await validSkillZip({ name: "no-deps" }), "owner-1");
+ const created = state.versions[0]!;
+ expect(created.metadata.dependsOn).toBeUndefined();
+ });
+
+ it("createSkill succeeds for a valid single dependency", async () => {
+ const { deps } = makeFakeDeps(seedDep());
+ const service = new SkillService(deps);
+ const { guid } = await service.createSkill(
+ await validSkillZip({ name: "report-gen", dependsOn: ["pdf-tools@1.0"] }),
+ "owner-1",
+ );
+ expect(guid).toBeTruthy();
+ });
+
+ it("createSkill throws skill_dependency_not_found for a missing dependency", async () => {
+ const { deps, state } = makeFakeDeps();
+ const service = new SkillService(deps);
+ let thrown: unknown;
+ try {
+ await service.createSkill(
+ await validSkillZip({ name: "report-gen", dependsOn: ["ghost-skill@1.0"] }),
+ "owner-1",
+ );
+ } catch (err) {
+ thrown = err;
+ }
+ expect(thrown).toBeInstanceOf(AppError);
+ expect((thrown as AppError).code).toBe("skill_dependency_not_found");
+ // Failed before any storage write.
+ expect(state.uploads).toHaveLength(0);
+ });
+
+ it("createSkill throws dependency_cycle when a transitive dep loops back", async () => {
+ // The seeded dependency `pdf-tools@1.0` itself depends on
+ // `report-gen@1.0`. report-gen isn't published yet, but the closure
+ // resolver walks pdf-tools' declared deps and `report-gen@1.0` can't
+ // be loaded — so this actually surfaces as skill_dependency_not_found,
+ // NOT a cycle, because report-gen has no published version yet.
+ //
+ // To exercise a real cycle we seed two mutually-dependent PUBLISHED
+ // skills and resolve a NEW skill that depends on one of them.
+ const aSkill = makeSkillDoc({ guid: "a-guid", name: "skill-a", latestVersion: "1.0" });
+ const bSkill = makeSkillDoc({ guid: "b-guid", name: "skill-b", latestVersion: "1.0" });
+ const aVersion = versionDoc({
+ _id: "a-guid@1.0",
+ skillGuid: "a-guid",
+ version: "1.0",
+ metadata: { category: "plain", dependsOn: ["skill-b@1.0"] },
+ });
+ const bVersion = versionDoc({
+ _id: "b-guid@1.0",
+ skillGuid: "b-guid",
+ version: "1.0",
+ metadata: { category: "plain", dependsOn: ["skill-a@1.0"] },
+ });
+ const { deps } = makeFakeDeps({
+ skills: new Map([
+ ["a-guid", aSkill],
+ ["b-guid", bSkill],
+ ]),
+ byName: new Map([
+ ["skill-a", aSkill],
+ ["skill-b", bSkill],
+ ]),
+ versions: [aVersion, bVersion],
+ });
+ const service = new SkillService(deps);
+ let thrown: unknown;
+ try {
+ await service.createSkill(
+ await validSkillZip({ name: "consumer", dependsOn: ["skill-a@1.0"] }),
+ "owner-1",
+ );
+ } catch (err) {
+ thrown = err;
+ }
+ expect(thrown).toBeInstanceOf(AppError);
+ expect((thrown as AppError).code).toBe("dependency_cycle");
+ expect((thrown as AppError).statusCode).toBe(409);
+ });
+
+ it("createSkill succeeds for a valid diamond closure", async () => {
+ // d (leaf) ← b, c ← consumer(root via b@1.0, c@1.0).
+ const dSkill = makeSkillDoc({ guid: "d-guid", name: "leaf-d", latestVersion: "1.0" });
+ const bSkill = makeSkillDoc({ guid: "b-guid", name: "mid-b", latestVersion: "1.0" });
+ const cSkill = makeSkillDoc({ guid: "c-guid", name: "mid-c", latestVersion: "1.0" });
+ const dVersion = versionDoc({
+ _id: "d-guid@1.0",
+ skillGuid: "d-guid",
+ version: "1.0",
+ metadata: { category: "plain" },
+ });
+ const bVersion = versionDoc({
+ _id: "b-guid@1.0",
+ skillGuid: "b-guid",
+ version: "1.0",
+ metadata: { category: "plain", dependsOn: ["leaf-d@1.0"] },
+ });
+ const cVersion = versionDoc({
+ _id: "c-guid@1.0",
+ skillGuid: "c-guid",
+ version: "1.0",
+ metadata: { category: "plain", dependsOn: ["leaf-d@1.0"] },
+ });
+ const { deps } = makeFakeDeps({
+ skills: new Map([
+ ["d-guid", dSkill],
+ ["b-guid", bSkill],
+ ["c-guid", cSkill],
+ ]),
+ byName: new Map([
+ ["leaf-d", dSkill],
+ ["mid-b", bSkill],
+ ["mid-c", cSkill],
+ ]),
+ versions: [dVersion, bVersion, cVersion],
+ });
+ const service = new SkillService(deps);
+ const { guid } = await service.createSkill(
+ await validSkillZip({ name: "diamond-root", dependsOn: ["mid-b@1.0", "mid-c@1.0"] }),
+ "owner-1",
+ );
+ expect(guid).toBeTruthy();
+ });
+
+ it("resolveSkillClosure returns the topo-ordered closure for a published skill", async () => {
+ const { deps } = makeFakeDeps(seedDep());
+ const service = new SkillService(deps);
+ await service.createSkill(
+ await validSkillZip({ name: "report-gen", dependsOn: ["pdf-tools@1.0"] }),
+ "owner-1",
+ );
+ const closure = await service.resolveSkillClosure("report-gen", SYSTEM_ACTOR);
+ expect(closure.map((n) => n.name)).toEqual(["pdf-tools"]);
+ // The closure excludes the skill itself; its direct dependencies are
+ // the roots of the walk → depth 0.
+ expect(closure[0]!.depth).toBe(0);
+ expect(closure[0]!.guid).toBe("dep-guid");
+ });
+
+ // ==========================================================================
+ // Per-node visibility gate in buildVersionLoader (#806/#968).
+ //
+ // A PUBLIC skill may transitively depend on a PRIVATE skill. When an
+ // anonymous / under-privileged caller resolves the public root's closure,
+ // the private node MUST NOT leak. The loader (service.ts buildVersionLoader)
+ // returns `null` for an unreadable node; the resolver (closure/resolver.ts
+ // `resolve()`) turns a null load into a hard `skill_dependency_not_found`
+ // (404) — existence is never disclosed. These two tests lock that branch:
+ // an unauthorized caller hits the error, an authorized caller still sees
+ // the node. Delete the `canReadSkill` guard and the negative test breaks
+ // (the closure would resolve successfully and leak the private dep).
+ // ==========================================================================
+
+ /**
+ * Seed a PUBLIC root `report-gen@1.0` that depends on a PRIVATE
+ * `pdf-tools@1.0`, both already published. Returns the seed maps for
+ * `makeFakeDeps` so the closure loader resolves real docs (no createSkill
+ * round-trip — the root must be public, which the fake's create() isn't).
+ */
+ function seedPublicRootPrivateDep() {
+ const rootSkill = makeSkillDoc({
+ guid: "root-guid",
+ name: "report-gen",
+ latestVersion: "1.0",
+ isPrivate: false,
+ createdBy: "owner-1",
+ });
+ const privateDep = makeSkillDoc({
+ guid: "dep-guid",
+ name: "pdf-tools",
+ latestVersion: "1.0",
+ isPrivate: true,
+ createdBy: "owner-1",
+ });
+ const rootVersion = versionDoc({
+ _id: "root-guid@1.0",
+ skillGuid: "root-guid",
+ version: "1.0",
+ metadata: { category: "plain", dependsOn: ["pdf-tools@1.0"] },
+ });
+ const depVersion = versionDoc({
+ _id: "dep-guid@1.0",
+ skillGuid: "dep-guid",
+ version: "1.0",
+ metadata: { category: "plain" },
+ });
+ return {
+ skills: new Map([
+ ["root-guid", rootSkill],
+ ["dep-guid", privateDep],
+ ]),
+ byName: new Map([
+ ["report-gen", rootSkill],
+ ["pdf-tools", privateDep],
+ ]),
+ versions: [rootVersion, depVersion],
+ };
+ }
+
+ // Anonymous caller: a logged-out request. `userId: ""` matches neither the
+ // private dep's `createdBy` ("owner-1") nor its (empty) ACLs, and is not a
+ // platform admin → cannot read pdf-tools.
+ const ANON: ActorContext = {
+ userId: "",
+ memberships: [],
+ isPlatformAdmin: false,
+ membershipsResolved: true,
+ };
+
+ it("resolveSkillClosure hides a private transitive dep from an anonymous caller (skill_dependency_not_found)", async () => {
+ const { deps } = makeFakeDeps(seedPublicRootPrivateDep());
+ const service = new SkillService(deps);
+ // The PUBLIC root passes resolveSkillClosure's own entry gate; the walk
+ // then reaches the PRIVATE pdf-tools, whose loader returns null for an
+ // unreadable node → resolver throws skill_dependency_not_found. The
+ // private skill's existence is never disclosed.
+ let thrown: unknown;
+ try {
+ await service.resolveSkillClosure("report-gen", ANON);
+ } catch (err) {
+ thrown = err;
+ }
+ expect(thrown).toBeInstanceOf(AppError);
+ expect((thrown as AppError).code).toBe("skill_dependency_not_found");
+ expect((thrown as AppError).statusCode).toBe(404);
+ });
+
+ it("resolveSkillClosure hides a private transitive dep from a non-owner non-admin caller", async () => {
+ const { deps } = makeFakeDeps(seedPublicRootPrivateDep());
+ const service = new SkillService(deps);
+ // A different authenticated user who is neither the author, on the ACL,
+ // nor a platform admin — same outcome as anonymous.
+ const stranger: ActorContext = {
+ userId: "intruder-9",
+ memberships: [],
+ isPlatformAdmin: false,
+ membershipsResolved: true,
+ };
+ let thrown: unknown;
+ try {
+ await service.resolveSkillClosure("report-gen", stranger);
+ } catch (err) {
+ thrown = err;
+ }
+ expect(thrown).toBeInstanceOf(AppError);
+ expect((thrown as AppError).code).toBe("skill_dependency_not_found");
+ expect((thrown as AppError).statusCode).toBe(404);
+ });
+
+ it("resolveSkillClosure exposes the same private transitive dep to an authorized caller", async () => {
+ const { deps } = makeFakeDeps(seedPublicRootPrivateDep());
+ const service = new SkillService(deps);
+ // SYSTEM_ACTOR (and equivalently the owner / platform admin) CAN read the
+ // private dep, so the gate hides it only from unauthorized callers — no
+ // over-correction. The closure resolves and includes pdf-tools.
+ const closure = await service.resolveSkillClosure("report-gen", SYSTEM_ACTOR);
+ expect(closure.map((n) => n.name)).toEqual(["pdf-tools"]);
+ expect(closure[0]!.guid).toBe("dep-guid");
+
+ // The owning author sees it too (the gate keys on identity, not just the
+ // SYSTEM bypass).
+ const owner: ActorContext = {
+ userId: "owner-1",
+ memberships: [],
+ isPlatformAdmin: false,
+ membershipsResolved: true,
+ };
+ const ownerClosure = await service.resolveSkillClosure("report-gen", owner);
+ expect(ownerClosure.map((n) => n.name)).toEqual(["pdf-tools"]);
+ });
+
+ // createVersionLoader was promoted from private `buildVersionLoader` to a
+ // public method (#969) so the skillsets service can reuse it to resolve
+ // member refs against the live skill graph. This pins the public surface:
+ // SYSTEM_ACTOR resolves a known published ref to a canonical node.
+ it("createVersionLoader(SYSTEM_ACTOR) resolves a known published ref", async () => {
+ const { deps } = makeFakeDeps(seedDep());
+ const service = new SkillService(deps);
+ const load = service.createVersionLoader(SYSTEM_ACTOR);
+ const node = await load("pdf-tools@1.0");
+ expect(node).not.toBeNull();
+ expect(node!.ref).toBe("pdf-tools@1.0");
+ expect(node!.name).toBe("pdf-tools");
+ expect(node!.version).toBe("1.0");
+ expect(node!.guid).toBe("dep-guid");
+ // An unknown ref resolves to null (surfaced as not-found by callers).
+ expect(await load("ghost-skill@1.0")).toBeNull();
+ });
+});
+
describe("SkillService.getSkill / dist-tags / versions", () => {
function seededService() {
const skill = makeSkillDoc({ guid: "guid-1", latestVersion: "1.0", distTags: { beta: "1.0" } });
diff --git a/ornn-api/src/domains/skills/crud/service.ts b/ornn-api/src/domains/skills/crud/service.ts
index a3e15d80..1f3558a3 100644
--- a/ornn-api/src/domains/skills/crud/service.ts
+++ b/ornn-api/src/domains/skills/crud/service.ts
@@ -14,7 +14,13 @@ import { AppError } from "../../../shared/types/index";
import { fetchSkillFromGitHub, parseGithubUrl, type GitHubPullInput } from "./utils/githubPull";
import { computeVersionDiff, type VersionDiffResult } from "./utils/versionDiff";
import { isReservedVerb } from "../../../shared/reservedVerbs";
-import { canReadSkill, isMemberOfOrg, type ActorContext } from "./authorize";
+import { canReadSkill, isMemberOfOrg, SYSTEM_ACTOR, type ActorContext } from "./authorize";
+import {
+ resolveClosure,
+ type LoadVersion,
+ type ResolvedVersion,
+ type ClosureNode,
+} from "../closure/resolver";
/**
* Convert the stored hex `skillHash` into npm-style Subresource Integrity
@@ -34,6 +40,8 @@ import {
validateSkillFrontmatter,
SKILL_NAME_REGEX,
SKILL_NAME_MAX,
+ SKILL_VERSION_REGEX,
+ DEPENDS_ON_REF_REGEX,
} from "../../../shared/schemas/skillFrontmatter";
import { resolveZipRoot } from "../../../shared/utils/zip";
import { enforceZipLimits, type ZipLimitsConfig } from "../../../shared/utils/zipLimits";
@@ -266,6 +274,12 @@ export class SkillService {
throw AppError.conflict("skill_name_exists", `Skill '${name}' already exists`);
}
+ // 3c. Skill-dependency validation (#968). Resolve the closure of the
+ // declared `depends-on` refs BEFORE any storage write so a missing
+ // dependency / cycle / version conflict fails the publish early.
+ // No-op when the skill declares no dependencies.
+ await this.validatePublishDependencies(metadata, { name, version });
+
// 4. Generate GUID and hash
const guid = randomUUID();
const skillHash = createHash("sha256").update(zipBuffer).digest("hex");
@@ -700,6 +714,11 @@ export class SkillService {
}
}
+ // Skill-dependency validation (#968) — same gate as createSkill,
+ // applied to the new version's declared deps before any storage
+ // write. No-op when the version declares no dependencies.
+ await this.validatePublishDependencies(metadata, { name, version });
+
const skillHash = createHash("sha256").update(options.zipBuffer).digest("hex");
// Upload under a new, versioned storage key — versions are immutable.
@@ -1477,6 +1496,168 @@ export class SkillService {
}
}
+ // ==========================================================================
+ // Dependency closure (#968)
+ // ==========================================================================
+
+ /**
+ * Build a {@link LoadVersion} loader over the live skill collections,
+ * scoped to what `actor` may read. Resolves a dependency ref
+ * (`@` or `@`) into a
+ * {@link ResolvedVersion} the closure resolver can walk, or `null` when
+ * the skill / version doesn't exist OR isn't visible to the actor.
+ *
+ * Per-node `canReadSkill` (#806/#968): an anonymous or under-privileged
+ * caller resolving the closure of a public skill that transitively
+ * depends on a PRIVATE skill gets `null` for that node, surfaced as
+ * `skill_dependency_not_found` — existence isn't leaked. Trusted
+ * callers (publish-time validation) pass `SYSTEM_ACTOR`.
+ *
+ * PUBLIC (#969): the skillsets service injects `SkillService` and
+ * reuses this loader to resolve a skillset's member refs against the
+ * live skill graph — a skillset member is just a skill ref. Promoting
+ * the loader from `private` to a public method means the closure walk
+ * stays single-sourced; both surfaces resolve refs (and apply the
+ * per-node `canReadSkill` visibility gate) identically.
+ */
+ createVersionLoader(actor: ActorContext): LoadVersion {
+ return async (ref: string): Promise => {
+ const at = ref.lastIndexOf("@");
+ if (at <= 0 || at === ref.length - 1) return null;
+ const idOrName = ref.slice(0, at);
+ const versionOrTag = ref.slice(at + 1);
+
+ const skill =
+ (await this.skillRepo.findByGuid(idOrName)) ??
+ (await this.skillRepo.findByName(idOrName));
+ if (!skill) return null;
+
+ // Visibility gate (#806) — a node the actor cannot read is invisible
+ // (returns null), not a hard error, so the closure of a public skill
+ // never leaks the existence of a private dependency.
+ if (!canReadSkill(skill, actor)) return null;
+
+ // Resolve a dist-tag to a literal version; a literal passes through.
+ // Dist-tag refs use the `@` grammar; map them via the
+ // skill's distTags. `resolveDistTag` expects an `@`-prefixed tag, so
+ // detect the literal-version shape first.
+ let literalVersion: string;
+ if (SKILL_VERSION_REGEX.test(versionOrTag)) {
+ literalVersion = versionOrTag;
+ } else {
+ const resolved = skill.distTags?.[versionOrTag];
+ if (resolved) {
+ literalVersion = resolved;
+ } else if (versionOrTag === "latest") {
+ literalVersion = skill.latestVersion;
+ } else {
+ return null;
+ }
+ }
+
+ const versionDoc = await this.skillVersionRepo.findBySkillAndVersion(
+ skill.guid,
+ literalVersion,
+ );
+ if (!versionDoc) return null;
+
+ const node: ResolvedVersion = {
+ ref: `${skill.name}@${versionDoc.version}`,
+ name: skill.name,
+ version: versionDoc.version,
+ guid: skill.guid,
+ skillHash: versionDoc.skillHash,
+ dependsOn: versionDoc.metadata?.dependsOn ?? [],
+ };
+ return node;
+ };
+ }
+
+ /**
+ * Resolve the full transitive dependency closure of a skill version,
+ * scoped to what `actor` may read (#968).
+ *
+ * The roots are the skill's own direct `depends-on` refs at the
+ * requested version (NOT the skill itself — the closure describes what
+ * the skill *needs*). Returns nodes in deps-first topological order.
+ *
+ * Throws `skill_not_found` (404) when the root skill / version is
+ * unknown, and `dependency_cycle` / `dependency_conflict` /
+ * `skill_dependency_not_found` from the resolver.
+ */
+ async resolveSkillClosure(
+ idOrName: string,
+ actor: ActorContext,
+ version?: string,
+ ): Promise {
+ const skill = await this.findSkillByIdOrName(idOrName);
+ if (!canReadSkill(skill, actor)) {
+ throw AppError.notFound("skill_not_found", `Skill '${idOrName}' not found`);
+ }
+
+ const resolvedVersion =
+ version === undefined || version.length === 0
+ ? skill.latestVersion
+ : resolveDistTag(skill, version) ?? skill.latestVersion;
+ parseVersion(resolvedVersion);
+
+ const versionDoc = await this.skillVersionRepo.findBySkillAndVersion(
+ skill.guid,
+ resolvedVersion,
+ );
+ if (!versionDoc) {
+ throw AppError.notFound(
+ "skill_version_not_found",
+ `Version '${resolvedVersion}' not found for skill '${skill.name}'`,
+ );
+ }
+
+ const roots = versionDoc.metadata?.dependsOn ?? [];
+ if (roots.length === 0) {
+ logger.info({ idOrName, version: resolvedVersion }, "Closure resolved: no dependencies");
+ return [];
+ }
+
+ const closure = await resolveClosure(roots, {
+ loadVersion: this.createVersionLoader(actor),
+ });
+ logger.info(
+ { idOrName, version: resolvedVersion, nodeCount: closure.length },
+ "Skill dependency closure resolved",
+ );
+ return closure;
+ }
+
+ /**
+ * Publish-time dependency validation (#968). Walks the closure of the
+ * just-extracted `dependsOn` refs to guarantee, BEFORE the version is
+ * committed, that every dependency exists, is readable to the author,
+ * the graph is acyclic, and no two versions of one skill collide.
+ *
+ * Runs as `SYSTEM_ACTOR` deliberately: the author may legitimately
+ * depend on a private skill they own / were granted; the closure is
+ * computed over the full graph and the route layer does NOT expose
+ * these results — it only gates the publish. A missing / unresolvable
+ * dependency surfaces as `skill_dependency_not_found`, a cycle as
+ * `dependency_cycle`, a conflict as `dependency_conflict`.
+ *
+ * No-op when the new version declares no dependencies.
+ */
+ private async validatePublishDependencies(
+ metadata: SkillMetadata,
+ context: { name: string; version: string },
+ ): Promise {
+ const roots = metadata.dependsOn ?? [];
+ if (roots.length === 0) return;
+ await resolveClosure(roots, {
+ loadVersion: this.createVersionLoader(SYSTEM_ACTOR),
+ });
+ logger.info(
+ { name: context.name, version: context.version, depCount: roots.length },
+ "Publish-time dependencies validated",
+ );
+ }
+
// ==========================================================================
// Private helpers
// ==========================================================================
@@ -1592,6 +1773,15 @@ export class SkillService {
metadata.tags = rawMeta.tag;
}
+ // Skill dependencies (#968). Map the kebab `depends-on` frontmatter
+ // field onto the camelCase `dependsOn` metadata field. The Zod schema
+ // already validated grammar + self-ref + the 50-entry cap, so this is
+ // a straight copy. Only set the key when non-empty so legacy / no-dep
+ // versions read back clean (absent, not `[]`).
+ if (rawMeta["depends-on"].length > 0) {
+ metadata.dependsOn = rawMeta["depends-on"];
+ }
+
// Author-supplied changelog lives next to the formal frontmatter but isn't
// part of the Zod schema — kept permissive so missing/older SKILL.md files
// just report null instead of hard-failing. Accepts either `release-notes`
@@ -1697,6 +1887,30 @@ export class SkillService {
const metadata: SkillMetadata = { category };
if (tags && tags.length > 0) metadata.tags = tags;
+ // Skill dependencies (#968) under skipValidation. The strict Zod
+ // schema was bypassed, so we re-apply the grammar regex here and keep
+ // only well-formed refs (self-refs by name dropped too). A malformed
+ // ref is silently discarded rather than failing the import — same
+ // best-effort posture as `tags` above. This keeps the closure
+ // resolver's input invariant ("every persisted ref parses") even on
+ // the lenient path; the dropped refs are logged for diagnosis.
+ if (Array.isArray(rawMeta["depends-on"])) {
+ const validDeps = (rawMeta["depends-on"] as unknown[]).filter(
+ (d): d is string =>
+ typeof d === "string" &&
+ DEPENDS_ON_REF_REGEX.test(d) &&
+ d.slice(0, d.indexOf("@")) !== name,
+ );
+ const dropped = (rawMeta["depends-on"] as unknown[]).length - validDeps.length;
+ if (dropped > 0) {
+ logger.info(
+ { name, dropped },
+ "skipValidation: dropped malformed/self depends-on entries",
+ );
+ }
+ if (validDeps.length > 0) metadata.dependsOn = validDeps.slice(0, 50);
+ }
+
const rawReleaseNotes = raw["release-notes"] ?? raw["releaseNotes"];
let releaseNotes: string | null = null;
if (typeof rawReleaseNotes === "string" && rawReleaseNotes.trim().length > 0) {
diff --git a/ornn-api/src/domains/skills/format/routes.ts b/ornn-api/src/domains/skills/format/routes.ts
index 954b50d9..5dee22b3 100644
--- a/ornn-api/src/domains/skills/format/routes.ts
+++ b/ornn-api/src/domains/skills/format/routes.ts
@@ -60,6 +60,7 @@ export const SKILL_FORMAT_RULES = `# Ornn Skill Package Format Rules
- **runtime-env-var** (array, optional): environment variable names in UPPER_SNAKE_CASE.
- **tool-list** (array): required when category is \`tool-based\` or \`mixed\`. Array of tool name strings.
- **tag** (array, optional): array of lowercase kebab-case tags.
+ - **depends-on** (array, optional): other skills this skill depends on, max 50. Each entry pins one skill by \`@\` (e.g. \`pdf-tools@1.0\`) or \`@\` (e.g. \`pdf-tools@beta\`). Semver ranges (\`^1.0\`, \`~1.0\`, \`1.2.3\`) are **not** allowed. A skill must not depend on itself. The full transitive closure is validated at publish time (no missing deps, no cycles, no conflicting versions of the same skill) and can be read via \`GET /api/v1/skills/{id}/closure\`.
### Optional Frontmatter Fields
diff --git a/ornn-api/src/domains/skillsets/authorize.ts b/ornn-api/src/domains/skillsets/authorize.ts
new file mode 100644
index 00000000..9b26d00a
--- /dev/null
+++ b/ornn-api/src/domains/skillsets/authorize.ts
@@ -0,0 +1,41 @@
+/**
+ * Skillset authorization helpers (#969).
+ *
+ * A skillset's ownership/visibility shape mirrors a skill's verbatim
+ * (`isPrivate` / `sharedWithUsers` / `sharedWithOrgs` / `createdBy`), so
+ * the read/write gates delegate straight to the skills `authorize.ts`
+ * helpers — there is exactly one visibility policy, shared across both
+ * resources, and it can never drift.
+ *
+ * @module domains/skillsets/authorize
+ */
+
+import {
+ canReadSkill,
+ canManageSkill,
+ type ActorContext,
+} from "../skills/crud/authorize";
+
+/** Minimal ownership shape (subset of SkillsetDocument / detail). */
+export interface SkillsetOwnership {
+ createdBy: string;
+ isPrivate: boolean;
+ sharedWithUsers: string[];
+ sharedWithOrgs: string[];
+}
+
+/** True when `actor` may read the skillset. Delegates to the skill gate. */
+export function canReadSkillset(
+ skillset: SkillsetOwnership,
+ actor: ActorContext,
+): boolean {
+ return canReadSkill(skillset, actor);
+}
+
+/** True when `actor` may mutate the skillset. Delegates to the skill gate. */
+export function canManageSkillset(
+ skillset: SkillsetOwnership,
+ actor: ActorContext,
+): boolean {
+ return canManageSkill(skillset, actor);
+}
diff --git a/ornn-api/src/domains/skillsets/bootstrap.test.ts b/ornn-api/src/domains/skillsets/bootstrap.test.ts
new file mode 100644
index 00000000..bc3aafa3
--- /dev/null
+++ b/ornn-api/src/domains/skillsets/bootstrap.test.ts
@@ -0,0 +1,90 @@
+/**
+ * Skillsets bootstrap wiring smoke test (#969).
+ *
+ * `wireSkillsets` builds repos + service (injecting a SkillService) +
+ * search + routes, and exposes `ensureIndexes()`. This test mounts both
+ * route surfaces on an app backed by a real in-memory Mongo and confirms
+ * they're reachable under `/api/v1`:
+ * - `GET /api/v1/skillset-search` → 200 (empty registry)
+ * - `GET /api/v1/skillsets/` → 404 (handler reached, not 404 from
+ * an unmounted route — the body carries the skillset_not_found code)
+ *
+ * @module domains/skillsets/bootstrap.test
+ */
+
+import { afterAll, beforeAll, describe, expect, test } from "bun:test";
+import { Hono } from "hono";
+import { MongoClient, type Db } from "mongodb";
+import { MongoMemoryServer } from "mongodb-memory-server";
+import { buildProblemJsonBody } from "../../shared/types/index";
+import type { IStorageClient } from "../../clients/storageClient";
+import { SkillService } from "../skills/crud/service";
+import { SkillRepository } from "../skills/crud/repository";
+import { SkillVersionRepository } from "../skills/crud/skillVersionRepository";
+import { wireSkillsets } from "./bootstrap";
+
+let mongo: MongoMemoryServer;
+let client: MongoClient;
+let db: Db;
+
+beforeAll(async () => {
+ mongo = await MongoMemoryServer.create();
+ client = new MongoClient(mongo.getUri());
+ await client.connect();
+ db = client.db("skillsets_bootstrap_test");
+});
+
+afterAll(async () => {
+ await client.close();
+ await mongo.stop();
+});
+
+function buildApp() {
+ const skillService = new SkillService({
+ skillRepo: new SkillRepository(db),
+ skillVersionRepo: new SkillVersionRepository(db),
+ storageClient: {} as unknown as IStorageClient,
+ storageBucketResolver: async () => "bucket",
+ });
+ const skillsets = wireSkillsets({ db, skillService });
+
+ const app = new Hono();
+ const apiApp = new Hono();
+ apiApp.route("/", skillsets.routes);
+ apiApp.route("/", skillsets.searchRoutes);
+ app.route("/api/v1", apiApp);
+ app.onError((err, c) => {
+ const code = (err as { code?: string }).code ?? "internal_error";
+ const status = (err as { statusCode?: number }).statusCode ?? 500;
+ return c.json(
+ buildProblemJsonBody({ statusCode: status, code, message: err.message, instance: c.req.path, requestId: null }),
+ status as never,
+ { "Content-Type": "application/problem+json" },
+ );
+ });
+ return { app, ensureIndexes: skillsets.ensureIndexes };
+}
+
+describe("wireSkillsets — smoke mount", () => {
+ test("ensureIndexes resolves against a real Mongo", async () => {
+ const { ensureIndexes } = buildApp();
+ await ensureIndexes();
+ expect(true).toBe(true);
+ });
+
+ test("GET /api/v1/skillset-search is mounted (200, empty registry)", async () => {
+ const { app } = buildApp();
+ const res = await app.request("/api/v1/skillset-search");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { data: { items: unknown[]; total: number } };
+ expect(Array.isArray(body.data.items)).toBe(true);
+ expect(body.data.total).toBe(0);
+ });
+
+ test("GET /api/v1/skillsets/:idOrName is mounted (404 from the handler)", async () => {
+ const { app } = buildApp();
+ const res = await app.request("/api/v1/skillsets/does-not-exist");
+ expect(res.status).toBe(404);
+ expect(((await res.json()) as { code: string }).code).toBe("skillset_not_found");
+ });
+});
diff --git a/ornn-api/src/domains/skillsets/bootstrap.ts b/ornn-api/src/domains/skillsets/bootstrap.ts
new file mode 100644
index 00000000..20644fe5
--- /dev/null
+++ b/ornn-api/src/domains/skillsets/bootstrap.ts
@@ -0,0 +1,57 @@
+/**
+ * Wire the skillsets domain (#969).
+ *
+ * Builds the two repositories (identity + append-only versions), the
+ * CRUD/closure service (injecting the existing `SkillService` so member
+ * resolution + the #968 closure walk stay single-sourced), the search
+ * service, and both route surfaces (`/skillsets/*` + `/skillset-search`).
+ *
+ * @module domains/skillsets/bootstrap
+ */
+
+import type { Db } from "mongodb";
+import type { Hono } from "hono";
+import type { AuthVariables } from "../../middleware/nyxidAuth";
+import type { SkillService } from "../skills/crud/service";
+import { SkillsetRepository } from "./repository";
+import { SkillsetVersionRepository } from "./skillsetVersionRepository";
+import { SkillsetService } from "./service";
+import { createSkillsetRoutes } from "./routes";
+import { SkillsetSearchService } from "./search/service";
+import { createSkillsetSearchRoutes } from "./search/routes";
+
+export interface SkillsetWiring {
+ readonly service: SkillsetService;
+ readonly routes: Hono<{ Variables: AuthVariables }>;
+ readonly searchRoutes: Hono<{ Variables: AuthVariables }>;
+ /** Ensure the two collections' indexes. Awaited by bootstrap on startup. */
+ ensureIndexes(): Promise;
+}
+
+export function wireSkillsets(deps: {
+ db: Db;
+ skillService: SkillService;
+}): SkillsetWiring {
+ const skillsetRepo = new SkillsetRepository(deps.db);
+ const skillsetVersionRepo = new SkillsetVersionRepository(deps.db);
+
+ const service = new SkillsetService({
+ skillsetRepo,
+ skillsetVersionRepo,
+ skillService: deps.skillService,
+ });
+ const routes = createSkillsetRoutes({ skillsetService: service });
+
+ const searchService = new SkillsetSearchService({ skillsetRepo });
+ const searchRoutes = createSkillsetSearchRoutes({ skillsetSearchService: searchService });
+
+ return {
+ service,
+ routes,
+ searchRoutes,
+ ensureIndexes: async () => {
+ await skillsetRepo.ensureIndexes();
+ await skillsetVersionRepo.ensureIndexes();
+ },
+ };
+}
diff --git a/ornn-api/src/domains/skillsets/repository.test.ts b/ornn-api/src/domains/skillsets/repository.test.ts
new file mode 100644
index 00000000..667c42f5
--- /dev/null
+++ b/ornn-api/src/domains/skillsets/repository.test.ts
@@ -0,0 +1,212 @@
+/**
+ * SkillsetRepository + SkillsetVersionRepository unit tests (#969).
+ *
+ * Backed by mongodb-memory-server, mirroring the skills repository
+ * harness. Pins:
+ * - findByScope visibility (honors the shared applyScope matrix)
+ * - kind equality filter narrows
+ * - tags $all AND-match
+ * - append-only versions reject a duplicate `guid@version`
+ *
+ * @module domains/skillsets/repository.test
+ */
+
+import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
+import { MongoClient, type Db } from "mongodb";
+import { MongoMemoryServer } from "mongodb-memory-server";
+import { SkillsetRepository } from "./repository";
+import { SkillsetVersionRepository } from "./skillsetVersionRepository";
+
+let mongo: MongoMemoryServer;
+let client: MongoClient;
+let db: Db;
+let repo: SkillsetRepository;
+let versionRepo: SkillsetVersionRepository;
+
+beforeAll(async () => {
+ mongo = await MongoMemoryServer.create();
+ client = new MongoClient(mongo.getUri());
+ await client.connect();
+ db = client.db("skillsets_test");
+ repo = new SkillsetRepository(db);
+ versionRepo = new SkillsetVersionRepository(db);
+ await repo.ensureIndexes();
+ await versionRepo.ensureIndexes();
+});
+
+afterAll(async () => {
+ await client.close();
+ await mongo.stop();
+});
+
+beforeEach(async () => {
+ await db.collection("skillsets").deleteMany({});
+ await db.collection("skillset_versions").deleteMany({});
+});
+
+function makeDoc(overrides: Record = {}): Record {
+ const now = new Date();
+ return {
+ _id: "ss-1",
+ name: "review-set",
+ description: "a curated set",
+ kind: "generic",
+ tags: ["alpha", "beta"],
+ createdBy: "owner-1",
+ createdOn: now,
+ updatedBy: "owner-1",
+ updatedOn: now,
+ isPrivate: false,
+ sharedWithUsers: [],
+ sharedWithOrgs: [],
+ latestVersion: "1.0",
+ ...overrides,
+ };
+}
+
+async function seed(...docs: Array>): Promise {
+ await db.collection("skillsets").insertMany(docs.map((d) => makeDoc(d)) as never);
+}
+
+describe("SkillsetRepository — CRUD", () => {
+ test("create then findByGuid / findByName round-trips", async () => {
+ await repo.create({
+ guid: "ss-x",
+ name: "my-set",
+ description: "desc",
+ kind: "consensus-supported",
+ tags: ["x"],
+ createdBy: "owner-1",
+ latestVersion: "1.0",
+ });
+ const byGuid = await repo.findByGuid("ss-x");
+ expect(byGuid?.name).toBe("my-set");
+ expect(byGuid?.kind).toBe("consensus-supported");
+ const byName = await repo.findByName("my-set");
+ expect(byName?.guid).toBe("ss-x");
+ });
+
+ test("create rejects a duplicate name with skillset_name_exists", async () => {
+ await repo.create({
+ guid: "ss-1",
+ name: "dup",
+ description: "d",
+ kind: "generic",
+ tags: [],
+ createdBy: "o",
+ latestVersion: "1.0",
+ });
+ let code = "";
+ try {
+ await repo.create({
+ guid: "ss-2",
+ name: "dup",
+ description: "d",
+ kind: "generic",
+ tags: [],
+ createdBy: "o",
+ latestVersion: "1.0",
+ });
+ } catch (err) {
+ code = (err as { code: string }).code;
+ }
+ expect(code).toBe("skillset_name_exists");
+ });
+});
+
+describe("SkillsetRepository — findByScope visibility", () => {
+ test("anonymous public scope sees only public skillsets", async () => {
+ await seed(
+ { _id: "pub", name: "pub-set", isPrivate: false },
+ { _id: "priv", name: "priv-set", isPrivate: true },
+ );
+ const { skillsets, total } = await repo.findByScope("public", "", [], 1, 20);
+ expect(total).toBe(1);
+ expect(skillsets.map((s) => s.guid)).toEqual(["pub"]);
+ });
+
+ test("private scope honors author + shared-user + shared-org grants", async () => {
+ await seed(
+ { _id: "mine", name: "mine-set", isPrivate: true, createdBy: "u1" },
+ { _id: "shared-u", name: "su-set", isPrivate: true, createdBy: "other", sharedWithUsers: ["u1"] },
+ { _id: "shared-o", name: "so-set", isPrivate: true, createdBy: "other", sharedWithOrgs: ["org-a"] },
+ { _id: "hidden", name: "hidden-set", isPrivate: true, createdBy: "other" },
+ );
+ const { skillsets } = await repo.findByScope("private", "u1", ["org-a"], 1, 20);
+ expect(skillsets.map((s) => s.guid).sort()).toEqual(["mine", "shared-o", "shared-u"]);
+ });
+});
+
+describe("SkillsetRepository — kind + tags filters", () => {
+ test("kind filter narrows", async () => {
+ await seed(
+ { _id: "g", name: "g-set", kind: "generic", isPrivate: false },
+ { _id: "c", name: "c-set", kind: "consensus-supported", isPrivate: false },
+ );
+ const { skillsets } = await repo.findByScope("public", "", [], 1, 20, {
+ kind: "consensus-supported",
+ });
+ expect(skillsets.map((s) => s.guid)).toEqual(["c"]);
+ });
+
+ test("tags $all requires every listed tag", async () => {
+ await seed(
+ { _id: "ab", name: "ab-set", tags: ["a", "b"], isPrivate: false },
+ { _id: "a", name: "a-set", tags: ["a"], isPrivate: false },
+ );
+ const { skillsets } = await repo.findByScope("public", "", [], 1, 20, {
+ tagsAll: ["a", "b"],
+ });
+ expect(skillsets.map((s) => s.guid)).toEqual(["ab"]);
+ });
+});
+
+describe("SkillsetVersionRepository — append-only", () => {
+ test("rejects a duplicate guid@version", async () => {
+ const data = {
+ skillsetGuid: "ss-1",
+ version: "1.0",
+ majorVersion: 1,
+ minorVersion: 0,
+ kind: "generic" as const,
+ description: "d",
+ instructions: "p",
+ tags: [],
+ members: ["a@1.0", "b@1.0"],
+ createdBy: "owner-1",
+ };
+ await versionRepo.create(data);
+ let code = "";
+ try {
+ await versionRepo.create(data);
+ } catch (err) {
+ code = (err as { code: string }).code;
+ }
+ expect(code).toBe("skillset_version_exists");
+ });
+
+ test("listBySkillset returns newest version first", async () => {
+ for (const [version, major, minor] of [
+ ["1.0", 1, 0],
+ ["1.1", 1, 1],
+ ["2.0", 2, 0],
+ ] as const) {
+ await versionRepo.create({
+ skillsetGuid: "ss-1",
+ version,
+ majorVersion: major,
+ minorVersion: minor,
+ kind: "generic",
+ description: "d",
+ instructions: "p",
+ tags: [],
+ members: ["a@1.0", "b@1.0"],
+ createdBy: "owner-1",
+ });
+ }
+ const versions = await versionRepo.listBySkillset("ss-1");
+ expect(versions.map((v) => v.version)).toEqual(["2.0", "1.1", "1.0"]);
+ const latest = await versionRepo.findLatestBySkillset("ss-1");
+ expect(latest?.version).toBe("2.0");
+ });
+});
diff --git a/ornn-api/src/domains/skillsets/repository.ts b/ornn-api/src/domains/skillsets/repository.ts
new file mode 100644
index 00000000..36d1b527
--- /dev/null
+++ b/ornn-api/src/domains/skillsets/repository.ts
@@ -0,0 +1,247 @@
+/**
+ * Skillset identity repository — the `skillsets` collection (#969).
+ *
+ * Thin Mongo wrapper mirroring `SkillRepository` for the skillset
+ * identity document. Keys each skillset by its UUID-string `_id` (the
+ * public GUID), exactly like skills. Reuses the shared `scopeFilter.ts`
+ * predicates so the skillset visibility matrix can never drift from the
+ * skill one; adds a `kind` equality filter + `tags $all` for skillset
+ * search.
+ *
+ * @module domains/skillsets/repository
+ */
+
+import type { Collection, Db, Document } from "mongodb";
+import { AppError } from "../../shared/types/index";
+import { createLogger } from "../../shared/logger";
+import { applyScope, applyExtraFilters, type SkillScope } from "../skills/crud/scopeFilter";
+import type { SkillsetDocument, SkillsetKind } from "./types";
+
+const logger = createLogger("skillsetRepository");
+
+/** Coerce a string GUID into the `_id` shape the driver expects. */
+function skillsetId(guid: string): never {
+ if (typeof guid !== "string" || guid.length === 0) {
+ throw AppError.badRequest(
+ "invalid_skillset_id",
+ "Skillset id must be a non-empty string",
+ );
+ }
+ return guid as never;
+}
+
+export interface CreateSkillsetData {
+ guid: string;
+ name: string;
+ description: string;
+ kind: SkillsetKind;
+ tags: string[];
+ createdBy: string;
+ // Optionals widen to `T | undefined` for exactOptionalPropertyTypes (#657).
+ createdByEmail?: string | undefined;
+ createdByDisplayName?: string | undefined;
+ isPrivate?: boolean | undefined;
+ /** Initial version, e.g. "1.0". Required. */
+ latestVersion: string;
+}
+
+export interface UpdateSkillsetData {
+ description?: string;
+ kind?: SkillsetKind;
+ tags?: string[];
+ isPrivate?: boolean;
+ sharedWithUsers?: string[];
+ sharedWithOrgs?: string[];
+ latestVersion?: string;
+ updatedBy: string;
+}
+
+/** Filters specific to skillset search: `kind` equality + `tags $all` + a `q`
+ * case-insensitive substring match on name/description. */
+export interface SkillsetSearchFilters {
+ kind?: SkillsetKind | undefined;
+ tagsAll?: string[] | undefined;
+ /** Free-text keyword — matched (case-insensitive) against name + description. */
+ q?: string | undefined;
+ sharedWithOrgsAny?: string[] | undefined;
+ sharedWithUsersAny?: string[] | undefined;
+ createdByAny?: string[] | undefined;
+}
+
+export class SkillsetRepository {
+ private readonly collection: Collection;
+ /** Server-side cap on paginated reads (mirrors SkillRepository). */
+ private static readonly MAX_QUERY_MS = 5_000;
+
+ constructor(db: Db) {
+ this.collection = db.collection("skillsets");
+ }
+
+ /**
+ * Ensure the indexes the skillset collection relies on. Idempotent.
+ * `name` is unique (one skillset per name, like skills); the rest feed
+ * scoped search + ordering.
+ */
+ async ensureIndexes(): Promise {
+ await Promise.all([
+ this.collection.createIndex({ name: 1 }, { unique: true }),
+ this.collection.createIndex({ createdBy: 1, createdOn: -1 }),
+ this.collection.createIndex({ createdOn: -1 }),
+ this.collection.createIndex({ isPrivate: 1, createdOn: -1 }),
+ this.collection.createIndex({ kind: 1, createdOn: -1 }),
+ ]);
+ }
+
+ async findByGuid(guid: string): Promise {
+ const doc = await this.collection.findOne({ _id: skillsetId(guid) });
+ return mapDoc(doc);
+ }
+
+ async findByName(name: string): Promise {
+ const doc = await this.collection.findOne({ name });
+ return mapDoc(doc);
+ }
+
+ async create(data: CreateSkillsetData): Promise {
+ const now = new Date();
+ const doc: Record = {
+ _id: skillsetId(data.guid),
+ name: data.name,
+ description: data.description,
+ kind: data.kind,
+ tags: data.tags,
+ createdBy: data.createdBy,
+ createdByEmail: data.createdByEmail ?? null,
+ createdByDisplayName: data.createdByDisplayName ?? null,
+ createdOn: now,
+ updatedBy: data.createdBy,
+ updatedOn: now,
+ isPrivate: data.isPrivate ?? true,
+ sharedWithUsers: [],
+ sharedWithOrgs: [],
+ latestVersion: data.latestVersion,
+ };
+
+ try {
+ await this.collection.insertOne(doc as never);
+ logger.info({ guid: data.guid, name: data.name, kind: data.kind }, "Skillset created");
+ } catch (err) {
+ if (typeof err === "object" && err !== null && "code" in err && err.code === 11000) {
+ throw AppError.conflict("skillset_name_exists", `Skillset '${data.name}' already exists`);
+ }
+ throw err;
+ }
+ return mapDoc(doc as Document)!;
+ }
+
+ async update(guid: string, data: UpdateSkillsetData): Promise {
+ const setFields: Record = {
+ updatedBy: data.updatedBy,
+ updatedOn: new Date(),
+ };
+ if (data.description !== undefined) setFields.description = data.description;
+ if (data.kind !== undefined) setFields.kind = data.kind;
+ if (data.tags !== undefined) setFields.tags = data.tags;
+ if (data.isPrivate !== undefined) setFields.isPrivate = data.isPrivate;
+ if (data.sharedWithUsers !== undefined) setFields.sharedWithUsers = data.sharedWithUsers;
+ if (data.sharedWithOrgs !== undefined) setFields.sharedWithOrgs = data.sharedWithOrgs;
+ if (data.latestVersion !== undefined) setFields.latestVersion = data.latestVersion;
+
+ await this.collection.updateOne({ _id: skillsetId(guid) }, { $set: setFields });
+ logger.info({ guid }, "Skillset updated");
+ return (await this.findByGuid(guid))!;
+ }
+
+ async hardDelete(guid: string): Promise {
+ await this.collection.deleteOne({ _id: skillsetId(guid) });
+ logger.info({ guid }, "Skillset hard-deleted");
+ }
+
+ /**
+ * Scoped + filtered paginated read. Visibility via the shared
+ * `applyScope` (identical to skills); `kind` equality + `tags $all` +
+ * the shared registry-chip filters layered on. Mirrors
+ * `SkillRepository.findByScope`.
+ */
+ async findByScope(
+ scope: SkillScope,
+ currentUserId: string,
+ userOrgIds: string[],
+ page: number,
+ pageSize: number,
+ filters?: SkillsetSearchFilters,
+ ): Promise<{ skillsets: SkillsetDocument[]; total: number }> {
+ const matchStage: Record = {};
+ applyScope(matchStage, scope, currentUserId, userOrgIds);
+ // Scope resolved to "match nothing" — short-circuit.
+ if ((matchStage._id as { $in?: unknown[] } | undefined)?.$in?.length === 0) {
+ return { skillsets: [], total: 0 };
+ }
+
+ // Reuse the shared chip filters (`tags $all`, shared-with-*Any,
+ // createdByAny) verbatim; `tags` on a skillset lives at the top level
+ // (not under `metadata.tags`), so add the `tags $all` clause directly
+ // rather than via `applyExtraFilters`' `metadata.tags` path.
+ applyExtraFilters(matchStage, {
+ sharedWithOrgsAny: filters?.sharedWithOrgsAny,
+ sharedWithUsersAny: filters?.sharedWithUsersAny,
+ createdByAny: filters?.createdByAny,
+ });
+
+ const extra: Array> = [];
+ if (filters?.kind) extra.push({ kind: filters.kind });
+ if (filters?.tagsAll && filters.tagsAll.length > 0) {
+ extra.push({ tags: { $all: filters.tagsAll } });
+ }
+ // Keyword: case-insensitive substring on name OR description. Escape regex
+ // metachars so user input can't inject a pattern (or a catastrophic one).
+ if (filters?.q && filters.q.trim().length > 0) {
+ const safe = filters.q.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ extra.push({
+ $or: [
+ { name: { $regex: safe, $options: "i" } },
+ { description: { $regex: safe, $options: "i" } },
+ ],
+ });
+ }
+ if (extra.length > 0) {
+ const existingAnd = (matchStage.$and as Array> | undefined) ?? [];
+ matchStage.$and = [...existingAnd, ...extra];
+ }
+
+ const total = await this.collection.countDocuments(matchStage, {
+ maxTimeMS: SkillsetRepository.MAX_QUERY_MS,
+ });
+ const offset = (page - 1) * pageSize;
+ const docs = await this.collection
+ .find(matchStage)
+ .sort({ createdOn: -1 })
+ .skip(offset)
+ .limit(pageSize)
+ .maxTimeMS(SkillsetRepository.MAX_QUERY_MS)
+ .toArray();
+
+ return { skillsets: docs.map((d) => mapDoc(d)!), total };
+ }
+}
+
+function mapDoc(doc: Document | null): SkillsetDocument | null {
+ if (!doc) return null;
+ return {
+ guid: doc._id as string,
+ name: doc.name,
+ description: doc.description ?? "",
+ kind: (doc.kind as SkillsetKind) ?? "generic",
+ tags: Array.isArray(doc.tags) ? (doc.tags as string[]) : [],
+ createdBy: doc.createdBy ?? "",
+ createdByEmail: doc.createdByEmail ?? undefined,
+ createdByDisplayName: doc.createdByDisplayName ?? undefined,
+ createdOn: doc.createdOn ?? new Date(),
+ updatedBy: doc.updatedBy ?? "",
+ updatedOn: doc.updatedOn ?? new Date(),
+ isPrivate: doc.isPrivate ?? true,
+ sharedWithUsers: Array.isArray(doc.sharedWithUsers) ? (doc.sharedWithUsers as string[]) : [],
+ sharedWithOrgs: Array.isArray(doc.sharedWithOrgs) ? (doc.sharedWithOrgs as string[]) : [],
+ latestVersion: doc.latestVersion ?? "1.0",
+ };
+}
diff --git a/ornn-api/src/domains/skillsets/routes.test.ts b/ornn-api/src/domains/skillsets/routes.test.ts
new file mode 100644
index 00000000..0e6bb52d
--- /dev/null
+++ b/ornn-api/src/domains/skillsets/routes.test.ts
@@ -0,0 +1,279 @@
+/**
+ * Route-level tests for the skillset CRUD + closure routes (#969).
+ *
+ * Mounts the real `createSkillsetRoutes` on a bare Hono app with a Proxy
+ * fake service (un-asserted methods throw). Pins:
+ * - closure resolves before :idOrName (literal segment wins)
+ * - 409 conflict surfaces with the right code
+ * - 404 unknown skillset
+ * - 403 scope-denied (missing ornn:skill:* scope)
+ * - permission scopes REUSE ornn:skill:* (not ornn:skillset:*)
+ *
+ * @module domains/skillsets/routes.test
+ */
+
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+import { Hono } from "hono";
+import { createSkillsetRoutes, type SkillsetRoutesConfig } from "./routes";
+import { AppError, buildProblemJsonBody } from "../../shared/types/index";
+import { __resetRateLimitForTests } from "../../middleware/rateLimit";
+
+const CREATE = "ornn:skill:create";
+const UPDATE = "ornn:skill:update";
+const DELETE = "ornn:skill:delete";
+const OWNER = "owner-1";
+
+function detail(overrides: Record = {}) {
+ return {
+ guid: "ss-1",
+ name: "review-set",
+ description: "a set",
+ instructions: "Run member a, then feed its output to member b.",
+ kind: "generic",
+ tags: [],
+ members: ["a@1.0", "b@1.0"],
+ version: "1.0",
+ latestVersion: "1.0",
+ isPrivate: false,
+ createdBy: OWNER,
+ sharedWithUsers: [],
+ sharedWithOrgs: [],
+ createdOn: "2026-01-01T00:00:00Z",
+ updatedOn: "2026-01-01T00:00:00Z",
+ ...overrides,
+ };
+}
+
+function fakeService(impl: Record unknown>) {
+ return new Proxy(impl, {
+ get(target, prop: string) {
+ if (prop in target) return target[prop];
+ return (..._args: unknown[]) => {
+ throw new Error(`skillsetService.${prop} should not be called in this test`);
+ };
+ },
+ }) as unknown as SkillsetRoutesConfig["skillsetService"];
+}
+
+interface BuildOpts {
+ authenticated?: boolean;
+ userId?: string;
+ permissions?: string[];
+ service?: Record unknown>;
+}
+
+function buildApp(opts: BuildOpts = {}) {
+ const { authenticated = true, userId = OWNER, permissions = [], service = {} } = opts;
+ const config: SkillsetRoutesConfig = {
+ skillsetService: fakeService(service),
+ };
+ const app = new Hono();
+ if (authenticated) {
+ app.use("*", async (c, next) => {
+ c.set("auth" as never, {
+ userId,
+ email: `${userId}@test.local`,
+ displayName: userId,
+ roles: [],
+ permissions,
+ } as never);
+ await next();
+ });
+ }
+ app.route("/api/v1", createSkillsetRoutes(config));
+ app.onError((err, c) => {
+ const code = (err as { code?: string }).code ?? "internal_error";
+ const status = (err as { statusCode?: number }).statusCode ?? 500;
+ const body = buildProblemJsonBody({
+ statusCode: status,
+ code,
+ message: err.message,
+ instance: c.req.path,
+ requestId: null,
+ });
+ return c.json(body, status as never, {
+ "Content-Type": "application/problem+json",
+ });
+ });
+ return app;
+}
+
+beforeEach(() => __resetRateLimitForTests());
+afterEach(() => __resetRateLimitForTests());
+
+describe("GET /skillsets/:idOrName/closure", () => {
+ test("the literal /closure segment wins over :idOrName", async () => {
+ const calls: string[] = [];
+ const app = buildApp({
+ authenticated: false,
+ service: {
+ resolveClosure: async () => {
+ calls.push("resolveClosure");
+ return {
+ instructions: "master prompt for the set",
+ items: [{ guid: "g-a", name: "a", version: "1.0", depth: 0 }],
+ };
+ },
+ getSkillset: async () => {
+ calls.push("getSkillset");
+ return detail();
+ },
+ },
+ });
+ const res = await app.request("/api/v1/skillsets/review-set/closure");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { data: { instructions: string; items: unknown[] } };
+ expect(body.data.items).toHaveLength(1);
+ // The master prompt (#978) rides as a ROOT sibling of items.
+ expect(body.data.instructions).toBe("master prompt for the set");
+ // Only resolveClosure ran — :idOrName's getSkillset was NOT reached.
+ expect(calls).toEqual(["resolveClosure"]);
+ });
+
+ test("409 dependency_conflict surfaces from the resolver", async () => {
+ const app = buildApp({
+ authenticated: false,
+ service: {
+ resolveClosure: async () => {
+ throw AppError.conflict("dependency_conflict", "two versions of x");
+ },
+ },
+ });
+ const res = await app.request("/api/v1/skillsets/review-set/closure");
+ expect(res.status).toBe(409);
+ expect(((await res.json()) as { code: string }).code).toBe("dependency_conflict");
+ });
+
+ test("404 unknown skillset", async () => {
+ const app = buildApp({
+ authenticated: false,
+ service: {
+ resolveClosure: async () => {
+ throw AppError.notFound("skillset_not_found", "nope");
+ },
+ },
+ });
+ const res = await app.request("/api/v1/skillsets/ghost/closure");
+ expect(res.status).toBe(404);
+ expect(((await res.json()) as { code: string }).code).toBe("skillset_not_found");
+ });
+});
+
+describe("GET /skillsets/:idOrName", () => {
+ test("200 for a public skillset (anon)", async () => {
+ const app = buildApp({
+ authenticated: false,
+ service: { getSkillset: async () => detail({ isPrivate: false }) },
+ });
+ const res = await app.request("/api/v1/skillsets/review-set");
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { data: { name: string } };
+ expect(body.data.name).toBe("review-set");
+ });
+
+ test("404 for a private skillset to anon (no leak)", async () => {
+ const app = buildApp({
+ authenticated: false,
+ service: { getSkillset: async () => detail({ isPrivate: true, createdBy: "someone" }) },
+ });
+ const res = await app.request("/api/v1/skillsets/secret-set");
+ expect(res.status).toBe(404);
+ expect(((await res.json()) as { code: string }).code).toBe("skillset_not_found");
+ });
+});
+
+describe("POST /skillsets — scope reuse + gating", () => {
+ test("401 unauthenticated", async () => {
+ const app = buildApp({ authenticated: false });
+ const res = await app.request("/api/v1/skillsets", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ name: "x", description: "d", members: ["a@1.0", "b@1.0"] }),
+ });
+ expect(res.status).toBe(401);
+ });
+
+ test("403 without ornn:skill:create (scope reuse, NOT ornn:skillset:*)", async () => {
+ const app = buildApp({ permissions: ["ornn:skill:read"] });
+ const res = await app.request("/api/v1/skillsets", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ name: "x", description: "d", members: ["a@1.0", "b@1.0"] }),
+ });
+ expect(res.status).toBe(403);
+ });
+
+ test("201 + Location with ornn:skill:create", async () => {
+ const app = buildApp({
+ permissions: [CREATE],
+ service: { createSkillset: async () => detail({ guid: "ss-new", isPrivate: true }) },
+ });
+ const res = await app.request("/api/v1/skillsets", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ name: "review-set",
+ description: "d",
+ instructions: "Use a, then b.",
+ members: ["a@1.0", "b@1.0"],
+ }),
+ });
+ expect(res.status).toBe(201);
+ expect(res.headers.get("Location")).toBe("/api/v1/skillsets/ss-new");
+ });
+
+ test("400 on fewer than 2 members", async () => {
+ const app = buildApp({ permissions: [CREATE] });
+ const res = await app.request("/api/v1/skillsets", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ name: "review-set", description: "d", members: ["a@1.0"] }),
+ });
+ expect(res.status).toBe(400);
+ });
+});
+
+describe("PUT/DELETE /skillsets/:id — scope gating", () => {
+ test("PUT 403 without ornn:skill:update", async () => {
+ const app = buildApp({ permissions: [CREATE] });
+ const res = await app.request("/api/v1/skillsets/ss-1", {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ version: "1.1", members: ["a@1.0", "b@1.0"] }),
+ });
+ expect(res.status).toBe(403);
+ });
+
+ test("PUT 200 with ornn:skill:update", async () => {
+ const app = buildApp({
+ permissions: [UPDATE],
+ service: { publishVersion: async () => detail({ version: "1.1", latestVersion: "1.1" }) },
+ });
+ const res = await app.request("/api/v1/skillsets/ss-1", {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ version: "1.1",
+ instructions: "Use a, then b.",
+ members: ["a@1.0", "b@1.0"],
+ }),
+ });
+ expect(res.status).toBe(200);
+ expect(((await res.json()) as { data: { version: string } }).data.version).toBe("1.1");
+ });
+
+ test("DELETE 403 without ornn:skill:delete", async () => {
+ const app = buildApp({ permissions: [UPDATE] });
+ const res = await app.request("/api/v1/skillsets/ss-1", { method: "DELETE" });
+ expect(res.status).toBe(403);
+ });
+
+ test("DELETE 200 with ornn:skill:delete", async () => {
+ const app = buildApp({
+ permissions: [DELETE],
+ service: { deleteSkillset: async () => undefined },
+ });
+ const res = await app.request("/api/v1/skillsets/ss-1", { method: "DELETE" });
+ expect(res.status).toBe(200);
+ });
+});
diff --git a/ornn-api/src/domains/skillsets/routes.ts b/ornn-api/src/domains/skillsets/routes.ts
new file mode 100644
index 00000000..a2d3cd4e
--- /dev/null
+++ b/ornn-api/src/domains/skillsets/routes.ts
@@ -0,0 +1,216 @@
+/**
+ * Skillset CRUD + closure routes (#969).
+ *
+ * URL layout follows CONVENTIONS.md (plural noun). Static sub-resource
+ * segments (`/closure`, `/versions`) are registered ABOVE the
+ * `/:idOrName` capture so the literal segment wins the match (mirrors the
+ * skills `/closure` registration order).
+ *
+ * Permission scopes REUSE the existing `ornn:skill:{create,read,update,
+ * delete}` scopes — a skillset is a skill-lifecycle resource. A dedicated
+ * `ornn:skillset:*` scope split is a tracked follow-up (see
+ * docs/CONVENTIONS.md).
+ *
+ * POST /skillsets — create (ornn:skill:create)
+ * GET /skillsets/:idOrName/closure — resolve (optional auth)
+ * GET /skillsets/:idOrName/versions — list (optional auth)
+ * GET /skillsets/:idOrName — read (optional auth)
+ * PUT /skillsets/:id — publish (ornn:skill:update)
+ * PUT /skillsets/:id/permissions — visibility (ornn:skill:update)
+ * DELETE /skillsets/:id — delete (ornn:skill:delete)
+ *
+ * @module domains/skillsets/routes
+ */
+
+import { Hono } from "hono";
+import { z } from "zod";
+import {
+ type AuthVariables,
+ nyxidAuthMiddleware,
+ optionalAuthMiddleware,
+ requirePermission,
+ getAuth,
+} from "../../middleware/nyxidAuth";
+import { validateBody, getValidatedBody } from "../../middleware/validate";
+import { buildActorContext, type ActorContext } from "../skills/crud/authorize";
+import { AppError } from "../../shared/types/index";
+import { createLogger } from "../../shared/logger";
+import { canReadSkillset } from "./authorize";
+import type { SkillsetService } from "./service";
+import {
+ createSkillsetSchema,
+ publishSkillsetSchema,
+ skillsetPermissionsSchema,
+} from "./types";
+
+const logger = createLogger("skillsetRoutes");
+
+export interface SkillsetRoutesConfig {
+ skillsetService: SkillsetService;
+}
+
+/** Anonymous read actor — sees public skillsets only. Fresh per call so
+ * the mutable `memberships` array is never shared. */
+function anonActor(): ActorContext {
+ return {
+ userId: "",
+ memberships: [],
+ isPlatformAdmin: false,
+ membershipsResolved: true,
+ };
+}
+
+export function createSkillsetRoutes(
+ config: SkillsetRoutesConfig,
+): Hono<{ Variables: AuthVariables }> {
+ const { skillsetService } = config;
+ const app = new Hono<{ Variables: AuthVariables }>();
+ const auth = nyxidAuthMiddleware();
+ const optionalAuth = optionalAuthMiddleware();
+
+ /**
+ * POST /skillsets — create a skillset (private by default).
+ * Requires: ornn:skill:create
+ */
+ app.post(
+ "/skillsets",
+ auth,
+ requirePermission("ornn:skill:create"),
+ validateBody(createSkillsetSchema, "invalid_skillset"),
+ async (c) => {
+ const authCtx = getAuth(c);
+ const body = getValidatedBody>(c);
+ const created = await skillsetService.createSkillset(body, {
+ userId: authCtx.userId,
+ email: authCtx.email,
+ displayName: authCtx.displayName,
+ });
+ logger.info({ guid: created.guid, name: created.name }, "Skillset created via API");
+ c.header("Location", `/api/v1/skillsets/${created.guid}`);
+ return c.json({ data: created, error: null }, 201);
+ },
+ );
+
+ /**
+ * GET /skillsets/:idOrName/closure — one-call resolve: union of all
+ * members + each member's #968 dependency closure, deduped + topo-sorted.
+ *
+ * Registered ABOVE /skillsets/:idOrName so the literal `/closure`
+ * segment wins. Auth: optional — anon callers resolve public skillsets
+ * only; a private member dep surfaces as skill_dependency_not_found.
+ */
+ app.get("/skillsets/:idOrName/closure", optionalAuth, async (c) => {
+ const idOrName = c.req.param("idOrName");
+ const version = c.req.query("version") || undefined;
+ const authCtx = c.get("auth");
+ const actor = authCtx ? await buildActorContext(c) : anonActor();
+ logger.info(
+ { idOrName, version: version ?? null, anon: !authCtx },
+ "Skillset closure request",
+ );
+ // `instructions` (the master prompt, #978) is a ROOT sibling of `items`
+ // — sourced from the same loaded version the resolver read.
+ const { instructions, items } = await skillsetService.resolveClosure(idOrName, actor, version);
+ return c.json({ data: { instructions, items }, error: null });
+ });
+
+ /**
+ * GET /skillsets/:idOrName/versions — list all published versions,
+ * newest first. Visibility matches GET /skillsets/:idOrName.
+ */
+ app.get("/skillsets/:idOrName/versions", optionalAuth, async (c) => {
+ const idOrName = c.req.param("idOrName");
+ const authCtx = c.get("auth");
+ const actor = authCtx ? await buildActorContext(c) : anonActor();
+ // Gate read visibility on the identity doc — a private skillset the
+ // actor cannot read 404s identically to a missing one.
+ const detail = await skillsetService.getSkillset(idOrName);
+ if (detail.isPrivate && !canReadSkillset(detail, actor)) {
+ throw AppError.notFound("skillset_not_found", `Skillset '${idOrName}' not found`);
+ }
+ const items = await skillsetService.listVersions(idOrName);
+ return c.json({ data: { items }, error: null });
+ });
+
+ /**
+ * GET /skillsets/:idOrName — read a skillset by GUID or name.
+ * Query: `version` (optional). Auth: optional — anon sees public only.
+ */
+ app.get("/skillsets/:idOrName", optionalAuth, async (c) => {
+ const idOrName = c.req.param("idOrName");
+ const version = c.req.query("version") || undefined;
+ const authCtx = c.get("auth");
+ const detail = await skillsetService.getSkillset(idOrName, version);
+
+ if (detail.isPrivate) {
+ const actor = authCtx ? await buildActorContext(c) : anonActor();
+ if (!canReadSkillset(detail, actor)) {
+ throw AppError.notFound("skillset_not_found", `Skillset '${idOrName}' not found`);
+ }
+ }
+ return c.json({ data: detail, error: null });
+ });
+
+ /**
+ * PUT /skillsets/:id — publish a new immutable version.
+ * Requires: ornn:skill:update + author/admin.
+ */
+ app.put(
+ "/skillsets/:id",
+ auth,
+ requirePermission("ornn:skill:update"),
+ validateBody(publishSkillsetSchema, "invalid_skillset"),
+ async (c) => {
+ const id = c.req.param("id");
+ const body = getValidatedBody>(c);
+ const actor = await buildActorContext(c);
+ const updated = await skillsetService.publishVersion(id, body, actor);
+ logger.info({ guid: id, version: updated.version }, "Skillset version published via API");
+ return c.json({ data: updated, error: null });
+ },
+ );
+
+ /**
+ * PUT /skillsets/:id/permissions — apply a new ACL state.
+ * Requires: ornn:skill:update + author/admin.
+ */
+ app.put(
+ "/skillsets/:id/permissions",
+ auth,
+ requirePermission("ornn:skill:update"),
+ validateBody(skillsetPermissionsSchema, "invalid_permissions"),
+ async (c) => {
+ const id = c.req.param("id");
+ const body = getValidatedBody>(c);
+ const actor = await buildActorContext(c);
+ const updated = await skillsetService.setPermissions(
+ id,
+ {
+ isPrivate: body.isPrivate,
+ sharedWithUsers: body.sharedWithUsers,
+ sharedWithOrgs: body.sharedWithOrgs,
+ },
+ actor,
+ );
+ return c.json({ data: { skillset: updated }, error: null });
+ },
+ );
+
+ /**
+ * DELETE /skillsets/:id — delete a skillset + all its versions.
+ * Requires: ornn:skill:delete + author/admin.
+ */
+ app.delete(
+ "/skillsets/:id",
+ auth,
+ requirePermission("ornn:skill:delete"),
+ async (c) => {
+ const id = c.req.param("id");
+ const actor = await buildActorContext(c);
+ await skillsetService.deleteSkillset(id, actor);
+ return c.json({ data: { success: true }, error: null });
+ },
+ );
+
+ return app;
+}
diff --git a/ornn-api/src/domains/skillsets/search/routes.test.ts b/ornn-api/src/domains/skillsets/search/routes.test.ts
new file mode 100644
index 00000000..a4d9512c
--- /dev/null
+++ b/ornn-api/src/domains/skillsets/search/routes.test.ts
@@ -0,0 +1,138 @@
+/**
+ * Route tests for GET /skillset-search (#969).
+ *
+ * Pins: kind narrows (param forwarded), tags forwarded, anon collapses to
+ * public scope, cursor pagination decoded.
+ *
+ * @module domains/skillsets/search/routes.test
+ */
+
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+import { Hono } from "hono";
+import { createSkillsetSearchRoutes, type SkillsetSearchRoutesConfig } from "./routes";
+import { buildProblemJsonBody } from "../../../shared/types/index";
+import { __resetRateLimitForTests } from "../../../middleware/rateLimit";
+
+interface SearchCall {
+ scope: string;
+ kind?: string;
+ tagsAll?: string[];
+ q?: string;
+ page: number;
+ pageSize: number;
+ currentUserId: string;
+}
+
+function buildApp(opts: {
+ authenticated?: boolean;
+ capture?: (call: SearchCall) => void;
+ total?: number;
+ itemCount?: number;
+}) {
+ const { authenticated = false, capture = () => {}, total = 0, itemCount = 0 } = opts;
+ const skillsetSearchService = {
+ search: async (params: SearchCall) => {
+ capture(params);
+ return {
+ items: Array.from({ length: itemCount }, (_, i) => ({
+ guid: `g${i}`,
+ name: `s${i}`,
+ description: "",
+ kind: "generic",
+ tags: [],
+ memberCount: 0,
+ latestVersion: "1.0",
+ isPrivate: false,
+ createdBy: "o",
+ createdOn: "2026-01-01T00:00:00Z",
+ updatedOn: "2026-01-01T00:00:00Z",
+ })),
+ total,
+ page: params.page,
+ pageSize: params.pageSize,
+ totalPages: Math.ceil(total / params.pageSize),
+ };
+ },
+ } as unknown as SkillsetSearchRoutesConfig["skillsetSearchService"];
+
+ const app = new Hono();
+ if (authenticated) {
+ app.use("*", async (c, next) => {
+ c.set("auth" as never, {
+ userId: "u1",
+ email: "u1@test.local",
+ displayName: "u1",
+ roles: [],
+ permissions: [],
+ } as never);
+ await next();
+ });
+ }
+ app.route("/api/v1", createSkillsetSearchRoutes({ skillsetSearchService }));
+ app.onError((err, c) => {
+ const code = (err as { code?: string }).code ?? "internal_error";
+ const status = (err as { statusCode?: number }).statusCode ?? 500;
+ return c.json(
+ buildProblemJsonBody({ statusCode: status, code, message: err.message, instance: c.req.path, requestId: null }),
+ status as never,
+ { "Content-Type": "application/problem+json" },
+ );
+ });
+ return app;
+}
+
+beforeEach(() => __resetRateLimitForTests());
+afterEach(() => __resetRateLimitForTests());
+
+describe("GET /skillset-search", () => {
+ test("forwards kind to the service", async () => {
+ let call: SearchCall | null = null;
+ const app = buildApp({ capture: (c) => (call = c) });
+ const res = await app.request("/api/v1/skillset-search?kind=consensus-supported");
+ expect(res.status).toBe(200);
+ expect(call!.kind).toBe("consensus-supported");
+ });
+
+ test("forwards tags as a CSV list (AND match)", async () => {
+ let call: SearchCall | null = null;
+ const app = buildApp({ capture: (c) => (call = c) });
+ await app.request("/api/v1/skillset-search?tags=alpha,beta");
+ expect(call!.tagsAll).toEqual(["alpha", "beta"]);
+ });
+
+ test("forwards the q keyword to the service", async () => {
+ let call: SearchCall | null = null;
+ const app = buildApp({ capture: (c) => (call = c) });
+ await app.request("/api/v1/skillset-search?q=research%20bundle");
+ expect(call!.q).toBe("research bundle");
+ });
+
+ test("anonymous caller is collapsed to public scope", async () => {
+ let call: SearchCall | null = null;
+ const app = buildApp({ authenticated: false, capture: (c) => (call = c) });
+ await app.request("/api/v1/skillset-search?scope=private");
+ expect(call!.scope).toBe("public");
+ });
+
+ test("authenticated caller keeps the requested scope", async () => {
+ let call: SearchCall | null = null;
+ const app = buildApp({ authenticated: true, capture: (c) => (call = c) });
+ await app.request("/api/v1/skillset-search?scope=mine");
+ expect(call!.scope).toBe("mine");
+ expect(call!.currentUserId).toBe("u1");
+ });
+
+ test("rejects an unknown kind with 400", async () => {
+ const app = buildApp({});
+ const res = await app.request("/api/v1/skillset-search?kind=bundle");
+ expect(res.status).toBe(400);
+ });
+
+ test("emits a nextCursor when a full page is returned (pagination)", async () => {
+ const app = buildApp({ total: 100, itemCount: 20 });
+ const res = await app.request("/api/v1/skillset-search?pageSize=20");
+ const body = (await res.json()) as { data: { meta: { hasMore: boolean; nextCursor?: string } } };
+ expect(body.data.meta.hasMore).toBe(true);
+ expect(typeof body.data.meta.nextCursor).toBe("string");
+ });
+});
diff --git a/ornn-api/src/domains/skillsets/search/routes.ts b/ornn-api/src/domains/skillsets/search/routes.ts
new file mode 100644
index 00000000..7383cee5
--- /dev/null
+++ b/ornn-api/src/domains/skillsets/search/routes.ts
@@ -0,0 +1,127 @@
+/**
+ * Skillset search routes (#969).
+ *
+ * `GET /skillset-search` — plain-Mongo discovery by `kind` / `tags` /
+ * `scope`, with cursor pagination per CONVENTIONS.md §4.3. Sibling of
+ * `/skill-search` (not a `/skillsets/*` sub-resource) so it never collides
+ * with `GET /skillsets/:idOrName`.
+ *
+ * @module domains/skillsets/search/routes
+ */
+
+import { Hono } from "hono";
+import { z } from "zod";
+import {
+ type AuthVariables,
+ optionalAuthMiddleware,
+ readUserOrgIds,
+} from "../../../middleware/nyxidAuth";
+import { validateQuery, getValidatedQuery } from "../../../middleware/validate";
+import { AppError } from "../../../shared/types/index";
+import { createLogger } from "../../../shared/logger";
+import { decodeCursor, buildNextCursor, MAX_PAGE } from "../../../shared/cursor";
+import { rateLimit } from "../../../middleware/rateLimit";
+import { SKILLSET_KINDS } from "../types";
+import type { SkillsetSearchService } from "./service";
+
+const logger = createLogger("skillsetSearchRoutes");
+
+const searchQuerySchema = z.object({
+ kind: z.enum(SKILLSET_KINDS).optional(),
+ scope: z
+ .enum(["public", "private", "mixed", "shared-with-me", "mine"])
+ .optional()
+ .default("public"),
+ page: z.coerce.number().int().min(1).max(MAX_PAGE).optional().default(1),
+ pageSize: z.coerce.number().int().min(1).max(100).optional().default(20),
+ cursor: z.string().max(2048).optional(),
+ limit: z.coerce.number().int().min(1).max(100).optional(),
+ /** Comma-separated tag list — skillsets must have ALL listed tags. */
+ tags: z.string().optional(),
+ /** Free-text keyword — case-insensitive substring on name + description. */
+ q: z.string().max(200).optional(),
+});
+
+function parseCsv(raw: string | undefined): string[] | undefined {
+ if (!raw) return undefined;
+ const parts = raw
+ .split(",")
+ .map((s) => s.trim())
+ .filter((s) => s.length > 0);
+ return parts.length > 0 ? parts : undefined;
+}
+
+export interface SkillsetSearchRoutesConfig {
+ skillsetSearchService: SkillsetSearchService;
+}
+
+export function createSkillsetSearchRoutes(
+ config: SkillsetSearchRoutesConfig,
+): Hono<{ Variables: AuthVariables }> {
+ const { skillsetSearchService } = config;
+ const app = new Hono<{ Variables: AuthVariables }>();
+ const optionalAuth = optionalAuthMiddleware();
+
+ /**
+ * GET /skillset-search — discover skillsets by kind / tags / scope.
+ * Auth: optional. Anonymous callers see public skillsets only.
+ */
+ app.get(
+ "/skillset-search",
+ optionalAuth,
+ rateLimit({ windowMs: 60_000, max: 60, label: "skillset-search" }),
+ validateQuery(searchQuerySchema, "invalid_query"),
+ async (c) => {
+ const parsed = getValidatedQuery>(c);
+ const pageSize = parsed.limit ?? parsed.pageSize;
+ let page = parsed.page;
+ if (parsed.cursor !== undefined) {
+ const decoded = decodeCursor(parsed.cursor);
+ if (!decoded) {
+ throw AppError.badRequest(
+ "invalid_cursor",
+ "The provided cursor is malformed or from a previous API version.",
+ );
+ }
+ page = decoded.page;
+ }
+
+ const authCtx = c.get("auth");
+ const isAnonymous = !authCtx;
+ // Anonymous callers can only search public scope.
+ const scope = isAnonymous ? "public" : parsed.scope;
+ const currentUserId = authCtx?.userId ?? "";
+ const userOrgIds = authCtx ? await readUserOrgIds(c) : [];
+
+ logger.debug(
+ { kind: parsed.kind ?? null, scope, anonymous: isAnonymous },
+ "Skillset search request",
+ );
+
+ const response = await skillsetSearchService.search({
+ scope,
+ currentUserId,
+ userOrgIds,
+ page,
+ pageSize,
+ kind: parsed.kind,
+ tagsAll: parseCsv(parsed.tags),
+ q: parsed.q,
+ });
+
+ const itemsReturned = response.items.length;
+ const meta = {
+ limit: pageSize,
+ hasMore: itemsReturned >= pageSize && response.page * pageSize < response.total,
+ nextCursor: buildNextCursor({
+ currentPage: response.page,
+ pageSize,
+ itemsReturned,
+ }),
+ };
+ return c.json({ data: { ...response, meta }, error: null });
+ },
+ );
+
+ return app;
+}
diff --git a/ornn-api/src/domains/skillsets/search/service.test.ts b/ornn-api/src/domains/skillsets/search/service.test.ts
new file mode 100644
index 00000000..26c66bb0
--- /dev/null
+++ b/ornn-api/src/domains/skillsets/search/service.test.ts
@@ -0,0 +1,99 @@
+/**
+ * SkillsetSearchService unit tests (#969).
+ *
+ * In-memory `findByScope` fake; pins that kind / tags / scope params are
+ * forwarded correctly and the response envelope is shaped right.
+ *
+ * @module domains/skillsets/search/service.test
+ */
+
+import { describe, expect, it } from "bun:test";
+import { SkillsetSearchService } from "./service";
+import type { SkillsetRepository } from "../repository";
+import type { SkillsetDocument } from "../types";
+
+function doc(overrides: Partial = {}): SkillsetDocument {
+ const now = new Date("2026-01-01T00:00:00Z");
+ return {
+ guid: "ss-1",
+ name: "review-set",
+ description: "d",
+ kind: "generic",
+ tags: [],
+ createdBy: "owner-1",
+ createdOn: now,
+ updatedBy: "owner-1",
+ updatedOn: now,
+ isPrivate: false,
+ sharedWithUsers: [],
+ sharedWithOrgs: [],
+ latestVersion: "1.0",
+ ...overrides,
+ };
+}
+
+function makeService(
+ capture: (args: unknown[]) => void,
+ result: { skillsets: SkillsetDocument[]; total: number },
+) {
+ const skillsetRepo = {
+ findByScope: async (...args: unknown[]) => {
+ capture(args);
+ return result;
+ },
+ } as unknown as SkillsetRepository;
+ return new SkillsetSearchService({ skillsetRepo });
+}
+
+describe("SkillsetSearchService", () => {
+ it("forwards kind + tags filters to findByScope", async () => {
+ let captured: unknown[] = [];
+ const service = makeService(
+ (a) => (captured = a),
+ { skillsets: [doc({ kind: "consensus-supported" })], total: 1 },
+ );
+ const res = await service.search({
+ scope: "public",
+ currentUserId: "",
+ userOrgIds: [],
+ page: 1,
+ pageSize: 20,
+ kind: "consensus-supported",
+ tagsAll: ["x"],
+ });
+ // findByScope(scope, userId, orgIds, page, pageSize, filters)
+ expect(captured[0]).toBe("public");
+ expect(captured[5]).toEqual({ kind: "consensus-supported", tagsAll: ["x"] });
+ expect(res.items[0]!.kind).toBe("consensus-supported");
+ expect(res.total).toBe(1);
+ expect(res.totalPages).toBe(1);
+ });
+
+ it("maps documents to lighter search items", async () => {
+ const service = makeService(() => {}, {
+ skillsets: [doc({ guid: "g1", name: "a" }), doc({ guid: "g2", name: "b" })],
+ total: 2,
+ });
+ const res = await service.search({
+ scope: "public",
+ currentUserId: "",
+ userOrgIds: [],
+ page: 1,
+ pageSize: 20,
+ });
+ expect(res.items.map((i) => i.name)).toEqual(["a", "b"]);
+ expect(res.items[0]!.createdOn).toBe("2026-01-01T00:00:00.000Z");
+ });
+
+ it("computes totalPages from total + pageSize", async () => {
+ const service = makeService(() => {}, { skillsets: [], total: 45 });
+ const res = await service.search({
+ scope: "public",
+ currentUserId: "",
+ userOrgIds: [],
+ page: 1,
+ pageSize: 20,
+ });
+ expect(res.totalPages).toBe(3);
+ });
+});
diff --git a/ornn-api/src/domains/skillsets/search/service.ts b/ornn-api/src/domains/skillsets/search/service.ts
new file mode 100644
index 00000000..3ef44c54
--- /dev/null
+++ b/ornn-api/src/domains/skillsets/search/service.ts
@@ -0,0 +1,95 @@
+/**
+ * Skillset search service (#969).
+ *
+ * Plain-Mongo discovery only — `kind` equality + `tags $all` + scope via
+ * the shared `findByScope`. Deliberately NO LLM / semantic ranking and NO
+ * facets: a skillset is a small curated set; discovery is by typed
+ * filter, not relevance ranking (and the marketplace-drift guard rules
+ * out leaderboards / popularity ranking).
+ *
+ * @module domains/skillsets/search/service
+ */
+
+import { createLogger } from "../../../shared/logger";
+import type { SkillScope } from "../../skills/crud/scopeFilter";
+import type { SkillsetRepository } from "../repository";
+import type {
+ SkillsetDocument,
+ SkillsetKind,
+ SkillsetSearchItem,
+ SkillsetSearchResponse,
+} from "../types";
+
+const logger = createLogger("skillsetSearchService");
+
+export interface SkillsetSearchServiceDeps {
+ skillsetRepo: SkillsetRepository;
+}
+
+export class SkillsetSearchService {
+ private readonly skillsetRepo: SkillsetRepository;
+
+ constructor(deps: SkillsetSearchServiceDeps) {
+ this.skillsetRepo = deps.skillsetRepo;
+ }
+
+ async search(params: {
+ scope: SkillScope;
+ currentUserId: string;
+ userOrgIds: string[];
+ page: number;
+ pageSize: number;
+ // exactOptionalPropertyTypes (#657)
+ kind?: SkillsetKind | undefined;
+ tagsAll?: string[] | undefined;
+ q?: string | undefined;
+ }): Promise {
+ const { scope, currentUserId, userOrgIds, page, pageSize } = params;
+ const start = Date.now();
+ const { skillsets, total } = await this.skillsetRepo.findByScope(
+ scope,
+ currentUserId,
+ userOrgIds,
+ page,
+ pageSize,
+ {
+ kind: params.kind,
+ tagsAll: params.tagsAll,
+ q: params.q,
+ },
+ );
+ logger.info(
+ { scope, kind: params.kind ?? null, q: params.q ?? null, total, queryTimeMs: Date.now() - start },
+ "Skillset search completed",
+ );
+ return {
+ items: skillsets.map(toItem),
+ total,
+ page,
+ pageSize,
+ totalPages: Math.ceil(total / pageSize),
+ };
+ }
+}
+
+function toItem(s: SkillsetDocument): SkillsetSearchItem {
+ return {
+ guid: s.guid,
+ name: s.name,
+ description: s.description,
+ kind: s.kind,
+ tags: s.tags,
+ // The identity doc doesn't carry the member list (that's on the
+ // version); search exposes the cached top-level shape. Member count is
+ // surfaced from the detail / closure endpoints, not search — keep it 0
+ // here rather than an extra per-row version read.
+ memberCount: 0,
+ latestVersion: s.latestVersion,
+ isPrivate: s.isPrivate,
+ createdBy: s.createdBy,
+ createdByEmail: s.createdByEmail,
+ createdByDisplayName: s.createdByDisplayName,
+ createdOn: s.createdOn instanceof Date ? s.createdOn.toISOString() : String(s.createdOn),
+ updatedOn: s.updatedOn instanceof Date ? s.updatedOn.toISOString() : String(s.updatedOn),
+ };
+}
diff --git a/ornn-api/src/domains/skillsets/service.test.ts b/ornn-api/src/domains/skillsets/service.test.ts
new file mode 100644
index 00000000..ba70d3ee
--- /dev/null
+++ b/ornn-api/src/domains/skillsets/service.test.ts
@@ -0,0 +1,659 @@
+/**
+ * SkillsetService unit tests (#969).
+ *
+ * Hermetic, in-memory fakes for the skillset repos + a REAL `SkillService`
+ * wired over in-memory skill fakes so `createVersionLoader` resolves member
+ * refs exactly as production does. Pins:
+ * - create → publish → re-publish bumps version; prior version immutable
+ * - visibility transitions mirror skills (setPermissions)
+ * - publish member validation: missing member → skill_dependency_not_found
+ * - conflicting member dep-closures → dependency_conflict
+ * - resolveClosure: members + their #968 dep closures topo-sorted
+ * - anon on public skillset w/ private member dep → skill_dependency_not_found
+ *
+ * @module domains/skillsets/service.test
+ */
+
+import { describe, expect, it } from "bun:test";
+import { AppError } from "../../shared/types/index";
+import type { IStorageClient } from "../../clients/storageClient";
+import { SkillService } from "../skills/crud/service";
+import type { SkillRepository } from "../skills/crud/repository";
+import type { SkillVersionRepository } from "../skills/crud/skillVersionRepository";
+import { SYSTEM_ACTOR, type ActorContext } from "../skills/crud/authorize";
+import type {
+ SkillDocument,
+ SkillVersionDocument,
+} from "../../shared/types/index";
+import { SkillsetService } from "./service";
+import type { SkillsetDocument, SkillsetVersionDocument } from "./types";
+
+const ANON: ActorContext = {
+ userId: "",
+ memberships: [],
+ isPlatformAdmin: false,
+ membershipsResolved: true,
+};
+const OWNER: ActorContext = {
+ userId: "owner-1",
+ memberships: [],
+ isPlatformAdmin: false,
+ membershipsResolved: true,
+};
+
+// ---- Skill graph fakes (for the injected real SkillService) ----------
+
+function skillDoc(overrides: Partial = {}): SkillDocument {
+ const now = new Date("2026-01-01T00:00:00Z");
+ return {
+ guid: "g",
+ name: "n",
+ description: "d",
+ license: null,
+ compatibility: null,
+ metadata: { category: "plain" },
+ skillHash: "h",
+ storageKey: "k",
+ createdBy: "owner-1",
+ createdOn: now,
+ updatedBy: "owner-1",
+ updatedOn: now,
+ isPrivate: false,
+ sharedWithUsers: [],
+ sharedWithOrgs: [],
+ latestVersion: "1.0",
+ ...overrides,
+ } as SkillDocument;
+}
+
+function skillVersion(overrides: Partial = {}): SkillVersionDocument {
+ return {
+ _id: "g@1.0",
+ skillGuid: "g",
+ version: "1.0",
+ majorVersion: 1,
+ minorVersion: 0,
+ storageKey: "k",
+ skillHash: "h",
+ metadata: { category: "plain" },
+ license: null,
+ compatibility: null,
+ createdBy: "owner-1",
+ createdOn: new Date("2026-01-01T00:00:00Z"),
+ ...overrides,
+ } as SkillVersionDocument;
+}
+
+/** Build a SkillService over fixed in-memory skill + version maps. */
+function makeSkillService(
+ skills: SkillDocument[],
+ versions: SkillVersionDocument[],
+): SkillService {
+ const byGuid = new Map(skills.map((s) => [s.guid, s]));
+ const byName = new Map(skills.map((s) => [s.name, s]));
+ const skillRepo = {
+ findByGuid: async (g: string) => byGuid.get(g) ?? null,
+ findByName: async (n: string) => byName.get(n) ?? null,
+ } as unknown as SkillRepository;
+ const skillVersionRepo = {
+ findBySkillAndVersion: async (g: string, v: string) =>
+ versions.find((x) => x.skillGuid === g && x.version === v) ?? null,
+ } as unknown as SkillVersionRepository;
+ return new SkillService({
+ skillRepo,
+ skillVersionRepo,
+ storageClient: {} as unknown as IStorageClient,
+ storageBucketResolver: async () => "bucket",
+ });
+}
+
+// ---- Skillset repo fakes ---------------------------------------------
+
+interface SkillsetState {
+ skillsets: Map;
+ byName: Map;
+ versions: SkillsetVersionDocument[];
+}
+
+function makeSkillsetDeps(skillService: SkillService) {
+ const state: SkillsetState = {
+ skillsets: new Map(),
+ byName: new Map(),
+ versions: [],
+ };
+ const skillsetRepo = {
+ findByGuid: async (g: string) => state.skillsets.get(g) ?? null,
+ findByName: async (n: string) => state.byName.get(n) ?? null,
+ create: async (data: {
+ guid: string;
+ name: string;
+ description: string;
+ kind: SkillsetDocument["kind"];
+ tags: string[];
+ createdBy: string;
+ isPrivate?: boolean;
+ latestVersion: string;
+ }) => {
+ const now = new Date();
+ const doc: SkillsetDocument = {
+ guid: data.guid,
+ name: data.name,
+ description: data.description,
+ kind: data.kind,
+ tags: data.tags,
+ createdBy: data.createdBy,
+ createdOn: now,
+ updatedBy: data.createdBy,
+ updatedOn: now,
+ isPrivate: data.isPrivate ?? true,
+ sharedWithUsers: [],
+ sharedWithOrgs: [],
+ latestVersion: data.latestVersion,
+ };
+ state.skillsets.set(data.guid, doc);
+ state.byName.set(data.name, doc);
+ return doc;
+ },
+ update: async (g: string, patch: Record) => {
+ const cur = state.skillsets.get(g)!;
+ const next = { ...cur, ...patch, updatedOn: new Date() } as SkillsetDocument;
+ state.skillsets.set(g, next);
+ state.byName.set(next.name, next);
+ return next;
+ },
+ hardDelete: async (g: string) => {
+ const doc = state.skillsets.get(g);
+ if (doc) state.byName.delete(doc.name);
+ state.skillsets.delete(g);
+ },
+ } as unknown as import("./repository").SkillsetRepository;
+
+ const skillsetVersionRepo = {
+ create: async (data: {
+ skillsetGuid: string;
+ version: string;
+ majorVersion: number;
+ minorVersion: number;
+ kind: SkillsetDocument["kind"];
+ description: string;
+ instructions: string;
+ tags: string[];
+ members: string[];
+ createdBy: string;
+ }) => {
+ const id = `${data.skillsetGuid}@${data.version}`;
+ if (state.versions.some((v) => v._id === id)) {
+ throw AppError.conflict("skillset_version_exists", `dup ${id}`);
+ }
+ const doc: SkillsetVersionDocument = {
+ _id: id,
+ skillsetGuid: data.skillsetGuid,
+ version: data.version,
+ majorVersion: data.majorVersion,
+ minorVersion: data.minorVersion,
+ kind: data.kind,
+ description: data.description,
+ instructions: data.instructions,
+ tags: data.tags,
+ members: data.members,
+ createdBy: data.createdBy,
+ createdOn: new Date(),
+ };
+ state.versions.push(doc);
+ return doc;
+ },
+ findBySkillsetAndVersion: async (g: string, v: string) =>
+ state.versions.find((x) => x.skillsetGuid === g && x.version === v) ?? null,
+ findLatestBySkillset: async (g: string) =>
+ state.versions
+ .filter((x) => x.skillsetGuid === g)
+ .sort((a, b) => b.majorVersion - a.majorVersion || b.minorVersion - a.minorVersion)[0] ??
+ null,
+ listBySkillset: async (g: string) =>
+ state.versions
+ .filter((x) => x.skillsetGuid === g)
+ .sort((a, b) => b.majorVersion - a.majorVersion || b.minorVersion - a.minorVersion),
+ deleteAllBySkillset: async (g: string) => {
+ const before = state.versions.length;
+ state.versions = state.versions.filter((x) => x.skillsetGuid !== g);
+ return before - state.versions.length;
+ },
+ } as unknown as import("./skillsetVersionRepository").SkillsetVersionRepository;
+
+ return {
+ deps: { skillsetRepo, skillsetVersionRepo, skillService },
+ state,
+ };
+}
+
+/** Two public member skills, no deps. */
+function twoMemberSkills(): { skills: SkillDocument[]; versions: SkillVersionDocument[] } {
+ const a = skillDoc({ guid: "g-a", name: "pdf-tools", latestVersion: "1.0" });
+ const b = skillDoc({ guid: "g-b", name: "csv-tools", latestVersion: "1.0" });
+ return {
+ skills: [a, b],
+ versions: [
+ skillVersion({ _id: "g-a@1.0", skillGuid: "g-a", version: "1.0" }),
+ skillVersion({ _id: "g-b@1.0", skillGuid: "g-b", version: "1.0" }),
+ ],
+ };
+}
+
+describe("SkillsetService — create / publish (immutable versioning)", () => {
+ it("create → publish bumps version; prior version stays immutable", async () => {
+ const { skills, versions } = twoMemberSkills();
+ const skillService = makeSkillService(skills, versions);
+ const { deps, state } = makeSkillsetDeps(skillService);
+ const service = new SkillsetService(deps);
+
+ const created = await service.createSkillset(
+ {
+ name: "review-set",
+ description: "v1",
+ instructions: "prompt-v1: run pdf-tools then csv-tools",
+ kind: "generic",
+ tags: ["t"],
+ members: ["pdf-tools@1.0", "csv-tools@1.0"],
+ version: "1.0",
+ },
+ { userId: "owner-1" },
+ );
+ expect(created.version).toBe("1.0");
+ expect(created.isPrivate).toBe(true);
+ expect(created.instructions).toBe("prompt-v1: run pdf-tools then csv-tools");
+
+ const guid = created.guid;
+ await service.publishVersion(
+ guid,
+ {
+ description: "v2",
+ instructions: "prompt-v2: csv-tools first this time",
+ members: ["pdf-tools@1.0", "csv-tools@1.0"],
+ version: "1.1",
+ },
+ OWNER,
+ );
+
+ // Identity doc advanced to the new version.
+ const latest = await service.getSkillset(guid);
+ expect(latest.version).toBe("1.1");
+ expect(latest.latestVersion).toBe("1.1");
+ expect(latest.description).toBe("v2");
+ // Master prompt comes straight from THIS publish (no carry-forward).
+ expect(latest.instructions).toBe("prompt-v2: csv-tools first this time");
+
+ // The prior 1.0 version still reads back unchanged (immutable) — its
+ // own prompt is untouched by the v1.1 publish (per-version immutability).
+ const v1 = await service.getSkillset(guid, "1.0");
+ expect(v1.version).toBe("1.0");
+ expect(v1.description).toBe("v1");
+ expect(v1.instructions).toBe("prompt-v1: run pdf-tools then csv-tools");
+ expect(state.versions).toHaveLength(2);
+ });
+
+ it("re-publishing the current version is rejected (non-incrementing)", async () => {
+ // Republishing the SAME version is a non-incrementing publish — the
+ // strict-increment guard catches it (mirrors the skill publish path,
+ // where `!isGreater` rejects equal versions before the storage-level
+ // duplicate `_id` check is ever reached).
+ const { skills, versions } = twoMemberSkills();
+ const skillService = makeSkillService(skills, versions);
+ const { deps } = makeSkillsetDeps(skillService);
+ const service = new SkillsetService(deps);
+ const created = await service.createSkillset(
+ {
+ name: "review-set",
+ description: "v1",
+ instructions: "p",
+ kind: "generic",
+ tags: [],
+ members: ["pdf-tools@1.0", "csv-tools@1.0"],
+ version: "1.0",
+ },
+ { userId: "owner-1" },
+ );
+ let code = "";
+ try {
+ await service.publishVersion(
+ created.guid,
+ { instructions: "p", members: ["pdf-tools@1.0", "csv-tools@1.0"], version: "1.0" },
+ OWNER,
+ );
+ } catch (err) {
+ code = (err as AppError).code;
+ }
+ expect(code).toBe("VERSION_NOT_INCREMENTED");
+ });
+
+ it("rejects a lower version without regressing latestVersion (#969)", async () => {
+ // latestVersion advanced to 2.0; publishing a never-used LOWER 1.5 must
+ // be rejected (VERSION_NOT_INCREMENTED) AND must not regress the pointer
+ // or leak a stale version row into "latest".
+ const { skills, versions } = twoMemberSkills();
+ const skillService = makeSkillService(skills, versions);
+ const { deps, state } = makeSkillsetDeps(skillService);
+ const service = new SkillsetService(deps);
+ const members = ["pdf-tools@1.0", "csv-tools@1.0"];
+
+ const created = await service.createSkillset(
+ {
+ name: "review-set",
+ description: "v1",
+ instructions: "p",
+ kind: "generic",
+ tags: [],
+ members,
+ version: "1.0",
+ },
+ { userId: "owner-1" },
+ );
+ const guid = created.guid;
+ await service.publishVersion(guid, { instructions: "p", members, version: "1.1" }, OWNER);
+ await service.publishVersion(guid, { instructions: "p", members, version: "2.0" }, OWNER);
+ expect((await service.getSkillset(guid)).latestVersion).toBe("2.0");
+
+ // (a) lower version is rejected with the version-not-incremented code.
+ let code = "";
+ try {
+ await service.publishVersion(guid, { instructions: "p", members, version: "1.5" }, OWNER);
+ } catch (err) {
+ code = (err as AppError).code;
+ }
+ expect(code).toBe("VERSION_NOT_INCREMENTED");
+
+ // (b) the pointer did NOT regress and no stale 1.5 row leaked into latest.
+ expect((await service.getSkillset(guid)).latestVersion).toBe("2.0");
+ expect(state.versions.some((v) => v.version === "1.5")).toBe(false);
+ });
+
+ it("create rejects a duplicate skillset name", async () => {
+ const { skills, versions } = twoMemberSkills();
+ const skillService = makeSkillService(skills, versions);
+ const { deps } = makeSkillsetDeps(skillService);
+ const service = new SkillsetService(deps);
+ const input = {
+ name: "dup-set",
+ description: "d",
+ instructions: "p",
+ kind: "generic" as const,
+ tags: [],
+ members: ["pdf-tools@1.0", "csv-tools@1.0"],
+ version: "1.0",
+ };
+ await service.createSkillset(input, { userId: "owner-1" });
+ let code = "";
+ try {
+ await service.createSkillset(input, { userId: "owner-1" });
+ } catch (err) {
+ code = (err as AppError).code;
+ }
+ expect(code).toBe("skillset_name_exists");
+ });
+});
+
+describe("SkillsetService — visibility transitions (mirror skills)", () => {
+ it("setPermissions flips public/private + persists shared lists", async () => {
+ const { skills, versions } = twoMemberSkills();
+ const skillService = makeSkillService(skills, versions);
+ const { deps } = makeSkillsetDeps(skillService);
+ const service = new SkillsetService(deps);
+ const created = await service.createSkillset(
+ {
+ name: "review-set",
+ description: "d",
+ instructions: "p",
+ kind: "generic",
+ tags: [],
+ members: ["pdf-tools@1.0", "csv-tools@1.0"],
+ version: "1.0",
+ },
+ { userId: "owner-1" },
+ );
+ const updated = await service.setPermissions(
+ created.guid,
+ { isPrivate: false, sharedWithUsers: ["u2"], sharedWithOrgs: [] },
+ OWNER,
+ );
+ expect(updated.isPrivate).toBe(false);
+ expect(updated.sharedWithUsers).toEqual(["u2"]);
+ });
+
+ it("setPermissions 403s a non-owner", async () => {
+ const { skills, versions } = twoMemberSkills();
+ const skillService = makeSkillService(skills, versions);
+ const { deps } = makeSkillsetDeps(skillService);
+ const service = new SkillsetService(deps);
+ const created = await service.createSkillset(
+ {
+ name: "review-set",
+ description: "d",
+ instructions: "p",
+ kind: "generic",
+ tags: [],
+ members: ["pdf-tools@1.0", "csv-tools@1.0"],
+ version: "1.0",
+ },
+ { userId: "owner-1" },
+ );
+ const stranger: ActorContext = {
+ userId: "stranger",
+ memberships: [],
+ isPlatformAdmin: false,
+ membershipsResolved: true,
+ };
+ let code = "";
+ try {
+ await service.setPermissions(
+ created.guid,
+ { isPrivate: true, sharedWithUsers: [], sharedWithOrgs: [] },
+ stranger,
+ );
+ } catch (err) {
+ code = (err as AppError).code;
+ }
+ expect(code).toBe("forbidden");
+ });
+});
+
+describe("SkillsetService — publish member validation (#969)", () => {
+ it("rejects a non-existent member with skill_dependency_not_found", async () => {
+ const { skills, versions } = twoMemberSkills();
+ const skillService = makeSkillService(skills, versions);
+ const { deps, state } = makeSkillsetDeps(skillService);
+ const service = new SkillsetService(deps);
+ let code = "";
+ try {
+ await service.createSkillset(
+ {
+ name: "review-set",
+ description: "d",
+ instructions: "p",
+ kind: "generic",
+ tags: [],
+ members: ["pdf-tools@1.0", "ghost-tools@1.0"],
+ version: "1.0",
+ },
+ { userId: "owner-1" },
+ );
+ } catch (err) {
+ code = (err as AppError).code;
+ }
+ expect(code).toBe("skill_dependency_not_found");
+ // Failed before any persistence.
+ expect(state.skillsets.size).toBe(0);
+ expect(state.versions).toHaveLength(0);
+ });
+
+ it("rejects conflicting member dep-closures with dependency_conflict", async () => {
+ // member-a depends on shared@1.0; member-b depends on shared@2.0 → the
+ // union closure pins `shared` to two versions → dependency_conflict.
+ const shared1 = skillDoc({ guid: "g-s", name: "shared", latestVersion: "2.0" });
+ const memberA = skillDoc({ guid: "g-a", name: "member-a", latestVersion: "1.0" });
+ const memberB = skillDoc({ guid: "g-b", name: "member-b", latestVersion: "1.0" });
+ const skills = [shared1, memberA, memberB];
+ const versions = [
+ skillVersion({ _id: "g-s@1.0", skillGuid: "g-s", version: "1.0", majorVersion: 1 }),
+ skillVersion({ _id: "g-s@2.0", skillGuid: "g-s", version: "2.0", majorVersion: 2 }),
+ skillVersion({
+ _id: "g-a@1.0",
+ skillGuid: "g-a",
+ version: "1.0",
+ metadata: { category: "plain", dependsOn: ["shared@1.0"] },
+ }),
+ skillVersion({
+ _id: "g-b@1.0",
+ skillGuid: "g-b",
+ version: "1.0",
+ metadata: { category: "plain", dependsOn: ["shared@2.0"] },
+ }),
+ ];
+ const skillService = makeSkillService(skills, versions);
+ const { deps } = makeSkillsetDeps(skillService);
+ const service = new SkillsetService(deps);
+ let err: AppError | null = null;
+ try {
+ await service.createSkillset(
+ {
+ name: "conflict-set",
+ description: "d",
+ instructions: "p",
+ kind: "consensus-supported",
+ tags: [],
+ members: ["member-a@1.0", "member-b@1.0"],
+ version: "1.0",
+ },
+ { userId: "owner-1" },
+ );
+ } catch (e) {
+ err = e as AppError;
+ }
+ expect(err?.code).toBe("dependency_conflict");
+ expect(err?.statusCode).toBe(409);
+ });
+});
+
+describe("SkillsetService — resolveClosure (roots = members)", () => {
+ it("returns members + their #968 dep closures, topo-sorted", async () => {
+ // pdf-tools depends on leaf-d; csv-tools has no deps. Closure =
+ // [leaf-d, pdf-tools, csv-tools] (deps before dependents).
+ const leaf = skillDoc({ guid: "g-d", name: "leaf-d", latestVersion: "1.0" });
+ const pdf = skillDoc({ guid: "g-a", name: "pdf-tools", latestVersion: "1.0" });
+ const csv = skillDoc({ guid: "g-b", name: "csv-tools", latestVersion: "1.0" });
+ const skills = [leaf, pdf, csv];
+ const versions = [
+ skillVersion({ _id: "g-d@1.0", skillGuid: "g-d", version: "1.0" }),
+ skillVersion({
+ _id: "g-a@1.0",
+ skillGuid: "g-a",
+ version: "1.0",
+ metadata: { category: "plain", dependsOn: ["leaf-d@1.0"] },
+ }),
+ skillVersion({ _id: "g-b@1.0", skillGuid: "g-b", version: "1.0" }),
+ ];
+ const skillService = makeSkillService(skills, versions);
+ const { deps } = makeSkillsetDeps(skillService);
+ const service = new SkillsetService(deps);
+ await service.createSkillset(
+ {
+ name: "review-set",
+ description: "d",
+ instructions: "closure-master-prompt: orchestrate the set",
+ kind: "generic",
+ tags: [],
+ members: ["pdf-tools@1.0", "csv-tools@1.0"],
+ version: "1.0",
+ },
+ { userId: "owner-1" },
+ );
+ const closure = await service.resolveClosure("review-set", SYSTEM_ACTOR);
+ const names = closure.items.map((n) => n.name);
+ // leaf-d must precede pdf-tools (its dependent).
+ expect(names).toContain("leaf-d");
+ expect(names).toContain("pdf-tools");
+ expect(names).toContain("csv-tools");
+ expect(names.indexOf("leaf-d")).toBeLessThan(names.indexOf("pdf-tools"));
+ // The master prompt (#978) rides alongside items as a root sibling,
+ // sourced from the resolved version (no extra read).
+ expect(closure.instructions).toBe("closure-master-prompt: orchestrate the set");
+ });
+
+ it("hides a private member dep from an anonymous caller (no leak)", async () => {
+ // PUBLIC skillset → PUBLIC member pdf-tools → PRIVATE dep secret-lib.
+ // An anon caller resolving the closure must get skill_dependency_not_found
+ // for the private node, never a leak.
+ const secret = skillDoc({ guid: "g-x", name: "secret-lib", latestVersion: "1.0", isPrivate: true });
+ const pdf = skillDoc({ guid: "g-a", name: "pdf-tools", latestVersion: "1.0", isPrivate: false });
+ const csv = skillDoc({ guid: "g-b", name: "csv-tools", latestVersion: "1.0", isPrivate: false });
+ const skills = [secret, pdf, csv];
+ const versions = [
+ skillVersion({ _id: "g-x@1.0", skillGuid: "g-x", version: "1.0" }),
+ skillVersion({
+ _id: "g-a@1.0",
+ skillGuid: "g-a",
+ version: "1.0",
+ metadata: { category: "plain", dependsOn: ["secret-lib@1.0"] },
+ }),
+ skillVersion({ _id: "g-b@1.0", skillGuid: "g-b", version: "1.0" }),
+ ];
+ const skillService = makeSkillService(skills, versions);
+ const { deps } = makeSkillsetDeps(skillService);
+ const service = new SkillsetService(deps);
+ // Author creates it (publish validates as SYSTEM, so the private dep is fine).
+ const created = await service.createSkillset(
+ {
+ name: "review-set",
+ description: "d",
+ instructions: "p",
+ kind: "generic",
+ tags: [],
+ members: ["pdf-tools@1.0", "csv-tools@1.0"],
+ version: "1.0",
+ },
+ { userId: "owner-1" },
+ );
+ // Make the skillset public so the anon caller passes the entry gate.
+ await service.setPermissions(
+ created.guid,
+ { isPrivate: false, sharedWithUsers: [], sharedWithOrgs: [] },
+ OWNER,
+ );
+
+ let code = "";
+ try {
+ await service.resolveClosure("review-set", ANON);
+ } catch (err) {
+ code = (err as AppError).code;
+ }
+ expect(code).toBe("skill_dependency_not_found");
+
+ // SYSTEM (and the owner) CAN see it — the gate keys on identity.
+ const sys = await service.resolveClosure("review-set", SYSTEM_ACTOR);
+ expect(sys.items.map((n) => n.name)).toContain("secret-lib");
+ });
+
+ it("404s an anonymous caller on a PRIVATE skillset (entry gate)", async () => {
+ const { skills, versions } = twoMemberSkills();
+ const skillService = makeSkillService(skills, versions);
+ const { deps } = makeSkillsetDeps(skillService);
+ const service = new SkillsetService(deps);
+ await service.createSkillset(
+ {
+ name: "secret-set",
+ description: "d",
+ instructions: "p",
+ kind: "generic",
+ tags: [],
+ members: ["pdf-tools@1.0", "csv-tools@1.0"],
+ version: "1.0",
+ },
+ { userId: "owner-1" },
+ ); // private by default
+ let code = "";
+ try {
+ await service.resolveClosure("secret-set", ANON);
+ } catch (err) {
+ code = (err as AppError).code;
+ }
+ expect(code).toBe("skillset_not_found");
+ });
+});
diff --git a/ornn-api/src/domains/skillsets/service.ts b/ornn-api/src/domains/skillsets/service.ts
new file mode 100644
index 00000000..eab7dfdf
--- /dev/null
+++ b/ornn-api/src/domains/skillsets/service.ts
@@ -0,0 +1,461 @@
+/**
+ * Skillset CRUD + closure service (#969).
+ *
+ * A skillset is a curated, versioned, visibility-scoped meta-package over
+ * N member skills. This service mirrors `SkillService`:
+ * - CRUD with immutable, append-only versioning (publish appends
+ * `guid@version`, advances `latestVersion`; prior versions never
+ * mutate).
+ * - Visibility transitions identical to skills (`setPermissions`).
+ * - Publish-time member validation: every member ref must resolve to a
+ * readable skill version, AND each member's own #968 dependency
+ * closure must be conflict-free — reusing the SAME closure resolver.
+ * - One-call closure resolution: `roots = members`, walked through the
+ * injected `SkillService.createVersionLoader`. No forked DFS.
+ *
+ * `SkillService` is injected (not duplicated) so member resolution +
+ * per-node `canReadSkill` visibility gating stays single-sourced with the
+ * skill dependency closure.
+ *
+ * @module domains/skillsets/service
+ */
+
+import { randomUUID } from "node:crypto";
+import { AppError } from "../../shared/types/index";
+import { createLogger } from "../../shared/logger";
+import { isReservedVerb } from "../../shared/reservedVerbs";
+import { resolveClosure, type ClosureNode } from "../skills/closure/resolver";
+import {
+ canReadSkill,
+ canManageSkill,
+ isMemberOfOrg,
+ SYSTEM_ACTOR,
+ type ActorContext,
+} from "../skills/crud/authorize";
+import type { SkillService } from "../skills/crud/service";
+import { isGreater, parseVersion } from "../skills/crud/version";
+import type { SkillsetRepository } from "./repository";
+import type { SkillsetVersionRepository } from "./skillsetVersionRepository";
+import type {
+ CreateSkillsetInput,
+ PublishSkillsetInput,
+ SkillsetDetailResponse,
+ SkillsetDocument,
+ SkillsetVersionDocument,
+} from "./types";
+
+const logger = createLogger("skillsetService");
+
+/**
+ * Result of {@link SkillsetService.resolveClosure} (#978) — the resolved
+ * delivery closure PLUS the version's master prompt.
+ *
+ * `instructions` is a ROOT sibling of `items` (NOT folded into the shared
+ * `ClosureNode[]` — that resolver + the skill `/skills/:id/closure` path
+ * stay clean). Sourced from the already-loaded skillset version document.
+ */
+export interface SkillsetClosureResult {
+ /** The version's master prompt (#978) — surfaced verbatim. */
+ instructions: string;
+ /** Deps-first topo-sorted closure (the shared #968 node shape). */
+ items: ClosureNode[];
+}
+
+export interface SkillsetServiceDeps {
+ skillsetRepo: SkillsetRepository;
+ skillsetVersionRepo: SkillsetVersionRepository;
+ /** Injected to reuse the member-ref loader + closure resolution (#968). */
+ skillService: SkillService;
+}
+
+export class SkillsetService {
+ private readonly skillsetRepo: SkillsetRepository;
+ private readonly skillsetVersionRepo: SkillsetVersionRepository;
+ private readonly skillService: SkillService;
+
+ constructor(deps: SkillsetServiceDeps) {
+ this.skillsetRepo = deps.skillsetRepo;
+ this.skillsetVersionRepo = deps.skillsetVersionRepo;
+ this.skillService = deps.skillService;
+ }
+
+ // ==========================================================================
+ // Create / publish (immutable versioning)
+ // ==========================================================================
+
+ /**
+ * Create a new skillset (private by default, like skills). Validates the
+ * member closure BEFORE any write, seeds the first immutable version,
+ * and points `latestVersion` at it.
+ */
+ async createSkillset(
+ input: CreateSkillsetInput,
+ actor: { userId: string; email?: string; displayName?: string },
+ ): Promise {
+ if (isReservedVerb("skillset", input.name)) {
+ throw AppError.badRequest(
+ "reserved_name",
+ `Skillset name '${input.name}' is reserved — pick a different name`,
+ );
+ }
+ const existing = await this.skillsetRepo.findByName(input.name);
+ if (existing) {
+ throw AppError.conflict("skillset_name_exists", `Skillset '${input.name}' already exists`);
+ }
+
+ const parsed = parseVersion(input.version);
+ // Member validation BEFORE any write — every member must resolve to a
+ // readable skill version and be closure-conflict-free.
+ await this.validateMembers(input.members, { name: input.name, version: input.version });
+
+ const guid = randomUUID();
+ await this.skillsetRepo.create({
+ guid,
+ name: input.name,
+ description: input.description,
+ kind: input.kind,
+ tags: input.tags,
+ createdBy: actor.userId,
+ createdByEmail: actor.email,
+ createdByDisplayName: actor.displayName,
+ isPrivate: true,
+ latestVersion: input.version,
+ });
+ await this.skillsetVersionRepo.create({
+ skillsetGuid: guid,
+ version: input.version,
+ majorVersion: parsed.major,
+ minorVersion: parsed.minor,
+ kind: input.kind,
+ description: input.description,
+ // Master prompt (#978) — straight from input, no carry-forward.
+ instructions: input.instructions,
+ tags: input.tags,
+ members: input.members,
+ createdBy: actor.userId,
+ createdByEmail: actor.email,
+ createdByDisplayName: actor.displayName,
+ });
+
+ logger.info({ guid, name: input.name, version: input.version, kind: input.kind }, "Skillset created");
+ return this.getSkillset(guid);
+ }
+
+ /**
+ * Publish a new immutable version of an existing skillset. The new
+ * version must be strictly greater than the current `latestVersion`
+ * — enforced by an explicit strict-increment guard (VERSION_NOT_INCREMENTED)
+ * that rejects both equal and lower versions, mirroring the skill publish
+ * path. The append-only `guid@version` `_id` is a defence-in-depth backstop
+ * for an exact duplicate. Prior versions remain immutable.
+ */
+ async publishVersion(
+ guid: string,
+ input: PublishSkillsetInput,
+ actor: ActorContext,
+ ): Promise {
+ const existing = await this.skillsetRepo.findByGuid(guid);
+ if (!existing) {
+ throw AppError.notFound("skillset_not_found", `Skillset '${guid}' not found`);
+ }
+ if (!canManageSkill(existing, actor)) {
+ throw AppError.forbidden("forbidden", "You do not have permission to manage this skillset");
+ }
+
+ const parsed = parseVersion(input.version);
+ const members = input.members;
+ const kind = input.kind ?? existing.kind;
+ const description = input.description ?? existing.description;
+ const tags = input.tags ?? existing.tags;
+
+ await this.validateMembers(members, { name: existing.name, version: input.version });
+
+ // Enforce strictly-incrementing version BEFORE any write — mirrors the
+ // skill publish guard (#969). The append-only `guid@version` `_id` only
+ // rejects an EXACT re-publish; without this check a never-used LOWER
+ // version (e.g. 1.5 over latest 2.0) would insert a stale version row
+ // AND regress `latestVersion` backward, so "latest" consumers silently
+ // resolve the downgraded member set. Same VERSION_NOT_INCREMENTED code
+ // the skill path emits, covering both lower and equal versions.
+ const currentLatest = await this.skillsetVersionRepo.findLatestBySkillset(guid);
+ if (currentLatest) {
+ const parsedCurrent = parseVersion(currentLatest.version);
+ if (!isGreater(parsed, parsedCurrent)) {
+ throw AppError.conflict(
+ "VERSION_NOT_INCREMENTED",
+ `New version '${input.version}' must be strictly greater than the current latest '${currentLatest.version}'.`,
+ );
+ }
+ }
+
+ // Append-only — a duplicate `guid@version` is rejected by the version
+ // repo (skillset_version_exists). Prior versions are never touched.
+ await this.skillsetVersionRepo.create({
+ skillsetGuid: guid,
+ version: input.version,
+ majorVersion: parsed.major,
+ minorVersion: parsed.minor,
+ kind,
+ description,
+ // Master prompt (#978) — REQUIRED on publish, NO carry-forward: each
+ // version explicitly carries its own prompt straight from input
+ // (unlike `description`/`kind`/`tags`, which inherit when omitted).
+ instructions: input.instructions,
+ tags,
+ members,
+ createdBy: actor.userId,
+ });
+
+ // Advance the identity doc's cached pointers to the new version.
+ await this.skillsetRepo.update(guid, {
+ description,
+ kind,
+ tags,
+ latestVersion: input.version,
+ updatedBy: actor.userId,
+ });
+
+ logger.info({ guid, version: input.version }, "Skillset version published");
+ return this.getSkillset(guid);
+ }
+
+ // ==========================================================================
+ // Read / delete / permissions
+ // ==========================================================================
+
+ /** Read a skillset detail by GUID or name (optionally a specific version). */
+ async getSkillset(idOrName: string, version?: string): Promise {
+ const skillset = await this.findByIdOrName(idOrName);
+ const resolvedVersion =
+ version === undefined || version.length === 0 ? skillset.latestVersion : version;
+ const versionDoc = await this.skillsetVersionRepo.findBySkillsetAndVersion(
+ skillset.guid,
+ resolvedVersion,
+ );
+ if (!versionDoc) {
+ throw AppError.notFound(
+ "skillset_version_not_found",
+ `Version '${resolvedVersion}' not found for skillset '${skillset.name}'`,
+ );
+ }
+ return toDetail(skillset, versionDoc);
+ }
+
+ /** List all published versions, newest first. */
+ async listVersions(idOrName: string): Promise<
+ Array<{
+ version: string;
+ kind: SkillsetVersionDocument["kind"];
+ memberCount: number;
+ createdBy: string;
+ createdByEmail?: string | undefined;
+ createdByDisplayName?: string | undefined;
+ createdOn: string;
+ }>
+ > {
+ const skillset = await this.findByIdOrName(idOrName);
+ const versions = await this.skillsetVersionRepo.listBySkillset(skillset.guid);
+ return versions.map((v) => ({
+ version: v.version,
+ kind: v.kind,
+ memberCount: v.members.length,
+ createdBy: v.createdBy,
+ createdByEmail: v.createdByEmail,
+ createdByDisplayName: v.createdByDisplayName,
+ createdOn: v.createdOn instanceof Date ? v.createdOn.toISOString() : String(v.createdOn),
+ }));
+ }
+
+ /** Delete a skillset + all its versions. Caller must be author/admin. */
+ async deleteSkillset(guid: string, actor: ActorContext): Promise {
+ const existing = await this.skillsetRepo.findByGuid(guid);
+ if (!existing) {
+ throw AppError.notFound("skillset_not_found", `Skillset '${guid}' not found`);
+ }
+ if (!canManageSkill(existing, actor)) {
+ throw AppError.forbidden("forbidden", "You do not have permission to delete this skillset");
+ }
+ await this.skillsetVersionRepo.deleteAllBySkillset(guid);
+ await this.skillsetRepo.hardDelete(guid);
+ logger.info({ guid }, "Skillset deleted");
+ }
+
+ /**
+ * Replace the permission model in a single write. Mirrors
+ * `SkillService.setSkillPermissions` — author/admin only; an owner may
+ * only share into orgs they belong to (CWE-862).
+ */
+ async setPermissions(
+ guid: string,
+ permissions: { isPrivate: boolean; sharedWithUsers: string[]; sharedWithOrgs: string[] },
+ actor: ActorContext,
+ ): Promise {
+ const existing = await this.skillsetRepo.findByGuid(guid);
+ if (!existing) {
+ throw AppError.notFound("skillset_not_found", `Skillset '${guid}' not found`);
+ }
+ if (!canManageSkill(existing, actor)) {
+ throw AppError.forbidden("forbidden", "You do not have permission to manage this skillset");
+ }
+
+ const sharedWithUsers = Array.from(
+ new Set(permissions.sharedWithUsers.filter((id) => id && id !== existing.createdBy)),
+ );
+ const sharedWithOrgs = Array.from(new Set(permissions.sharedWithOrgs.filter((id) => !!id)));
+
+ if (!actor.isPlatformAdmin) {
+ if (sharedWithOrgs.length > 0 && !actor.membershipsResolved) {
+ logger.warn({ guid }, "Org membership unresolved; cannot validate share into orgs");
+ throw AppError.serviceUnavailable(
+ "org_membership_unavailable",
+ "Could not verify your organization memberships right now. Retry shortly.",
+ );
+ }
+ const nonMember = sharedWithOrgs.filter((orgId) => !isMemberOfOrg(actor, orgId));
+ if (nonMember.length > 0) {
+ logger.warn({ guid, nonMember }, "Rejected skillset share into non-member org(s)");
+ throw AppError.forbidden(
+ "not_org_member",
+ "You can only share a skillset into organizations you belong to.",
+ );
+ }
+ }
+
+ await this.skillsetRepo.update(guid, {
+ isPrivate: permissions.isPrivate,
+ sharedWithUsers,
+ sharedWithOrgs,
+ updatedBy: actor.userId,
+ });
+ logger.info({ guid, isPrivate: permissions.isPrivate }, "Skillset permissions changed");
+ return this.getSkillset(guid);
+ }
+
+ // ==========================================================================
+ // Closure (one-call resolve — roots = members)
+ // ==========================================================================
+
+ /**
+ * Resolve the full delivery closure of a skillset version (#969): the
+ * union of all member skills PLUS each member's #968 dependency closure,
+ * deduplicated + topo-sorted (deps-first), PLUS the version's master
+ * prompt (#978).
+ *
+ * Reuses the #968 resolver directly — `roots = members`, walked through
+ * the injected `SkillService.createVersionLoader(actor)`. The loader's
+ * per-node `canReadSkill` gate means an anonymous caller resolving a
+ * PUBLIC skillset whose member transitively pins a PRIVATE skill gets
+ * `skill_dependency_not_found` (no leak), inheriting the exact codes the
+ * skill closure uses.
+ *
+ * The master prompt is sourced from the SAME already-loaded `versionDoc`
+ * — no extra read — and returned alongside `items` so the route can emit
+ * it as a root sibling. This is the SKILLSET closure result type; the
+ * shared `ClosureNode[]`/`resolveClosure` resolver and the skill
+ * `/skills/:id/closure` path stay untouched (#978).
+ */
+ async resolveClosure(
+ idOrName: string,
+ actor: ActorContext,
+ version?: string,
+ ): Promise {
+ const skillset = await this.findByIdOrName(idOrName);
+ if (!canReadSkill(skillset, actor)) {
+ throw AppError.notFound("skillset_not_found", `Skillset '${idOrName}' not found`);
+ }
+
+ const resolvedVersion =
+ version === undefined || version.length === 0 ? skillset.latestVersion : version;
+ parseVersion(resolvedVersion);
+
+ const versionDoc = await this.skillsetVersionRepo.findBySkillsetAndVersion(
+ skillset.guid,
+ resolvedVersion,
+ );
+ if (!versionDoc) {
+ throw AppError.notFound(
+ "skillset_version_not_found",
+ `Version '${resolvedVersion}' not found for skillset '${skillset.name}'`,
+ );
+ }
+
+ const roots = versionDoc.members;
+ const items = await resolveClosure(roots, {
+ loadVersion: this.skillService.createVersionLoader(actor),
+ });
+ logger.info(
+ { idOrName, version: resolvedVersion, memberCount: roots.length, nodeCount: items.length },
+ "Skillset closure resolved",
+ );
+ return { instructions: versionDoc.instructions, items };
+ }
+
+ // ==========================================================================
+ // Private helpers
+ // ==========================================================================
+
+ private async findByIdOrName(idOrName: string): Promise {
+ let skillset = await this.skillsetRepo.findByGuid(idOrName);
+ if (!skillset) {
+ skillset = await this.skillsetRepo.findByName(idOrName);
+ }
+ if (!skillset) {
+ throw AppError.notFound("skillset_not_found", `Skillset '${idOrName}' not found`);
+ }
+ return skillset;
+ }
+
+ /**
+ * Publish-time member validation (#969). Resolves the full closure of
+ * the member refs as `SYSTEM_ACTOR` (mirrors
+ * `SkillService.validatePublishDependencies`): every member must resolve
+ * to an existing, readable skill version, and the union closure must be
+ * conflict-free. A missing / unresolvable member surfaces as
+ * `skill_dependency_not_found`; a cross-member version collision as
+ * `dependency_conflict`; a cycle as `dependency_cycle`.
+ *
+ * Runs as SYSTEM so a curator may legitimately bundle a private skill
+ * they own / were granted — the route layer scopes the closure READ
+ * separately.
+ */
+ private async validateMembers(
+ members: string[],
+ context: { name: string; version: string },
+ ): Promise {
+ await resolveClosure(members, {
+ loadVersion: this.skillService.createVersionLoader(SYSTEM_ACTOR),
+ });
+ logger.info(
+ { name: context.name, version: context.version, memberCount: members.length },
+ "Publish-time skillset members validated",
+ );
+ }
+}
+
+function toDetail(
+ skillset: SkillsetDocument,
+ versionDoc: SkillsetVersionDocument,
+): SkillsetDetailResponse {
+ return {
+ guid: skillset.guid,
+ name: skillset.name,
+ description: versionDoc.description,
+ // Master prompt (#978) — surfaced verbatim from the loaded version.
+ instructions: versionDoc.instructions,
+ kind: versionDoc.kind,
+ tags: versionDoc.tags,
+ members: versionDoc.members,
+ version: versionDoc.version,
+ latestVersion: skillset.latestVersion,
+ isPrivate: skillset.isPrivate,
+ createdBy: skillset.createdBy,
+ createdByEmail: skillset.createdByEmail,
+ createdByDisplayName: skillset.createdByDisplayName,
+ sharedWithUsers: skillset.sharedWithUsers,
+ sharedWithOrgs: skillset.sharedWithOrgs,
+ createdOn:
+ skillset.createdOn instanceof Date ? skillset.createdOn.toISOString() : String(skillset.createdOn),
+ updatedOn:
+ skillset.updatedOn instanceof Date ? skillset.updatedOn.toISOString() : String(skillset.updatedOn),
+ };
+}
diff --git a/ornn-api/src/domains/skillsets/skillsetVersionRepository.ts b/ornn-api/src/domains/skillsets/skillsetVersionRepository.ts
new file mode 100644
index 00000000..f531ba9e
--- /dev/null
+++ b/ornn-api/src/domains/skillsets/skillsetVersionRepository.ts
@@ -0,0 +1,148 @@
+/**
+ * Repository for the `skillset_versions` Mongo collection (#969).
+ *
+ * Each document is an immutable, append-only snapshot of a skillset at a
+ * specific version. `_id = ${skillsetGuid}@${version}` gives free
+ * uniqueness on (skillsetGuid, version) without a separate compound
+ * unique index — identical to `skillVersionRepository`.
+ *
+ * Carries NO blob / skillHash / storageKey / AgentSeal — a skillset
+ * version is pure metadata (a member-ref list). The heavy artefacts live
+ * on the member skill versions, resolved at closure time.
+ *
+ * @module domains/skillsets/skillsetVersionRepository
+ */
+
+import type { Collection, Db, Document } from "mongodb";
+import { AppError } from "../../shared/types/index";
+import { createLogger } from "../../shared/logger";
+import type { SkillsetVersionDocument, SkillsetKind } from "./types";
+
+const logger = createLogger("skillsetVersionRepository");
+
+export interface CreateSkillsetVersionData {
+ skillsetGuid: string;
+ version: string;
+ majorVersion: number;
+ minorVersion: number;
+ kind: SkillsetKind;
+ description: string;
+ /** Master prompt (#978) — per-version, immutable. */
+ instructions: string;
+ tags: string[];
+ members: string[];
+ createdBy: string;
+ // Optionals widen to `T | undefined` for exactOptionalPropertyTypes (#657).
+ createdByEmail?: string | undefined;
+ createdByDisplayName?: string | undefined;
+ createdOn?: Date | undefined;
+}
+
+export class SkillsetVersionRepository {
+ private readonly collection: Collection;
+
+ constructor(db: Db) {
+ this.collection = db.collection("skillset_versions");
+ }
+
+ /** Idempotent — call once on startup. */
+ async ensureIndexes(): Promise {
+ await this.collection.createIndex(
+ { skillsetGuid: 1, majorVersion: -1, minorVersion: -1 },
+ { name: "skillset_versions_latest_lookup" },
+ );
+ }
+
+ async create(data: CreateSkillsetVersionData): Promise {
+ const createdOn = data.createdOn ?? new Date();
+ const doc: Document = {
+ _id: `${data.skillsetGuid}@${data.version}` as unknown as Document["_id"],
+ skillsetGuid: data.skillsetGuid,
+ version: data.version,
+ majorVersion: data.majorVersion,
+ minorVersion: data.minorVersion,
+ kind: data.kind,
+ description: data.description,
+ instructions: data.instructions,
+ tags: data.tags,
+ members: data.members,
+ createdBy: data.createdBy,
+ createdByEmail: data.createdByEmail ?? null,
+ createdByDisplayName: data.createdByDisplayName ?? null,
+ createdOn,
+ };
+
+ try {
+ await this.collection.insertOne(doc as never);
+ logger.info(
+ { skillsetGuid: data.skillsetGuid, version: data.version, memberCount: data.members.length },
+ "Skillset version inserted",
+ );
+ } catch (err: unknown) {
+ if ((err as { code?: number }).code === 11000) {
+ throw AppError.conflict(
+ "skillset_version_exists",
+ `Version '${data.version}' already exists for skillset '${data.skillsetGuid}'`,
+ );
+ }
+ throw err;
+ }
+ return mapDoc(doc)!;
+ }
+
+ async findBySkillsetAndVersion(
+ skillsetGuid: string,
+ version: string,
+ ): Promise {
+ const doc = await this.collection.findOne({ _id: `${skillsetGuid}@${version}` as never });
+ return mapDoc(doc);
+ }
+
+ async findLatestBySkillset(skillsetGuid: string): Promise {
+ const doc = await this.collection
+ .find({ skillsetGuid })
+ .sort({ majorVersion: -1, minorVersion: -1 })
+ .limit(1)
+ .next();
+ return mapDoc(doc);
+ }
+
+ async listBySkillset(skillsetGuid: string): Promise {
+ const docs = await this.collection
+ .find({ skillsetGuid })
+ .sort({ majorVersion: -1, minorVersion: -1 })
+ .toArray();
+ return docs.map((d) => mapDoc(d)!);
+ }
+
+ async deleteAllBySkillset(skillsetGuid: string): Promise {
+ const result = await this.collection.deleteMany({ skillsetGuid });
+ logger.info(
+ { skillsetGuid, deleted: result.deletedCount },
+ "Skillset versions cascade-deleted",
+ );
+ return result.deletedCount ?? 0;
+ }
+}
+
+function mapDoc(doc: Document | null): SkillsetVersionDocument | null {
+ if (!doc) return null;
+ return {
+ _id: doc._id as string,
+ skillsetGuid: doc.skillsetGuid,
+ version: doc.version,
+ majorVersion: doc.majorVersion,
+ minorVersion: doc.minorVersion,
+ kind: (doc.kind as SkillsetKind) ?? "generic",
+ description: doc.description ?? "",
+ // `?? ""` tolerates a pre-#978 version row that predates the required
+ // master prompt — the surface stays well-typed (never `undefined`).
+ instructions: doc.instructions ?? "",
+ tags: Array.isArray(doc.tags) ? (doc.tags as string[]) : [],
+ members: Array.isArray(doc.members) ? (doc.members as string[]) : [],
+ createdBy: doc.createdBy,
+ createdByEmail: doc.createdByEmail ?? undefined,
+ createdByDisplayName: doc.createdByDisplayName ?? undefined,
+ createdOn: doc.createdOn ?? new Date(),
+ };
+}
diff --git a/ornn-api/src/domains/skillsets/types.test.ts b/ornn-api/src/domains/skillsets/types.test.ts
new file mode 100644
index 00000000..1b4dfb15
--- /dev/null
+++ b/ornn-api/src/domains/skillsets/types.test.ts
@@ -0,0 +1,223 @@
+/**
+ * Schema tests for the skillsets domain (#969).
+ *
+ * Pins the member-ref grammar (reuses DEPENDS_ON_REF_REGEX), the 2..N
+ * member bound, the kind enum (both values), and the nested-skillset
+ * rejection.
+ *
+ * @module domains/skillsets/types.test
+ */
+
+import { describe, expect, it } from "bun:test";
+import {
+ createSkillsetSchema,
+ publishSkillsetSchema,
+ SKILLSET_INSTRUCTIONS_MAX,
+ SKILLSET_KINDS,
+} from "./types";
+
+function baseCreate(overrides: Record