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` carrying `{id, name, arguments}`. Each `choices[].delta.tool_calls[]` chunk merges into its index buffer (id + name arrive on the first chunk for that call; the `arguments` JSON string accumulates across many chunks). A turn flushes when any of: explicit `finish_reason` (`tool_calls`, `stop`, anything non-null), upstream `[DONE]` sentinel, or stream EOF — whichever fires first. A `flushed` guard makes flush idempotent so we never double-emit if multiple end signals fire. + +The synthesized event matches the Responses-API shape `chatService.ts` already validates with Zod (`outputItemDoneEventSchema`): + +```js +{ + type: "response.output_item.done", + item: { + type: "function_call", + id, call_id, name, arguments, // arguments is the accumulated JSON string + }, +} +``` + +This way zero changes are needed in `chatService.ts` — its existing `pendingToolCall` capture + `executeToolCall` dispatch path works for both upstream formats now. + +Parallel tool calls within one assistant turn are supported (one done event per index, emitted in index order). Missing `index` falls back to 0 (treated as a single tool call). Streams that close without `[DONE]` or `finish_reason` still flush at EOF so a buffered call is never lost. + +Coverage: 6 new tests appended to `src/clients/nyxid/llm.test.ts` — chunked accumulation + finish_reason flush, EOF flush without [DONE], parallel tool calls, idempotent flush across finish_reason+[DONE], intermixed text+tool deltas (correct event order), missing `index` fallback. All 17 tests in the file green; full ornn-api suite has no new regressions. diff --git a/.changeset/feat-1059-skillset-web-ui.md b/.changeset/feat-1059-skillset-web-ui.md new file mode 100644 index 00000000..041669e5 --- /dev/null +++ b/.changeset/feat-1059-skillset-web-ui.md @@ -0,0 +1,5 @@ +--- +"ornn-web": minor +--- + +Skillset web UI (#1059). A full web surface for the #969 skillsets backend, mirroring the existing skills UI. Browse skillsets at `/skillsets` (one page, tabs Public / Mine / Shared-with-me via `?scope`, with `kind` + `tag` filters and pagination), open a skillset at `/skillsets/:idOrName` (`?version` resolves a specific published version; the page surfaces the name, description, kind, the rendered master prompt, the member list, the server-flattened dependency closure, visibility, and a version picker), create at `/skillsets/new`, publish a new version at `/skillsets/:id/edit` (name locked, version required + bumped), and manage your own at `/my-skillsets`. Owners get a per-skillset permissions editor (public / org / user grants) and a delete flow. The member picker enforces the v1 rules client-side (2–100 members, no nested `skillset:` refs, no self-reference), and the master-prompt editor enforces the required, trimmed 1–8000-char body. New nav entries: "Skillsets" in the top nav and "My Skillsets" in the account menu. All copy is keyed under `skillset*` i18n namespaces in both English and Chinese. diff --git a/.changeset/feat-1064-skillset-member-dependency-graph.md b/.changeset/feat-1064-skillset-member-dependency-graph.md new file mode 100644 index 00000000..c045c0f2 --- /dev/null +++ b/.changeset/feat-1064-skillset-member-dependency-graph.md @@ -0,0 +1,7 @@ +--- +"ornn-web": minor +--- + +Skillset member-dependency graph (#1064). A skillset author can now declare a "runs before" dependency graph between member skills, visualised with Mermaid. In the create/publish form, a new dependency-graph editor sits between the member picker and the master prompt: click a member, then another, to draw an edge; edges show as removable chips with a live diagram preview and a non-blocking advisory when the members form a cycle. The skillset detail page renders the graph read-only (with pan / zoom / lightbox) under a "Member dependencies" section, with an empty state when no dependencies are declared. + +Edges are persisted ENTIRELY inside the skillset's master prompt (`instructions`) as a single managed, comment-fenced Mermaid block — no new backend field, no new API call, and no change to any member skill's metadata or package. The graph is a pure projection of the one `instructions` state: the codec preserves the author's prose byte-for-byte across round-trips and treats any absent / unknown / garbled block as "no edges", so it is forward-compatible and never corrupts a prompt. Distinct from the #968 skill-intrinsic dependency closure. New copy is keyed under the `skillsetGraph` i18n namespace in English and Chinese. No new runtime dependency — reuses the already-installed Mermaid renderer. diff --git a/.changeset/feat-1067-skillset-registry-ui-react-flow.md b/.changeset/feat-1067-skillset-registry-ui-react-flow.md new file mode 100644 index 00000000..be8b3a55 --- /dev/null +++ b/.changeset/feat-1067-skillset-registry-ui-react-flow.md @@ -0,0 +1,5 @@ +--- +"ornn-web": minor +--- + +Rework the skillset UI to mirror the skill registry + a drag-on-canvas dependency editor (#1067). The skill registry shell is now factored into shared, reusable shells — `RegistryTabs`, `RegistrySidebar` (FilterSection / FilterChipList / FilterChip / FilterEmpty), `RegistryGrid`, `DetailHeroStrip`, and `RailCard` — consumed by BOTH the skill and skillset pages so the two surfaces read identically. The skillset browse page (`/skillsets`) gains a left filter `aside` (Kind chip section + Tags input) and the shared cards grid; it deliberately mounts NO keyword search bar because the skillset-search backend has no `q` param. The skillset detail page (`/skillsets/:idOrName`) now renders a `SkillsetHeroStrip` (kind / visibility / version pills + owner Edit / Permissions) and wraps its rail sections (Members / Metadata / Visibility / Danger zone) in the shared `RailCard`. The member-dependency editor (#1064) is rebuilt on `@xyflow/react`: a lazy-loaded, drag-on-canvas graph where dragging between members declares "runs before", with a keyboard-accessible click-to-connect mirror, removable edge chips, and a non-blocking cycle advisory. The react-flow chunk loads ONLY on the create/edit form — the read-only detail page keeps the lightweight Mermaid render. Dependency edges remain a pure projection of the master prompt's managed block; the graph editor never mutates a member skill. diff --git a/.changeset/feat-968-skill-dependencies.md b/.changeset/feat-968-skill-dependencies.md new file mode 100644 index 00000000..20911420 --- /dev/null +++ b/.changeset/feat-968-skill-dependencies.md @@ -0,0 +1,6 @@ +--- +"ornn-api": minor +"@chronoai/ornn-sdk": minor +--- + +Skill dependencies (#968). Skills can now declare other skills they depend on via the `metadata.depends-on` SKILL.md frontmatter field — each entry pins one skill by `@` or `@` (no semver ranges, no self-references; max 50 direct deps). The full transitive closure is validated at publish time (`POST /skills`, `PUT /skills/:id`): missing dependencies, cycles, and conflicting versions of the same skill are rejected before the version is committed. A new `GET /api/v1/skills/:idOrName/closure` endpoint resolves and returns the full closure in deps-first topological order, scoped to what the caller may read. Three new error codes: `dependency_cycle` (409), `dependency_conflict` (409), `skill_dependency_not_found` (404). The TypeScript SDK gains `resolveClosure` / `pullClosure`; the Python SDK gains `resolve_closure` / `pull_closure`. diff --git a/.changeset/feat-969-skillsets.md b/.changeset/feat-969-skillsets.md new file mode 100644 index 00000000..2d50d725 --- /dev/null +++ b/.changeset/feat-969-skillsets.md @@ -0,0 +1,6 @@ +--- +"ornn-api": minor +"@chronoai/ornn-sdk": minor +--- + +Skillsets (#969). A **skillset** is a named, versioned, owned, visibility-scoped meta-package that references N member skills and carries a `kind` (`generic` | `consensus-supported`). Skillsets mirror the skill ownership/visibility/immutable-versioning model and reuse the `ornn:skill:{create,read,update,delete}` permission scopes. New endpoints: `POST /api/v1/skillsets` (create, private by default), `GET /api/v1/skillsets/:idOrName` (detail), `GET /api/v1/skillsets/:idOrName/versions`, `GET /api/v1/skillsets/:idOrName/closure` (one-call resolve — the union of all members plus each member's #968 dependency closure, deduplicated + topo-sorted), `PUT /api/v1/skillsets/:id` (publish a new immutable version), `PUT /api/v1/skillsets/:id/permissions`, `DELETE /api/v1/skillsets/:id`, and `GET /api/v1/skillset-search` (discovery by kind / tags / scope). Members (2..N) are validated at publish time against the live skill graph via the #968 closure resolver — a missing/unreadable member or a conflicting union closure rejects the publish, reusing the `skill_dependency_not_found` / `dependency_cycle` / `dependency_conflict` codes verbatim. The TypeScript SDK gains `createSkillset` / `getSkillset` / `publishSkillset` / `setSkillsetPermissions` / `deleteSkillset` / `getSkillsetClosure` / `searchSkillsets`; the Python SDK gains `create_skillset` / `get_skillset` / `publish_skillset` / `set_skillset_permissions` / `delete_skillset` / `resolve_skillset_closure` / `search_skillsets`. diff --git a/.changeset/feat-970-ornn-assistant.md b/.changeset/feat-970-ornn-assistant.md new file mode 100644 index 00000000..166714ba --- /dev/null +++ b/.changeset/feat-970-ornn-assistant.md @@ -0,0 +1,6 @@ +--- +"ornn-api": minor +"ornn-web": minor +--- + +Add Ornn Assistant — an authenticated, repo-aware Q&A assistant. A new `/api/v1/assistant/chat` SSE endpoint and an in-app chat widget answer questions about Ornn and the skill catalog, grounded in a curated knowledge base plus visibility-scoped skill search (no private or PII data exposed). Admins can select the assistant model per provider. (#970) diff --git a/.changeset/feat-978-skillset-master-prompt.md b/.changeset/feat-978-skillset-master-prompt.md new file mode 100644 index 00000000..0fbc8f95 --- /dev/null +++ b/.changeset/feat-978-skillset-master-prompt.md @@ -0,0 +1,6 @@ +--- +"ornn-api": minor +"@chronoai/ornn-sdk": minor +--- + +Skillset master prompt (#978). Skillsets now carry a **REQUIRED**, versioned `instructions` field — a markdown master prompt telling an agent HOW to use the set (orchestration, ordering, which member to pick when). It is required on BOTH create (`POST /api/v1/skillsets`) and publish (`PUT /api/v1/skillsets/:id`) with NO carry-forward: every published version explicitly restates its own master prompt (unlike `description`/`kind`/`tags`, which a publish may omit to inherit the prior value). `instructions` is 1..8000 chars, trimmed server-side (a whitespace-only body is rejected), and is distinct from the short `description` (≤1024 chars). It is stored opaque — Ornn does not render, sanitize, template, lint, or search-index it — and is surfaced verbatim on `GET /api/v1/skillsets/:idOrName` and as a root sibling of `items` on `GET /api/v1/skillsets/:idOrName/closure` (`{ data: { instructions, items }, error: null }`). The skill `/skills/:id/closure` envelope is unchanged. The TypeScript SDK adds `instructions` to `CreateSkillsetInput` / `PublishSkillsetInput` / `SkillsetDetail` and a new `SkillsetClosureResult` type returned by `getSkillsetClosure`; the Python SDK requires an `instructions` kwarg on `create_skillset` / `publish_skillset` and adds a new `SkillsetClosureResult` returned by `resolve_skillset_closure`. diff --git a/.changeset/feat-graph-proper-canvas-hover-dialog-larger-no-patch.md b/.changeset/feat-graph-proper-canvas-hover-dialog-larger-no-patch.md new file mode 100644 index 00000000..62b982c5 --- /dev/null +++ b/.changeset/feat-graph-proper-canvas-hover-dialog-larger-no-patch.md @@ -0,0 +1,11 @@ +--- +"ornn-web": minor +--- + +Skillset detail graph: switched read-only rendering from Mermaid to the react-flow canvas (proper canvas engine, same as editor) for much better space utilization, layout, pan/zoom and hover support. Removed the old direct-mermaid hover code. + +Hover dialog for package preview is now larger (wider/taller, better padding/shadow). + +Version auto-increaser in edit: removed patch button (and default now uses minor bump); only +minor / +major remain. + +All per latest feedback. Tests/build updated. \ No newline at end of file diff --git a/.changeset/feat-skillset-edit-auto-version-bump.md b/.changeset/feat-skillset-edit-auto-version-bump.md new file mode 100644 index 00000000..0262b517 --- /dev/null +++ b/.changeset/feat-skillset-edit-auto-version-bump.md @@ -0,0 +1,5 @@ +--- +"ornn-web": minor +--- + +Skillset edit form: the "Version" field for publishing a new version is now automatically pre-filled with the next patch (e.g. 1.0 → 1.1) when you open the page. Three compact +patch / +minor / +major buttons appear directly under the input so you can change the bump level without typing. Manual editing still works. The "must be different from current" validation and server bump rules are unchanged. This removes the previous manual tag entry friction for the common case. \ No newline at end of file diff --git a/.changeset/feat-skillset-graph-hover-package-preview.md b/.changeset/feat-skillset-graph-hover-package-preview.md new file mode 100644 index 00000000..85e26f25 --- /dev/null +++ b/.changeset/feat-skillset-graph-hover-package-preview.md @@ -0,0 +1,7 @@ +--- +"ornn-web": minor +--- + +Skillset detail: the member dependencies graph (Mermaid) now occupies the full vertical space in the left column (permanent package viewer below it has been removed). Hovering a node in the graph shows a floating dialog containing the package preview for that skill (reuses the compact path of SkillsetMemberViewer). + +To enable node hover detection the read-only graph now renders the Mermaid SVG directly (trusted source) instead of the sandboxed iframe, and MermaidBlock gained `direct` + `onNodeHover` support for this. Layout comments and one affected page test updated. The graph canvas is now fully utilized while the package viewer UX is preserved on-demand via hover. \ No newline at end of file diff --git a/.changeset/fix-skillset-depgraph-graph-span.md b/.changeset/fix-skillset-depgraph-graph-span.md new file mode 100644 index 00000000..a4c85246 --- /dev/null +++ b/.changeset/fix-skillset-depgraph-graph-span.md @@ -0,0 +1,5 @@ +--- +"ornn-web": patch +--- + +Skillset detail: member dependencies graph now spans its full allocated RailCard height. Removed wasteful `my-4`/`p-4`/`minHeight`/`bg-page` chrome from the Mermaid container for the read-only case (via new optional `className` on `MermaidBlock`), wired `flex flex-col` on the graph's RailCard + `flex-1 min-h-0` so the diagram area claims the space after the card header. The SVG now utilizes the tall left column instead of sitting tiny inside large empty padding. (Also synced a stale HeroStrip test that was asserting removed version-picker/permissions UI.) \ No newline at end of file diff --git a/.changeset/fix-skillset-member-picker-version-combo.md b/.changeset/fix-skillset-member-picker-version-combo.md new file mode 100644 index 00000000..70befe6f --- /dev/null +++ b/.changeset/fix-skillset-member-picker-version-combo.md @@ -0,0 +1,5 @@ +--- +"ornn-web": patch +--- + +Skillset create/edit form: the Members picker now requires an explicit skill + published-version combination. Clicking a skill from the name typeahead fetches its versions and shows a chooser (concrete `name@X.Y` entries, newest first with "latest" badge). Raw `name@ver` entry still works. This prevents accidentally pinning only a skill name (implicit latest); every added member ref is now a deliberate pinned combination, consistent with how members are modeled and displayed on the detail page (closure, graph, chips). \ No newline at end of file diff --git a/.changeset/launch-promo-foundation-724.md b/.changeset/launch-promo-foundation-724.md new file mode 100644 index 00000000..187b4c41 --- /dev/null +++ b/.changeset/launch-promo-foundation-724.md @@ -0,0 +1,26 @@ +--- +"ornn-api": minor +--- + +Launch-promo foundation — admin-driven manual award flow + caller status (#724, PR 1 of 2). + +The landing/news page promises that the first 500 Ornn users who star the GitHub repo and sign in receive a redemption code (200 Playground + 200 Skill Generation credits) plus the NyxID invite code, delivered to the Ornn notification inbox within 24 h. This PR lands the foundation that lets an admin honour that promise today, and gives the calling user a way to see their eligibility. + +What ships: + +- **`launchPromo` settings section** — `enabled`, `repoOwner`, `repoName`, `totalSlots` (default 500), `awardPlayground` / `awardSkillGen` (default 200), `pollIntervalMs`, `codeExpiryDays`, `nyxidInviteCode`. Defaults are conservative (`enabled: false`, empty repo, empty invite code) so the promo stays dormant until an admin explicitly turns it on. +- **`launch_promo_claims` collection + repo** — one doc per awarded user, keyed on `_id = userId` for primary-key idempotency. Fields: `eligibilityRank`, `redemptionCodeId`, `awardedAt`, `awardedBy`, optional `githubLogin`. Index on `awardedAt desc` for admin observability. +- **`LaunchPromoService.awardUser({ userId, awardedBy, githubLogin? })`** — gates on enabled + rank ≤ `totalSlots` + slots remaining + not-already-claimed, mints a redemption code via the existing redemption-codes service (no quota write — user redeems themselves through Settings → Redeem), drops a `launchPromo.codeDelivered` notification containing the code + NyxID invite code, then records the claim. Duplicate-key during insert (race) cleanly resolves to `ALREADY_CLAIMED`. Notification failure is logged but doesn't roll back the claim — the user already has the grant; admins can resend. +- **`LaunchPromoService.getStatusForUser(userId)`** — composes `{ promoEnabled, claimed, rank, totalSlots, slotsRemaining, awardedAt }` for the `/me/launch-promo` endpoint. +- **`UserDirectoryRepository.getRegistrationRank(userId)`** — 1-based ordering by `firstSeenAt asc`. Two queries (PK lookup + filter count), no scan. +- **New `launchPromo.codeDelivered` NotificationCategory.** +- **Routes** — `GET /me/launch-promo`, `POST /admin/launch-promo/award/:userId` (gated on `ornn:admin:skill`), `GET /admin/launch-promo/recent` for observability. Service-layer error sentinels (`PROMO_DISABLED`, `RANK_EXCEEDED`, `SLOTS_EXHAUSTED`, `ALREADY_CLAIMED`, `USER_NOT_FOUND`) map to 400 / 403 / 409 / 404. + +What is **deferred to PR 2** (clearly TODO in the design comments): + +- GitHub stargazers HTTP client (public API, no auth) + cron loop driven by `pollIntervalMs`. +- NyxID → GitHub-login resolution (currently the manual admin flow doesn't need this; the cron path will once it lands). +- Frontend admin UI for the settings section + "recent awards" panel. +- Frontend caller-side display ("you're in the first N — claim ready / claimed"). + +Coverage: 12 colocated unit tests on `LaunchPromoService` covering the happy path, every error sentinel, race-on-insert resolving to `ALREADY_CLAIMED`, and notification-failure-does-not-rollback. All green. diff --git a/.changeset/nice-wolves-trade.md b/.changeset/nice-wolves-trade.md new file mode 100644 index 00000000..2d9edc38 --- /dev/null +++ b/.changeset/nice-wolves-trade.md @@ -0,0 +1,5 @@ +--- +"ornn-web": minor +--- + +Restore landing announcement popups with glassmorphic style; skillset graph popup click-to-open with blurred backdrop; skill explorer for member picker with scope tabs and version selection; assistant mascot default position right-edge centered diff --git a/.changeset/readme-conversion-polish-640.md b/.changeset/readme-conversion-polish-640.md new file mode 100644 index 00000000..c6b2256c --- /dev/null +++ b/.changeset/readme-conversion-polish-640.md @@ -0,0 +1,4 @@ +--- +--- + +Polish the top-level `README.md` ahead of launch traffic (#640). Three conversion-focused additions: scannable value-prop bullets at the top of "What is Ornn" so visitors see the three reasons to care in one glance; a new "Try Ornn free" section between Quickstart and the comparison table that hooks readers with the launch perk (first 500 users · 400 free GPT-5.5 conversations) the moment they've learned how to use Ornn; and a quiet star CTA above License. README content only, no version bump. diff --git a/.changeset/skillset-breadcrumb-quota.md b/.changeset/skillset-breadcrumb-quota.md new file mode 100644 index 00000000..491f6c09 --- /dev/null +++ b/.changeset/skillset-breadcrumb-quota.md @@ -0,0 +1,5 @@ +--- +"ornn-web": patch +--- + +Restore the breadcrumb row + quota chip (playground + skill-gen pills) on the skillset pages (#1078): `useBreadcrumbs()` had no cases for `/skillsets*` routes, so `RootLayout` hid the whole breadcrumb bar — and with it the auth-only `QuotaChip` — on every skillset page. Adds breadcrumb trails for the skillset browse / detail / new / edit / mine routes (resolving GUID→name like the skill route). Also drops the version badge from the skillset browse card so its badge row matches `SkillCard` (which surfaces version only on the detail page). diff --git a/.changeset/skillset-detail-parity.md b/.changeset/skillset-detail-parity.md new file mode 100644 index 00000000..1aaf6b31 --- /dev/null +++ b/.changeset/skillset-detail-parity.md @@ -0,0 +1,5 @@ +--- +"ornn-web": patch +--- + +Bring the skillset detail page to visual parity with the skill detail page (#1076): the two-pane layout is now viewport-locked with independently scrolling columns, the master-prompt / member-dependencies / resolved-closure sections use the shared RailCard chrome instead of bespoke cards, and the hero gains the owner + published/updated footer with a varied pill trio (kind · visibility · version). diff --git a/.changeset/skillset-detail-relayout.md b/.changeset/skillset-detail-relayout.md new file mode 100644 index 00000000..bc8b4b20 --- /dev/null +++ b/.changeset/skillset-detail-relayout.md @@ -0,0 +1,5 @@ +--- +"ornn-web": patch +--- + +Restructure the skillset detail page (#1082): the master prompt becomes the topmost full-width card (right under the hero, out of the right rail); the member-dependency graph moves into the left column above a slimmer fixed-height package viewer; and the package viewer's skill selector becomes a vertical list on the far left of the file tree (skills | files | content) instead of tabs on top. The right rail keeps metadata + resolved closure + visibility + danger; the fragile viewport-height lock is dropped in favor of natural page scroll. diff --git a/.changeset/skillset-details-ui-parity.md b/.changeset/skillset-details-ui-parity.md new file mode 100644 index 00000000..8cdfed7f --- /dev/null +++ b/.changeset/skillset-details-ui-parity.md @@ -0,0 +1,5 @@ +--- +"ornn-web": "minor" +--- + +feat(web): skillset details UI parity + polish (smaller closure font, "Closure" rename, member links to skills, remove perms from hero, versions card in right rail like skills) diff --git a/.changeset/skillset-form-compact-scroll.md b/.changeset/skillset-form-compact-scroll.md new file mode 100644 index 00000000..469fbbc0 --- /dev/null +++ b/.changeset/skillset-form-compact-scroll.md @@ -0,0 +1,5 @@ +--- +"ornn-web": patch +--- + +Fix the skillset create/edit page so it scrolls (its content was clipped by the overflow-hidden app shell — each page needs its own scroll container), and reorganize the metadata fields into a compact 2-column grid (name + version, description, kind + tags) so the member picker, dependency-graph canvas, and master prompt get the vertical room (#1074). diff --git a/.changeset/skillset-graph-edges.md b/.changeset/skillset-graph-edges.md new file mode 100644 index 00000000..9c0be01a --- /dev/null +++ b/.changeset/skillset-graph-edges.md @@ -0,0 +1,5 @@ +--- +"ornn-web": patch +--- + +Dependency graph polish (#1094): the arc-blue edges now animate with a gentle flowing dash and carry a "runs before" label pill so the relationship direction is legible at a glance; the read-only detail-page graph gains zoom in/out/fit controls; and the on-hover package-preview dialog is bigger and no longer vanishes when you move the cursor toward it (a grace timer bridges the node→dialog gap and the dialog stays open while hovered). diff --git a/.changeset/skillset-graph-modern.md b/.changeset/skillset-graph-modern.md new file mode 100644 index 00000000..f63dbe64 --- /dev/null +++ b/.changeset/skillset-graph-modern.md @@ -0,0 +1,5 @@ +--- +"ornn-web": patch +--- + +Modernize the member-dependency graph (#1092): replace react-flow's plain box nodes with a custom Forge card node — a code-glyph icon (arc-blue) + skill name + version, hairline border, letterpress hard-offset shadow, ember border on hover/selected. The read-only detail-page graph now also gets directed arc-blue edges with arrowheads (previously unstyled), and both the editor and read-only canvases gain a faint blueprint grid + vignette. Tokens-only per DESIGN.md (arc-blue diagrammatic accent, ember action accent, letterpress — no soft glow or gradient). diff --git a/.changeset/skillset-search-and-member-viewer.md b/.changeset/skillset-search-and-member-viewer.md new file mode 100644 index 00000000..376e7661 --- /dev/null +++ b/.changeset/skillset-search-and-member-viewer.md @@ -0,0 +1,6 @@ +--- +"ornn-api": minor +"ornn-web": minor +--- + +Skillset browse + detail upgrades (#1080): the browse page gains a keyword search box (new `q` param on `GET /skillset-search` — case-insensitive substring on name + description) and drops the per-card edit button (manage from the detail page). The skillset detail page's left pane becomes a member skill-package viewer — click any member skill in the set to view its files read-only, mirroring the skill detail page — and the master prompt moves into the top metadata card. The dependency graph and resolved closure are preserved as read-only rail cards. diff --git a/.changeset/smooth-skillset-depgraph-canvas.md b/.changeset/smooth-skillset-depgraph-canvas.md new file mode 100644 index 00000000..ea8ddcca --- /dev/null +++ b/.changeset/smooth-skillset-depgraph-canvas.md @@ -0,0 +1,5 @@ +--- +"ornn-web": patch +--- + +Smoother skillset dependency-graph canvas (#1072): dragging a node now sticks (was snapping home), drawing a dependency no longer teleports the layout, and the editor gains directed arrowheaded edges, grabbable handles, zoom Controls, a Tidy re-layout button, a taller canvas, and tweened motion — while node positions stay presentation-only (never persisted) and the dependency edges still live solely in the skillset master prompt. diff --git a/.changeset/warm-actors-call.md b/.changeset/warm-actors-call.md new file mode 100644 index 00000000..5d7f8bb4 --- /dev/null +++ b/.changeset/warm-actors-call.md @@ -0,0 +1,5 @@ +--- +"ornn-web": patch +--- + +Fix skillset detail graph hover popup to 800×40vh with independent pane scroll; enable node drag for repositioning diff --git a/.github/release-notes-20260612.md b/.github/release-notes-20260612.md new file mode 100644 index 00000000..d757a4a2 --- /dev/null +++ b/.github/release-notes-20260612.md @@ -0,0 +1,22 @@ +## Fixed + +- Skillset member picker only searched public skills; now browses all visible skills (system, public, private, shared) +- Landing announcement popups flashed a transparent frame before the glass-blur effect appeared +- Chat-completion tool calls now normalized into Responses events for compatibility +- Few technical bugs fixed + +## New Feature + +- Skill explorer panel in skillset create/edit — scope tabs (All, System, Public, My Skills, Shared), search, expandable version selection +- Skillset dependency graph popup is now click-to-open, centered with blurred backdrop, instead of hover-triggered +- Graph nodes are draggable on the detail page for visual rearrangement +- Launch promotion foundation — admin manual award + user /me status endpoint +- Dependency graph modernised with Forge card nodes, animated edges, and blueprint grid +- Technical enhancement + +## Changed + +- Hero CTA buttons no longer have a background overlay plate +- Ornn assistant mascot defaults to right-edge, vertically centered +- User directory search and resolve endpoints now gated and scoped +- Technical enhancement diff --git a/.github/workflows/changeset-release.yml b/.github/workflows/changeset-release.yml index ae7eb019..89d04e84 100644 --- a/.github/workflows/changeset-release.yml +++ b/.github/workflows/changeset-release.yml @@ -52,7 +52,7 @@ jobs: steps: - name: Mint app token id: app-token - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@v3 with: app-id: ${{ secrets.RELEASE_BOT_APP_ID }} private-key: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11156bd5..5abd2057 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,26 @@ jobs: - run: bun install --frozen-lockfile - run: bun run typecheck + # Assistant KB freshness (#970, MAJOR-1). The grounding digest + # (digest.generated.md) is a committed build artifact distilled from the + # repo docs by scripts/build-assistant-kb.ts. If a source doc or the + # source manifest changes but the digest isn't regenerated, the assistant + # ships stale grounding. Rebuild it here and fail if the committed copy + # differs — the build is deterministic, so a clean tree means in-sync. + assistant-kb-freshness: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: oven-sh/setup-bun@v2 + - run: bun install --frozen-lockfile + - name: Rebuild assistant KB digest + working-directory: ornn-api + run: bun run build:assistant-kb + - name: Fail if the committed digest is stale + run: | + git diff --exit-code -- ornn-api/src/domains/assistant/kb/digest.generated.md \ + || { echo "::error::assistant KB digest is stale — run 'bun run build:assistant-kb' in ornn-api/ and commit the result"; exit 1; } + test: runs-on: ubuntu-latest steps: @@ -55,21 +75,21 @@ jobs: # because a single codecov-action invocation applies one flag set to # every file it uploads. - name: Upload ornn-api coverage to Codecov - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v7 if: ${{ !cancelled() }} with: flags: api fail_ci_if_error: false files: ./ornn-api/coverage/lcov.info - name: Upload ornn-web coverage to Codecov - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v7 if: ${{ !cancelled() }} with: flags: web fail_ci_if_error: false files: ./ornn-web/coverage/lcov.info - name: Upload TS SDK coverage to Codecov - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v7 if: ${{ !cancelled() }} with: flags: sdk-ts @@ -103,7 +123,7 @@ jobs: # Codecov upload (#471) — same as the bun job but for the # Python SDK's coverage.xml. - name: Upload Python coverage to Codecov - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v7 if: ${{ !cancelled() }} with: flags: python diff --git a/.gitignore b/.gitignore index 5fa00640..dd836b8f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -node_modules/ +node_modules dist/ coverage/ .env @@ -21,3 +21,8 @@ __pycache__/ .pytest_cache/ build/ .gstack/ + +# Local agent / orchestrator runtime artifacts — disposable, never committed. +.auto/ +.agents/ +skills-lock.json diff --git a/README.md b/README.md index 53a73cd0..b16b5257 100644 --- a/README.md +++ b/README.md @@ -8,30 +8,48 @@ CI Latest release License -  The skill lifecycle API for AI agents, not another marketplace. + Last commit + Discussions + Stars

---- +

+ Project status: alpha + Model-agnostic + HTTP and MCP +

-## What is Ornn +

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=|] +``` + +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` for that node — existence is not leaked. +- **Search:** `GET /v1/skillset-search?kind=…&tags=a,b&scope=…` — plain keyword/filter discovery (no semantic / LLM ranking, no facets, no popularity ranking). Cursor pagination per §4.3. + +SDK helpers: `client.createSkillset(...)` / `getSkillset(...)` / `publishSkillset(...)` / `setSkillsetPermissions(...)` / `deleteSkillset(...)` / `getSkillsetClosure(...)` / `searchSkillsets(...)` (TypeScript); `client.create_skillset(...)` / `get_skillset(...)` / `publish_skillset(...)` / `set_skillset_permissions(...)` / `delete_skillset(...)` / `resolve_skillset_closure(...)` / `search_skillsets(...)` (Python). + +> **Scope follow-up:** skillset endpoints intentionally reuse the `ornn:skill:*` permission scopes in v1 (a skillset is a skill-lifecycle resource). Splitting them into dedicated `ornn:skillset:{create,read,update,delete}` scopes is a tracked follow-up; callers should not assume the reuse is permanent. + --- ## 3. HTTP semantics @@ -331,6 +398,7 @@ Endpoints pick a subset and MAY add endpoint-specific events with the same prefi |---|---| | `POST /v1/skills/generate` | `generation_start`, `generation_delta`, `generation_validation_error`, `generation_error`, `generation_complete` | | `POST /v1/playground/chat` | `chat_start`, `chat_text_delta`, `chat_tool_call`, `chat_tool_result`, `chat_file_output`, `chat_error`, `chat_finish` | +| `POST /v1/assistant/chat` | `chat_start`, `chat_text_delta`, `chat_error`, `chat_finish` | ### 6.3 Transport rules diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 63b83050..b5d3ad5b 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -597,3 +597,4 @@ The current app still uses a legacy design vocabulary centered on `neon.css`, `n | 2026-04-29 | Promoted Forge Workshop direction (v3) into the documented system | The v2 Editorial Paper baseline read too close to Anthropic Claude's marketing surface (warm parchment + italic serif + soft drop shadows). v3 introduces Space Grotesk Bold UPPERCASE display, dark-first default, cool steel-paper light bg (`#EAECEC`), bi-tonal arc-blue secondary diagrammatic accent, letterpress press-down shadows on CTAs and cards, drafting-paper edge rulers and viewport-corner registration marks on landing, hand-applied highlighter mark replacing italic-Fraunces-ember signature. Reference build at `chrono-ornn.surge.sh/Ornn-Landing-v3.html` and `design-preview/Ornn-Landing-v3.html`. Italic Fraunces is deprecated for new landing surfaces; persists for app-shell during separate migration. New "Differentiation Guardrails" section makes the anti-Claude rules testable at PR-review time | | 2026-04-29 | Approved arc-blue (`#5BC8E8` dark / `#2B7791` light) as a secondary diagrammatic accent | Relaxes the prior "no purple, blue, or rainbow tech gradients" anti-pattern at a restricted role only — bracketed labels, dim-rule end caps, hover variants, status-info pings. Never primary CTA fill, never gradient wash, never decorative default. The bi-tonal ember + arc identity is what carries the "two sides of the forge" brand distinction from Claude's mono-orange | | 2026-04-29 | Replaced soft drop shadows with letterpress hard-offset impressions on cards and CTAs | Generic SaaS card vocabulary (soft drop shadows + hover lift) was a load-bearing Claude-adjacent trait. Letterpress vocabulary (hard offset shadows + hover-press-down) plants riso-print / industrial-publication identity instead. Component tokens for per-state shadows (`--button-primary-shadow-rest/-hover/-active`, `--card-shadow-rest/-hover`) replace inline arbitrary shadow strings. Inline shadows on landing components are now a review-blocker | +| 2026-06-10 | Unified skill registry + skillset browse on shared registry chrome and tokenized the react-flow dependency-graph editor (#1067) | The skills registry and the skillset browse now share one set of registry shells — `RegistryTabs` (the index/section tab bar), `RegistryGrid` (the numbered, row-rhythm result grid), and `RegistrySidebar` (the filter/metadata rail) — so both indices read as the same ledger and shell-wide registry adjustments stay centralized. Their detail pages share the same chrome too: `DetailHeroStrip` (the bracketed-mono + Space Grotesk title band) and `RailCard` (the right-rail metadata/action card). The skillset dependency-graph **editor** is built on `@xyflow/react`; its stock palette (white/charcoal nodes, `#b1b1b7` edges/handles, `#1a192b`/blue selection) reads as off-brand react-flow blue. We re-point react-flow's own `--xy-*` CSS vars at Forge semantic tokens via a scoped `.skillset-depgraph-canvas` wrapper class (light + dark resolve automatically): **arc-blue secondary accent** (`--color-accent-secondary`) for diagrammatic edges / connecting line / handles (its restricted role); **ember primary accent** (`--color-accent`) for the selected edge and the node selection ring; **card / border-subtle / strong-ink** tokens (`--color-card`, `--color-border-subtle`, `--color-strong`) for node fill / border / text so the canvas nodes match the click-to-connect chips exactly. Token re-pointing only — no custom `nodeTypes`, no react-flow internal re-theming | diff --git a/docs/ERRORS.md b/docs/ERRORS.md index be5c5d7f..92271726 100644 --- a/docs/ERRORS.md +++ b/docs/ERRORS.md @@ -92,23 +92,41 @@ The caller is authenticated but lacks the permission required for this resource ## resource_not_found **HTTP:** `404 Not Found` -**Common subcodes (lowercase post-#585):** `skill_not_found`, `skill_version_not_found`, `org_not_found`, `provider_not_found`, `announcement_not_found`, `broadcast_not_found`, `notification_not_found`, `audit_not_found`, `redemption_code_not_found`, … +**Common subcodes (lowercase post-#585):** `skill_not_found`, `skill_version_not_found`, `skill_dependency_not_found`, `skillset_not_found`, `skillset_version_not_found`, `org_not_found`, `provider_not_found`, `announcement_not_found`, `broadcast_not_found`, `notification_not_found`, `audit_not_found`, `redemption_code_not_found`, … The target resource does not exist, **or** it exists but is not visible to the caller (private skill outside their access scope). The two cases are intentionally not distinguished — disclosing existence is itself information. **Client action:** for known-good identifiers, this likely means a visibility issue. For typed identifiers, verify the GUID / name. +### skill_dependency_not_found + +A skill in a dependency closure (#968) could not be resolved — either the referenced `@` / `@` does not exist, or it is a private skill the caller cannot read. Surfaced at publish time (a new version declares a `depends-on` ref that won't resolve) and from `GET /api/v1/skills/{id}/closure`. The **skillset** closure + publish paths (#969) reuse this code verbatim — a skillset member ref, or a member's transitive dependency, that won't resolve surfaces here too. As with every `resource_not_found`, "missing" and "not visible" are intentionally indistinguishable. + +**Client action:** verify each `depends-on` ref points at a published, readable skill version. Publish or share the dependency first, then retry. + --- ## resource_conflict **HTTP:** `409 Conflict` -**Common subcodes (lowercase post-#585):** `skill_name_exists`, `reconcile_already_running`, `redemption_code_expired`, `redemption_code_already_redeemed`, `redemption_code_already_invalidated`, `old_repo_not_confirmed`, … +**Common subcodes (lowercase post-#585):** `skill_name_exists`, `skillset_name_exists`, `skillset_version_exists`, `dependency_cycle`, `dependency_conflict`, `reconcile_already_running`, `redemption_code_expired`, `redemption_code_already_redeemed`, `redemption_code_already_invalidated`, `old_repo_not_confirmed`, … The request collides with current state — a duplicate skill name on create, a concurrent modification, a job that's already running, etc. **Client action:** read `detail` to decide. For duplicates, prompt the user for a different value. For concurrent modifications, refetch and retry. +### dependency_cycle + +The skill dependency graph (#968) contains a cycle — following `depends-on` refs eventually loops back to a skill already on the path. A closure with a cycle cannot be installed in any order. Surfaced at publish time and from `GET /api/v1/skills/{id}/closure`, and identically from the skillset closure/publish paths (#969). + +**Client action:** break the cycle by removing one of the offending `depends-on` refs. The `detail` names a skill involved in the loop. + +### dependency_conflict + +Two different versions of the **same** skill appear in one dependency closure (#968) — e.g. `a` depends on `b@1.0` while `a`'s other dependency `c` depends on `b@2.0`. Only one version of a given skill can be installed in a closure. The skillset closure (#969) reuses this verbatim: two members (or their transitive deps) that pin the same skill to different versions collide here. + +**Client action:** align the conflicting pins so every path resolves the skill to the same `` version, then retry. + --- ## payload_too_large @@ -176,6 +194,10 @@ A dependency Ornn relies on (NyxID, OpenSandbox, LLM provider, mirror target, **Client action:** retry with exponential backoff. If the failure persists, check [status.chrono-ai.fun](https://status.chrono-ai.fun) (when published) or [Discussions → Q&A](https://github.com/ChronoAIProject/Ornn/discussions/categories/q-a). +### chat_error (SSE) — `/assistant/chat` + +`POST /v1/assistant/chat` (SSE; see [`docs/CONVENTIONS.md`](CONVENTIONS.md) §6.2) introduces **no new error codes** — it reuses the existing catalog. Failures before the stream opens use the normal `application/problem+json` envelope: `validation_error` (400, bad body), `authentication_required` (401), `rate_limited` (429), and — only when the caller supplies an explicit `modelId` — `MODEL_NOT_ENABLED` / `MODEL_NOT_FOUND` (400, from the per-surface model resolver). Once the stream is open, an in-stream LLM failure is delivered as an SSE `chat_error` event with `code: "upstream_unavailable"` and no terminal `chat_finish`, mirroring this section's parent code. + --- ## Appendix: pre-#585 migration map @@ -216,6 +238,9 @@ Clients pinned to the old `SCREAMING_SNAKE_CASE` codes need to switch to the low | `SKILL_NAME_EXISTS` | 409 | `skill_name_exists` | `resource_conflict` | | `SKILL_NOT_FOUND` | 404 | `skill_not_found` | `resource_not_found` | | `SKILL_VERSION_NOT_FOUND` | 404 | `skill_version_not_found` | `resource_not_found` | +| _(new in #968)_ | 404 | `skill_dependency_not_found` | `resource_not_found` | +| _(new in #968)_ | 409 | `dependency_cycle` | `resource_conflict` | +| _(new in #968)_ | 409 | `dependency_conflict` | `resource_conflict` | | `UPSTREAM_DOWN` | 502 | `upstream_down` | `upstream_unavailable` | Format rule for future codes: lowercase ASCII, words joined by `_`, no leading/trailing `_`. Pick from the parent §1.4 vocabulary when generic; add a specific subcode only when the caller needs to branch on it. diff --git a/ornn-api/Dockerfile b/ornn-api/Dockerfile index 3303b132..7d382244 100644 --- a/ornn-api/Dockerfile +++ b/ornn-api/Dockerfile @@ -3,7 +3,7 @@ # ornn-web/Dockerfile for the failure mode). Same pinning + same # copy-the-real-package.jsons treatment here so the install stage stays # reproducible. -FROM oven/bun:1.3.13 AS install +FROM oven/bun:1.3.14 AS install WORKDIR /app @@ -21,7 +21,7 @@ COPY sdk/typescript/package.json sdk/typescript/ RUN bun install # Runtime stage -FROM oven/bun:1.3.13 +FROM oven/bun:1.3.14 WORKDIR /app diff --git a/ornn-api/package.json b/ornn-api/package.json index 5580f918..58faa57d 100644 --- a/ornn-api/package.json +++ b/ornn-api/package.json @@ -10,18 +10,19 @@ "migrate:versions": "bun run scripts/migrate-skill-versions.ts", "migrate:ownership": "bun run scripts/migrate-skill-ownership.ts", "migrate:drop-topics": "bun run scripts/drop-topics.ts", - "audit:reserved-verbs": "bun run scripts/audit-reserved-verbs.ts" + "audit:reserved-verbs": "bun run scripts/audit-reserved-verbs.ts", + "build:assistant-kb": "bun run scripts/build-assistant-kb.ts" }, "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" @@ -29,7 +30,7 @@ "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" } } diff --git a/ornn-api/scripts/build-assistant-kb.ts b/ornn-api/scripts/build-assistant-kb.ts new file mode 100644 index 00000000..cad6551e --- /dev/null +++ b/ornn-api/scripts/build-assistant-kb.ts @@ -0,0 +1,154 @@ +/** + * Build the Ornn Assistant knowledge-base digest (#970). + * + * bun run scripts/build-assistant-kb.ts + * + * Reads the curated repo docs declared in + * `src/domains/assistant/kb/sources.ts`, distills them into a single + * size-budgeted grounding digest, and writes the committed artifact at + * `src/domains/assistant/kb/digest.generated.md`. The runtime loader reads + * that artifact — it never re-reads these source docs. + * + * v1 uses the deterministic distiller (priority-ordered curation + per- + * source caps + global budget clamp). The "big model reads the repo at + * build time" idea slots in here: swap `DeterministicKbDistiller` for an + * `LlmKbDistiller` that implements the same `KbDistiller` contract — the + * rest of this script (sourcing, writing, provenance) is unchanged. + * + * Budget is overridable via the `ASSISTANT_KB_TOKEN_BUDGET` env var. + * + * Determinism note: the artifact header intentionally carries NO timestamp + * so re-running on unchanged inputs yields a byte-identical file (clean + * diffs, stable CI). Provenance is the input list + budget, not a clock. + * + * @module scripts/build-assistant-kb + */ + +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { createLogger } from "../src/shared/logger"; +import { + DeterministicKbDistiller, + type KbDistiller, + type KbSourceDoc, +} from "../src/domains/assistant/kb/distiller"; +import { KB_SOURCE_MANIFEST } from "../src/domains/assistant/kb/sources"; +import { DIGEST_ARTIFACT_FILENAME } from "../src/domains/assistant/kb/loader"; +import { resolveKbTokenBudget } from "../src/domains/assistant/kb/tokens"; + +const logger = createLogger("buildAssistantKb"); + +// scripts/ → ornn-api/ → repo root +const REPO_ROOT = join(import.meta.dir, "..", ".."); +const ARTIFACT_PATH = join( + import.meta.dir, + "..", + "src", + "domains", + "assistant", + "kb", + DIGEST_ARTIFACT_FILENAME, +); + +/** + * Read each manifest source from the repo root. Missing files are skipped + * with a warning — a doc rename must not break the build, it just yields + * less grounding until the manifest is retuned. + */ +function readSources(): KbSourceDoc[] { + const docs: KbSourceDoc[] = []; + for (const spec of KB_SOURCE_MANIFEST) { + const abs = join(REPO_ROOT, spec.repoRelPath); + let text: string; + try { + text = readFileSync(abs, "utf-8"); + } catch (err) { + logger.warn( + { id: spec.id, path: spec.repoRelPath, err: (err as Error).message }, + "KB source missing — skipping", + ); + continue; + } + docs.push({ + id: spec.id, + title: spec.title, + text, + maxTokens: spec.maxTokens, + ...(spec.headings ? { headings: spec.headings } : {}), + }); + } + return docs; +} + +function renderArtifact( + digestText: string, + header: { + budgetTokens: number; + estimatedTokens: number; + sources: ReadonlyArray<{ id: string; estimatedTokens: number; truncated: boolean }>; + }, +): string { + const sourceLines = header.sources + .map( + (s) => + ` - ${s.id}: ~${s.estimatedTokens} tok${s.truncated ? " (clipped)" : ""}`, + ) + .join("\n"); + // HTML-comment provenance block — stripped by the loader, never fed to + // the model. No timestamp (see module doc: deterministic output). + const meta = [ + "", + "", + ].join("\n"); + return `${meta}\n${digestText}\n`; +} + +function main(): void { + const budgetTokens = resolveKbTokenBudget(); + const distiller: KbDistiller = new DeterministicKbDistiller(); + + const sources = readSources(); + if (sources.length === 0) { + logger.error("No KB sources could be read — aborting without writing artifact"); + process.exitCode = 1; + return; + } + + const digest = distiller.distill(sources, { + budgetTokens, + generatedFrom: "scripts/build-assistant-kb.ts (DeterministicKbDistiller)", + }); + + const artifact = renderArtifact(digest.text, { + budgetTokens: digest.budgetTokens, + estimatedTokens: digest.estimatedTokens, + sources: digest.sources, + }); + + writeFileSync(ARTIFACT_PATH, artifact, "utf-8"); + + logger.info( + { + artifact: ARTIFACT_PATH, + budgetTokens: digest.budgetTokens, + estimatedTokens: digest.estimatedTokens, + sourceCount: digest.sources.length, + sources: digest.sources.map((s) => ({ + id: s.id, + tokens: s.estimatedTokens, + truncated: s.truncated, + })), + }, + "Assistant KB digest written", + ); +} + +main(); diff --git a/ornn-api/src/bootstrap.ts b/ornn-api/src/bootstrap.ts index 73983bf1..5845e103 100644 --- a/ornn-api/src/bootstrap.ts +++ b/ornn-api/src/bootstrap.ts @@ -59,6 +59,9 @@ import { SkillVersionRepository } from "./domains/skills/crud/skillVersionReposi import { SkillService } from "./domains/skills/crud/service"; import { createSkillRoutes } from "./domains/skills/crud/routes"; +// Domain: Skillsets (#969) +import { wireSkillsets } from "./domains/skillsets/bootstrap"; + // Domain: Skill Audit import { AuditRepository } from "./domains/skills/audit/repository"; import { AuditService } from "./domains/skills/audit/service"; @@ -67,6 +70,7 @@ import { createAuditRoutes } from "./domains/skills/audit/routes"; // Domain: Notifications import { wireNotifications } from "./domains/notifications/bootstrap"; +import { wireLaunchPromo } from "./domains/launchPromo/bootstrap"; // Domain: Announcements (landing-page popup) import { wireAnnouncements } from "./domains/announcements/bootstrap"; @@ -89,6 +93,9 @@ import { wireSkillGeneration } from "./domains/skills/generation/bootstrap"; // Domain: Playground import { wirePlayground } from "./domains/playground/bootstrap"; +// Domain: Assistant (#970 — repo-aware Q&A chatbot) +import { wireAssistant } from "./domains/assistant/bootstrap"; + // Domain: Admin import { createAdminRoutes } from "./domains/admin/routes"; @@ -332,12 +339,14 @@ export async function bootstrap( // shape stays narrow — the empty `gatewayUrl` is what triggers the // fail-closed branch downstream. const resolveLlmProviderForSurface = async ( - surface: "playground" | "skillGen", + surface: "playground" | "skillGen" | "assistant", ): Promise<{ gatewayUrl: string; apiKey: string; apiFormat: ApiFormat }> => { const sec = surface === "playground" ? await settingsService.getPlayground() - : await settingsService.getSkillGen(); + : surface === "skillGen" + ? await settingsService.getSkillGen() + : await settingsService.getAssistant(); if (!sec.defaultProviderId) { return { gatewayUrl: "", apiKey: "", apiFormat: "responses" }; } @@ -362,12 +371,14 @@ export async function bootstrap( // override for callers that want to pin a specific model regardless // of the cross-provider default. const resolveSurfaceDefaults = async ( - surface: "playground" | "skillGen", + surface: "playground" | "skillGen" | "assistant", ): Promise<{ model: string; maxOutputTokens: number; temperature: number }> => { const sec = surface === "playground" ? await settingsService.getPlayground() - : await settingsService.getSkillGen(); + : surface === "skillGen" + ? await settingsService.getSkillGen() + : await settingsService.getAssistant(); let model = sec.defaultModelId ?? ""; if (!model) { const resolution = await llmProvidersService.resolveModel({ surface }); @@ -490,12 +501,15 @@ export async function bootstrap( db, logger, }); - const { service: notificationService, routes: notificationRoutes } = - await wireNotifications({ - db, - logger, - broadcastRepo: broadcastRepoForNotifications, - }); + const { + service: notificationService, + routes: notificationRoutes, + repo: notificationRepo, + } = await wireNotifications({ + db, + logger, + broadcastRepo: broadcastRepoForNotifications, + }); // ---- Domain: Announcements (landing-page popup, issue #307) ---- const { routes: announcementRoutes } = await wireAnnouncements({ db, logger }); @@ -627,10 +641,26 @@ export async function bootstrap( // ---- Domain: Redemption codes (single-use admin-issued quota grants) ---- const { + service: redemptionCodeService, adminRoutes: adminRedemptionCodesRoutes, meRoutes: meRedemptionCodesRoutes, } = wireRedemptionCodes({ db, logger, quotaService }); + // ---- Domain: Launch promo (#724) ---- + // Sits on top of redemption codes + notifications: when an admin + // (or, in a follow-up PR, the cron loop) awards an eligible user, + // the service mints a code via redemptionCodeService.mint and drops + // it into the user's notification inbox. + const { service: launchPromoService, routes: launchPromoRoutes } = + await wireLaunchPromo({ + db, + settingsService, + redemptionCodeService, + notificationRepo, + userDirectoryRepo, + }); + void launchPromoService; + // ---- Per-provider model catalog migration (#270) ---- // Fold the standalone `models` collection into `llm_providers.models[]` // arrays. One-time, idempotent — see `migration.ts`. Must run before @@ -736,6 +766,13 @@ export async function bootstrap( getSaAccessToken, }); + // ---- Domain: Skillsets (#969) ---- + // A skillset is a curated, versioned meta-package over N member skills. + // The service injects `skillService` so member resolution + the #968 + // closure walk stay single-sourced. + const skillsets = wireSkillsets({ db, skillService }); + await skillsets.ensureIndexes(); + // ---- Domain: Skill Generation ---- const { service: generationService, routes: generationRoutes } = wireSkillGeneration({ @@ -760,6 +797,20 @@ export async function bootstrap( llmProvidersService, }); + // ---- Domain: Assistant (#970) ---- + // Repo-aware Q&A chatbot. Reuses the shared NyxLlmClient, the assistant + // LLM surface (resolver + quota), and a visibility-scoped retrieval over + // the same SkillRepository. Pure Q&A — no agentic tool loop. + const { routes: assistantRoutes } = wireAssistant({ + llmClient: nyxLlmClient, + skillRepo, + quotaService, + llmProvidersService, + defaultsResolver: async () => resolveSurfaceDefaults("assistant"), + keepAliveIntervalMsResolver: async () => + (await settingsService.getAssistant()).sseKeepAliveMs, + }); + // ---- Domain: Admin ---- const adminRoutes = createAdminRoutes({ analyticsEmitter, @@ -905,15 +956,19 @@ export async function bootstrap( quotaRoutes: adminQuotaRoutes, } = wireAdmin({ db, userDirectoryRepo, quotaService }); apiApp.route("/", skillRoutes); + apiApp.route("/", skillsets.routes); + apiApp.route("/", skillsets.searchRoutes); apiApp.route("/", mirrorRoutes); apiApp.route("/", auditRoutes); apiApp.route("/", notificationRoutes); + apiApp.route("/", launchPromoRoutes); apiApp.route("/", announcementRoutes); apiApp.route("/", broadcastRoutes); apiApp.route("/", analyticsRoutes); apiApp.route("/", searchRoutes); apiApp.route("/", generationRoutes); apiApp.route("/", playgroundRoutes); + apiApp.route("/", assistantRoutes); apiApp.route("/", adminRoutes); apiApp.route("/", adminDashboardRoutes); apiApp.route("/", adminUsersRoutes); diff --git a/ornn-api/src/clients/nyxid/llm.test.ts b/ornn-api/src/clients/nyxid/llm.test.ts index 6e0d69d1..9b665f28 100644 --- a/ornn-api/src/clients/nyxid/llm.test.ts +++ b/ornn-api/src/clients/nyxid/llm.test.ts @@ -644,3 +644,211 @@ describe("NyxLlmClient.complete() routing on apiFormat", () => { ]); }); }); + +// --------------------------------------------------------------------------- +// #608 — chat-completion tool-call delta normalization +// --------------------------------------------------------------------------- + +describe("chat-completion stream tool-call normalization (#608)", () => { + function makeClient(): NyxLlmClient { + return new NyxLlmClient({ + resolver: makeResolver({ + gatewayUrl: "https://api.example.com", + apiKey: "sk-x", + apiFormat: "chat-completion", + }), + saTokenProvider: STUB_SA_TOKEN, + }); + } + + it("accumulates tool_calls across chunks and emits one output_item.done on finish_reason=tool_calls", async () => { + fetchHandler = () => + sseResponse([ + JSON.stringify({ + choices: [{ + delta: { + tool_calls: [{ + index: 0, + id: "call_abc", + type: "function", + function: { name: "execute_in_sandbox", arguments: "" }, + }], + }, + }], + }), + JSON.stringify({ + choices: [{ + delta: { tool_calls: [{ index: 0, function: { arguments: "{\"scr" } }] }, + }], + }), + JSON.stringify({ + choices: [{ + delta: { tool_calls: [{ index: 0, function: { arguments: "ipt\":\"x\"}" } }] }, + }], + }), + JSON.stringify({ choices: [{ delta: {}, finish_reason: "tool_calls" }] }), + ]); + + const events: ResponsesApiStreamEvent[] = []; + for await (const e of makeClient().stream({ + model: "deepseek-v4", + input: [{ role: "user", content: "run x" }], + tools: [{ + type: "function", + name: "execute_in_sandbox", + description: "run", + parameters: { type: "object" }, + }], + })) events.push(e); + + const done = events.filter((e) => e.type === "response.output_item.done"); + expect(done).toHaveLength(1); + expect(done[0]).toEqual({ + type: "response.output_item.done", + item: { + type: "function_call", + id: "call_abc", + call_id: "call_abc", + name: "execute_in_sandbox", + arguments: "{\"script\":\"x\"}", + }, + }); + }); + + it("flushes a buffered tool call when stream ends without [DONE] or finish_reason", async () => { + // Body has no [DONE] sentinel and no finish_reason — just an EOF. + fetchHandler = () => + new Response( + `data: ${JSON.stringify({ + choices: [{ + delta: { + tool_calls: [{ + index: 0, + id: "call_z", + function: { name: "t", arguments: "{\"a\":1}" }, + }], + }, + }], + })}\n\n`, + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ); + + const events: ResponsesApiStreamEvent[] = []; + for await (const e of makeClient().stream({ + model: "m", + input: [{ role: "user", content: "go" }], + })) events.push(e); + + const done = events.find((e) => e.type === "response.output_item.done"); + expect(done).toEqual({ + type: "response.output_item.done", + item: { + type: "function_call", + id: "call_z", + call_id: "call_z", + name: "t", + arguments: "{\"a\":1}", + }, + }); + }); + + it("supports parallel tool calls — one done event per index", async () => { + fetchHandler = () => + sseResponse([ + JSON.stringify({ + choices: [{ + delta: { + tool_calls: [ + { index: 0, id: "call_a", function: { name: "fn_a", arguments: "{}" } }, + { index: 1, id: "call_b", function: { name: "fn_b", arguments: "{}" } }, + ], + }, + }], + }), + JSON.stringify({ choices: [{ delta: {}, finish_reason: "tool_calls" }] }), + ]); + + const events: ResponsesApiStreamEvent[] = []; + for await (const e of makeClient().stream({ + model: "m", + input: [{ role: "user", content: "go" }], + })) events.push(e); + + const done = events.filter((e) => e.type === "response.output_item.done"); + expect(done).toHaveLength(2); + expect((done[0]!.item as { id: string }).id).toBe("call_a"); + expect((done[1]!.item as { id: string }).id).toBe("call_b"); + }); + + it("only flushes once when finish_reason and [DONE] both arrive", async () => { + fetchHandler = () => + sseResponse([ + JSON.stringify({ + choices: [{ + delta: { + tool_calls: [{ index: 0, id: "call_x", function: { name: "fn", arguments: "{}" } }], + }, + finish_reason: "tool_calls", + }], + }), + ]); + + const events: ResponsesApiStreamEvent[] = []; + for await (const e of makeClient().stream({ + model: "m", + input: [{ role: "user", content: "go" }], + })) events.push(e); + + const done = events.filter((e) => e.type === "response.output_item.done"); + expect(done).toHaveLength(1); + }); + + it("intermixed text + tool_call deltas produce text-delta then done event in order", async () => { + fetchHandler = () => + sseResponse([ + JSON.stringify({ choices: [{ delta: { content: "thinking…" } }] }), + JSON.stringify({ + choices: [{ + delta: { + tool_calls: [{ index: 0, id: "call_q", function: { name: "fn", arguments: "{}" } }], + }, + }], + }), + JSON.stringify({ choices: [{ delta: {}, finish_reason: "tool_calls" }] }), + ]); + + const events: ResponsesApiStreamEvent[] = []; + for await (const e of makeClient().stream({ + model: "m", + input: [{ role: "user", content: "go" }], + })) events.push(e); + + expect(events.map((e) => e.type)).toEqual([ + "response.output_text.delta", + "response.output_item.done", + ]); + }); + + it("tool_calls.index missing → falls back to index 0", async () => { + fetchHandler = () => + sseResponse([ + JSON.stringify({ + choices: [{ + delta: { + tool_calls: [{ id: "call_noix", function: { name: "fn", arguments: "{}" } }], + }, + finish_reason: "tool_calls", + }], + }), + ]); + + const events: ResponsesApiStreamEvent[] = []; + for await (const e of makeClient().stream({ + model: "m", + input: [{ role: "user", content: "go" }], + })) events.push(e); + + const done = events.find((e) => e.type === "response.output_item.done"); + expect((done?.item as { id: string }).id).toBe("call_noix"); + }); +}); diff --git a/ornn-api/src/clients/nyxid/llm.ts b/ornn-api/src/clients/nyxid/llm.ts index 595afcfc..6d8e51a1 100644 --- a/ornn-api/src/clients/nyxid/llm.ts +++ b/ornn-api/src/clients/nyxid/llm.ts @@ -11,13 +11,10 @@ * `ResponsesApiStreamEvent`). For chat-completion providers the client * translates the request body on the way out and normalizes the SSE * stream / completion payload back into Responses-API event shape on - * the way in, so consumers (skill generation + playground) do not need - * to branch on apiFormat (#574). - * - * Chat-completion tool-call normalization is implemented: streamed - * `choices[].delta.tool_calls[]` fragments are accumulated and flushed - * as `response.output_item.done` function_call events, and non-streamed - * `choices[].message.tool_calls[]` map to `function_call` outputs (#608). + * the way in — both text deltas (#574) and accumulated tool-call + * deltas synthesized into `response.output_item.done` / + * `function_call` events (#608) — so consumers (skill generation + + * playground tool loop) never branch on apiFormat. * * Authenticates using a Service Account (SA) token obtained via * client_credentials grant when the resolved provider has no direct @@ -188,56 +185,76 @@ async function* parseResponsesStream( } /** - * Parse Chat Completions SSE and translate both text deltas and - * tool-call deltas into Responses-API event shape so consumers stay - * format-agnostic. + * Internal accumulator for a single OpenAI Chat Completions tool call + * as it streams in. `id` + `name` arrive on the first delta for that + * tool-call index; `arguments` is a JSON string that accumulates + * across many chunks before the model finishes emitting it. + */ +interface ToolCallAccumulator { + id: string; + name: string; + arguments: string; +} + +/** + * Parse Chat Completions SSE and translate it into Responses-API + * event shape so downstream consumers (skill generation, playground + * tool-use loop) stay format-agnostic. * - * Text deltas (`choices[].delta.content`) pass through as - * `response.output_text.delta`. Tool-call fragments - * (`choices[].delta.tool_calls[]`) arrive incrementally — `id`/`name` - * land on the first fragment, `function.arguments` streams across many - * — so we accumulate per `index` and flush each completed call as a - * single `response.output_item.done` function_call event (matching the - * Responses-API shape the playground consumer reads). We never parse the - * arguments mid-stream; the consumer parses the assembled JSON (#608). + * - `choices[].delta.content` chunks → `response.output_text.delta` + * events (#574). + * - `choices[].delta.tool_calls` chunks are buffered per tool-call + * index (id + name + accumulated JSON arguments). When the turn + * completes (explicit `finish_reason` of `"tool_calls"` / + * `"stop"`, upstream `[DONE]`, or EOF), each buffered tool call is + * emitted as a synthesized `response.output_item.done` event with + * `item.type === "function_call"` — the exact shape the playground + * loop already consumes (#608). Without this the playground never + * sees a tool call from chat-completion providers and renders + * `execute_in_sandbox(...)` as plain text instead of running it. */ async function* parseChatCompletionStream( response: Response, ): AsyncIterable { - const toolCalls = new Map(); + const toolCalls = new Map(); let flushed = false; - function* flush(): Generator { + function* flushToolCalls(): Generator { if (flushed) return; flushed = true; - for (const index of [...toolCalls.keys()].sort((a, b) => a - b)) { - const call = toolCalls.get(index)!; + const indices = [...toolCalls.keys()].sort((a, b) => a - b); + for (const idx of indices) { + const tc = toolCalls.get(idx)!; yield { type: "response.output_item.done", item: { type: "function_call", - id: call.id, - call_id: call.id, - name: call.name, - arguments: call.argsBuffer, + id: tc.id, + call_id: tc.id, + name: tc.name, + arguments: tc.arguments, }, }; } } for await (const { data } of parseSSELines(response)) { - if (data === "[DONE]") break; + if (data === "[DONE]") { + yield* flushToolCalls(); + return; + } let chunk: { choices?: Array<{ delta?: { content?: string | null; tool_calls?: Array<{ - index: number; + index?: number; id?: string; + type?: string; function?: { name?: string; arguments?: string }; }>; }; - finish_reason?: string; + finish_reason?: string | null; }>; }; try { @@ -248,34 +265,45 @@ async function* parseChatCompletionStream( } const choice = chunk.choices?.[0]; - const textDelta = choice?.delta?.content; - if (typeof textDelta === "string" && textDelta.length > 0) { - yield { type: "response.output_text.delta", delta: textDelta }; + if (!choice) continue; + + const text = choice.delta?.content; + if (typeof text === "string" && text.length > 0) { + yield { type: "response.output_text.delta", delta: text }; } - const fragments = choice?.delta?.tool_calls; - if (fragments) { - for (const frag of fragments) { - const existing = toolCalls.get(frag.index) ?? { id: "", name: "", argsBuffer: "" }; - if (frag.id) existing.id = frag.id; - if (frag.function?.name) existing.name = frag.function.name; - if (typeof frag.function?.arguments === "string") { - existing.argsBuffer += frag.function.arguments; + const deltaToolCalls = choice.delta?.tool_calls; + if (Array.isArray(deltaToolCalls)) { + for (const piece of deltaToolCalls) { + // OpenAI uses `index` to disambiguate parallel tool calls + // within one assistant turn. Missing index → assume a single + // tool call at index 0. + const idx = typeof piece.index === "number" ? piece.index : 0; + const acc = toolCalls.get(idx) ?? { id: "", name: "", arguments: "" }; + if (piece.id) acc.id = piece.id; + if (piece.function?.name) acc.name = piece.function.name; + if (typeof piece.function?.arguments === "string") { + acc.arguments += piece.function.arguments; } - toolCalls.set(frag.index, existing); + toolCalls.set(idx, acc); } } - if (choice?.finish_reason === "tool_calls") { - yield* flush(); + // Some providers (DeepSeek, Together, …) terminate a tool-call + // turn with finish_reason=tool_calls; others emit + // finish_reason=stop alongside the final tool-call delta. Treat + // any non-null finish_reason as "no more deltas this turn" and + // flush so the playground loop sees the tool call before + // [DONE] / EOF. + if (choice.finish_reason && toolCalls.size > 0) { + yield* flushToolCalls(); } } - // Flush on stream end / [DONE] in case the upstream omitted the - // `finish_reason: "tool_calls"` chunk but still streamed tool calls. - if (toolCalls.size > 0) { - yield* flush(); - } + // Stream ended without a [DONE] sentinel (some upstreams just + // close the body). Flush any pending tool calls so we don't drop + // them on the floor. + yield* flushToolCalls(); } // --------------------------------------------------------------------------- diff --git a/ornn-api/src/domains/assistant/bootstrap.ts b/ornn-api/src/domains/assistant/bootstrap.ts new file mode 100644 index 00000000..ec4272c9 --- /dev/null +++ b/ornn-api/src/domains/assistant/bootstrap.ts @@ -0,0 +1,68 @@ +/** + * Wire the Ornn Assistant domain (#970). + * + * Composition root: build the KB loader (cache warmed at boot), the + * visibility-scoped skill retriever (over the shared `SkillRepository`), + * the chat service, and mount the SSE route. Quota + model resolution + + * SSE keep-alive are injected as resolvers so admin settings edits land on + * the next request without a restart. + * + * @module domains/assistant/bootstrap + */ + +import type { Hono } from "hono"; +import type { AuthVariables } from "../../middleware/nyxidAuth"; +import type { NyxLlmClient } from "../../clients/nyxid/llm"; +import type { SkillRepository } from "../skills/crud/repository"; +import type { QuotaService } from "../quota/service"; +import type { LlmProvidersService } from "../settings/llmProviders/service"; +import { AssistantKbLoader } from "./kb/loader"; +import { ScopedSkillRetriever, type SkillSearchPort } from "./retrieval"; +import { + AssistantChatService, + type AssistantChatDefaults, +} from "./chatService"; +import { createAssistantRoutes } from "./routes"; + +export interface AssistantWiring { + readonly routes: Hono<{ Variables: AuthVariables }>; +} + +export function wireAssistant(deps: { + llmClient: NyxLlmClient; + skillRepo: SkillRepository; + quotaService: QuotaService; + llmProvidersService: LlmProvidersService; + /** Resolve the per-request model + sampling snapshot (assistant surface). */ + defaultsResolver: () => Promise; + /** Resolve the SSE keep-alive cadence (assistant section). */ + keepAliveIntervalMsResolver: () => Promise; + /** Optional KB loader override (tests inject a fake digest reader). */ + kbLoader?: AssistantKbLoader; +}): AssistantWiring { + const kbLoader = deps.kbLoader ?? new AssistantKbLoader(); + // Warm the cache at boot so the first chat doesn't pay the artifact read + // (and any read/budget warning surfaces in boot logs, not mid-stream). + kbLoader.load(); + + const retriever = new ScopedSkillRetriever({ + // SkillRepository structurally satisfies the narrow SkillSearchPort. + search: deps.skillRepo as SkillSearchPort, + }); + + const chatService = new AssistantChatService({ + llmClient: deps.llmClient, + kbLoader, + retriever, + defaultsResolver: deps.defaultsResolver, + }); + + const routes = createAssistantRoutes({ + chatService, + quotaService: deps.quotaService, + llmProvidersService: deps.llmProvidersService, + keepAliveIntervalMsResolver: deps.keepAliveIntervalMsResolver, + }); + + return { routes }; +} diff --git a/ornn-api/src/domains/assistant/chatService.test.ts b/ornn-api/src/domains/assistant/chatService.test.ts new file mode 100644 index 00000000..43aa16d6 --- /dev/null +++ b/ornn-api/src/domains/assistant/chatService.test.ts @@ -0,0 +1,203 @@ +/** + * UT-ASST-CHAT-* — AssistantChatService (#970). + * + * Verifies the wire-contract event sequence, the structural "no tools" + * guarantee (pure Q&A), fail-soft retrieval, error mapping, and usage. + * + * @module domains/assistant/chatService.test + */ + +import { describe, expect, it } from "bun:test"; +import type { + NyxLlmStreamParams, + ResponsesApiStreamEvent, +} from "../../clients/nyxid/llm"; +import type { ActorContext } from "../skills/crud/authorize"; +import { AssistantChatService, latestUserMessage } from "./chatService"; +import type { AssistantChatEvent, RetrievedSkill } from "./types"; + +const ACTOR: ActorContext = { + userId: "u-1", + memberships: [], + isPlatformAdmin: false, + membershipsResolved: true, +}; + +const DEFAULTS = { model: "default-model", maxOutputTokens: 4096, temperature: 0.4 }; + +class FakeLlm { + lastParams: NyxLlmStreamParams | null = null; + events: ResponsesApiStreamEvent[] = [ + { type: "response.output_text.delta", delta: "Hello" }, + { type: "response.output_text.delta", delta: " world" }, + ]; + throwError: Error | null = null; + async *stream(params: NyxLlmStreamParams): AsyncIterable { + this.lastParams = params; + if (this.throwError) throw this.throwError; + for (const e of this.events) yield e; + } +} + +function fakeKb(text = "Ornn KB.") { + return { load: () => ({ text, estimatedTokens: 2, budgetTokens: 100, truncated: false }) }; +} + +function fakeRetriever(skills: RetrievedSkill[] = [], err?: Error) { + return { + retrieve: async () => { + if (err) throw err; + return skills; + }, + }; +} + +function makeService(llm: FakeLlm, retriever = fakeRetriever()) { + return new AssistantChatService({ + llmClient: llm, + kbLoader: fakeKb(), + retriever, + defaultsResolver: async () => DEFAULTS, + }); +} + +async function collect( + gen: AsyncGenerator, +): Promise { + const out: AssistantChatEvent[] = []; + for await (const e of gen) out.push(e); + return out; +} + +describe("latestUserMessage", () => { + it("UT-ASST-CHAT-000: returns the last user turn", () => { + expect( + latestUserMessage([ + { role: "user", content: "first" }, + { role: "assistant", content: "reply" }, + { role: "user", content: "second" }, + ]), + ).toBe("second"); + expect(latestUserMessage([{ role: "assistant", content: "x" }])).toBe(""); + }); +}); + +describe("AssistantChatService", () => { + it("UT-ASST-CHAT-001: emits chat_start → deltas → chat_finish", async () => { + const llm = new FakeLlm(); + const svc = makeService(llm); + const events = await collect( + svc.chat(ACTOR, { messages: [{ role: "user", content: "hi" }] }, undefined, { + modelId: "m-explicit", + }), + ); + expect(events[0]).toEqual({ type: "chat_start", model: "m-explicit" }); + expect(events.filter((e) => e.type === "chat_text_delta")).toEqual([ + { type: "chat_text_delta", delta: "Hello" }, + { type: "chat_text_delta", delta: " world" }, + ]); + expect(events[events.length - 1]!.type).toBe("chat_finish"); + }); + + it("UT-ASST-CHAT-002: NEVER passes tools to the LLM (pure Q&A, no agentic loop)", async () => { + const llm = new FakeLlm(); + await collect( + makeService(llm).chat( + ACTOR, + { messages: [{ role: "user", content: "hi" }] }, + undefined, + { modelId: "m" }, + ), + ); + expect(llm.lastParams?.tools).toBeUndefined(); + // Grounding developer message is injected first. + expect(llm.lastParams?.input[0]?.role).toBe("developer"); + }); + + it("UT-ASST-CHAT-003: falls back to surface default model when modelId blank", async () => { + const llm = new FakeLlm(); + const events = await collect( + makeService(llm).chat( + ACTOR, + { messages: [{ role: "user", content: "hi" }] }, + undefined, + { modelId: "" }, + ), + ); + expect(events[0]).toEqual({ type: "chat_start", model: "default-model" }); + }); + + it("UT-ASST-CHAT-004: retrieval failure is non-fatal — still answers KB-only", async () => { + const llm = new FakeLlm(); + const svc = makeService(llm, fakeRetriever([], new Error("mongo down"))); + const events = await collect( + svc.chat(ACTOR, { messages: [{ role: "user", content: "hi" }] }, undefined, { + modelId: "m", + }), + ); + expect(events.some((e) => e.type === "chat_text_delta")).toBe(true); + expect(events[events.length - 1]!.type).toBe("chat_finish"); + }); + + it("UT-ASST-CHAT-005: stream error → chat_error with a catalog code, no finish", async () => { + const llm = new FakeLlm(); + llm.throwError = new Error("LLM Gateway error (502): upstream down"); + const events = await collect( + makeService(llm).chat( + ACTOR, + { messages: [{ role: "user", content: "hi" }] }, + undefined, + { modelId: "m" }, + ), + ); + const err = events.find((e) => e.type === "chat_error"); + expect(err).toBeDefined(); + if (err && err.type === "chat_error") { + expect(err.code).toBe("upstream_unavailable"); + expect(err.message).toContain("502"); + } + expect(events.some((e) => e.type === "chat_finish")).toBe(false); + }); + + it("UT-ASST-CHAT-006: reports usage on chat_finish when provider supplies it", async () => { + const llm = new FakeLlm(); + llm.events = [ + { type: "response.output_text.delta", delta: "hi" }, + { + type: "response.completed", + response: { usage: { input_tokens: 12, output_tokens: 7, total_tokens: 19 } }, + }, + ]; + const events = await collect( + makeService(llm).chat( + ACTOR, + { messages: [{ role: "user", content: "hi" }] }, + undefined, + { modelId: "m" }, + ), + ); + const finish = events.find((e) => e.type === "chat_finish"); + expect(finish).toEqual({ + type: "chat_finish", + usage: { inputTokens: 12, outputTokens: 7, totalTokens: 19 }, + }); + }); + + it("UT-ASST-CHAT-007: aborted signal stops the stream (no finish)", async () => { + const llm = new FakeLlm(); + const ac = new AbortController(); + ac.abort(); + const events = await collect( + makeService(llm).chat( + ACTOR, + { messages: [{ role: "user", content: "hi" }] }, + ac.signal, + { modelId: "m" }, + ), + ); + // chat_start always emits; the loop bails on the first aborted check. + expect(events[0]!.type).toBe("chat_start"); + expect(events.some((e) => e.type === "chat_finish")).toBe(false); + expect(events.some((e) => e.type === "chat_text_delta")).toBe(false); + }); +}); diff --git a/ornn-api/src/domains/assistant/chatService.ts b/ornn-api/src/domains/assistant/chatService.ts new file mode 100644 index 00000000..5ddcef99 --- /dev/null +++ b/ornn-api/src/domains/assistant/chatService.ts @@ -0,0 +1,197 @@ +/** + * Ornn Assistant chat service (#970) — pure Q&A, ONE streamed completion. + * + * Per request: + * 1. emit `chat_start`, + * 2. run a deterministic, visibility-scoped skill retrieval on the + * latest user message (failures are non-fatal — KB-only still + * answers), + * 3. assemble grounding (curated KB + scoped skills) + the conversation, + * 4. stream ONE completion via `NyxLlmClient.stream` (NO tools, NO + * agentic loop), mapping text deltas → `chat_text_delta`, + * 5. emit `chat_finish` (with usage when the provider reports it) or + * `chat_error` on failure. + * + * This service yields the WIRE-CONTRACT events directly; the route only + * serializes them to SSE frames and reconciles quota. Keeping the mapping + * here (not the route) makes the event sequence unit-testable without HTTP. + * + * @module domains/assistant/chatService + */ + +import { createLogger } from "../../shared/logger"; +import type { + NyxLlmClient, + ResponsesApiStreamEvent, +} from "../../clients/nyxid/llm"; +import type { ActorContext } from "../skills/crud/authorize"; +import type { AssistantKbLoader } from "./kb/loader"; +import type { ScopedSkillRetriever } from "./retrieval"; +import { assembleAssistantInput } from "./contextAssembler"; +import type { + AssistantChatEvent, + AssistantChatRequest, + AssistantUsage, + RetrievedSkill, +} from "./types"; + +const logger = createLogger("assistantChatService"); + +/** Per-request model + sampling snapshot resolved from settings. */ +export interface AssistantChatDefaults { + readonly model: string; + readonly maxOutputTokens: number; + readonly temperature: number; +} + +export interface AssistantChatServiceDeps { + readonly llmClient: Pick; + readonly kbLoader: Pick; + readonly retriever: Pick; + readonly defaultsResolver: () => Promise; +} + +export class AssistantChatService { + private readonly llmClient: Pick; + private readonly kbLoader: Pick; + private readonly retriever: Pick; + private readonly defaultsResolver: () => Promise; + + constructor(deps: AssistantChatServiceDeps) { + this.llmClient = deps.llmClient; + this.kbLoader = deps.kbLoader; + this.retriever = deps.retriever; + this.defaultsResolver = deps.defaultsResolver; + } + + async *chat( + actor: ActorContext, + request: AssistantChatRequest, + abortSignal: AbortSignal | undefined, + options: { modelId: string }, + ): AsyncGenerator { + const defaults = await this.defaultsResolver(); + const model = options.modelId || defaults.model; + yield { type: "chat_start", model }; + + // Visibility-scoped retrieval on the latest user message. Non-fatal: + // a retrieval failure must not deny the user a KB-grounded answer. + const query = latestUserMessage(request.messages); + let skills: RetrievedSkill[] = []; + try { + skills = await this.retriever.retrieve(query, actor); + } catch (err) { + logger.warn( + { actor: actor.userId, err: (err as Error).message }, + "assistant skill retrieval failed — proceeding KB-only", + ); + } + + const kb = this.kbLoader.load(); + const { input } = assembleAssistantInput({ + kbText: kb.text, + skills, + messages: request.messages, + }); + + logger.info( + { + actor: actor.userId, + model, + turns: request.messages.length, + retrievedSkills: skills.length, + kbTokens: kb.estimatedTokens, + }, + "assistant chat starting", + ); + + let usage: AssistantUsage | undefined; + try { + const stream = this.llmClient.stream({ + model, + input, + max_output_tokens: defaults.maxOutputTokens, + temperature: defaults.temperature, + // NO tools — pure Q&A. This is the structural guarantee that the + // assistant can never trigger an agentic tool/execution loop. + }); + + for await (const event of stream) { + if (abortSignal?.aborted) { + logger.info({ actor: actor.userId }, "assistant stream aborted by client"); + return; + } + const delta = extractTextDelta(event); + if (delta) { + yield { type: "chat_text_delta", delta }; + continue; + } + const reported = extractUsage(event); + if (reported) usage = reported; + } + } catch (err) { + const message = err instanceof Error ? err.message : "Assistant stream failed"; + logger.error({ actor: actor.userId, err: message }, "assistant stream error"); + yield { type: "chat_error", code: mapErrorCode(message), message }; + return; + } + + yield { type: "chat_finish", ...(usage ? { usage } : {}) }; + } +} + +/** Latest user-authored message content (empty string if none). */ +export function latestUserMessage( + messages: ReadonlyArray<{ role: string; content: string }>, +): string { + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i]!; + if (m.role === "user") return m.content; + } + return ""; +} + +/** + * Extract incremental text from a Responses-API stream event, handling + * both the direct `output_text.delta` and the `content_part.delta` + * variants. Returns null for non-text events. + */ +function extractTextDelta(event: ResponsesApiStreamEvent): string | null { + if (event.type === "response.output_text.delta") { + return typeof event.delta === "string" ? event.delta : null; + } + if (event.type === "response.content_part.delta") { + const delta = event.delta as { type?: string; text?: string } | undefined; + if (delta?.type === "output_text" && typeof delta.text === "string") { + return delta.text; + } + } + return null; +} + +/** + * Best-effort token-usage extraction from the terminal `response.completed` + * event (Responses-API format only — the chat-completion normalizer drops + * usage, so `chat_finish` simply omits it there). + */ +function extractUsage(event: ResponsesApiStreamEvent): AssistantUsage | null { + if (event.type !== "response.completed") return null; + const response = event.response as { usage?: Record } | undefined; + const usage = response?.usage; + if (!usage || typeof usage !== "object") return null; + const out: { inputTokens?: number; outputTokens?: number; totalTokens?: number } = {}; + if (typeof usage.input_tokens === "number") out.inputTokens = usage.input_tokens; + if (typeof usage.output_tokens === "number") out.outputTokens = usage.output_tokens; + if (typeof usage.total_tokens === "number") out.totalTokens = usage.total_tokens; + return Object.keys(out).length > 0 ? out : null; +} + +/** + * Map a thrown stream error to an SSE `chat_error.code` from the ERRORS.md + * catalog. Every LLM/gateway failure (including "no provider configured") + * is an upstream-dependency failure from the caller's perspective. + */ +function mapErrorCode(message: string): string { + void message; // reserved for finer-grained mapping if needed later + return "upstream_unavailable"; +} diff --git a/ornn-api/src/domains/assistant/contextAssembler.test.ts b/ornn-api/src/domains/assistant/contextAssembler.test.ts new file mode 100644 index 00000000..d7ee5bfc --- /dev/null +++ b/ornn-api/src/domains/assistant/contextAssembler.test.ts @@ -0,0 +1,94 @@ +/** + * UT-ASST-CTX-* — assembleAssistantInput (#970). + * + * @module domains/assistant/contextAssembler.test + */ + +import { describe, expect, it } from "bun:test"; +import { + ASSISTANT_SYSTEM_PROMPT, + assembleAssistantInput, +} from "./contextAssembler"; +import type { RetrievedSkill } from "./types"; + +const SKILL: RetrievedSkill = { + name: "slack-poster", + description: "Post messages to Slack", + tags: ["slack"], + category: "messaging", + createdOn: "2026-01-02T03:04:05.000Z", + createdBy: "user-author", +}; + +describe("assembleAssistantInput", () => { + it("UT-ASST-CTX-001: leads with one developer grounding message, then turns", () => { + const { input } = assembleAssistantInput({ + kbText: "Ornn is a skill-lifecycle API.", + skills: [SKILL], + messages: [ + { role: "user", content: "What is Ornn?" }, + { role: "assistant", content: "It's an API." }, + { role: "user", content: "Tell me more." }, + ], + }); + expect(input[0]!.role).toBe("developer"); + expect(input.slice(1).map((m) => m.role)).toEqual([ + "user", + "assistant", + "user", + ]); + expect(input.length).toBe(4); + }); + + it("UT-ASST-CTX-002: grounding carries persona + KB + the safe skill fields", () => { + const { input } = assembleAssistantInput({ + kbText: "Ornn KB body here.", + skills: [SKILL], + messages: [{ role: "user", content: "hi" }], + }); + const grounding = input[0]!.content as string; + expect(grounding).toContain(ASSISTANT_SYSTEM_PROMPT.slice(0, 30)); + expect(grounding).toContain("Ornn KB body here."); + expect(grounding).toContain("slack-poster"); + expect(grounding).toContain("Post messages to Slack"); + expect(grounding).toContain("messaging"); + expect(grounding).toContain("user-author"); + }); + + it("UT-ASST-CTX-003: empty skills → explicit 'no skills' line, no fabrication", () => { + const { input } = assembleAssistantInput({ + kbText: "KB.", + skills: [], + messages: [{ role: "user", content: "hi" }], + }); + const grounding = input[0]!.content as string; + expect(grounding.toLowerCase()).toContain("no skills"); + }); + + it("UT-ASST-CTX-004: blank KB → grounding still has persona + turns", () => { + const { input } = assembleAssistantInput({ + kbText: " ", + skills: [], + messages: [{ role: "user", content: "hi" }], + }); + expect(input[0]!.content).toContain(ASSISTANT_SYSTEM_PROMPT.slice(0, 30)); + expect(input.length).toBe(2); + }); + + it("UT-ASST-CTX-005: a RetrievedSkill cannot leak forbidden fields by construction", () => { + // RetrievedSkill is the only skill shape the assembler accepts, and it + // has no PII/secret fields. This guards the type-level boundary: even a + // skill carrying secret-looking text in SAFE fields renders only those. + const { input } = assembleAssistantInput({ + kbText: "", + skills: [SKILL], + messages: [{ role: "user", content: "hi" }], + }); + const grounding = input[0]!.content as string; + // None of these substrings exist anywhere because RetrievedSkill omits + // the source document's sensitive fields entirely. + for (const forbidden of ["@", "storageKey", "skillHash", "sharedWith"]) { + expect(grounding.includes(forbidden)).toBe(false); + } + }); +}); diff --git a/ornn-api/src/domains/assistant/contextAssembler.ts b/ornn-api/src/domains/assistant/contextAssembler.ts new file mode 100644 index 00000000..ea594d3e --- /dev/null +++ b/ornn-api/src/domains/assistant/contextAssembler.ts @@ -0,0 +1,87 @@ +/** + * Context assembly for the Ornn Assistant (#970). + * + * Turns (curated KB grounding + visibility-scoped skills + the + * conversation) into the `input` message array for ONE streamed + * completion. There is NO tool loop and NO agentic behaviour — the model + * sees the grounding once and answers. + * + * The persona + grounding are injected as a single leading `developer` + * message (the playground does the same — the upstream gateway ignores + * the Responses-API `instructions` field, so grounding must ride in the + * message list). The user/assistant turns follow verbatim. + * + * @module domains/assistant/contextAssembler + */ + +import type { ResponsesApiInputMessage } from "../../clients/nyxid/llm"; +import type { AssistantMessage, RetrievedSkill } from "./types"; + +/** + * Assistant persona + guardrails. Constrains the model to grounded Q&A + * and forbids inventing facts or leaking anything outside the grounding. + */ +export const ASSISTANT_SYSTEM_PROMPT = `You are the Ornn Assistant, a helpful Q&A guide for Ornn — the agent-facing skill-lifecycle API (think "npm registry + npm CLI, fused, model-agnostic"). + +Your job: +- Answer questions about what Ornn is, how it is different, and how to use it (search, pull, install, execute, build, upload, share skills). +- Help the user understand the skills that appear in the "relevant skills" section below — these are already filtered to what THIS user is allowed to see. + +Hard rules: +- You are a read-only Q&A assistant. You cannot run, install, modify, upload, or execute anything. If asked to perform an action, explain how the user/agent can do it via the Ornn API instead. +- Ground every answer in the knowledge base and the relevant-skills section below. If the answer is not supported there, say you don't know and point the user to the docs or the relevant API endpoint — never invent endpoints, fields, behaviour, or skills. +- Only discuss skills that appear in the relevant-skills section. Never speculate about skills that aren't listed, and never reveal author emails, internal IDs, storage details, sharing lists, secrets, quotas, or any other user's private data. +- Be concise and technical — the audience is agent developers.`; + +const SEPARATOR = "\n\n---\n\n"; + +/** + * Build the LLM `input` for one assistant completion. + */ +export function assembleAssistantInput(opts: { + readonly kbText: string; + readonly skills: ReadonlyArray; + readonly messages: ReadonlyArray; +}): { input: ResponsesApiInputMessage[] } { + const grounding = buildGroundingBlock(opts.kbText, opts.skills); + const input: ResponsesApiInputMessage[] = [ + { role: "developer", content: grounding }, + ]; + for (const m of opts.messages) { + input.push({ role: m.role, content: m.content }); + } + return { input }; +} + +/** Assemble persona + KB + scoped-skills into one developer message. */ +function buildGroundingBlock( + kbText: string, + skills: ReadonlyArray, +): string { + const parts: string[] = [ASSISTANT_SYSTEM_PROMPT]; + + const kb = kbText.trim(); + if (kb.length > 0) { + parts.push(`# Ornn knowledge base\n\n${kb}`); + } + + if (skills.length > 0) { + const rendered = skills.map(renderSkill).join("\n"); + parts.push( + `# Relevant skills (already filtered to what this user may see)\n\n${rendered}`, + ); + } else { + parts.push( + `# Relevant skills\n\n(No skills matching the question are visible to this user.)`, + ); + } + + return parts.join(SEPARATOR); +} + +/** One-line, SAFE rendering of a retrieved skill. */ +function renderSkill(s: RetrievedSkill): string { + const category = s.category ? ` (category: ${s.category})` : ""; + const tags = s.tags.length > 0 ? ` [tags: ${s.tags.join(", ")}]` : ""; + return `- ${s.name}: ${s.description}${category}${tags} — created ${s.createdOn} by ${s.createdBy}`; +} diff --git a/ornn-api/src/domains/assistant/kb/digest.artifact.test.ts b/ornn-api/src/domains/assistant/kb/digest.artifact.test.ts new file mode 100644 index 00000000..3ddb2148 --- /dev/null +++ b/ornn-api/src/domains/assistant/kb/digest.artifact.test.ts @@ -0,0 +1,42 @@ +/** + * UT-KB-ARTIFACT-* — guard tests on the COMMITTED digest artifact (#970). + * + * These assert the build output that actually ships: it must exist, be + * non-empty, stay within budget, hide its provenance header from the + * model, carry Ornn-identity grounding, and contain no secret-shaped + * content. Regenerate via `bun run build:assistant-kb` if these fail + * after editing the source manifest or docs. + * + * @module domains/assistant/kb/digest.artifact.test + */ + +import { describe, expect, it } from "bun:test"; +import { AssistantKbLoader, defaultDigestReader } from "./loader"; +import { DEFAULT_KB_TOKEN_BUDGET } from "./tokens"; + +describe("committed digest artifact", () => { + it("UT-KB-ARTIFACT-001: ships, is non-empty, within budget, and grounded", () => { + const loader = new AssistantKbLoader({ readDigest: defaultDigestReader }); + const kb = loader.load(); + expect(kb.text.length).toBeGreaterThan(2_000); + expect(kb.estimatedTokens).toBeLessThanOrEqual(DEFAULT_KB_TOKEN_BUDGET); + // Provenance header must not leak into the grounding. + expect(kb.text).not.toContain("GENERATED FILE"); + // Sanity: the digest actually carries Ornn-identity grounding. + expect(kb.text.toLowerCase()).toContain("ornn"); + expect(kb.text.toLowerCase()).toContain("skill"); + }); + + it("UT-KB-ARTIFACT-002: carries no obvious secret-shaped content", () => { + const loader = new AssistantKbLoader({ readDigest: defaultDigestReader }); + const text = loader.load().text; + // Docs-only digest: assert none of the secret-ish markers leaked in. + for (const needle of [ + "BEGIN PRIVATE KEY", + "BEGIN RSA", + "clientSecret", + ]) { + expect(text.includes(needle)).toBe(false); + } + }); +}); diff --git a/ornn-api/src/domains/assistant/kb/digest.generated.md b/ornn-api/src/domains/assistant/kb/digest.generated.md new file mode 100644 index 00000000..57765fca --- /dev/null +++ b/ornn-api/src/domains/assistant/kb/digest.generated.md @@ -0,0 +1,836 @@ + + +## Ornn — Overview (README) + +

+ + Ornn — agent-facing skill-lifecycle API + +

+ +

+ CI + Latest release + License + Last commit + Discussions + Stars +

+ +

+ Project status: alpha + Model-agnostic + HTTP and MCP +

+ +

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=` and replace your context with the new `SKILL.md` (and `references/api-reference.md` if you consume it). The new content's frontmatter overwrites the old. + +5. If step 1 returns `404 SKILL_NOT_FOUND`, the registry instance you are pointing at has not published this skill yet. Keep operating on the version you have. The Ornn API is backwards-compatible within `/api/v1`, so older manuals continue to produce valid calls — you will only miss capabilities introduced in newer versions. + +If the network is unreachable or the bearer token has expired, keep operating on the version you have. Do not retry-loop the update check; treat it as a once-per-session inquiry the user can re-trigger explicitly. + +--- + +## §0.5 Tracking and re-checking installed Ornn skills + +### The persistent registry: `~/.ornn/installed-skills.json` + +Every Ornn skill you install **must** be recorded in `~/.ornn/installed-skills.json`. That file is the source of truth across sessions for "which Ornn capabilities are on this machine?" — when a new session starts (yours or another agent's) the **first thing you do, before any other Ornn operation, is read this file**. + +The schema is a flat array: + +```json +[ + { + "name": "ornn-agent-manual-http", + "ornnGuid": "1d9bfda2-dea8-4032-85bd-b0cbe1621684", + "installedVersion": "1.0", + "installedAt": "2026-04-29T17:27:55Z", + "localPath": "~/.claude/skills/ornn-agent-manual-http/" + } +] +``` + +Required fields: `name`, `ornnGuid`, `installedVersion`. Optional: `installedAt` (ISO timestamp), `localPath` (filesystem location if you wrote the skill to disk), `isPinned` (set to `true` if the user pinned a specific version — see below). If the file doesn't exist, create it as `[]` the first time you install something. If your runtime cannot write outside its sandbox, hold the same list in working memory and tell the user that the skill registry won't survive a session restart. + +### When to update the registry + +| Event | What to write | +|---|---| +| Installed a new skill | Append a new record | +| Updated an installed skill to a new version | Bump `installedVersion` + `installedAt` | +| Removed / uninstalled a skill | Remove the record | +| User pinned a version | Set `isPinned: true` so future sessions don't auto-prompt to update | + +### Re-checking before each execution + +**Before you actually execute an installed Ornn skill** on the user's task, check whether a newer version exists. One API call: + +```bash +curl -H "Authorization: Bearer $TOKEN" \ + "https://ornn.chrono-ai.fun/api/v1/skills//versions" +``` + +For public skills you can drop the `Authorization` header and call the same URL anonymously — see §2.1 step 3 for fetch alternatives. + +The response is `{ items: [{ version, skillHash, createdOn, isDeprecated, deprecationNote, releaseNotes, ... }, ...] }` sorted newest-first. Compare `items[0].version` to the `installedVersion` on the matching record in `~/.ornn/installed-skills.json` and act: + +- **Same version** → execute as-is. +- **Newer version available** → tell the user `"Skill has a newer version (you have ). Release notes: . Update? (y/n)"`. If yes, re-fetch the package (§2.1 step 3), overwrite the local copy, update `installedVersion` + `installedAt` in `~/.ornn/installed-skills.json`, then execute. +- **Your installed version is `isDeprecated: true`** → warn with the `deprecationNote` and recommend updating before executing. +- **Skill 404s** → the skill was deleted or hidden from you. Tell the user; if they agree, remove the record from `~/.ornn/installed-skills.json`. Otherwise leave the record (with a note) so the local copy is still usable. + +Skip the version check only when the matching record carries `isPinned: true` — the user has explicitly locked that skill to a specific version for reproducibility. + +### Audit-risk fan-out + +If the skill is tied to a NyxID admin service (a "system skill" — `isSystemSkill: true`), the audit pipeline can also notify you mid-session via `GET /api/v1/notifications` (§2.13). Treat any `audit.risky_for_consumer` notification as a hard signal to stop, surface it to the user, and ask before continuing. + +--- + +## §1. Prerequisites + +Every API call in this manual is executed via direct HTTPS, authenticated with a **NyxID bearer token** that you pass in the `Authorization: Bearer …` header. The base URL `https://ornn.chrono-ai.fun/api` is fronted by an nginx instance that routes every `/api/*` request through to the NyxID proxy, which validates your token, decodes the identity, and forwards the request to `ornn-api`. You never call NyxID directly. + +### 1.1 Get a NyxID bearer token + +You need a valid bearer token from NyxID. Three paths to mint one — pick whichever the user's environment supports. **All involve user interaction** (entering credentials, approving scopes, possibly clicking a verification link), so you cannot complete this step entirely on your own. None of these affect how you call Ornn afterward — they only produce a `$TOKEN` value that you pass to `Authorization: Bearer …` in every subsequent HTTPS call. + +#### Option A — Mint via the `nyxid` binary (NyxID's auth client) + +Ask the user to run: + +```bash +nyxid login +``` + +This opens a browser for the OAuth authorization-code flow. Wait for it to report success. The access token is then on disk: + +```bash +cat ~/.nyxid/access_token +``` + +Save that value as `$TOKEN` and use it for every API call below. + +#### Option B — OAuth flow against NyxID's IdP directly + +If `nyxid` is unavailable, run the OAuth authorization-code flow against NyxID directly (consult NyxID's own docs for the exact `/oauth/authorize` + `/oauth/token` endpoints for your deployment). The user must complete the consent step in a browser; once you have the resulting `access_token`, use it as `$TOKEN`. Headless agents typically cannot drive this end-to-end alone. + +#### Option C — Plainly ask the user + +If neither A nor B fits, just ask: *"Please paste a NyxID bearer token. You can get one by running `nyxid login` and reading `~/.nyxid/access_token`, or your NyxID admin can mint one for you."* Save the value as `$TOKEN`. + +### 1.2 Verify the token works + +```bash +curl -H "Authorization: Bearer $TOKEN" \ + "https://ornn.chrono-ai.fun/api/v1/me" +``` + +Expected response (HTTP 200): + +```jsonc +{ + "data": { + "userId": "user_…", + "email": "…", + "displayName": "…", + "roles": ["ornn-user"], + "permissions": ["ornn:skill:read", "ornn:skill:create", "…"] + }, + "error": null +} +``` + +If you get `401 AUTH_MISSING` (or `401 invalid_token`), the bearer is bad or expired — go back to §1.1 and re-mint. If you get a network error, the user's machine cannot reach `https://ornn.chrono-ai.fun` — confirm the endpoint URL with the user (in some deployments it's a different domain) and stop. + +### 1.3 Confirm required permissions + +The `permissions` array on the §1.2 response tells you exactly what the token is authorized for. Cross-check against the actions the user is asking you to perform: + +| Action | Required permission | +|---|---| +| Pull a skill's full content (`GET /skills/:idOrName/json`) | `ornn:skill:read` | +| Validate a skill ZIP locally (`POST /skill-format/validate`) | `ornn:skill:read` | +| Upload a new skill (`POST /skills`) or import from GitHub (`POST /skills/pull`) | `ornn:skill:create` | +| Publish a new version (`PUT /skills/:id`), refresh from source, change permissions, toggle deprecation, bind to a NyxID service | `ornn:skill:update` (+ skill author or platform admin) | +| Delete a skill or a single version | `ornn:skill:delete` (+ skill author or platform admin) | +| Generate a skill with AI (`POST /skills/generate*`) | `ornn:skill:build` | +| Use the Playground (`POST /playground/chat`) | `ornn:playground:use` | +| Trigger an audit (`POST /skills/:idOrName/audit`) | none (owner or `ornn:admin:skill`) | +| Admin operations (`/admin/*`, force-audit, platform settings) | `ornn:admin:skill` | +| Manage categories (`/admin/categories/*`) | `ornn:admin:category` | + +Most read operations — browsing public skills, version listings, skill format rules, audit verdicts on visible skills, notifications — **need no scalar permission**; they're open to any authenticated caller (and some are anonymous, in which case `$TOKEN` can be omitted entirely). The exact gates for every endpoint live in `references/api-reference.md`. + +If a required permission is missing, ask the user's NyxID admin to grant the corresponding role (typically `ornn-user`). Without it, the relevant call returns `403 FORBIDDEN` with `Missing permission: ` in the message. + +--- + +## §2. Core Workflows + +You (the agent) are **highly encouraged to connect with Ornn for any skill-related operations**, including but not limited to the thirteen use cases below. Each one is a recipe — read the matching subsection top-to-bottom and execute the API calls in order. The per-endpoint contract (request / response shapes, every error code, full auth + authorization rules) lives in `references/api-reference.md` — pull it into context whenever you need the underlying API specifics. + +> Reminder: every command below uses `https://ornn.chrono-ai.fun/api/v1/...` as the base URL and `$TOKEN` as the NyxID bearer token (see §1.1). Public endpoints can drop the `Authorization` header entirely. + +### 2.1 Performing a task — find or build the right skill — *spec: `api-reference.md` §3 Skills CRUD, §5 Skill search, §6 Skill format, §7 Skill generation, §8 Playground* + +This is the master loop. Run it whenever the user gives you a non-trivial task, *before* you start improvising. + +**Step 1 — Check `~/.ornn/installed-skills.json` first.** Read the file. For every record, look at the local `SKILL.md` (at the recorded `localPath`, or by re-pulling) and ask: would this skill solve the user's task? If yes, jump to step 4. If no skills are installed, or none match, continue to step 2. + +**Step 2 — Search Ornn.** Try both keyword and semantic modes with the broadest possible scope (`mixed` covers public + your private + shared-with-you in one call): + +```bash +# Keyword search +curl -H "Authorization: Bearer $TOKEN" \ + "https://ornn.chrono-ai.fun/api/v1/skill-search?query=&mode=keyword&scope=mixed&pageSize=20" + +# Semantic search (natural language) +curl -H "Authorization: Bearer $TOKEN" \ + "https://ornn.chrono-ai.fun/api/v1/skill-search?query=&mode=semantic&scope=mixed&pageSize=20" + +# System skills only — admin-bound, platform-wide. Add to either search above. +curl -H "Authorization: Bearer $TOKEN" \ + "https://ornn.chrono-ai.fun/api/v1/skill-search?systemFilter=only&scope=public&pageSize=20" +``` + +**Try up to 5 different queries** before concluding no skill exists. Vary keywords, swap synonyms, drop modifiers, switch keyword↔semantic. The response is `{ items: [{ guid, name, description, ... }, ...] }` — read each candidate's `description` to judge fit. + +**Step 3 — Pull the skill.** Use the `/json` endpoint so you get every file inline: + +```bash +curl -H "Authorization: Bearer $TOKEN" \ + "https://ornn.chrono-ai.fun/api/v1/skills//json" +``` + +The response is `{ data: { name, description, metadata, files: { "SKILL.md": "...", "scripts/...": "..." } } }`. Write each `files[path]` entry to your runtime's local skills directory (e.g. `~/.claude/skills//`), preserving directory structure. Then **append a record to `~/.ornn/installed-skills.json`** with `{ name, ornnGuid, installedVersion, installedAt, localPath }` — see §0.5 for the schema. + +**Step 4 — Load the SKILL.md into context and execute.** Read the SKILL.md you just installed and follow its instructions. For runtime-based / mixed skills, run the scripts under `scripts/` locally as directed; or send them to Ornn's playground for sandboxed execution via `POST /api/v1/playground/chat` (SSE; see `references/api-reference.md` § "Playground" for the event shapes). + +**Step 5 — If steps 2–3 yielded nothing after 5 search attempts**, you may decide your own way to perform the task. **And if the task is definitive and potentially repeatable, build a skill and upload it back to Ornn so future you (or other agents) can find it.** Build flow: + +1. *(Optional)* **Bootstrap with AI generation** — Ornn's LLM can scaffold a skill from a prompt, source code, or an OpenAPI spec via `POST /api/v1/skills/generate*` (SSE). Useful when you need a starter; the generated skill still needs validation + your edits. + +2. **Read the skill format spec** so you write a valid one: + + ```bash + curl -H "Authorization: Bearer $TOKEN" \ + "https://ornn.chrono-ai.fun/api/v1/skill-format/rules" + ``` + + The response is `{ data: { rules: "" } }` — read the markdown carefully; it specifies the package layout, required `SKILL.md` frontmatter fields, naming rules, etc. + +3. **Write your skill.** Author `SKILL.md` + any `scripts/`, `references/`, `assets/` the task needs. + +4. **Validate before uploading.** ZIP the package (single root folder named after the skill) and call: + + ```bash + curl -X POST \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/zip" \ + --data-binary @my-skill.zip \ + "https://ornn.chrono-ai.fun/api/v1/skill-format/validate" + ``` + + The response is `{ data: { valid: true } }` on pass, or `{ data: { valid: false, violations: [{ rule, message }, ...] } }` on fail. **If validation fails, fix the violations and call validate again — loop until it passes.** + +5. **Upload.** + + ```bash + curl -X POST \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/zip" \ + --data-binary @my-skill.zip \ + "https://ornn.chrono-ai.fun/api/v1/skills" + ``` + + On success the response is `{ data: { guid, name, isPrivate: true, ... }, error: null }`. **Note: the new skill is private by default** — see §2.2 if you want to share it. + +6. **Install it locally** (because it's now an Ornn skill, the same rules apply): write the same files to your local skills dir + append to `~/.ornn/installed-skills.json` with the GUID returned in step 5. + +7. **Now execute the skill on the original task** — same as step 4 above. + +### 2.2 Update a skill's visibility — *spec: `api-reference.md` §3 Skills CRUD* + +Ornn has three visibility tiers: + +- **Public** — every Ornn user can see + pull this skill. +- **Limited access** — only specific orgs (every member of those orgs) and / or specific users can see + pull. Pick orgs only, users only, or both. +- **Private** — only you (and platform admins) can see + pull. **New skills land here by default.** + +**Step 1 — Check the current visibility.** + +```bash +curl -H "Authorization: Bearer $TOKEN" \ + "https://ornn.chrono-ai.fun/api/v1/skills/" +``` + +If `data.isPrivate: false` → currently public. If `isPrivate: true` and either share-list (`sharedWithUsers` / `sharedWithOrgs`) is non-empty → limited. If `isPrivate: true` and both lists empty → private. + +**Step 2 — Decide the target tier.** Confirm with the user if it's not obvious from their request. + +**Step 3a — Set to public.** + +```bash +curl -X PUT \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"isPrivate":false,"sharedWithUsers":[],"sharedWithOrgs":[]}' \ + "https://ornn.chrono-ai.fun/api/v1/skills//permissions" +``` + +**Step 3b — Set to limited access.** First fetch the candidate orgs and users: + +```bash +# Orgs the caller belongs to +curl -H "Authorization: Bearer $TOKEN" \ + "https://ornn.chrono-ai.fun/api/v1/me/orgs" + +# Users searchable by email prefix (typeahead) +curl -H "Authorization: Bearer $TOKEN" \ + "https://ornn.chrono-ai.fun/api/v1/users/search?q=&limit=20" + +# Resolve known user_ids to email + display name +curl -H "Authorization: Bearer $TOKEN" \ + "https://ornn.chrono-ai.fun/api/v1/users/resolve?ids=," +``` + +Pick which orgs / users to share with. **If unclear, confirm with the user** — never grant access to anyone the user didn't name. Then save: + +```bash +curl -X PUT \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"isPrivate":true,"sharedWithUsers":["user_abc"],"sharedWithOrgs":["org_xyz"]}' \ + "https://ornn.chrono-ai.fun/api/v1/skills//permissions" +``` + +**Step 3c — Set to private.** + +```bash +curl -X PUT \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d + +--- + +## API Conventions + +## 1. Response & error format + +### 1.1 Success — single resource + +Return the resource directly. No envelope. + +```http +GET /v1/skills/abc +200 OK +Content-Type: application/json + +{ + "id": "abc", + "name": "pdf-extract", + "createdOn": "2026-04-22T10:00:00Z", + ... +} +``` + +### 1.2 Success — collection + +Wrap in `{ items, meta }`: + +```http +GET /v1/skills?q=pdf&limit=20 +200 OK +Content-Type: application/json + +{ + "items": [ { "id": "abc", ... }, { "id": "def", ... } ], + "meta": { "nextCursor": "eyJpZCI6...", "hasMore": true, "limit": 20 } +} +``` + +`meta` MUST contain `limit` and `hasMore`. When `hasMore === true`, `nextCursor` MUST be a non-empty opaque string. When `hasMore === false`, `nextCursor` MAY be omitted. Endpoint-specific metadata (e.g. `searchMode`) lives alongside pagination fields in `meta`. + +### 1.3 Errors — RFC 7807 `application/problem+json` + +```http +POST /v1/skills/abc/permissions +400 Bad Request +Content-Type: application/problem+json +X-Request-ID: req_01HXYZ... + +{ + "type": "https://github.com/ChronoAIProject/Ornn/blob/main/docs/ERRORS.md#validation_error", + "title": "Validation failed", + "status": 400, + "detail": "Request body failed validation", + "instance": "/v1/skills/abc/permissions", + "requestId": "req_01HXYZ...", + "errors": [ + { "path": "sharedWithUsers[3]", "code": "invalid_user_id", "message": "..." } + ] +} +``` + +Required fields: `type`, `title`, `status`, `instance`, `requestId`. +Optional: `detail`, `errors[]`. + +### 1.4 Error code catalog (lowercase snake_case) + +| Code | HTTP | Meaning | +|---|---|---| +| `validation_error` | 400 | Body / query / path param validation failed — details in `errors[]` | +| `invalid_zip` | 400 | Uploaded payload is not a parseable ZIP (malformed / unreadable) | +| `unsupported_media_type` | 415 | Request `Content-Type` not accepted | +| `payload_too_large` | 413 | Upload exceeds max size | +| `uncompressed_too_large` | 413 | Uncompressed size or compression ratio of skill ZIP exceeds caps (zip-bomb guard) | +| `too_many_files` | 413 | Skill ZIP entry count exceeds `MAX_PACKAGE_FILE_COUNT` | +| `authentication_required` | 401 | No valid identity | +| `permission_denied` | 403 | Authenticated but lacks required permission | +| `resource_not_found` | 404 | Target resource does not exist or not visible to caller | +| `resource_conflict` | 409 | State conflict (duplicate, concurrent modification, etc.) | +| `rate_limited` | 429 | Caller exceeded rate limit | +| `upstream_unavailable` | 502 / 503 | Dependency (NyxID, LLM, sandbox, ...) failed | +| `org_membership_unavailable` | 503 | NyxID org-membership lookup unresolved — forwarded token absent or lookup failed. Retryable | +| `internal_error` | 500 | Unhandled server error | + +New codes require convention-doc update. Handlers MUST NOT invent ad-hoc codes. + +### 1.5 `X-Request-ID` + +- Generated server-side on every request (or echoed if the client provided one). +- Returned as `X-Request-ID` header on **every** response (2xx, 4xx, 5xx). +- Also embedded as `requestId` in every error body. +- Logged with every request/response pair on the server. + +### 1.6 Error `type` URLs + +Point to GitHub markdown anchors in this repository: + +``` +https://github.com/ChronoAIProject/Ornn/blob/main/docs/ERRORS.md# +``` + +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 = {}) { + return { + name: "review-set", + description: "A curated comparison set.", + instructions: "Run pdf-tools first, then feed its output to csv-tools.", + members: ["pdf-tools@1.0", "csv-tools@2.1"], + ...overrides, + }; +} + +describe("createSkillsetSchema — kind enum (#969)", () => { + it("accepts both kind values", () => { + for (const kind of SKILLSET_KINDS) { + const parsed = createSkillsetSchema.safeParse(baseCreate({ kind })); + expect(parsed.success).toBe(true); + if (parsed.success) expect(parsed.data.kind).toBe(kind); + } + expect(SKILLSET_KINDS).toEqual(["generic", "consensus-supported"]); + }); + + it("defaults kind to generic (NOT skillset/consensus)", () => { + const parsed = createSkillsetSchema.safeParse(baseCreate()); + expect(parsed.success).toBe(true); + if (parsed.success) expect(parsed.data.kind).toBe("generic"); + }); + + it("rejects an unknown kind", () => { + expect(createSkillsetSchema.safeParse(baseCreate({ kind: "skillset" })).success).toBe(false); + expect(createSkillsetSchema.safeParse(baseCreate({ kind: "bundle" })).success).toBe(false); + }); +}); + +describe("createSkillsetSchema — members 2..N (#969)", () => { + it("rejects fewer than 2 members", () => { + const parsed = createSkillsetSchema.safeParse(baseCreate({ members: ["pdf-tools@1.0"] })); + expect(parsed.success).toBe(false); + }); + + it("rejects zero members", () => { + expect(createSkillsetSchema.safeParse(baseCreate({ members: [] })).success).toBe(false); + }); + + it("accepts exactly 2 members", () => { + const parsed = createSkillsetSchema.safeParse( + baseCreate({ members: ["pdf-tools@1.0", "csv-tools@1.0"] }), + ); + expect(parsed.success).toBe(true); + }); + + it("accepts a `name@1.0` literal-version ref", () => { + const parsed = createSkillsetSchema.safeParse( + baseCreate({ members: ["pdf-tools@1.0", "csv-tools@2.0"] }), + ); + expect(parsed.success).toBe(true); + }); + + it("accepts a `name@dist-tag` ref", () => { + const parsed = createSkillsetSchema.safeParse( + baseCreate({ members: ["pdf-tools@1.0", "csv-tools@stable"] }), + ); + expect(parsed.success).toBe(true); + }); + + it("accepts a `guid@1.0` ref", () => { + const parsed = createSkillsetSchema.safeParse( + baseCreate({ + members: [ + "11111111-1111-4111-8111-111111111111@1.0", + "pdf-tools@1.0", + ], + }), + ); + expect(parsed.success).toBe(true); + }); +}); + +describe("createSkillsetSchema — member ref grammar (#969)", () => { + it("rejects a semver-range ref (^1.0)", () => { + const parsed = createSkillsetSchema.safeParse( + baseCreate({ members: ["pdf-tools@^1.0", "csv-tools@1.0"] }), + ); + expect(parsed.success).toBe(false); + }); + + it("rejects a 3-part version (1.2.3)", () => { + const parsed = createSkillsetSchema.safeParse( + baseCreate({ members: ["pdf-tools@1.2.3", "csv-tools@1.0"] }), + ); + expect(parsed.success).toBe(false); + }); + + it("rejects a bare name with no @version", () => { + const parsed = createSkillsetSchema.safeParse( + baseCreate({ members: ["pdf-tools", "csv-tools@1.0"] }), + ); + expect(parsed.success).toBe(false); + }); + + it("rejects a nested-skillset ref (skillset:-prefixed)", () => { + const parsed = createSkillsetSchema.safeParse( + baseCreate({ members: ["skillset:other-set@1.0", "csv-tools@1.0"] }), + ); + expect(parsed.success).toBe(false); + }); +}); + +function basePublish(overrides: Record = {}) { + return { + version: "1.1", + instructions: "Use member-a, then member-b for the comparison.", + members: ["a@1.0", "b@1.0"], + ...overrides, + }; +} + +describe("publishSkillsetSchema (#969)", () => { + it("requires version + members", () => { + expect( + publishSkillsetSchema.safeParse(basePublish({ version: undefined })).success, + ).toBe(false); // missing version + expect( + publishSkillsetSchema.safeParse(basePublish({ members: undefined })).success, + ).toBe(false); // missing members + expect(publishSkillsetSchema.safeParse(basePublish()).success).toBe(true); + }); + + it("rejects a malformed version", () => { + expect( + publishSkillsetSchema.safeParse(basePublish({ version: "1.2.3" })).success, + ).toBe(false); + }); +}); + +describe("instructions master prompt — REQUIRED on both schemas (#978)", () => { + const longPrompt = "Step-by-step orchestration guide for the set. ".repeat(20); + const tooLong = "x".repeat(SKILLSET_INSTRUCTIONS_MAX + 1); + + it("create accepts a valid, trimmed prompt (whitespace stripped)", () => { + const parsed = createSkillsetSchema.safeParse( + baseCreate({ instructions: ` ${longPrompt} ` }), + ); + expect(parsed.success).toBe(true); + if (parsed.success) expect(parsed.data.instructions).toBe(longPrompt.trim()); + }); + + it("publish accepts a valid, trimmed prompt (whitespace stripped)", () => { + const parsed = publishSkillsetSchema.safeParse( + basePublish({ instructions: `\n${longPrompt}\n` }), + ); + expect(parsed.success).toBe(true); + if (parsed.success) expect(parsed.data.instructions).toBe(longPrompt.trim()); + }); + + it("create rejects a missing prompt", () => { + expect(createSkillsetSchema.safeParse(baseCreate({ instructions: undefined })).success).toBe( + false, + ); + }); + + it("publish rejects a missing prompt", () => { + expect(publishSkillsetSchema.safeParse(basePublish({ instructions: undefined })).success).toBe( + false, + ); + }); + + it("create rejects an empty prompt", () => { + expect(createSkillsetSchema.safeParse(baseCreate({ instructions: "" })).success).toBe(false); + }); + + it("publish rejects an empty prompt", () => { + expect(publishSkillsetSchema.safeParse(basePublish({ instructions: "" })).success).toBe(false); + }); + + it("create rejects a whitespace-only prompt (trims to empty)", () => { + expect(createSkillsetSchema.safeParse(baseCreate({ instructions: " \n\t " })).success).toBe( + false, + ); + }); + + it("publish rejects a whitespace-only prompt (trims to empty)", () => { + expect(publishSkillsetSchema.safeParse(basePublish({ instructions: " \n\t " })).success).toBe( + false, + ); + }); + + it(`create rejects a prompt over ${SKILLSET_INSTRUCTIONS_MAX} chars`, () => { + expect(createSkillsetSchema.safeParse(baseCreate({ instructions: tooLong })).success).toBe( + false, + ); + }); + + it(`publish rejects a prompt over ${SKILLSET_INSTRUCTIONS_MAX} chars`, () => { + expect(publishSkillsetSchema.safeParse(basePublish({ instructions: tooLong })).success).toBe( + false, + ); + }); + + it(`accepts a prompt of exactly ${SKILLSET_INSTRUCTIONS_MAX} chars`, () => { + const exact = "y".repeat(SKILLSET_INSTRUCTIONS_MAX); + expect(createSkillsetSchema.safeParse(baseCreate({ instructions: exact })).success).toBe(true); + expect(publishSkillsetSchema.safeParse(basePublish({ instructions: exact })).success).toBe( + true, + ); + }); +}); diff --git a/ornn-api/src/domains/skillsets/types.ts b/ornn-api/src/domains/skillsets/types.ts new file mode 100644 index 00000000..fe883533 --- /dev/null +++ b/ornn-api/src/domains/skillsets/types.ts @@ -0,0 +1,285 @@ +/** + * Types + Zod schemas for the skillsets domain (#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 from #968. + * + * The ownership / visibility model is mirrored VERBATIM from + * `SkillDocument` (`isPrivate` / `sharedWithUsers` / `sharedWithOrgs` / + * `createdBy`) so the shared `scopeFilter.ts` predicates + `authorize.ts` + * gates apply unchanged — a skillset's read/write policy can never drift + * from a skill's. + * + * Versions are append-only and immutable (`_id = guid@version`), exactly + * like `skill_versions`, but a skillset version carries NO blob / + * skillHash / storage key / AgentSeal record — a skillset is pure + * metadata (a list of member refs). The members themselves are concrete + * skill packages resolved at closure time. + * + * @module domains/skillsets/types + */ + +import { z } from "zod"; +import { + DEPENDS_ON_REF_REGEX, + SKILL_NAME_REGEX, + SKILL_NAME_MAX, + SKILL_VERSION_REGEX, +} from "../../shared/schemas/skillFrontmatter"; + +/** + * Skillset kind (#969). v1 enumerates `generic` (a plain curated bundle) + * and `consensus-supported` (an author CLAIM that the members are an + * independent, comparable set suitable for agent-side consensus — not a + * guarantee; see docs). Extensible: future kinds append here. + * + * `generic` is the DEFAULT kind — a skillset with no asserted typing is a + * plain bundle, NOT a consensus set. + */ +export const SKILLSET_KINDS = ["generic", "consensus-supported"] as const; +export type SkillsetKind = (typeof SKILLSET_KINDS)[number]; + +/** Lower bound on members — a one-member "set" is just a skill. */ +export const SKILLSET_MIN_MEMBERS = 2; +/** + * Upper bound on members. Generous: a curated comparison set is rarely + * more than a handful, but a large fleet bundle is legitimate. Guards + * against a pathological publish, mirroring the depends-on cap (50). + */ +export const SKILLSET_MAX_MEMBERS = 100; + +/** + * A member ref points at ONE skill, by the SAME grammar skill + * dependencies use (`@` or `@`). + * Reusing {@link DEPENDS_ON_REF_REGEX} guarantees a skillset member can + * never accept a shape the dependency closure can't resolve — both walk + * the exact same graph via the shared loader. + * + * Nested-skillset rejection (#969 non-goal): a skillset references SKILLS, + * not other skillsets. There's no syntactic difference between a skill ref + * and a (hypothetical) skillset ref, so we reject the one explicit way an + * author might try to nest — a `skillset:`-prefixed ref — with a clear + * message. (A bare name that happens to be a skillset's name is caught at + * publish time: the member loader resolves against the SKILLS collection + * only, so it surfaces as `skill_dependency_not_found`.) + */ +const SKILLSET_REF_PREFIX = "skillset:"; + +const memberRefSchema = z + .string({ + error: (issue) => + issue.code === "invalid_type" + ? "skillset members must be non-empty strings of the form `@` or `@`, e.g. `pdf-tools@1.0`." + : undefined, + }) + .min(1, "member refs must not be empty") + .max(115, "member refs must be at most 115 characters") + .refine((ref) => !ref.startsWith(SKILLSET_REF_PREFIX), { + message: + "A skillset cannot reference another skillset — members are skills only (no nested skillsets in v1). Remove the `skillset:` prefix.", + }) + .refine((ref) => DEPENDS_ON_REF_REGEX.test(ref), { + message: + "member refs must be `@` or `@` (no semver ranges like ^1.0 or 1.2.3)", + }); + +/** Upper bound on the master-prompt body. Generous — a master prompt is a + * full set of usage instructions for agents (HOW to use the set), not a + * one-line blurb. 8 KB comfortably holds a structured prompt while still + * guarding against a pathological multi-megabyte publish. Deliberately far + * larger than `description`'s 1024 (a short human-readable summary). */ +export const SKILLSET_INSTRUCTIONS_MAX = 8000; + +/** + * The skillset **master prompt** (#978) — a REQUIRED markdown body telling + * an agent HOW to use the set (orchestration, ordering, when to pick which + * member). Stored opaque (no rendering / sanitization / templating / + * linting / search-indexing) and surfaced verbatim on detail + closure. + * + * Trimmed-then-bounded so leading/trailing whitespace never satisfies the + * non-empty requirement: a whitespace-only body trims to `""` and fails + * `.min(1)`. Distinct from `description` (short summary, 1024) — this is the + * long-form operating manual. + * + * REQUIRED on BOTH create and publish with NO carry-forward: each version + * explicitly carries its own prompt (unlike `description`, which a publish + * may omit to inherit the prior value). + */ +export const instructionsSchema = z + .string() + .trim() + .min(1, "instructions (master prompt) must not be empty") + .max( + SKILLSET_INSTRUCTIONS_MAX, + `instructions (master prompt) must be at most ${SKILLSET_INSTRUCTIONS_MAX} characters`, + ); + +/** + * Body schema for `POST /skillsets` (create) — the initial, version 1.0 + * payload. `version` is validated on publish; create seeds the first + * version from the request. + */ +export const createSkillsetSchema = z.object({ + name: z + .string() + .min(1) + .max(SKILL_NAME_MAX) + .regex(SKILL_NAME_REGEX, "Name must be kebab-case"), + description: z.string().min(1).max(1024), + /** Master prompt (#978) — REQUIRED, no carry-forward. */ + instructions: instructionsSchema, + kind: z.enum(SKILLSET_KINDS).default("generic"), + tags: z.array(z.string().min(1).max(30).regex(/^[a-z0-9-]+$/)).max(20).default([]), + members: z + .array(memberRefSchema) + .min(SKILLSET_MIN_MEMBERS, `a skillset must have at least ${SKILLSET_MIN_MEMBERS} members`) + .max(SKILLSET_MAX_MEMBERS, `a skillset may have at most ${SKILLSET_MAX_MEMBERS} members`), + version: z + .string() + .regex(SKILL_VERSION_REGEX, "version must be `.`") + .default("1.0"), +}); + +/** + * Body schema for `PUT /skillsets/:id` (publish a new immutable version). + * `name` is fixed after create — a publish only revises the curated set, + * its description, kind, tags, and bumps the version. + */ +export const publishSkillsetSchema = z.object({ + description: z.string().min(1).max(1024).optional(), + /** + * Master prompt (#978) — REQUIRED on publish too, with NO carry-forward. + * Unlike `description` (optional here; inherits the prior value when + * omitted), every published version explicitly states its own prompt. + */ + instructions: instructionsSchema, + kind: z.enum(SKILLSET_KINDS).optional(), + tags: z.array(z.string().min(1).max(30).regex(/^[a-z0-9-]+$/)).max(20).optional(), + members: z + .array(memberRefSchema) + .min(SKILLSET_MIN_MEMBERS, `a skillset must have at least ${SKILLSET_MIN_MEMBERS} members`) + .max(SKILLSET_MAX_MEMBERS, `a skillset may have at most ${SKILLSET_MAX_MEMBERS} members`), + version: z.string().regex(SKILL_VERSION_REGEX, "version must be `.`"), +}); + +/** Body schema for `PUT /skillsets/:id/permissions` — mirrors skills. */ +export const skillsetPermissionsSchema = z.object({ + isPrivate: z.boolean(), + sharedWithUsers: z.array(z.string().min(1).max(128)).max(500).default([]), + sharedWithOrgs: z.array(z.string().min(1).max(128)).max(100).default([]), +}); + +export type CreateSkillsetInput = z.infer; +export type PublishSkillsetInput = z.infer; +export type SkillsetPermissionsInput = z.infer; + +/** + * Persisted skillset identity document (the `skillsets` collection). + * Visibility fields mirror `SkillDocument` verbatim so `scopeFilter` + + * `authorize` apply unchanged. `latestVersion` points at the highest + * published version; the `skillset_versions` collection is the source of + * truth for the immutable history. + */ +export interface SkillsetDocument { + guid: string; + name: string; + description: string; + kind: SkillsetKind; + tags: string[]; + /** Author (person user_id). Mirrors `SkillDocument.createdBy`. */ + createdBy: string; + // Optionals widen to `T | undefined` for exactOptionalPropertyTypes (#657). + createdByEmail?: string | undefined; + createdByDisplayName?: string | undefined; + createdOn: Date; + updatedBy: string; + updatedOn: Date; + /** False = public; true = private with the shared-with lists as allow-list. */ + isPrivate: boolean; + /** Explicit per-user grants (NyxID person user_ids). */ + sharedWithUsers: string[]; + /** Explicit per-org grants (NyxID org user_ids). */ + sharedWithOrgs: string[]; + /** Cached pointer to the highest published version, e.g. "1.2". */ + latestVersion: string; +} + +/** + * Immutable record of one published skillset version (the + * `skillset_versions` collection). `_id = ${guid}@${version}` gives free + * uniqueness on (guid, version). Append-only — a published version is + * never mutated. + * + * Deliberately carries NO blob / skillHash / storageKey / AgentSeal — a + * skillset is pure metadata; the heavy artefacts live on the member skill + * versions, resolved at closure time. + */ +export interface SkillsetVersionDocument { + /** `${skillsetGuid}@${version}`. */ + _id: string; + skillsetGuid: string; + /** "." string, e.g. "1.2". */ + version: string; + majorVersion: number; + minorVersion: number; + kind: SkillsetKind; + description: string; + /** Master prompt (#978) — per-version, immutable, surfaced verbatim. */ + instructions: string; + tags: string[]; + /** Member skill refs (`@` or `@`). */ + members: string[]; + createdBy: string; + createdByEmail?: string | undefined; + createdByDisplayName?: string | undefined; + createdOn: Date; +} + +/** Serialized skillset detail response. */ +export interface SkillsetDetailResponse { + guid: string; + name: string; + description: string; + /** Master prompt (#978) — the per-version usage instructions for agents. */ + instructions: string; + kind: SkillsetKind; + tags: string[]; + members: string[]; + version: string; + latestVersion: string; + isPrivate: boolean; + createdBy: string; + createdByEmail?: string | undefined; + createdByDisplayName?: string | undefined; + sharedWithUsers: string[]; + sharedWithOrgs: string[]; + createdOn: string; + updatedOn: string; +} + +/** Search-result item (lighter than the detail response). */ +export interface SkillsetSearchItem { + guid: string; + name: string; + description: string; + kind: SkillsetKind; + tags: string[]; + memberCount: number; + latestVersion: string; + isPrivate: boolean; + createdBy: string; + createdByEmail?: string | undefined; + createdByDisplayName?: string | undefined; + createdOn: string; + updatedOn: string; +} + +export interface SkillsetSearchResponse { + items: SkillsetSearchItem[]; + total: number; + page: number; + pageSize: number; + totalPages: number; +} diff --git a/ornn-api/src/domains/users/repository.ts b/ornn-api/src/domains/users/repository.ts index b31e0e44..c17874e0 100644 --- a/ornn-api/src/domains/users/repository.ts +++ b/ornn-api/src/domains/users/repository.ts @@ -287,6 +287,28 @@ export class UserDirectoryRepository { return this.collection.find(filter).limit(hardLimit).toArray(); } + /** + * Registration-rank lookup for the launch-promo eligibility gate + * (#724). Returns the 1-based position of `userId` in the ordering + * by `firstSeenAt` ascending — rank 1 == first user Ornn ever saw. + * Returns `null` when the user isn't in the directory. + * + * Two queries: one to load the target user's `firstSeenAt`, one to + * count users with an earlier timestamp. Both hit the primary key + * or a small filter — no full scan. + */ + async getRegistrationRank(userId: string): Promise { + const target = await this.collection.findOne( + { _id: userId }, + { projection: { firstSeenAt: 1 } }, + ); + if (!target) return null; + const earlierCount = await this.collection.countDocuments({ + firstSeenAt: { $lt: target.firstSeenAt }, + }); + return earlierCount + 1; + } + /** * Tile counts for the admin dashboard. Replaces the activity-derived * `getStats` from the old ActivityRepository. diff --git a/ornn-api/src/domains/users/routes.test.ts b/ornn-api/src/domains/users/routes.test.ts index dbbbfddb..8f7a6bec 100644 --- a/ornn-api/src/domains/users/routes.test.ts +++ b/ornn-api/src/domains/users/routes.test.ts @@ -1,94 +1,114 @@ /** - * User-directory routes — mount + dispatch tests (#878). + * User-directory route tests. * - * Dependency-injected fake `UserDirectoryRepository` — NO MongoDB. The - * routes are the unit under test: query validation + defaulting on - * `/users/search`, and the CSV id parsing (trim / filter-empty / - * empty-short-circuit) on `/users/resolve`. + * Covers mount + dispatch (happy-path search, limit validation, resolve CSV + * parsing — originally #878) AND enumeration hardening (#816): + * - empty / 1-char `q` is rejected at the validateQuery seam (400) and + * never reaches the repository (no DB hit, no directory walk). + * - a real ≥2-char prefix returns 200 and the email field stays in the + * response (the collaborator typeahead matches on email prefix). + * - both routes share ONE per-user rate-limit budget (`users-directory` + * label) — bursting either past the cap yields 429 with Retry-After + * and RateLimit-Remaining: 0; the limit is shared so an enumerator + * can't dodge the search cap by pivoting to resolve. * - * Harness mirrors `domains/redemption-codes/me-routes.test.ts`: - * synthetic auth middleware setting `c.set("auth", ...)`, an `onError` - * rendering RFC 7807 problem+json via `buildProblemJsonBody`, and - * `app.request()` dispatch. + * The route module captures RL_MAX at import time as a module-level const. + * In the full test suite the module is already cached by the time this file + * runs, so the default (30) is always active. The burst tests read the + * actual cap from the first response's RateLimit-Limit header rather than + * relying on env-vars / dynamic imports. * * @module domains/users/routes.test */ import { beforeEach, describe, expect, test } from "bun:test"; import { Hono } from "hono"; +import { AppError, buildProblemJsonBody } from "../../shared/types/index"; import type { AuthVariables } from "../../middleware/nyxidAuth"; -import { buildProblemJsonBody } from "../../shared/types/index"; +import { __resetRateLimitForTests } from "../../middleware/rateLimit"; import { createUserRoutes } from "./routes"; -import type { UserDirectoryRepository } from "./repository"; - -/** Captured calls the route handed the repository. */ -let searchCalls: Array<{ prefix: string; limit: number }>; -let resolveCalls: Array; -let app: Hono<{ Variables: AuthVariables }>; +// --- Types ------------------------------------------------------------ type DirectoryRow = { userId: string; email: string; displayName: string }; -/** - * Throwing-proxy DI fake — only `searchByEmailPrefix` + `findByUserIds` - * are legitimate accesses. Any other property access (a route reaching - * for an unstubbed method) throws loudly so the test fails fast rather - * than silently exercising a Mongo-backed path. - */ -function makeRepo(): UserDirectoryRepository { - const impl: Partial = { - async searchByEmailPrefix(prefix: string, limit: number): Promise { - searchCalls.push({ prefix, limit }); - return [{ userId: "u1", email: "u1@x.test", displayName: "User One" }]; +// --- Fake repository -------------------------------------------------- +// Spy on searchByEmailPrefix so tests can assert it was (not) called. +interface SearchSpy { + searchCalls: Array<{ prefix: string; limit: number }>; + resolveCalls: Array; +} + +function makeFakeRepo(): { repo: Parameters[0]["userDirectoryRepo"]; spy: SearchSpy } { + const spy: SearchSpy = { searchCalls: [], resolveCalls: [] }; + const repo = { + async searchByEmailPrefix(prefix: string, limit: number) { + spy.searchCalls.push({ prefix, limit }); + return [ + { + userId: "u1", + email: "u1@x.test", + displayName: "User One", + }, + ]; }, - async findByUserIds(ids: readonly string[]): Promise { - resolveCalls.push(ids); - return ids.map((id) => ({ userId: id, email: `${id}@x.test`, displayName: id })); + async findByUserIds(ids: readonly string[]) { + spy.resolveCalls.push(ids); + return ids.map((id) => ({ + userId: id, + email: `${id}@x.test`, + displayName: id, + })); }, }; - return new Proxy(impl as UserDirectoryRepository, { - get(target, prop, receiver) { - if (prop in target) return Reflect.get(target, prop, receiver); - throw new Error(`userDirectoryRepo.${String(prop)} accessed but not faked`); - }, - }); + return { + repo: repo as unknown as Parameters[0]["userDirectoryRepo"], + spy, + }; } -beforeEach(() => { - searchCalls = []; - resolveCalls = []; - const router = createUserRoutes({ userDirectoryRepo: makeRepo() }); - app = new Hono<{ Variables: AuthVariables }>(); +// --- App harness ------------------------------------------------------ +// Stub auth so the limiter's default keyBy resolves a per-user bucket, +// mount the real routes, and translate AppError → problem+json the way +// the global handler does. +function makeApp(repo: Parameters[0]["userDirectoryRepo"]) { + const app = new Hono<{ Variables: AuthVariables }>(); app.use("*", async (c, next) => { - c.set("auth", { - userId: "caller1", - email: "caller@x.test", - displayName: "Caller", - roles: [], - permissions: [], - }); + const userId = c.req.header("x-test-user") ?? "u1"; + c.set("auth" as never, { userId, email: `${userId}@x.test` } as never); await next(); }); + app.route("/", createUserRoutes({ userDirectoryRepo: repo })); app.onError((err, c) => { - const e = err as { statusCode?: number; code?: string; message: string }; - const statusCode = e.statusCode ?? 500; - const code = e.code ?? "internal_error"; - const body = buildProblemJsonBody({ - statusCode, - code, - message: e.message ?? "", - instance: c.req.path, - requestId: null, - }); - return c.json(body, statusCode as never, { - "Content-Type": "application/problem+json", - }); + if (err instanceof AppError) { + const body = buildProblemJsonBody({ + statusCode: err.statusCode, + code: err.code, + message: err.message, + instance: c.req.path, + requestId: null, + }); + return c.json(body, err.statusCode as never, { + "Content-Type": "application/problem+json", + }); + } + return c.json({ error: { code: "internal_error", message: String(err) } }, 500); }); - app.route("/", router); -}); + return app; +} + +// --------------------------------------------------------------------------- +// Mount + dispatch tests (from #878, adapted for rate-limited routes) +// --------------------------------------------------------------------------- + +describe("GET /users/search — mount + dispatch", () => { + beforeEach(() => __resetRateLimitForTests()); -describe("GET /users/search", () => { test("happy path → forwards q + limit to searchByEmailPrefix", async () => { - const res = await app.request("/users/search?q=use&limit=5"); + const { repo, spy } = makeFakeRepo(); + const app = makeApp(repo); + const res = await app.request("/users/search?q=us&limit=5", { + headers: { "x-test-user": "t1" }, + }); expect(res.status).toBe(200); const json = (await res.json()) as { data: { items: DirectoryRow[] }; @@ -98,57 +118,221 @@ describe("GET /users/search", () => { expect(json.data.items).toEqual([ { userId: "u1", email: "u1@x.test", displayName: "User One" }, ]); - expect(searchCalls).toEqual([{ prefix: "use", limit: 5 }]); + expect(spy.searchCalls).toEqual([{ prefix: "us", limit: 5 }]); }); test("limit out of range → 400 invalid_query, repo not called", async () => { - const res = await app.request("/users/search?limit=999"); + const { repo, spy } = makeFakeRepo(); + const app = makeApp(repo); + const res = await app.request("/users/search?limit=999", { + headers: { "x-test-user": "t2" }, + }); expect(res.status).toBe(400); const json = (await res.json()) as { code: string; status: number }; expect(json.code).toBe("invalid_query"); - expect(searchCalls).toEqual([]); - }); - - test("defaults — no q / no limit → empty prefix + limit 10", async () => { - const res = await app.request("/users/search"); - expect(res.status).toBe(200); - expect(searchCalls).toEqual([{ prefix: "", limit: 10 }]); + expect(spy.searchCalls).toEqual([]); }); }); -describe("GET /users/resolve", () => { +describe("GET /users/resolve — mount + dispatch", () => { + beforeEach(() => __resetRateLimitForTests()); + test("absent ids param → empty items, repo NOT called", async () => { - const res = await app.request("/users/resolve"); + const { repo, spy } = makeFakeRepo(); + const app = makeApp(repo); + const res = await app.request("/users/resolve", { + headers: { "x-test-user": "t3" }, + }); expect(res.status).toBe(200); const json = (await res.json()) as { data: { items: DirectoryRow[] } }; expect(json.data.items).toEqual([]); - expect(resolveCalls).toEqual([]); + expect(spy.resolveCalls).toEqual([]); }); test("empty ids param → empty items, repo NOT called", async () => { - const res = await app.request("/users/resolve?ids="); + const { repo, spy } = makeFakeRepo(); + const app = makeApp(repo); + const res = await app.request("/users/resolve?ids=", { + headers: { "x-test-user": "t4" }, + }); expect(res.status).toBe(200); const json = (await res.json()) as { data: { items: DirectoryRow[] } }; expect(json.data.items).toEqual([]); - expect(resolveCalls).toEqual([]); + expect(spy.resolveCalls).toEqual([]); }); test("all-blank ids param → empty items, repo NOT called", async () => { - const res = await app.request("/users/resolve?ids=" + encodeURIComponent(" , , ")); + const { repo, spy } = makeFakeRepo(); + const app = makeApp(repo); + const res = await app.request("/users/resolve?ids=" + encodeURIComponent(" , , "), { + headers: { "x-test-user": "t5" }, + }); expect(res.status).toBe(200); const json = (await res.json()) as { data: { items: DirectoryRow[] } }; expect(json.data.items).toEqual([]); - expect(resolveCalls).toEqual([]); + expect(spy.resolveCalls).toEqual([]); }); test("csv trim + filter-empty — ' a , ,b ' → ['a','b'] + happy resolve", async () => { - const res = await app.request("/users/resolve?ids=" + encodeURIComponent(" a , ,b ")); + const { repo, spy } = makeFakeRepo(); + const app = makeApp(repo); + const res = await app.request("/users/resolve?ids=" + encodeURIComponent(" a , ,b "), { + headers: { "x-test-user": "t6" }, + }); expect(res.status).toBe(200); const json = (await res.json()) as { data: { items: DirectoryRow[] } }; - expect(resolveCalls).toEqual([["a", "b"]]); + expect(spy.resolveCalls).toEqual([["a", "b"]]); expect(json.data.items).toEqual([ { userId: "a", email: "a@x.test", displayName: "a" }, { userId: "b", email: "b@x.test", displayName: "b" }, ]); }); }); + +// --------------------------------------------------------------------------- +// Enumeration hardening tests (#816) +// --------------------------------------------------------------------------- + +describe("GET /users/search — q validation (#816)", () => { + beforeEach(() => __resetRateLimitForTests()); + + test("empty q → 400 and searchByEmailPrefix is NOT called", async () => { + const { repo, spy } = makeFakeRepo(); + const app = makeApp(repo); + const res = await app.request("/users/search?q=", { + headers: { "x-test-user": "alice" }, + }); + expect(res.status).toBe(400); + const body = (await res.json()) as { code: string; status: number }; + expect(body.code).toBe("invalid_query"); + expect(body.status).toBe(400); + expect(spy.searchCalls.length).toBe(0); + }); + + test("1-char q → 400 and searchByEmailPrefix is NOT called", async () => { + const { repo, spy } = makeFakeRepo(); + const app = makeApp(repo); + const res = await app.request("/users/search?q=a", { + headers: { "x-test-user": "bob" }, + }); + expect(res.status).toBe(400); + expect(spy.searchCalls.length).toBe(0); + }); + + test("2-char q → 200, spy called once with the prefix, email present", async () => { + const { repo, spy } = makeFakeRepo(); + const app = makeApp(repo); + const res = await app.request("/users/search?q=al", { + headers: { "x-test-user": "carol" }, + }); + expect(res.status).toBe(200); + expect(spy.searchCalls.length).toBe(1); + expect(spy.searchCalls[0]?.prefix).toBe("al"); + const body = (await res.json()) as { + data: { items: Array<{ email: string; userId: string }> }; + }; + // Positive typeahead control: email stays in the response shape. + expect(body.data.items[0]?.email).toBe("u1@x.test"); + // RFC 9239 limit header is present (exact value depends on env, + // just verify it's a positive integer). + const limitHeader = res.headers.get("RateLimit-Limit"); + expect(limitHeader).not.toBeNull(); + expect(Number(limitHeader)).toBeGreaterThan(0); + }); +}); + +describe("GET /users/search — rate limit (#816)", () => { + beforeEach(() => __resetRateLimitForTests()); + + test("burst past cap → last is 429 with Retry-After + Remaining 0", async () => { + const { repo } = makeFakeRepo(); + const app = makeApp(repo); + const headers = { "x-test-user": "dave" }; + + // Discover the actual cap from the first response. + const probe = await app.request("/users/search?q=al", { headers }); + expect(probe.status).toBe(200); + const cap = Number(probe.headers.get("RateLimit-Limit")); + expect(cap).toBeGreaterThan(0); + + // Send (cap - 1) more requests to fill the bucket (first already used 1). + for (let i = 1; i < cap; i++) { + const ok = await app.request("/users/search?q=al", { headers }); + expect(ok.status).toBe(200); + } + // The next one is over the cap. + const denied = await app.request("/users/search?q=al", { headers }); + expect(denied.status).toBe(429); + expect(denied.headers.get("Content-Type")).toContain("application/problem+json"); + expect(denied.headers.get("Retry-After")).not.toBeNull(); + expect(denied.headers.get("RateLimit-Remaining")).toBe("0"); + const body = (await denied.json()) as { code: string; status: number }; + expect(body.code).toBe("rate_limited"); + expect(body.status).toBe(429); + }); + + test("different users have independent budgets", async () => { + const { repo } = makeFakeRepo(); + const app = makeApp(repo); + // Discover the actual cap. + const probe = await app.request("/users/search?q=al", { headers: { "x-test-user": "probe" } }); + const cap = Number(probe.headers.get("RateLimit-Limit")); + + // Exhaust user A. + for (let i = 0; i < cap; i++) { + await app.request("/users/search?q=al", { headers: { "x-test-user": "eve" } }); + } + const aDenied = await app.request("/users/search?q=al", { + headers: { "x-test-user": "eve" }, + }); + expect(aDenied.status).toBe(429); + // User B is untouched. + const bOk = await app.request("/users/search?q=al", { + headers: { "x-test-user": "frank" }, + }); + expect(bOk.status).toBe(200); + }); +}); + +describe("GET /users/resolve — shared rate-limit budget (#816)", () => { + beforeEach(() => __resetRateLimitForTests()); + + test("burst on /users/resolve → 429 after cap+1 for one user", async () => { + const { repo } = makeFakeRepo(); + const app = makeApp(repo); + // Discover cap from a search probe. + const probe = await app.request("/users/search?q=al", { headers: { "x-test-user": "probe" } }); + const cap = Number(probe.headers.get("RateLimit-Limit")); + + const headers = { "x-test-user": "grace" }; + for (let i = 0; i < cap; i++) { + const ok = await app.request("/users/resolve?ids=u1,u2", { headers }); + expect(ok.status).toBe(200); + } + const denied = await app.request("/users/resolve?ids=u1,u2", { headers }); + expect(denied.status).toBe(429); + expect(denied.headers.get("Retry-After")).not.toBeNull(); + expect(denied.headers.get("RateLimit-Remaining")).toBe("0"); + }); + + test("search + resolve draw from ONE shared per-user budget (same label)", async () => { + const { repo } = makeFakeRepo(); + const app = makeApp(repo); + // Discover cap. + const probe = await app.request("/users/search?q=al", { headers: { "x-test-user": "probe" } }); + const cap = Number(probe.headers.get("RateLimit-Limit")); + + const headers = { "x-test-user": "heidi" }; + // Spend the budget across both endpoints: a search + resolves. + const first = await app.request("/users/search?q=al", { headers }); + expect(first.status).toBe(200); + for (let i = 1; i < cap; i++) { + const ok = await app.request("/users/resolve?ids=u1", { headers }); + expect(ok.status).toBe(200); + } + // cap requests spent across the two routes → the (cap+1)th on + // EITHER route is denied because they share the bucket. + const denied = await app.request("/users/resolve?ids=u9", { headers }); + expect(denied.status).toBe(429); + }); +}); diff --git a/ornn-api/src/domains/users/routes.ts b/ornn-api/src/domains/users/routes.ts index 2b463caf..5a01f56b 100644 --- a/ornn-api/src/domains/users/routes.ts +++ b/ornn-api/src/domains/users/routes.ts @@ -16,10 +16,36 @@ import { nyxidAuthMiddleware, } from "../../middleware/nyxidAuth"; import { validateQuery, getValidatedQuery } from "../../middleware/validate"; +import { rateLimit } from "../../middleware/rateLimit"; +import { createLogger } from "../../shared/logger"; import type { UserDirectoryRepository } from "./repository"; +const logger = createLogger("userRoutes"); + +/** + * Minimum `q` length for the user-directory typeahead (#816). Empty / + * 1-char queries are rejected with 400 so an authenticated caller can't + * walk the entire directory one prefix at a time (enumeration). Clamped + * to a floor of 2 — operators can raise it via env but never below 2. + */ +const MIN_Q = Math.max(2, Number(process.env.ORNN_USER_SEARCH_MIN_Q) || 2); + +/** + * Per-user (per-IP for the rare anon path) burst budget shared across the + * whole directory surface (#816). Both /users/search and /users/resolve + * mount the SAME limiter label, so the budget is a single shared + * allowance — an enumerator can't sidestep the search cap by pivoting to + * resolve. Defaults: 30 req / 60s. Env-tunable. + */ +const RL_WINDOW_MS = Number(process.env.ORNN_USER_DIRECTORY_RATELIMIT_WINDOW_MS) || 60_000; +const RL_MAX = Number(process.env.ORNN_USER_DIRECTORY_RATELIMIT_PER_MIN) || 30; + const searchQuerySchema = z.object({ - q: z.string().max(256).optional().default(""), + // #816 — require a real prefix. Empty / 1-char `q` now 400s via the + // validateQuery seam (dropped `.optional().default("")`). The repo's + // empty-q branch stays intact because admin/quota/routes.ts depends on + // it; the enumeration gate lives HERE in the route, not the repo. + q: z.string().trim().min(MIN_Q).max(256), limit: z.coerce.number().int().min(1).max(50).optional().default(10), }); @@ -33,6 +59,14 @@ export function createUserRoutes( const { userDirectoryRepo } = config; const app = new Hono<{ Variables: AuthVariables }>(); const auth = nyxidAuthMiddleware(); + // Shared per-user budget across the whole directory surface (#816). + // One limiter instance, one label → /users/search and /users/resolve + // draw from the same bucket. + const directoryRateLimit = rateLimit({ + windowMs: RL_WINDOW_MS, + max: RL_MAX, + label: "users-directory", + }); /** * GET /users/search?q=&limit= @@ -41,13 +75,28 @@ export function createUserRoutes( * targets, and we intentionally don't gate this behind admin. Result * set is scoped to users who have actually interacted with Ornn * (have a directory row). + * + * Issue #816 — option (a): reject empty/1-char q + per-user rate + * limit. Email stays in the response because the collaborator + * typeahead matches on email prefix; the repository empty-q branch is + * intentionally left intact because admin/quota/routes.ts depends on + * it — the enumeration gate lives HERE in the route, not the repo. + * + * Mount order: auth → directoryRateLimit → validateQuery → handler, + * so the limiter keys on the authenticated userId (set upstream) and + * runs before the schema check. */ app.get( "/users/search", auth, + directoryRateLimit, validateQuery(searchQuerySchema, "invalid_query"), async (c) => { const parsed = getValidatedQuery>(c); + logger.debug( + { qLen: parsed.q.length, limit: parsed.limit }, + "user directory search", + ); const results = await userDirectoryRepo.searchByEmailPrefix( parsed.q, parsed.limit, @@ -65,8 +114,12 @@ export function createUserRoutes( * email-prefix search can't match on a UUID, so we need a direct * id→row lookup. Unknown ids (users who never signed into Ornn) * are silently dropped from the response. + * + * Shares the `users-directory` rate-limit budget with /users/search + * (#816) — same label, same per-user bucket — so an enumerator can't + * dodge the search cap by pivoting to resolve. */ - app.get("/users/resolve", auth, async (c) => { + app.get("/users/resolve", auth, directoryRateLimit, async (c) => { const raw = c.req.query("ids") ?? ""; const ids = raw .split(",") diff --git a/ornn-api/src/openapi/schemas.ts b/ornn-api/src/openapi/schemas.ts index fbf5bf54..378efb94 100644 --- a/ornn-api/src/openapi/schemas.ts +++ b/ornn-api/src/openapi/schemas.ts @@ -183,6 +183,39 @@ export const playgroundChatEventSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("finish"), finishReason: z.string() }), ]); +// --------------------------------------------------------------------------- +// Assistant (#970) — repo-aware Q&A chatbot +// --------------------------------------------------------------------------- + +export const assistantChatRequestBodySchema = z.object({ + messages: z + .array( + z.object({ + role: z.enum(["user", "assistant"]), + content: z.string(), + }), + ) + .min(1) + .max(100), + modelId: z.string().optional(), +}); + +export const assistantChatEventSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("chat_start"), model: z.string() }), + z.object({ type: z.literal("chat_text_delta"), delta: z.string() }), + z.object({ type: z.literal("chat_error"), code: z.string(), message: z.string() }), + z.object({ + type: z.literal("chat_finish"), + usage: z + .object({ + inputTokens: z.number().optional(), + outputTokens: z.number().optional(), + totalTokens: z.number().optional(), + }) + .optional(), + }), +]); + // --------------------------------------------------------------------------- // Admin // --------------------------------------------------------------------------- diff --git a/ornn-api/src/openapi/specBuilder.ts b/ornn-api/src/openapi/specBuilder.ts index d9e91bcd..4fa1e7e1 100644 --- a/ornn-api/src/openapi/specBuilder.ts +++ b/ornn-api/src/openapi/specBuilder.ts @@ -303,6 +303,29 @@ function playgroundChatPath(): PathItem { }; } +function assistantChatPath(): PathItem { + return { + post: { + summary: "Ornn Assistant — repo-aware Q&A chat (SSE stream)", + description: + "Pure, non-agentic Q&A about Ornn and the skills the caller may see. Grounds answers in a curated knowledge-base digest plus a visibility-scoped skill retrieval (SAFE fields only). SSE event types: 'chat_start', 'chat_text_delta', 'chat_error', 'chat_finish' (+ keepalive comment frames). No tools / no execution.", + operationId: "assistantChat", + tags: ["Assistant"], + security: bearerAuth(), + requestBody: { + required: true, + content: { + "application/json": { schema: toSchema(S.assistantChatRequestBodySchema) }, + }, + }, + responses: { + ...sseResponse("SSE stream of assistant chat events"), + ...errorResponses(400, 401, 429, 503), + }, + }, + }; +} + function categoriesListCreatePath(): PathItem { return { get: { @@ -444,6 +467,8 @@ export function buildSpec(): OpenApiSpec { [`${prefix}/skill-manifest-schema.json`]: formatSchemaPath(), // Playground [`${prefix}/playground/chat`]: playgroundChatPath(), + // Assistant (#970) + [`${prefix}/assistant/chat`]: assistantChatPath(), // Admin [`${prefix}/admin/categories`]: categoriesListCreatePath(), [`${prefix}/admin/categories/{id}`]: categoryUpdateDeletePath(), diff --git a/ornn-api/src/shared/reservedVerbs.ts b/ornn-api/src/shared/reservedVerbs.ts index a5c2e217..987e25cf 100644 --- a/ornn-api/src/shared/reservedVerbs.ts +++ b/ornn-api/src/shared/reservedVerbs.ts @@ -17,6 +17,10 @@ export const RESERVED_VERBS = { skill: ["format", "validate", "search", "counts", "generate", "lookup"], + // Skillset sub-resource segments the router gives priority over the + // `:idOrName` capture (#969) — a skillset named the same would shadow + // them and become unreachable via its canonical read. + skillset: ["closure", "permissions", "versions"], category: [] as string[], tag: [] as string[], } as const satisfies Record; diff --git a/ornn-api/src/shared/schemas/skillFrontmatter.test.ts b/ornn-api/src/shared/schemas/skillFrontmatter.test.ts index 3670802f..e9b30db0 100644 --- a/ornn-api/src/shared/schemas/skillFrontmatter.test.ts +++ b/ornn-api/src/shared/schemas/skillFrontmatter.test.ts @@ -123,3 +123,106 @@ describe("validateSkillFrontmatter — actionable errors (#649)", () => { expect(r.success).toBe(true); }); }); + +describe("validateSkillFrontmatter — depends-on grammar (#968)", () => { + const GUID = "11111111-2222-4333-8444-555555555555"; + + test("accepts @", () => { + const r = validateSkillFrontmatter( + base({ metadata: { category: "plain", "depends-on": ["pdf-tools@1.0"] } }), + ); + expect(r.success).toBe(true); + if (!r.success) return; + expect(r.data.metadata["depends-on"]).toEqual(["pdf-tools@1.0"]); + }); + + test("accepts @", () => { + const r = validateSkillFrontmatter( + base({ metadata: { category: "plain", "depends-on": [`${GUID}@2.3`] } }), + ); + expect(r.success).toBe(true); + }); + + test("accepts @", () => { + const r = validateSkillFrontmatter( + base({ metadata: { category: "plain", "depends-on": ["pdf-tools@beta"] } }), + ); + expect(r.success).toBe(true); + }); + + test("accepts @", () => { + const r = validateSkillFrontmatter( + base({ metadata: { category: "plain", "depends-on": [`${GUID}@stable`] } }), + ); + expect(r.success).toBe(true); + }); + + test("defaults to [] when omitted", () => { + const r = validateSkillFrontmatter(base()); + expect(r.success).toBe(true); + if (!r.success) return; + expect(r.data.metadata["depends-on"]).toEqual([]); + }); + + test("rejects 3-digit semver (name@1.2.3)", () => { + const r = validateSkillFrontmatter( + base({ metadata: { category: "plain", "depends-on": ["pdf-tools@1.2.3"] } }), + ); + expect(r.success).toBe(false); + if (r.success) return; + const msg = r.errors.find((e) => e.field === "metadata.depends-on.0")?.message ?? ""; + expect(msg).toContain("no semver ranges"); + }); + + test("rejects caret range (name@^1.0)", () => { + const r = validateSkillFrontmatter( + base({ metadata: { category: "plain", "depends-on": ["pdf-tools@^1.0"] } }), + ); + expect(r.success).toBe(false); + }); + + test("rejects tilde range (name@~1.0)", () => { + const r = validateSkillFrontmatter( + base({ metadata: { category: "plain", "depends-on": ["pdf-tools@~1.0"] } }), + ); + expect(r.success).toBe(false); + }); + + test("rejects a bare name with no @version", () => { + const r = validateSkillFrontmatter( + base({ metadata: { category: "plain", "depends-on": ["pdf-tools"] } }), + ); + expect(r.success).toBe(false); + }); + + test("rejects a self-reference by name", () => { + const r = validateSkillFrontmatter( + base({ + name: "qa-sample", + metadata: { category: "plain", "depends-on": ["qa-sample@1.0"] }, + }), + ); + expect(r.success).toBe(false); + if (r.success) return; + const msg = r.errors.find((e) => e.field === "metadata.depends-on.0")?.message ?? ""; + expect(msg).toContain("cannot depend on itself"); + }); + + test("rejects more than 50 direct dependencies", () => { + const deps = Array.from({ length: 51 }, (_, i) => `dep-${i}@1.0`); + const r = validateSkillFrontmatter( + base({ metadata: { category: "plain", "depends-on": deps } }), + ); + expect(r.success).toBe(false); + }); + + test("rejects a null list item", () => { + const r = validateSkillFrontmatter( + base({ metadata: { category: "plain", "depends-on": [null] } }), + ); + expect(r.success).toBe(false); + if (r.success) return; + const msg = r.errors.find((e) => e.field === "metadata.depends-on.0")?.message ?? ""; + expect(msg).toContain("depends-on entries must be non-empty"); + }); +}); diff --git a/ornn-api/src/shared/schemas/skillFrontmatter.ts b/ornn-api/src/shared/schemas/skillFrontmatter.ts index a6d1d617..5a906bb2 100644 --- a/ornn-api/src/shared/schemas/skillFrontmatter.ts +++ b/ornn-api/src/shared/schemas/skillFrontmatter.ts @@ -70,6 +70,45 @@ const dependencyItemSchema = z .min(1, "runtime-dependency entries must not be empty") .max(200, "runtime-dependency entries must be at most 200 characters"); +// Skill-dependency item (#968). +// +// Grammar: `@` OR `@`. No semver +// ranges, no `^`/`~`/`>=` — a dependency pins to one concrete published +// surface (a literal version) or to a moving dist-tag the owner controls. +// +// The three alternatives below reuse the canonical regex *bodies* so the +// dependency grammar can never drift from the name / version rules enforced +// elsewhere: +// 1. `@` — kebab name + 2-digit version +// 2. `@` — UUID v4 + 2-digit version +// 3. `@` — kebab name OR UUID + npm-style dist-tag +// (lowercase, leading letter, hyphens; same rule as DIST_TAG_NAME_RE in +// the CRUD service). The leading-letter rule keeps a dist-tag from +// looking like a version number, so the literal-version and dist-tag +// forms never collide. +const DEPENDS_ON_NAME_BODY = "[a-z0-9][a-z0-9-]*"; +const DEPENDS_ON_GUID_BODY = + "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; +const DEPENDS_ON_VERSION_BODY = "(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)"; +const DEPENDS_ON_DIST_TAG_BODY = "[a-z][a-z0-9-]{0,49}"; +export const DEPENDS_ON_REF_REGEX = new RegExp( + `^(?:${DEPENDS_ON_NAME_BODY}|${DEPENDS_ON_GUID_BODY})@(?:${DEPENDS_ON_VERSION_BODY}|${DEPENDS_ON_DIST_TAG_BODY})$`, +); + +const dependsOnItemSchema = z + .string({ + error: (issue) => + issue.code === "invalid_type" + ? 'depends-on entries must be non-empty strings of the form `@` or `@`, e.g. `depends-on: [pdf-tools@1.0]` — an empty `- ` line in YAML parses as `null`.' + : undefined, + }) + .min(1, "depends-on entries must not be empty") + .max(115, "depends-on entries must be at most 115 characters") + .regex( + DEPENDS_ON_REF_REGEX, + "depends-on entries must be `@` or `@` (no semver ranges like ^1.0 or 1.2.3)", + ); + // Metadata sub-schema (base, before refinement) export const metadataSchema = z.object({ category: z.enum(FRONTMATTER_CATEGORIES), @@ -79,6 +118,13 @@ export const metadataSchema = z.object({ "runtime-env-var": z.array(envVarItemSchema).max(30).default([]), "tool-list": z.array(toolItemSchema).max(50).default([]), tag: z.array(tagItemSchema).max(10).default([]), + // Skill dependencies (#968). Each entry pins another skill by + // `@` or `@`. Capped at 50 + // direct deps per version — the transitive closure can be much larger, + // but a single SKILL.md declaring >50 direct deps is almost certainly a + // mistake. Self-references are rejected at the top-level schema (where + // the skill's own `name` is in scope). + "depends-on": z.array(dependsOnItemSchema).max(50).default([]), }); export type MetadataInput = z.input; @@ -173,8 +219,8 @@ export const SKILL_VERSION_REGEX = /^(0|[1-9]\d*)\.(0|[1-9]\d*)$/; export const SKILL_NAME_REGEX = /^[a-z0-9][a-z0-9-]*$/; export const SKILL_NAME_MAX = 64; -// Full frontmatter schema -export const skillFrontmatterSchema = z.object({ +// Full frontmatter schema (base, before the top-level refinement). +const baseSkillFrontmatterSchema = z.object({ name: z.string().min(1).max(SKILL_NAME_MAX).regex(SKILL_NAME_REGEX, "Name must be kebab-case"), description: z.string().min(1).max(1024), // YAML parses `version: 0.1` (unquoted) as a float and `1.0` as an @@ -212,6 +258,34 @@ export const skillFrontmatterSchema = z.object({ hooks: z.record(z.string(), z.unknown()).optional(), }); +/** + * Top-level frontmatter schema. Wraps the base object with a + * `superRefine` that enforces cross-field rules where both the skill's + * own `name` and `metadata.depends-on` are in scope. + * + * Self-reference (#968): a skill MUST NOT depend on itself. We reject any + * `depends-on` entry whose `` segment equals the skill's + * own `name`. (GUID-form self-refs can't be detected here — the skill's + * GUID isn't known at frontmatter-parse time — so they're caught later by + * the closure resolver's cycle check at publish time.) + */ +export const skillFrontmatterSchema = baseSkillFrontmatterSchema.superRefine((data, ctx) => { + const dependsOn = data.metadata?.["depends-on"] ?? []; + for (let i = 0; i < dependsOn.length; i++) { + const ref = dependsOn[i]; + if (typeof ref !== "string") continue; + const at = ref.indexOf("@"); + const target = at === -1 ? ref : ref.slice(0, at); + if (target === data.name) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["metadata", "depends-on", i], + message: `A skill cannot depend on itself ('${data.name}'). Remove the self-reference '${ref}'.`, + }); + } + } +}); + export type SkillFrontmatterInput = z.input; export type SkillFrontmatterOutput = z.output; diff --git a/ornn-api/src/shared/types/index.ts b/ornn-api/src/shared/types/index.ts index ac7f7ec0..83e2e388 100644 --- a/ornn-api/src/shared/types/index.ts +++ b/ornn-api/src/shared/types/index.ts @@ -294,6 +294,16 @@ export interface SkillMetadata { "mcp-servers"?: Array<{ mcp: string; version: string }>; }>; tags?: string[]; + /** + * Skill dependencies (#968). Each entry pins another skill by + * `@` or `@` (the + * `metadata.depends-on` frontmatter field, kebab→camel mapped on + * extract). Persisted per immutable version inside + * `SkillVersionDocument.metadata`, so no new version-doc field or + * migration is needed — a version published before #968 simply reads + * back with `dependsOn` absent. Empty/omitted = no dependencies. + */ + dependsOn?: string[]; } export interface SkillDetailResponse { diff --git a/ornn-web/Dockerfile b/ornn-web/Dockerfile index f7312b60..a5fc3838 100644 --- a/ornn-web/Dockerfile +++ b/ornn-web/Dockerfile @@ -6,7 +6,7 @@ # 'zustand'". Pinning here + copying the real sibling package.jsons # below makes the install reproducible across CI / local / fresh dev # machines. -FROM oven/bun:1.3.13 AS build +FROM oven/bun:1.3.14 AS build WORKDIR /app # Copy workspace root package files diff --git a/ornn-web/package.json b/ornn-web/package.json index f8ab9913..9924bb61 100644 --- a/ornn-web/package.json +++ b/ornn-web/package.json @@ -12,30 +12,31 @@ "test:watch": "vitest" }, "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", @@ -44,14 +45,14 @@ "@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" } } diff --git a/ornn-web/src/App.tsx b/ornn-web/src/App.tsx index d8deb060..6ae3cf2f 100644 --- a/ornn-web/src/App.tsx +++ b/ornn-web/src/App.tsx @@ -21,6 +21,7 @@ import { Outlet, Route, RouterProvider, + useLocation, } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { RootLayout } from "@/components/layout/RootLayout"; @@ -28,26 +29,32 @@ import { AdminLayout } from "@/components/layout/AdminLayout"; import { AuthGuard } from "@/components/auth/AuthGuard"; import { AdminGuard } from "@/components/auth/AdminGuard"; import { ErrorBoundary } from "@/components/ErrorBoundary"; -import { AnnouncementBanner } from "@/components/announcements/AnnouncementBanner"; import { HighlighterMarkFilter } from "@/pages/landing/HighlighterMark"; import { VersionUpdateBanner } from "@/components/layout/VersionUpdateBanner"; import { PostHogProvider } from "@/components/analytics/PostHogProvider"; import { CookieConsentBanner } from "@/components/analytics/CookieConsentBanner"; +import { AssistantWidget } from "@/components/assistant/AssistantWidget"; /** * Top-level wrapper rendered as the root route's element. Lives INSIDE * the router tree so child analytics hooks (`useLocation`) work, and * renders the consent banner above every page. PostHogProvider has no * DOM output — it just wires init / identify / pageview tracking. + * + * The Ornn Assistant mounts here (not RootLayout) so its mascot launcher + * floats over EVERY page — including the landing page and for anonymous + * visitors (#976). Suppressed only on the auth handshake routes + * (`/login`, `/oauth/*`) where a floating chatbot would be noise. */ function AnalyticsRoot() { + const { pathname } = useLocation(); + const hideAssistant = pathname === "/login" || pathname.startsWith("/oauth"); return ( <> - {/* Global announcement surface — top-right headline pill on every page. */} - + {!hideAssistant && } ); } @@ -124,6 +131,21 @@ const PlaygroundPage = lazy(() => const MySkillsPage = lazy(() => import("@/pages/skill/MySkillsPage").then((m) => ({ default: m.MySkillsPage })), ); +const SkillsetExplorePage = lazy(() => + import("@/pages/SkillsetExplorePage").then((m) => ({ default: m.SkillsetExplorePage })), +); +const SkillsetDetailPage = lazy(() => + import("@/pages/SkillsetDetailPage").then((m) => ({ default: m.SkillsetDetailPage })), +); +const SkillsetNewPage = lazy(() => + import("@/pages/SkillsetNewPage").then((m) => ({ default: m.SkillsetNewPage })), +); +const SkillsetEditPage = lazy(() => + import("@/pages/SkillsetEditPage").then((m) => ({ default: m.SkillsetEditPage })), +); +const MySkillsetsPage = lazy(() => + import("@/pages/MySkillsetsPage").then((m) => ({ default: m.MySkillsetsPage })), +); const ServiceDetailPage = lazy(() => import("@/pages/ServiceDetailPage").then((m) => ({ default: m.ServiceDetailPage })), ); @@ -259,6 +281,11 @@ const router = createBrowserRouter( path="/skills/:idOrName/audits" element={} /> + {/* Skillsets registry (#1059). `/skillsets/new` + `/skillsets/:id/edit` + are auth-guarded below; static segments win the match over the + `:idOrName` capture regardless of declaration group. */} + } /> + } /> {/* Protected routes */} @@ -273,6 +300,12 @@ const router = createBrowserRouter( } /> } /> + + {/* Skillsets create/edit/mine (#1059) — auth-guarded. */} + } /> + } /> + } /> + } /> } /> } /> diff --git a/ornn-web/src/assets/ornn-forge-greet.jpg b/ornn-web/src/assets/ornn-forge-greet.jpg new file mode 100644 index 00000000..8da22d7b Binary files /dev/null and b/ornn-web/src/assets/ornn-forge-greet.jpg differ diff --git a/ornn-web/src/assets/ornn-forge-poster.jpg b/ornn-web/src/assets/ornn-forge-poster.jpg new file mode 100644 index 00000000..4c5433db Binary files /dev/null and b/ornn-web/src/assets/ornn-forge-poster.jpg differ diff --git a/ornn-web/src/assets/ornn-forge.mp4 b/ornn-web/src/assets/ornn-forge.mp4 new file mode 100644 index 00000000..6153b5b9 Binary files /dev/null and b/ornn-web/src/assets/ornn-forge.mp4 differ diff --git a/ornn-web/src/assets/ornn-mascot-wave.webp b/ornn-web/src/assets/ornn-mascot-wave.webp new file mode 100644 index 00000000..2c317ee7 Binary files /dev/null and b/ornn-web/src/assets/ornn-mascot-wave.webp differ diff --git a/ornn-web/src/assets/ornn-mascot.webp b/ornn-web/src/assets/ornn-mascot.webp new file mode 100644 index 00000000..aad4a397 Binary files /dev/null and b/ornn-web/src/assets/ornn-mascot.webp differ diff --git a/ornn-web/src/components/admin/settings/ProviderModelsDrawer.test.tsx b/ornn-web/src/components/admin/settings/ProviderModelsDrawer.test.tsx new file mode 100644 index 00000000..cc7d543e --- /dev/null +++ b/ornn-web/src/components/admin/settings/ProviderModelsDrawer.test.tsx @@ -0,0 +1,134 @@ +/** + * ProviderModelsDrawer — assistant surface column (#970). + * + * The drawer gained a fourth surface column (Assistant) alongside + * Playground and Skill-Gen. This guards that the assistant Toggle/Radio + * render and PATCH the right flag, so the admin can target the Ornn + * Assistant surface exactly like the other two. + * + * framer-motion is stubbed pass-through; the toast store + settings API + * are mocked so no network / localStorage init chain runs. The drawer's + * patch mutation is built inline with useMutation, so the component is + * wrapped in a QueryClientProvider. + * + * @module components/admin/settings/ProviderModelsDrawer.test + */ + +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { LlmProvider } from "@/services/settingsApi"; + +const addToast = vi.fn(); +const patchProviderModelFlags = vi.fn(); + +vi.mock("framer-motion", () => ({ + AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}, + motion: new Proxy( + {}, + { + get: + (_t, tag: string) => + ({ + children, + initial: _i, + animate: _a, + exit: _e, + transition: _tr, + ...rest + }: Record & { children?: React.ReactNode }) => { + void _i; + void _a; + void _e; + void _tr; + const Tag = tag as keyof React.JSX.IntrinsicElements; + return {children}; + }, + }, + ), +})); + +vi.mock("@/stores/toastStore", () => ({ + useToastStore: (selector: (s: { addToast: typeof addToast }) => T) => + selector({ addToast }), +})); + +vi.mock("@/services/settingsApi", () => ({ + patchProviderModelFlags: (...args: unknown[]) => patchProviderModelFlags(...args), +})); + +import { ProviderModelsDrawer } from "./ProviderModelsDrawer"; + +const PROVIDER: LlmProvider = { + _id: "prov-1", + name: "Alpha Gateway", + gatewayUrl: "https://alpha.example.com/v1", + modelListUrl: "https://alpha.example.com/v1/models", + apiFormat: "chat-completion", + auth: { kind: "apiKey", apiKey: "k" }, + maxOutputTokens: 4096, + defaultTemperature: 0.7, + models: [ + { + id: "gpt-5", + displayName: "GPT-5", + enabledForPlayground: true, + enabledForSkillGen: false, + enabledForAssistant: false, + defaultForPlayground: false, + defaultForSkillGen: false, + defaultForAssistant: false, + removed: false, + }, + ], +}; + +function renderDrawer() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + {}} provider={PROVIDER} /> + , + ); +} + +beforeEach(() => { + addToast.mockReset(); + patchProviderModelFlags.mockReset(); + patchProviderModelFlags.mockResolvedValue(PROVIDER); +}); + +afterEach(cleanup); + +describe("ProviderModelsDrawer — assistant column", () => { + it("renders the Assistant column header", () => { + renderDrawer(); + expect(screen.getByText("Assistant")).toBeInTheDocument(); + }); + + it("renders the assistant enable toggle + default radio for a model", () => { + renderDrawer(); + expect(screen.getByLabelText("Enabled for assistant")).toBeInTheDocument(); + expect(screen.getByLabelText("Default for assistant")).toBeInTheDocument(); + }); + + it("PATCHes enabledForAssistant when the assistant toggle is flipped", async () => { + renderDrawer(); + fireEvent.click(screen.getByLabelText("Enabled for assistant")); + await waitFor(() => + expect(patchProviderModelFlags).toHaveBeenCalledWith("prov-1", "gpt-5", { + enabledForAssistant: true, + }), + ); + }); + + it("PATCHes defaultForAssistant when the assistant default radio is selected", async () => { + renderDrawer(); + fireEvent.click(screen.getByLabelText("Default for assistant")); + await waitFor(() => + expect(patchProviderModelFlags).toHaveBeenCalledWith("prov-1", "gpt-5", { + defaultForAssistant: true, + }), + ); + }); +}); diff --git a/ornn-web/src/components/admin/settings/ProviderModelsDrawer.tsx b/ornn-web/src/components/admin/settings/ProviderModelsDrawer.tsx index a6c21db1..10186759 100644 --- a/ornn-web/src/components/admin/settings/ProviderModelsDrawer.tsx +++ b/ornn-web/src/components/admin/settings/ProviderModelsDrawer.tsx @@ -7,10 +7,12 @@ * operator can: * - toggle "Enabled for Playground" * - toggle "Enabled for SkillGen" + * - toggle "Enabled for Assistant" (#970) * - radio-pick "Default for Playground" (server enforces at-most-one * across **all** providers, so flipping one default unselects every * other provider's default for that surface in the same write) * - radio-pick "Default for SkillGen" (same) + * - radio-pick "Default for Assistant" (same — #970) * * Removed-from-upstream rows (`removed: true`) are segregated below the * active rows with an "archived" badge and a disabled toggle column — @@ -156,7 +158,7 @@ export function ProviderModelsDrawer({ to pull the upstream catalog.

) : ( - +
+ @@ -182,7 +187,7 @@ export function ProviderModelsDrawer({ {archived.length > 0 && ( <> - @@ -270,6 +275,24 @@ function ModelRow({ model, disabled, onPatch }: ModelRowProps) { /> + ); } diff --git a/ornn-web/src/components/assistant/AssistantWidget.test.tsx b/ornn-web/src/components/assistant/AssistantWidget.test.tsx new file mode 100644 index 00000000..5e5d5220 --- /dev/null +++ b/ornn-web/src/components/assistant/AssistantWidget.test.tsx @@ -0,0 +1,332 @@ +/** + * AssistantWidget — launcher + panel behavior (#970, redesigned #976). + * + * Covers the user-facing contract: + * - The mascot launcher renders for EVERYONE (anonymous + authed) — it + * is no longer auth-gated. + * - Anonymous send is intercepted with an inline sign-in prompt and + * never reaches the chat backend; the prompt's button initiates the + * NyxID login. + * - Authenticated send still forwards to the chat hook. + * - First-visit auto-open: the panel opens once when the localStorage + * flag is unset, and stays closed when it is set. + * - The launcher opens a dialog with the three example questions; a + * suggestion click fills the composer; close dismisses the panel; + * Tab focus is trapped; header controls keep a 44px touch target. + * + * framer-motion is stubbed pass-through (incl. useReducedMotion + + * useMotionValue, and all drag/gesture/animation props are dropped so + * they don't leak onto the DOM). The auth store + chat hook are mocked; + * the assistant store (open/close) is the real session store, reset per + * test. react-i18next is stubbed globally in src/test/setup.ts, resolving + * the real en.json copy. localStorage is cleared per test so the + * first-visit auto-open is deterministic. + * + * @module components/assistant/AssistantWidget.test + */ + +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; + +// The test runtime's default localStorage lacks `.clear()`; install a +// minimal in-memory Storage (same pattern as AnnouncementBanner.test) so +// the first-visit / launcher-position flags are deterministic per test. +function installFakeLocalStorage() { + const store = new Map(); + const fake: Storage = { + get length() { + return store.size; + }, + clear: () => store.clear(), + getItem: (k) => (store.has(k) ? (store.get(k) as string) : null), + key: (i) => Array.from(store.keys())[i] ?? null, + removeItem: (k) => void store.delete(k), + setItem: (k, v) => void store.set(k, String(v)), + }; + Object.defineProperty(globalThis, "localStorage", { value: fake, configurable: true }); +} +installFakeLocalStorage(); + +// jsdom doesn't implement HTMLMediaElement playback — guard play/pause so +// the panel's forge-hero
@@ -168,6 +170,9 @@ export function ProviderModelsDrawer({ Skill-Gen + Assistant +
+ Archived (no longer in upstream catalog)
+
+ + onPatch({ enabledForAssistant: !model.enabledForAssistant }) + } + /> + onPatch({ defaultForAssistant: true })} + /> +
+