The ledger
Every @feature annotation across the org, in one searchable index. Filter by repo, category, or maturity; each row resolves to the exact source line it was declared on.
Spectrum · top 12 categories
click to filter⬡ status coverage63%1,968 / 3,109 features annotated1,141 missing status:
worst offenders
- 11%openalice-embed601 missing
- 66%openalice-platform144 missing
- 78%openalice-persona66 missing
- 92%openalice60 missing
- 31%openalice-world36 missing
- 23%openalice-social34 missing
- 0%openalice-lab25 missing
- 0%openalice-inspector23 missing
0 matched0 in ledger
- embed-app.route.analyticsopenalice-embed · embed-app/routeGET /api/analytics?widget_id=<public_id>&days=<N> Server-side proxy to openalice-agent-lite's internal analytics summary: GET /internal/analytics/summary?widget_id=<public_id>&days=<N> authenticated with the shared `x-internal-secret`. The secret lives server-side only (INTERNAL_SECRET) and never reaches the browser — this route is the only thing the dashboard client calls. Contract: Request query: widget_id (required), days (optional, default 30, 7|30) Response (JSON): ConversationAnalytics on success { error: string } on failure (4xx/5xx)
- embed-app.route.avatar-thumbnailopenalice-embed · embed-app/routePOST /api/avatars/thumbnail — persists a browser-rendered VRM preview to the statically-served public dir so it shows up in the avatar catalog cards. Design (NAO's explicit zero-server-render rule): the platform admin renders each catalog VRM in their browser (three.js) and captures preview WebPs at three framings. This route is the ONLY server cost — a bounded file write of an already-encoded image. No GPU, no headless browser, no model load here. Auth: admin-only. Reuses the dashboard's getDashboardToken() (real session cookie OR the dev-minted tenant JWT). Anonymous requests are rejected 401 — there is no anon write path. (Studio is a dashboard route behind the same middleware guard; this is the defence-in-depth server-side check.) Input (multipart/form-data OR JSON): - id : avatar id, charset-validated ^[a-z0-9_-]{1,64}$ (no path chars) - angle : one of head | half | full (whitelist) - image : the WebP blob (multipart "image") OR base64 data-URL/raw string in JSON "image" Validation: content-type must be image/webp, size <= 512 KB. The file is written to public/v1/vrm/thumb/<id>-<angle>.webp via a path that is re-derived from the validated id + a fixed basename — never from caller input — so it can never escape the thumb dir. For angle==="head" we ALSO write the primary public/v1/vrm/thumb/<id>.webp (the card's default image, matched by AvatarPicker's deriveThumb()). Returns the saved public URL(s). public/v1/vrm/ is gitignored (generated CDN asset, like aria.vrm) so these never enter source control.
- embed-app.route.conversation-note-addopenalice-embed · embed-app/routePOST /api/conversations/[id]/notes Server-side proxy to agent-lite's BEARER-gated inbox-depth endpoint (#773 wave 2): POST /v1/conversations/{id}/notes {"text": string} agent-lite trims + bounds the note (non-empty, ≤4000 chars) and attributes it to the verified principal. Append-only; read back via the conversation detail's `notes` array. The tenant bearer is resolved server-side via getDashboardToken() and never reaches the browser. Contract: Request body: { text: string } ← required, non-empty after trim, ≤4000 Response: 201 on success { error: string } JSON on failure (4xx/5xx)
- embed-app.route.conversation-operator-messageopenalice-embed · embed-app/routePOST /api/conversations/[id]/operator-message Server-side proxy to agent-lite's BEARER-gated live-takeover endpoint (EMBED_LIVE_TAKEOVER_V1): POST /v1/conversations/{id}/operator-message {"text": string} agent-lite persists the turn (role "operator") and relays it to the visitor's event stream. The tenant bearer is resolved server-side via getDashboardToken() and never reaches the browser. Contract: Request body: { text: string } ← required, non-empty after trim Response: 204 on success { error: string } JSON on failure (4xx/5xx)
- embed-app.route.conversation-releaseopenalice-embed · embed-app/routePOST /api/conversations/[id]/release Server-side proxy to agent-lite's BEARER-gated live-takeover endpoint (EMBED_LIVE_TAKEOVER_V1): POST /v1/conversations/{id}/release {} Flips control back to the agent ("Lily answers again"); agent-lite broadcasts operator_left + control to the visitor's event stream. The tenant bearer is resolved server-side via getDashboardToken() and never reaches the browser. Contract: Request body: none required (any body is ignored) Response: 204 on success { error: string } JSON on failure (4xx/5xx)
- embed-app.route.conversations-listopenalice-embed · embed-app/routeGET /api/conversations?widget_id=<public_id>&limit=<n>&cursor=<iso8601> &q=<text>&tag=<tag> Server-side proxy to openalice-agent-lite's internal conversations list: GET /internal/conversations?widget_id=<public_id>&limit=<n>&cursor=<iso8601> &q=<text>&tag=<tag> authenticated with the shared `x-internal-secret`. The secret lives server-side only (INTERNAL_SECRET) and never reaches the browser — this route is the only thing the dashboard client calls. Inbox-depth search (#773 wave 2): `q` is a case-insensitive substring over the visitor id / tier + conversation id + tags — NOT message content (which is encrypted at rest). `tag` filters to one exact operator tag. Contract: Request query: widget_id (required), limit/cursor/q/tag (all optional) Response (JSON): ConversationList on success { error: string } on failure (4xx/5xx)
- embed-app.route.conversation-tag-addopenalice-embed · embed-app/routePOST /api/conversations/[id]/tags Server-side proxy to agent-lite's BEARER-gated inbox-depth endpoint (#773 wave 2): POST /v1/conversations/{id}/tags {"tag": string} agent-lite re-validates + normalizes the tag (trim, lowercase, [a-z0-9-_], ≤32) and is idempotent. The tenant bearer is resolved server-side via getDashboardToken() and never reaches the browser; the client also pre-validates via normalizeConversationTag. Contract: Request body: { tag: string } ← required, non-empty after normalize Response: 204 on success { error: string } JSON on failure (4xx/5xx)
- embed-app.route.conversation-tag-removeopenalice-embed · embed-app/routeDELETE /api/conversations/[id]/tags/[tag] Server-side proxy to agent-lite's BEARER-gated inbox-depth endpoint (#773 wave 2): DELETE /v1/conversations/{id}/tags/{tag} agent-lite normalizes the path tag the same way as the add path and is idempotent (removing an absent tag → 204). The tenant bearer is resolved server-side via getDashboardToken() and never reaches the browser. Contract: Request path: conversation id + tag (the normalized label) Response: 204 on success { error: string } JSON on failure (4xx/5xx)
- embed-app.route.conversation-tags-notesopenalice-embed · embed-app/routeTests for the inbox-depth proxy routes (#773 wave 2): POST /api/conversations/[id]/tags — add an operator tag - 400 on a missing / malformed tag (validated BEFORE the bearer check) - normalizes the tag (trim + lowercase) before forwarding - 401 when no bearer, 204 on success, agent-lite error mapping DELETE /api/conversations/[id]/tags/[tag] — remove an operator tag - 400 on a malformed path tag, 401 when no bearer, 204 on success - normalizes the path tag before forwarding POST /api/conversations/[id]/notes — append an operator note - 400 on empty / missing / over-cap text, 401 when no bearer - forwards trimmed text, 201 on success, agent-lite error mapping Strategy mirrors operatorRoutes.test.ts: vi.mock the agentLite write fns + getDashboardToken. Route handlers receive a plain Request + a params Promise (Next 16 signature).
- embed-app.route.conversation-takeoveropenalice-embed · embed-app/routePOST /api/conversations/[id]/takeover Server-side proxy to agent-lite's BEARER-gated live-takeover endpoint (EMBED_LIVE_TAKEOVER_V1): POST /v1/conversations/{id}/takeover {"operator_name": string} The operator_name is derived SERVER-SIDE from the signed-in session's claims (display_name → username → email) so the browser cannot spoof an identity; an optional body {operator_name} acts only as a dev fallback when no identity claim exists (dev-token sessions), with "Operator" as the final default. The tenant bearer is resolved via getDashboardToken() and never reaches the browser. Contract: Request body (optional): { operator_name?: string } ← dev fallback only Response (JSON): { ok: true, operator_name: string } on success { error: string } on failure (4xx/5xx)
- embed-app.route.conversation-takeoveropenalice-embed · embed-app/routeTests for the live-takeover proxy routes (EMBED_LIVE_TAKEOVER_V1): POST /api/conversations/[id]/takeover - derives operator_name from session claims (claims win over body) - falls back to the body operator_name, then "Operator" - 401 when no bearer, agent-lite error mapping POST /api/conversations/[id]/operator-message - forwards {text}, 400 on empty/missing text, 204 on success POST /api/conversations/[id]/release - forwards with the bearer, 204 on success Strategy: vi.mock the agentLite operator actions + getDashboardToken + getSessionClaims (claimsDisplayName stays REAL via importActual so the claims-precedence logic is exercised end-to-end). Route handlers receive plain Request objects + a params Promise, matching the Next 16 signature. claimsDisplayName itself (display_name → username → email → null) is also unit-tested here.
- embed-app.route.conversation-transcriptopenalice-embed · embed-app/routeGET /api/conversations/[id]?widget_id=<public_id> Server-side proxy to openalice-agent-lite's internal transcript endpoint: GET /internal/conversations/{id}?widget_id=<public_id> authenticated with the shared `x-internal-secret`. The secret lives server-side only (INTERNAL_SECRET) and never reaches the browser — this route is the only thing the dashboard client calls. Contract: Request query: widget_id (required); path: conversation id Response (JSON): ConversationTranscript on success { error: string } on failure (4xx/5xx) POST /api/conversations/[id] Body: { value?: number|null, goal?: string|null } Marks the conversation CONVERTED. Proxies to agent-lite's BEARER-gated POST /v1/conversations/{id}/convert with the tenant token resolved server-side via getDashboardToken(). 204 on success.
- embed-app.route.experimentsopenalice-embed · embed-app/routeThe BFF proxy that bridges the browser A/B dashboard (ABTestsView) to the two server-only clients. Both secrets (the tenant JWT and the INTERNAL_SECRET) live server-side only and never reach the browser — this route is the only thing the dashboard client calls. GET /api/experiments?widget_id=<public_id>&experiment_id=<uuid>&days=<N> → experimentsInsights.getExperimentInsights() (agent-lite metrics) Response (JSON): { variants: VariantMetrics[] } | { error: string } POST /api/experiments → experiments.createExperiment() (tenants CRUD) Response (JSON): ExperimentFull | { error: string } Read (GET) hits agent-lite over the internal secret; write (POST) hits tenants over the tenant JWT. Mutating the experiment LIFECYCLE (start/pause/conclude) and variant CRUD go through dedicated server actions on the page, not this route — this BFF only covers the two browser-initiated flows the client component needs.
- embed-app.route.improve-promptopenalice-embed · embed-app/routePOST /api/improve-prompt — expands a customer's rough persona notes into our ÉTALON structured brand-rep system prompt using OpenRouter. EU-sovereign by design: the model is mistralai/mistral-large (Mistral, FR). The OpenRouter key lives server-side only (OPENROUTER_API_KEY); the browser never sees it. A single bounded completion (max_tokens ~800, temp ~0.4) keeps cost predictable. The route only generates a draft — the dashboard fills the textarea and lets the customer review/edit before they save, so nothing here mutates widget config. Contract: Request (JSON): { draft: string } — the customer's rough notes Response (JSON): { improved: string } on success { error: string } on failure (4xx/5xx)
- embed-app.route.insights-csatopenalice-embed · embed-app/routeGET /api/insights/csat?widget_id=<public_id>&days=<N> Server-side proxy to openalice-agent-lite's internal CSAT insights: GET /internal/insights/csat?widget_id=<public_id>&days=<N> authenticated with the shared `x-internal-secret` (server-side only; never reaches the browser). Contract: Request query: widget_id (required), days (optional, default 30) Response (JSON): CsatInsights on success { error: string } on failure (4xx/5xx)
- embed-app.route.insights-intentopenalice-embed · embed-app/routeGET /api/insights/intent?widget_id=<public_id>&days=<N> Server-side proxy to openalice-agent-lite's internal intent insights: GET /internal/insights/intent?widget_id=<public_id>&days=<N> authenticated with the shared `x-internal-secret`. The secret lives server-side only (INTERNAL_SECRET) and never reaches the browser — this route is the only thing the dashboard client calls. Contract: Request query: widget_id (required), days (optional, default 30) Response (JSON): IntentInsights on success { error: string } on failure (4xx/5xx)
- embed-app.route.insights-knowledge-gapsopenalice-embed · embed-app/routeGET /api/insights/knowledge-gaps?widget_id=<public_id>&days=<N>&limit=<N> Server-side proxy to openalice-agent-lite's internal knowledge-gaps surface: GET /internal/insights/knowledge-gaps?widget_id=<public_id>&days=<N>&limit=<N> authenticated with the shared `x-internal-secret`. The secret lives server-side only (INTERNAL_SECRET) and never reaches the browser — this route is the only thing the dashboard client calls. Contract: Request query: widget_id (required), days (optional, default 30), limit (optional, default 10) Response (JSON): KnowledgeGaps on success { error: string } on failure (4xx/5xx)
- embed-app.route.insights-leadsopenalice-embed · embed-app/routeGET /api/insights/leads?widget_id=<public_id>&days=<N>&limit=<N> Server-side proxy to openalice-agent-lite's internal leads surface: GET /internal/insights/leads?widget_id=<public_id>&days=<N>&limit=<N> authenticated with the shared `x-internal-secret`. The secret lives server-side only (INTERNAL_SECRET) and never reaches the browser — this route is the only thing the dashboard Leads view calls. Contract: Request query: widget_id (required), days (optional, default 30), limit (optional, default 100) Response (JSON): LeadList on success { error: string } on failure (4xx/5xx)
- embed-app.route.insights-revenueopenalice-embed · embed-app/routeGET /api/insights/revenue?widget_id=<public_id>&days=<N> Server-side proxy to openalice-agent-lite's internal revenue insights: GET /internal/insights/revenue?widget_id=<public_id>&days=<N> authenticated with the shared `x-internal-secret`. The secret lives server-side only (INTERNAL_SECRET) and never reaches the browser — this route is the only thing the dashboard client calls. Contract: Request query: widget_id (required), days (optional, default 30) Response (JSON): RevenueInsights on success { error: string } on failure (4xx/5xx)
- embed-app.route.insights-sentimentopenalice-embed · embed-app/routeGET /api/insights/sentiment?widget_id=<public_id>&days=<N> Server-side proxy to openalice-agent-lite's internal sentiment insights: GET /internal/insights/sentiment?widget_id=<public_id>&days=<N> authenticated with the shared `x-internal-secret`. The secret lives server-side only (INTERNAL_SECRET) and never reaches the browser — this route is the only thing the dashboard client calls. Contract: Request query: widget_id (required), days (optional, default 30) Response (JSON): SentimentInsights on success { error: string } on failure (4xx/5xx)
- embed-app.route.insights-top-conversationopenalice-embed · embed-app/routeGET /api/insights/top-conversation?widget_id=<public_id>&days=<N> Server-side proxy to openalice-agent-lite's internal top-conversation insight: GET /internal/insights/top-conversation?widget_id=<public_id>&days=<N> authenticated with the shared `x-internal-secret` (server-side only; never reaches the browser). Contract: Request query: widget_id (required), days (optional, default 30) Response (JSON): TopConversation | null on success { error: string } on failure (4xx/5xx)
- embed-app.route.kb-statsopenalice-embed · embed-app/routeGET /api/kb/stats?widget_id=<public_id>&source_url=<https url> Server-side proxy to openalice-agent-lite's internal KB stats endpoint: GET /internal/kb/stats?widget_id=<public_id>&source_url=<https url> authenticated with the shared `x-internal-secret`. The secret lives server-side only (INTERNAL_SECRET) and never reaches the browser. agent-lite resolves widget_id → tenant_id server-side via the config resolver (same as /internal/analytics/summary), so an unknown widget 404s and no cross-tenant stats leak is possible. `source_url` (when supplied) is likewise scoped server-side to the resolved tenant_id — a caller can only ever see chunk counts within their OWN tenant. Returns honest, directly-measured account-level knowledge-base metrics (total_chunks, distinct_docs, last_indexed_at) from the rag_chunks table. Coverage % and retrieval latency are NOT returned — neither is genuinely measurable from the stored data. `source_url` is optional (§per-source-kb-stats) — pass the widget's `brand_kb_url` to also get a `source` field with the real per-source chunk count for that one url, so the Knowledge page can show honest ingest status for the website source instead of a hardcoded "—". Contract: Request query: widget_id (required — the widget's public_id), source_url (optional — a https url to also scope stats to) Response (JSON): KbStats on success { error: string } on failure (4xx/5xx)
- embed-app.route.kb-uploadopenalice-embed · embed-app/routeTenant-scoped BFF proxy for per-widget knowledge-base document upload + management against openalice-tenants: POST /api/kb-upload?id=<widget> — upload a document (multipart `file`) POST /api/kb-upload?id=<widget>&action=paste — add a pasted-text source (JSON {title,text}) POST /api/kb-upload?id=<widget>&action=reindex — re-index the widget's current KB source GET /api/kb-upload?id=<widget> — list this widget's knowledge sources DELETE /api/kb-upload?id=<widget>&source=<id> — remove one knowledge source The tenant bearer token (devToken.ts → real session cookie OR dev-minted JWT) is resolved server-side and forwarded to openalice-tenants over EMBED_ACCOUNTS_INTERNAL_URL. The token never reaches the browser; the file bytes live in tenants (on the EU Hetzner NVMe) and are served back by tenants itself — never on this Next.js process disk. Defence in depth — POST validates the extension + a 10MB cap BEFORE forwarding, even though tenants re-validates server-side (kb_upload.rs).
- embed-app.route.leads-exportopenalice-embed · embed-app/routeGET /api/leads-export?widget_id=<public_id>&days=<N> Streams a UTF-8 CSV of all captured leads for the widget — GDPR Art. 20 data portability export. Up to 1 000 rows per request. Security: - Requires an authenticated dashboard session; delegates to assertWidgetAccess() for the exact same IDOR gate used by every other widget-read route (closes the same ownership-check class). - Response is never cached (Cache-Control: no-store). CSV contract: Columns: captured_at, name, email, phone, message, conversation_id Encoding: UTF-8 with BOM so Excel auto-detects the charset. Quoting: RFC 4180 — all fields wrapped in double-quotes; embedded double-quotes escaped as ""; CR, LF, CRLF inside field values are preserved inside the quoted field. Content-Disposition: attachment; filename="leads-<widget>-<days>d.csv"
- embed-app.route.legal-impressumopenalice-embed · embed-app/routeGET /impressum — temporary (307) redirect to the legal host's Impressum (legal notice) page. Required for DE/EU compliance. The dashboard does not host legal copy itself; see src/lib/legal.ts.
- embed-app.route.legal-privacyopenalice-embed · embed-app/routeGET /privacy — temporary (307) redirect to the legal host's Privacy page. The dashboard does not host legal copy itself; see src/lib/legal.ts.
- embed-app.route.legal-redirectopenalice-embed · embed-app/routeTemporary (307) server-side redirects for the legal-content routes — /terms, /privacy, /impressum. Those pages live on a separate host (the marketing / landing site) configured via `LEGAL_BASE_URL`, so the dashboard bounces the visitor there instead of 404-ing. The footer links in login / signup / onboarding point at these routes. 307 (Temporary Redirect) is used deliberately, NOT 308 (Permanent): `LEGAL_BASE_URL` will change at the prod cutover (openalice.blal.pro → openalice.eu). A 308 is cached indefinitely by browsers / CDNs, so any client that caught the redirect BEFORE the migration would be permanently pinned to the OLD origin even after the env var is updated. 307 is not cached by default, so the redirect always reflects the CURRENT `LEGAL_BASE_URL` at request time — the migration cost is nothing. 307 still preserves the request method (unlike 301/302), so it's correct for any caller, not just GET — though these routes are GET-only today. Open-redirect note: the target origin is the operator-configured legal base (env), NEVER user input, and the path is a hard-coded slug — so a visitor cannot influence where the redirect lands. When the env var is unset we fall back to the prod legal host so the routes always resolve.
- embed-app.route.legal-termsopenalice-embed · embed-app/routeGET /terms — temporary (307) redirect to the legal host's Terms page. The dashboard does not host legal copy itself; see src/lib/legal.ts.
- embed-app.route.logoutopenalice-embed · embed-app/routePOST /logout — clears the HttpOnly session cookie and redirects to /login. Logout is a MUTATION, so only POST performs it: SameSite=Lax still sends the session cookie on a cross-site top-level GET navigation, so a GET logout would be a logout-CSRF vector (any site could force-log-out the user via a link / redirect / image tag). GET therefore just bounces to /login WITHOUT clearing the cookie.
- embed-app.route.voice-previewopenalice-embed · embed-app/routeGET /api/voice-preview?provider=<p>&voiceId=<id> Server-side proxy to openalice-agent-lite GET /internal/voice-preview?provider=&voice= which returns a short audio/mpeg sample of a CATALOG voice (Voxtral EU / Fish US / ElevenLabs US-premium). The shared `x-internal-secret` lives server-side only (INTERNAL_SECRET) and never reaches the browser — this route is the only thing the VoiceSelector's ▶ Preview button calls. These are generic catalog samples (NOT tenant data), so there is no per-widget ownership gate; we validate only the provider + voice-id shape and stream the bytes back inline so a Web Audio <audio> element can play them. The samples are immutable, so the response is cacheable. NOTE on the upstream: agent-lite's /v1/embed/voice-preview endpoint is being built in parallel. The upstream URL (EMBED_RUNTIME_INTERNAL_URL) and secret (INTERNAL_SECRET) are both known here, so this is a real proxy — not a 501 stub. If agent-lite has not shipped the endpoint yet it answers 404/501, which this route maps to the client (4xx pass-through / 5xx → 502) and the Preview button degrades to a graceful inline error.