Vai al contenuto
ErmisAI

The operations console

Every admin surface, who can reach it, and what each action does downstream.

/admin is the platform operations console. It is a separate route group (src/app/(internal)/admin/) with its own layout, its own role model, and no relationship to a tenant's newsroom role. The marketing header and footer return null on /admin paths (src/components/layout/RootHeader.tsx:38-45), so the operator shell is the only chrome.

Two facts shape everything below:

  1. There is no link to /admin anywhere in the tenant app. The only /admin hrefs in src/ are inside the console itself; everything else that mentions the path is a pathname.startsWith('/admin') check used to hide marketing chrome. Operators type the URL.
  2. Every admin page is noindex via buildNoIndexMetadata (src/lib/marketing/metadata.ts:200-207). Layout metadata is title Operations, description Authenticated ErmisAI operations surface.

The shell header shows the brand link ErmisAI Operations (pointing at /admin/review), a pill containing the operator's raw platform role string, the role-filtered nav, and a theme toggle (src/app/(internal)/admin/layout.tsx:95-111).

The role pill renders platformRole verbatim — literally super_admin, editor, ops, or unassigned. It is never translated or prettified. The same is true of plan ids, billing statuses, pipeline phases, editorial states, queue names, and delivery methods throughout the console.

Roles and the surface matrix

The platform role is a separate axis from the tenant role (owner / admin / member). It comes from the Clerk JWT session claim app_role (src/lib/auth/clerk-session.ts:200), normalised case-insensitively; anything unrecognised becomes unassigned (src/lib/auth/platform-roles.ts:37-60).

adminSurfaceAccessMap (src/lib/auth/platform-roles.ts:25-35) is the single source of truth:

Surfacesuper_admineditorops
reviewyesyes
tenantsyes
sourcesyesyesyes
clustersyesyes
queuesyesyes
flagsyes
aiyes
costsyesyes
deliveriesyesyes

unassigned gets nothing — canAccessAdminSurface early-returns false (platform-roles.ts:71-73).

Landing surface per role

/admin itself renders no UI. It redirects (src/app/(internal)/admin/page.tsx, getDefaultAdminSurfaceForRole at platform-roles.ts:78-94):

SessionDestination
Not signed in/sign-in
ops/admin/queues
editor/admin/review
super_admin/admin/review
unassigned or unrecognised/app/feed

The brand link in the header is hardcoded to /admin/review (layout.tsx:98) and is not role-filtered like the nav is. An ops operator who clicks ErmisAI Operations lands on the review page's access-denied card, not on their own landing surface.

Where the gate is enforced

Three independent layers, in this order:

Middleware. /admin(/.*)? is matched in isNonLocalizedRoute (src/proxy.ts:70-77). This enforces authentication only — no role check. Anyone signed in can request /admin/*.

The layout. isAllowedAdminAppRole(platformRole) decides whether the shell renders at all. If it fails, the entire shell is replaced by a card reading Operations access is restricted / This area is reserved for platform operations roles. with a link Return to tenant app/app/feed (layout.tsx:74-90, strings from admin.accessDenied). There is no redirect and no 404.

Each page and each API route. Every page re-checks its own surface with canAccessAdminSurface and renders AccessRestrictedState with a Return to admin home link → /admin. Every /api/admin/* handler repeats the check independently; tests/api-route-auth-guard.vitest.ts fails CI for a route.ts without a recognised gate.

Per-surface denial copy lives in admin.pages.<surface>.accessRestricted and follows one pattern, for example: Platform review access is restricted / This operations surface is limited to platform users with platform review access.

The platform role is always read from the live JWT claim and never from the DB snapshot clerk_users.app_role. getResolvedClerkSessionMetadata overrides appRole with the claim value even when the DB row is loaded (clerk-session.ts:250-274), so a demotion in Clerk takes effect on the next token refresh instead of waiting on webhook lag. See Route surfaces, proxy and auth gates.

The eight nav surfaces

Nav items are built one per surface and filtered by canAccessAdminSurface (layout.tsx:49-72), so an ops operator sees a four-item nav: Source catalog, Queues, Costs, Deliveries.

RouteNav label (admin.nav)Roles
/admin/reviewPlatform reviewsuper_admin, editor
/admin/tenantsTenantssuper_admin
/admin/sourcesSource catalogsuper_admin, editor, ops
/admin/queuesQueuessuper_admin, ops
/admin/flagsFeature flagssuper_admin
/admin/aiAI configsuper_admin
/admin/costsCostssuper_admin, ops
/admin/deliveriesDeliveriessuper_admin, ops

/admin/clusters/[clusterId] is gated on a ninth surface but has no nav entry and no index page. See Dead and missing surfaces.

/admin/review — the platform review queue

Heading Platform review queue, kicker Review queue, subheading Operations-side review for synthesis quality and pipeline integrity. A transport pill reads Live / Polling / Connecting / Offline; there is a Refresh button and the empty state No stories pending platform review.

The table (ReviewQueueTable.tsx, DataTable at pageSize={20} — the only paginated admin screen) has columns Story, Category, Owner, Priority, Confidence, Pipeline, Editorial, Sensitive, Age, Action. Pipeline and Editorial print raw enum values. Age is minutes since submittedForReviewAt ?? updatedAt. The action link is Open workspace.

Data path

listReviewQueue() (src/lib/platform/local-platform-data.ts:2730-2761) is repository-only. It calls listEditorialReviewQueueSummariesAcrossTenantsFromRepository(), filters with isStoryWorkflowReviewQueueEligible, and sorts by editorial priority then recency.

When the editorial-draft repository is unavailable, listReviewQueue() returns [] — there is no cross-tenant in-memory fallback, and the failure is swallowed into disableEditorialDraftDb. An empty review queue can mean "Postgres is down", not "nothing is pending." Confirm against /api/health before concluding the desk is idle.

Every row carries tenantScopeId, because story ids are content-derived and can collide across tenants. Live updates come from the review.status.changed SSE event with skipInitialReplay: true; the workspace re-fetches /api/admin/review on each signal. A user holding the review surface receives every tenant's review events (src/app/api/notifications/events/route.ts:58-85), where a tenant user sees only their own scope.

/admin/review/[storyId] — story review detail

This route is unusable without ?tenantScopeId=. Deep-linking a bare story id renders a hardcoded English card: "Missing tenant scope. Open this story from the review queue so the tenant context is carried along - admin review acts on tenant data." (src/app/(internal)/admin/review/[storyId]/page.tsx:44-50). That is deliberate — see Tenant scoping and isolation.

The page loads getTenantStoryDetail(null, storyId, tenantScopeId) and getStorySources(storyId, tenantScopeId) in parallel. With a tenant scope, getStorySources is draft-first and strict: it reads the draft's stored source snapshot and never falls through to the aggregated feed cache, which would otherwise trigger a global rebuild (local-platform-data.ts:2173-2195).

Right column: a Claim provenance card (per-paragraph, with Supported / Partial support / Disputed badges and an optional Conflict signal: block), a Source excerpts card, and a Review actions card whose subheading reads This surface is for operations checks, not newsroom editing.

The three decisions

The buttons are Approve, Reject, and Send back to compose. They POST to /api/admin/review/<storyId>/{approve,reject,return-to-compose} with { tenantScopeId } in the body (src/lib/api/admin.ts:171-207). Each route re-checks the review surface, then delegates to the tenant review runtime with a null actor (local-platform-data.ts:2763-2786).

Despite the "operations checks, not newsroom editing" subheading, an admin decision is a tenant decision. It performs the same compare-and-swap write, fires the same owner notifications and tenant-scoped SSE events, clears the same editor queues, and runs the same alert-rule evaluation — which can dispatch outbound webhooks and emails to the tenant's endpoints.

ActionAllowed fromResult
Approvein_review onlypublished; stamps reviewedAt and publishedAt; appends an approval note and a "Review approved" revision
Rejectin_review onlyrejected; appends a rejection note and a "Review rejected" revision
Send back to composein_review or rejecteddraft; clears submittedForReviewAt and reviewedAt; adds a "Resynthesized for redraft" revision

Implementation details worth knowing (src/lib/platform/review/tenant-review-runtime.ts:553-824):

  • The state check is re-applied inside the CAS updater, and the approve commit is gated on the transition itself (publishedAt === reviewedAt), so a lost race cannot re-fire side effects.
  • Post-commit side effects run through Promise.allSettled and never fail the request. A green toast does not prove the notification or alert landed — failures are logged, not surfaced.
  • published is terminal. The in-updater check refuses to flip a just-published draft back to draft.
  • "Send back to compose" does not re-run synthesis and does not change confidence. The old "+4 confidence per click" ratchet was removed.
  • Revisions are capped at 50 entries.

Failure responses: 404 {"error":"Review item not found"} when the runtime returns null (wrong state or lost race), 400 {"error":"tenantScopeId is required"} on a bad body, 401/403 on auth.

The API accepts an optional note bounded by REVIEW_NOTE_MAX_LENGTH, but the admin UI has no note field and never sends one.

/admin/tenants and /admin/tenants/[tenantId]

Heading Tenants. Columns Tenant, Plan, Monthly AI capacity, Status, Action. The Tenant column is the tenant scope id itself (org:org_… / user:user_…) — TenantAdminSummary.name is assigned the scope id (src/lib/platform/admin/dashboards.ts:81), so there is no human-readable newsroom name anywhere on this screen. Action is Open. Load failure swaps the table for Tenant summaries are unavailable right now.

listTenantSummaries() selects from tenant_subscriptions ordered by tenantScopeId, capped at DEFAULT_TENANT_SUMMARY_LIMIT = 500 (dashboards.ts:41-94). The detail page re-runs the same list and does an in-memory .find() — there is no single-tenant query, so a tenant beyond the 500-row cap cannot be opened from this screen.

The displayed envelope is standing-aware by design: only when subscriptionGrantsPlanEntitlements(status) does it use envelopeOverride ?? resolveCostEnvelopeForPlanCents(planId, seatQuantity); otherwise it shows the individual_free envelope. A past_due or canceled tenant therefore displays €5, not its contracted capacity — because €5 is what enforcement actually uses.

AI billing controls

The detail page is almost entirely hardcoded English. Card 1 is the scope id as H1 plus Plan: <planId> · Status: <status> and Monthly AI capacity: €<n>. Card 2 is TenantAiControlsPanel:

  • Header line: Effective monthly envelope: €N (override active) or (from plan)
  • Checkbox Overage enabled (metered billing beyond the included envelope)
  • Monthly hard limit (EUR, empty = plan default)
  • Envelope override (EUR/month, empty = plan envelope; REQUIRED for enterprise)
  • Save controls, with toasts AI billing controls updated. / Failed to update AI billing controls. / Amounts must be non-negative euro values.

PUT /api/admin/billing/ai-controls is gated on the tenants surface (super_admin only) and writes ai_overage_enabled, ai_monthly_hard_limit_cents, and cost_envelope_per_month_cents_override on tenant_subscriptions via updateTenantBillingAiControlsInDb. That function is documented in-code as the only write path for those three columns (src/lib/db/billing-repository.ts:578-616). Values are non-negative integers bounded by MAX_CONTROL_CENTS = 1_000_000_000; undefined leaves a field unchanged, explicit null clears it.

Enterprise onboarding is not complete until you set the envelope override here. The enterprise catalog envelope is null, so resolveCostEnvelopeForPlanCents returns 0, envelopeConfigured is false, and the tenant's very first AI call throws AiEnvelopeNotConfiguredError (HTTP 402). This is why the input label says "REQUIRED for enterprise" (src/app/api/admin/billing/ai-controls/route.ts:11-16).

Both this page and the panel hardcode el-GR number formatting (tenants/[tenantId]/page.tsx:14, TenantAiControlsPanel.tsx:96) regardless of the operator's UI locale. The tenants list correctly uses the UI locale.

/admin/sources — source health

Heading Source health. One card per source, no table, no buttons at all — the component has zero click handlers. Each card shows the source name, three pills (active/inactive, ingest on/ingest off, and ok / failed / empty parse / not polled), then Last polled: (or never) · 24h articles: · Error rate:, the feed URL, and either the raw last-error text or Parser returned no RSS items or Atom entries.

listSourceHealth() (dashboards.ts:96-147) joins listSourceCatalogFromRepository({ includeInactive: true }) with runtime fetch health from the last feed build. Status resolution order: live fetchStatus → the stored lastFetchStatus column → failed if any error message exists → not_polled. errorRate24h defaults to 100 when the status is failed/empty and no live number exists.

"ingest on/off" is a display pill, not a control. Catalog-to-Postgres sync has no UI at all — see Source catalog operations.

/admin/queues — a derived board, not queue telemetry

Heading Queue health. A grid of four cards named ingest-queue, cluster-queue, synthesis-queue, review-queue, each with a lag badge (<n> + s lag, emerald ≤10s, amber >10s, destructive >30s) and three tiles: Pending, Retries, Dead letter. Read-only — no requeue, drain, or purge.

listQueueHealth() (dashboards.ts:149-196) reads no queue broker at all — not Vercel Queues, not Redis. It derives four hardcoded rows from application state:

  • ingest-queue: pending = global feed items with status === 'collecting'; retries = sources with errorRate24h > 0; dead letter = sources with errorRate24h >= 100; lag = seconds since the newest global story's updatedAt.
  • cluster-queue / synthesis-queue: pending = global items in clustering / synthesizing; retries and dead letter are literal 0; lag reuses the same ingest number.
  • review-queue: pending = listReviewQueue().length; retries, dead letter, and lag are all 0.

Do not treat this screen as queue depth or DLQ monitoring, and do not page on it. For the real state of the queue subsystem see Scheduled jobs and the queue subsystem.

The story counts come from listStoryFeedItems(), which reads the unscoped global draft store — so this board reflects the platform-wide catalog, not any tenant's work.

/admin/costs — all-time provider spend plus billing ops

Heading Cost tracker. Two parts.

CostBreakdownChart — a Total tracked cost card formatted in USD, then one card per row showing the provider, Stage: <stage> · Scope: <tenant|platform>, the USD cost, and Requests: N. It is a direct GROUP BY over ai_usage_ledger on provider_id, stage, billing_scope, summing actual_cost_cents_usd (src/lib/db/ai-usage-repository.ts:372-387).

No date range is passed. toRangeWhere({}) returns no conditions (ai-usage-repository.ts:72-84), so every figure on this page is an all-time total, not a period. It is also in USD, while tenant envelopes on /admin/tenants are in EUR, with no conversion note between the two adjacent screens. See AI usage accounting, guardrails and metering.

BillingOpsPanel — hardcoded English, kicker Billing ops, three tools:

ToolEndpointSurface
Resync tenant from Polar (input org:org_… or user:user_…, button Resync)POST /api/admin/billing/resynccosts
Requeue dead metering rows (button Requeue failed rows)POST /api/admin/ai/metering/requeueai
Webhook event log (filters all | processed | ignored | failed)GET /api/admin/billing/webhook-events?status=…&limit=50costs

Resync pulls the live Polar subscription for the given tenant scope and upserts it through the webhook path; a paid DB row with no active Polar subscription is downgraded to canceled. The success toast is Resync: <outcome>. Use it after an event was acked ignored or failed and left billing state stale — neither of those is retried by Polar.

Requeue resets attempt counters on metering outbox rows that exhausted the 5-attempt budget. Until an operator does this, those rows are silently unbilled overage.

/admin/deliveries — a bounded recent-activity mirror

Heading Delivery logs, a transport pill (Live stream / Polling fallback / Connecting stream / Offline), Refresh, and the empty state No delivery log entries available. Rows show the tenant scope id, a raw success / failed pill, then <METHOD> · HTTP <code> · <datetime>.

Methods are webhook | email | feed | cms | wordpress | api. api is read-only legacy — nothing writes it any more (src/lib/platform/shared/delivery-log.ts:48-55).

listDeliveryLogs() reads the KV key platform:admin:delivery-logs, which the source file describes as "Explicitly recent-N, not a ledger" and caps at 500 records (DELIVERY_LOG_LIMIT, delivery-log.ts:15,35). On a busy platform it silently drops older entries. This screen is not an audit trail. Per-tenant raw logs and exact per-day counters live under separate scoped keys.

Writers are the webhook dispatcher (src/lib/platform/integrations/webhooks.ts:346), the CMS/WordPress export (public-cms-story-export.ts:178), and the feed delivery path (local-platform-data.ts:1970). Live updates arrive over the delivery.failed SSE event; a holder of the deliveries surface receives every tenant's failures.

/admin/flags and /admin/ai

Both are super_admin only. They are covered in full on Feature flags and runtime controls; the short version:

/admin/flags manages exactly five flags stored at the platform:admin:feature-flags key in platform_state, with per-tenant On / Off / Inherit overrides keyed by a lowercased tenant scope id:

KeyDefault
editorPersonalQueueon
storyNoteson
reviewNudgeson
rssQueuePipelineoff
notificationsRestPollingoff

An untouched flag renders Updated 1 Jan 1970, because a missing stored record falls back to defaultFeatureFlagState with updatedAt defaulting to the epoch. The override panel has no tenant picker — you must know and type the exact scope id.

/admin/ai (heading AI runtime governance) is the only editor for the persisted platform AI runtime config: provider mode, profile, global and per-stage reasoning effort, chat stream mode, the ledger/metering/DeepSeek-Flash toggles, guardrails mode, the four stage models, and the Azure deployment registry. It also carries read-only pipeline diagnostics and an AI usage panel with Flush metering backlog.

Two traps. ERMIS_AI_PROVIDER_MODE, if set in the environment, is checked before the stored value on every load — the admin provider-mode dropdown appears to save but has no effect (src/lib/platform/ai-runtime-config.ts:132-154). And AiConfigPanel.save() always sends enableProviderOptionsMatrix: true and chatAllowModelOverride: false (AiConfigPanel.tsx:367-368), silently rewriting two fields the UI never shows.

Screens that can cost money to open

/admin/sources and /admin/queues both call listSourceHealth(), which calls listSourceFetchHealth()loadFeedCache() with no forceRefresh. If the global cache holds zero stories — a cold serverless instance, or an empty bootstrap — loadFeedCache builds synchronously and awaits it: fetch every source, cluster, then AI-synthesise (src/lib/services/rss-aggregation.ts:3813-3826). That is real provider spend and a slow response.

/admin/queues additionally calls listStoryFeedItems(), which will forceRefresh the whole aggregation when both the cache and the global drafts are empty (local-platform-data.ts:720-737). /admin/clusters/[clusterId] has the same property through getAggregatedStoryById.

With warm content these screens serve stale and kick a background refresh instead. Feed cache TTL defaults to 3 minutes (ERMIS_FEED_CACHE_TTL_MS, clamped 30s–30min).

Cross-surface role mismatches

These are live inconsistencies between what a role can see and what it can call. They are worth knowing before an ops operator reports a bug.

SymptomCause
An ops operator sees Requeue failed rows on /admin/costs and gets a 403The button is on a costs-surface page, but POST /api/admin/ai/metering/requeue is gated on the ai surface (super_admin only)
An ops operator clicking the header brand hits an access-denied cardThe brand link is hardcoded to /admin/review, which ops cannot access
POST /api/admin/state/cleanup deletes rows but is reachable by opsIt is gated on the queues surface, not tenants (state/cleanup/route.ts:80)

Adjacent billing routes are also gated differently on purpose: /api/admin/billing/resync and /api/admin/billing/webhook-events on costs (ops-reachable), /api/admin/billing/ai-controls on tenants (super_admin only).

Operator actions with no admin UI

Several documented operator actions exist only as API routes. There is no button for any of them anywhere in src/components.

ActionEndpointSurface
Manual RSS refreshPOST /api/admin/rss/refreshsources
Catalog → Postgres syncPOST /api/admin/sources/catalog/syncsources
Expired state cleanupPOST /api/admin/state/cleanupqueues
Ledger retry redrivePOST /api/admin/ai/ledger/retry/flushai
Open, confirm or cancel a GDPR erasure requestGET/POST /api/admin/erasuretenants
Execute due erasuresPOST /api/admin/erasure/purgetenants

There is no /admin/erasure page. The erasure routes carry an explicit in-code comment that they are gated on the most privileged surface because erasure is irreversible (src/app/api/admin/erasure/route.ts:31-37). Confirming inside the grace window returns 409 erasure_grace_window_active unless force: true. The procedure is GDPR erasure runbook.

Launch-day brakes — signupsPaused, waitlistEnabled, aiPaused, platformDailySpendCapCents, platformMonthlySpendCapCents — are enforced but rendered by no control anywhere. AiConfigPanel.save() never sends them. Flipping one requires a hand-written PUT /api/admin/ai/config or the matching ERMIS_* env var; /api/health reports their current values.

Cron routes that share the admin namespace

Five /api/admin/* paths are also Vercel cron targets (vercel.json). They bypass Clerk in src/proxy.ts:143-160 and self-protect: GET requires the CRON_SECRET bearer via isAuthorizedCronRequest, POST requires an admin session on the surface listed above.

PathSchedule
/api/admin/ai/metering/flush*/2 * * * *
/api/admin/ai/ledger/retry/flush*/2 * * * *
/api/admin/rss/refresh*/10 * * * *
/api/admin/state/cleanup15 3 * * *
/api/admin/erasure/purge45 2 * * *

Dead and missing surfaces

/admin/clusters/[clusterId] is dead UI. It has no nav entry, no index page (clusters/page.tsx does not exist), no generateMetadata, no i18n, and no inbound link anywhere in src/. getClusterDetails is a thin formatter over the live global feed cache: a hit renders three literal "unique angle" strings (Primary source: …, Published at: …, Source taxonomy: …), and a miss renders the placeholder prose "Cluster not found in current live feed window." (dashboards.ts:231-251). It is reachable only by typing a URL with a known cluster id.

No search, sorting, filtering or pagination exists on /admin/tenants, /admin/sources, /admin/deliveries or /admin/costs. Only /admin/review paginates.

Most operator-facing copy is untranslated English. BillingOpsPanel, TenantAiControlsPanel, the tenant detail page, the cluster page, and most of AiConfigPanel hardcode English strings even though the shell loads the full admin and app message namespaces. A block of keys under admin.pages.ai.panel exists in all ten locales but is never consumed — the component uses hardcoded constants instead.

The error boundary copy is wrong about the layout. src/app/(internal)/admin/error.tsx reports to Sentry and renders Admin workspace error with the body "An internal admin screen failed to render. The operator shell is still available via the admin sidebar." There is no sidebar; the shell uses a top nav bar.

Operational notes

  • Admin session resolution hits Postgres on every admin page and API request. getResolvedClerkSessionMetadata reads the Clerk-user repository; on failure it degrades to claim-only metadata and disables the DB path (clerk-session.ts:304-307).
  • app_role is provisioned in Clerk, not in ErmisAI. No route, script, or admin UI in this repository writes it. It must exist as a custom session claim in the Clerk JWT template. If it is absent, resolvePlatformAppRole returns unassigned and nobody can reach any /admin surface.
  • Guardrails set to disabled in a deployed environment emit exactly one loud stderr warning naming /api/admin/ai/config and ERMIS_AI_GUARDRAILS_MODE (ai-runtime-config.ts:296-316). It is a warning, not an alert.
  • An invalid persisted AI config falls back wholesale to defaults. If the normalised object fails platformAiRuntimeConfigSchema, normalizeRuntimeConfig returns the entire default config (ai-runtime-config.ts:692-698).
  • src/app/(internal)/admin/loading.tsx renders a skeleton so client-side nav inside the persisted layout gives feedback.

In questa pagina