diff --git a/.changeset/admin-search-cap-446.md b/.changeset/admin-search-cap-446.md new file mode 100644 index 00000000..d648042b --- /dev/null +++ b/.changeset/admin-search-cap-446.md @@ -0,0 +1,5 @@ +--- +"ornn-api": patch +--- + +Add `maxTimeMS` + ensure indexes on admin skill search (#446). `GET /admin/skills?q=…` ran an escaped `$regex` against `name` and `description` with no time cap and no documented indexes — a crafted partial-match query on a large collection could pin a Mongo node's CPU indefinitely. Adds a 5 s `maxTimeMS` to both `countDocuments` + `find`, and a new `SkillRepository.ensureIndexes()` (wired into bootstrap) that creates `name` (unique), `description`, `createdBy + createdOn`, `createdOn`, and `isPrivate + createdOn` indexes — partial-regex still can't use a btree, but the secondary filters (`createdBy=…`, `isPrivate=…`) and the `createdOn` sort now hit indexes instead of a full collection scan. diff --git a/.changeset/admin-settings-zod-actionable-698.md b/.changeset/admin-settings-zod-actionable-698.md new file mode 100644 index 00000000..5a42ae68 --- /dev/null +++ b/.changeset/admin-settings-zod-actionable-698.md @@ -0,0 +1,19 @@ +--- +"ornn-web": patch +--- + +`useSectionForm` validation errors now include the offending field path and rephrase length-1 string failures as "is required" instead of the raw `Too small: expected string to have >=1 characters` (#698). + +The shared admin-settings form hook joined `i.message` only when surfacing Zod validation issues. The Mirror section (and every other section that uses `.min(1)` as a required-field gate) ended up emitting a single SectionShell alert that read like: + +> Too small: expected string to have >=1 characters; Too small: expected string to have >=1 characters; Too small: expected string to have >=1 characters; ... + +— with no indication of which fields needed filling. + +Fix: prefix each issue with `path.join(".")`, and for the specific `code: "too_small"` + `type: "string"` + `minimum: 1` triple swap the message to `is required`. Renders as: + +> owner: is required; repo: is required; branch: is required; appId: is required; installationId: is required; appPrivateKey: is required + +— actionable from the alert alone, no schema-by-schema rewrite needed. + +Per-section schemas can still ship their own friendlier messages (`.min(1, "Owner is required")`) — those flow through `i.message` unchanged. diff --git a/.changeset/admin-user-search-display-name-587.md b/.changeset/admin-user-search-display-name-587.md new file mode 100644 index 00000000..68350b2b --- /dev/null +++ b/.changeset/admin-user-search-display-name-587.md @@ -0,0 +1,13 @@ +--- +"ornn-api": patch +--- + +Admin Users search now matches display name as well as email (#587). + +The search input's placeholder said "email or display name" but the Mongo filter only matched `email` with an anchored-prefix regex. Display names + display-name substrings were silently ignored, so admins typing `Haylee01` or `Proxy` got empty result lists even though those users existed. + +Fix is additive — the email behaviour is preserved (still an anchored, case-insensitive prefix match), and a case-insensitive **substring** match on `displayName` is OR'd in alongside it. Display names don't have a meaningful prefix (the issue's reproducer was `Proxy` matching `Ornn Local Proxy`), so substring is the right shape. + +Both the unbounded `findAllInRole` (the admin dashboard's paginated-in-memory path) and the paginated `listUsers` (the page-then-fetch path) use the same `buildUserSearchFilter` helper so they stay in sync. + +Regex metacharacters in the query are escaped, same as before — pinned with a new test so the escape stays in place. diff --git a/.changeset/agentseal-path-validation-442.md b/.changeset/agentseal-path-validation-442.md new file mode 100644 index 00000000..02013b20 --- /dev/null +++ b/.changeset/agentseal-path-validation-442.md @@ -0,0 +1,11 @@ +--- +"ornn-api": patch +--- + +Harden the AgentSeal subprocess (#442). Two defensive changes, both small. + +**Boot-time path validation.** `AgentSealScanner`'s constructor now refuses `python` / `script` config values that aren't absolute paths to existing regular files. Closes a lateral-movement gap: if scanner config ever sourced from a less-trusted place (admin-editable UI, env that picks up `PATH`), `spawn("python", ...)` would silently resolve against `$PATH` and let an attacker swap in any binary they could plant on the search path. Validation only fires when `enabled: true`, so dev/test envs that don't have agentseal installed can boot fine. New `AGENTSEAL_ENABLED=false` env flag toggles the whole scanner (default `true`). + +**Unref child after kill.** When the subprocess hits the timeout and we send SIGTERM / SIGKILL, we now also call `child.unref()` so the killed process can no longer keep the API event loop alive during shutdown. Previously, a scanner mid-flight when the API received SIGTERM could delay graceful shutdown by up to `timeoutMs + 1s`. + +Tests: 6 new assertions on the path validator (relative rejected, missing rejected, directory rejected, disabled skips validation, happy path constructs, helper unit-tested). Existing subprocess tests adjusted to use a real on-disk dummy script. diff --git a/.changeset/api-stability-doc-474.md b/.changeset/api-stability-doc-474.md new file mode 100644 index 00000000..70423e7e --- /dev/null +++ b/.changeset/api-stability-doc-474.md @@ -0,0 +1,4 @@ +--- +--- + +Publishes `docs/API_STABILITY.md` (#474) — the public stability commitment for `/api/v1/*`. Codifies the alpha caveat, the post-v1 semver policy, three stability tiers (`stable` / `beta` / `experimental` declared via OpenAPI `x-stability`), and the deprecation policy (RFC 8594 headers, two-minor-release lead time, signal channels, breaking-change checklist). Linked from README docs section; `CONVENTIONS.md §7` cross-links here. diff --git a/.changeset/apidelete-dedup-578.md b/.changeset/apidelete-dedup-578.md new file mode 100644 index 00000000..c8ff881b --- /dev/null +++ b/.changeset/apidelete-dedup-578.md @@ -0,0 +1,5 @@ +--- +"ornn-web": patch +--- + +Route `apiDelete` through `fetchWithRetry` (#578) so the proactive-refresh / 401-retry / redirect-to-login logic lives in one place instead of being copy-pasted between `GET/POST/PATCH/PUT` and DELETE. Behaviour-equivalent: DELETE still proactively refreshes, retries once on 401, redirects to `/login` if refresh fails, and surfaces non-2xx responses as `ApiClientError`. Only observable difference is the default error code on bodyless failures is now `UNKNOWN_ERROR` instead of `DELETE_FAILED` — that string was a dead default, no caller checks it. diff --git a/.changeset/audit-rerun-silent-fail-718.md b/.changeset/audit-rerun-silent-fail-718.md new file mode 100644 index 00000000..90ddc150 --- /dev/null +++ b/.changeset/audit-rerun-silent-fail-718.md @@ -0,0 +1,11 @@ +--- +"ornn-web": patch +--- + +Skill Detail now surfaces a "latest rerun failed" indicator next to the score so a failed audit rerun is no longer invisible (#718). + +Background: `auditSummaryByVersion` returns the latest *completed* audit per version. When a rerun ends in `failed`, the summary still points at the previous successful record, and Skill Detail renders that stale score — visually identical to "current passing audit". Admins only discovered the failure by opening Audit History. + +Fix: `useSkillDetail` already loads `versionAuditHistory` (newest-first across all statuses) to compute `versionAuditRunning`. Compute one more derived flag from it — `versionAuditLatestFailed = history[0].status === "failed" && newer than the displayed completed audit` — and thread it through to `AuditVerdictPill` as the new `latestRerunFailed` prop. The pill keeps the old completed score (so admins can still see the last-good number) and renders a danger-toned banner directly below: "Latest rerun failed — score above is from the prior audit. Check audit history for details." (`skillDetail.auditLatestFailed`). + +No backend change. The shape `getAudit` returns is unchanged — the gap was that the *latest-of-any-status* signal was already in hand and just wasn't propagated. diff --git a/.changeset/bare-catch-sweep-579.md b/.changeset/bare-catch-sweep-579.md new file mode 100644 index 00000000..dd54a4c8 --- /dev/null +++ b/.changeset/bare-catch-sweep-579.md @@ -0,0 +1,9 @@ +--- +"ornn-api": patch +--- + +Audit + tighten the 31 bare `catch {}` blocks across `ornn-api/src` (#579). + +Critical-path catches that swallowed errors silently now capture the error and emit `logger.debug({ err }, '…')` — analytics dispatch, NyxID org lookups, audit-bundle reads, audit-JSON parse, package-parse on source-refresh, optional JSON-array form fields, generation-context binary skips, LLM output parse, GitHub URL parse. Caller behavior is unchanged (still returns null / falls back to defaults), but a misconfigured or broken upstream is now observable in logs instead of hidden behind an empty result set. + +The catches that already logged, already rethrew as `AppError`, or where the return value IS the signal (validation result, violation list, parse-failure fallback) are left alone. Each one that stays silent on purpose now carries a one-line comment explaining why, so a future reader doesn't re-flag it. diff --git a/.changeset/bootstrap-decompose-580.md b/.changeset/bootstrap-decompose-580.md new file mode 100644 index 00000000..21ea6e6c --- /dev/null +++ b/.changeset/bootstrap-decompose-580.md @@ -0,0 +1,25 @@ +--- +"ornn-api": patch +--- + +Decompose `bootstrap.ts` per-domain (#580). + +Lifts 10 leaf domains' wiring out of the 1089-line `bootstrap.ts` monolith into per-domain `bootstrap.ts` modules. Each one exports a `wire{Domain}({ db, logger, ...deps })` function that bundles repo construction + `ensureIndexes()` fire-and-forget catch + any one-shot boot migration + service construction + routes construction into a single call. The orchestrator stays in charge of *ordering* and shared client construction; the per-domain *detail* moves out. + +Domains extracted: + +- announcements +- analytics +- quota (consumed by playground / skill-gen / admin) +- redemption-codes (admin + me route surfaces, shared service for atomic pivot consistency) +- broadcasts (2-step: shared repo first, then service + routes) +- notifications (consumes shared broadcasts repo for the merged feed) +- platform settings (legacy single-doc surface) +- admin (dashboard + users + quota admin) +- skill search +- skill generation +- playground + +What's still inlined: skills CRUD, skill audit, GitHub mirror, settings export/import, and `createAdminRoutes` (skill / generation / agentseal admin). These have heavier cross-cutting dependency lists (scheduler lifecycle, audit fan-out, analytics emitter closures) — extracting them cleanly needs a follow-up. + +bootstrap.ts: **1089 → 970 lines** (-11%, -119 lines net). No behavioral change — boot order is preserved, all 798 tests still pass. diff --git a/.changeset/chat-events-zod-449.md b/.changeset/chat-events-zod-449.md new file mode 100644 index 00000000..65eff755 --- /dev/null +++ b/.changeset/chat-events-zod-449.md @@ -0,0 +1,5 @@ +--- +"ornn-api": patch +--- + +Type-check playground LLM stream events with a Zod discriminated union (#449). `chatService` previously read `event.type`, `event.delta`, `event.item` via `as any` — a runtime no-op that let upstream field renames silently propagate `undefined` through the SSE stream to clients. Three permissive schemas (`response.output_text.delta`, `response.content_part.delta`, `response.output_item.done`) now gate every event; unknown shapes are dropped with a debug log so the upstream API can add fields freely without breaking us. diff --git a/.changeset/chat-input-length-cap-654.md b/.changeset/chat-input-length-cap-654.md new file mode 100644 index 00000000..787bde79 --- /dev/null +++ b/.changeset/chat-input-length-cap-654.md @@ -0,0 +1,26 @@ +--- +"ornn-web": patch +"ornn-api": patch +--- + +Chat composer length cap + counter (#654). + +The Playground + AI-generation chat composer accepted prompts of any length — the live reproducer was 24 000 chars typed, send button still enabled, no warning. Backend caps existed only for message *count* (100), never message *content*. + +Front-end (`ChatInput.tsx`): + +- `maxLength={32_000}` on the textarea — browser-side hard cap on typing / paste. +- Live ` / ` counter appears once the input crosses 24 000 chars (75 %); stays hidden below that so the composer isn't chromed for normal use. +- Counter flips danger-tone + send button disables when content is over the cap (defensive — `maxLength` should make this unreachable, but covers IME / non-browser-paste edge cases). +- Imperative `setValue` (used by suggestion-prompt clicks) truncates past the cap so curated copy can't bypass the limit silently. + +Back-end: + +- `playgroundMessageSchema.content` adds `.max(MAX_CHAT_MESSAGE_CHARS)` — rejects with `400 content_too_long` (RFC 7807 envelope). +- `skills/generation` JSON path validates prompt length AND each multi-turn message's content length symmetrically — rejects with `400 prompt_too_long` or `400 content_too_long`. + +The 32 000-char ceiling is `~8k tokens` at 4 chars/token. Generous for interactive prompts without enabling whole-novel pastes; the three constants are deliberately duplicated across `ChatInput.tsx`, `playground/routes.ts`, and `skills/generation/routes.ts` with cross-referencing comments so a change in one stays in step with the others. + +Pinned with `ChatInput.test.tsx` (7 assertions covering `maxLength` attribute, counter visibility, send-enable / disable, imperative truncate, empty disabled). + +Closes #654. diff --git a/.changeset/codecov-471.md b/.changeset/codecov-471.md new file mode 100644 index 00000000..54537cf3 --- /dev/null +++ b/.changeset/codecov-471.md @@ -0,0 +1,4 @@ +--- +--- + +Codecov integration + coverage badge (#471). CI now runs `bun test --coverage` (per-workspace lcov) and `pytest --cov` (sdk/python coverage.xml) and uploads both via `codecov/codecov-action@v4` — separate `bun` / `python` flags so a regression in one language is obvious. New `codecov.yml` commits to a realistic 70% project / 80% patch target and excludes the four god-files awaiting decomposition (SkillDetailPage, DocsPage, PlaygroundPage, bootstrap.ts) so the headline number reflects code that's reasonably testable today. README gains the Codecov badge. diff --git a/.changeset/concurrent-token-refresh-dedup-631.md b/.changeset/concurrent-token-refresh-dedup-631.md new file mode 100644 index 00000000..01c9e4b9 --- /dev/null +++ b/.changeset/concurrent-token-refresh-dedup-631.md @@ -0,0 +1,19 @@ +--- +"ornn-web": patch +--- + +Deduplicate concurrent NyxID token-refresh requests (#631). + +`authStore.refreshToken()` could fire 2–4 times within the same millisecond — `apiClient.fetchWithRetry`'s proactive `ensureFreshToken()`, its reactive 401-retry path, the scheduled `startTokenRefresh` `setTimeout`, and the `visibilitychange` handler all converge near the expiry boundary. Each fired its own `POST /oauth/token` with `grant_type=refresh_token`. + +NyxID rotates the refresh token on every successful exchange. The second concurrent caller therefore lost the rotation race and got: + +```json +{"error":"invalid_request","error_description":"Conflict: Refresh token was concurrently rotated, please retry"} +``` + +…which the SPA's `refreshToken` `catch` interpreted as a hard failure: it nulled the access + refresh tokens and surfaced an unexpected logout. Users observed it as "I came back to the tab and got logged out". + +Fix funnels every caller through a single `_refreshInFlight: Promise | null` slot on the store. The first caller stores the promise; subsequent callers `await` the same one. The slot is cleared in `finally` so a later (truly new) refresh starts fresh. + +Slot is excluded from `partialize` — Promises aren't serialisable and the dedup window only matters within a tab's lifetime. diff --git a/.changeset/cors-cleanup-528.md b/.changeset/cors-cleanup-528.md new file mode 100644 index 00000000..d8bf54b8 --- /dev/null +++ b/.changeset/cors-cleanup-528.md @@ -0,0 +1,13 @@ +--- +"ornn-web": patch +--- + +Remove dead `X-User-*` headers from skill-create + activity log (#528). + +`createSkill` (POST /api/v1/skills — used by Free / Guided / AI-generated save) and `logActivity` (POST /api/v1/activity/login|logout) still attached `X-User-Email` / `X-User-Display-Name` headers, leftover from a pre-NyxID-proxy auth model where the backend read identity off these headers. The backend hasn't read them in months (identity comes from the proxy-forwarded JWT), and the `apiClient.createHeaders` cleanup that struck the same code from the shared client missed these two raw-`fetch` callers. + +Sending them caused the browser's CORS preflight to ask permission for `X-User-Email` and `X-User-Display-Name`. The backend CORS allowlist is `["Content-Type", "Authorization"]` (`bootstrap.ts:744`), so the preflight response didn't include those headers — the browser then blocked the actual `POST` with a CORS error. End user sees: "Save Skill" never completes, DevTools shows preflight `204` then a `CORS error` on the real request. + +Net change: both callers now send only `Content-Type` + `Authorization` (matching the rest of the SDK), the preflight allow-headers list is fully satisfied, and the real request goes through. + +This is the definitive fix for the `POST /skills` case. The `PUT /skills/:id` case tracked in #565 doesn't send `X-User-*` itself, but the parallel login-time `logActivity` failure here was producing a CORS-error toast that could be misattributed to the in-flight PUT — worth re-verifying #565 after this lands. diff --git a/.changeset/cursor-pagination-457-465.md b/.changeset/cursor-pagination-457-465.md new file mode 100644 index 00000000..83395a3a --- /dev/null +++ b/.changeset/cursor-pagination-457-465.md @@ -0,0 +1,32 @@ +--- +"ornn-api": minor +"@chronoai/ornn-sdk": minor +--- + +Cursor pagination on `/skill-search` per CONVENTIONS.md §4.3 + SDK auto-pagination iterator (#457 + #465). + +**API (`/api/v1/skill-search`)** + +- Accepts `?cursor=` (alongside the existing `?page=N`). When both are sent, `cursor` wins. +- Accepts `?limit=N` as an alias for the existing `?pageSize=N`. +- Response now carries a `meta` envelope: `{ data: { items, total, page, pageSize, totalPages, meta: { limit, hasMore, nextCursor? } }, error }`. The legacy fields stay until they're sunset — clients can migrate at their own pace. +- A malformed cursor returns `400 invalid_cursor` (RFC 7807 problem+json) instead of silently falling back to page 1. +- Cursor payload is server-internal (`{ page: number }` today, `lastSort` keyset in a future PR) — clients MUST treat it as opaque. + +**SDK (`@chronoai/ornn-sdk`)** + +- `client.search()` now accepts `cursor` + `limit` params (additive). +- New `client.searchAll({ q })` returns an `AsyncIterableIterator`. Threads `meta.nextCursor` automatically; terminates on `hasMore === false` or no more cursor. 10k-page safety cap. + +```ts +for await (const skill of client.searchAll({ q: "pdf" })) { + console.log(skill.name); +} +``` + +**Out of scope (follow-up)** + +- Real lastSort keyset cursor under the hood — current cursor encodes `{ page }` so the wire contract conforms to §4.3 while the underlying query stays offset-based. Switching the payload is invisible to clients. +- Cursor support on other list endpoints (categories, tags, users) — those keep their existing offset shape for now. +- Python SDK `search_all()` — follow-up. +- `Sunset:` header on the legacy `page`/`pageSize` shape — once cursor adoption is high enough. diff --git a/.changeset/delete-dead-skill-repo-577.md b/.changeset/delete-dead-skill-repo-577.md new file mode 100644 index 00000000..f4988279 --- /dev/null +++ b/.changeset/delete-dead-skill-repo-577.md @@ -0,0 +1,5 @@ +--- +"ornn-api": patch +--- + +Delete dead `domains/skills/crud/repositories/` subdirectory (#577) — a 218-line `SkillRepository` impl + interface + test that no route, service, or test outside the directory itself imported. The live skill repository (915 lines) lives at `domains/skills/crud/repository.ts` and is unaffected. diff --git a/.changeset/dist-tags-463.md b/.changeset/dist-tags-463.md new file mode 100644 index 00000000..0e6c4e46 --- /dev/null +++ b/.changeset/dist-tags-463.md @@ -0,0 +1,29 @@ +--- +"ornn-api": minor +--- + +Add dist-tags for skill versions (#463). Lets callers pin to a stable channel without enumerating versions or hard-coding numbers, matching the shape npm / yarn / pnpm exposes. + +**New surface** + +```http +GET /api/v1/skills/{idOrName}/dist-tags → { tags: { latest, stable, ... } } +PUT /api/v1/skills/{id}/dist-tags/{tag} Body: { version } +DELETE /api/v1/skills/{id}/dist-tags/{tag} +GET /api/v1/skills/{idOrName}?version=@stable → resolves via dist-tag +``` + +`SkillDetailResponse` now carries a `distTags` field on every read. + +**Semantics** + +- `latest` is **auto-managed**. Every successful publish sets `distTags.latest = newVersion`. `PUT` / `DELETE` against `latest` return 400 `dist_tag_immutable`. +- Custom tags (`stable`, `beta`, `rc-1`, ...) are owner-managed. Tag names match `/^[a-z][a-z0-9-]{0,49}$/` — npm rules, leading letter required so tags don't look like version numbers. +- Setting a tag for a non-existent version returns 404 `skill_version_not_found`. +- `?version=@latest` falls back to `skill.latestVersion` on legacy skills predating this PR so the resolution path stays compatible. + +**Out of scope** + +- TS / Python SDK helper methods around dist-tags — the endpoints work directly via the raw client. SDK convenience wrappers ride in a follow-up so this PR stays scoped. +- OpenAPI spec entries for the new paths — `/api/v1/openapi.json` is already incomplete for `/skills/:id/*` write paths; the bigger contract-test pass in #462 will pick all of them up at once. +- schemastore-style schema for the dist-tag write body (not visible in any IDE flow today). diff --git a/.changeset/docker-compose-466.md b/.changeset/docker-compose-466.md new file mode 100644 index 00000000..8bf3b004 --- /dev/null +++ b/.changeset/docker-compose-466.md @@ -0,0 +1,14 @@ +--- +"ornn-api": patch +"ornn-web": patch +--- + +Ship a `docker-compose.yml` for one-command local dev (#466). Brings up MongoDB, MinIO, `ornn-api`, and `ornn-web` in a single `docker compose up`. README's new "Run Ornn locally (5 minutes)" section and `CONTRIBUTING.md`'s rewritten "Getting set up" tier the prerequisites by what each contributor actually needs: + +- **Unit tests / lint / typecheck:** just Bun + Docker. +- **Running the services:** `docker compose up`. +- **Full integration with NyxID / chrono-storage / chrono-sandbox / opensandbox:** the existing K8s manifests under `deployment/`. + +NyxID stays out of compose deliberately — mocking the OAuth + JWT-signing path is non-trivial and would either ship a fake or pin to a real staging. Public endpoints (`/livez`, `/api/v1/skill-format/rules`, `/api/v1/skill-manifest-schema.json`, OpenAPI spec) work without auth, which is enough for most contributor flows. Auth-required endpoints need `NYXID_BASE_URL` pointed at your own NyxID instance — same model the existing `deployment/.env.ornn` uses. + +Includes a sample `.env.compose.sample` with the only knob a contributor typically overrides (`ENCRYPTION_KEY`). diff --git a/.changeset/docs-page-decompose-453.md b/.changeset/docs-page-decompose-453.md new file mode 100644 index 00000000..d3664793 --- /dev/null +++ b/.changeset/docs-page-decompose-453.md @@ -0,0 +1,17 @@ +--- +"ornn-web": patch +--- + +Decompose DocsPage into 5 colocated components (#453). + +`pages/DocsPage.tsx`: **905 → 303 lines (−67%)**, just over the issue's 300-line target. Five new components under `components/docs/`: + +- `DocsMermaid` (244L) — MermaidBlock + MermaidLightbox + SandboxedSvg + per-theme palettes +- `DocsMarkdownComponents` (139L) — `markdownComponents` object for `` plus the shared `slugify()` helper +- `DocsReleaseAccordion` (139L) — ReleaseAccordion + VersionBadge +- `DocsSidebar` (112L) — collapsible left rail +- `DocsTableOfContents` (69L) — sticky right minimap + +DocsPage now only carries page state (active doc, scroll-spy active heading, frontmatter parse, copy-doc handler) and the layout shell. Behavior unchanged; 110 web tests still pass. + +Second PR under #453 (after SkillDetailPage in #651). PlaygroundPage (813L) still pending its own PR. diff --git a/.changeset/drop-dead-author-backfill-254.md b/.changeset/drop-dead-author-backfill-254.md new file mode 100644 index 00000000..6d7ebde1 --- /dev/null +++ b/.changeset/drop-dead-author-backfill-254.md @@ -0,0 +1,14 @@ +--- +"ornn-api": patch +--- + +Delete the dead `backfill-skill-author-display-names.ts` migration script (#254). + +The script joined `skills.createdBy` against an `activities` collection to retro-populate `createdByEmail` / `createdByDisplayName` on legacy skill rows. Both prerequisites are gone: + +- New skills cache the author labels at create time (no backfill needed for any post-#239 row). +- The `activities` collection was retired in #271 (PostHog took over the audit pipeline) — no source-side reference to it remains in `ornn-api/src`. The script would read from an empty / nonexistent collection on any current deployment. + +The bug #254 originally reported (`$last` in an unsorted aggregation picking arbitrary rows) is moot for a script that can't run usefully anyway. Cleaner to delete than to fix code that's known dead. Also removes the companion test file. + +If a future deployment unearths a database still carrying `activities`, the right cleanup is a fresh one-shot migration scoped to that database, not resurrecting this script. diff --git a/.changeset/drop-ownerid-581.md b/.changeset/drop-ownerid-581.md new file mode 100644 index 00000000..6764d9b2 --- /dev/null +++ b/.changeset/drop-ownerid-581.md @@ -0,0 +1,20 @@ +--- +"ornn-api": minor +"ornn-web": patch +"@chronoai/ornn-sdk": minor +--- + +**BREAKING:** drop the legacy `ownerId` field from skill responses (#581). The field was a no-op back-compat mirror of `createdBy` for an old "org-as-owner" design that visibility logic no longer consults — `createdBy` + `sharedWithUsers` + `sharedWithOrgs` is the authoritative ownership model. + +Removed from: + +- `ornn-api` — `SkillDocument`, `SkillDetailResponse`, `SkillSearchItem`, repository write path, search service mapping, routes response shape. No DB migration ships; old documents keep the field in storage, code just stops reading it. +- `@chronoai/ornn-sdk` (TypeScript) — `SkillDetail.ownerId` dropped. +- `ornn-sdk` (Python) — `SkillDetail.owner_id` dropped; `from_dict` tolerates the field appearing on stale responses by ignoring it. +- `ornn-web` — `SkillSearchResult.ownerId` dropped. + +Also clears three more dead exports flagged in the same issue: + +- `clients/nyxid/auth.ts` + its colocated test (the `AuthClient` was never mounted — its consumer middleware was deleted earlier). +- `INTERNAL_AUTH_HEADER` constant + `ApiKeyInfo` interface (both only referenced by the now-deleted `AuthClient`). +- `createErrorHandler` factory (live error handler is `app.onError` in bootstrap; the factory had no callers). diff --git a/.changeset/edit-skill-page-pass-guid-565.md b/.changeset/edit-skill-page-pass-guid-565.md new file mode 100644 index 00000000..054b9a20 --- /dev/null +++ b/.changeset/edit-skill-page-pass-guid-565.md @@ -0,0 +1,13 @@ +--- +"ornn-web": patch +--- + +EditSkillPage hands the resolved skill GUID to write mutations (#565). + +Background: PR #586 tightened the backend's `PUT /skills/:id` and `DELETE /skills/:id` routes to resolve via `skillRepo.findByGuid` only — no fallback to `findByName`. The SPA's owner-edit page route stays human-readable (`/skills/:name/edit`) for shareability, but the page was passing the URL `:id` (the skill name) directly into `useUpdateSkill` / `useUpdateSkillPackage`. The mutations then built `PUT /api/v1/skills/` and the backend returned 404, surfacing on the live cluster as "Failed to update package" with no visible reason. + +End-to-end QA on the local cluster (2026-05-22) confirmed the failure. Owner-edit was 100% broken — every PUT returned `404 skill_not_found`. + +Fix: resolve through `useSkill(id)` first (which accepts name OR GUID) and pass `skill.guid` to both write hooks. The fallback to the URL `:id` only matters on the first paint before skill data arrives; both mutations are gated behind the `isLoading` / `!skill` early returns, so the user can never click a button while the resolved id is still pointing at the name. Same pattern as `useSkillDetail.ts:77`. + +Coverage: new `EditSkillPage.test.tsx` mocks `useSkill` to return `{ guid: "abc-123", name: "my-public-skill" }` and asserts the write hooks both receive `"abc-123"`, not `"my-public-skill"`. The test fails on the pre-fix code. diff --git a/.changeset/env-var-30-item-cap-683.md b/.changeset/env-var-30-item-cap-683.md new file mode 100644 index 00000000..1a30016b --- /dev/null +++ b/.changeset/env-var-30-item-cap-683.md @@ -0,0 +1,9 @@ +--- +"ornn-web": patch +--- + +Guided-create env-var input now enforces the 30-item cap inline (#683). + +`runtime-env-var` is capped at 30 in both the frontend frontmatter schema (`shared/schemas/skillFrontmatter.ts`) and the backend Zod schema. The Guided wizard's env-var input was a `MultiValueInput` with no `max` prop, so it defaulted to 50 — the page happily accepted chip 31. Submit-time Zod validation then blocked navigation, but the NEXT button stayed enabled with no inline feedback; users saw "click NEXT, nothing happens". + +Passing `max={30}` to the env-var `MultiValueInput` surfaces the cap in the field header (`N/30`), disables the input at the cap, and rejects pasted-past-cap input inline with the existing "Maximum 30 values" copy. Runtime dependencies (capped at 50 in both schemas) stay at the default, matching their real limit. diff --git a/.changeset/error-envelope-extraction-694.md b/.changeset/error-envelope-extraction-694.md new file mode 100644 index 00000000..6d989a00 --- /dev/null +++ b/.changeset/error-envelope-extraction-694.md @@ -0,0 +1,11 @@ +--- +"ornn-web": patch +--- + +`apiClient.handleResponse` now extracts the actionable `error.message` from the legacy `{ data: null, error: { code, message } }` envelope on non-2xx responses, in addition to the RFC 7807 `application/problem+json` shape it already handled (#694). + +Pre-#694, `handleResponse` parsed only `body.code / body.detail / body.title` on non-2xx. Several backend domains (LLM provider model sync, settings validation under #456, anything still funnelling through `AppError → buildErrorEnvelope`) keep emitting the legacy envelope, so their structured `error.message` was discarded and the frontend surfaced only the generic literal "An unexpected error occurred" — losing actionable detail like `"MODEL_LIST_UNREACHABLE: Provider model-list endpoint failed: …"` or `"INVALID_SETTING: postHogHost: URL host is private/loopback/link-local; set ORNN_URL_ALLOWLIST_CIDR to allow"`. + +Fix: in the non-2xx branch, try `body.error.{code,message}` first (the more actionable shape when present), then fall back to RFC 7807's `body.{code,detail,title}`, then to the generic literal. `ApiClientError.code` and `.message` now carry whichever was richer. + +Out of scope: no new unit test added because the existing module-init chain (`apiClient → authStore → …`) fails to load cleanly in vitest's headless environment without additional setup; the per-domain `onError` callsites already exercise `translateError(err, fallback)` against real `ApiClientError` instances in `pages/admin/settings/*` and `components/skill/*` flows. Manual repro per the issue body (LLM provider sync against an invalid model-list URL; saving PostHog config with a loopback host) is the gate. diff --git a/.changeset/errors-deprecations-docs-576.md b/.changeset/errors-deprecations-docs-576.md new file mode 100644 index 00000000..5056a1e5 --- /dev/null +++ b/.changeset/errors-deprecations-docs-576.md @@ -0,0 +1,4 @@ +--- +--- + +Publishes `docs/ERRORS.md` and `docs/DEPRECATIONS.md` (#576) — the GitHub-anchored catalogs that `application/problem+json` `type` URLs and `Link: rel="deprecation"` headers point at. `ERRORS.md` documents all ten target `lowercase_snake_case` codes with `##` headings (so anchors resolve) and lists every `SCREAMING_SNAKE_CASE` code currently emitted in an appendix mapping table, owned by the #585 case migration. `DEPRECATIONS.md` ships empty (alpha = no deprecation cycle yet) with the entry template ready for v1. `CONVENTIONS.md` `type` and `Link` example URLs updated to point at the new paths. diff --git a/.changeset/exact-optional-api-657.md b/.changeset/exact-optional-api-657.md new file mode 100644 index 00000000..c29ed208 --- /dev/null +++ b/.changeset/exact-optional-api-657.md @@ -0,0 +1,21 @@ +--- +"ornn-api": patch +--- + +Enable `exactOptionalPropertyTypes` on ornn-api (#657 part 1). + +Closes the ornn-api half of the deferred work from #450. Enabling the flag surfaced 77 errors across ~35 files. Patterns: + +1. **Optional class fields** assigned from optional deps widen to `T | undefined`. Clients (NyxidOrgsClient, SandboxClient, StorageClient), services (QuotaService.notificationService, AuditService.notificationService/nyxidOrgsClient, SkillService.analyticsEmitter/agentsealScanner). + +2. **Optional interface fields** widen to `T | undefined` so call sites passing Zod-inferred shapes (`{ field: T | undefined }`) fit: SettingsActor, RedemptionCodeDoc, AuditRecord, CreateSkillData, CreateSkillVersionData, GitHubPullInput, SkillDocument, SkillDetailResponse, SkillSearchItem, SkillSource, ExportImportRoutesConfig, SettingsAuditLogger, GeneratedSkill, FetchedBundle, FetchOptions, ExtraFilters, search service params, UpdateAnnouncementInput, UpdateBroadcastDocInput, UpdateBroadcastParams, PlaygroundChatRequest. + +3. **Conditional spread at call sites** for routes/services passing Zod-validated bodies: admin-users, admin/quota, admin/redemption-codes, analytics, notifications, playground, quota, skills setNyxidService, generation resolveModel, analytics emitter + posthog capture, apiRequestTracking, nyxidAuth. + +4. One Zod refinement param widened to `Record` (announcements `assertCtaPairing`) so it works against both create + update schemas under the stricter inferred types. + +5. One cast in skill generation — Zod's `.optional()` produces `outputType: "text" | "file" | undefined` (explicit-undefined-non-optional) vs the interface's `outputType?:` (optional-with-undefined). Same shape; cast bridges the contract. + +No behavior change — every fix is a type-only nudge. 793 backend tests still pass; typecheck clean. + +ornn-web's `exactOptionalPropertyTypes` (~134 errors) is the remaining half of #657 and ships in a follow-up commit. diff --git a/.changeset/exact-optional-web-657.md b/.changeset/exact-optional-web-657.md new file mode 100644 index 00000000..b844bd47 --- /dev/null +++ b/.changeset/exact-optional-web-657.md @@ -0,0 +1,19 @@ +--- +"ornn-web": patch +--- + +Enable `exactOptionalPropertyTypes` on ornn-web (#657 part 2). + +Closes the ornn-web half of the deferred work from #450. Enabling the flag surfaced 134 errors across ~40 files. Patterns mirror part 1: + +1. **Optional component-prop interfaces widen to `T | undefined`** — UI primitives (Input, Select, Badge, MarkdownEditor), form inputs (TagInput, ToolsInput, MultiValueInput, RuntimeSelect), FileTree + SkillFileViewer + SkillPackagePreview, SkillVersionList + SkillVersionsBrowserModal, SkillHeroStrip + AuditVerdictPill + DeprecationBanner, SectionShell, Toast hooks, SkillCard, ExplorePage's TabButton/FilterSidebar/SystemFilters, GenerationChatMessage CompleteBubble, ChatMessage ToolResultMessage. PageTransition uses conditional spread for motion.div's `exit`. + +2. **Service-layer + hook params widen** — useSkillAnalytics, useSkillPulls, useSkillAuditHistory, useSystemSkills/useSkills/useMySkills/useSharedWithMeSkills, useAdminQuotaUsers, ChatStreamParams, GenerateStreamParams, SkillSearchParams, SkillSearchResult, SkillVersionEntry, ChatDisplayMessage, GrantInput/BulkGrantInput, StartAuditInput, AdminRedemptionCodeFilters, BufferedCall (analytics). + +3. **Settings sections widen via SectionMeta** — `updatedAt`/`updatedBy` accept `T | undefined` so Zod-inferred shapes (Playground, SkillGen, Mirror, NyxId, SkillAudit, Telemetry, Extras) round-trip cleanly. + +4. **One type bridge**: `INK_OVERRIDES as unknown as never` for framer-motion's stricter MotionStyle — custom CSS-variable keys aren't expressible in either CSSProperties or MotionStyle under the stricter flag. + +No behavior change — every fix is a type-only nudge. 110 web tests + 793 backend + 17 sdk = 920 tests pass; typecheck clean. + +Closes #657 (both halves now landed). diff --git a/.changeset/examples-dir-469.md b/.changeset/examples-dir-469.md new file mode 100644 index 00000000..a7900ddd --- /dev/null +++ b/.changeset/examples-dir-469.md @@ -0,0 +1,4 @@ +--- +--- + +Adds `examples/` with three minimal copy-paste starter skills (#469): `text-summarizer` (TS / LLM-backed), `csv-processor` (Python / stdlib-only deterministic), `api-fetch-wrapper` (TS / external HTTP with retries + no-leak errors). Each has a working `SKILL.md` with valid frontmatter, a short README, and an entrypoint that runs locally. Main README gains an `## Examples` section + nav anchor. Each example documents how to adapt it — the three failure-mode archetypes (LLM, pure-local, external HTTP) cover the patterns a real skill author actually has to handle. diff --git a/.changeset/filter-deactivated-nyxid-services-715.md b/.changeset/filter-deactivated-nyxid-services-715.md new file mode 100644 index 00000000..4727c403 --- /dev/null +++ b/.changeset/filter-deactivated-nyxid-services-715.md @@ -0,0 +1,18 @@ +--- +"ornn-api": patch +--- + +`/skill-facets/system-services` now drops services NyxID has deactivated, and the NyxID catalog cache TTL drops from 60s → 10s (#715). + +Background: when NyxID deactivates a service (`DELETE /api/v1/services/:id` is a soft delete that flips `is_active: false`), Ornn kept exposing it. The DB aggregation behind `/skill-facets/system-services` reads `nyxidServiceId/slug/label` straight off skill documents, so any skill ever bound to that service still surfaced the service as a usable filter chip. Per-caller paths (`/me/nyxid-services`, `/nyxid-services/:serviceId/skills`) already filtered `is_active=false` inside `NyxidServiceClient.listServicesForCaller`, but the 60-second cache widened the visibility lag after deactivation. + +Fix: + +- `NyxidServiceClient.listActiveServiceIdsAsPlatform(saToken)` (new): SA-token fetch of NyxID's `/services`, projected to a `Set` of active service ids. Separate from the per-caller cache (one slot — SA view is uniform). Fail-soft: returns `null` on non-2xx or thrown fetch so callers preserve current behaviour when NyxID is unreachable. Same 10s TTL as the per-caller cache. +- `cacheTtlMs` lowered from `60_000` to `10_000`. After a NyxID deactivation, every Ornn surface that goes through `findVisibleToCaller` (`/me/nyxid-services`, reverse lookup) drops the service within at most 10s instead of 60s. +- `invalidateCache()` also clears the platform cache so admin-side hooks can force a refresh. +- `/skill-facets/system-services` (search/routes) now intersects the DB aggregation with the platform active set when `nyxidServiceClient` + `getSaAccessToken` are wired in; falls through to the pre-#715 raw aggregation if either is missing or the SA fetch fails. Bootstrap wires both. + +Out of scope: skill detail still shows the historical `nyxidServiceId/slug/label` even when the service is deactivated. The QA report lists this as one of several acceptable mitigations; the simplest "stop misrepresenting the service as usable" path is to ensure the facet (the discovery surface) doesn't advertise it. A follow-up can mark the detail panel as "service unavailable" if we want a louder signal. + +Coverage: colocated `clients/nyxid/service.test.ts` exercises the per-caller `is_active=false` drop (pre-existing defence-in-depth), missing-`is_active` default-to-active, the new platform method (SA token + URL, caching, fail-soft on 5xx and network throw, empty SA short-circuit, `invalidateCache` re-fetch). 8 tests, all green. diff --git a/.changeset/folder-file-upload-validation-655.md b/.changeset/folder-file-upload-validation-655.md new file mode 100644 index 00000000..d0280527 --- /dev/null +++ b/.changeset/folder-file-upload-validation-655.md @@ -0,0 +1,20 @@ +--- +"ornn-web": patch +--- + +Guided creation supporting-file upload now validates duplicate filenames + per-file size (#655). + +The Guided wizard's Step 3 file uploader accepted whatever the user dropped — including a second `run.js` into the same `scripts/` folder (silently stacked, would have produced two entries with the same path in the final ZIP) and a 55 MB blob (no warning, would have blown through the backend's 100 MB total cap on its own). + +Both guards now live inside `FolderFileUpload`'s `handleFileSelect`, so click + drop paths share them: + +1. **Duplicate filename inside the same target folder** → rejected with inline ` already exists in /. Remove it first to replace.` Auto-overwrite would silently drop content the user hadn't intentionally removed; making them hit Remove keeps the action explicit. Cross-folder duplicates stay allowed (different paths in the final ZIP). +2. **Per-file size cap of 10 MiB** → rejected with ` is — over the 10 MiB per-file limit.` Backend / ZIP pipeline still caps total uncompressed at ~100 MB (#443 / #633); 10 MiB per file keeps a single oversize artifact from eating the whole budget and surfaces the cap early. + +Both errors render under the drop zone with `aria-live="polite"`, auto-clear on the next successful upload or folder switch, and explicitly do NOT call `onUpload` — parent state stays untouched on rejection so there's nothing for the user to undo. + +A persistent `Per-file limit: 10 MiB` hint now sits above the file list so the cap is discoverable before the user picks a file. + +i18n: 3 new keys per locale (`guided.fileSizeHint`, `guided.fileTooLarge`, `guided.fileDuplicate`) in EN + ZH. + +Pinned with `FolderFileUpload.test.tsx` (6 assertions covering accept under cap, same-folder dup reject, cross-folder dup allowed, size-cap reject, error-clears-on-success, hint always visible). diff --git a/.changeset/free-zip-upload-status-652.md b/.changeset/free-zip-upload-status-652.md new file mode 100644 index 00000000..fcc529fc --- /dev/null +++ b/.changeset/free-zip-upload-status-652.md @@ -0,0 +1,9 @@ +--- +"ornn-web": patch +--- + +Free / ZIP upload no longer shows contradictory "structure is valid" on backend reject (#652). + +When the frontend validator flagged a ZIP as `invalid`, the user could flip `Skip validation` and submit anyway. The backend then rejected (e.g. `SKILL.md not found in package`), the rejection toast fired — but the page banner unconditionally fell back to `valid` and rendered "Skill package structure is valid." on top of the toast. + +`CreateSkillFreePage` now restores `pageState` to the original `validationResult.status` after a failed submit (`invalid` stays `invalid`, `warning` stays `warning`, `valid` stays `valid`). The success banner can never appear on a structure the frontend itself flagged as bad. diff --git a/.changeset/friendly-sandbox-errors-530.md b/.changeset/friendly-sandbox-errors-530.md new file mode 100644 index 00000000..fe898982 --- /dev/null +++ b/.changeset/friendly-sandbox-errors-530.md @@ -0,0 +1,13 @@ +--- +"ornn-api": patch +--- + +`runSandboxOneShot` now translates chrono-sandbox HTTP errors into a one-line user-facing message and logs structured diagnostics, so the playground transcript stops surfacing raw JSON like `Sandbox service error (500): {"error":"internal_error","error_code":1006,"message":"An internal error occurred"}` (#530). + +Background: `SandboxClient.post` throws an `Error` whose message is `"Sandbox service error (): "`. The playground catch handler spat that string straight into the chat. The QA's repro on the `nyxid` skill (`https://ornn.chrono-ai.fun/playground?skill=nyxid` → `LIST CAPABILITIES`) hit a chrono-sandbox internal 500 with `error_code: 1006` and the transcript showed the raw JSON envelope verbatim — useless to the user, and presented as if the failure were the user's fault. + +Fix: new `formatSandboxError` helper parses the `"Sandbox service error (): "` shape. If the body is the structured `{ error, error_code, message }` envelope, it returns a friendly sentence keyed off `status` (500 / 1006 → transient sandbox-server hint, 503/504 → timeout hint, anything else → `HTTP [code N]: `). When the body isn't JSON or the regex doesn't match, it falls back to the raw message so any new upstream shape still reaches the operator. Pino `error` log carries `language`, `scriptLen`, and the raw error message so admins can grep production for 1006-class failures without scrolling chat transcripts. + +The underlying chrono-sandbox 500s themselves are out of scope — those originate inside the sandbox runtime, not Ornn. This change is about UX of the failure surface. + +`runSandboxToolCall`'s `sessionExecute` catch path stays as-is (it already falls back to the one-shot path, which now formats the error before returning). diff --git a/.changeset/frontend-logger-584.md b/.changeset/frontend-logger-584.md new file mode 100644 index 00000000..138cc5d1 --- /dev/null +++ b/.changeset/frontend-logger-584.md @@ -0,0 +1,5 @@ +--- +"ornn-web": patch +--- + +Production-mode console silencing for auth + analytics + apiClient + activityApi (#584). Introduces `ornn-web/src/lib/logger.ts` — a `createLogger(tag)` factory that emits via `console` in development and no-ops in production. Replaces the four ad-hoc per-module loggers that previously leaked auth lifecycle metadata (refresh timing, token expiry, user ids) to browser devtools where it persisted across the session. Dev experience unchanged. diff --git a/.changeset/frontmatter-actionable-errors-649.md b/.changeset/frontmatter-actionable-errors-649.md new file mode 100644 index 00000000..1428e95b --- /dev/null +++ b/.changeset/frontmatter-actionable-errors-649.md @@ -0,0 +1,14 @@ +--- +"ornn-api": patch +--- + +Frontmatter validation errors now tell the user what to fix (#649). + +The Free / ZIP upload page already shows clear actionable messages for most validation failures (`version` semver rule, tag-regex rule, env-var UPPER_SNAKE_CASE rule), but the `tag`, `runtime`, `tool-list`, `runtime-env-var`, and `runtime-dependency` *item* schemas surfaced as bare `Invalid input: expected string, received null` when an author hit common YAML mistakes: + +- `tag: - ` (empty list-item dash) → YAML parses as `null` +- `version: 0.1` (unquoted) → YAML parses as a number — already addressed in an earlier pass; pinned with a test here so it can't regress. + +Fix is additive: each item schema gains a Zod 4 `error` callback that handles `invalid_type` with a clear sentence including a concrete shape example (`tag: [my-tag]`, `runtime: [python]`, `runtime-env-var: [OPENAI_API_KEY]`, etc.). The existing `min`/`max`/`regex` messages still fire for non-null shape problems. + +Pinned with a new `skillFrontmatter.test.ts` — 8 assertions covering version-quoting, null-tag, uppercase-tag (regex preservation), null-env-var, null-runtime, null-tool, null-dependency, and a happy-path round trip. diff --git a/.changeset/frontmatter-invalid-type-actionable-649.md b/.changeset/frontmatter-invalid-type-actionable-649.md new file mode 100644 index 00000000..e05f7fd0 --- /dev/null +++ b/.changeset/frontmatter-invalid-type-actionable-649.md @@ -0,0 +1,13 @@ +--- +"ornn-web": patch +--- + +Frontmatter pre-validator surfaces actionable error copy for non-string YAML values (#649). + +PR #672 landed the actionable `invalid_type` callbacks on the backend Zod schema at `ornn-api/src/shared/schemas/skillFrontmatter.ts`, but the SPA carries a separate pre-upload validator at `ornn-web/src/utils/skillFrontmatterSchema.ts` that still used bare `z.string()`. The frontend gate fires first on the client, so users who uploaded a SKILL.md with `version: 0.1` (unquoted, YAML → number) still saw the unhelpful default `version: Invalid input` — the backend's friendly message never reached the page. + +Mirrors the backend pattern on the frontend for the six affected fields (`version`, `tag`, `runtime-env-var`, `tool-list`, `runtime`, `runtime-dependency`). Each schema now carries an `error: (issue) => issue.code === "invalid_type" ? issueMessage({ key: "errors.frontmatter.…InvalidType" }) : undefined` callback. Six new i18n keys land in both `en.json` and `zh.json` so Chinese users see localized copy too. + +Author-visible result: uploading a ZIP with `version: 0.1` now shows "version must be a quoted string — write `version: \"0.1\"` in SKILL.md, not `version: 0.1` (YAML parses the unquoted form as a number…)". Existing `versionFormat` / `tagFormat` / `envVarFormat` messages still fire for wrong-shape strings — only the wrong-type path was missing actionable copy. + +Coverage: new `skillFrontmatterSchema.test.ts` pins all six `invalid_type` branches plus three regression-guard cases (string-shape errors still route through the original keys; happy path still parses). diff --git a/.changeset/github-import-skip-validation-frontmatter-529.md b/.changeset/github-import-skip-validation-frontmatter-529.md new file mode 100644 index 00000000..4266b81e --- /dev/null +++ b/.changeset/github-import-skip-validation-frontmatter-529.md @@ -0,0 +1,19 @@ +--- +"ornn-api": patch +--- + +`Skip validation` on GitHub import now also bypasses the frontmatter Zod check (#529). + +`POST /skills/pull` (and the symmetric `POST /skills` + `POST /skills/:id/refresh` paths) accepted `skipValidation: true` but it only short-circuited the directory-structure validator (`validateZipFormat`). The frontmatter Zod schema in `extractSkillInfo` ran unconditionally, so importing a third-party skill (e.g. Anthropic's official skills repo) with non-Ornn-shaped frontmatter still failed with `frontmatter_validation_failed`. The toggle's name + tooltip both said "skip Ornn package format validation" — users reasonably expected the frontmatter check to be part of that surface. + +Fix extends the `skipValidation` semantics into `extractSkillInfo`. When the flag is set AND the strict schema rejects, the parser falls back to `extractSkillInfoLenient` — a best-effort extract that: + +- Requires `name` (no defensible fallback for the document-id). +- Defaults `version` to `0.1` when missing — downstream `parseVersion` still enforces the `.` format. +- Defaults `metadata.category` to `plain` (the safest category — no runtime / tool execution expected). User can edit post-import. +- Pulls `tags` only when the value looks plausibly correct (array of strings); dropped silently otherwise. +- YAML syntax errors still hard-fail — we can't import what we can't parse, no matter how lenient we want to be. + +The dry-run refresh preview also passes `skipValidation: true` to `extractSkillInfo` so a third-party-shaped SKILL.md doesn't kill the preview flow. + +All 805 existing tests pass; typecheck clean. diff --git a/.changeset/hero-asset-suite-690.md b/.changeset/hero-asset-suite-690.md new file mode 100644 index 00000000..f1c04ccb --- /dev/null +++ b/.changeset/hero-asset-suite-690.md @@ -0,0 +1,4 @@ +--- +--- + +Store the rest of the Ornn hero SVG suite under `ornn-web/public/` alongside the existing `hero-brand.svg` (#690). Adds `hero-brand-dark.svg` (dark variant for a future `` + `prefers-color-scheme: dark` swap in the README), `hero-architecture.svg`, and `hero-terminal.svg`. No README change in this PR — the assets are checked in so they're available for future docs work. Asset-only, no code or schema delta. diff --git a/.changeset/i18n-batch-695-696-697-719-722-731.md b/.changeset/i18n-batch-695-696-697-719-722-731.md new file mode 100644 index 00000000..bafdf005 --- /dev/null +++ b/.changeset/i18n-batch-695-696-697-719-722-731.md @@ -0,0 +1,25 @@ +--- +"ornn-web": patch +--- + +P1 i18n cluster: 6 admin/creation surfaces now follow the active UI language (#695, #696, #697, #719, #722, #731). + +Background: the global language switch translated the outer chrome but several surfaces leaked English (or in #731's case, Chinese) literals through `placeholder=`, `aria-label=`, hardcoded JSX text, and Zod inline messages. None of these are user-authored copy; they're application chrome. + +Fixes: + +- **#731 Broadcasts list dates**: `BroadcastsPage` rendered `createdAt`/`updatedAt` with `toLocaleString(undefined, ROW_DATE_FMT)`, so `Intl` fell back to the browser locale. Switched the first arg to `i18n.language` (already in scope as `lang`) so the date format follows the Ornn UI language. + +- **#695 Guided Create validation errors**: `skillCreateSchemas` had hardcoded English Zod messages (`"Name must be at least 2 characters"`, etc.). Introduced `makeBasicInfoSchema(t)` / `makeContentSchema(t)` factories; messages route through `guided.validation.*` keys. The static `basicInfoSchema` / `contentSchema` exports stay for type derivation and tests. `CreateSkillGuidedPage` memoizes a per-`t` localized schema and passes it to `useForm` so a language switch rebuilds the resolver. + +- **#696 Markdown editor (used by Broadcast drawer)**: three hardcoded strings in `MarkdownEditor.tsx` — `"Preview"`, `"Nothing to preview yet..."`, and the Markdown help-text — now route through `markdownEditor.*`. `BroadcastEditDrawer` itself was already fully `t()`-ified. + +- **#697 Admin Users table**: column header labels (`Username`, `Email`, `Skills`, `Last active`, `Activities`, `First joined`), the filter placeholder, the empty-state copy (`No users found.`), the row action label (`Grant quota`), the formatted-date null fallback (`Never`), and the sort `aria-label` (`Sort by ${col.label}`) all route through `adminUsersTable.*`. `COLUMNS` moved inside the component as a `useMemo` keyed on `t`. + +- **#719 NyxID service binding panel**: `AdvancedOptionsModal` already used `t()` with English string fallbacks for the binding intro, unbound option, unbound description, admin/personal tier labels, empty-state, and force-public warning — but the `nyxidService.*` keys were never added to either JSON. Added the full Chinese set (and codified the English fallback as the official `en.json` entry) so the translations actually resolve at runtime. + +- **#722 Admin shell**: `Sidebar` mobile header (`"Navigation"`) and `SettingsNav`'s 9 hardcoded entries (`"LLM Providers"`, `"Playground"`, `"Skill Generation"`, `"GitHub Mirror"`, `"NyxID Integration"`, `"Skill Auditing"`, `"PostHog"`, `"Service Binding List"`, `"Export / Import"`) now route through `sidebar.navigation` / `adminSettingsNav.*`. + +JSON additions: 5 new top-level namespaces in both `en.json` and `zh.json` — `nyxidService`, `markdownEditor`, `adminUsersTable`, `sidebar`, `adminSettingsNav` — plus a `guided.validation` block. No deletions or renames in existing keys. + +Out of scope: `AnnouncementsPage` shares the same `ROW_DATE_FMT, undefined)` pattern as the Broadcasts page; can ride a follow-up since #731 only names Broadcasts. Pre-existing `validateSkillFrontmatter #649` test failures (6) are unrelated. diff --git a/.changeset/idempotency-key-middleware-459.md b/.changeset/idempotency-key-middleware-459.md new file mode 100644 index 00000000..957973ce --- /dev/null +++ b/.changeset/idempotency-key-middleware-459.md @@ -0,0 +1,15 @@ +--- +"ornn-api": minor +--- + +Implement the `Idempotency-Key` header documented in CONVENTIONS.md §3.4 (#459). + +State-changing requests (`POST` / `PUT` / `PATCH` / `DELETE`) that include an `Idempotency-Key` header now get retry-safe replay semantics: the server fingerprints `(userId, method, path, key)` and caches the response (body + status + headers) in a new `idempotency_keys` Mongo collection for 24h. Retries within that window get the cached response back with `Idempotency-Replay: true` and the handler is NOT re-executed. + +Matches the `Idempotency-Key` shape Stripe / Square / AWS / GitHub already expose. Closes a real reliability gap where an agent timing-out on a network blip and retrying could create duplicate skills / redemptions / notifications. + +Scope decisions: +- Cache `2xx` + `4xx` responses (a validation error is deterministic for the same input); skip `5xx` (transient, retrying may succeed). +- Keys are scoped per `userId` so two unrelated callers using the same string can't collide. +- 24h TTL via a Mongo TTL index on `createdAt` — sweep cost is negligible at our request volume. +- Keys longer than 255 chars are silently bypassed rather than rejected, to avoid breaking every existing caller as soon as the middleware ships. diff --git a/.changeset/install-prompt-version-pin-639.md b/.changeset/install-prompt-version-pin-639.md new file mode 100644 index 00000000..603e9a00 --- /dev/null +++ b/.changeset/install-prompt-version-pin-639.md @@ -0,0 +1,28 @@ +--- +"ornn-api": minor +"ornn-web": patch +--- + +Install-card prompt now pins to the viewed version (#639). + +When a user opened an older skill version (`?version=0.2`) the URL + file viewer correctly switched to that version, but the install card's prompt was still latest-shaped: + +- The pull commands (`nyxid proxy request …/json` + the `curl …/json` line) had no `?version=…`, so an agent following the prompt would silently install `latest` at install time instead of the version the user was actually looking at. +- The prompt header didn't mention which version the user had viewed, so even careful agents couldn't tell. + +Fix is end-to-end: + +**Backend (`ornn-api`)** + +- `getSkillJson(idOrName, version?)` now accepts an optional `version` query — literal `.` OR a dist-tag (#463). When set, the response uses that version's `storageKey` + `metadata`; otherwise the latest package is returned (unchanged behaviour for legacy callers). +- Returns a new top-level `version` field so callers can confirm exactly which package they got. +- `GET /api/v1/skills/:idOrName/json` reads `?version=` and threads it through. Bad version → `400 invalid_version`; missing version → `404 skill_version_not_found` (RFC 7807). Visibility check unchanged. +- Bumped to **minor** because the response shape gains a new field. + +**Frontend (`ornn-web`)** + +- `buildTrySkillPrompt({…, version })` adds `?version=` to both pull URLs (curl + NyxID CLI via `--query version=…`), and to the footer `Ornn URL:`. Header line becomes `# Install Ornn skill: @ ` and a "Pinned to version ``" paragraph spells out why the URLs carry the query. +- `SkillInstallCard` passes the currently-viewed `skill.version` straight through, so the prompt always matches the page. +- No-version callers (theoretical "always pull latest" surfaces) are unaffected — the version is opt-in. + +Pinned with 3 new `buildTrySkillPrompt.test.ts` assertions (version-pinning surfaces, no-pin parity, dist-tag passes through unchanged) and the existing 9 cases re-verified. diff --git a/.changeset/integrity-hash-461.md b/.changeset/integrity-hash-461.md new file mode 100644 index 00000000..b0b2b206 --- /dev/null +++ b/.changeset/integrity-hash-461.md @@ -0,0 +1,5 @@ +--- +"ornn-api": patch +--- + +Surface npm-style Subresource Integrity on the skill version manifest (#461). `GET /skills/:idOrName/versions` now returns each version with an `integrity: "sha256-"` field alongside the existing hex `skillHash`. Clients (SDK + agents) verify a downloaded package byte-for-byte before installing — equivalent in spirit to npm's `package-lock.json` `integrity:` field and PyPI's per-file `sha256_digest`. The underlying hash was already computed at upload + stored on the version doc; this PR just derives the SRI form (`hexToIntegrity` helper) and surfaces it. diff --git a/.changeset/json-visibility-567.md b/.changeset/json-visibility-567.md new file mode 100644 index 00000000..7b8abd92 --- /dev/null +++ b/.changeset/json-visibility-567.md @@ -0,0 +1,5 @@ +--- +"ornn-api": patch +--- + +Enforce per-skill visibility on `GET /skills/:idOrName/json` (#567). The endpoint previously gated only on `ornn:skill:read`, so a caller who knew a private skill's name could fetch its full package contents through this route — broader than `/skills/:idOrName`, which applies `canReadSkill`. Now the JSON route loads the skill first and runs the same visibility check (`canReadSkill` against `createdBy` / `sharedWithUsers` / `sharedWithOrgs` + platform-admin permission), returning `SKILL_NOT_FOUND` for inaccessible private skills. Closes the leak surfaced by the `aevatar` `/v1/responses` Ornn bridge. diff --git a/.changeset/landing-tokens-452.md b/.changeset/landing-tokens-452.md new file mode 100644 index 00000000..4552aa7a --- /dev/null +++ b/.changeset/landing-tokens-452.md @@ -0,0 +1,5 @@ +--- +"ornn-web": patch +--- + +Replace hardcoded hex literals on the landing pages with design tokens (#452). `PhoneMockup` (bezel edges, side buttons, camera lens) and `AnimatedTerminal` (traffic-light dots, terminal-output green) previously inlined `#1a1713`, `#2a2620`, `#0b1520`, `#3a5060`, `#c94a4a`, `#c9a64a`, `#5a9b5a`, `#7dc97d` in `bg-[…]` / `shadow-[…]` / arbitrary-gradient strings. Adds eight tokens under `@theme` in `neon.css` (`--color-bezel-edge`, `-rim`, `-lens-deep`, `-lens-rim`, `-traffic-{red,amber,green}`, `-terminal-ok`) and rewires both files to use the generated `bg-bezel-edge` / `text-terminal-ok` / `var(--color-…)` utilities. Visual output unchanged. diff --git a/.changeset/llm-apiformat-routing-574.md b/.changeset/llm-apiformat-routing-574.md new file mode 100644 index 00000000..32906671 --- /dev/null +++ b/.changeset/llm-apiformat-routing-574.md @@ -0,0 +1,18 @@ +--- +"ornn-api": patch +--- + +NyxLlmClient now routes outbound LLM calls on the resolved provider's `apiFormat` (#574). + +Background: the admin LLM Provider form exposes `apiFormat: chat-completion | responses`, but the runtime client hard-coded `{gatewayUrl}/responses` for both `stream()` and `complete()` and ignored the setting. Providers behind the Chat Completions API — DeepSeek and any OpenAI-compatible gateway without `/responses` — returned 404 on every skill generation request, surfacing in the UI as a misleading "LLM Gateway error (404)" even though the model/key/gateway were all configured correctly. + +Fix: thread `apiFormat` through `resolveLlmProviderForSurface` (`bootstrap.ts`) into the new `LlmProviderResolution.apiFormat` field, and dispatch inside `NyxLlmClient`: + +- `responses` → `POST {gatewayUrl}/responses` with the native Responses-API body (unchanged behavior). +- `chat-completion` → `POST {gatewayUrl}/chat/completions` with a translated body (`input` → `messages`, `developer` role → `system`, `max_output_tokens` → `max_tokens`, `instructions` prepended as a `system` message, tools projected into OpenAI function-tool shape). The Chat Completions SSE stream is normalized so each `choices[].delta.content` chunk is yielded as a Responses-API `response.output_text.delta` event — consumers (skill generation + playground) stay format-agnostic. + +Tool-call delta normalization for the chat-completion path is intentionally out of scope here; it is tracked in #608 (playground runtime/mixed skills not triggering `execute_in_sandbox` under chat-completion providers). + +Trailing slashes on `gatewayUrl` are still trimmed before path concatenation, and the empty-`gatewayUrl` `LLM_PROVIDER_NOT_CONFIGURED` fail-closed branch is preserved. + +Coverage: new `src/clients/nyxid/llm.test.ts` covers both formats — endpoint dispatch, body translation (role/field/tool mapping), text-delta normalization, content-part flattening, SA-token fallback, trailing-slash trim, fail-closed, and non-2xx surfacing. 11 tests, all green. diff --git a/.changeset/llm-provider-save-and-default-pin-588-607.md b/.changeset/llm-provider-save-and-default-pin-588-607.md new file mode 100644 index 00000000..750ad836 --- /dev/null +++ b/.changeset/llm-provider-save-and-default-pin-588-607.md @@ -0,0 +1,19 @@ +--- +"ornn-api": patch +--- + +LLM provider: save preserves model list + Playground default pin is honoured (#588 + #607). + +Two related bugs in the LLM-providers admin surface, fixed together because the diagnosis touched the same `providerUpdateSchema` / `listPickerModels` surface: + +**#588 — Saving basic provider settings can clear the model list.** `providerUpdateSchema = providerCreateSchema.partial()` inherited the `models: z.array(...).default([])` from the create schema, so a PATCH that omitted `models` came back as `models: []`. The service then ran `if (patch.models)` — truthy on `[]` — and wiped the persisted list. `ProviderEditDrawer`'s basic-settings save sends only `name` / `gatewayUrl` / `apiFormat` / `auth` / `maxOutputTokens` / `defaultTemperature`, no `models` at all, so every basic-fields save nuked the provider's model catalog. Fix: `.extend({ models: z.array(modelInputSchema).optional() })` on the update schema so `undefined` (caller didn't send it) is distinguishable from `[]` (caller explicitly wiped); service uses `patch.models !== undefined` instead of truthy-check. Explicit `[]` still wipes — preserves the model-list-refresh-found-zero-models intent. + +**#607 — Playground saved default model not honoured by picker.** `listPickerModels` derived the default slot from the per-model `defaultForX` flag, not from the per-section `playground.defaultModelId` pin. So admins could save Playground settings with a chosen default, the setting persisted correctly, but `/me/models` returned a different model as default — picker pre-selected the wrong row and chat used it. The chat **execute** path's `resolveSurfaceDefaults` (in `bootstrap.ts`) DID honour the pin; only the picker disagreed. Fix: `listPickerModels(surface, sectionDefaultModelId?)` accepts the pin and the picker route resolves it via a new `sectionDefaultResolver` config function that reads `settingsService.getPlayground() / .getSkillGen()`. Pinned model wins the `default` slot AND sorts first in `items`; stale pin (model removed or disabled) falls through to the per-model `defaultForX` flag, matching the resolver's behaviour. + +Pinned with 3 new service tests: + +- `UT-LLM-004a` — basic-settings save without `models` key preserves existing list (#588 reproducer) +- `UT-LLM-004b` — explicit `models: []` still wipes (#588 symmetry) +- `UT-LLM-004c` — picker honours section pin; stale pin falls through (#607 reproducer) + +808 / 0 fail ornn-api tests; typecheck clean. diff --git a/.changeset/logger-consolidation-575.md b/.changeset/logger-consolidation-575.md new file mode 100644 index 00000000..99fab0b7 --- /dev/null +++ b/.changeset/logger-consolidation-575.md @@ -0,0 +1,11 @@ +--- +"ornn-api": patch +--- + +Consolidate 61 standalone `pino({ level: "info" })` loggers behind a single `createLogger(moduleName)` factory (#575). Before this PR, every module had its own pino instance — neither the `LOG_LEVEL` env var nor the bootstrap logger's redaction rules (`authorization`, `x-api-key`, `password`, `secret`, `apiKey`) made it past the bootstrap. Setting `LOG_LEVEL=debug` to debug a request silently no-op'd on 64 of 65 logger instances, hiding exactly the `logger.debug(...)` calls #579 just added. + +New `ornn-api/src/shared/logger.ts` exposes `createLogger(name)` — drop-in replacement for the old pattern. The factory reads `LOG_LEVEL` from env once at module load and applies the same redaction rules the bootstrap pino uses. Every module logger created via the factory inherits both. + +61 sites swept across `ornn-api/src`. The 3 remaining standalone pinos are intentionally-silent test loggers (`pino({ level: "silent" })`) — they suppress output in test runs and aren't a candidate for the factory. + +Bootstrap pino kept separate — it has its own `service: "ornn-api"` binding and adds `requestId` per request, which module loggers don't need. diff --git a/.changeset/login-telemetry-cors-709.md b/.changeset/login-telemetry-cors-709.md new file mode 100644 index 00000000..46e8ae50 --- /dev/null +++ b/.changeset/login-telemetry-cors-709.md @@ -0,0 +1,9 @@ +--- +"ornn-web": patch +--- + +`logActivity` no longer sets `credentials: "include"`, unblocking the post-OAuth `POST /api/v1/activity/login` call on the hosted environment (#709). + +The endpoint is Bearer-token-authenticated; it never read cookies. The lone `credentials: "include"` set in `activityApi.ts` (the rest of the SPA's `apiClient` calls never set it) forced the browser to demand `Access-Control-Allow-Credentials: true` plus a specific (non-`*`) `Access-Control-Allow-Origin` on the preflight response. The NyxID proxy doesn't emit those for this endpoint, so the preflight failed and every login on `ornn.chrono-ai.fun` ate a `TypeError: Failed to fetch`. Authenticated GETs through the same proxy keep working because they're simple requests (no preflight) and the rest of the POST endpoints don't set `credentials`. + +Dropping the flag brings this call into line with every other authenticated POST in the SPA and the preflight succeeds. diff --git a/.changeset/lowercase-error-codes-585.md b/.changeset/lowercase-error-codes-585.md new file mode 100644 index 00000000..dc17289f --- /dev/null +++ b/.changeset/lowercase-error-codes-585.md @@ -0,0 +1,8 @@ +--- +"ornn-api": minor +"ornn-web": patch +--- + +**BREAKING:** every error `code` emitted by `/api/v1/*` is now `lowercase_snake_case` per CONVENTIONS.md §1.4 (#585). `SKILL_NOT_FOUND` → `skill_not_found`, `INVALID_BODY` → `invalid_body`, `FORBIDDEN` → `forbidden`, etc. One-for-one lowercase translation: every existing code keeps its specificity, the parent §1.4 catalog (`validation_error`, `permission_denied`, `resource_not_found`, …) is the taxonomy these subcodes hang under. Clients pinned to the old strings need to switch — `docs/ERRORS.md` ships the full migration map. + +Web call sites that branch on specific codes (`AGENTSEAL_DISABLED`, `OLD_REPO_NOT_CONFIRMED`, `AUDIT_NOT_FOUND`) migrated to the lowercase equivalents in the same PR. No code-aware logic in the published SDK code paths today, so SDK consumers only need to update their own catch-by-code handlers. diff --git a/.changeset/mermaid-iframe-440.md b/.changeset/mermaid-iframe-440.md new file mode 100644 index 00000000..4b3be468 --- /dev/null +++ b/.changeset/mermaid-iframe-440.md @@ -0,0 +1,5 @@ +--- +"ornn-web": patch +--- + +Render Mermaid SVG inside a sandboxed iframe (#440). The DocsPage previously injected the rendered SVG via `dangerouslySetInnerHTML`. Mermaid is the only producer today and its input is trusted in-repo markdown, so this is purely defence-in-depth — but if any future code path ever feeds user-controlled diagram source (e.g. user-authored skill READMEs with mermaid blocks), the strict `sandbox=""` boundary already prevents script execution, form submission, navigation, and storage access. The lightbox pan/zoom transform is owned by the parent `
`, so interaction is unchanged. diff --git a/.changeset/notification-dropdown-stale-728.md b/.changeset/notification-dropdown-stale-728.md new file mode 100644 index 00000000..a2aab3fb --- /dev/null +++ b/.changeset/notification-dropdown-stale-728.md @@ -0,0 +1,9 @@ +--- +"ornn-web": patch +--- + +`useNotifications` now polls on the same 30s interval as the unread-count query, so the bell dropdown stays in sync with the badge (#728). + +Background: the bell badge subscribes to `useUnreadNotificationCount` (polled every `UNREAD_POLL_MS` = 30s) and the dropdown list to `useNotifications` (no `refetchInterval`, only `staleTime: 10_000`). When a new targeted broadcast lands, the count poll ticks to `1` and the badge updates — but `useNotifications` only re-fetches on remount/invalidation, so an open (or just-mounted-but-still-fresh) dropdown showed the pre-broadcast list. Users saw "1 unread" + a list that didn't contain it. + +Fix: add `refetchInterval: UNREAD_POLL_MS` to `useNotifications` so the two queries tick together. `refetchIntervalInBackground: false` mirrors the count query — no traffic when the tab is hidden. `staleTime` stays at 10s so quick remounts (e.g. dropdown toggle) still serve cached data without an extra round-trip. diff --git a/.changeset/openapi-contract-tests-462.md b/.changeset/openapi-contract-tests-462.md new file mode 100644 index 00000000..6ea6d6a0 --- /dev/null +++ b/.changeset/openapi-contract-tests-462.md @@ -0,0 +1,20 @@ +--- +"ornn-api": patch +--- + +OpenAPI contract test pass against `buildSpec()` (#462). New `tests/contract/openapi.test.ts` pins 12 structural properties of the generated spec so it can't silently regress: + +- Spec is OpenAPI 3.1 with title/version/description/servers. +- `BearerAuth` security scheme declared. +- Every declared path has at least one HTTP method. +- Every operation declares `tags`, a `summary` or `operationId`, at least one response, and at least one `2xx` response. +- Every `4xx`/`5xx` response uses `application/problem+json` (or JSON-compatible) per RFC 7807 / #456. +- Every operation outside a small `publicPaths` allowlist declares `BearerAuth` security per CONVENTIONS.md §5. +- A foundational route-coverage list (`/skills`, `/skill-search`, `/skill-format/*`, `/skill-manifest-schema.json`) MUST stay in the spec. + +What's NOT in scope (tracked as follow-ups on #462): + +- **Reflection over the live Hono app** to assert every registered route has a spec entry. The current spec covers ~12 of ~50 routes; closing that gap is a separate PR that needs each missing route documented with its own per-route Zod schema. +- **Cross-checking declared error codes against handler `throw` statements** — needs a static code-walker. + +The infrastructure for those follow-ups is now in place. Adding a new route without spec metadata, or shipping a half-documented route, fails CI today. diff --git a/.changeset/ornn-agent-manual-cli-v1-2.md b/.changeset/ornn-agent-manual-cli-v1-2.md new file mode 100644 index 00000000..a7c05546 --- /dev/null +++ b/.changeset/ornn-agent-manual-cli-v1-2.md @@ -0,0 +1,4 @@ +--- +--- + +Refresh `ornn-agent-manual-cli` skill manual to v1.2 (#560). Re-syncs `SKILL.md` and `references/api-reference.md` against the current `/api/v1/*` surface on `develop` — fixes the `GET /skills/:idOrName/versions` envelope in §0.5, drops the defunct `ornn:admin:category` permission row, teaches §2.13 the discriminated `source: "user" | "broadcast"` notification feed, adds the §2.15 quota / model-picker recipe, removes six ghost admin sections (`/admin/stats`, `/admin/activities`, categories CRUD, tags CRUD) and replaces them with the real surface (`/admin/dashboard/stats`, `/admin/quota/*`, `/admin/redemption-codes/*`, `/admin/mirror/*`, AgentSeal rescan, announcements, broadcasts), rewrites §14 platform settings around the sectioned routes + LLM-provider CRUD + export/import, adds §11.9–§11.12 for `/me/quota`, `/me/models`, `/me/redemption-codes/*`, and corrects the appendix skill name. No `ornn-api` / `ornn-web` code change — empty changeset satisfies the gate; agents pick up the new contract by pulling `GET /api/v1/skills/ornn-agent-manual-cli/json` after the registry-side sync. diff --git a/.changeset/p2-batch-716-725-727-729.md b/.changeset/p2-batch-716-725-727-729.md new file mode 100644 index 00000000..f9530643 --- /dev/null +++ b/.changeset/p2-batch-716-725-727-729.md @@ -0,0 +1,18 @@ +--- +"ornn-web": patch +--- + +P2 UX cluster: four small admin/registry papercuts (#716, #725, #727, #729). + +- **#716 Admin Mirror dashboard route**: `/admin/mirror` used to redirect to `/admin/settings/mirror`, and the section's "Open mirror dashboard" link pointed at `/admin/skills`. Net effect: no reachable UI for manual reconcile / status counts. `MirrorPage` (full operations console — counts grid, reconcile button, status header) is now lazy-mounted at `/admin/mirror` again, and the section link points there instead of Admin Skills. + +- **#725 Mode-selection cards alignment**: the four mode cards on `/skills/new` share a layout but the description paragraph had no min-height, so a shorter copy (Free Mode) shifted the bullet list up versus its neighbours. Added `min-h-[3rem] sm:min-h-[3.5rem]` to the description `

` so all four bullet lists start on the same baseline. + +- **#727 Keyword search placeholder lied about tags**: the placeholder advertised "name, description, or tags" but the backend keyword search doesn't match tags (the tag chip facet is the supported path). Updated `search.placeholderKeyword` in both en and zh to drop "or tags" — and the in-component fallback string to match. + +- **#729 Shared With Me cards now show the org name**: the access-reason sub-line on `SkillCard` rendered a generic "Via organization" even though `skill.sharedViaOrgId` was already populated. Look it up against `useMyOrgs()`'s membership list and render the new `viaOrganizationNamed` key (`Via {{org}}` / `通过 {{org}} 共享`). Falls back to the old generic copy when the org isn't in the caller's memberships (rare — server only sets `sharedViaOrgId` to an org the caller belongs to, but defence-in-depth). + +Out of scope: + +- **#723** (Notifications page can't scroll past viewport) — needs investigation of `RootLayout`'s `overflow-hidden` policy and any layout regressions adjacent pages might depend on. +- **#726** (Registry semantic search with empty query renders the no-skills empty state instead of "enter a description") — needs a small UX state in the search component to distinguish "validation gate hit" from "no matches". Both will follow up. diff --git a/.changeset/p2-followup-723-726.md b/.changeset/p2-followup-723-726.md new file mode 100644 index 00000000..052412a2 --- /dev/null +++ b/.changeset/p2-followup-723-726.md @@ -0,0 +1,9 @@ +--- +"ornn-web": patch +--- + +P2 follow-up: notifications page can scroll past the viewport and Registry semantic search with an empty query shows an actionable validation message instead of the generic empty state (#723, #726). + +- **#723** — `RootLayout`'s `

` is `overflow-hidden`; `NotificationsPage` had no own scroll container, so once the list exceeded the viewport, older notifications were clipped and unreachable via wheel/touch scroll. Wrapped the page body in an `h-full overflow-y-auto` shell — same pattern `UploadSkillPage` uses for the same root-layout constraint. + +- **#726** — Registry's semantic-search button could flip on while the input was empty; the backend correctly returned `400 QUERY_REQUIRED` but `ExplorePage` rendered the regular "No public skills match" empty state, indistinguishable from a legitimate zero-result search. Added a `semanticGateUnmet = mode === "semantic" && !query.trim()` check that swaps the empty state for an `EmptyState` with explicit copy: `Enter a search description / Semantic search needs a description of what you're looking for. Type a phrase in the search box above, or switch back to Keyword mode.` (+ Chinese translation). The backend queries keep firing (cheap, cached) but the user sees a clear validation hint instead of a misleading null result. diff --git a/.changeset/parseint-env-fallback-447.md b/.changeset/parseint-env-fallback-447.md new file mode 100644 index 00000000..f9d8193b --- /dev/null +++ b/.changeset/parseint-env-fallback-447.md @@ -0,0 +1,5 @@ +--- +"ornn-api": patch +--- + +Replace `Number(process.env.X ?? "200")` with a fail-fast `parseNonNegativeInt(name, fallback)` helper in `scripts/migrate-quota-to-buckets.ts` (#447). `Number()` silently returns `NaN` on garbage input, baking `NaN` quotas into Mongo when an env file has a typo. The new helper rejects non-numeric input, trailing garbage (`"200abc"`), fractions, negatives, and empty strings at startup with a clear error naming the offending env var. Covered by 9 unit tests in the colocated test file. diff --git a/.changeset/playground-env-isolation-721.md b/.changeset/playground-env-isolation-721.md new file mode 100644 index 00000000..e47274a8 --- /dev/null +++ b/.changeset/playground-env-isolation-721.md @@ -0,0 +1,16 @@ +--- +"ornn-api": patch +--- + +Playground no longer puts user-supplied env *values* into the LLM prompt and server-side overrides any value the model emits at `execute_in_sandbox` time (#721). + +Before this change, `buildSkillContext` injected `KEY=value` pairs into the developer message so the model could pass them through to `execute_in_sandbox`. When a chat-completion provider returned the tool call as plain assistant text instead of a structured tool-call frame (the failure mode #608 fixed for compliant providers — but a non-compliant model can still emit raw JSON in `text-delta`), the env values appeared verbatim in the user-visible transcript. Even with the secret value redacted on the wire, the bug surface was that the secret had ever passed through the LLM at all. + +Fix has two layers: + +- **Developer message**: list only the env-var *names* the user provided, with a placeholder shape `KEY=` and a brief instruction telling the model to reference each by name. The model never has the literal value, so it can't echo it. +- **Tool dispatch**: `runSandboxToolCall` merges `request.envVars` (the real values, supplied by the UI / API caller) *on top of* `args.env` (whatever the model produced). User-supplied keys always win at execution time. Keys the model legitimately invents (sentinel markers, etc.) ride through unchanged. + +Net effect: even if a future regression lets the model serialize a tool call as text again, the transcript carries `KEY=` instead of the secret, and the sandbox still runs with the real value because the merge happens on the server before `sessionExecute`. + +Coverage: 2 new tests in `chatService.test.ts` cover the user-override-wins case (real value replaces model's guessed value, untouched keys ride through) and the no-envVars passthrough case (model-only env reaches sandbox unchanged). All 10 tests in the file green. diff --git a/.changeset/playground-not-found-563.md b/.changeset/playground-not-found-563.md new file mode 100644 index 00000000..d1118eb5 --- /dev/null +++ b/.changeset/playground-not-found-563.md @@ -0,0 +1,5 @@ +--- +"ornn-web": patch +--- + +Render a "Skill not found" state on Playground when the API returns 404 (#563). The page previously gated only on `skillLoading` — once loading completed, missing `skill` data still rendered the full playground UI (starter prompts, chat input, ENV drawer chrome) for unauthorized users hitting a private skill's URL directly. Backend already returned `SKILL_NOT_FOUND` via the [#567](https://github.com/ChronoAIProject/issues/567) visibility check; this PR is the matching client gate that doesn't paint the surface when the data isn't allowed. New i18n keys `playground.notFoundTitle` / `playground.notFoundBody` (EN + ZH). diff --git a/.changeset/playground-page-decompose-453.md b/.changeset/playground-page-decompose-453.md new file mode 100644 index 00000000..0ca65c10 --- /dev/null +++ b/.changeset/playground-page-decompose-453.md @@ -0,0 +1,18 @@ +--- +"ornn-web": patch +--- + +Decompose PlaygroundPage into 6 colocated components (#453). + +`pages/PlaygroundPage.tsx`: **813 → 518 lines (−36%)**. Six new components under `components/playground/`: + +- `PlaygroundHelpers` (101L) — pure helpers + `ThinkingBubble` indicator +- `PlaygroundEmptyHero` (86L) — centered welcome flag + 3 suggestion chips +- `PlaygroundConversation` (109L) — forwardRef: turns + streaming buffer + file outputs + error banner + scroll anchor +- `PlaygroundRail` (101L) — fixed right-edge rail with hover-peek + click-to-pin +- `PlaygroundEnvDrawerBody` (81L) — per-key input list + lock hint +- `PlaygroundPackageDrawerBody` (79L) — SkillPackagePreview + registry-link footer + +PlaygroundPage now carries state plumbing (hooks for skill / package / chat / quota), the drawer outer container, the composer (ChatInput + ModelPicker + QuotaInline), and the early-return states (no-skill / loading / 404 / over-limit). The suggested `usePlaygroundSession()` hook extraction is deferred — would pull queries / chat state / handlers out and likely bring the page under 300L, but the data flow doesn't bisect into a small commit. + +Closes the page-component portion of #453 (SkillDetailPage in #651, DocsPage in #659, this PR). Issue stays open for the deferred hook-extraction work. diff --git a/.changeset/post-201-location-458.md b/.changeset/post-201-location-458.md new file mode 100644 index 00000000..ee564236 --- /dev/null +++ b/.changeset/post-201-location-458.md @@ -0,0 +1,5 @@ +--- +"ornn-api": patch +--- + +Return `201 Created` + `Location` header on resource-creating POST endpoints (#458). `POST /skills` (ZIP upload), `POST /skills/pull` (GitHub pull), `POST /admin/announcements`, and `POST /admin/broadcasts` previously returned `200 OK` with the resource in the envelope; CONVENTIONS.md §3.2 + RFC 9110 §15.3.2 specify `201 Created` with a `Location: /api/v1/{resource}/{id}` header so clients (and the upcoming SDK auto-pagination wrapper) can discover the canonical URL without re-parsing the body. Response body unchanged — only status code + new header. Existing 200-aware clients still work; the SDK already follows redirects and reads the envelope regardless of 2xx code. diff --git a/.changeset/private-key-validation-441.md b/.changeset/private-key-validation-441.md new file mode 100644 index 00000000..5c44b520 --- /dev/null +++ b/.changeset/private-key-validation-441.md @@ -0,0 +1,5 @@ +--- +"ornn-api": patch +--- + +Validate the shape of `appPrivateKey` on `POST /github/repo` (#441). The mirror settings endpoint previously accepted any non-empty string; pastes with stray whitespace, embedded NULs, missing BEGIN/END markers, or a truncated body wrote garbage into settings and surfaced much later as opaque crypto errors during mirror runs. The new `validateGitHubAppPrivateKey` helper enforces an 8 KB cap, rejects C0 control bytes, requires PKCS#1 / PKCS#8 PEM markers, and round-trips through `crypto.createPrivateKey` to catch shape-passing-but-broken keys before they're persisted. Empty string still clears the key. 12 unit tests cover happy + rejection paths. diff --git a/.changeset/python-sdk-audit-445.md b/.changeset/python-sdk-audit-445.md new file mode 100644 index 00000000..2fd41853 --- /dev/null +++ b/.changeset/python-sdk-audit-445.md @@ -0,0 +1,4 @@ +--- +--- + +Add a `python-sdk-audit` CI job + bound the Python SDK dependencies (#445). The previous `httpx>=0.27` constraint (no upper bound) would have silently picked up a hypothetical httpx 1.0 release with breaking semantics; bounds on `httpx`, `pytest`, `respx`, and `pytest-asyncio` keep us inside the 0.x/major lines we've actually tested against. The new CI job runs `pip-audit --strict` against the resolved environment on every PR so a CVE landing on a transitive dep fails CI loudly. Full lockfile (`pip-compile` workflow) deferred — the bounds + audit pair already closes the loud-failure gap without the lockfile maintenance overhead. diff --git a/.changeset/python-sdk-ruff-mypy-583.md b/.changeset/python-sdk-ruff-mypy-583.md new file mode 100644 index 00000000..4104389f --- /dev/null +++ b/.changeset/python-sdk-ruff-mypy-583.md @@ -0,0 +1,4 @@ +--- +--- + +Add ruff + mypy gates to the Python SDK CI job (#583). `python-sdk-test` previously only ran pytest with respx mocking — lint + type errors could land on develop without detection. Adds ruff config (E/W/F/I/B/UP + per-file exceptions for tests) and a strict-mypy config (boundary `Any` from httpx/respx allowed, internal code stays strict). CI runs `ruff check`, `ruff format --check`, and `mypy` before pytest. Source edits to satisfy the new gates: drop unused `httpx` import in a test fixture, split one over-long line, cast `httpx.Response.content` to `bytes` (typeshed annotates it as `Any`). diff --git a/.changeset/quota-refresh-races-629-630-624.md b/.changeset/quota-refresh-races-629-630-624.md new file mode 100644 index 00000000..add110cc --- /dev/null +++ b/.changeset/quota-refresh-races-629-630-624.md @@ -0,0 +1,13 @@ +--- +"ornn-web": patch +--- + +Fix three Playground / Skill-Gen quota refresh races (#629 + #630 + #624). + +All three were the same family of "the chrome and the truth disagree": + +- **#629** — `QuotaInline` warning banner and `QuotaSummary` row both rendered `Math.round((used / ceiling) * 100)`. With `used=199, ceiling=200, remaining=1`, that's `99.5 → 100%` even though the chip next to it showed `1 Playground calls left`. New shared `displayUsagePercent(used, ceiling, remaining)` uses `Math.floor`, clamps to `[0, 99]` whenever `remaining > 0`, and only returns `100` when `remaining <= 0`. Pinned with 6 unit assertions. +- **#630** — `usePlaygroundChat`'s `finish` / `error` handlers didn't invalidate `MY_QUOTA_KEY`. The page kept showing the pre-charge snapshot for up to 60 seconds (the next poll). Added `qc.invalidateQueries({ queryKey: MY_QUOTA_KEY })` on both terminal events so the chip / banner / over-limit gate reflect the actual remaining count immediately. +- **#624** — `CreateSkillGenerativePage` (and `PlaygroundPage`) routed to `OverLimitPage` whenever `isOverLimit` flipped true, even if the user had a generated result / live conversation on screen. The post-charge quota poll arriving after the *final* allowed run would yank the result away. Now the over-limit redirect only fires on a *fresh* arrival (no preview / no messages); the Send button's existing `isOverLimit` disable still prevents new runs. + +Net effect: the user's last allowed run lands, the result stays visible, the chip flips to 0/N right away, and the banner copy stops contradicting the chip. diff --git a/.changeset/rate-limit-439-460.md b/.changeset/rate-limit-439-460.md new file mode 100644 index 00000000..bb7ea359 --- /dev/null +++ b/.changeset/rate-limit-439-460.md @@ -0,0 +1,23 @@ +--- +"ornn-api": minor +--- + +Sliding-window rate-limit middleware + RFC 9239 headers on every response (#439 + #460). + +New `ornn-api/src/middleware/rateLimit.ts` exports `rateLimit({ windowMs, max, label?, keyBy? })`. Defaults: +- **Key:** `auth.userId` when present, else `x-forwarded-for` first IP, else `"anonymous"`. +- **Storage:** in-memory `Map` per process; cleanup pass every 60s on access. +- **Headers:** `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` (seconds) emitted on every allowed AND denied response. Denied responses also carry `Retry-After`. +- **Deny:** throws `AppError(429, "rate_limited", "...")` — the global handler emits it as `application/problem+json` per RFC 7807. + +Applied to the three highest-cost endpoints: + +| Route | Limit | Why | +|---|---|---| +| `GET /skill-search` | 60/min/user | Mongo aggregation (keyword) or LLM rerank (semantic) | +| `POST /skills` | 10/min/user | ZIP validate + storage write + AgentSeal scan | +| `POST /skills/generate` | 20/min/user | Every request is an LLM call | + +6 new unit tests pin: header emission, per-user keying, window reset, 429 response shape, multi-label composition. Full suite 704 / 0. + +**Storage caveat for prod:** in-memory means multi-pod clusters get per-pod-buckets — the effective limit is `N × max` where N is the replica count. Acceptable for the current single-replica dev/staging cluster; a Redis backend is the natural next step before prod traffic hits a multi-replica deployment. diff --git a/.changeset/react-keys-451.md b/.changeset/react-keys-451.md new file mode 100644 index 00000000..2d85732c --- /dev/null +++ b/.changeset/react-keys-451.md @@ -0,0 +1,5 @@ +--- +"ornn-web": patch +--- + +Document or replace `key={i}` on `Array.map` lists across the web app (#451). Every site flagged in the audit was reviewed: positional lists that never reorder (skeleton cards, OTP cells, code-editor line numbers, table skeletons) keep `key={i}` but now carry a one-line comment so a future audit doesn't churn the file. Data-driven lists that can re-shape (RootLayout breadcrumbs, CreateSkillFreePage validation messages) switch to composite keys that include the data identity, so reconciliation doesn't preserve hover/focus state on the wrong element when the source array changes shape. diff --git a/.changeset/readme-badge-trim-688.md b/.changeset/readme-badge-trim-688.md new file mode 100644 index 00000000..4df7861f --- /dev/null +++ b/.changeset/readme-badge-trim-688.md @@ -0,0 +1,4 @@ +--- +--- + +Trim the `README.md` badge row to CI / release / license, and drop the status / model-agnostic / HTTP·MCP pills below the hero (#688). Removes the broken `codecov unknown`, the noisy `last commit` and `discussions` badges, the vanity `stars` badge, and three positioning pills that are already shown inside the hero SVG. Surveyed 20 popular SDK / API repos; the trimmed set matches the dominant `version + license + CI` pattern. Docs-only, no code or schema change. diff --git a/.changeset/readme-header-rework-701.md b/.changeset/readme-header-rework-701.md new file mode 100644 index 00000000..847637a4 --- /dev/null +++ b/.changeset/readme-header-rework-701.md @@ -0,0 +1,4 @@ +--- +--- + +README polish for #701 — drop the small `` logo at the top, lock the hero to `hero-brand-dark.svg` (no theme swap) and wrap it in a link to ornn.chrono-ai.fun, collapse the badges + tagline onto a single left-aligned line ("The skill lifecycle API for AI agents, not another marketplace."), left-align the nav, and remove the premature `## SDK quickstart` section + its nav entry since `@chronoai/ornn-sdk` and `ornn-sdk` are not yet published. Docs-only; no package code touched. diff --git a/.changeset/readme-hero-dark-swap-692.md b/.changeset/readme-hero-dark-swap-692.md new file mode 100644 index 00000000..b97d1c27 --- /dev/null +++ b/.changeset/readme-hero-dark-swap-692.md @@ -0,0 +1,4 @@ +--- +--- + +Wire the README hero to swap between `hero-brand.svg` (light) and `hero-brand-dark.svg` (dark) via a `` + `prefers-color-scheme` block (#692), matching the light/dark swap the wordmark logo at the top of the README already uses. Pure markup change — the dark asset was already stored under `ornn-web/public/` (#690). Docs-only, no code or schema delta. diff --git a/.changeset/readme-hero-image-684.md b/.changeset/readme-hero-image-684.md new file mode 100644 index 00000000..94f59195 --- /dev/null +++ b/.changeset/readme-hero-image-684.md @@ -0,0 +1,4 @@ +--- +--- + +Add Ornn hero brand image to `README.md` (#684). Drops `ornn-web/public/hero-brand.svg` alongside the existing logo assets and inserts a centered hero block between the badges row and the status pills — `logo → badges → hero → pills → tagline → links → nav`. Docs-only, no code or schema change. diff --git a/.changeset/readme-mermaid-polish-707.md b/.changeset/readme-mermaid-polish-707.md new file mode 100644 index 00000000..476f3135 --- /dev/null +++ b/.changeset/readme-mermaid-polish-707.md @@ -0,0 +1,9 @@ +--- +--- + +Polish the README `## How it works` Mermaid diagram for #707 — fixes from the #705 forge-palette render: +1. Subgraph titles use the DESIGN.md bracketed mono pattern (`[ § YOUR MACHINE ]` / `[ § ORNN CLOUD ]`) and drop the `ornn.chrono-ai.fun` suffix so they stop being clipped. +2. `edgeLabelBackground` set to canvas (`#0B0907`) so edge labels float on the dark background instead of rendering as dark-on-dark rectangles. +3. `clusterBkg` / `clusterBorder` set as defaults; `[ § YOUR MACHINE ]` lifted to `iron` (`#221E16`) while `[ § ORNN CLOUD ]` stays at `graphite` (`#14110B`) for a gentle material contrast. +4. Node and edge labels trimmed to one line (no more parenthetical subtitles) so the diagram fits the GitHub viewport without overflow. +5. Bonus: `CLI ==>|HTTPS| API` is thick + ember-tinted via `linkStyle 1` (the load-bearing action edge — single localized accent per DESIGN.md's allowance for wire / pulse effects), node strokes bumped to 1.5–2px for the "forged metal" feel, default `lineColor` dropped to `ash` so the ember edge wins the visual weight contest. Docs-only; no package code touched. diff --git a/.changeset/readme-mermaid-restyle-705.md b/.changeset/readme-mermaid-restyle-705.md new file mode 100644 index 00000000..7685f9ea --- /dev/null +++ b/.changeset/readme-mermaid-restyle-705.md @@ -0,0 +1,4 @@ +--- +--- + +Restyle the README `## How it works` Mermaid diagram with the Editorial Forge palette for #705. Uses Mermaid's `init` directive + per-node `classDef` to map directly onto DESIGN.md tokens: obsidian + graphite subgraph fills, forged-metal (`#1A1610` / `#221E16`) node fills with steel borders and parchment text, ember (`#FF7322`) anchored on `ornn-api` as the brand-voice protagonist, arc-blue (`#5BC8E8`) on `NyxID` as the restricted secondary diagrammatic accent (auth / identity = "cool side of the forge"). Subgraph titles render UPPERCASE. Locked to dark in both GitHub themes — per DESIGN.md's carve-out for operational / code-chrome surfaces, consistent with the dark-only hero. Docs-only; no package code touched. diff --git a/.changeset/readme-positioning-table-472.md b/.changeset/readme-positioning-table-472.md new file mode 100644 index 00000000..fd0fbd8f --- /dev/null +++ b/.changeset/readme-positioning-table-472.md @@ -0,0 +1,4 @@ +--- +--- + +Adds a `## How Ornn compares` positioning section to the main README (#472) — comparison matrix vs MCP servers, Smithery, and npm registry, followed by a "what this means in practice" paragraph that owns the differences honestly (incl. the CLI gap, footnoted to the roadmap). Top nav gets a new anchor. diff --git a/.changeset/readme-restructure-703.md b/.changeset/readme-restructure-703.md new file mode 100644 index 00000000..14823a8b --- /dev/null +++ b/.changeset/readme-restructure-703.md @@ -0,0 +1,4 @@ +--- +--- + +Restructure the README into a tight 5-section layout for #703: `What is Ornn + Why we built it`, `How it works` (Mermaid flowchart instead of ASCII), `Quickstart` (3 numbered steps — NyxID sign-up with invite code, `ornn-agent-manual-cli` install + `nyxid` provisioning, concrete plain-language example prompts for search / pull+install / audit / build+publish), `Community and Contributing` (merged, with Roadmap folded in as a bullet), `License`. Removes the now-redundant `Run Ornn locally`, `How Ornn compares`, `Examples`, `Documentation`, and `Roadmap` H2 sections plus the top-of-page nav strip — those links live in CONTRIBUTING.md, on the website, or as a single bullet in Community. Docs-only; no package code touched. diff --git a/.changeset/readme-sdk-quickstart-470.md b/.changeset/readme-sdk-quickstart-470.md new file mode 100644 index 00000000..e3089e1e --- /dev/null +++ b/.changeset/readme-sdk-quickstart-470.md @@ -0,0 +1,4 @@ +--- +--- + +Adds an `## SDK quickstart` section to the main README (#470) — copy-paste-ready TypeScript and Python snippets that match the real `OrnnClient` constructor + `.search({ q })` shape. Sits between `## How it works` and the existing agent-side `## Quickstart`, with a nav link. Install lines reference `@chronoai/ornn-sdk` / `ornn-sdk`; until #473 lands the npm side is install-from-source, called out inline. diff --git a/.changeset/readme-website-link.md b/.changeset/readme-website-link.md new file mode 100644 index 00000000..9a8725e4 --- /dev/null +++ b/.changeset/readme-website-link.md @@ -0,0 +1,4 @@ +--- +--- + +Surface the Ornn official website (https://ornn.chrono-ai.fun) right under the tagline in the root `README.md` (#572). Previously the only mentions of the domain were buried in the `/docs` sub-path link — first-time readers had no obvious entry point to the product homepage. No `ornn-api` / `ornn-web` code change; empty changeset satisfies the gate. diff --git a/.changeset/recipients-popover-507.md b/.changeset/recipients-popover-507.md new file mode 100644 index 00000000..d10a7330 --- /dev/null +++ b/.changeset/recipients-popover-507.md @@ -0,0 +1,14 @@ +--- +"ornn-web": patch +--- + +Replace the BroadcastsPage recipients tooltip with a real popover (#507). + +The recipients column rendered the email list inside the native HTML `title` attribute as a `\n`-joined string. That broke two ways: + +- **Safari** collapses `\n` to a single space inside `title`, so all emails ran together as one unreadable line. +- **All browsers** truncate / flicker the multi-line native tooltip past ~20 entries — long broadcast lists were unusable. + +Replaced with an inline `RecipientsPopover` that mirrors `CategoryTooltip`'s pattern: hover OR click opens, click-outside / Esc / second-click closes, `aria-expanded` reflects state. The list is `max-h-64 overflow-y-auto` so 20+ recipients stay readable. Component is kept inline (BroadcastsPage is the only consumer) — promoting to `components/ui/` would be premature. + +Kept the inline anchor as a `