Skip to content

Latest commit

 

History

History
634 lines (464 loc) · 29.1 KB

File metadata and controls

634 lines (464 loc) · 29.1 KB

ornn API & Architecture Conventions

The contract every /api/v1/* endpoint and every ornn-api module must follow. All future endpoints and modules MUST conform. Changes that violate a convention are blocked at review.

This document is normative. It is the authoritative source for decisions that would otherwise be re-litigated per PR. When in doubt, this file wins.


Table of Contents

  1. Response & error format
  2. URL structure
  3. HTTP semantics
  4. Query parameters
  5. Authentication & authorization
  6. SSE streaming
  7. Deprecation
  8. Caching
  9. Observability headers
  10. OpenAPI
  11. Architecture conventions
  12. Every new /v1/ endpoint checklist

1. Response & error format

1.1 Success — single resource

Return the resource directly. No envelope.

GET /v1/skills/abc
200 OK
Content-Type: application/json

{
  "id": "abc",
  "name": "pdf-extract",
  "createdOn": "2026-04-22T10:00:00Z",
  ...
}

1.2 Success — collection

Wrap in { items, meta }:

GET /v1/skills?q=pdf&limit=20
200 OK
Content-Type: application/json

{
  "items": [ { "id": "abc", ... }, { "id": "def", ... } ],
  "meta": { "nextCursor": "eyJpZCI6...", "hasMore": true, "limit": 20 }
}

meta MUST contain limit and hasMore. When hasMore === true, nextCursor MUST be a non-empty opaque string. When hasMore === false, nextCursor MAY be omitted. Endpoint-specific metadata (e.g. searchMode) lives alongside pagination fields in meta.

1.3 Errors — RFC 7807 application/problem+json

POST /v1/skills/abc/permissions
400 Bad Request
Content-Type: application/problem+json
X-Request-ID: req_01HXYZ...

{
  "type": "https://github.com/ChronoAIProject/Ornn/blob/main/docs/ERRORS.md#validation_error",
  "title": "Validation failed",
  "status": 400,
  "detail": "Request body failed validation",
  "instance": "/v1/skills/abc/permissions",
  "requestId": "req_01HXYZ...",
  "errors": [
    { "path": "sharedWithUsers[3]", "code": "invalid_user_id", "message": "..." }
  ]
}

Required fields: type, title, status, instance, requestId. Optional: detail, errors[].

1.4 Error code catalog (lowercase snake_case)

Code HTTP Meaning
validation_error 400 Body / query / path param validation failed — details in errors[]
invalid_zip 400 Uploaded payload is not a parseable ZIP (malformed / unreadable)
unsupported_media_type 415 Request Content-Type not accepted
payload_too_large 413 Upload exceeds max size
uncompressed_too_large 413 Uncompressed size or compression ratio of skill ZIP exceeds caps (zip-bomb guard)
too_many_files 413 Skill ZIP entry count exceeds MAX_PACKAGE_FILE_COUNT
authentication_required 401 No valid identity
permission_denied 403 Authenticated but lacks required permission
resource_not_found 404 Target resource does not exist or not visible to caller
resource_conflict 409 State conflict (duplicate, concurrent modification, etc.)
rate_limited 429 Caller exceeded rate limit
upstream_unavailable 502 / 503 Dependency (NyxID, LLM, sandbox, ...) failed
org_membership_unavailable 503 NyxID org-membership lookup unresolved — forwarded token absent or lookup failed. Retryable
internal_error 500 Unhandled server error

New codes require convention-doc update. Handlers MUST NOT invent ad-hoc codes.

1.5 X-Request-ID

  • Generated server-side on every request (or echoed if the client provided one).
  • Returned as X-Request-ID header on every response (2xx, 4xx, 5xx).
  • Also embedded as requestId in every error body.
  • Logged with every request/response pair on the server.

1.6 Error type URLs

Point to GitHub markdown anchors in this repository:

https://github.com/ChronoAIProject/Ornn/blob/main/docs/ERRORS.md#<code>

The catalog lives in docs/ERRORS.md with ## headings per code (GitHub auto-generates anchors). Zero infra cost; resolves day one. Future migration to a docs domain (docs.ornn.xyz) is a one-time redirect configuration; no client changes required.


2. URL structure

2.1 Versioning

All endpoints live under /api/v1/. Breaking changes ship under /api/v2/. Additive changes ship under v1.

2.2 Resource paths

  • Plural resource nouns: /skills, /categories, /tags, /users, /activities.
  • Canonical URL uses the stable ID (GUID). No polymorphic :idOrName on write operations.
  • Name→ID resolution via GET /v1/{resource}/lookup?name=<name> (returns { id }).
  • Caller-scoped resources under /v1/me/*.

2.3 Non-CRUD actions — sub-resource

Custom actions as sub-resource paths:

POST /v1/skills/generate
POST /v1/skills/generate/from-openapi
POST /v1/skills/validate
POST /v1/skills/search
POST /v1/playground/chat

Router config MUST declare static action segments with priority over :id params (Hono / Express / Rails default behavior). Skill / category names that collide with reserved action verbs are rejected at create time.

Reserved action verbs per resource documented in ornn-api/src/shared/reservedVerbs.ts.

2.4 Search — dual-track

  • GET /v1/{resource}?q=...&<filters> — simple keyword filter over URL params (cacheable, bookmarkable).
  • POST /v1/{resource}/search — complex queries with structured body (semantic mode, long queries, compound filters).

Both return the same collection shape ({ items, meta }).

2.5 Skill dependency closure (#968)

GET /v1/skills/{idOrName}/closure[?version=<major.minor>|<dist-tag>]

Resolves the full transitive dependency closure of a skill version. A skill declares its direct dependencies in SKILL.md frontmatter via metadata.depends-on — an array of <name-or-guid>@<major.minor> or <name>@<dist-tag> refs (no semver ranges). The endpoint walks that graph and returns every transitive dependency.

  • Auth: optional. Anonymous callers resolve against public skills only; a public skill that transitively depends on a private skill the caller can't read surfaces that node as skill_dependency_not_found (existence not leaked).
  • Order: items are returned in deps-first topological order — every dependency precedes the dependents that pin it, so installing in array order is always safe. Shared nodes (diamonds) appear exactly once.
  • Response: standard collection envelope.
{
  "data": {
    "items": [
      { "guid": "", "name": "pdf-tools", "version": "1.0", "skillHash": "", "depth": 1 },
      { "guid": "", "name": "report-gen", "version": "2.3", "skillHash": "", "depth": 0 }
    ]
  },
  "error": null
}
  • Errors: dependency_cycle (409) when the graph loops; dependency_conflict (409) when one skill is pinned to two versions in the same closure; skill_dependency_not_found (404) when a ref doesn't resolve or isn't visible. See docs/ERRORS.md.

The same closure is validated at publish time: declaring a depends-on ref that can't be resolved, forms a cycle, or conflicts fails the create/update before the version is committed.

SDK helpers: client.resolveClosure(idOrName, { version }) / client.pullClosure(...) (TypeScript), client.resolve_closure(...) / client.pull_closure(...) (Python).

2.6 Skillsets (#969)

A skillset is a named, versioned, owned, visibility-scoped meta-package that references N member skills and carries a kind. One call resolves + delivers the whole set — including each member's dependency closure (§2.5). The ownership / visibility / immutable-versioning model mirrors skills verbatim; permission scopes reuse the existing ornn:skill:{create,read,update,delete} (see §5.2 — a dedicated ornn:skillset:* scope split is a tracked follow-up).

POST   /v1/skillsets                       — create (ornn:skill:create; private by default)
GET    /v1/skillsets/{idOrName}            — read   (optional auth; anon sees public only)
GET    /v1/skillsets/{idOrName}/versions   — list versions (optional auth)
GET    /v1/skillsets/{idOrName}/closure    — one-call resolve (optional auth)
PUT    /v1/skillsets/{id}                  — publish a new immutable version (ornn:skill:update)
PUT    /v1/skillsets/{id}/permissions      — visibility / sharing (ornn:skill:update)
POST   /v1/skillsets/{id}/transfer-ownership — hand to another user (ornn:skill:update; ADMIN tier — §5.4)
DELETE /v1/skillsets/{id}                  — delete + cascade versions (ornn:skill:delete)
GET    /v1/skillset-search                 — discovery by kind / tags / scope (optional auth)
  • kind: enum, v1 { "generic", "consensus-supported" } (extensible). Default generic. consensus-supported is an author claim that the members are an independent, comparable set suitable for agent-side consensus — not a guarantee (stated honestly; Ornn packages + delivers the set, the agent runs any consensus in its own runtime).
  • members: 2..N skill refs, each <name-or-guid>@<major.minor> or <name>@<dist-tag> (the same grammar as depends-on, §2.5). No nested skillsets in v1 — a skillset references skills only. Validated on publish: every member must resolve to a readable skill version, and the union dependency closure must be conflict-free.
  • instructions (master prompt, #978): a REQUIRED, versioned markdown body telling an agent HOW to use the set (orchestration, ordering, which member to pick when). 1..8000 chars (trimmed server-side; a whitespace-only body is rejected). Distinct from description (a short ≤1024-char human summary). Required on BOTH create and publish, with NO carry-forward — unlike description/kind/tags (which a publish may omit to inherit the prior version's value), every published version must explicitly state its own master prompt. Stored opaque — Ornn does not render, sanitize, template, lint, or search-index it. Surfaced verbatim on GET /v1/skillsets/{idOrName} and as a root field on /closure.
  • Create / publish bodies (JSON):
POST /v1/skillsets
{ "name": "review-set", "description": "",
  "instructions": "Run pdf-tools first, then feed its output to csv-tools…",
  "kind": "consensus-supported",
  "tags": ["review"], "members": ["pdf-tools@1.0", "csv-tools@2.1"], "version": "1.0" }

GET /v1/skillsets/{idOrName} returns the detail object including the version's instructions.

  • Closure: GET /v1/skillsets/{idOrName}/closure resolves roots = members through the same §2.5 resolver — the union of all members plus each member's transitive dependency closure, deduplicated and topo-sorted (deps-first). The success body carries the version's master prompt as a root sibling of items: { "data": { "instructions": "…", "items": [ … ] }, "error": null } (the skill /skills/:id/closure envelope stays { items }, unchanged). Same error codes as §2.5: dependency_cycle (409), dependency_conflict (409), skill_dependency_not_found (404). Anonymous callers resolving a public skillset whose member transitively pins a private skill get skill_dependency_not_found for that node — existence is not leaked.
  • Search: GET /v1/skillset-search?kind=…&tags=a,b&scope=… — plain keyword/filter discovery (no semantic / LLM ranking, no facets, no popularity ranking). Cursor pagination per §4.3.

SDK helpers: client.createSkillset(...) / getSkillset(...) / publishSkillset(...) / setSkillsetPermissions(...) / deleteSkillset(...) / getSkillsetClosure(...) / searchSkillsets(...) (TypeScript); client.create_skillset(...) / get_skillset(...) / publish_skillset(...) / set_skillset_permissions(...) / delete_skillset(...) / resolve_skillset_closure(...) / search_skillsets(...) (Python).

Scope follow-up: skillset endpoints intentionally reuse the ornn:skill:* permission scopes in v1 (a skillset is a skill-lifecycle resource). Splitting them into dedicated ornn:skillset:{create,read,update,delete} scopes is a tracked follow-up; callers should not assume the reuse is permanent.


3. HTTP semantics

3.1 Methods

Method Semantics
GET Safe, idempotent read
POST Create, or custom action
PUT Full replace of a resource (idempotent)
PATCH Partial update
DELETE Remove (idempotent)

Partial updates MUST use PATCH. PUT MUST accept a complete representation.

3.2 Status codes

Code Use
200 OK Successful read / update returning a body
201 Created Successful create. MUST include Location: /v1/{resource}/{id} header
202 Accepted Async job accepted (not currently used)
204 No Content Successful delete, or update with no body to return
400 validation_error
401 authentication_required
403 permission_denied
404 resource_not_found
409 resource_conflict
413 payload_too_large
415 unsupported_media_type
429 rate_limited
500 internal_error
502 / 503 upstream_unavailable

3.3 Content negotiation

When a resource has multiple representations, select via Accept:

GET /v1/skills/abc
Accept: application/json           → JSON metadata + file contents
Accept: application/zip            → raw ZIP package

Do not encode representation in the URL path (no /skills/:id/json).

3.4 Idempotency

POST creates accept optional Idempotency-Key: <uuid> header. Server persists the key + response for 24h and returns the cached response on retry. Implementation: middleware layer in ornn-api/src/middleware/idempotency.ts.

3.5 Bulk operations

Bulk-capable endpoints are symmetric:

POST   /v1/{parent}/{id}/{child}  { <child>Ids: [...] }   # add
DELETE /v1/{parent}/{id}/{child}  { <child>Ids: [...] }   # remove (body)

Single-item convenience endpoints MAY exist alongside.


4. Query parameters

4.1 Naming

  • camelCase everywhere (matches JSON body convention).
  • Search query param is q (never query).
  • Booleans are true / false — omit for "any".

4.2 Arrays — repeated keys

?sharedWithOrgs=a&sharedWithOrgs=b&sharedWithOrgs=c

Never CSV. Never bracket notation. Handler: c.req.queries('sharedWithOrgs') returns string[].

4.3 Pagination — cursor-only

?cursor=<opaque>&limit=<1-100>
  • cursor is opaque (base64-encoded server-chosen payload). Clients MUST NOT parse.
  • limit defaults per-endpoint (typically 20), max 100.
  • Absence of cursor = first page.
  • Response meta.nextCursor feeds the next request.
  • Total counts are NOT part of pagination. Endpoints needing a count expose a sibling (e.g. GET /v1/skills/counts) or fold the count into list meta.

4.4 Filters

Endpoint-specific. Rules:

  • Orthogonal filters are separate params. Do NOT overload (avoid scope=shared-with-me|mine|...).
  • Booleans instead of tri-state enums when possible.
  • For /v1/skills:
    • visibilitypublic | private (omit for "any" within caller's reach)
    • ownerme | others (omit for "any")
    • sharedWithme (filters to skills shared with caller)
    • isSystem — boolean (omit for "any")

5. Authentication & authorization

5.1 Transport

  • Authorization: Bearer <jwt> between client and the NyxID proxy.
  • X-NyxID-Identity-Token and X-NyxID-* headers between proxy and ornn-api (internal).
  • OpenAPI declares one bearerAuth scheme; X-NyxID-* is not part of the public contract.

5.2 NyxID request scopes (route-level)

Format: ornn:<resource>:<action>. These are the request scopes NyxID mints onto an access token — requirePermission(...) middleware checks them per route. They gate who may call an endpoint at all; they are distinct from the per-object READ / WRITE / ADMIN tier (§5.4), which decides what the caller may do to a specific skill/skillset. A caller needs both: the route scope to reach the handler, and the object tier to act on the target.

Permission Grants
ornn:skill:read Read skills (respects visibility)
ornn:skill:create Create skills (upload, pull from GitHub)
ornn:skill:update Update / publish / refresh / change permissions / transfer ownership / toggle deprecation / bind NyxID service (+ object ADMIN/WRITE per §5.4)
ornn:skill:delete Delete a skill or a single version (+ object ADMIN per §5.4)
ornn:skill:build Invoke skill generation endpoints (high LLM cost)
ornn:playground:use Invoke playground chat (runs user code)
ornn:admin:skill Platform-admin bypass — manage any skill/skillset (override ownership), plus all /admin/*, force-audit, and platform settings
ornn:category:read List categories
ornn:category:admin Manage categories
ornn:tag:read List tags
ornn:tag:admin Manage tags
ornn:user:admin User dashboard (list users, aggregate stats per user)
ornn:activity:read Platform activity log read access
ornn:stats:read Platform-wide dashboard aggregates

Skillset endpoints reuse the ornn:skill:{create,read,update,delete} scopes verbatim (§2.6) — there is no ornn:skillset:* scope split in v1.

NyxID composes a "Platform Admin" role around ornn:admin:skill (plus the *:admin + *:read scopes above); a token carrying ornn:admin:skill bypasses object ownership on every skill/skillset operation. Current platform admins inherit this role with zero UX change. Sub-admin roles (content moderator, tag curator, support) can be composed from subsets when needed.

Adding a new permission requires convention-doc update. NyxID role → permission mapping is owned by NyxID config; this doc is the permission catalog.

5.3 Scope declaration

Every route in OpenAPI tagged with its required scopes. Public endpoints explicitly declare security: [].

5.4 Object-level permission tiers (#1123)

Independent of the request scopes above, every skill AND skillset carries a three-tier object-permission model. All three gates derive from one source of truth — ornn-api/src/domains/skills/crud/authorize.ts (canReadSkill / canWriteSkill / canManageSkill) — and skillsets reuse the same gates. A request scope decides whether the caller may call the endpoint; the object tier decides whether they may act on this specific skill/skillset.

Tier Gate Who qualifies What it grants
READ canReadSkill Public skill → anyone. Private → author, platform admin, or any grantee (read or write), directly or via a granted org. View / pull / execute / list versions.
WRITE canWriteSkill Author OR platform admin OR a write grantee (direct or via a granted org). READ, plus update the skill's content + metadata only (publish a new version).
ADMIN canManageSkill Author OR platform admin only. Change permissions, transfer ownership, delete skill/version, toggle deprecation, manage dist-tags, bind a NyxID service.

A write grantee is never an admin — the danger-zone operations stay with the author (createdBy) and platform admins (ornn:admin:skill). Org grants resolve uniformly: every admin/member of a granted org inherits the grant's level. The org-membership gates fail soft on an unresolved NyxID lookup (deny) — they never grant on a "couldn't ask" result.

Typed grant ACL (grants)

The canonical access-control list is a typed grants array, exposed on skill/skillset detail responses and accepted by the permissions endpoints (PUT /v1/skills/:id/permissions, PUT /v1/skillsets/:id/permissions):

{
  "grants": [
    { "type": "user", "id": "<nyxid-person-user-id>", "level": "read" },
    { "type": "org",  "id": "<nyxid-org-user-id>",    "level": "write" }
  ]
}
  • type"user" (a NyxID person user_id) or "org" (a NyxID org user_id).
  • id — the principal's NyxID id (1..128 chars).
  • level"read" or "write" (a write grant implies read). An invalid value is rejected with invalid_permission_level (a validation_error subcode — see docs/ERRORS.md).

The author (createdBy) is never represented in grants — they hold implicit ADMIN. The legacy read-only sharedWithUsers / sharedWithOrgs arrays are still accepted on the permissions endpoints and still returned for back-compat; any principal supplied through them is treated as a read-level grant. A skill predating typed grants authorizes exactly as before.

Ownership transfer

POST /v1/skills/:id/transfer-ownership      { "newOwnerUserId": "<id>" }
POST /v1/skillsets/:id/transfer-ownership    { "newOwnerUserId": "<id>" }
  • Auth: ADMIN tier (canManageSkill) — author or platform admin only. A write grantee can never transfer. Rides on the existing ornn:skill:update request scope; no new scope was added.
  • Behavior: immediate, synchronous transfer. The target becomes the new owner (createdBy); the prior owner is kept as a READ grantee (retains visibility, loses edit/admin rights).
  • Target validation: newOwnerUserId must resolve to a known Ornn user — someone who has signed in to Ornn at least once. An unresolvable target is rejected with invalid_transfer_target (400).
  • No-op guard: transferring to the current owner returns ownership_conflict (409).
  • Returns the updated resource ({ data: { skill | skillset }, error: null }).

6. SSE streaming

6.1 Event naming

Format: <resource>_<event>, snake_case.

Shared event vocabulary across endpoints:

Suffix Meaning
_start Stream opened
_text_delta Incremental text content
_tool_call Model requests tool invocation
_tool_result Tool output
_file_output File produced during run
_validation_error Recoverable validation failure
_error Terminal error
_complete / _finish Stream ended normally

Endpoints pick a subset and MAY add endpoint-specific events with the same prefix.

6.2 Current endpoint mapping

Endpoint Events
POST /v1/skills/generate generation_start, generation_delta, generation_validation_error, generation_error, generation_complete
POST /v1/playground/chat chat_start, chat_text_delta, chat_tool_call, chat_tool_result, chat_file_output, chat_error, chat_finish
POST /v1/assistant/chat chat_start, chat_text_delta, chat_error, chat_finish

6.3 Transport rules

  • Content-Type: text/event-stream
  • Each event has a type field in the JSON payload plus SSE-native event: line set to the same value
  • Keep-alive events every config.sseKeepAliveIntervalMs milliseconds (JSON { type: "keepalive" })
  • Clients abort via AbortSignal / closing the connection
  • Last-Event-ID reconnection: not supported in v1; clients start over on reconnect

7. Deprecation

See docs/API_STABILITY.md for the public stability commitment, per-route tiers, and the full deprecation lead-time policy. This section codifies the header shape only.

Per RFC 8594 on deprecated endpoints and representations:

Deprecation: true
Sunset: Wed, 01 Jan 2027 00:00:00 GMT
Link: <https://github.com/ChronoAIProject/Ornn/blob/main/docs/DEPRECATIONS.md#skill-version-v1>; rel="deprecation"

Free-form notes go in response body, not custom headers. No X-Skill-Deprecated style custom headers.


8. Caching

  • GET endpoints returning immutable or slowly-changing data set ETag + Cache-Control.
  • Docs endpoints, GET /v1/openapi.json, GET /v1/skills/format are public and cacheable.
  • Authenticated reads use Cache-Control: private, max-age=<short> where appropriate.

9. Observability headers

Every response carries:

  • X-Request-ID (§ 1.5)
  • Future: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset

10. OpenAPI

  • GET /v1/openapi.json is the source of truth.
  • Every route declares security, request content types, all documented error responses, and at least one example.
  • CI contract test asserts every handler in code appears in the spec with complete metadata.
  • Error type URLs point to live documentation per § 1.6.

10.1 Skill manifest JSON Schema

The canonical JSON Schema for SKILL.md YAML frontmatter is published at:

GET /api/v1/skill-manifest-schema.json
  • Generated from the Zod source of truth (ornn-api/src/shared/schemas/skillFrontmatter.ts) at server boot — the published schema cannot drift from the runtime validator.
  • Output is JSON Schema draft-2020-12 ($schema) — what current IDEs (VS Code, Cursor, JetBrains) and schemastore.org consume.
  • Served with Content-Type: application/schema+json (IANA registration). No { data, error } envelope — the body root is the schema document itself, because that's what schema-store tools expect.
  • Public, no auth required. Cache-Control: public, max-age=3600.
  • SKILL_MANIFEST_SCHEMA_VERSION (currently "1") is bumped manually when the frontmatter contract changes in a way external tooling cares about. Out of an abundance of caution while we're pre-1.0, this version is not yet baked into the URL; consumers should re-fetch on a finite TTL.

Skill authors and tooling SHOULD point their YAML language servers at this URL via # yaml-language-server: $schema=... so SKILL.md gets autocomplete + inline validation.


11. Architecture conventions

11.1 Error model

Single AppError class in ornn-api/src/shared/errors/. No inlined or per-module copies. Global error handler is a single mapping from AppErrorapplication/problem+json.

11.2 Config loading

Zod schema, not imperative requiredEnv() / Number() casts. A malformed env var (MAX_PACKAGE_SIZE_BYTES=abc) must fail-fast with a typed error, not silently produce NaN.

Library code MUST NOT call process.exit(). Throw a typed error; let the entry point decide.

11.3 Middleware order

All global middleware declared in this order:

  1. CORS (with env-driven ALLOWED_ORIGINS, not (origin) => origin)
  2. requestId — generate/echo X-Request-ID, attach to pino child logger
  3. logging — structured with requestId
  4. proxyAuthSetup() — parse NyxID proxy headers
  5. nyxidOrgLookupMiddleware() — lazy, per-request org membership lookup
  6. route handler (with per-route validateBody / validateQuery / validateParams middleware)
  7. onError — single handler, AppError → problem+json

11.4 Domain module shape

ornn-api/src/domains/<resource>/
├── routes.ts        HTTP layer — routing + validation middleware only
├── service.ts       business logic — the only caller of repository
├── repository.ts    data access — only domains/<resource>/ imports this
├── schemas.ts       Zod schemas — request, response, domain types
└── [optional] <sub>/  sub-module for large domain (e.g. skills/search/)

11.5 Route ↔ repository boundary

Routes MUST NOT import repositories directly. Only services may call repositories. A lint rule (in eslint.config.js) enforces this.

11.6 Clients layer

All NyxID-related clients grouped under ornn-api/src/clients/nyxid/:

ornn-api/src/clients/
├── nyxid/
│   ├── base.ts           shared HTTP client, SA-token provider
│   ├── llm.ts            LLM gateway client
│   ├── orgs.ts           org lookup
│   └── userServices.ts   user-services client
├── storage.ts            chrono-storage
└── sandbox.ts            chrono-sandbox

NyxidSaTokenProvider is a first-class class, not an inlined closure.

11.7 Testing strategy

  • Unit tests colocated with source (foo.ts + foo.test.ts). Pure functions, no DB.
  • Integration tests in ornn-api/tests/integration/ — real Mongo (via testcontainers or a spun-up instance), real HTTP. Not mocks.
  • Contract tests in ornn-api/tests/contract/ — assert OpenAPI spec matches handler behavior. Runs in CI.
  • Frontend unit tests — Vitest + Testing Library, colocated with source.

Per-test teardown is the test's responsibility; shared fixtures live in tests/fixtures/.


12. Every new /v1/ endpoint checklist

  • Path follows /v1/{resource} or /v1/{resource}/{action} (sub-resource for actions)
  • Method matches semantics (PATCH for partial update, DELETE returns 204, POST create returns 201 + Location)
  • Collection response shape is { items, meta } with cursor pagination
  • Error response uses application/problem+json with a code from the catalog
  • X-Request-ID on every response; requestId in every error body
  • Query params camelCase; arrays as repeated keys; q for search
  • Required request scopes from the catalog declared in OpenAPI security (§5.2)
  • Object-level authz, where the target is an owned resource, gates on the READ / WRITE / ADMIN tier (§5.4) — distinct from the route scope
  • Content negotiation for multi-representation resources
  • SSE events named <resource>_<event> snake_case
  • Deprecation uses RFC 8594 headers