Skip to content

Latest commit

 

History

History
870 lines (717 loc) · 335 KB

MAP.md

File metadata and controls

870 lines (717 loc) · 335 KB

MAP.md — Codebase Architecture

Single source of truth for the structure of topiqu-blog.

Current implementation facts come from source and executable contracts. PRODUCTION_AUDIT.md preserves dated 20–21 October 2026 observations; its historical assumptions and test counts do not certify this working tree or the live deployment. Current acceptance evidence belongs in docs/plans/2026-10-22-optimization-and-production-readiness.md.

Product work deferred from the current scope is tracked in ROADMAP.md.

Bun 1.4.4 is pinned across the package manifest, Docker and CI; Node 26.13.0 is pinned for Nuxt compilation, Vue type checking and browser tooling in .node-version, Docker and CI.

Docker uses an app/architecture-scoped Bun package cache and BuildKit APT mounts. The dependency stage stays independent of application source. Host-generated ZenStack output and local test/tool artifacts are excluded from the context; the build regenerates the ORM before Nuxt. Only the prerelease stage installs the copied Node compiler's libatomic1 dependency and uses Node for Nuxt compilation, matching CI; Bun still installs dependencies, prepares Nuxt, generates ZenStack, packages migrations and runs the released server. The compiler command checks that Node starts before generation and limits its heap to 4096 MiB. Node, its PATH and NODE_OPTIONS are absent from the release stage; Bun keeps its normal runtime defaults. Runtime defaults explicitly set production mode and listen on 0.0.0.0:3000 as bun, with its working directory owned by that user. Readiness follows Nitro's NITRO_PORT/PORT precedence. Image builds never migrate a database; the deployment entrypoint applies committed migrations before starting Nitro and exits if they fail. Production deployment remains operator-controlled.

For the resolved Bun preset only, Nitro's rollup:before hook selects server/runtime/bun.ts; dev, prerender and other presets keep their upstream entry. This native adapter preserves Nitro 2.13.5's Fetch/websocket handling and owns stoppable Cron handles because the upstream Bun entry has no signal lifecycle. server/runtime/bunLifecycle.ts makes SIGTERM/SIGINT idempotent: stop admission and future ticks, drain HTTP responses, await active scheduled and response waitUntil promises, then run every Nitro close hook with all results awaited. server/plugins/database.ts disconnects the shared ORM pool; server/plugins/cache.ts closes Redis. A shared 8000ms deadline (NITRO_SHUTDOWN_TIMEOUT, integer 1–300000ms) bounds drain and close together, and must stay below the platform stop grace. Failed draining skips resource closure under uncertain live requests; deadline/failure exits 1 and timeout force-closes connections. Earlier reported background failures do not change a later clean shutdown's exit status. Persisted task leases remain necessary for abrupt termination and incomplete provider operations; successful draining is not a recovery guarantee.

Nitro's virtual tasks module emits false when no schedules remain, so the Bun entry normalizes it with scheduledTasks || []. Runtime TEST=1 also excludes Cron execution through std-env while retaining NODE_ENV=production for isolated diagnostics. The authored-entry regression exercises both exclusions and positive task dispatch without starting a listener or replacing the global Bun runtime.

server/runtime/bunBody.ts bounds actual incoming bytes before Nitro dispatch, independently of HTTP method or claimed length. Shared request limits are 2,000,000 bytes for ordinary bodies and 16 MiB for multipart bodies; the latter includes 1 MiB of envelope space above the largest supported 15 MiB file. Bun's native maxRequestBodySize uses the same multipart ceiling. Incremental reads reject excess bytes with 413, release readers/listeners and stop promptly on abort; rejected input never reaches route work. Nuxt Security's secondary header limiter is aligned with the inclusive ceilings, while each upload route still owns its smaller file/format checks. Raw webhook bytes are preserved.

Unused TurnstileWidget, Article/Editor/Chip and Article/Editor/Popover, the unused AI image-tool wrappers and their dependencies were removed. Client/article list-query date validation is shared in server/utils/listQuery.ts. MJML 5 rendering for every email template and locale is covered in tests/server/sendEmail.test.ts, with SES transport mocked.

The Nitro inline rule for nuxt/dist accepts both path separators to work around Nuxt #36467: without it Windows builds leave the SSR renderer external and return HTTP 500. Nuxt Image's Nitro 2 integration needs the authored Fetch bridge described below to preserve Bun response headers. The provider's URL parsing, filesystem paths and remote-host restrictions remain upstream-owned. Vite 8 uses Oxc minification to remove production console/debugger statements.

1. Stack

Nuxt UI's upstream Popover and DropdownMenuContent use direct Reka component exports rather than the shared namespace barrel, keeping public controls independent of private calendar/date exports. Nuxt DevTools invokes Git through its command runner. These replace the former UI/DevTools Bun compatibility patches; preserve compiled popover, hover-card and locale checks when upgrading these modules.

The Bun patch for Unhead 3.4.3 consumes completed DOM cleanup callbacks. Its upstream renderer retained already-fired callbacks in an older cleanup map; live callbacks shared that lexical context and kept removed meta/link nodes across SPA navigation. Deleting each callback after it succeeds breaks that chain and preserves error ordering. tests/unit/headCleanup.test.ts checks real renderer disposal/document switching, title/attributes and event cleanup. Docker copies declared patches before its frozen install. Reassess this patch against the same regressions when upgrading Unhead.

With Nitro 2, Nuxt Image 2.2 selects a legacy IPX Node handler whose raw header writes are lost by Nitro's Bun response shim. The Nitro build:before hook recognizes both the module's actual extensionless legacy handler path and its .js form, then replaces only that registered handler with server/runtime/ipx.ts: public IPX Fetch APIs and H3's toWebRequest preserve the returned Response headers and conditional requests. Nuxt Image still creates the storage, TTL, aliases and host restrictions; the local bridge adds no parallel image policy. This also avoids the dynamically required srvx/node adapter, so no special dependency trace or package patch is needed. tests/unit/ipxRouteConfiguration.test.ts loads the installed module to verify its real registration; compiled IPX smoke checks verify the resulting image.

Production OG image resources use server/runtime/ogFetch.ts around the installed module's Node fetch binding. The build hook retains that exact upstream transport under a private alias; DNS checks, checked socket lookup, redirect validation and deadlines stay upstream-owned. A byte-counting stream allows at most the shared 15 MiB image limit, rejects oversized declared lengths before reading and cancels/releases a failed stream. The module's existing failure path omits an unavailable image. Redirect/error/null-body semantics are preserved; custom and web bindings are left intact. Tests exercise the installed module registration and actual redirect/body reader with synthetic transport responses, without external calls or dependency patches.

Nuxt UI's component detection generates only themes referenced by app source (including component dependencies). Keep dynamic Nuxt UI component names statically discoverable or explicitly include them in ui.experimental.componentDetection. features.inlineStyles also inlines global Tailwind/UI/font CSS, removing the blocking entry stylesheet from SSR while preserving styles owned by lazy components. Nitro emits gzip variants of public assets; Brotli stays a CDN concern to avoid expensive build-time compression. The article's related shelf uses native visible hydration with its SSR content intact; header controls and forms hydrate immediately. Release smoke asserts SSR global styles, gzip negotiation and hashed-asset caching.

Responsive media uses explicit xs (320px) and phone (480px) image breakpoints instead of an unqualified fluid size: Nuxt Image otherwise generates 1px/2px candidates. The Manrope Latin preload has a stable head key shared by SSR and hydration. NuxtLink prefetching starts on pointer/focus interaction instead of importing every visible destination during startup. Consent settings mount lazily on their first opening and stay mounted for closing transitions. Shared browser Zod schemas import shared/utils/zod.ts, which disables JIT before schema construction to avoid a CSP-blocked Function() probe; server validation retains its default JIT path. Error reporting and CSP remain enabled.

useDashboardAccess gates the dashboard layout, header drawer trigger and generation activity together. Ordinary admins need the publication host's resolved site ID to match their active, server-authorized session tenant; platform root/app hosts retain tenant management, while unresolved publication hosts fail closed. Superadmins retain global access. This is UI gating, not a replacement for server membership/scope checks.

  • Public rendering performance: critical AppMedia images are visible in SSR, eager and high-priority, without a JavaScript-dependent placeholder over them. Non-critical images retain lazy loading; actual errors still retry the original source, with a 30-second visible-image stall deadline instead of prematurely blacklisting slow images. Article covers and hero logos explicitly opt into priority; covers include mobile srcset variants. The homepage keeps previously rendered search results during debounce/fetch, applies a query snapshot, replaces first-page results only on completion, and reserves the largest measured result height, capped at one viewport, while filters are active so empty results do not pull lower sections into view. Clearing filters resets that reservation. Dashboard sidebar modules and privileged dialogs are async; dialog modules mount on first opening, initialize their data immediately and stay attached thereafter to preserve close transitions. Tag management captures the opener and explicitly restores keyboard focus on close. Background article-generation UI is restricted to privileged sessions. Nuxt's build manifest disables blanket SSR prefetch hints for unused async modules; rendered-module preloads and NuxtLink navigation prefetching remain intact. These are loading boundaries, not authorization gates.

  • Production boundaries: generic uploads validate tenant scope and decoded raster content before moderation/storage, normalize media, and use server-generated UUID keys under the tenant/user namespace. Client filenames never select storage objects. Public feed queries share server/utils/articleCard.ts's explicit select; cache namespaces were versioned when internal fields were removed. Authenticated API responses are no-store, and the service worker no longer runtime-caches responses; its activation script removes the old api-cache.

server/utils/articleViewerAccess.ts::publicArticleWhere defines released, published, undeleted article/tenant visibility for public author/tag collections, crawler feeds and external exports. Published translations remain subject to their source article's visibility; Markdown detail uses canReadArticle with an anonymous viewer. Administrative previews remain explicit and separate from public counts/exports. Tag-ID reads retain authenticated reader access through the shared public card selection, filtering at the article root before pagination so hidden joins cannot become null records or expose prompts/accounting. Global-tag previews stay within the administrator's active tenant; superadmins retain broader undeleted previews. Author summaries use a versioned payload namespace while retaining their existing generation invalidation.

  • Lifecycle and scheduling: requireCron fails closed for absent secrets. Nitro schedules trial expiry and rotating domain revalidation, and disables scheduling in browser-test builds. Scope/session/public host resolution rejects deactivated tenants; deleting a tenant detaches global identities rather than deleting their other publications. Stripe plan mutations are conditional on the currently attached subscription and reconcile current provider state. Scheduled publication uses a transaction-scoped advisory claim. Translation workers use monotonic timestamp leases, reclaim abandoned work and compare the source article's editVersion under a shared row lock before committing; changed sources return to STALE, and a replaced worker claim cannot overwrite human edits. The rebuildable media-usage index runs after content commits, so a caught indexing failure cannot abort that transaction.

  • Translation writes and public caches: manual translation POST is create-only. AI replacement requires explicit replacement intent and the observed translation timestamp; PATCH and DELETE also compare that timestamp and the observed status. Writes affecting live content require ARTICLE_PUBLISH. Every writer advances timestamps explicitly because ORM updateMany does not do that automatically. Translation changes and tag rename/delete invalidate public feed generations; global-tag mutations collect referring tenant IDs before deleting membership rows and invalidate after the mutation succeeds. Provider usage, audit and announcement failures are reported without classifying already completed translation work as failed.

  • Article text metrics: server/utils/metrics.ts::articleTextMetrics owns the word/200-word reading-time contract for stored HTML and plain text. It preserves inline word joins, separates block/table boundaries, decodes entities and includes saved poll questions/options while excluding scripts, styles, templates, comments and image metadata. Generation savings and the ORM mutation plugin consume the same counts; the plugin preserves explicitly supplied metrics. Financial rate/speed estimates remain in shared/utils/savings.ts. Historical values are not rewritten; Markdown is an export format, not the stored body format.

  • Recovery and release: password reset tokens use purpose-bound HMAC values in Better Auth's existing Verification table; consumption, password change, session revocation and DB audit share one transaction. Old reset codes must be reissued. Invitation consumption is guarded inside its membership transaction and never changes a system role. Docker builds are DB-independent and run under bun; scripts/prepare-migrations.ts packages the existing locked Prisma CLI/native engines, generated ZModel schema and committed migration history inside .output/migrate. The unprivileged container entrypoint applies migrations before starting Nitro and exits on failure. A direct DB URL is scoped only to migration execution; runtime keeps its normal URL. Backup/rollout remains operator-controlled as documented in README. The DB pool is bounded, health has a deadline, external logging has a bounded asynchronous transport, and fallback identity rate limits cap memory without evicting active limits.

  • Dashboard UI: Header.vue owns the wordmark, account, interface language, theme and notifications. Its drawer trigger uses the same lg breakpoint as Nuxt UI's sidebar; Sidebar.vue puts the tenant switcher in its header. Settings and profile use lazy fetches with explicit initial skeleton/error states; late responses initialize pristine forms without replacing dirty edits. TenantCreate.vue only allows revisiting steps reached through Continue, and revalidates details before Review.

  • Dashboard setup: Admin/SetupChecklist.vue sits directly above the Client/Version.vue bar in one global floating stack, with no header or sidebar trigger. Tenant admins with settings permission can expand/collapse it; the preference is stored per user and tenant. New blogs start expanded, already published blogs start compact, and the entire widget disappears when complete. buildSetupChecklist in shared/utils/siteSetup.ts splits the steps into two phases. start is the way to a first article and is listed first, so the next open step is always one of it: on AI plans the website import (done once REVIEWED, even skipped, or with focus + audience entered by hand), a first draft (setup.hasDraft) and a live published article; on Basic branding and the article. later holds the rest and locks nothing: branding, AI author (name, portrait, bio, writing style), focus + audience, an enabled indexed knowledge source, publishing mode, any configured custom domain (including pending DNS changes) and the optional steps. The header counts the phase in front of the user, and the collapsed later group opens once start is done. The website step works in place (Admin/SetupWebsite.vue): import progress, a summary with accept-all (not on the settings page, where the panel may hold unsaved edits of the same fields), the start form, or Admin/SetupBrief.vue for a blog without a website. The draft step lists the import's topics (WebsiteImport/Topics.vue); while the website step is open, WebsiteImportNotice hides (useState('setup-checklist-website')). AI steps only appear on AI plans. Each accordion row lists concrete requirements and a permission-aware link to the relevant settings, knowledge sources or existing draft. Processing and failed imports have separate states. These recommendations are distinct from the smaller buildSetupSteps requirements on the empty publication, where logos and knowledge imports remain optional. /api/clients/status uses the request-scoped client, selects only safe author fields, excludes deleted/future publications, and summarizes enabled sources without content. It returns the latest draft slug only to article writers when there is no published article. hasDraft also counts a COMPLETED ArticleGenerationSession, read with the system client because the model denies every policy read: a new editor autosaves to ArticleDraft, so no Article row exists yet. Article invalidation, knowledge-source changes and saved author uploads refresh the shared status snapshot; the checklist never changes generation or publication settings. ensureMinAccountAge refuses with ACCOUNT_TOO_NEW and retryAfterMs; useArticleGeneration waits that out once, so the first generation of a new account does not fail.

    While an unfinished knowledge step is processing, the widget polls shared status every five seconds, including when collapsed. Polling stops on completion, an error, a tenant/access mismatch or a hidden browser tab, and resumes when the tab becomes visible.

    Publishing setup accepts an active, valid creation schedule or an explicit review of the saved manual/automatic mode; reviewing never enables automation. Measurement, a teammate and an external publishing channel are optional steps. The status endpoint reports only booleans/counts: enabled Analytics with an ID or an active Search Console connection with a property, active human memberships or unexpired/unrevoked invitations, and an enabled Shopify destination blog or an unexpired LinkedIn connection. Tokens, invitation addresses and integration identities are not returned. Skip/restore decisions and manual-mode review are browser-local preferences scoped to the user and tenant, separate from actual server completion; skipped rows have their own status and count as resolved. New integration/member mutations and reopening the checklist refresh the shared status. The measurement action deep-links to the Analytics dialog using settings?tab=integrations&integration=analytics. There is no topic-plan editing or AI-visibility measurement step.

  • Domain recovery: Admin/DomainVerificationBanner.vue reads the active session tenant status and displays the existing verification API's TXT/CNAME instructions on the dashboard and branding settings. /api/domain/pending also accepts valid, unreserved Topiqu subdomains; activation rechecks availability and domain rules, then uses the same atomic swap and old-host redirect as custom domains. Managed subdomains need no external DNS proof or paid plan. This is an explicit change of canonical domain, not a second simultaneously served address; no schema change is required.

  • Editor navigation: pages/admin/editor/[id].vue owns route middleware/meta, an immediate Suspense boundary and a local error boundary with an explicit reload action. The existing editor lives in Article/Editor/Workspace.vue; Article/Editor/Skeleton.vue matches its command bar, fields, canvas and desktop panel while the workspace module and article load. SSR still waits for the actual editor. The workspace measures its command bar into --editor-command-bar-height; the Tiptap toolbar sticks below it at a lower stacking layer, including when controls wrap. useClientSiteStatus() reuses the shared status snapshot on initial consumers and bypasses it for explicit refresh/invalidation.

  • Controversy control: Form/Client/ControversyScale.vue is a discrete 0–3 slider (NONE/LOW/MEDIUM/HIGH; empty = 0) on a rounded muted track with inverted fill, checkpoint dots and clickable short labels (controversyLevel.short.*). It uses Reka slider primitives because USlider hardcodes the thumb aria-label and has no value text. Stops are offset like Reka's in-bounds thumb (12px − p·24px) so dots and labels line up with it. Covered by tests/unit/controversyScale.test.ts.

  • Content settings: Form/Client/Content.vue and Form/Client/Community.vue follow the AI tab layout: h2 page header, then groups with an h3 outside a card, each group using a different card type (field grid, divided rows, switch rows with icon tiles). Keywords use UInputTags with addKeywords as the setter for case-insensitive dedupe; language uses USelect because USelectMenu's trigger aria-label overrides <label for>. Covered by tests/unit/contentBrief.test.ts and tests/unit/tenantPublicationSettings.test.ts.

  • Structured AI output: use generateText / streamText with Output.object({ schema }); read the validated result from output. server/utils/ai/articleStream.ts combines the SDK's stream activity/errors with partialOutputStream drafts so manual generation keeps its progress signals, watchdog and recovery snapshots. The endpoint's NDJSON contract remains unchanged. tests/server/ai/articleStream.test.ts covers partial/final output, usage, provider errors, schema failure and reader cleanup with the real SDK and a mock provider.

  • Framework: Nuxt 4 (Vue 3.5, Composition API), TypeScript strict. Vue component conventions are documented in CLAUDE.md under Code Style.

  • Styling: Nuxt UI 4 + Tailwind CSS 4, loaded by app/assets/styles/main.css. Reka UI supplies the accessible primitives. Use Nuxt UI components and Tailwind utilities; shared article/table hooks are styled in main.css.

  • Article tables use shared ARTICLE_TABLE_CLASS / EDITOR_TABLE_CLASS hooks from shared/utils/articleProse.ts. Article/Parsed.vue wraps published tables in a scrollable surface; Tiptap/Editor.vue adds editor-only chrome such as selected cells and resize handles. Both are styled in main.css.

  • Article spacing lives in main.css. ARTICLE_PROSE_CLASS hides legacy empty paragraphs and trailing line breaks in both the reader and editor preview. dropBlankLines normalizes the same markup when saving/generating content.

  • Reading progress is scoped to the article body, not the full page. useArticleScrollContext measures the body rectangle against the active window/dashboard scroller, so comments, sources, related cards and the footer do not pin progress near the beginning and then force a jump to 100% at document bottom. An ancestor marked data-article-scroller (the editor preview) takes precedence over the dashboard scroller.

  • Article/View.vue is the one article layout — hero, tags, summary, body, FAQ, series, sources, TOC. pages/clanky/[slug].vue adds engagement (follow, likes, share, comments, ads) through slots; Article/Editor/Preview.vue feeds it unsaved editor state inside a fullscreen UModal. The preview wraps it in .publication-surface with tenantThemeStyle from clientsite-status, because the editor runs on the platform host where useClientSite() is null. A capture-phase click handler blocks in-app links so the preview never navigates out of the editor. Global .article-content rules belong in main.css, not a page <style>: a page stylesheet only loads with that page.

  • State: Pinia (@pinia/nuxt) + pinia-plugin-persistedstate; Pinia Colada (@pinia/colada + @pinia/colada-nuxt) for admin-side data fetching and cache invalidation, see Data-fetching split below. There is no event bus.

  • i18n: @nuxtjs/i18n (en, cs, de, fr). Each locale has the same UI namespaces, master administration copy, and email templates. shared/utils/language.ts owns supported codes, language tags and picker labels; shared/utils/routes.ts owns localized article, tag, author and auth segments outside Vue. The Language database enum supports all four codes. Existing tenants with an empty translation target list are migrated to their previous cs/en counterpart; new tenants may select any of the other three languages. The UI language is picked in the account menu (User/LanguageMenu.vue, guests too); a signed-in choice is saved to User.language, which also selects the language of their emails.

  • Auth: Better Auth (server/utils/auth.ts, ZenStack adapter), argon2 hashing, OTP (otplib). See §4 Auth.

  • DB / ORM: ZenStack ORM v3.10 with its PostgreSQL dialect (domain-split prisma/schema.zmodel + prisma/models/*.zmodel → ignored generated/zenstack/ TypeScript schema/types, ~50 models). Prisma is not an application dependency; the ZenStack CLI carries Prisma internally only for migration-engine compatibility. See §6.

  • Editor: Tiptap 3 (custom extensions in extensions/: Poll, slashCommand, indent, figure, image, aiDisclosure, productCard, xPost).

  • Body images are <figure class="article-image"> (extensions/figure.ts): Figure + a single Figcaption child, so heading/list commands cannot turn the caption into a block and drop the image. Attributes live on the <img> (alt, title, width/height = intrinsic size for CLS, data-media-id), on a wrapping <a> (href), and on the figure (style="width: N%" = displayWidth, data-align incl. float-left/float-right). Parsing accepts only 10–100 % in 5 % steps, the known aligns and http(s)/mailto/relative links (app/utils/articleFigure.ts), because DOMPurify keeps any style. The legacy AI shape <p><img><br><small>caption</small></p> is upgraded to a figure when an article is opened; an image in running text stays the inline image node. AiDisclosure keeps <span data-ai-disclosure> through edits. Selecting an image (the figureSelect plugin; ProseMirror only click-selects leaves) shows Tiptap/ToolbarImageBubble.vue: alt/title/link (ImageDetails.vue, optional write-back to MediaAsset.defaultAltText), width presets, alignment, replace from library, delete. File/TiptapImageFrame.vue adds drag-resize handles and a missing-alt badge. Floats apply from sm up (main.css); a linked image navigates instead of opening the lightbox.

  • AI: AI SDK 7 with @ai-sdk/anthropic and @ai-sdk/openai. All task, image, embedding and visibility model IDs live in server/utils/ai/modelRegistry.ts. OpenAI gpt-6-luna through Responses handles article writing, translation, text editing, brief enhancement, LinkedIn posts, backlink outreach and the ChatGPT visibility probe. Other text tasks run on claude-haiku-5-5. Article writing and its automatic revision pick the writer by the saved quality (modelRegistry.ts::ARTICLE_WRITERS): Standard = gpt-6-luna, High = gpt-6.1-terra, Ultra = gpt-6.1-sol, all at effort high (shared/utils/articleQuality.ts). The writers stay on OpenAI so the Anthropic claim check and copy desk remain an independent model family. The editor's automatic revision rewrites the whole article, so ai/article.ts::REVISION_TIMEOUT_MS allows 5 minutes; 90 s left no margin under load, and an aborted revision silently kept the unrevised draft. Research and editorial/factual reviews use high; translations, text edits, short copy, helper tasks and the native Claude visibility adapter use medium. ai/models.ts picks the provider per task (aiModel, aiWebSearchTool(task, depth): Anthropic web_search_20250305 with maxUses 3/5/8, or OpenAI searchContextSize). Both providers count reasoning against the output limit, so middleware adds 8k/16k/32k/64k tokens at medium/high/xhigh/max to the caller's answer budget; explicitly disabled thinking gets no extra headroom. Haiku rejects temperature/top_p/top_k. Guarded by tests/unit/aiModels.test.ts, tests/server/ai/articleWritingQuality.test.ts and actual SDK request/response tests in tests/server/ai/models.test.ts, without live API calls. Images use gpt-image-2.5-flare with explicit medium quality; knowledge embeddings remain text-embedding-3-small at 1536 dimensions. Visibility HTTP adapters read the central AI_VISIBILITY_MODELS registry and run only providers with a configured API key. Optional provider model IDs retain their original values. Reasoning and answer budgets remain explicit at their call sites.

  • Article quality and credits: ClientSite.articleQuality defaults to STANDARD; Form/Client/ArticleQuality.vue edits the setting through the existing tenant settings form. shared/utils/articleCredits.ts prices Standard/High/Ultra at 1/2/3 credits, including review and one automatic revision. Manual and scheduled generation capture quality before reservation and use that snapshot for writing, audit metadata and settlement. The 20261022170000_article_credit_quality_counts migration lets operations reserve multiple credits while keeping charges bounded by their reservation; deploy it before enabling quality-priced generation. Wallet copy, generation controls and release forecasts use the same price. articlesRemaining remains the API field for available credits. ai/articleUsage.ts attributes language, image and embedding token usage to an attempt, operation and stage through request-local middleware; ai/spend.ts and aiAllowance.ts enforce separate provider-cost funding, detailed under AI allowances and invoice receipts below.

  • Default editorial author: server/utils/ensureAiAuthor.ts creates or reuses the tenant's oldest active AI author, with a localized publication-based name from shared/utils/aiAuthor.ts and the publication logo. Tenant creation calls it inside the existing transaction; scheduled generation and fully AI-authored manual saves also resolve it for older sites. Parent-row locking and collision-safe usernames prevent concurrent duplicate authors without overwriting a customized identity. The existing avatar and settings routes reuse this author.

  • Manual generation streams: server/utils/ai/article.ts splits into researchTopic (live-web facts and URLs plus a dedicated YouTube lookup that tries up to three retrieved, oEmbed-verified candidates when selected; the lookup gets the topic wrapped as a search task, since a bare question made Haiku answer it at 4–5× the time, and its video survives a failed brief under the no-research note; RESEARCH_CONFIG timeouts cover the whole output ceiling, because high-effort thinking alone varied 5–10k tokens) / buildArticleConfig (Prisma preferences + research-only factual grounding + articleSchema) / finalizeArticle (licensed Wikimedia/Openverse lookup with progressively shorter archive queries, generated fallback only where editorially safe, then numbered media-slot replacement) / blocking and streaming writers. POST /api/articles/generate emits NDJSON progress, the actual reservation, finalized content, then an authoritative billing event with the new balance. Events carry outcomes, not just counts: research fallback reason, the YouTube outcome (found with oEmbed title / noCandidates / rejected / failed / researchOff), per-slot image source (official / screenshot / library / ai / null), review issues, server-computed missingModules on final, and stage + creditReturned on error. The client folds them with the pure reduceGenerationRun / generationSteps (shared/utils/articleGeneration.ts) into one GenerationRun, rendered by Article/Editor/GenerationRun.vue above the title; the sidebar only holds the preset form. While running it shows a step track, the active step live (the streamed H2 outline via writtenHeadings, review stages, image tiles) and, once scrolled away, a compact bar teleported to the page bottom. The report lists only what needs attention, each with its fix: a fact check, Media/Picker for a missing image, the YouTube prompt, the cover setting. A clean run folds into its header. The media event carries coverUrl/slotUrls for thumbnails and, on complete, gaps: each unfilled slot with its query and after, the whitespace-free text of the block the marker followed (imageSlotAnchors), because the marker itself is stripped. The editor (Tiptap/Editor.vue fillImage/insertVideo/focusImage) inserts at that block or at the end when it was edited away; a video lands before the second H2. The writer has two clocks (WRITER_LIMITS in the endpoint): thinking until the first partial, with no idle check since a reasoning model is silent until then, and writing from there with the 45 s idle guard. Until textAt the panel says no text exists yet and what the writer works from (knowledgeItems); limits and slowness are not shown while running, only in the failure message. A timeout aborts only the writer (writerSignal). If two finished sections remain (salvageBody in server/utils/ai/draftCut.ts drops the unfinished last block), completeDraft writes the remaining fields in one more call with the writer's config, the run is reviewed and finished as usual, final.cut marks it and it is not charged. The run itself lives in the articleGeneration Pinia store, not the page: leaving the editor keeps it going, the editor re-attaches by target (new / article id) and Article/GenerationActivity.vue shows it elsewhere. Leaving a finished, unsaved run on its source language does not ask to discard: the editor writes the author's edits back into the store (keepGeneratedEdits), and only saving or dismissing clears it. The store is mirrored to sessionStorage and restored after hydration, so a full page load (the public site, a refresh) keeps it too. It also holds the run's prompt and options, which a remounted editor copies into its empty form so Retry resends the same request; with no prompt Retry is disabled. A run restored as running polls GET /api/articles/generations/[id]/status and takes the finished article from the session snapshot, marking the steps it never heard about as resumed. A disconnected stream keeps generating on the server; only POST /api/articles/generations/[id]/stop aborts it. Internal [[IMAGE…]]/poll/video markers are omitted from the streamed editor view; resolved images appear only when ready, while all non-final exits strip every remaining slot. Only slots carry assets: unwrapContentSlots lifts a marker out of an <img>/<figure> the writer wrapped around it, and dropAuthoredImages removes images the writer wrote itself.

  • The generation report expands the research source count into the existing Article/SourceList.vue. Editorial review-progress events mark actual live verification, copy-desk review, revision and rechecking; the shared reducer stores the current stage, with a generic fallback for older persisted runs. Transport heartbeats do not count as editorial activity. After 20 seconds without progress, the report shows elapsed seconds since the last actual update, without inventing intermediate progress.

  • Editor sidebar = three tabs (Article/Editor/SettingsPanel.vue, shared by the desktop aside and the mobile slideover through the page's settingsTab): Article (cover, sources, series, tags, release), AI (GenerationForm.vue, or AiUpsell.vue without hasAiPlan), Checks (optimization, fact check — locked without an AI plan, since /api/articles/fact-check is AI-gated — and media rights; the tab badge shows media needing attention before the score). AI opens first only on a blank article / ?ai=1 / preset prompt; a finished run switches to Checks. Tabs keep hidden panes mounted (unmountOnHide: false), but focusOptimizationTarget still switches to Article before scrolling, because a hidden pane has no box.

  • Phone editor layout (Article/Editor/Workspace.vue, Tiptap/Toolbar.vue). Below sm the command bar and the toolbar are one row each, so the two chrome layers no longer take most of the screen. Secondary actions and tools stay mounted and are only hidden (max-sm:hidden) behind a ⋯ toggle, because the language tabs, the Shopify panel (a template ref) and the file and colour inputs are components that a dropdown could not hold. The command bar is sticky only from xl, where --editor-command-bar-height also offsets the toolbar. The toolbar collapses again after a command and when the text takes focus; the character count moves under the text as compact; main.css hides [data-client-dock] while the editor has focus so it cannot cover the lines being typed. Keeping the toolbar above the on-screen keyboard is not solved here. Tests: tests/unit/tiptapToolbarMobile.test.ts, tests/e2e/authenticated-flows.spec.ts; openEditorActions in tests/e2e/helpers.ts opens the panel for specs that use secondary actions.

  • No v-if on Tiptap bubble menus (Tiptap/Editor.vue). Tiptap detaches their element, so unmounting one breaks Vue's patch and every later update of the page fails silently; generation toggles edit, which froze the whole editor sidebar. They hide through shouldShow + editor.isEditable instead.

  • Library image matching (images/selection.ts → matchesImageQuery) requires the query's identity terms — names and 1–3 digit installment numbers — not every descriptive word; queries without a name need half of their content words. The every-word rule rejected nearly all real photos, so illustrative slots fell through to AI. A named subject is retried as a portrait (photoSubjectQuery) for every intent before AI is considered. The cover never stays empty when AI images are allowed: the writer's coverImage.broaderQuery (the place, organisation or field) is searched next and credited as illustrative, then a labelled AI illustration follows — for a photo subject through illustrationPrompt, which excludes people, logos, text and the real event. Body images keep the empty-slot rule, except in a manual story (illustrative): its scenes were never photographed, so the writer asks for real places and franchise material as photo and invented scenes as generate without named characters, and a photo slot nothing matches falls back to the same people-free illustrationPrompt when the plan and the author's toggle allow AI images. Cron never auto-publishes an article without a cover (coverApproved).

  • Vision check of library images (images/verify.ts → imageDepicts): word overlap cannot tell what a picture shows. A Grand Canyon plaque ceremony whose 600-word Commons description mentioned "energy efficient", "retrofit" and "funding" became the cover of a house-insulation subsidy article. Every Commons/Openverse candidate (including the photoSubjectQuery retry) is therefore downloaded through the SSRF-safe fetchPublicUrl (Openverse thumbnail preview, ≤ 4 MB, raster types only) and shown to Haiku (imageCheck, thinking off) with the article title and the wanted subject; photo intent requires the subject itself, stock/generate any fitting picture. Stand-ins (cosplay, figurines, statues, merchandise, fan art) are rejected unless they are the article's subject; Commons has little else for fictional characters, so those slots usually stay empty. At most 3 looks per search (IMAGE_CHECKS); a rejected candidate moves the provider to its next one, and any download/model failure counts as a rejection. Official press images and store screenshots skip the check: they come from the subject's publisher. Each look is an image_check audit step with the verdict. Cost is roughly $0.0001–0.0002 per look. Commons matching also reads only the first 200 characters of ImageDescription, since a long description contains half the words of almost any generic query. Provider accept callbacks may be async (ImageAccept); createImageSelection().taken peeks at the reservation without recording it, so a picture is reserved only after it passed the check.

  • Formats propose modules, they do not restrict them. An explicit module selection passes selectedModulesFor / applyFormat untouched; allowedModules only bounds what the cron topic picker may choose (topicSchema). The autopilot adds media in code (formats.ts::autopilotModules): body images whenever the format allows them, and a YouTube video whenever the topic is researched. The writer embeds a video only with a real URL from research. Left to the picker, neither was chosen in 23 autopilot articles (19 October – 3 November 2026), so every article had a cover and no body media. The picker now chooses only answer, takeaways, FAQ, poll and table. The editor preselects ARTICLE_GENERATION_DEFAULT_MODULES on a format change and keeps the author's media choices. The picker also gets aiToneOfVoice and aiControversyLevel (topic.ts::voiceBlock); at HIGH it prefers opinion, which is exempt from the recent-format rotation.

  • The author's first-hand account is testimony (AUTHOR_ACCOUNT_RULE / AUTHOR_ACCOUNT_VERIFICATION in articleQuality.ts, used by writer, verifier, copy desk and revision). Before it, a first-person story was an unverifiable claim, so the writer produced a "model case" with a disclaimer. Specifics of the case the account does not state (names, amounts, dates, durations, quotes, decisions) must stay general; the verifier flags invented ones as UNSUPPORTED MATERIAL.

  • The article speaks about its topic, not its research (editorialPolicy in server/utils/ai/editorialPolicy.ts, shared by writer, verifier, copy desk and revision). A statement that something cannot be verified is allowed only as a correction of a specific claim, a reported unconfirmed status, or a conflict between authoritative sources; otherwise the point is omitted. aiControversyLevel sets both the voice and how residual uncertainty reads (NONE/LOW attribute once, MEDIUM/HIGH fold it into the argument). Caveats came from the pipeline, not the writer: research returned an Unknowns section the writer published, and the revision saw advisory NOT VERIFIED verdicts and turned them into qualifiers. Research now lists only corrections, revisionEvidence drops NOT VERIFIED lines before revision (they stay in the audit), and the copy desk reports remaining caveats as blocking hedging. A story is narrated in scenes and framed once as illustrative; research gathers the real process it passes through instead of checking the plot, and the copy desk may not demand its documentation. Stories read as fact sheets with a protagonist walking through them (a body disclaimer about what is fictional, "a study notes" attributions, word definitions, an invented phone screen to carry a requested table, an options list), so STORY_CRAFT in formats.ts sets the writer's scene rules: the illustrative frame lives in the perex only, fictional characters may speak invented dialogue (real people never do), research enters as what the protagonist meets, modules sit between scenes as plain sidebars, and HIGH controversy puts the voice in a sardonic narrator. The copy desk reports those failures as broken_structure; a list in a story body is a deterministic issue. A requested video is owed only when research appended a Verified YouTube video line (deterministic check); otherwise the desk may not report it missing, since the writer cannot invent a URL. Story video research searches the real subject (character, franchise, place), not the invented plot. Review notes reach the editor in its interface language (the X-UI-Locale header the generation request sends, else the i18n_lang cookie, via useServerI18n().locale → noteLanguage; the cookie alone lags behind a /cs/ link): the copy desk and verifier write their notes and findings in it, while system-written issues keep an English note for the reviser and carry a key/params that GenerationRun.vue renders from articles.editor.ai.run.note.*. After a verified revision the follow-up desk pass (factsChecked) treats its own unsupported_claim as advisory and blocks only invented_detail (a quote, number, date or source absent from the grounding); the editor shows those remaining issues instead of a green review step. Measure with bun run eval:ai (tests/eval/, live model calls, skipped in CI).

  • Assignment fidelity is part of editorial approval. editorialPolicy carries aiToneOfVoice to writer, copy desk and revision; an explicit tone in the article assignment overrides the publisher default. An opinion develops the requested stance, including requested profanity, without interpreting profanity alone as violent intent or replacing the article with a civility/nonviolence lesson. HIGH opinion/story explicitly retain the confrontational voice the topic and tone call for, without mandatory balancing paragraphs or conciliatory endings; substantial unsolicited softening is a blocking assignment_mismatch. HIGH analysis/comparison get a lighter directive (committed): argue a thesis in the requested tone, and a neutral explainer is a blocking assignment_mismatch. A civil assignment still overrides a profane publisher tone. Shared AUTHORIAL_REASONING distinguishes expressive hyperbole from concrete factual allegations for the verifier too; factual repairs preserve the rest of the argument's force. articleQuality.ts treats substantive assignment_mismatch as blocking even after factual verification, using the existing single revision and follow-up review. Factual corrections remain required; minor style differences are advisory. The manual editor retains an unapproved draft and shows the issue, while scheduled auto-publication still requires editorial approval. tests/eval/articleVoice.test.ts uses the production model/search adapters, runs tenant fixtures sequentially, checks known bad and good review examples, and saves both article versions plus a separate voice rubric assessment for human inspection. Rubric judgments are model assessments, not proof of correctness.

  • Editorial gate: the review revises, only risk holds (server/utils/ai/article.ts::editorialPass, shared by the editor stream and the autopilot). One full review (live fact verification + copy desk), one revision with every finding, then articleQuality.ts::recheckRevision: it asks only whether each finding was resolved and whether the revision added specifics found in neither the original draft, the grounding nor the assignment; module, table and story rules re-run in code. There is no second web verification and no fresh review. In production (October 2026) the autopilot's re-review with live verification approved 0 of 13 revised articles: each live pass samples different claims and each fresh copy desk finds new omissions, so no revision could converge. Every finding carries blocking. The copy desk may set it only for invented_detail, assignment_mismatch, missing_specifics when the title's central question goes unanswered, and factual codes when no verification ran (BLOCKABLE_CODES); verifier verdicts, an incomplete verification (retried once first; a failed or timed-out call, VERIFY_TIMEOUT_MS 180 s, counts as incomplete) and the code-checked structure rules always block. A first review that throws keeps the draft with a blocking reviewUnavailable (reviewUnavailable(), editor stream and autopilot alike). Requests for more figures, sources or scope, hedging and copy-desk structure notes are revised but never block — the October notes showed the copy desk's missing_specifics was mostly "add the fine tiers from the brief", useful as an edit, wrong as a veto. approved = no blocking finding remains. A failed revision or recheck keeps the reviewed original publishable when only edits were pending, and holds otherwise. Notes are written for whoever reads them: the editor's UI language, and for the autopilot the site language as a UI language. tests/eval/writerModels.test.ts records the notes and asks Opus whether each article is publishable as it stands, so the gate can be compared with an independent judge.

  • The revision is an edit list, not a rewrite (articleQuality.ts::buildEditPrompt, applyRevisionEdits). The writer returns { find, replace, insertAfter } body edits plus optional replacements for title, perex, answer, takeaways and FAQ; code applies them to the exact, unique excerpt (whitespace-tolerant) and skips an edit whose excerpt is missing or ambiguous (audited as ARTICLE_GENERATION_FALLBACK outcome partial); the recheck reads the whole revised draft, so its finding stays open. Only a list where no body edit applies falls back to the rewrite. Both October eval holds were specifics the full rewrite had invented in passages no finding asked it to touch; untouched text cannot pick up new errors, slot markers and links survive, and the output is a fraction of a rewrite. recheckRevision then receives only the changes (before/after) instead of two full drafts. A full rewrite (buildRevisionPrompt) remains for a blocking assignment_mismatch, which changes the whole piece, and as the fallback for an edit list that does not apply. A non-blocking tone note goes through edits: on 3 November 2026 one triggered a rewrite that removed the blocking claim but added two quoted phrases, which then held the article. EditorialOutcome.revisionMode records which ran. Edits run at reasoning effort medium whatever the tier, since correcting marked passages needs less thinking than writing and reasoning bills as output; the full rewrite keeps the tier's effort. The October minisuite (9 articles) measured revisions at about a third of the article cost even as edits, mostly input (instructions, brief, draft) and reasoning rather than the edited text.

  • Edits carry their citations (revisionEditsSchema.sources, SOURCE_CONFLICT_RULE). The edit list used to have no sources field, so facts the reviser took from the independent verification reached the prose while their URLs could never join sources. On 4 November 2026 an article ended with a roll call of four outlets' conflicting effective dates, none of them cited. Edits now return the verification URLs behind what they add; applyRevisionEdits appends them (never in removal-only mode) and the usual filterResearchSources against the grounding brief still decides what ships. SOURCE_CONFLICT_RULE (writer and reviser) allows naming an outlet only when it is cited and asks for the best-supported value instead of a who-said-what list; the copy desk reports such a list as hedging.

  • Removal pass before holding (buildRemovalPrompt, isRemovable). When every blocking finding left after the recheck is a wrong or unsupported specific (invented_detail, unsupported_claim other than an incomplete verification), the copy-desk model returns delete-only edits and applyRevisionEdits(..., { removalOnly: true }) rejects any edit that adds a word or a tag (onlyRemoves, multiset comparison). A third, findings-only recheck confirms the passages are gone. Missing answers and reversed stances cannot be fixed by deleting, so they stay held. A deletion that resolved at least one finding and added nothing is kept even when another finding still holds the article, so the held draft and its notes show only what is really left.

  • Findings a revision can meet. The reviser gets no new sources, so the verifier's UNSUPPORTED MATERIAL findings and the copy desk's unsupported_claim edits may ask only to narrow a claim to the grounding or delete it, never to verify or add specifics. recheckRevision resolves such a finding once the claim is gone, corrected or narrowed, even if the note asked for more. A note demanding research could never be met, and the recheck kept a removed claim open. Quotation marks around a hypothetical or colloquial phrase that the article does not present as anyone's actual words are not a quotation for the copy desk or the recheck. A blockquote, or wording attributed to a real person, organisation, authority or document, still is. The recheck returns a one-sentence reason per unresolved finding, logged with the review step (unresolved). Revision steps log revisionMode (edits / rewrite / removal), because the audit's own mode (manual / scheduled) overwrites a step key of that name.

  • A held autopilot article explains itself. When auto-release is on but the finished article stays a draft, generate-article.ts writes Article.releaseHold (ReleaseHold from shared/utils/articleGeneration.ts::releaseHoldOf: reasons review/research/media/cover plus the blocking findings). Article/Editor/ReleaseHold.vue shows it on the source-language draft with "Publish anyway" through the normal publish flow (media-rights confirmation included). Admin-only (ADMIN_FIELDS in articles/[id]/index.get.ts); the article PATCH strips client-supplied values and clears it on publish or schedule. The same run calls server/utils/articleHeld.ts::notifyArticleHeld: an in-app SYSTEM notification linking to the editor and the articleHeld email, to owners and members with ARTICLE_PUBLISH, once per article (article-held:<id> idempotency key). Without it the slot passed in silence: the next slot is claimed before generation, there is no retry, and the reason lived only in an audit log the UI never showed.

  • Perex and title lengths are enforced after generation, not while decoding. OpenAI strict structured output applies a JSON-schema maxLength by cutting the text mid-word (a German perex ended "welche Kosten mitged"). articleSchema therefore leaves headroom (title +35, perex +90 characters) and formats.ts::applyFormat trims the perex to the SEO maximum with shared/utils/articleOptimization.ts::fitText: the last sentence end if it keeps at least half, else a word boundary with an ellipsis. Titles over the SEO range stay whole; a cut title is worse than a long one.

  • AI edits (shared/utils/aiEdit.ts, server/utils/ai/enhance.ts): POST /api/articles/generate/enhance takes an action for the brief (questions returns questions whose answers are appended as the author's details; sharpen / shorten / grammar rewrite it). POST /api/articles/rewrite edits a selected prose passage from the Tiptap text bubble (useTiptapRewrite); input and output go through sanitizePassage (prose tags + href only), and a range the author changed meanwhile is not overwritten. improve requires a free-text instruction (TiptapRewritePrompt). The toolbar's whole-article variant sends only the text runs of paragraphs and headings as blocks (documentTextPassages) and writes them back in place, so images, tables and embeds are never round-tripped through the model; a different block count is rejected. The writer keeps verification internal: a stale premise is corrected once rather than turning the article into repeated caveats. Provider usage is logged per run to AiUsage, including aborted runs. Selected format/modules/depth are audit-stamped from the start through completion or abort.

  • In the manual editor, generation passes the selected language to the server and the writer uses it explicitly; changing language tabs is disabled during a run. Article.language records each article's source language independently of ClientSite.language; the selected new-article tab supplies it, recovery drafts remember it, and existing articles keep it fixed. Public cards, detail canonicals, sitemap, RSS, external APIs, and translation targets use the article language, so an English-first site can publish a Czech-only article at its Czech URL. Existing rows receive their site's language in migration 20260927180000_article_source_language; a database trigger supplies it for older app replicas during rollout. The sticky editor bar keeps the current stage and elapsed time visible while the settings pane scrolls. Optimization results are hidden during streaming and recalculated from the final editor content. useArticleDrafts keeps the returned ArticleDraft.id and sends it on later autosaves so one editing session updates its recovery draft; POST /api/articles/draft scopes that update to the signed-in user and tenant. Article creation checks AI-suggested tag IDs against the tenant before connecting them, dropping unknown suggestions rather than failing the article write. A cover credit JSON object contains kind, which ZenStack's CREATE policy parser mistakes for a query node; the create route writes the credit with an UPDATE inside the same article transaction.

  • Scheduled editorial variety stays inside the existing topic-selection call. server/utils/ai/formats.ts holds six broad formats, several dramaturgical variants per format, and each format's eligible modules (answer, takeaways, FAQ, poll, table, body images, YouTube). topicSchema requires one to four useful modules but mandates no particular type; cron respects that exact filtered selection, so images, videos and polls remain topic-dependent rather than universal filler. The writer receives the variant's progression and applyFormat clears unselected structured modules. server/utils/ai/articleQuality.ts is the independent copy desk for scheduled output: it checks factual grounding, repetition, filler, artificial time framing, specificity and natural wording, requests one full revision when needed, then reviews again. A failed or unavailable final review forces the article to draft even when auto-release is enabled; news and every topic marked time-sensitive additionally require a completed live-research pass with at least one retrieved source. The cron feeds the picker the last 12 realised signatures (including table/image/video detection from body HTML), persists format/variant, and emits one compact generation audit with requested/delivered/missing modules plus media counts and the editorial verdict; token billing includes research, review and revision calls. Its monitored result reports per-site outcomes, failures with stages, and aggregated skip reasons instead of dumping unrelated skipped tenants. Search Console opportunities remain best-effort prompt enrichment.

  • Release schedule = one slot planner for every rhythm. ClientSite.generationFrequency is DAILY / WEEKLY (local releaseHour ±FIXED_JITTER_MINUTES) or INTERVAL (a random gap of intervalMinHours–intervalMaxHours, min 6 h, landing inside the local releaseWindowStart–End on releaseDays), all in the tenant's timeZone. Slots come from the pure shared/utils/releaseSchedule.ts → nextRelease (tests/unit/releaseSchedule.test.ts), which the settings preview (Form/Client/ReleaseSchedule.vue, seeded so examples stay put) and the credit forecast reuse. The chosen slot is stored in nextReleaseAt.

    • generate-article runs every 15 minutes and picks sites whose slot is within GENERATION_LEAD_MS (3 h). planRun (server/utils/generationSchedule.ts) decides; a guarded updateMany on the read nextReleaseAt claims the slot by writing the following one before any AI call, so overlapping runs cannot double-generate. A failed run forfeits its slot instead of retrying on our AI bill; a site out of credits keeps it and generates after the top-up.
    • An approved auto-release article is saved as a draft with releaseAt = slot plus its media-rights snapshot, and publish-check releases it — the same path as a human-scheduled article. A slot already in the past publishes at once. Without auto-release no releaseAt is set, because publish-check publishes every draft that has one.
    • Stock origin follows the licence (stockOrigin in articleMedia.ts): CC0, Public Domain Mark and Commons PD-… register as PUBLIC_DOMAIN, other CC … (except NC — a tenant blog is commercial) as CREATIVE_COMMONS, anything else as EXTERNAL, which always needs a person to confirm. Store media registers as PUBLISHER (uploaded by the publisher for promotion; cleared with a source): images/stores.ts → STORE_MEDIA lists each store's CDN host and path, and an image counts by its own URL or, once re-hosted from an official page, by its credit's sourceUrl. Steam and the App Store (iTunes Search API) are searched directly; GOG, Epic and Google Play have no documented public API, so their media arrives only through a store page the research returns as OFFICIAL MEDIA. Press-page images and YouTube thumbnails stay EXTERNAL unless a licence is declared: a schema.org ImageObject.license per image, or the page's rel="license" for files on the page's own host (press.ts, ccLicenseName). Images from the publisher's own knowledge pages (publisherPages in articleConfig.ts: feed products and pages on the blog or websiteUrl host family) register as PUBLISHER, matched by the credit's authorUrl; those pages load apart from the research pages (4 each) and rank first on ties. A re-find upgrades an unconfirmed EXTERNAL asset to the more specific origin (registerMediaAsset). With auto-release on, generation passes requireClearRights: providers (ImageProvider.search accept) skip candidates whose prospective asset fails mediaRightsIssues and try the next real one; AI comes only after every real candidate, and if it is unavailable the cover falls back to an unclear real image so the article waits for review. Photo slots never get AI, they are dropped.
    • A site without a slot (migrated DAILY/WEEKLY, or scheduling re-enabled) gets one measured from lastGeneratedAt, so the deploy never fires an extra article. A settings change to any RELEASE_SCHEDULE_FIELDS is validated as a whole (releaseScheduleError) and restarts the rhythm from now; revoking ARTICLE_CRONS clears the slot.
    • CRON_ARTICLE_SKIPPED carries an idempotency key per slot (or per day without one), so the 15-minute cadence logs one entry per missed slot.
    • An insufficient_articles skip emails the tenant's verified owners and BILLING_CHANGE members (server/utils/creditsDepleted.ts, template emails/templates/creditsDepleted.mjml). The CREDITS_DEPLETED_NOTIFIED audit key names the newest credit grant, so it sends once per depletion and a top-up re-arms it; the audit row is written after sending, so an SES failure retries next run.
  • Automatic article series are opt-in and retrospective, not speculative. ClientSite.aiSeriesEnabled enables a conservative classifier after a scheduled article is finished; server/utils/ai/articleSeries.ts may attach it to an eligible recent series or create one only by grouping it with one or two real, recent standalone FULL-AI articles. The server revalidates IDs and cadence inside the article transaction. One series must sit out the next two articles and may occupy at most three of the last ten; at most three AI-created series may be active, and only one new AI series may be created per ten-article window. A series absent from that window becomes dormant automatically, so there is no planner or open/closed lifecycle to maintain. Classification failure degrades to a standalone article. Manual series edits are unrestricted, and the existing selector owns rename/delete recovery.

  • Internal links are a separate pass after writing, never the writer's job. The writer is told not to link the publication. server/utils/ai/articleLinks.ts (linkArticle, cron and manual stream) shows a cheap model the finished paragraphs plus up to 40 published same-language articles (all of them on most sites; above that, shared tags then recency decide, so no embeddings). The model returns {paragraph, anchor, target}; insertArticleLinks keeps a link only for an offered target whose anchor is verbatim whole-word text in a body p/li (not captions, quotes, tables), max 3 articles plus 2 publisher pages, one per paragraph and target. Publisher pages (W1…) are the citable knowledge entries retrieval selected for this article (KnowledgeBrief.linkTargets: pages and in-stock products, a product under its own name), so the model never sees the whole catalogue; the audit records siteCandidates/siteLinks/productCards. A linked product also gets a product card after the top-level block holding the link (never inside a list), once per product, and its image is copied then. The model also names the subject, the W page the whole article is about: its link is applied first, so service pages cannot use up the cap, and a subject product keeps its card even when no anchor held (after the proposed paragraph, else at the end of the body). Cards come only from this pass: the product is in stock and the anchor verbatim (or the product is the subject), which a writer slot could not guarantee. Their links are absolute URLs, kept as written in translations and not rechecked at render. Zero links is a valid result; failure returns the body unchanged.

  • Article links are stored as written and resolved at render. server/utils/articleLinks.ts (resolveArticleLinks, article GET blocks and /md) parses /{lang}/{segment}/{slug} (parseArticlePath), follows ArticleSlugRedirect, points to the published translation in the reader's language (else the original) and unwraps links to unpublished or deleted articles. It covers hand-written links too, so there is no data-article-id to keep alive through TipTap or translation. The editor's content stays unresolved. The internal-link optimisation check counts only links to an article, not the homepage.

  • Search Console Autopilot is an explicit write opt-in, gated by active SEARCH_CONSOLE + AI features and SEO_AUTOPILOT_PLANS (PREMIUM; configurable CUSTOM). The SEARCH_CONSOLE feature itself (connection, sync, opportunities) is granted from PRO. The nightly search-console-sync stores finalised date/page/query performance, then search-console-autopilot atomically claims each opted-in connection and changes at most one published article per day. A page-one query with ≥200 impressions and CTR below 2% may rewrite only title+excerpt; a position-4–20 query may append one focused section. Audit history enforces seven days between writes to one article and 30 days for the same article/query pair. Existing article URLs are removed from the new-article signals sent to generate-article, preventing the obvious cannibalisation path; growing uncovered queries may reprioritise that cron but cannot override focus/novelty. Every write stores before/after data in the immutable audit chain, invalidates feeds, stales translations and exposes a guarded rollback that refuses to overwrite later human edits. Losing either feature or the autopilot plan disables the opt-in, so it never silently resumes after a downgrade.

  • Image model note. articleImage moved off Imagen 4 Fast because Google shuts the Imagen family down on 2026-08-17; tests/unit/aiModels.test.ts now fails on any imagen-* id so it cannot come back. Nano Banana 2 Lite is a Gemini image model (:generateContent), not an Imagen one (:predict) — google.image() fronts both, so models.ts is unchanged, but Gemini image models ignore n/maxImagesPerCall (dropped from ai/image.ts) and return PNG rather than WebP, which imageExtension(output.image.mediaType) already handles.

  • Cloud / Infra: AWS S3, Rekognition and SES; the server Stripe SDK through server/utils/stripe.ts; self-hosted Dokploy/VPS packaging through Dockerfile (multi-stage Bun, NITRO_PRESET=bun); PWA (@vite-pwa/nuxt). The unused Nuxt Stripe module was removed; billing still uses the server-owned singleton. OG images use Takumi and PDF routes use PDFKit, without a browser renderer. Playwright is development/CI tooling for browser verification. Deployment and migration ordering are documented in README and docs/operations/production-workflows.md; the deleted serverless migration playbook is historical.

  • Media Library timeline (Media/Library.vue, utils/dayGroups.ts). The grid is a dense, gallery-style timeline grouped by the day an asset joined the library, newest first. Pages load as the user scrolls, with a button as the keyboard path and the retry after a failure. Numbered pages would split a day across pages. A reload refetches every page scrolled so far, so editing an old image keeps the position. The first render groups in UTC with absolute dates, because the server does not know the viewer's time zone; today/yesterday and local days apply once mounted.

  • Series management: /admin/series edits (Series/Edit.vue) and deletes series directly; deleting detaches the articles and keeps them. ArticleSeries also allows superadmins, like the neighbouring article models. Tests: tests/server/seriesManage.test.ts, tests/unit/seriesManage.test.ts.

  • Media Library: MediaAsset is the tenant-owned reusable asset record for uploaded, externally referenced, stock and Topiqu-generated media; storage remains in S3/CDN, while external and stock assets are hotlinked. /admin/media and components/Media/{Library,Picker,Detail}.vue share search/filter/select behavior across cover and body images. ArticleMediaUsage is a rebuildable index of HTML/cover references, not the source of truth; indexing failures cannot undo a persisted article. Routes enforce active tenant capabilities and constrained lookups, and selected cover IDs are revalidated server-side. Rights/provenance remains on MediaAsset for both the library and pre-publish checks.

    • Library reads reuse SSR payloads and isolate concurrent pickers with an instance/user/tenant key. Filter changes refresh that record through a scoped watcher; reactive query refs stay out of Nuxt's retained options. app/utils/asyncDataRequest.ts owns a disposable callback outside component setup so inactive Nuxt async-data closures cannot retain the picker and its parent editor after unmount. Public author/tag/related feeds share that boundary through useArticleFeed. Default factories in app/utils/asyncDataDefaults.ts retain only plain initial data and return fresh lists/nested data. History, tag/follow queries and currency reads keep retained options separate from component-owned getters/models. Nuxt 4.6 still leaves small inactive records; this does not purge its private registry. The resource protocol uses native time and explicitly disposes chooser handles; its File/DataTransfer assignment avoids Playwright 1.64.1's additional internal retargeted handle. Before/after memory evidence must distinguish application ownership from those instrumentation roots.
    • Deletion is archive-first and refuses known references. The scheduled media-gc reviews at most 100 due requests, checks references across tenants/shared objects and guards updates against changed archive requests. Referenced objects are retained; unreferenced objects stay quarantined, with physical purged: 0. Bytes are retained because HTML writers currently commit before best-effort usage indexing, so a remote delete cannot exclude a new reference safely.
    • scripts/backfill-media-library.ts is dry-run by default and registers legacy media/enriches records/rebuilds usage rows. Its assets → articles → translations → drafts phases share a bounded row budget; batches are capped at 200, document bodies at 1 MiB/500 occurrences, candidate matches at 1000 and new asset identities at 20,000 per invocation. Resume the same tenant/filter with emitted nextCursor (PHASE:LAST_ID, or a bare phase); null means completion. Oversized, ambiguous, deleted and concurrently changed rows remain intact. Each document's apply-mode asset registration and usage replacement is atomic, preserving existing rights/archive/provenance metadata. Dry-run proposal deduplication is per invocation. Apply mode requires review and separate authorization against an identified target; it is not a build or startup operation. README documents the operator commands.
  • Cache / Redis: server/utils/cache.ts uses ioredis over the Redis RESP2 protocol via REDIS_URL, compatible with Redis/Valkey. No Upstash REST credentials or Vercel marketplace integration are required. Only publicly shareable data enters the cross-process cache; personalized fields are resolved outside it. Missing/unavailable cache bypasses reads to the loader; identical pending loads coalesce per process with a bounded registry. Rate limiting falls back to an expiring, bounded process-local map. server/plugins/cache.ts closes sockets/retries on Nitro shutdown. docker-compose.yml starts PostgreSQL only, so cache provisioning is separate; repository code cannot establish whether a live deployment has a ready cache. /master/operations reports runtime availability.

  • SEO: @nuxtjs/seo, nuxt-og-image, nuxt-gtag.

  • OG styling: Takumi templates keep gradients, filters and border styles explicit: Tailwind's composed CSS variables can resolve to invalid initial tokens and fail image rendering. Backdrop blur and z-index utilities are unsupported. The Tailwind sans family matches the app's bundled Manrope stack so OG font discovery does not try to download system-only fonts; templates explicitly use Inter.

  • Security: nuxt-security, isomorphic-dompurify, @zxcvbn-ts/core, fingerprintjs.

  • Email: mjml templates in emails/, sent via SES (server/utils/sendEmail.ts).

  • Observability: @sentry/nuxt error tracking, configured server trace sampling and consented document Web Vitals. The DSN selects error/metric ingestion; the separate optional log transport targets Better Stack. Session Replay is not configured.

2. Top-Level Layout

.vscode/        Workspace editor config (ZenStack v3 language/formatter association)
app/            Nuxt app layer (Vue UI)
server/         Nitro server (API + tasks + utils)
shared/         Cross-cut code shared by app & server (zod schemas, utils)
prisma/         ZenStack source and SQL migrations
generated/      Ignored ZenStack schema metadata and TypeScript types
extensions/     Tiptap editor extensions
emails/         MJML email templates
scripts/        Dev-only verification scripts (ai-smoke, seed-tokens)
i18n/           Locale files (en, cs, de, fr)
types/          Shared domain types and ambient TS declarations
public/         Static assets
todo/           Working notes (non-code)

3. App Layer (app/)

  • Nuxt and Nitro auto-import exports from root types/ and shared/ through their respective imports.dirs. Nuxt's default shared/types/ and shared/utils/ scans remain enabled. Ambient root types/**/*.d.ts declarations are included separately through typescript.tsConfig.include.

  • Nitro uses the Oxc import parser, matching Nuxt, so runtime references inside TypeScript assertions such as z.enum(VALUES as [...]) receive imports too.

  • Vitest loads server modules directly. tests/helpers/nitroImports.ts exposes their selected real or mocked dependencies through vi.stubGlobal; suites load these before module initializers and restore them before each test. Standalone CLI/runtime dependencies and Vue macro type imports remain explicit.

  • Confirmations use one AppConfirmDialog.vue: useConfirm() returns a boolean through Nuxt UI's overlay provider; declarative dialogs use v-model:open and @confirm. Cancel, Escape and outside dismissal return false. Article tag selection uses Tags/Manager.vue from the editor's settings panel; the disconnected tag picker and translation queue banner have been removed.

  • Admin stat rows use AppStatTiles.vue (icon, value, note, info popover; muted for a not-yet-measured value), shared by Topics and AI visibility.

  • pages/ — public, account, integration, dashboard and operator routes; Czech source segments (autor, autorizace, clanky, stitky, uzivatel) are localized through nuxt.config.ts::i18n.pages. Admin sections live under admin/ and master/. The stitky/[slug] and autor/[name] listings share the presentational Article/Collection.vue (search/sort bar, skeleton, card grid and hasMore pagination); each page owns its fetch, header and SEO/JSON-LD. The filesystem and route registry are authoritative rather than a manually maintained page count.

    • Both listing headers are identity headers, not sentences. They previously led with "Articles by author" + name / "Articles with tag" + name, which restates what the surrounding page already is. autor/[name] now renders a real profile header — <UserPicture size="xl"> (which owns the initial/icon fallback, so an author with no upload still gets an avatar instead of the v-if-collapsed NuxtImg that made the page look broken), the name as the h1, the bio — and stitky/[slug] renders #tag (the # is aria-hidden, so the accessible name stays the bare tag). The removed copy is replaced by information: both endpoints return a policy-scoped total (db.article.count under enhanced Prisma, so drafts stay invisible to non-owners) rendered via the pluralized articles.articlesCount. total is deliberately unfiltered by search — it describes the author/tag, not the current query, so it does not flicker while typing.
      • While there: api/tags/slug/[slug].ts selected each article's author with user: { omit: { password: true } }, which only drops the @omit-ed secrets and shipped the rest of every author row (email, emailVerified, lastLogin, role, clientSiteId) into the SSR payload of a public tag page. ArticleCardData only ever reads user.username, so it is now an explicit select: { username: true }, matching by-author. Rule of thumb this encodes: omit is a deny-list and leaks by default — public projections must be select.
    • index.vue — tenant homepage. The featured response is awaited for the SSR lead story and published-article count; the archive remains lazy. A zero-article site renders the dedicated empty page, while a failed featured request renders a retry alert. Sections with no articles, tags, popular stories or poll are omitted. Unfiltered archive cards exclude the lead selection, while filtering searches the full feed. The filtered empty state uses articles.home.noMatches.
      • The shared editorial layout keeps the large publication hero: logo, name, tagline and description beside the lead article and a small topic/count rail. Its scroll cue moves to the next content section. Recommendations, the series shelf, topic filter, article archive, discussions and popular/poll area follow. The featured endpoint returns three public, tenant-scoped articles ordered by views after excluding the lead selection, so the lower popular list does not rank only the first feed page. The lazy feed keeps search and pagination, and resetting its visible rows on locale/filter changes prevents stale-language cards.
      • The default layout publishes the tenant's color and typography through the publication theme tokens; Nuxt UI controls and homepage links use the same primary accent rather than a fixed platform color.
      • A zero-article tenant renders a dedicated page, not a hole in the feed. isBlankSite requires a successful featured response with totalArticles === 0, and Article/Empty/Page.vue replaces the whole editorial template. The count is SSR-available and payload-transferred, so the first HTML and hydrated view agree; the feed request is skipped for that tenant. The count uses the server's published-article scope rather than the resolved feed, and a network error never masquerades as an empty site.
      • Empty state is audience-split under Article/Empty/ — Page.vue is the shell (centred full-height section, gradient/dot backdrop, logo or name monogram) and picks one of two bodies by isOwner. Visitor.vue: site name as h1, description, a "coming soon" pill (articles.empty.badge) and articles.empty.title/message. Owner.vue: an "only you can see this" badge, articles.empty.owner.* copy stating that visitors see this page and that it is noindex until the first article, primary/secondary CTAs to admin-editor-id/new (the second with ?ai=1, which the editor reads to autofocus its AI composer — the panel itself is always visible now), plus a launch checklist with a progress bar. isOwner is role === 'superadmin' || (role === 'admin' && user.clientSiteId === clientSite.id), so an admin of another tenant sees the visitor copy.
      • Checklist logic is shared and tested in shared/utils/siteSetup.ts (buildSetupSteps + setupProgress, tests/unit/siteSetup.test.ts). Both the empty-publication checklist and the dashboard layout use the same completion rules: a nonblank description, focus + audience on AI plans only, an unverified custom domain when applicable, and a live published article. Logos and knowledge imports are recommendations rather than progress requirements. BASIC has two steps on a managed subdomain; an AI-capable plan has three. The dashboard lifecycle, storage isolation and server projections are covered by tests/unit/dashboardSetup.test.ts and tests/server/clients/setupStatus.test.ts.
      • The feed section is guarded by showFeed (feedPending || hasFilters || visibleArticles.length || feedError). Unfiltered cards exclude the lead and recommendations, so a one-article site does not show a redundant empty archive; an active filter searches the full feed. Loading, failed, filtered-empty and pagination states remain distinct.
      • Thin-content noindex. server/api/articles/featured/[slug].ts returns the totalArticles count it already computed, and the page emits robots: 'noindex, follow' until that count is non-zero, so an unpopulated tenant homepage is never indexed as thin content. The count comes from enhanced Prisma, so for an anonymous crawler it is the policy-visible (published) count — exactly the right signal. This is why the featured fetch is a blocking useFetch rather than useLazyFetch: robots meta must be correct in the SSR HTML, and a lazy fetch would render noindex for every site on first paint. The feed below the fold stays lazy — and is not requested at all when the count is zero. The same count now also drives isBlankSite, so noindex and the dedicated empty page always agree.
      • Series shelf and discussions are self-fetching lazy blocks (Home/Series.vue under Recommended, Home/Discussions.vue above poll/top) that render nothing when empty. GET /api/series/by-clientsite/[slug] caches the series list (≥2 published parts, newest first, max 3) under feedGen, then adds per-reader read/next from ArticleView outside the cache, keyed by the same identity view.post.ts writes (user id, else anon cookie). GET /api/comments/by-clientsite/[slug] returns the latest comment per article (max 4), excludes actively banned users, returns [] when commentsEnabled is off, and is cached 60 s with no invalidation. Both build with the anonymous enhanced client because the result is shared. Discussion cards link to #comments (Comment/Section.vue).
      • Feed ad (Home/FeedAd.vue) renders the tenant GAM unit /home/feed when tenantGamEnabled and Marketing consent allow it. The placement is after FEED_AD_AFTER (6) cards when more follow, with reserved creative space.
    • settings/index.vue (admin middleware) — full client settings, migrated from the former Client/Preferences.vue modal into a dedicated page. Sections are tabs driven by ?tab= (branding/content/integrations/ai/billing, gated by plan + tokenLimit + billingPlan). Owns the client fetch + shared form (seeded by the pure app/utils/buildClientSettingsForm.ts), one sticky PATCH /api/clients/:id save, and an onBeforeRouteLeave unsaved-changes guard whose dirty check is a fast-deep-equal of form vs a pristine snapshot (replaces the old hand-maintained field-by-field boolean). Consumes common.preferences.* (incl. new preferences.tabs.*). Opened from Sidebar.vue cog → router.push('/settings').
  • components/ — grouped by domain: Admin/, App/, Article/ (incl. Article/Empty/ — the zero-article homepage, see pages/index.vue), Auth/, Button/, Charts.vue, Client/, Comment/, Dev/, Dropdown/, Emoji/, File/, Form/, Gif/, Landing/, Modal/, Network/, Notification/, OgImage/, Settings/, Stats/, Status/, Tags/, User/, plus the shared Tiptap editor TiptapEditor.vue. The client settings sections live in Form/Client/ (Branding/Content/LinkedIn/AI/Billing) + Settings/Nav.vue (tab rail), composed by pages/settings/index.vue.

  • Activity comment media: GET /api/users/activity includes gifUrl for comments and replies only when the comment is not deleted and its readable article's site enables comment GIFs. User/ActivityComment.vue renders them with the same Gif component as article discussions.

  • Homepage discussion GIFs: the public discussion cache retains the selected gifUrl; GET /api/comments/by-clientsite/[slug] applies the site's current commentGifsEnabled setting after reading that cache so disabling GIFs takes effect immediately. Home/Discussions.vue uses the small Gif size, with the existing mention text and discussion links retained.

  • Reader engagement opens with publication. Poll votes, likes and helpfulness feedback are refused until articleViewerAccess.ts::isArticleLive (published, past releaseAt); taking a like back stays allowed. Whoever may already see the article (tenant admins via canReadArticle, the author via canSeeUnreleasedArticle) gets a 409 naming the reason (common.errors.pollNotOpen / engagementNotOpen); everyone else gets the same 404 as a missing id, because the read policy also returns published rows before their release. The article GET returns engagementOpen, so the page renders LikeButton and Feedback closed, and GET .../vote returns open for Article/Poll.vue. Tests: tests/server/articles/{vote,reaction,feedback}.test.ts.

  • Article likes: Article/LikeButton.vue is the shared labelled toggle for listing cards and the article detail, with an outlined/filled heart, pressed and pending states, and a nonzero count inside the button. It defaults to the md button size of the detail action bar and the sticky header; the sm overlay buttons on cards and the homepage lead pass size="sm". The sticky header uses its icon-only form; useArticleHeaderContext carries the same liked/count/pending state. All surfaces retain useArticleReaction for shared optimistic state and server confirmation. Card footers group author, date and reading time, with nonzero comments linking to the discussion. Cards omit passive view/share counters; the detail action bar shows its like count only inside the toggle.

  • composables/ — reactive domain behavior for articles/editor recovery, media/rights, comment threads, follow status, tenant access, consent, integrations and UI state. Nuxt discovers exports here; use rg --files app/composables for the current inventory. useTime shares the time contract described below.

  • useFollowStatus lazily reads the authenticated viewer's state for one author through GET /api/follows/followed?followedId=… (zero or one profile); SSR stores the ownership snapshot and boolean instead of the profile list. The default unfiltered API response remains complete for User/FollowDialog.vue. Target/viewer changes and disposal fence reads and page mutations; unknown/error status must be loaded/retried before toggling. Generation activity/run clocks pause when hidden or finished and release their timers with the component scope.

  • Time/date stack — single source of truth in shared/utils/time.ts (TIME_PRESETS: date, datetime, short, shortDatetime, time as Intl.DateTimeFormatOptions, plus the relative preset). <AppTime :datetime preset> (App/Time.vue) wraps the built-in <NuxtTime> — SSR-safe, live-ticking relative, locale auto-injected from useI18n — for templates; useTime().formatTime(date, preset, localeOverride?) is the JS-context counterpart (table cell renderers, $t interpolation) using the same presets via Intl. Prefer these over date-fns format(). The smart hybrid relative/absolute timestamp formatDate (shared/utils/index.ts, custom thresholds + articles.dateFormats.* keys) is a deliberately separate concern and still uses date-fns.

  • Date inputs — <AppDateInput v-model [time] [min] [max]> uses Nuxt UI UInputDate and UCalendar, with locale from UApp. app/utils/dateInput.ts preserves the native string format (YYYY-MM-DD / YYYY-MM-DDTHH:mm, local wall time, empty string when cleared), so caller and API contracts stay stable.

  • Client IP = clientIp in server/utils/ip.ts, the only place that reads it (getIp, login rate limits, sessions, audit). Traefik replaces a client-sent X-Forwarded-For with its peer, so only the last entry is trusted; when that peer is a Cloudflare edge (hardcoded Cloudflare ranges) the visitor comes from cf-connecting-ip, otherwise the peer is the visitor (DNS-only tenant domains). Taking the first entry, or cf-connecting-ip unconditionally, logs the edge or lets a direct caller pick its rate-limit bucket.

  • Credentials login security lives in server/utils/authLogin.ts: email lookup is trimmed, lower-cased and case-insensitive; Better Stack receives structured success/failure/rate-limit events with a keyed identity fingerprint rather than the email; Valkey-backed limits apply independently to IP and identity at both the TOTP preflight and POST /api/auth/sign-in/credentials, with the cache module's in-process fallback during an outage. Failure reasons remain private while the client receives generic credentials errors (only email_not_verified and rate_limited get their own code). lastLogin is written only when a session is created, and Auth/Form.vue stops on a failed sign-in before fetching the profile or navigating.

  • 2FA secrets never leave the server after setup (server/utils/totpChallenge.ts, PATCH /api/users/:id). /account returns only totpEnabled. Setup keeps the pending secret in a signed totpSetup token, because a stored but unconfirmed secret already gates login, and writes it only after the first code, guarded on totpSecret: null. Turning 2FA off needs a current code (a superadmin clearing another user's is recovery). Every code check goes through checkTotpCode (10 per user per 15 min). An account without a password can set one only within 10 min of signing in.

  • Deactivating another account is DELETE /api/users/:id (superadmin only, never on themselves): soft delete plus revocation of every session. PATCH drops deletedAt, because zod strips unknown keys.

  • Deleting your own account is POST /api/users/deletion (server/utils/accountDeletion.ts). The server requires the typed email, the exact domain of every live blog the user solely owns, the password and any two-factor code. An account without a password gets a one-hour signed confirmation link instead, and that link fails if the set of blogs changed. A superadmin and an owner of a CUSTOM blog are refused, because a contract must not end through this flow.

    • Restorable period. The account is deactivated for ACCOUNT_DELETION_GRACE_DAYS, its sessions end and a restore link is mailed. A sign-in attempt with the right password, or through OAuth, mails the link again (3 per hour) and never opens a session. The link is bound to deletionRequestedAt, so it dies once used and cannot restore a later deletion. A reminder goes out ACCOUNT_DELETION_REMINDER_DAYS before the end.
    • Billing. Blog deactivation leaves subscriptions running, so deletion sets cancel_at_period_end on every subscription of the blogs it closes, read from Stripe by customer and metadata.clientSiteId, including blogs already deactivated. The row lock on User is the claim, so an overlapping request gets 409 before touching Stripe; billing follows the commit and is never rolled back. The hourly task retries what failed, and anonymisation refuses to proceed until it succeeds, because nobody can cancel once the blog is gone. ClientSite.closingSubscriptionIds records an id before Stripe changes it and only if this flow changed it, so a restore does not revive a subscription the customer cancelled; an id stays until the resume succeeds. While closingAt is set, assertBlogNotClosing freezes the routes that create or change recurring billing. Cancellation commits its recovery marker first, then rechecks current closure state under the blog row lock; resume uses the same lock and clears only a confirmed marker. Provider requests have an eight-second timeout and no SDK retries while locked, and a failed request leaves its marker for reconciliation. A pending plan change must be resolved before requesting deletion, since a released schedule cannot be restored. Shopify billing cannot be ended from here; the dialog tells the owner to uninstall the app.
    • Agencies. An agency with no other live OWNER closes with its last owner (closingAgencies): BillingAccount.closingAt/closingSubscriptionIds mirror the blog fields and the same end/resume code runs on them (ClosingStore), matching subscriptions by metadata.billingAccountId. Checkout and plan changes answer 409 AGENCY_CLOSING. Anonymisation hands the agency's live blogs back to BASIC with their own team (as a detach) and deletes the agency's member rows; the account row stays for the ledger.
    • Anonymisation, not deletion (account-deletion task, hourly). The User row stays: Article cascades from it, which would remove articles from other people's blogs; Log rows form an HMAC chain; OnboardingRequest restricts deletion. Personal fields, sessions, OAuth accounts, memberships, drafts, notifications, follows and reactions go; API keys are revoked and unlinked; comments lose their text and stay as tombstones for their replies; generation rows lose their prompts but stay as a blog's usage receipts. The old username also leaves plain text in other people's rows: @username in comments and the name in notifications, by whole-word regular expression, plus ArticleRevision.actorName. Articles on other blogs remain under the anonymised author. Tombstone usernames and email addresses use a fresh random UUID, rather than a public user ID that another account could reserve.
    • Still kept. Log rows keep their IP and metadata until a retention rule exists. A closed blog is only soft-deleted and keeps its content, though its LinkedIn, Search Console and Shopify connections are removed; permanent removal stays a request to privacy@topiqu.com. OnboardingRequest keeps its keyed email hash, so onboarding with the same address stays refused while direct registration and OAuth work.
  • Account export is GET /api/users/export (session user only, no ID in the URL, 5 per hour, audited as USER_DATA_EXPORT). server/utils/userExport.ts streams one JSON document in batches through the policy client with explicit selects; a failed read errors the stream, so the browser never saves a truncated file. An owner also receives their blogs' articles, media and Knowledge. The own-record read rules on Account, ArticleView, ApiKey, MediaAsset and KnowledgeSource exist for it. Article translations, ArticleGenerationSession, OnboardingRequest (policies deny all) and LinkedIn approval notes are not included; those requests go through privacy@topiqu.com. An audit row exports its IP only for SECURITY_EVENT_ACTIONS, because other rows can record someone else's. Knowledge needs a current membership, not just having created it. The profile PDF stays a human-readable summary.

  • Self-importing packages in the server bundle. Nitro keeps a second version of a package in .nitro/<name>@<version>, and node_modules/<name> is another version. pdfkit needs @noble/hashes 1.x, which imports itself by name, so under Bun that import reached 2.x and every PDF route answered 500 at load. scripts/linkNitroSelfReferences.ts runs on the compiled hook and links each versioned package to itself. tests/e2e/release-smoke.spec.ts renders a real PDF from the built server.

  • The app hosts the one privacy policy (/en/privacy-policy, /cs/ochrana-soukromi). The sibling landing links to it and redirects its former URLs, because those are registered with OAuth providers and stores.

  • Every auth().role == 'admin' policy is pinned to the admin's own site (… && <path>.clientSiteId == auth().clientSiteId); a bare role check let any tenant's admin write other tenants' rows through the enhanced client. User is writable only by itself, a superadmin, or its site's admins when it is that site's AI persona.

  • A client-side permission gate must restate the ZenStack rule, not a stricter guess. canManageArticle (shared/utils/articleEditor.ts, tests/unit/articleEditor.test.ts) is admin && user.clientSiteId === article.clientSiteId — the exact @@allow('all', …) on Article, authorship excluded, so a tenant's admins reach each other's articles and the AI author's, which no human owns. Superadmin is out on both sides. pages/clanky/[slug].vue had role === 'admin' && session.user.id === data.user.id inline and hid the edit/status/comments controls the server would have accepted; pages/index.vue's isOwner never had that clause.

  • Comments expose only public commenter fields. GET /api/comments/:articleId applies article visibility/release and tenant guards, exposes no email/login data and restricts ban reasons to moderators. Root pages, direct-reply cursors and permalink ancestor context have explicit continuations; each response contains at most 60 comment nodes, including deleted parent placeholders. LIKE ranking selects bounded root IDs in SQL; grouped reaction/emoji aggregates avoid returning voter lists. useCommentThreads merges/indexes iteratively, preserves loaded replies and owns cancellation/retry; Comment/Section.vue renders flat parent-before-child siblings rather than a recursively deep component tree. publicationLikes counts active tenant members. useCommentPermissions mirrors server moderation gates, and mutations additionally require CONTENT_MODERATE. New-comment notifications/reports use server/utils/commentAudience.ts for the site's OWNER/moderators plus a human author, never the AI persona.

  • Toasts go straight through Nuxt UI — use a concise outcome as title, an affected item or useful recovery detail as description, and an action only when a real next step exists. Copy/like/simple saves stay concise; publication offers View, AI completion links back to its editor, domain verification links to settings, and failed autosave explains that edits are unsaved. Never invent Undo for a destructive mutation without a restore endpoint. Global Nuxt UI toast slots wrap long text and place actions below it on narrow screens. app/utils/fetchErrorMessage.ts reads the createError message off a $fetch error with a fallback.

  • Publication links use app/utils/publicationUrl.ts and require a verified domain. Sidebar, article table and editor pass their SSR-safe request URL so loopback tenant domains retain the local protocol and port; production domains always use HTTPS. Article/View.vue sizes the whole reading column from --topiqu-reading-width, keeping the hero, summary, actions and body aligned rather than centring a narrower body inside them. LanguageFlag.vue supplies local SVG flags to language controls without a remote icon provider.

  • Tag navigation uses stored slugs. Article cards and the article body keep tag names for labels. The tag endpoint also accepts old name-based links, preferring an exact slug; the page redirects those old links to the returned canonical slug. It queries Article roots through the enhanced client before pagination, because public ArticleTag joins can otherwise point at drafts that policy returns as null. Counts use the same tenant/search filter.

  • Public layout height belongs to the shell. The default layout owns the viewport minimum; its main content grows between header and footer. Status, auth/invitation/OAuth, empty-blog and public content pages do not add another viewport minimum. The consent launcher remains in the app overlay; footer links no longer duplicate its privacy/settings controls.

  • Publication footers: nullable ClientSite.footerSettings stores validated text, up to six safe links, social visibility and the enabled switch. Settings → Branding exposes these controls to paid plans; the server enforces the same entitlement and BASIC always resolves to the default footer. Disabling a paid footer also hides its FOOTER widgets, independently of the consent overlay. Apply 20261020010000_publication_footer before deploying code that selects this field.

  • Data-fetching split (HARD RULE) — two layers, chosen by whether the response is SSR/SEO-critical:

    • useFetch / useAsyncData for public, SSR-rendered, SEO-critical reads (pages/index.vue, clanky/[slug], stitky/[slug], autor/[name], useClientSite.ts). These depend on the Nuxt payload, getCachedData, and blocking-vs-lazy semantics that drive robots meta — do not migrate them.
    • A useFetch payload you write back into needs deep: true. Nuxt 4 defaults deep to false, so data is a shallowRef. pages/clanky/[slug].vue writes likes/shared/followerCount back into the article after each action; a triggerRef re-renders the page but Article/ActionsBar.vue gets the same object identity, so hasPropsChanged bails and the counts only moved on a manual refresh. A triggerRef beside a useFetch payload is therefore a symptom, not a fix.
    • Pinia Colada (useQuery/useMutation) for client-only admin CRUD behind auth. Rationale: those endpoints are per-user and per-tenant (policy-scoped through getEnhancedPrisma), so they can never be cached server-side (ISR / defineCachedEventHandler / server/utils/cache.ts, whose invariant forbids personalised data) — a shared cache there would be a cross-tenant leak. Colada's cache lives in one authenticated user's tab, so isolation is structural.
    • Query keys are centralised in app/utils/queryKeys.ts and are the invalidation contract; the prefix hierarchy (['articles'] ⊃ ['articles','list'] / ['articles','detail',id] ⊃ …tags / …available-tags, plus ['clients'] and ['stats']) is pinned by tests/unit/queryKeys.test.ts, which also asserts the three roots can never invalidate each other. Never inline a key literal at a call site — a wrong key surfaces as stale UI, which is a worse failure than the over-fetching it replaces.
    • SSR + auth: queries must fetch via useRequestFetch(), not bare $fetch — Colada prefetches on the server via onServerPrefetch and bare $fetch does not forward the session cookie (401). Mutations are client-only and use plain $fetch.
    • Global defaults live in colada.options.ts at the repo root, not in nuxt.config.ts — the module declares configKey: 'colada' but reads a root-level file, and silently falls back to export default {} if it is missing. staleTime is 300 s (library default is 5 s, which would refetch on practically every remount and defeat the point of the cache); gcTime 30 min. The module also auto-imports useQuery / useMutation / useQueryCache, so do not import them explicitly.
    • Every query renders three states. A failed fetch must never fall through to the empty state — before this was fixed, a 401/500 left data undefined, rows empty, and the table showed "no articles" for what was actually a broken request. Each migrated component derives loadFailed from error and an absence of data (so a refetch failure over placeholderData keeps the stale rows visible instead of blanking them), and offers refetch().
    • Two empty states in Article/Table.vue. Search or filters that match nothing offer clearing them. A tenant with no articles at all (total === 0) gets the same generate/write CTAs as Article/Empty/Owner.vue, gated by ARTICLE_WRITE, AI_USE, an AI plan and enough credits, plus the website import topics under them. The public Article/Collection.vue (author and tag pages) uses only existing reader keys.
    • Route-type blowups: typing a query as requestFetch('/api/…') can trigger TS2321 Excessive stack depth — Nitro's route-matching types leak into Colada's generic inference. Fix by passing an explicit generic sourced from InternalApi['/api/route']['default' | 'get'] (nitropack/types), which keeps the real response type without re-resolving the route. For mutations, an async … => { await $fetch(…) } body (concrete Promise<void>) is enough.
    • Cross-component invalidation goes through useCacheInvalidation() (app/composables/), the single place that maps a domain event to the keys it dirties (e.g. creating or deleting an article dirties articles and stats; editing dirties only articles; a tag edit dirties just that article's detail). Call it directly after the mutation — do not reintroduce an event bus.
    • mitt is fully removed (useArticleEvent / useClientEvent deleted, dependency dropped). It was only ever a cache-invalidation mechanism — every handler was a bare refresh() — and it had two structural faults Colada does not: the module-level emitter leaked one never-removed listener per SSR render and per remount, and an event was lost entirely if the interested component happened to be unmounted. Colada marks the cache entry stale instead, so a closed dialog refetches correctly the next time it opens.
    • Two components read the same article tags, so they must share a key: Article/Tag.vue (the modal off the article table) and Tags/Manager.vue (embedded in Article/Modal.vue and the editor) both hit /api/articles/:id/tags and are both keyed queryKeys.articles.tags(id). They previously held two independent caches, so adding a tag in one left the other stale — and Article/Modal.vue's invalidateArticleDetail() only reached half its own subtree. Tags/Manager.vue keeps its local tagBuffer staging model (it doubles as the create-mode tag picker, where no article exists yet and the parent persists on save); the query only seeds it, gated by enabled: () => !!props.article?.id.
    • Creating a tag dirties two roots — the article's detail and ['tags'], the tenant tag catalogue behind /api/tags shared by Tags/Manager.vue and Tags/Create.vue. Article/Tag.vue's create mutation invalidates both.
    • Tags/Create.vue (sidebar "Manage tags") is one field that filters and adds (adding is refused on an exact name match) over a chip cloud; clicking a chip name swaps it for an input — Enter saves, blur keeps a valid rename and drops an invalid one, and Escape is caught in UModal's onEscapeKeyDown so it cancels the rename instead of closing the modal. Its useFetch is deep: true because the optimistic edits mutate the list in place.
    • Migrated: Article/Table.vue and Client/Table.vue (keyed pagination, placeholderData, debounced search feeding the key, refetch dimming + aria-busy; the article table also has row skeletons), Article/Tag.vue, Tags/Manager.vue, Stats/Dialog.vue (dashboard + sentiment, the latter gated by enabled: () => !isBasicPlan). Mutation sites (Article/Modal.vue, Client/Create.vue, pages/admin/editor/[id].vue) invalidate rather than emit. Still on useFetch: User/List.vue, Tags/Create.vue, Client/Users.vue, Client/Version.vue, User/Activity.vue, Notification/Bar.vue (the last is a hand-rolled infinite scroll — useInfiniteQuery + refetchInterval candidate).
  • Connection state is a state, not a toast — Network/Indicator.vue (mounted once in app.vue) keeps its pill up for as long as the connection is down and only auto-dismisses the recovery, which reuses the same mounted pill so red→green is a color transition rather than two separate notifications. The visibility machine is composables/useNetworkPill.ts (tests/unit/networkIndicator.test.ts); the component only maps state to an icon and a surface. A single shared timeout meant a drop landing mid-recovery inherited the pending dismiss and hid itself after 3 s while still offline.

  • stores/ — Pinia (theme.ts); persistence handled per-store via plugin.

  • layouts/, middleware/, error.vue, app.vue — standard Nuxt scaffolding. The global Header.vue is a fixed h-18 overlay: content-heavy public pages own their hero spacing, while app pages (admin, master, settings) reserve the header plus their intended gap explicitly; Settings navigation sticks at top-24, and the editor header at top-18. Making the global header participate in flex layout duplicates the homepage's existing clearance and makes descendant sticky offsets slide behind it. One other load-bearing detail in layouts/default.vue: the root wrapper is overflow-x-clip, not overflow-hidden. hidden makes that box a scroll container, and position: sticky resolves against its nearest scrollport — a box that is exactly as tall as its content and therefore never scrolls, so every sticky descendant silently degrades to static while the page scrolls past it. That killed the article editor's sticky header (taking Save/Publish off screen the moment you scrolled into the body), the Tiptap toolbar, the homepage filter bar and Footer.vue — all four had been written as sticky and none of them stuck. clip still contains horizontal bleed, which is the reason the class is there next to max-w-screen, without establishing a scrollport (and overflow-y stays visible beside clip, unlike beside hidden). Don't swap it back.

  • assets/styles/ — global main.css, loaded through nuxt.config.css, imports Tailwind CSS, Nuxt UI and variable fonts. It owns shared theme tokens, publication and article styles, and editor/vendor overrides.

4. Server Layer (server/)

  • api/ — route handlers grouped by resource: admin, articles (CRUD, search, drafts, featured, generate, by-clientsite), auth, bans, clients, comments, companies, crons, currency, drafts, emojis, external, features, follows, gifs, linkedin, notifications, onboarding, publish, series, sessions, stats, stripe, tags, users, plus standalone upload.ts.
  • tasks/ — Nitro scheduled tasks. All wrapped via defineMonitoredTask (utils/monitoring.ts) so each run pings its Better Stack heartbeat (BETTERSTACK_HEARTBEAT_<NAME>) on success/fail — covers both the Nitro/Vercel-cron path and manual runTask() cron routes. Missing heartbeat URLs disable only the external ping. SystemTaskStatus retains one durable summary per task, with guarded run IDs, monotonic last-success/failure timestamps and bounded counters; no provider responses or exception payloads enter the status table. Monitoring failures cannot fail completed customer work. /master/operations reads safe aggregates behind a superadmin boundary; the only new retry queues an existing failed webhook and audits the guarded state change in the same transaction.
  • utils/ — Cross-cutting helpers: prisma.ts, zenstack.ts, session.ts, requireUser.ts (auto-imported per-route auth guard — resolves getSessionUser, enforces optional role/clientSite, throws localized 401/403; the single audited chokepoint for access control, replacing the copy-pasted session-check boilerplate across handlers), sendEmail.ts, stripe.ts (Stripe SDK singleton — useStripe(), see §7), sanitize.ts, geo.ts, ip.ts, metrics.ts, paginator.ts, log.ts, userLog.ts, consumeTokens.ts, tokenRatio.ts, unsplash.ts, pdfFont.ts, i18n.ts, notificationsPoll.ts (pure cursor/query helpers for the notifications poll endpoint), monitoring.ts (Better Stack heartbeats: pingHeartbeat/withHeartbeat/defineMonitoredTask), plus ai/ and linkedin/ subdirs. linkedin/publisher.ts publishes drafts to LinkedIn behind an atomic claim (executePublish flips DraftStatus → PUBLISHING via a guarded updateMany, so concurrent cron runs / manual triggers can never double-post; PublishedPost.draftId unique is the DB backstop). publishApprovedDraft is the cron/manual entry for human-approved drafts; publishDecisionAndExecute is the post-generation auto-publish-or-review decision. linkedin/token.ts (getValidAccessToken) proactively refreshes the LinkedIn access token via the stored refresh token (5-min expiry buffer) and persists it before publish/metrics calls — used by publisher.ts and the linkedin-sync cron (which caches the token per company for the run). Integration OAuth now uses the centrally confirmed, one-use grants in integrationOAuth.ts; see the integration contract below. The retained linkedin/oauthState.ts helper is not the connection-flow authority.

Auth (Better Auth)

  • Development auth accepts HTTP loopback origins, including *.localhost tenant subdomains on development ports. Production keeps the existing Topiqu origin allowlist; local session cookies remain host-only.
  • server/utils/auth.ts is the instance; getSessionUser(event) (cached on event.context.authUser) and requireUser are the only server entry points. Client state is useAuth() (app/composables/useAuth.ts), filled by app/plugins/auth.ts during SSR; the Better Auth client library is not shipped.
  • server/api/auth/[...all].ts forwards only an allowlist of paths. Better Auth also mounts /update-user, /change-email, /link-social, … which would bypass our validation, and disabledPaths only matches exact paths.
  • Credentials and the onboarding token are a local plugin (/sign-in/credentials, /sign-in/token): passwords stay on User.password, so there is no credential Account row. Account holds OAuth identities only.
  • Sessions are DB rows (Session.token, @omit-ed; the adapter gets a client with that omit lifted). Revoking deletes the row, so there is no revocation middleware. databaseHooks.session.create blocks deactivated users and stamps device, geo and the initial tenant (initialTenant); customSession → toSessionUser derives role/plan/tenant/scopes per read and drops a tenant the user left.
  • Session role admin means "any active member", not a permission. ZenStack policies only see that role, so a member with zero scopes passes them. Every tenant-admin route must call requireTenantScope (or requireArticleAccess / requireDomainAccess / requireShopifyAccess / requireWebhookAccess / resolveAiUser); hasTenantScope gates fields and secondary checks. SessionUser.scopes (owner/superadmin: all) feeds useTenantScope() for UI gating only. tests/server/tenantGuardWiring.test.ts fails on a non-GET route with no guard unless it is in its exemption list; tenantScopeMatrix.test.ts runs handlers without each scope and expects 403 before any write.
  • OAuth: a new user needs a verified provider email (user.create.before); linking to an existing user needs both emails verified (neither provider is trusted). Failures redirect to /auth-error, which maps Better Auth codes in shared/utils/authError.ts.
  • Audit: logAction writes Log rows under an HMAC hash chain (advisory lock) and mirrors them to the logger as audit:<action>. The account Security panel shows only session/password/role events, and /api/clients/:id/log keeps a compact whitelist, so generation steps and config diffs stay out of both. ARTICLE_GENERATION_STEP_* / ARTICLE_GENERATION_FALLBACK (articleGenerationAudit.ts, keyed by attemptId/mode/stage) are best effort and never carry prompts, briefs, article bodies or provider responses. CLIENT_SITE_UPDATE logs only changed fields as before/after through a whitelist (auditChanges), so API keys and DNS tokens never enter.
  • Better Auth's rate limiter is off: it keys on the first X-Forwarded-For entry, which is the Cloudflare edge here. checkLoginRateLimit (clientIp) and the nuxt-security limiter on /api/auth/sign-in/** cover it.

Local DevConsole

  • components/Dev/Console.vue — a draggable, dev-only floating panel (Teleport to body, useDraggable + useLocalStorage for persisted position/collapse). Rendered in app.vue via Nuxt's <DevOnly>, so it is stripped from production builds. Complements (does not replace) Nuxt DevTools.
  • Capabilities: force the rendered view (auto/tenant) so tenant resolution can be exercised on localhost; impersonate seed users (reader/admin/super) through a dev-minted onboarding token redeemed at /api/auth/sign-in/token; show git branch/short-hash/dirty flag and the resolved tenant + plan. Toggle/hide via Ctrl+Shift+D (a corner launcher restores it); drag snaps to the nearest edge and clamps to the viewport.
  • View override lives in composables/useDevView.ts (cookie-backed for SSR consistency). Git context comes from server/api/_dev/meta.get.ts, a dev-only endpoint (404s in production).
  • Host topology (two separate projects, both on the Dokploy VPS since 2026-08):
    • Apex topiqu.com (+ www) → separate landing project (marketing), deployed independently of this app. The old in-app 302 to landing.topiqu.com was removed; the app no longer routes anyone to a landing subdomain.
    • app.topiqu.com + *.topiqu.com catch-all → this app project.
    • Wildcard TLS for *.topiqu.com is managed by Dokploy itself (its Traefik certificate handling), not by a Cloudflare Origin CA cert or a hand-edited traefik.yml resolver. Consequence worth knowing: that certificate is platform state living outside this repo — nothing here reproduces it, so a Dokploy rebuild/restore has to restore it too, and it is not covered by any repo-level backup.
  • Auth / OAuth: OAuth completes on app.topiqu.com (Google/GitHub authorized redirect URIs include https://app.topiqu.com/api/auth/callback/*; the apex topiqu.com URI also stays registered). Without a pinned AUTH_ORIGIN, Better Auth derives baseURL (and so the redirect_uri) from the request host, limited to topiqu.com/*.topiqu.com. Auth/Form.vue#handleSocialAuth: on app.topiqu.com it POSTs /api/auth/sign-in/social directly; on any other host (tenant subdomain) it hops to app.topiqu.com/oauth-start, which makes that POST so the OAuth state cookie and the callback share the registered host. The session cookie is set on .topiqu.com, so login is shared across the apex and every *.topiqu.com subdomain; trustedOrigins admits only those as callbackURL.
  • Custom-domain auth uses authForEvent: each request rechecks an exact, verified, nondeleted canonical domain before using a bounded cached Better Auth instance. Cookies are host-only and signed with a key derived from the origin and tenant ID; platform cookies cannot be replayed there or vice versa. SSR session fetches preserve the public request host. A member opens that tenant's context; a nonmember gets reader context rather than a different blog's admin context. Google/GitHub still complete on the central origin. /auth-transfer requires explicit account/hostname confirmation before minting a separate target session through server/api/auth/transfer/. The flow stores hashed browser proof and a single-use, 60-second authorization code in existing Verification rows, binds the exact origin/tenant, rechecks source-session validity and consumes atomically. Completion uses an HttpOnly host-only browser proof cookie. Expired flow records receive bounded cleanup at start; no migration or wildcard origin trust is introduced. See docs/custom-domain-auth.md.
  • Interface preferences are account-global: Better Auth includes validated language/theme in session projections, and plugins/preferences.ts applies them after auth, including custom-domain handoffs. topiqu-language/topiqu-theme cookies share .topiqu.com only on that domain and its subdomains; custom hosts use host-only cookies. Color mode uses a host-only cookie for the pre-hydration script, seeded from account/shared preferences during SSR; legacy localStorage choices migrate on mount. preferenceLocale.ts redirects only / to the account/shared locale with a private, noncached response. Explicit locale-prefixed URLs retain their language. Changes become visible on other domains on their next authenticated page load; anonymous custom-domain preferences remain local. No cross-domain cookie or session-token transfer is used.
  • Integration OAuth uses integrationOAuth.ts and /integrations-connect on the central auth origin, independently of login handoffs. A same-origin authenticated start creates a hashed ten-minute Verification grant bound to actor, source session, exact tenant/origin and locale. Central confirmation rechecks account and capability, atomically binds state, and sets a host-only provider cookie; callback consumes once and revalidates source session and membership before persisting tokens with a tenant-scoped policy context. The central session's selected tenant need not match the grant. Provider callback origins must equal the central auth origin. authTelemetry.ts strips credentials from OAuth/handoff breadcrumbs, error events and traces. billingReturnUrl validates same-origin destinations and supported locale prefixes for Stripe.
  • Session.origin records the creation origin for new sessions; older rows remain unknown. Account DTOs expose only device/location/origin/time summaries, never tokens, IPs or raw user agents. DELETE /api/sessions/others requires the current authenticated session, exact request origin and rate limit, then deletes only that account's other session rows. Topiqu subdomains share their platform session; custom domains retain separate host-only sessions until explicitly revoked.
  • Source article editing uses Article.editVersion, maintained by the PostgreSQL Article_revision_before_update trigger in 20261022010000_production_workflows. Editable field changes archive the previous state and increment the version; views, timestamps and bookkeeping do not. The trigger covers automation as well as editor writes, retains the newest 50 snapshots, and records transaction-local actor metadata when provided. Editor PATCH requires expectedVersion for content writes, locks the tenant-constrained article row, synchronizes polls, updates content and replaces tags within one transaction. Tag-only edits force a version change through a transaction-local article ID. History routes require article write/ownership access, return bounded summaries and sanitize validated restoration fields; removed tags are filtered out. Recovery loads unsaved source content, never old status, address, ownership or publication schedule. Translation saves use an atomic timestamp comparison and preserve the local draft on conflicts; source history does not archive translation versions.
  • plugins/web-vitals.client.ts lazily imports web-vitals only for sampled, consenting production documents with a Sentry DSN. Metrics have fixed page/device categories, no URLs, DOM attribution or user IDs, and stop reporting after consent revocation, sensitive navigation or app unmount; both plugin watchers are disposed. Web Vitals 6 registration explicitly disables soft-navigation metrics, and the shared reporter also rejects them so they cannot inherit the initial document's page category. Sentry metric hooks remove SDK-added identity attributes. scripts/performance-budgets.ts reads built Nitro manifests and compressed assets, deduplicates startup/preload scripts and enforces committed numerical CI ceilings. Field p75 targets and operator interpretation are in docs/operations/production-workflows.md.
  • scripts/verify-backup-restore.ts is a dry-run-first, local-container-only rehearsal. Apply mode creates a new disposable database, streams a dump without a workspace archive and compares rows, migrations and schema metadata. It never restores over or deletes a database; production backup/PITR, storage and recovery approval remain operator responsibilities. See docs/operations/backup-restore.md.
  • Global app fetches specify the exact generated Nitro route key as their request generic, including when their response is intentionally ignored. Consumed non-GET responses use their generated InternalApi[route][method] contract or an existing explicit DTO; supplying only unknown and a route leaves Nitro's default options generic selecting GET. External HTTP calls specify a response type and a string request type, unless they already import the standalone ofetch client. This prevents recursive expansion of the entire internal API graph while retaining method checks on internal literal routes; runtime URLs and request behavior are unchanged.
  • App surfaces (app.vue): app.topiqu.com is treated as a root domain (no tenant lookup; root redirects to /autorizace). The marketing site is a separate project (../landing, served on the apex) — this app renders no landing surface of its own; the former in-app Landing/ tree was removed as a duplicate of it.
  • Dev resolver note: server/api/clients/slug/[slug].ts matches { OR: [domain, name] } outside production so localhost (seeded ClientSite.domain = localhost) resolves to a tenant; production still matches domain only. It returns publicClientSiteSelect only — see Who may read which ClientSite field — so useClientSite() is typed PublicClientSite, and anything privileged has to come from useClientSiteStatus().
  • Admin surfaces are bound to their own tenant's host (middleware/admin.ts, covering admin, admin/editor/[id], settings). The session cookie is .topiqu.com-wide (server/utils/authCookie.ts), so before the bind an admin browsing another tenant's blog got an admin page whose host half (useClientSite(): plan, ids, domain) described that tenant while every mutation hit their own — the upgrade banner advertised the foreign tenant's plan and charged the session's. isForeignHost (shared/utils/domain.ts) compares the two ids; on a mismatch the middleware re-routes to the same path on the session tenant's own verified domain, falling back to / when that domain is unverified or resolves back to the current host (which would loop). Root hosts carry no tenant, so they never trip it. Corollary: inside /admin/** and /settings, useClientSite() is the session tenant — but anything that gates or spends against a plan should still read auth.user.plan, which toSessionUser re-reads from the DB on every session read.

Keyword plan and news (server/utils/keywords/)

  • Enrichment for the generate-article topic picker, never a prerequisite: failures log and fall back to "none". Everything needs DataForSEO credentials; outside production dataforseo.ts calls the free sandbox.
  • Principle: keyword names, demand and news come from DataForSEO data; models judge relevance/reader intent or pick from that data. keywordSeeds rewords the tenant's own focus into niche search phrases; keywordOpportunity proposes an angle only for supplied queries. Provider spend is logged per rebuild; caps are five Labs requests and thirty SERPs plus the seed/scope/opportunity model calls.
  • Rebuild steps: Labs keyword_suggestions per seed (max 5, allSettled, fails only if all fail); dataforseo.ts preserves all task results because the seed and suggestions can be separate. The focus supplies additional niche seeds even when the tenant already has keywords. Clusters merge word-order/accent variants; demand is the head keyword's volume, never a sum of potentially overlapping synonym searches. Navigational intent is dropped and transactional intent discounted. Up to 250 candidates pass inScope before the top 30 are selected, so popular off-topic terms cannot crowd out a narrow niche. Every selected candidate gets a best-effort SERP (PAA, AI overview, domains and up to five organic titles, snippets and URLs).
  • Scoring (clusterScore): demand × ease × intent × reach × timing. Commercial intent counts fully and ×1.2 when the tenant has a product catalog (KnowledgeProduct); transactional ×0.6, since "buy X" wants a product page. Timing comes from Labs monthly_searches/search_volume_trend: a seasonal peak (≥1.5× the yearly average, peakMonth) 1–3 months ahead ×1.25, out of season ×0.8; yearly trend ±50 % nudges ×1.15/×0.85. Candidates sharing ≥3 top-10 URLs merge into the higher-priority one (groupBySerp), and a query where the blog already ranks top 10 is not planned as a new article (pins excepted).
  • opportunity.ts runs one keywordOpportunity assessment per plan: exact audience fit, editorial search intent, the unanswered reader need, coverage against up to 200 published/draft titles and excerpts, and duplicates within the proposed plan. Login/official-site/checkout/listing searches and already-covered needs are rejected. Accepted topics persist a concrete angle, a localized one-sentence reason and the evidence in KeywordCluster.opportunity; missing organic evidence is always marked possible, not a strong opportunity. The same need/reason reaches the topic picker and writer. An incomplete or failed assessment leaves the existing plan intact. No model predicts visitors or ranking guarantees.
  • Assessment uses the dominant SERP meaning (a franchise keyword does not license TV coverage for a games blog; a song-named coffee query does not prove coffee demand). Specific questions/comparisons receive priority over broad brands/categories; broad queries receive a score multiplier of 0.35. Reasons cannot invent competitor omissions from titles/snippets. Seed generation, scope classification and opportunity assessment use medium reasoning with separate answer budgets and middleware headroom. Usage is recorded before reading structured output so incomplete responses remain observable.
  • Refreshes are monthly, on profile/language/market changes, or when fewer than three assessed topics remain — daily only if the last rebuild found at least three (counting those used since), otherwise after 7 days, so a narrow niche does not pay for the same few topics every day. ClientSite.keywordPlanAttemptedAt claims paid work with a one-day retry cooldown; keywordPlanRefreshedAt records only successful refreshes. A versioned profile binds usable candidates to the current focus, audience, seeds, language, market and domain; GET /api/keyword-plan applies the same filter, so the list shows exactly what the generator can offer. PINNED rows always re-enter the rebuild past the scope and coverage pre-filters, so a pin survives a profile change unless the full assessment drops it. PINNED/SKIPPED/USED/RESERVED survive, and so do open topics checked within 30 days; other stale rows are deleted only if still OPEN when the transaction runs. The article and its guarded OPEN/PINNED → USED transition share one transaction; a topic skipped mid-generation leaves the article unlinked (KEYWORD_PLAN_TOPIC_UNAVAILABLE) instead of discarding it.
  • Candidate memory. A rebuild spends its checks only on topics nothing has decided yet. Before the scope check it drops USED, SKIPPED and RESERVED topics with their synonyms, open topics of the current profile checked within 30 days, and KeywordRejection rows of the current profile from the last 90 days, all matched by phraseKey (word order and accents, keeping the short tokens keywordKey drops, so iphone 15 does not block iphone 16). Only the room left beside the kept open topics gets SERPs, and the kept and reserved topics go to the assessment as already planned, so a new query cannot duplicate them. With nothing new it pays for no SERP and no assessment. Without this, every rebuild spent its 30 slots on the same rejected and used topics. Rejections are a separate system-only table rather than a cluster status, so a rejected row cannot leak into a query on KeywordCluster, and carry a reason: covered, duplicate, poor, navigational, other, ranking or merged. A pin is never remembered, nor is a candidate judged without SERP evidence, nor a scope rejection: inScope skips when in doubt, so one bad answer would lock a niche's topics out for months, and asking again costs only tokens. KEYWORD_PLAN_REFRESHED logs suggestions, fresh, known, retained and rejected per reason.
  • Refill by demand. refillKeywordPlan(site, stock) is the one gate for paid rebuilds: the one-day lease, the shortage cooldown, and no rebuild without a focus or keywords (hasPlanProfile), because the seeds would be the model's guess. generate-article asks for three topics. keyword-plan-refill (hourly, at most five rebuilds a run) asks for two weeks of the rhythm (keywordPlanStock) on sites with ARTICLE_CRONS, a schedule, an AI plan and credits for an article. A site without a schedule refills only when someone proposes a batch. shortageDue counts topics reserved since the last rebuild, so a batch that empties the plan is demand, not a narrow niche. Topics the blog used count only below three ready: above that the stock is a buffer, and rebuilding at every dip would pay daily for a topic or two.
  • scope.ts inScope: one topicScope classifier call per batch keeps only items the blog's focus covers, judged by the item text, not by whether the model knows the name. Keyword search finds the category, not the scope (videohry returns GTA news to an RPG blog). Errors propagate: callers fail closed.
  • News (news.ts): Google News via DataForSEO (/serp/google/news/live/advanced, $0.002 per query) in the tenant's market. Two OR queries per run, tenant keywords and plan candidates, so a busy plan keyword cannot crowd out the category. Apostrophes and quotes inside a term empty the result, so newsQuery strips them. Stories older than 72 h are dropped; the rest go through inScope with their snippet and max 5 reach the picker numbered. GDELT was dropped: it rejects short terms (BMW, RPG) and rate-limits an IP for minutes after a burst. Google News RSS forbids commercial use.
  • Themes, not pillars, decide rotation. A pillar is only the seed that first suggested a keyword, so dotace na okna and dotace na střechu could sit in different pillars while the homepage reads as one subject. The assessment labels each topic with a cross-seed KeywordCluster.theme (reusing known labels); the picker labels non-plan articles, and Article.theme stores the plan's label when a topic was used, so plan and news articles count under one name. orderCandidates: pinned first, then score / (1 + 0.6 × repeats of the theme among the last 5 published/draft articles); a theme already at 2 of 5 rests unless its peakMonth is ≤ 3 months away or nothing else is left. The penalty is computed at pick time, never stored: score is the topic's value, diversity depends on the moment. Topics planned before themes existed fall back to their pillar until the next rebuild. Replaced the old "two from the last pillar, rest after three in a row" rule, which allowed 75 % one-subject feeds.
  • Coverage recheck (recheckCoverage): rebuilds run monthly, so before offering topics the first 6 OPEN candidates are re-judged by assessOpportunities against articles created after their opportunity.checkedAt (one call, only when such articles exist). Covered → SKIPPED with articleId of the covering article; a gap that article misses → EXPAND suggestion (published only); the rest get a fresh checkedAt. A failed check offers the topics unchanged.
  • Picker: up to 3 plan candidates (pinned first) and the news list. topicSchema.planKeyword adds a ## SEARCH INTENT writer block and marks the cluster USED. newsItem > 0 forces research (no sources = held for review) and adds a ## NEWS HOOK block; the story URL is stored in Article.newsUrl. News is not fetched when 2 of the last 5 articles have a newsUrl.
  • CRON_GENERATE_ARTICLE metadata carries keywordPlan { offered, chosen, linked } and news { queries, found, relevant, used, costUsd } or { skipped }. UI: sidebar page /admin/keywords (pages/admin/keywords.vue, components/Keywords/*, one shared useKeywordPlan query on /api/keyword-plan). Header value line and market select, three numbers (search demand of written topics, top-10 share of tracked articles, topics ready), then results and update suggestions (each rendered only when non-empty; the page shows one dashed line for whatever is still pending) and the pin/skip plan: first five topics, the rest and skipped topics collapsed, volume bars relative to the largest topic, difficulty shown only when topics differ. Without AI + ARTICLE_CRONS the page shows only how to enable them; a small information button reveals each topic's reason. PINNED items take priority before the API's display limit and are listed newest first, the order keywordPlanFor offers them; load failures are distinct from an empty plan. tests/eval/keywordScope.test.ts tests relevance and keywordOpportunity.test.ts tests intent, semantic coverage, within-plan duplicates and specific gaps with controlled SERP fixtures; both require AI_EVAL=1 and write local reports to tests/eval/.results/.
  • tests/eval/keywordDiscovery.test.ts additionally requires DATAFORSEO_EVAL=1. It runs the complete discovery pipeline on live Czech Labs/SERP data for coffee, affordable cars and RPG blogs, writing an in-memory plan and a local report instead of touching tenant data. Unlike ordinary unit tests, these opt-in evals incur provider costs.
  • keywordSerps.json captures real Czech queries and organic results from 2026-10-04 for regression replay (keywordSerps.test.ts, AI cost only); assertions reject TV-season and music interpretations outside the tenant's focus. keywordTopic.test.ts verifies that an eligible planned query survives the actual topic-selection call instead of drifting to an unrelated idea. Topic selection uses medium reasoning with a 2000-token answer allowance plus middleware headroom; its wire schema accepts the format/module candidates, then the server filters ineligible decorations and validates the complete topic, preserving the search need when one extra module was invalid. SERP lookups run in batches of five; a complete outage, or failed evidence for every surviving recommendation, preserves the previous plan instead of replacing it with unverified topics.
  • rank.ts claims each due paid check, filters to active ARTICLE_CRONS tenants and schedules from actual Article.publishedAt. Rank points store country location, language and domain; results and the difficulty ceiling use only matching measurements. UI exposes the actual ranked URL, because the position belongs to the domain's best page rather than necessarily the linked generated article.
  • Article updates (refresh.ts, ContentAction, /api/content-actions, Keywords/Actions.vue): REFRESH suggestions need a published page at least 30 days old and two readings on positions 8–20 for that exact article URL; EXPAND suggestions reuse the opportunity assessment when an existing published article can answer an additional reader need. Open suggestions and recent decisions suppress repeats for 60 days. The UI offers preparation, a sanitized preview and a change summary, explicit apply/discard and rollback. Suggestions are free; refreshAllowance(plan, type) lets PRO prepare REFRESH and Premium/managed Custom also EXPAND, half their monthly article units (25/50), for one article credit each. /api/content-actions returns allowed per action. Preparation reserves a credit, queues fresh SERP/research and bounded section edits (at most 40% added or removed words), then settles the credit and READY state in one transaction. Failures release the reservation and do not consume the monthly allowance. A tenant lock serializes allowance checks; a separate processingUntil lease prevents duplicate work without changing the billing month. Expired reservations cannot fund new provider work.
  • Apply/rollback use the request's policy-enhanced database, tenant and member permissions (AI_USE for preparation, ARTICLE_PUBLISH for apply/rollback, ARTICLE_WRITE_OTHERS for another author's article), and a fingerprint plus guarded transactional writes covering title, excerpt and body. They preserve manual changes made while a proposal waits or while a transaction starts. Section replacements retain existing media and polls. Apply saves the previous content, sources and metrics for rollback, updates reading time and researched sources, and notifies translations, feeds and webhooks. The five-minute content-actions task prepares queued edits and measures the exact edited URL for the action query after 30 days, including expansions without a USED keyword cluster; regular rank readings may satisfy the same measurement. This records search position, not clicks or a causal traffic uplift. Migration: 20261004220000_content_actions; regression tests: tests/server/keywords/refresh.test.ts, contentActionRoutes.test.ts and PostgreSQL refresh.integration.test.ts (TEST_DATABASE_URL must identify a separate test database).

Free credits and article batches (/admin/credits, server/utils/articleBatch.ts)

A daily autopilot uses far less than the monthly allowance and unused credits are meant to carry over, so a client can sit on a large balance and will not create articles one by one. /admin/credits (linked from the sidebar, Billing and ArticleBatch/Nudge.vue on the article list) shows the free credits and offers three things, in order of risk to the client's site.

  • Numbers (shared/utils/articleBatch.ts::articleCreditOverview). Free credits are the available balance minus 30 days of forecast autopilot use at the current rhythm and quality. They are computed, never a constant, because an article costs 1/2/3 credits by quality. It is an estimate: interval rhythms are random.

  • Levers (ArticleBatch/Levers.vue, TENANT_SETTINGS, the normal client PATCH). recommendedArticleQuality is the best quality the allowance sustains at the current rhythm; it adds no pages to the site. recommendedAutopilotInterval is the rhythm that spends the allowance, capped at two articles a day, and needs a confirmation.

  • Article batch. ArticleBatch is a plan the client approves at once: PROPOSED → ACTIVE → FINISHED or CANCELLED. A partial unique index keeps one live batch per tenant. Progress is derived from ArticleBatchItem, never stored twice.

    Persistence fences the item by tenant, attempt, RUNNING status, missing article ID and live lease inside the article transaction; losing the claim rolls the article back too. Recovery rechecks expiry and missing output before requeueing or failing a run. Heartbeats, cleanup and pause decisions belong to an attempt, so a replaced worker cannot alter its successor.

    • Proposal is free and server-held. Without a focus or keywords proposeArticleBatch refuses with NO_PROFILE and the screen links to settings. Otherwise it offers min(affordable free credits, MAX_BATCH_ITEMS, good topics) from the keyword plan (planTopicsFor: pinned, then open in orderCandidates order with theme-capped topics last, without the paid rebuild), leaving a running autopilot a week of topics, seven at most. If that falls short, topUpArticleBatchProposal runs after the response (proposingUntil, polled by the page) because Bun drops silent requests after 10 seconds. On a site with ARTICLE_CRONS it first rebuilds the plan if the gate allows, drops proposed topics the rebuild removed and takes its new ones, then asks proposeArticleTopics for at most 10 more, at most twice a day per tenant. It returns fewer topics rather than filler, and the screen says so. Only pinned topics and strong, focused ones start selected (recommended). Weaker plan topics and AI topics, which have no measured demand, stay listed and the client opts in. A proposal is reused for 24 hours. Confirmation sends row IDs and edited text only; cluster IDs stay in ArticleBatch.proposal, so a client cannot name another tenant's topic.
    • Reservation. Confirming moves each topic to KeywordClusterStatus.RESERVED. A USED mark only arrives when the article is saved, so without this the autopilot could pick the same topic meanwhile, and a rebuild could delete an OPEN one. A topic already gone at confirmation is dropped, not written twice; so is one an autopilot run already in flight marked USED before the item started. A success also marks a still-reserved topic USED, so a run that could not load it does not strand it. clusterPinned restores the pin when a failed or cancelled item releases its topic. The keyword plan PATCH refuses RESERVED rows.
    • SPREAD writes each article within GENERATION_LEAD_MS of its scheduledFor and releases it through the same gates and publish-check path as the autopilot. Research does not go stale, credits are taken only when an article is written, and cancelling costs nothing. Slots come from batchSlots (the site's window and days, at the chosen pace) and step past the autopilot's next release (clearOfAutopilot). A held article stays a draft with releaseHold and notifyArticleHeld. Overdue slots after a pause or outage are replanned from now, or the backlog would publish back to back.
    • DRAFTS writes everything at once, one at a time, with no release. It also requests clear media rights and records releaseHold, so the reviewer sees which drafts need attention; the finished summary replaces per-article held emails.
    • Default mode follows ClientSite.autoRelease: whoever lets the autopilot publish already agreed to that.
  • Worker (server/tasks/article-batches.ts, every minute). api-generations waits for its whole run, and Nitro skips a tick while a task runs, so one slow article stalls the queue. This task only fills free slots and does not await them: at most MAX_RUNNING across tenants and one per tenant. One per tenant also gives variety, because client.articles includes drafts, so each run sees the previous article of the batch; the remaining topics reach the picker as plannedTopics. Claims run under an advisory lock, because capacity and the per-tenant rule are read before they are acted on. Un-awaited runs do not survive a deploy, so each process renews a five-minute lease on its runs every tick and a dead run frees its slot within minutes (45 minutes bounds a hung one). processClient writes articleId in the article's own transaction, which makes a dead run's outcome known: with an ID the article exists and the item succeeds, without one nothing was saved and it is written again, at most three times.

  • Pauses instead of failures. Missing credits, the AI cost guard and a requester who lost access put the article back in the queue and set pausedUntil/pauseReason. pausedUntil is only a retry time, so the batch recovers on its own and a retried pause notifies once. Three failures in a row pause for a day, because uncharged runs are our AI bill. Resume (/resume) makes the resuming member the new requester, and a pause for lost access therefore notifies the owners rather than the requester who cannot act. A lapsed plan or deleted site is different: the next due article cancels the whole batch, so re-upgrading months later cannot resume publishing on an old approval. A plan cancelled mid-article finishes it as a draft.

  • Credits are reserved per article under article-batch:<id>:<attempt>. The attempt scopes the key because reserveArticleCredit refuses a used key, and only a run that left no article is ever attempted again. Failed articles are not charged.

  • Authorization. Starting needs AI_USE, ARTICLE_WRITE and ARTICLE_WRITE_OTHERS, plus ARTICLE_PUBLISH for SPREAD. Articles keep the editorial AI author like the autopilot's, not the requester: a person's byline must not sit on text nobody read, and without ARTICLE_WRITE_OTHERS the requester could not open the drafts. hasArticleBatchAccess rechecks membership, seat, scopes and plan before each article. The models have no write policies; routes write with the system client after these checks.

  • Deployment. Migrations 20261106190000_article_batches and 20261106190100_keyword_rejections are additive (a new enum value and three tables) and go out before the code. RESERVED also needs the keyword plan guard in the same release. The balance copy assumes plan credit rollover, which ships separately: until it does nothing is carried and atRolloverCap stays false.

  • generationClientSelect must include articleQuality. It was missing, so the autopilot wrote and charged Standard whatever the setting said, and the quality lever would have done nothing. tests/server/ai/scheduledGenerationAudit.test.ts pins it, because its fixture client always carried the field.

Backlinks (server/utils/backlinks/)

  • Earned links only. There is no link network, exchange or paid placement: Google treats those as link schemes, and every tenant sharing Topiqu's hosting and templates would make one network trivially detectable, so a single manual action could hit all of them. The page /admin/backlinks finds pages that should link, drafts the request and verifies the result; the editor sends the e-mail from their own mailbox. Topiqu never e-mails a third party: cold outreach from SES would put the transactional sender reputation at risk and is regulated commercial communication. Personal e-mail addresses are never collected; the only contact hint is a same-host contact page link found on the source page.
  • Plans (backlinkAllowance in shared/utils/backlinks.ts): PRO gets unlinked mentions and verification; PREMIUM/CUSTOM add manufacturer pages and AI outreach drafts. Discovery also needs the AI feature. BACKLINK_PLANS gates both tasks.
  • When both links and opportunities are empty, Backlinks/Empty.vue replaces the two empty lists with one introduction to discovery, outreach and verification. It owns presentation only; the page supplies search availability, running state and refresh timing and handles its search event. The earned-link metric shows its outreach note only for a nonzero count.
  • Owned hosts are the blog domain and ClientSite.websiteUrl (ownedHosts); a link to either, or any subdomain of either, counts. A request asks for a link to the company website when there is one (backlinkTarget), because that is the page the business needs authority for.
  • Discovery (discover.ts) is claimed with ClientSite.backlinksAttemptedAt (claimBacklinkDiscovery): monthly from the daily backlink-discovery task (20 tenants per run), or manually from the page after seven days. The manual route claims before answering, so the page sees running immediately and polls; the run itself continues in the background. backlinksRefreshedAt is stamped only when at least one source succeeded. Cost per tenant is a few cents: two to three Content Analysis searches ($0.024 + $0.000036/row) and up to five 10-result SERPs; each run logs BACKLINKS_DISCOVERED with costUsd.
  • Unlinked mentions (mentions.ts): DataForSEO Content Analysis (one_per_domain, 50 rows) for every owned host verbatim and for the tenant name unless a host already spells it. Owned, reference (isReferenceDomain: forums, social, encyclopedias, public sector) and spammy (spam_score > 30) pages are dropped. Name matches go through one backlinkMatch model call, because a name search finds every "Alfa". Up to 25 pages are then fetched: a page linking already becomes a Backlink, a page whose text still names the term becomes an opportunity, anything unreadable is skipped. Content Analysis data can be weeks old, so the page itself is always the evidence.
  • Manufacturers (suppliers.ts): the top five KnowledgeProduct.brand values (Heureka MANUFACTURER, Google g:brand, Shopify vendor), excluding the shop's own label, each searched as <brand> <dealer term> in the tenant market; one backlinkMatch call picks the brand's own dealer page or homepage, never a marketplace or retailer. A listed shop is recorded as a backlink, an unlisted one as an opportunity.
  • Pages are read by page.ts through fetchPublicUrl (SSRF-pinned, https only, so an http URL is upgraded; 3 MB cap; charset from header or meta). readPage returns links to owned hosts with rel and anchor, followed links first. gone (404/410) counts against a link, failed (blocked, timeout, 5xx) does not.
  • BacklinkOpportunity statuses: OPEN → SENT (editor clicked "sent", first check in a week) → WON (link verified) or EXPIRED (no link 60 days after sending); OPEN/SENT → DISMISSED; DISMISSED/EXPIRED → OPEN. Rediscovery refreshes the evidence of an OPEN row only and never reopens a decision. Writes use guarded updateMany on the expected status; a lost race is a 409.
  • Verification (check.ts, daily backlink-check): SENT requests weekly; verified Backlink rows weekly, with a miss re-read the next day and the link marked lost after BACKLINK_LOST_AFTER (2) misses in a row. A page that cannot be read keeps its link until it has not been seen for 30 days. Both loops claim each row by its nextCheckAt before fetching.
  • Backlink has only a read policy: rows are created by system code after the page was actually fetched, never by a client. Routes (/api/backlinks, /discover, /[id]) use ANALYTICS_READ, like AI visibility; drafts additionally need AI_USE and are rate-limited (30/h per tenant), manual checks 20/h. Draft input is untrusted page data and the model may only write text.
  • Outreach drafts (outreach.ts, backlinkOutreach) are written in the source page's language, at most ~120 words, and must never offer money, a reciprocal link or a guest post. The UI offers copy and mailto: without a recipient.
  • Tests: tests/unit/backlinks.test.ts (hosts, allowance, matching), tests/server/backlinks/* (page parsing, mention filtering, discovery persistence, verification and lost links, route authorization). Migration 20261007120000_backlinks; adding brand to the hashed feed facts makes every feed sync once as changed without re-embedding, which fills the column.

Knowledge (first-party RAG)

  • KnowledgeSource (prisma/models/knowledge.zmodel) holds a tenant's note / file / URL as extracted text only; uploaded files are never stored, because the S3 bucket is public behind the CDN. KnowledgeChunk carries embedding vector(1536) (pgvector) and a generated searchVector tsvector over knowledge_unaccent(content). Postgres must ship pgvector — pgvector/pgvector:pg18-trixie locally, in CI and in production (same major version and Debian base as the former plain postgres:18); on a plain image the migration fails.
  • Pipeline: /api/knowledge (TENANT_SETTINGS scope, AI plan) → knowledge/extract.ts (magic bytes must match the extension; unpdf, mammoth, fetchPublicUrl for URLs; HTML goes through extractReadableSource in factCheckSources.ts, shared with fact-check, which renders <del>/<s>/<strike> and <ins> as [deleted: …] / [inserted: …] so legislative redlines don't merge both wordings; ai/factCheck.ts accepts a side as in force only when the page says so) → row PENDING → knowledge/indexing.ts (heading-aware chunks prefixed with title › heading path, embedMany, atomic claim; old chunks replaced in the same transaction that marks INDEXED). knowledge-index cron (every 5 min) is the safety net behind the immediate kick; PROCESSING older than 10 min is reclaimed, 3 attempts then FAILED. An edit bumps version, so an in-flight run for older text cannot mark the source indexed.
  • Retrieval (knowledge/retrieve.ts) runs beside researchTopic in buildArticleConfig: embed → one SQL with semantic + OR-tsquery lexical ranks fused by RRF → similarity floor 0.2 (the top 5 lexical hits are exempt, so a plan name or code the vector misses survives), ≤3 chunks per source, top 15 → knowledgeSelect model gate, which may return nothing (ranks cannot say "unrelated"). No tenant with indexed sources = no model call. The query is knowledgeQuery — the manual prompt, or the cron topic — so knowledge still applies with web research off. Selection bumps usageCount/lastUsedAt; the playground (POST /api/knowledge/test) passes track: false.
  • Lexical terms are folded in SQL by the index's own knowledge_unaccent, never in JS: NFD misses what unaccent rewrites (ł → l, ß → ss). Stopwords come from the tenant's corpus, not per-language lists: above 50 chunks a term in >30 % of them is dropped, the rarest always stays. Only chunks embedded by the current aiEmbeddingModelId are searched, so a model switch hides not-yet-reembedded sources instead of comparing incompatible vectors.
  • The editor's generation result lists the knowledge sources used (research.knowledgeSources), each linking to /admin/knowledge?source=<id>, which opens that source.
  • Retrieval changes are measured, not eyeballed: bun run knowledge:eval (scripts/knowledge-eval.ts + scripts/fixtures/knowledge-eval.json) indexes a fixture corpus into TEST_DATABASE_URL with the real models and fails below recall 0.9 / precision 0.8 / abstention 0.9. It also reports recall@3/10 and MRR of the shortlist before the gate, so a miss is attributable to retrieval or to selection, and scores indirect cases (topics that never name what the source covers) apart, ungated. expect must be selected, allow may be (pricing in a comparison). Tightening the gate prompt moved precision from 0.73 to ~0.87.
  • Freshness: the brief dates each entry by knowledgeAsOf (validAsOf → fetchedAt → indexedAt) and marks it STALE in code past 12 months, so the verifier never does date arithmetic. validAsOf is filled from PDF ModDate/CreationDate or HTML publish date, editable in the UI. knowledge-refresh (hourly, 50 URLs per run) re-fetches URL sources older than 7 days and queues feeds older than 24 h: unchanged text only moves fetchedAt; changed text becomes a new version; a failed fetch keeps the last good index and records error. The same run requeues sources whose embeddingModel is no longer the registry's.
  • Quotas per plan in shared/utils/knowledge.ts (knowledgeQuota); create/refresh/playground are rate-limited per tenant and per IP (consumeRateLimit). Knowledge model costs (embeddings, gate) are logged, not charged: indexing via KNOWLEDGE_SOURCE_INDEXED, playground via KNOWLEDGE_TEST_QUERY; in articles they join researchTokens.
  • Sitemap import: POST /api/knowledge/sitemap only discovers pages (same site, https, one sitemap-index level, capped by maxSitemapPages and remaining quota) and the dialog shows found / already known / offered with the cap reason before anything is imported; the client adds them two at a time through the regular URL create, so extraction, SSRF pinning, dedupe, quotas and the 60/h create limit apply unchanged. A pasted address list (webScope: 'LIST', one per line, deduplicated, capped at maxSitemapPages) skips discovery and uses the same loop. For website addresses, knowledge/sitemap.ts reads up to 512 KiB of robots.txt, tries at most five unique same-site HTTPS Sitemap: declarations, then falls back to /sitemap.xml; an explicit XML URL skips discovery. The first successfully fetched sitemap is used. Redirects stay within the same host family (www and bare host are equivalent), with public-address validation and DNS pinning at every hop.
  • Company website onboarding: the sibling landing forwards optional websiteUrl and versioned processing confirmation through its signed checkout request. Only system code creates WebsiteImport: the account-creation transaction and POST /api/website-import (TENANT_SETTINGS, AI plan, the same versioned confirmation, tenant from the session). The zmodel has no create policy, so the generic model API cannot skip the confirmation or the caps. A manual start is refused while an import is PENDING/PROCESSING/READY (the partial unique index is the race backstop) and is capped by WEBSITE_IMPORT_LIMITS.startsPerDay/startsPerMonth, counting onboarding and deleted rows because each run is paid work. Its baseline is the current websiteSnapshot. WebsiteImport/Start.vue sits in the settings panel on AI plans: after a failure, or opened from WebsiteImport/Entry.vue in the settings header (first import or re-run). useWebsiteImportCard shows the panel only while an import needs the user: PENDING/PROCESSING/READY/FAILED, or REVIEWED while it still leads to the first article. server/utils/websiteImport runs immediately in the background, with the minute website-import task as its recovery path. Atomic database claims prevent duplicate execution across replicas. One Anthropic request allows at most two domain-restricted searches; one subsequent structured extraction has no tools. Both disable thinking and automatic provider retries. Up to five HTML pages (2 MiB / 12,000 characters each) use the existing DNS-pinned fetcher with same-domain redirects. Metadata supplies observed brand assets, primary color and social links; unsupported assets remain missing. The extraction also returns up to three first-article topics ({ topic, reason }) into result, never into settings; the model schema keeps them loose and websiteTopics filters them, so one bad topic cannot fail the settings, and WebsiteResultSchema defaults them to [] for older results. A topic opens the editor as ?ai=1&prompt=. Existing knowledge rows are deduplicated and created with useInArticles: false under an atomic tenant quota check. The notice, checklist and settings panel share one client polling scope through useWebsiteImport; stale reads are cancelled and its cached handler releases component state on disposal. The persistent Admin/WebsiteImportNotice.vue opens WebsiteImport/Panel.vue in settings: review/edit and select fields, accept all available values, or skip. Applying uses the request-scoped policy client, checks current field hashes and tenant permissions, copies accepted raster assets through existing moderation/storage, enables only accepted import knowledge and records the review. Existing manual knowledge tools remain available for other sources and older onboarding clients. NUXT_WEBSITE_IMPORT_DAILY_BUDGET_USD defaults to USD 100 globally. A transaction with a database advisory lock reserves USD 4 before dispatch, then settles known model/search usage plus USD 0.10 for bounded knowledge indexing. A depleted budget defers work; unknown provider outcomes retain the reservation and never retry that paid stage. Search checkpoints permit recovery without another search. The usage rate card in websiteImport/policy.ts is tied to the centrally configured Haiku model; model changes require reviewing the reservation and rate card together. logAction captures queue/start/defer/ready/failure/review and AiUsage captures tokens, searches, estimates and durations; neither receives page bodies or provider responses. Migration 20261022140000_website_import adds the import table, active-tenant uniqueness and nullable KnowledgeSource.websiteImportId. Deploy the migration before the app, then the landing confirmation change; older clients continue storing the website without starting AI work.
  • Meta Conversions API (server/utils/metaConversions.ts, off without META_CAPI_TOKEN). The landing pixel sets _fbp/_fbc on .topiqu.com only after cookie consent and deletes them on refusal, so a sign-up without either is never stored or sent. Sources: the onboarding checkout (the landing forwards the cookies and user agent as signed-request X-Landing-* headers, outside the payload fingerprint), POST /api/auth/register on the platform host only (tenant readers use it too), and the first payment (recordFirstPayment in the Stripe webhook, firstPaidAt in creditAgencyInvoice), reported after the transaction for the blog or agency owner. User.metaSignals (@omit, cleared by anonymisation) carries the sign-up context to that payment for 60 days. CompleteRegistration uses metaRegistrationId(email), which the landing pixel derives too, so Meta deduplicates the pair. Sends never throw. OAuth sign-ups and Shopify billing are not reported.
  • Website import progress and reasons: each run stores WebsiteImport.progress (state per step address/search/pages/brand/analysis, plus each fetched page; only own-site URLs, counts and codes). WebsiteImport/Progress.vue renders it in the panel, polled every 2 s while PROCESSING. errorCode on a PENDING row is the wait reason (WEBSITE_IMPORT_WAIT_CODES), on a FAILED row the stop reason (WEBSITE_IMPORT_ERROR_CODES); websiteImportReason maps both to copy, anything else reads as PROCESSING_FAILED. websiteFailure classifies a stage error: a funding refusal comes from the spend reservation before any request, so it waits 30 min without an attempt and reopens the stage, returning its daily-budget reservation; 429/529 is unbilled and retries after 2 min within the attempt cap; everything else fails and is reported with the original error as cause. hasAiPlan reads only the plan name, so the manual start and each paid stage also check aiBudgetError (funded period and BUDGET remaining). A bought AI pack wakes imports waiting on AI_ALLOWANCE_EXHAUSTED. Migration 20261107120000_website_import_progress.
  • Superadmin plan changes on a blog without a Stripe subscription set billingPlan through manualBillingPlan, as creation does. Otherwise fund() finds no invoice-backed PLAN grant and the paid plan has no AI budget.
  • Publish confirmation is enforced server-side: POST /api/knowledge and a note text change in PATCH return 400 KNOWLEDGE_CONSENT without confirmed, and the audit row records consent: { version: KNOWLEDGE_CONSENT_VERSION, confirmedAt }. Bump the version when the checkbox wording changes. The add dialog has no "use in articles" switch: everything added may reach a published article, and embedding sends it to OpenAI regardless, so an "unused" upload would only store and ship data. useInArticles survives as pause/resume in the list; retrieval (eligible and the availability check) is the only path to the writer, gate and verifier, and both filter it.
  • Product feeds (kind: FEED, knowledge/feed.ts): an e-shop catalog is one source, not a page per product. The index queue runs the sync (syncFeed in indexing.ts): stream the XML (htmlparser2, no DTD, so no XXE/entity expansion; maxFeedBytes) → Heureka SHOPITEM or Google Merchant item/entry adapters → normalizeVariant (the only cleanup: HTML → text, https product links without tracking params, ISO 4217 currency, unambiguous prices or null) → variants merged by item_group_id (lowest price, best availability). One KnowledgeProduct row + one chunk per product; price and availability are never embedded, so a price change is an UPDATE and only products whose textHash changed are re-embedded, in committed batches. A failed sync keeps the last good catalog; a feed that parses to nothing throws instead of wiping it.
    • Retrieval treats a feed as eligible in any status (chunks are swapped per product, never half-written), only for articles in the feed's language, never out-of-stock products. The per-source cap is per product. The brief adds product · from <price> · <availability>, formatted in code; the product URL is the citable link, the feed URL never is (it often carries an access token, so the title defaults to its host). The writer states a price only for buying/pricing/comparison topics.
    • POST /api/knowledge/feed checks a link before it is added (count, sample, what fits the quota; nothing stored). A failed sync stores a KNOWLEDGE_FEED_ERRORS code the list translates; only unreachable and unexpected errors are retried, and only unexpected ones reach Sentry.
    • Product images (Heureka IMGURL, Google g:image_link, Shopify featuredMedia) are stored as imageUrl. Cards show cdnImageUrl, a 480 px WebP copy made by knowledge/productImage.ts on first use, because shop hosts are not on the IPX allowlist. cdnImageSource records which imageUrl was copied, including a failed copy, so a broken image is not refetched on every insert. A sync that changes the catalog re-copies changed or failed images, up to 50 per run, and deletes the old copy.
    • Heureka prices carry no currency, so the feed stores one; ClientSite.currency is the billing currency and must not stand in. Product quota is per tenant across feeds (maxProducts).
  • Manual generation can skip knowledge (options.useKnowledge, default on). Audits carry knowledgeShortlisted and knowledgeSelected (chunks), so the gate's empty-selection rate is measurable per tenant.
  • Raw SQL is the only tenant isolation for chunks (policies deny the ORM path entirely); clientSiteId comes from the session or tenant row, never the request. Deliberately no HNSW index: an ANN index filters by tenant after the search and starves small tenants; per-tenant exact scans are cheap. Revisit with pgvector ≥0.8 hnsw.iterative_scan once one tenant exceeds ~50k chunks.
  • Voice: the article runs on the publisher's site, so writer (knowledgePrompt) and copy desk treat first-party knowledge as its own offer: first person plural or its name, never "the seller / product page states" or an anonymous market ("česká nabídka", "prodejci uvádějí"), no "as of" dating or hedging of its own terms. Its own product figures win over web sources and are not contrasted with them; a figure its entry lacks stays unstated, never borrowed from a sibling variant or the web. The verifier reports a manufacturer value that differs from our listing as LISTING DIFFERS: it never blocks, and only the audit's verification keeps it (stripped from the brief the copy desk and reviser read), so the feed gets corrected instead of the article diverging from its own product page.
  • A product the prompt names by model (namedProducts in retrieve.ts: the name before " - ", else its longest leading word run with a number; only the closest match per model line) bypasses the relevance gate and is marked NAMED IN THE BRIEF, so a similarly named variant cannot stand in for it.
  • Citations: internal entries reach the writer as [Kn] … internal with no URL; only publicUrls join citationAllowlist, which replaces the research brief as the input to filterResearchSources. The reviewer/verifier receives the brief too: first-party claims about the publisher count as supported, except time-sensitive claims resting on entries older than 12 months or contradicted by an official web source (reported as UNSUPPORTED MATERIAL); a product's technical figures from its entry are exempt from the latter (LISTING DIFFERS). Used { sourceId, version, chunkIds } land in the manual MANUAL_GENERATION_WRITER_STARTED audit and the cron generation audit.
  • Why a claim survived review: editorialReview.verification in the generation audit holds every verdict line of each verification round (verdictLines in articleQuality.ts), not verificationBrief, which keeps only URL-backed lines and so drops UNSUPPORTED and first-party-supported verdicts.

Notifications delivery

  • Email unsubscribe is a signed link, not an address. server/utils/unsubscribe.ts HMACs the user id with AUTH_SECRET (non-expiring, so old mails keep working); pages/unsubscribe.vue posts it to POST /api/users/unsubscribe, which only ever sets allowEmail = false. The page acts on a click, not on load, so link-prefetching mail scanners cannot unsubscribe anyone. Verification-code mails are transactional and carry no unsubscribe link.

  • Notifications are persisted in the DB (source of truth) and delivered to the client by polling, not push. The client (Notification/Bar.vue) polls GET /api/notifications/poll?since=<ISO cursor> every 10s (paused while the tab is hidden) and merges anything newer.

  • Publication notifications share server/utils/articleNotifications.ts → notifyArticlePublished across manual publication, scheduled release and cron generation. It uses the actual author's username, recipient language with an English fallback, one translator per language, and follower inserts in batches of 100. The caller supplies its policy-aware DB/transaction and chooses whether to notify the author; cron generation skips the AI author. The unused cron SSE hooks have been removed.

  • Notifications related to an article include its imageUrl; Notification/Bar.vue renders a compact cover for publication, like and comment notifications when one exists, and a type icon otherwise. Both the paginated endpoint and polling select title, slug, language and image URL.

  • Reading. Opening the panel refreshes page 1 and calls POST /api/notifications/read with the newest seen createdAt as before, so a notification arriving meanwhile stays unread. The badge hides while the panel is open; rows unread at that moment stay highlighted until it closes (highlighted, client-only, since the server already has them as read). A failed call keeps the count and the next opening retries. Older rows loaded by scrolling after the call arrive as read, so with more than one page of unread rows those lose the highlight.

  • Panel layout. Rows are grouped by day (utils/dayGroups.ts, shared with the media library), in Europe/Prague like other timestamps. A row is one link, using a stretched ::after so the delete button is not nested in it: stored link first, then the article. Notifications with neither (a follow) are plain text. New arrivals raise one toast with a fixed id that keeps a running total across polling batches, and none while the panel is open.

  • Loading more watches a sentinel with useElementVisibility. The observer must not take the list as its root: the list itself never scrolls, so the sentinel would count as visible straight away.

  • Notification messages are stored in the recipient's account language at creation time; changing the interface language does not translate historical text. The language menu saves a differing account preference even when that language already matches the current route.

  • Reactions accept published articles and the signed-in author's own drafts. The route keeps deleted articles/sites excluded, uses the policy-aware database client, and suppresses self-notifications; guests and other readers cannot like private drafts.

  • Community-insight emotion identifiers come from shared/utils/communityInsight.ts and are translated at display time, including curiosity. The generated insight schema uses that vocabulary; historical unknown identifiers are omitted rather than exposed as raw labels.

  • Article/Series.vue uses the cover URLs returned in the article detail's series payload: compact thumbnails identify every entry in the expandable list, while the previous/next cards show wider covers and a two-line excerpt.

  • The article editor's leave guard compares articleEditorSnapshot values containing only persisted form inputs, tags and the selected series. API-derived fields such as counters and expanded relations cannot create a false dirty state after save or publish.

  • This replaced an in-memory SSE channel (server/utils/realtime.ts + /api/notifications/sse + the useRealtime composable), which is unviable on serverless/Vercel: held connections incur per-request/wall-clock billing and isolated function memory means a publish in one function never reaches a subscriber in another. That whole layer was removed; reviving push would mean a managed WebSocket provider (Ably/Pusher) or a long-running process off Vercel, not a serverless patch. Cursor semantics live in server/utils/notificationsPoll.ts and are unit-tested.

  • That constraint is gone — the app now runs as a persistent process on its own box (§1), so SSE is viable again and the removed layer is the reference design for bringing it back. Polling is simply what still ships. One caveat before it returns: in-process pub/sub only works at one replica; more than one needs fan-out through Redis/Valkey, which ties into the same "1 vs N replicas" decision as the crons (§8).

Blog statistics (/api/stats/dashboard)

  • The dashboard is the only statistics endpoint. The eleven disconnected per-metric routes were removed after checking app, server, scripts, tests and integration documentation for callers; production request logs were not available locally.

  • Saved time / money are derived on read. One formula in shared/utils/savings.ts → writingSavings (tests/unit/savings.test.ts): words ÷ speed = hours, hours × rate = money. The dashboard sums totalWords over aiInvolvement: 'FULL' only (the editor demotes to ASSIST on the first human edit) and prices it with the tenant's current rate. Article.savedAmount / savedTimeMinutes are still written but no longer authoritative — they froze a rate at generation time, so a correction never reached existing articles. Drop them in a later migration; don't add readers.

  • clients/[id] PATCH gets linkedinMode on every save, because the settings form defaults it — so that branch runs for tenants who never connected LinkedIn. It must not create a LinkedinCompany: only the OAuth callback has a real linkedinOrgId, and the stub value it used instead collided on the unique index, 500-ing settings for every tenant after the first.

  • The rate is USD and the column name says so. humanHourlyRate held a CZK figure while the UI formatted it as the tenant's currency (default USD), inflating the number ~23×. Migration 20260808120000_human_rate_usd renames it to humanHourlyRateUsd; display multiplies by useCurrencyRate like Billing.vue / AI.vue. The default (35) is stated twice — client.zmodel and DEFAULT_HOURLY_RATE_USD — so changing it takes both plus a migration that moves rows off the old value, or tenants stay priced at it forever.

  • Admin sidebar (Sidebar.vue): groups separated by dividers (Web, overview and statistics; content; reach; settings). Writing is a button, not a list item. On the publication's canonical origin, Web is a normal same-tab navigation item: publicationNavigationUrl returns the current localized index path. From the central host, Open site is an external footer link in a new tab to the verified publication URL in its primary language. Exact matching distinguishes Web and Dashboard; Nuxt routing preserves editor leave guards. The authorized sidebar remains on public routes, while foreign-tenant access stays hidden by the existing host/session gate. Both desktop and teleported mobile sidebar surfaces scope the admin primary/action tokens so publication accents do not change their controls. Header branding follows the resolved tenant on both admin and public pages and links to its index. On the central host Topiqu links to the role's dashboard (or sign-in for other visitors). Widget previews retain their resolved tenant branding. Tags are managed from the article table; custom emoji from the comment emoji picker (tenant admins on their own blog, Pro+) and Settings → Community.

  • Stats/Dashboard.vue (page /admin/stats) uses neutral report sections. Sections (Stats/SectionHeading.vue) and repeated rows (Stats/Row.vue — rank/icon, truncating label, right-aligned metric, optional share bar) share the dashboard's semantic colours. Charts and AI-involvement indicators use neutral tones; status colours remain available for real warnings/errors. Charts is behind <LazyCharts> so chart.js stays out of the chunk for BASIC, which never renders it.

  • Truncation needs min-w-0. A flex item defaults to min-width: auto and refuses to shrink below its content, so a long tag name pushed the view count out of its container. Every truncating label on this page sits in a min-w-0 parent with a shrink-0 metric beside it.

  • Modal/index.vue had no dark tokens at all — a white panel in dark mode, with the one card that did carry dark: variants floating in it. The panel and its title gradients now have them. h-11/12 is untouched and still forces every modal to ~92% viewport height regardless of content.

  • Charts.vue takes semantics, not a Chart.js config — kind (trend bar↔line / breakdown bar↔pie), labels, values; it owns colours and scales. Canvas colours are resolved from rendered semantic text/border/background tokens and update with colour mode. Bar/line charts use one neutral tone; pies use stable category tones, readable labels and a table alternative. Stats/Dashboard.vue always sends all five share platforms including zeros, since dropping the empty ones would repaint the survivors.

  • A share is gated per identity per platform, the same way ArticleReaction gates a like: ArticleShare carries userId/sessionId (the client fingerprint for anonymous visitors) and the handler skips the increment when a row already exists. The unique index is only a backstop — every row carries a NULL in the tuple, and Postgres treats NULLs as distinct, so it catches nothing on its own. Every share button goes through composables/useArticleShare.ts, which adds the fingerprint for anonymous visitors (without it the POST is a 400). Article/TOC.vue only emits share up through Article/View.vue, so the editor preview never counts one.

  • ArticleView is the view-event log (migration 20260819120000_article_view_events); Article.views stays the running counter, so sorting and totals keep their fast path. One row per visitor per article per UTC day, and sessionId is non-nullable precisely so the unique index bites — ArticleShare's equivalent index catches nothing because every row carries a NULL in the tuple. view.post.ts inserts with ON CONFLICT DO NOTHING and increments the counter only when a row was actually written, so the two never drift. Consequences: the counter now means unique visitor-days, not raw hits, so it grows slower than the historical values above it; the series cannot be backfilled, hence trackingSince (NULL until the first event) which the client renders as "měřeno od …" instead of drawing flat zeros.

  • The daily series is real readership now, bucketed on ArticleView.viewedOn over VIEW_TREND_DAYS (30). It replaced a 7-day series bucketed by COALESCE(publishedAt, createdAt), which counted views accumulated by articles published that day — not views that happened that day.

  • Views were forgeable. view.post.ts had no session, no dedup, no rate limit and no status filter: a loop of curl moved the number, and an admin previewing a draft inflated it. The identity is now server-issued (the anon_session cookie, never read from the body) and drafts are rejected. Totals and topArticle filter status: 'published' for the same reason.

  • Engagement is a site-wide ratio, interactions ÷ views (server/utils/dashboardStats.ts, tests/unit/dashboardStats.test.ts). It used to be the mean of per-article ratios, which let a 1-view article with 3 comments contribute 300% — that is why the client clamped the display at 100%. The clamp is gone; do not reintroduce it to "fix" a number above 100%, fix the formula.

  • topTags ranks in SQL. Prisma cannot order a tag by an aggregate over its articles, so the old code took the 10 tags with the most articles and sorted those by views — a tag riding two heavily-read articles never entered the candidate set. The raw query joins and ORDER BY SUM(views). Note the metric double-counts by design: an article with three tags gives its views to all three.

Domains

  • DomainConnection reserves custom hosts and owns provider IDs, encrypted queued certificate material, lifecycle and reconciliation leases. Generic model writes are denied; tenant-settings routes authorize system provisioning. server/utils/domains/ contains provider boundaries, certificate validation, signed routing probes and reconciliation; the existing domain task retries setup/cleanup and rechecks ownership. GET uses an explicit safe projection. Managed-domain switches stay local. See docs/custom-domains.md for rollout, O2O, manual-resource attachment, certificate limits and platform-host administration.

  • Disconnect/cancel first commits the safe address change and cleanup request, then attempts cleanup under a fresh lease. Retired connections can be retried by their tenant-scoped ID even after the canonical host has changed. A cleanup-only attempt cannot provision a subsequently reused connection. No-resource cleanup is local; provider failures retain IDs for retry. This tenant's pending cleanup is reported separately from another tenant's reservation.

  • Domain settings separate ownership, HTTPS and activation. The scoped domain GET reconstructs permanent DNS instructions for every retained connection without exposing provider credentials or raw connection rows. shared/utils/domainRecords.ts retains exact Cloudflare challenges in the existing JSON field, marks current requirements separately from history, and keeps multiple TXT values at one name. The UI offers activation only after READY, retains DNS instructions after activation and opens replacement-address setup explicitly.

  • DNS verification explicitly requests JSON parsing for Cloudflare's application/dns-json responses; ofetch otherwise returns a Blob, hiding the DNS status and records. This shared lookup serves initial verification, domain switches and scheduled revalidation, retaining exact TXT challenge and normalized CNAME matching.

  • domainError (shared/utils/domain.ts) is the one claim rule: a managed host must be a single unreserved label under the base domain (managed hosts are auto-verified, so billing.topiqu.com would be a live platform host), and a custom domain needs a non-BASIC plan. isDomainTaken (server/utils/domainVerification.ts) also counts old hosts in DomainRedirect, so nobody can take over another blog's redirect. Onboarding, tenant creation and the availability check all go through both.

  • domain is superadmin-only in PATCH /api/clients/[id]. A tenant moves through /api/domain/pending: the new domain is stored as pendingDomain with its own TXT token while domain keeps serving; pending/verify checks TXT + CNAME and then, in one transaction, swaps the two, adopts the token for domain-reverification and writes a DomainRedirect for the old host. A direct swap would take the blog offline, because tenantByHost refuses an unverified custom domain. Settings → Branding → Form/Client/Domain.vue.

  • server/middleware/domainRedirect.ts answers an old host with 301 (308 for non-GET) to the current domain, path and query kept. It only looks up redirects when the host is not a live tenant, and the swap drops the cached host lookups for both hosts (uncache).

  • Custom-domain entitlement retirement: every transition through syncPlanFeatures to BASIC calls domains/entitlement.ts inside the already locked tenant transaction. It restores a free managed hostname, clears pending custom activation and marks live provider connections for cleanup while retaining their leases/IDs. The minute domain-cleanup task retries cleanup independently of DNS revalidation; provider outages cannot roll back the canonical-host revocation. Host resolution is memoized per request only, and redirects read current database state, so neither a retired custom domain nor a newly restored managed fallback trusts an old five-minute cache entry. This costs one indexed tenant read per request shared by its hooks. Manually adopted resources retain their IDs and ADOPTION_REQUIRED until an operator removes them; the app never deletes external resources it does not manage.

  • Team seats and managed CUSTOM: unlimited blogs, each with its own subscription. Additional blogs are BASIC without another trial. shared/utils/teamSeats.ts defines BASIC 1, PRO 2 and PREMIUM 3, including the owner. paidTeamSeats distinguishes managed CUSTOM (minimum three purchased seats) from legacy individual contracts (null, unchanged). Membership creation, invitations and acceptance lock the same tenant row; pending invitations reserve capacity and excess access pauses on downgrade. customBilling.ts serializes changes with a database lease and signs short-lived proration quotes. /api/stripe/seats previews the actual Stripe amount, requires explicit billing authorization, uses pending payment before entitlements, and re-reads current state after idempotent updates. Inviting a member never charges. Capacity decreases reject occupied/reserved seats, reduce the next renewal and do not refund prorations.

  • AI allowances and invoice receipts: shared/utils/billingCatalog.ts is the server/UI commercial source. aiAllowance.ts locks ClientSite to fund grants, reserve operations and settle measured costs; unknown outcomes retain reservations, known overruns reduce future capacity and accounting failures do not fail delivered output. System-only AiAllowanceGrant, AiUsageReservation and AiAllowanceInvoice distinguish monthly/trial counts, provider capacity and explicit nonexpiring usage packs. Paid budgets use net plan invoice lines, exclude seats/add-ons/tax, normalize annual revenue monthly and retain zero-dollar receipts. Annual catch-up requires paidThrough; subscriptions without a funded period cannot mint new AI capacity. Article purchases and carried plan units (ARTICLE_PURCHASE/ARTICLE_ROLLOVER grants keyed article-funding:{articleGrantId}) retain funding spendable only by an operation that allocates that article grant; it is spent before the shared budget, while overrun debt drains the shared budget first. BUDGET (AI providers, 50 % of net plan revenue via AI_BUDGET_MICROS_PER_CENT) and SEO (DataForSEO; each call reserves its endpoint ceiling: SERP $0.02, Labs $0.05, other $0.20) are internal cost guards (INTERNAL_AI_ALLOWANCE_KINDS): reaching one reports AI cost cap reached, the customer sees common.aiAllowance.paused, and /api/stripe/ai-usage omits both. ai/spend.ts attributes text/image/embedding/search calls to the blog; provider calls fail closed without context. Translation exhaustion queues a timed retry, cleared by paid funding. Billing shows operation counts and explicit pack purchases, never automatic overage. Three additive 20261104* migrations ship these ledgers, leases and paid-period fields; 20261106200000_ai_cost_cap adds the SEO kind and scales stored 15 % budgets by 10/3. Details and calibration limits: docs/operations/ai-economics-proposal.md.

  • Independent provider-cost debt: ClientSite.aiBudgetDebtMicros and seoBudgetDebtMicros retain AI and DataForSEO charges above their reservations. Reservation, correction and settlement share the existing tenant row lock; the next funded reservation repays only its own kind's debt, and allowance reads subtract outstanding debt without changing grants. Repeated settlement is idempotent. 20261109120000_seo_cost_overrun adds the zero-default SEO counter before updated ORM queries run; it does not backfill historical overruns. The 25% business target is distinct from the 50% guard; target/per-article cost alerts and full-workflow cost calibration remain unimplemented, as described in docs/operations/ai-economics-proposal.md.

Integration OAuth and billing returns

LinkedIn and Search Console connections can start from an authorized tenant host, including its verified custom domain, or the central app. The source POST creates a hashed, expiring grant bound to the initiating account, session, tenant, exact origin and locale. /integrations-connect on the central auth origin explicitly confirms that account before starting provider OAuth. The callback rechecks source-session validity and tenant capabilities and atomically consumes the grant.

  • Provider state stays on the central host. server/utils/oauthStateCookie.ts uses HttpOnly, host-only cookies with the __Host- prefix under HTTPS. Both the provider authorization and its callback use the central origin; neither login handoffs nor shared subdomain cookies transfer integration state.
  • Returns preserve the verified source origin and locale. Integration returns use the origin and supported locale captured by the grant. Stripe checkout, subscription and portal returns use billingReturnUrl, which validates the request's origin and locale before building /[locale]/settings?tab=billing.
  • Provider failures return localized recovery UI and sanitized diagnostics. OAuth codes, tokens, grant values and raw provider errors must not enter telemetry.

LinkedIn Company Pages — DISABLED (personal-only connect, retained implementation)

  • linkedin/connect.get.ts 403s appType=pages and hardcodes the member scopes (openid profile email w_member_social). The organization scopes it used to request — w_organization_social, r_organization_social, rw_organization_admin — belong to LinkedIn's Community Management API, which LinkedIn grants only to registered legal entities (not sole traders), so the flow could never have completed. tests/server/linkedin/connectEndpoint.test.ts fails if an organization scope reappears.
  • Legacy company-page data and publishing utilities remain supported: LinkedinCompany.type still carries 'pages' | 'personal', and token.ts still reads LINKEDIN_CLIENT_ID_COMPANY for existing rows. The current centrally confirmed OAuth flow and callback support personal profiles only; the retained HMAC-state helper is not their authority. Re-enabling company-page connection requires an explicit product decision and provider/type binding in the central grant, authorization and callback flow, in addition to restoring the UI action and organization scopes.
  • clients/[id] PATCH no longer prefers a pages row when applying linkedinMode — it takes the tenant's single LinkedinCompany regardless of type, so a legacy pages row stays editable.
  • LinkedIn posts take their voice from ClientSite (aiToneOfVoice, audience) — the same fields the article writer reads. There is no LinkedIn-specific brand profile; the former BrandProfile table duplicated them and was never filled in.

5. Shared (shared/)

  • zod/ — split into common/, enums/, input/, models/, objects/ with a barrel index.ts. Schemas reused by both client forms and server validation.
  • types/ — hand-written cross-cut TS types. article.ts → ArticleCardData is the row shape consumed by Article/Collection.vue (shared by the stitky and autor listing pages).
  • utils/ — pure helpers shared by app + server. tenantTheme.ts turns a tenant's theme/accent/typography into the publication CSS variables and custom @font-face (used by layouts/default.vue and the branding editors). savings.ts is the single "what would a human have charged for these words" formula — see Blog statistics in §4.

app/utils/ also holds queryKeys.ts (Pinia Colada key factory, see §3 Data-fetching split) alongside buildClientSettingsForm.ts.

  • Stacking order is defined by the --topiqu-layer-* variables and z-* utilities in app/assets/styles/main.css; the unused TypeScript layer map has been removed.

External API

openapi/v1.json and the sibling landing's content/docs/{en,cs}/ are the consumer-facing API contract and guides. Generate the contract from scripts/generate-openapi.ts and keep the landing's public/openapi/v1.json synchronized. This section records the implementation and access invariants.

  • Keys (server/utils/apiKeys.ts, prisma/models/apiKey.zmodel). A tenant has up to MAX_API_KEYS named ApiKey rows. Only the SHA-256 of the key (sk_ + 256 random bits) is stored (hash, unique, @omit) with an 11-character prefix for display; the key is returned once by POST /api/api-keys and never again. List and revoke via GET /api/api-keys and DELETE /api/api-keys/:id (sets revokedAt); all three need API_KEY_CONTROL, and a member can only mint scopes they hold themselves (requiredTenantScopes). Settings → Integrations renders Form/Client/ApiKeys.vue. The migration 20261104120000_api_keys hashed the former plaintext ClientSite.apiKey into a read-only key named "Default" and dropped the column, so existing integrations keep working.
  • Scopes. READ (every key), WRITE (create/edit drafts, read drafts), PUBLISH (publish, unpublish, schedule), GENERATE (AI articles, uses credits). Write scopes need Pro+. requireApiKey(event, scope) looks the key up by hash, refuses revoked keys and deleted sites, rate-limits per key (API_RATE_LIMIT, 120/min, Retry-After) and throttles lastUsedAt writes. Every external route authenticates through it; requireExternalClient is its READ shorthand. Authenticated responses are private, no-store.
  • Writes act as the key's creator. actAsApiKeyMember sets event.context.authUser to the member who created the key (role admin, never superadmin, so ZenStack keeps the tenant boundary) and event.context.apiKeyMembership to that member's current scopes narrowed by the key (apiKeyTenantScopes, role MEMBER). requireTenantMember returns that membership, so the v1 write routes run the editor's own createArticle / updateArticle (server/utils/articleWrite.ts, shared with POST /api/articles and PATCH /api/articles/:id) with every existing check: publish scope, media-rights review (409 MEDIA_RIGHTS_REVIEW_REQUIRED, answered with mediaRightsReview), edit versions, webhooks, audit. A key without a creator (the migrated one), whose creator left the site, or whose member seat is paused cannot write. Private draft and generation-status reads validate the same current creator permissions and use the policy-enhanced database. GENERATE maps to AI_USE and ARTICLE_WRITE without granting the separate general WRITE API scope. Each API write also logs API_* with apiKeyId (logApiAction).
  • Idempotency. withIdempotency stores the response of a write sent with Idempotency-Key in ApiIdempotencyKey (unique per key) and replays it for 24 h (Idempotent-Replayed: true); a different body is 422, a request still running or with an uncertain result 409. Known client rejections, including insufficient articles (402), release their claim. Unknown failures and completed operations whose replay response could not be stored retain their claim, including beyond 24 hours, to prevent duplicate writes or paid generation; operators must reconcile these records before clearing them. It is required on POST /api/v1/articles/generate. api-generations prunes only completed expired rows.
  • Write routes (/api/v1). POST /articles (WRITE, + PUBLISH to publish or schedule; tags by slug via resolveTagSlugs), PATCH /articles/:id (WRITE, + PUBLISH when status/releaseAt is sent; expectedVersion optional, last-write-wins without it), GET /articles/:id returns drafts with status/editVersion/releaseAt to a WRITE key. Input schemas are strict (apiArticleCreateSchema, apiArticleUpdateSchema), so editor-owned fields cannot be set. There is no cover-image upload through the API yet.
  • Generation (server/utils/apiGenerations.ts, server/tasks/api-generations.ts). POST /api/v1/articles/generate (GENERATE + the member's AI_USE and ARTICLE_WRITE) checks the plan, at most MAX_PENDING_API_GENERATIONS queued or running per site and that the wallet covers every queued request plus this one (402), then stores an ApiGeneration and answers 202 with Location: /api/v1/generations/:id. The api-generations task (every minute) claims queued rows with a guarded update, revalidates the key and its creator before spending capacity, and runs the autopilot pipeline (processClient with a RequestedArticle): the picker keeps the requested subject (TopicInput.requested), news hooks are skipped, the article is always a draft, lastGeneratedAt and the schedule are untouched, and the credit reservation key is api-generation:<id>, so a retry cannot charge twice. The outcome sets the row to SUCCEEDED/FAILED (guarded on RUNNING) and sends generation.succeeded / generation.failed. A run older than API_GENERATION_TIMEOUT_MS is failed as interrupted.
  • GET /api/external/articles remains the backwards-compatible collection endpoint. It returns source-language, published Article rows only, newest first. Its original fields and nested tags[].tag shape remain intact; additive fields now expose updatedAt, publishedAt, reading time/word count, sources, cover credit, series and metadata for published translations. Pagination is page + limit (defaults 1/10, maximum 100), and response metadata includes the primary language and applied tag filters. Optional comma-separated tag filtering is trimmed/deduplicated and retains AND semantics: an article must carry every requested tag slug.
  • GET /api/external/articles/:id returns one published tenant article with the complete safe external projection. Its response uses flat tags, identifies the source language, and exposes published translation summaries as availableTranslations; a foreign, draft, archived or missing id is the same 404.
  • GET /api/external/tags is discovery for filtering: only tags actually attached to at least one published article of the authenticated tenant, sorted by name, with articleCount. This deliberately avoids leaking global/other-tenant tags and omits empty tags.
  • GET /api/external/site returns public integration metadata (name, domain, description, logo, theme, primary/available languages, socials) plus the published article count; it never projects billing, AI configuration or credentials.
  • The versioned contract is /api/v1 (server/api/v1, X-API-Version: 1), described by openapi/v1.json (generated by bun run openapi:generate from scripts/generate-openapi.ts; openapi:check fails when stale; the landing site serves a copy). Scopes are machine-readable there as vendor extensions, because apiKey schemes cannot carry scopes: x-api-key-scopes at the root, x-required-scopes per operation and x-conditional-scopes for PUBLISH on article writes. /api/external/* stays as the unversioned read API of the WordPress plugin. Change propagation is pushed by webhooks (below). There is no change/deletion cursor, conditional request support, localized full article response or canonical public URL; reliable unpublish/delete propagation would need a persisted change log rather than inference from the current published collection.

Webhooks (server/utils/webhooks.ts)

  • Pro+ tenants register up to MAX_WEBHOOKS HTTPS endpoints (Settings → Integrations, /api/webhooks, INTEGRATION_CONTROL scope) for article.published / updated / unpublished / deleted on the source-language article, and generation.succeeded / generation.failed for API generation requests. Published/updated payloads reuse the External API projection plus the public url; removals carry only id/slug/language, because a deleted row is gone by delivery time. Generation payloads carry the request (serializeApiGeneration) and, on success, only the draft's identity (id, title, slug, status, language, editVersion); the full draft is read with a WRITE key.
  • enqueueArticleWebhook writes one WebhookDelivery outbox row per endpoint with the payload frozen at enqueue time, then tries to send at once; webhook-deliver (every minute) retries after 1/5/30/120/720 min and prunes rows older than 30 days. Every publication path calls it (create, PATCH, delete, publish-check, generate-article, SEO autopilot write + rollback); a new path that changes a live article must too. Enqueue never throws into the article write.
  • A claim is a guarded updateMany to SENDING whose nextAttemptAt doubles as a 5-minute lease, so overlapping runs never double-send and a crashed send is retried (at-least-once; receivers dedupe on Topiqu-Delivery).
  • Topiqu-Signature: t=<unix>,v1=<hmac-sha256(secret, "t.body")>. The secret is @omit, returned only on create/rotate. Sends go through postPublicUrl (DNS-pinned, private ranges refused, redirects not followed) because the URL is tenant input. 20 consecutive failures switch an endpoint off (disabledAt); re-enabling resets the streak.

Site widgets (shared/utils/widgets.ts)

  • Deleting from a widget card or its editor uses the shared useConfirm dialog with the widget's title. Approval removes the draft by its stable key; the existing Save action persists the change. Cancel leaves the widget and editor intact.
  • SiteWidget rows (Settings → Widgets, GET/PUT /api/widgets, TENANT_SETTINGS scope). PUT replaces the tenant's whole set in one transaction; array order is position, and a body id that is not one of the tenant's live rows becomes a new row. Widgets left out are soft-deleted (deletedAt), and the audit entry lists their ids and kinds. config is Json validated per kind.
  • slot is a string key of WIDGET_SLOTS, not an enum. The layout owns the slots; when a redesign drops one, map it in RETIRED_SLOTS so its widgets move, and resolveWidgetSlot skips anything unknown. Pages render a slot with <WidgetSlot name>: HOME_TOP / HOME_MIDDLE / HOME_BOTTOM in pages/index.vue, ARTICLE_END in the article footer, FOOTER in the layout. At most MAX_PER_SLOT active widgets each.
  • Active widgets ride in publicClientSiteSelect, so rendering costs no extra request. Both routes that return it pass them through publicWidgets, which drops kinds the plan lacks and strips the HTML body. Plan limits apply at render, not on PUT, so a downgraded tenant keeps and can still save its widgets.
  • ANNOUNCEMENT (all plans) renders as Widget/Bar.vue above the content, only on the publication shell. Time windows are evaluated at render, not stored as state. widgetsInputSchema refuses two active bars that share a language and overlap in time. Closing writes announcementKey (id plus a hash of text and link) to the topiqu_bar cookie, read during SSR, so a closed bar is never rendered and an edited one shows again.
  • DIVIDER, CTA, PRICE, WEATHER need Pro+. A CTA image is a MediaAsset id; PUT checks the asset belongs to the tenant and writes its URL itself.
  • PRICE and WEATHER load client-side from /api/widgets/:id/data, which reads the stored config, so it is no open proxy. Providers (server/utils/widgetData.ts) are keyless and cached in Redis: Frankfurter/ECB for fiat (1 h), Coinbase exchange rates for crypto (5 min), MET Norway forecasts (30 min, coordinates rounded to 4 decimals). Forecast days follow the place's mean solar time (round(lon / 15) hours), not UTC; with no timezone database, a day boundary can be off by an hour or two (DST, political zones). The day's symbol is the six-hour summary nearest local noon. MET and Nominatim (the editor's place search, rate-limited per tenant) require the identifying User-Agent.
  • HTML needs Pro+ and a verified own domain: on *.topiqu.com a hostile script would put the platform domain's reputation at risk. The page never receives the body; <WidgetHtml> frames /widgets/:id (server/routes/widgets/[id].get.ts), which serves only on the tenant's own host, with CSP: sandbox minus allow-same-origin. The header, not just the iframe attribute, is what keeps the document opaque when opened directly. routeRules turns the global security headers off for /widgets/**, since its CSP would block the tenant's scripts. The frame reports its height by postMessage; the parent checks event.source (the origin is "null") and clamps it. server/middleware/opaqueOrigin.ts refuses cookie-carrying API writes with Origin: null.
  • Editor (Form/Client/Widgets.vue) groups widgets by slot, with the active count against MAX_PER_SLOT; arrows swap neighbours within a slot, and a widget moved to another slot goes to its end. A failed save names the widget by kind and slot, highlights and focuses its card. PRICE/WEATHER preview live data before saving via POST /api/widgets/preview (TENANT_SETTINGS, plan-checked, 30/min per tenant, same providers and cache); Widget/Price.vue and Widget/Weather.vue take that as a data prop instead of fetching. HTML has no inline preview: a srcdoc frame would inherit the admin CSP and block the tenant's external scripts, so the editor links to the saved, active widget's frame on the tenant's own domain instead.
  • Presets in the "add widget" gallery (app/utils/widgetPresets.ts) are only starting configs; nothing about them is stored.

WordPress integration (wordpress/topiqu-sync)

  • The installable Topiqu Sync plugin consumes the existing authenticated external API and stores source-language articles as native WordPress post rows. Remote identity and synchronization state live in protected _topiqu_* post meta; tags use WordPress terms and cover images are sideloaded into the Media Library.
  • Synchronization walks the complete paginated published collection in batches of 100. Only after every page succeeds without article-level errors does it reconcile missing remote IDs by moving their imported WordPress posts to draft. It never deletes posts or media, and a partial/failed API scan never unpublishes anything.
  • Identity includes the verified publication ID and normalized API URL. Source confirmation is explicit; changing credentials or endpoint cannot update or reconcile another source's posts. Ambiguous legacy posts require explicit adoption, processed in resumable batches of at most 500 while synchronization remains paused. Per-page identity lookup and complete-scan membership use a batched query/set; reconciliation walks 100 local IDs at a time with a keyset cursor.
  • Update policy is tenant-configurable: safe hashes the last imported title/slug/excerpt/body and preserves a locally edited post, overwrite makes Topiqu authoritative, and new_only imports once. _topiqu_updated_at avoids unchanged writes.
  • Settings, connection testing, manual runs and the last-run summary are under Settings → Topiqu Sync. Automated runs use a locked WP-Cron event at 15-minute, hourly or daily intervals; production sites can invoke that event from a real system cron. The non-autoloaded durable lock records an owner and heartbeat, with owner-checked compare-and-swap updates/release. It never expires into automatic takeover; recovery requires the expected owner, a heartbeat older than 15 minutes and explicit confirmation that the worker stopped. API credentials are stored as a non-autoloaded WordPress option and are never written to the sync log.
  • Uninstall removes plugin options, durable/legacy locks and the schedule but deliberately retains imported content. Packaging output is wordpress/dist/topiqu-sync.zip and is generated from wordpress/topiqu-sync/. Native WordPress/MySQL verification passed 32 behavior assertions plus one aggregate PASS, including an actual killed-worker recovery; target versions, isolation and cleanup are recorded in the dated verification record.
  • The settings integration catalog uses minimum-plan lanes (Pro and Premium) with explicit included/locked states derived from the tenant's current plan. It defaults to “available to me” and supports text and plan filtering; locked lanes remain discoverable through the other filters but cannot open their settings. Service cards use their brand colors. Google Ad Manager is its own Pro-lane card and dialog; as a field appended to the Google Analytics dialog it was unreachable by the catalog filter, which matches only card title + description — its description therefore names both “Google Ad Manager” and “GAM”. WordPress retains its product description and links to the published Topiqu Sync plugin on WordPress.org; the catalog also exposes the existing Google Search Console OAuth connection. Regenerating an existing API key requires destructive-action confirmation because the old key is revoked immediately.
  • by-clientsite/[slug] and featured/[slug] may carry likedByUser only because their 10-min shared cache is reached without a session (tests/server/articles/feedPresentation.test.ts pins that condition).
  • One anonymous identity: server/utils/anonSession.ts. Writes issue the anon_session cookie, GETs only read it — a minting GET hands one to every crawler. Reactions must never take it from the body as f4ece21 did with a FingerprintJS visitorId: the caller picks that value, so it farms freely and never matches what reads resolve. Abuse budget is consumeRateLimit on ipKey(event) in reaction.post.ts, create path only.
  • Client/PreferencesGuide.vue renders the live prompt block from server/utils/ai/topic.ts, so an empty brief field shows the server fallback instead of prose about it. Its per-field message keys are built at runtime, past i18nCompleteness; tests/unit/preferencesGuide.test.ts covers them.
  • Unsaved changes are communicated by app/components/UnsavedBar.vue — one body-teleported floating bar shared by /settings and /uzivatel; settings navigation does not add a second route-leave confirmation. The opaque warning-bordered panel wraps its actions on mobile. VueUse measures its border-box into --topiqu-unsaved-bar-height; Client/Version.vue moves its floating stack above that reservation below 90rem (wider, the centred bar cannot reach it), and settings reserves matching bottom space. Unmount clears the CSS variable directly because VueUse's watcher is disposed with the component. Its discard action asks nothing: useDiscardChanges resets the form and offers the edits back from a toast, since they are still in memory. Shared destructive confirmations use Nuxt UI's portaled, scrollable modal overlay so viewport centering comes from the overlay grid rather than manual coordinates.
  • Comment/index.vue keeps Reply and an ellipsis dropdown in the header; report, ban, own/moderator deletion and unban remain permission-derived menu items with their existing mutation boundaries. Reporting opens a scrollable form with a required 160-character reason, character count and input focus after opening; request errors retain the reason for retry. Browser regression coverage is in tests/e2e/comment-actions.spec.ts for long names and nested replies at 320/360/768 px.
  • Feature cards emit intent; useClientFeatures owns one confirmation, pending state and PATCH, including dependent deactivation and catalog-backed paid-feature terms. FEATURE_MIN_BILLING_DAYS in server/utils/planFeatures.ts drives both the actual activation lease and the /api/features metadata shown in confirmation; the catalog also exposes annualBillingPrice from the shared annualFromMonthly policy, so cards and confirmation do not recompute annual pricing in the browser. Billing permission and a loaded paid-feature catalog gate card interaction. Success calls useCacheInvalidation.invalidateClientFeatures even after leaving settings: shared site/status, client lists, visibility and stats refresh together. Refresh failure does not misclassify an already persisted change as failed. reconcileClientSettingsForm rebases untouched fields from the server while retaining local edits. /api/clients/status returns only active feature codes; visibility opportunities react to that shared ref, switching between Enable AI and Create/Improve article without changing the separate Premium/Custom monitoring entitlement.

6. Data Model

  • Authored in ZenStack, split by domain: a thin root prisma/schema.zmodel (datasource/policy plugin + imports) pulls in prisma/models/*.zmodel (base, article, poll, client, user, comment, notification, linkedin, log). Enums colocate with their domain; cross-domain enums + reusable Base/Ownable types live in base.zmodel. bun zenstack:generate writes the runtime schema and TypeScript types to ignored generated/zenstack/; shared/databaseSchemas.ts derives server-side Zod v4 model validators from the same metadata. Browser-used language/theme schemas live in shared/siteSchemas.ts so the 320 kB runtime metadata stays out of client chunks; tests/unit/siteSchemas.test.ts pins those option lists to the generated enums. Imports must be at the top of each file; cross-file relations need explicit imports (ZenStack resolves symbols only over a file's transitive imports).
  • Migrations in prisma/migrations/.
  • bun build pipeline: zen generate → nuxt build. Releases apply existing SQL migrations separately with bun db:migrate:deploy (zen migrate deploy).

Polls

  • ArticleView is the only event table with a non-nullable identity column (sessionId). That is deliberate: ArticleShare, ArticleReaction and ArticleFeedback all put nullable userId/sessionId in their @@unique, and Postgres treats NULLs as distinct, so those indexes never fire and the handlers carry the dedup alone. Copy ArticleView's shape, not theirs.
  • Fully normalized: Poll (question, order) → PollOption (label, order) → PollResult (vote). All cascade-delete from Article. PollResult references both Poll and the chosen PollOption by real FK (+ articleId kept denormalized so engagement stats use Article._count.pollResults). Two @@unique constraints — (pollId, userId) and (pollId, sessionId) — enforce one vote per identity at the DB layer.
  • Poll blocks are authored as Tiptap nodes (extensions/poll.ts + extensions/Poll.vue) and serialized into Article.content as <div data-type="poll" data-poll-id data-question data-options>, where data-options is a JSON array of { id, label }.
  • Product cards and X posts (shared/utils/articleEmbeds.ts, extensions/productCard.ts, extensions/xPost.ts) store their own fallback, so Markdown, RSS, Shopify and WordPress exports need no code for them:
    • <div data-type="product" data-product-id data-url data-name><p><a href>name</a></p></div> holds a reference only. server/utils/productCards.ts → resolveProductCards (article GET) loads the products of the article's clientSiteId, overwrites every data-* from the row (name, URL, price, availability, cdnImageUrl) and unwraps a deleted or out-of-stock product to its link.
    • <blockquote data-type="x-post" data-url data-author data-text [data-media]> + quote paragraphs + — author (@handle) link is a static copy: no widgets.js, so no third-party script, cookies or layout shift. The date comes from the snowflake id.
    • The escaping matches the editor's DOM serializer, so both round-trip unchanged (tests/unit/tiptapEmbeds.test.ts). The block readers accept https URLs only (X: canonical x.com/{handle}/status/{id}) and leave anything else as plain HTML.
    • Editor endpoints (ARTICLE_WRITE scope): GET /api/knowledge/products (search, in stock), POST /api/knowledge/products/[id]/card (copies the image, per-site rate limit), POST /api/embeds/x-post (server/utils/xPost.ts → fetchXPost: publish.x.com/oembed, redirect: 'error', rate-limited per user and IP).
    • AI posts: buildArticleConfig offers [[XPOST1]] only for post URLs found verbatim in the research brief (findXPostUrls). finalizeArticle fetches the oEmbed alongside the images; a deleted or private post drops its slot.
  • On article create/edit, server/utils/articlePolls.ts → syncArticlePolls reconciles the embedded blocks with the Poll/PollOption rows and stamps each block with server-assigned ids (poll id on the block, option ids inside data-options). Question/labels stay mirrored in the HTML so the editor round-trips and client rendering needs no DB read; the DB holds the stable ids votes key off. Option/label normalization lives in shared/utils/polls.ts (normalizePollOptions).
  • syncArticlePolls is mandatory on every write path that persists Article.content — it is the only code that creates Poll/PollOption rows and the only code that mints the ids the vote endpoints key off, so skipping it yields a poll that is unvotable in both directions at once. finalizeArticle emits only a cosmetic data-id and label-only options, so the generate-article cron (which writes AI output straight to the row) must run the same sync + sanitizeHtml as articles/index.post.ts and [id]/index.patch.ts; it previously did neither, shipping polls whose every click was a silent no-op (Poll.vue bails before $fetch on a missing option id, and the GET counts endpoint happily returns an empty tally for an unknown pollId, so the widget looked live). scripts/backfill-polls.ts repairs rows written before the fix (dry-run by default, APPLY=1 to write). Covered by tests/server/articles/articlePolls.test.ts.
  • The editor rewrites data-options on every keystroke (Tiptap/Editor.vue → validateContent, fed by onChange), so that pass must round-trip the option ids and be idempotent. It went through shared/utils/polls.ts → pollOptionsAttr (tests/unit/polls.test.ts) instead of an inline String(x) that assumed the legacy string[] shape — that wrote [object Object] over every label and dropped the ids, which Poll.vue then rendered as unvotable buttons at 0%. It hit any poll opened in an editor, most visibly the translation reviewer, since a translated article is edited there far more often than the source is re-saved.
  • Votes key off optionId (not the label text), so renaming an option never splits counts and removing one cleans up its votes via cascade. Vote endpoints: server/api/articles/[id]/vote.ts (GET counts via groupBy) + vote.post.ts (cast; maps PostgreSQL unique violation 23505 to 409).
  • Render: Article/Parsed.vue (client-side parse) → Article/Poll.vue (votes by optionId); homepage "latest poll" via extractPollData in by-clientsite/[slug].ts. Both require data-poll-id and do not fall back to data-id: that attribute is cosmetic, never a vote target, and the fallback's only effect was to render a dead widget instead of surfacing the missing sync.
  • Legacy note: the 20260527130000_polls_normalized migration wipes any pre-existing PollResult rows (old text-based votes couldn't be remapped to option ids without parsing HTML) — a one-time, intentional reset.

Article Translations

Automated AI translations turn the mono-lingual Article into a multi-lingual one via an ArticleTranslation sidecar (1:N from Article, cascade; denormalized clientSiteId). The source Article stays the canonical carrier of the primary language + all language-neutral data (views, comments, reactions, polls-as-entities, series); translations are pure per-language renditions. Migration 20260604120000_article_translations.

  • Lifecycle as queue. status: PENDING → TRANSLATING → READY → PUBLISHED (+ STALE on source edit, FAILED, SKIPPED by smart selection). Body fields (slug/title/content) are nullable — a queued row is a translation request without a body until the cron fills it. @@unique([articleId, language]) (one row per language) + @@unique([slug, clientSiteId, language]) (localized slug namespace, separate from the source's @@unique([slug, clientSiteId]) — locale-scoped routing means they never collide).
  • Config (ClientSite). translationMode (OFF/MANUAL/AUTO/HYBRID) + translationLanguages[] (ordered; empty = none, since 2026-11-01 — see Content languages and the translation allowance below). Set in the AI preferences form (Form/Client/AI.vue → common.preferences.translation.*). Gated on the active AI ClientFeature + plan PRO/PREMIUM/CUSTOM (see §6b). Target resolution lives in one place — translationQueue.ts → resolveTargetLanguages, shared by the enqueue cron, the review panel's endpoint and the manual translate endpoint, tested in tests/server/ai/translationQueue.test.ts. Migration 20261101120000_content_languages wrote the old "empty means all" set out for sites that were translating, so the rule change stopped nothing silently.
  • Engine (deterministic). server/utils/ai/translate.ts → generateTranslation uses aiModel('translation') (GPT-6 Luna with explicit medium reasoning, up to 20k tokens for the translated output plus 8k of middleware headroom for reasoning). Poll/embed/img blocks are masked out with cheerio before the model sees them (maskContentBlocks) — data-poll-id/optionId/image src never travel through the LLM as free text; poll question+labels, image alt/title, and human-readable attributes on surviving elements (title/aria-label, e.g. link tooltips — replaced in-place with [[ATTR_n]] tokens) are translated as ordered structured fields and zipped back onto server-held markup on rebuildContent (empty/absent alt stays untouched → decorative images preserved; attribute values are HTML-escaped on re-injection so a translated "/& can't break out of the attribute). Twitter embeds, X posts and product cards restored verbatim. Pure of billing — the caller logs usage via recordAiUsage('TRANSLATE_ARTICLE').
  • Two trigger paths. (1) On-demand POST /api/articles/[id]/translate (MANUAL/HYBRID); (2) cron server/tasks/translate-pending.ts (every 5 min) drains PENDING/STALE with a per-row atomic claim (guarded updateMany → TRANSLATING) so concurrent runs never double-translate — AUTO → PUBLISHED, HYBRID → READY (awaiting review), out-of-budget rows release back to PENDING. Enqueue/STALE is wired into all four publish paths via server/utils/ai/translationQueue.ts → syncArticleTranslationQueue (articles/index.post, [id]/index.patch, publish-check, generate-article). STALE keys off an explicit content-change signal, not Article.updatedAt (bumped by the view counter), to avoid token churn.
  • Smart selection (ClientSite.translationSelection: SMART default, ALL). Before a language's first AI translation the cron calls server/utils/ai/marketFit.ts → assessMarketFit (Haiku, article text and both keyword markets) and stores ArticleTranslation.marketFit: TRANSLATE/ADAPT/SKIP, a reason in the source language, localPassages. SKIP → SKIPPED with no translation. ADAPT passes the passages to generateTranslation, which may generalise them or name the source country but never substitutes another country's facts.
    • Rows with text are never re-judged, and a failed assessment translates anyway. SKIPPED is never re-enqueued or marked STALE; the manual endpoint translates it without re-judging (editor: "Translate anyway"), and switching to ALL requeues every SKIPPED row.
  • Shared slug dedupe. server/utils/ai/translationSlug.ts → dedupeTranslationSlug (used by both endpoint and cron). SEO rendering: see §8.
  • Every endpoint the article page calls must resolve the slug the same way (server/utils/articleBySlug.ts → resolveArticleBySlug, tests/server/articles/articleBySlug.test.ts). On a non-primary locale the URL carries the ArticleTranslation slug, which does not exist on Article at all. [id]/index.get.ts handled that; [id]/related never did, and its findUniqueOrThrow turned it into an unhandled 500 on every /en article view — latent until publishing a translation plus the mismatched-locale redirect made translated slugs actually reachable. The resolver is now shared: translation first (PUBLISHED, or any status for an admin), source row as fallback for an untranslated locale, null → 404 rather than P2025. related also localizes its cards via localizeArticles and is scoped to clientSiteId — Tag.clientSiteId is nullable, so a global tag matched articles across tenants.
  • Translation review. READY means "awaiting review". The editor's translation menu and language tabs expose the review state and actions for saved articles; approving publishes the translation. The disconnected admin queue banner was removed along with its unused locale keys.
    • Dirty tracking treats empty editor HTML (<p></p>, blank paragraphs) and an empty untranslated body as equivalent, using hasArticleBody; real text, media and formatting edits still count. translateNow confirms before replacing unsaved text and fences that confirmation to the same language, row version and draft. An untouched missing translation starts immediately and remains visible through background polling.
    • Endpoints: GET /api/articles/[id]/translations (rows + targetLanguages + mode), GET /api/translations?status= (tenant queue), PATCH /api/translations/[id] (edit / approve), DELETE /api/translations/[id] (discard).
    • These endpoints pin clientSiteId explicitly on top of the ZenStack policy. ArticleTranslation's read rule is status == 'PUBLISHED' || (admin && own site) — the first clause is deliberately unconditional so public article pages can render translations, which means a bare policy-scoped query would have let one tenant's admin list other tenants' published translations. The policy is right for the public path and wrong as the only isolation for an admin listing; both layers apply here.
    • A human edit flips source to HUMAN — the audit column must stop claiming a machine wrote text a person rewrote. PUBLISHED is refused unless title+content exist, so a body-less queue row (PENDING/TRANSLATING/FAILED) can't be published into a blank localized page with a hreflang pointing at it.
    • Language is a dimension of the article, not an appendix. The reviewer used to be Article/Translations.vue, a panel at the very bottom of the editor — below a Tiptap body of unbounded height, so nothing said it existed. That component is gone. admin/editor/[id].vue now carries Article/Editor/LanguageTabs.vue (a segmented control in the sticky header, stacked below it under sm): the source tab plus one tab per target language with a status dot. Selecting a language swaps the same title/excerpt/body fields over to that translation, and the header's Save/Publish become Save / Approve & publish acting on it. Language-neutral surfaces — AI composer, cover uploader, MetaBar, drafts — render only on the source tab, because they belong to the article rather than to one rendition of it; the translation tab shows a status strip (source, timestamp, unsaved marker) with Translate again / Discard instead.
    • app/composables/useArticleTranslations.ts owns that state: activeLang === '' means the source, so the page branches on one flag instead of tracking two ideas of "current language". It is reactive()-wrapped at the call site so the template reads tr.isSource rather than tr.isSource.value. The page routes the shared textareas through titleModel/excerptModel/bodyModel computeds, so useTextareaAutosize stays bound to one thing while the underlying target changes. hasChanges folds in tr.isDirty, or leaving the editor would silently drop a rewritten translation.
    • Pure logic lives in shared/utils/articleTranslations.ts (tests/unit/articleTranslations.test.ts): translationHasBody (the condition Save renders on), isTranslationDirty (the condition it is enabled on), countAwaitingReview, resolveActiveLanguage, and translationStatusDot. resolveActiveLanguage also fixes a latent bug in the old inline watchEffect — it picked the first READY row without checking it is still a configured target, so a leftover translation for a language dropped from settings selected a tab that is not rendered.
    • Listings are locale-aware (shared/utils/articleLocale.ts + server/utils/articleLocale.ts, tests/unit/articleLocale.test.ts). Until now neither by-clientsite/[slug].ts nor featured/[slug].ts touched ArticleTranslation at all, so an /en visitor got Czech titles linking to Czech slugs and a published translation was reachable only via hreflang or Google — tokens were being spent on pages nobody could click to. Both endpoints now take locale and overlay each card with its PUBLISHED translation. overlayTranslation only swaps when the row has both slug and title, since localized text pointing at the primary-language URL is worse than no translation; the excerpt may fall back. Two things to keep in mind: the feed's Redis key carries :loc= (the cached payload is localized, so two locales must not share an entry), and localizeArticles filters status: 'PUBLISHED' explicitly rather than trusting the ZenStack policy, whose read rule also passes for an admin on their own site — otherwise a logged-in admin would see unreviewed drafts in the public feed.
    • Navigating between language versions is one component, Article/LanguageLinks.vue, on three surfaces. target="public" renders localized NuxtLinks to each version's own slug; target="editor" points every entry at the source article (articleRef) with ?lang= picking the tab, because the editor resolves the article by its source slug and the translated slug would 404 there. Dots (translationStatusDot) render only for the editor target — on the public page every entry is published, so a dot would encode nothing.
      • Article detail — gated on hasTranslations (alternates.length >= 2), the same condition as hreflang, so a language is never offered that would only fall back.
      • Admin Article/Table.vue — a languages column, not extra rows: the bloat guard from §6b holds. articles/search.ts gained include: { translations: { select: { language, status, slug } } }, and drafts are visible here on purpose (it is the owning admin's own list, and "awaiting review" is the point). The cell is one Article/TranslationMenu.vue trigger (CS +3, a dot only for FAILED/READY/STALE); its dropdown lists every language with its status as text, opens that editor tab, and holds translate/retranslate — the row's action menu no longer does. The actions column includes an explicit eye action linking to the public article on both desktop and mobile; title and thumbnail remain links as larger hit targets. On the article detail, ArticleStatusCell is the single status presentation and emits its update directly to ActionsBar — a second published badge would duplicate both label and meaning.
      • Editor — ?lang= is read on load (primary language ⇒ source tab, which the composable stores as ''), plus an mdi:open-in-new link to the live page of whichever language is on screen. It is passed as useArticleTranslations(id, initialLang) rather than assigned to activeLang afterwards, and the reconciling watchEffect is gated on status === 'success'. Both matter: an empty targetLanguages means either "this site has no targets" or "the request has not resolved", and reconciling against the second silently reset ?lang=en to the source tab before the payload arrived — the deep link looked like it did nothing.
    • SEO framing that drove the above: translated pages are not duplicate content — that is what hreflang is for, and the untranslated-locale fallback already canonicalises onto the primary URL. The real Google exposure is scaled content abuse (bulk machine translation published without review), which makes HYBRID the safe mode and AUTO the risky one. The review step is therefore load-bearing, not polish, and anything that makes approving expensive pushes tenants onto AUTO.

Content languages and the translation allowance

Since 2026-11-01 the language registry has two tiers (shared/siteSchemas.ts). UI_LANGUAGE_OPTIONS (en, cs, de, fr) is the fully translated interface: dashboard, emails, server messages, sign-in and every account. LANGUAGE_OPTIONS adds the content-only languages es, it, pl, sk, nl and pt, which sites, articles, translations, knowledge sources and keyword plans can use. The schema mirrors the split: enum Language (content) and enum UiLanguage (User.language), so a type error marks every place that mixes the two. uiLanguageOf maps anything else to an interface language for emails, billing and OAuth return URLs and server messages.

  • Variants. es is Spain's Spanish and pt European Portuguese: the writer prompt (LANGUAGE_NAMES), the hreflang tag (LANGUAGE_TAGS) and the keyword market (LANGUAGE_MARKETS, home market only) must agree on one variant. Latin American Spanish or Brazilian Portuguese would be separate languages.
  • Public blog. Content-only locales are real @nuxtjs/i18n locales with their own route segments (nuxt.config.ts::i18n.pages, mirrored in shared/utils/routes.ts), so articles get /es/articulos/… URLs and proper hreflang. Each ships only i18n/locales/<code>/reader.json: the keys the public pages actually use, found by scripts/reader-i18n.ts (walks the reader pages, the components and helpers they reach, and collects key literals). i18n/i18n.config.ts::fallbackLocale fills the rest from English (Slovak from Czech); interface locales have no fallback, so they load nothing extra. tests/unit/readerLocales.test.ts holds every reader.json to exactly that key set, with placeholders intact.
  • Everything else stays in the interface. app/middleware/content-locale.global.ts lets a content-only locale render the homepage, article, tag and author pages only; any other route redirects to the same page in the account's interface language (then the preference cookie, then English). Sign-in therefore has no content-only segment (AUTH_SEGMENTS is UiLanguage-keyed).
  • hreflang on listings. app.vue drops useLocaleHead alternates for languages the site does not publish (primary + translationLanguages); ten locales would otherwise advertise translated chrome around the same articles. Article pages keep their own alternates from the published translations.
  • Slugs, search, fonts. All current content languages use the Latin script, which slugify, the simple + unaccent knowledge search and the Latin font subsets already handle. A CJK or right-to-left language would need slug transliteration, word segmentation, fonts and typography first.

Translation allowance. shared/utils/translationLanguages.ts is the single source: INCLUDED_TRANSLATION_LANGUAGES (FREE 0, PRO 2, PREMIUM 5, CUSTOM 5) plus ClientSite.extraTranslationLanguages gives translationLanguageLimit. activeTranslationLanguages keeps the first limit of the ordered selection, so a downgrade pauses the last-picked languages instead of rewriting the tenant's choice; the settings UI (Form/Client/TranslationLanguages.vue) marks them paused. It is enforced server-side in the queue (resolveTargetLanguages), the manual AI translate endpoint (403 common.translationLanguages.notIncluded) and the settings PATCH (403 overLimit unless the change only removes or reorders). Human-written translations (POST /api/articles/[id]/translations) cost no AI and stay unlimited. /api/clients/status exposes only the active languages, which the article table's translation menu offers.

Extra languages ($9/month each, EXTRA_TRANSLATION_LANGUAGE_PRICE_USD). PRO/PREMIUM buy them as a separate Stripe subscription with a quantity (STRIPE_PRICE_TRANSLATION_LANGUAGE, metadata kind: TRANSLATION_LANGUAGES): the Customer Portal can only cancel, not change, a subscription with several products, so an add-on item on the plan subscription would end self-serve plan switching. POST /api/stripe/translation-languages (BILLING_CHANGE) opens Checkout for the first purchase and later updates the quantity with proration, or cancels at 0. PRO/PREMIUM on Stripe billing only (trials included; CUSTOM and Shopify billing are refused). Every way back to FREE cancels the add-on: revokeToBasic, downgradeExpiredTrial and a superadmin plan change (cancelTranslationLanguageSubscription acts only on BASIC). A first purchase without a plan subscription creates the Stripe customer, which the mirror stores for later checkouts. In the UI, clicking a language beyond the allowance opens the purchase dialog for one more and selects it afterwards; Settings → Billing has the same control. server/utils/translationLanguageBilling.ts mirrors the subscription onto the site; every plan handler in stripe/webhook.ts returns early for the add-on's events, and revokeToBasic cancels the add-on so FREE is never billed for languages. On CUSTOM an operator sets extraTranslationLanguages (superadmin PATCH) and recalcFeatureBilling adds it to the à-la-carte total.

6a. Plan Matrix (ClientSite.plan — enum ClientPlan)

Marketing names diverge from the DB enum: marketing FREE = enum BASIC. Enum is the source of truth (prisma/schema.zmodel:84); the pricing copy now lives in the separate ../landing project. Tenant-facing surfaces (billing, the dock badge, upgrade CTAs) print planName(plan) from shared/utils/plans.ts; superadmin and dev views keep the enum. Superadmin client creation (POST /api/clients) syncs plan features and gives PRO/PREMIUM billingPlan: PERMANENT (manualBillingPlan): without an invoice, only a permanent contract funds the AI budget.

A literal @ in any i18n message breaks the production build. vue-i18n reads @ as the start of a linked message (@:key), so an e-mail address in a message fails with Invalid linked format. It only surfaces under jit: true, which the build uses and neither typecheck nor vitest does — write &#64; instead (these messages render through v-html, so the entity decodes in both the link text and the mailto: href).

Legal texts (i18n/locales/*/legal.json, rendered by pages/privacy.vue / pages/tos.vue; the cs/en copies) are a duplicate of the same file in ../landing — edit there and copy across, or the two sites drift. The two copies differ only in line endings (app LF, landing CRLF), so compare with diff --strip-trailing-cr. The [[BRACKETS]] placeholders are filled (as of 10 Aug 2026); the ARES link is built from the IČO, so both change together. The postal address stays out on purpose — ARES carries it. The processor list names no hosting provider on purpose — app, DB and cache run on own hardware, so there is no hosting processor to disclose under Art. 13(1)(e), and the "transfers outside the EEA" paragraph therefore states no baseline region. The LinkedIn section is written to match the retention limits in the LinkedIn API Terms (profile ≤24 h, social activity ≤48 h) — changing what server/utils/linkedin/ stores means changing that section too. The Search Console section carries what Google OAuth verification for webmasters.readonly requires (scope, data, purpose, Limited Use link, deletion); keep it when trimming. AI vendors are named as the category "AI model providers", so swapping a model needs no policy change. Section numbers are plain text: keep them sequential.

Who may write which ClientSite field. shared/utils/clientSiteFields.ts is the single source: PRIVILEGED_CLIENT_SITE_FIELDS (plan, tokenLimit) are superadmin-only; everything in TENANT_EDITABLE_CLIENT_SITE_FIELDS, including a tenant's own gamNetworkCode, is fair game for a site's own admin. server/api/clients/[id]/index.patch.ts builds a Zod pick from the applicable list, so a tenant's privileged extra keys are silently stripped rather than rejected. The partition is pinned by tests/unit/clientSiteFields.test.ts; it validates field names against generated ZenStack schema metadata. Reactivating a soft-deleted site (deletedAt: null) is superadmin-only too; self-deactivation stays open. Side effects must read the parsed value, never raw body — requestedTokenLimit, not scalarBody.tokenLimit.

Who may read which ClientSite field. Same file, read side: PUBLIC_CLIENT_SITE_FIELDS → publicClientSiteSelect (a fieldMask, i.e. an ORM select) is the only projection any session-less route may return, and CLIENT_SITE_SECRET_FIELDS names what must never join it (the three stripe* ids, token quota + usage, billing amounts, communityInsight, AI prompt config, rate assumptions). Being a whitelist, a newly added zmodel field is private until someone lists it; tests/unit/clientSiteFields.test.ts walks generated ZenStack schema metadata to assert both lists only name real scalars, that they are disjoint, and that the generated select is exactly the public list. tests/server/clients/publicProjection.test.ts pins the routes themselves.

  • clients/slug/[slug].ts (public, hit by useClientSite() on every page → lands in the SSR payload) and clients/[id]/by-userid.get.ts (public, powers the author card) select through the whitelist. Both previously returned the whole row from raw prisma, i.e. every tenant's apiKey and Stripe ids were readable by curl-ing any blog. @omit would not have helped: these go through raw prisma, and ClientSite is @@allow('read', true) anyway, so the select is the control.
  • clients/index.get.ts is role: 'superadmin' via requireDb. It was session-optional with getEnhancedPrisma(user), and because the model's read policy is true that enhanced client happily dumped every tenant row plus each site's users' usernames and e-mails to anonymous callers — a reminder that enhanced Prisma only enforces what the policy says, and @@allow('read', true) says "everyone".
  • clients/[id]/index.get.ts (settings source, keeps Stripe ids for the owner; API keys live in ApiKey as hashes) now 403s unless id === user.clientSiteId or the caller is superadmin. The old check was ['superadmin','admin'].includes(role) with an attacker-controlled id — any tenant admin could read any other tenant's full row.
  • clients/status.get.ts is where owner-only entitlement data lives now (plan, articlesRemaining, articleWallet, createdAt, firstPaidAt, focus, audience, a server-derived trial summary, hasActiveSubscription, and theme/typographyPreset/discloseAiContent for the editor preview). The site id comes from the session (user.clientSiteId), never a route param, so it has no IDOR surface. Exposed as useClientSiteStatus() and consumed by the trial modal, article balance panel, editor reservation state and first-article checklist.
  • useClientSiteStatus() fetches through useRequestFetch() and returns the useAsyncData handle, not data.value. Both halves were bugs: bare $fetch during SSR sends no session cookie, so the endpoint 401'd, a .catch(() => null) swallowed it, and the null was baked into the payload the client never re-fetched — every consumer silently ran on fallback numbers for the whole session (fabricated quota, dead trial modal, dead AI gate). Returning data.value then froze the snapshot at setup time, so nothing re-rendered after a top-up. The same useRequestFetch() rule as the Colada queries above — it applies to any authenticated useAsyncData handler. Consumers must not reintroduce fallback constants (?? 20000): when the limit is unknown the quota UI hides rather than inventing a denominator, otherwise 0/20000 reads as 0 % and fires the low-token alarm on a full account.
Capability FREE (BASIC) PRO PREMIUM CUSTOM
Price (per month, USD) $0 $49 $99 On request (sales)
Stripe checkout — STRIPE_PRICE_PRO STRIPE_PRICE_PREMIUM Sales-led, no SKU
Revenue share Halted Halted Halted Halted
Manual article writing ✅ ✅ ✅ ✅
Subdomain ✅ (free) ✅ ✅ ✅
Custom domain ❌ ✅ ✅ ✅
Outgoing webhooks ❌ ✅ ✅ ✅
White-label (no Topiqu branding) ❌ ❌ ❌ ✅
AI article generation ❌ ✅ (token bundle) ✅ (token bundle) ✅ (unlimited)
AI sentiment + auto images ❌ ❌ ✅ ✅
Advanced SEO optimization ❌ ✅ ✅ ✅
Backlinks ❌ ✅ (mentions) ✅ (+ makers, drafts) ✅
Article import ❌ ✅ ✅ ✅
Priority indexing + sourcing ❌ ❌ ✅ ✅
Custom emojis & branding ❌ ❌ ✅ ✅
Own GAM ad banners ❌ ✅ ✅ ✅
Analytics Basic Basic + GA4 Basic + GA4 Basic + GA4
Support Community Standard Priority 24/7 Dedicated

Feature gates are checked via ClientSite.plan plus the ClientFeature rows (see §6b) — and, for non-AI capabilities, the remaining booleans on the model (allowGtag, allowShapes). The BillingPlans enum (MONTHLY / ANNUAL) is orthogonal to the plan tier.

6c. Trial has explicit provenance; plan and payment are separate facts

  • Settings saves and feature toggles share useCacheInvalidation.invalidateClientFeatures: besides live tenant/status data, it invalidates topic plans, content actions, backlinks, domain status, article lists, visibility and statistics. The persistent app head consumes the live tenant ref so saved metadata and publication languages update without a reload.

  • Changes to managed Topiqu subdomains remain free on every plan, including during and after a trial, and never depend on Stripe availability or create Cloudflare Custom Hostnames. Custom-domain connections require payment history on a non-Basic plan, a CUSTOM/non-Basic PERMANENT plan, a non-Basic plan without trial provenance, or an unexpired Stripe card attached to the tenant's customer. A former trial manually changed to PRO/PREMIUM still needs payment history or a card. server/utils/domains/billing.ts checks this on request, final verification and pending custom-domain reconciliation. A Premium free trial by itself is insufficient; custom domains still require a non-Basic tier. Billing outages fail closed for changes and remain distinct from a missing payment. Disconnect/cancel and existing active connections remain available. The UI receives only the capability, never payment-method details.

  • Disposable inboxes and their provider subdomains are rejected by onboarding, password registration and the OAuth account-creation hook. Verified existing accounts retain sign-in access; this is a registration restriction, not an account deletion policy.

  • Stripe paused subscriptions also revoke paid entitlements, retaining the subscription ID so a later active update can restore the plan. past_due continues to use the existing dunning grace.

  • checkout.post.ts creates every trial tenant on TRIAL_PLAN (= PREMIUM) and stamps immutable trialStartedAt + trialEndsAt; the trial exercises the whole product including the crons. ClientSite.plan is only the entitlement and firstPaidAt only records money movement — neither proves trial provenance. shared/utils/trial.ts is the one state machine (NONE | ACTIVE | EXPIRED | ACKNOWLEDGED) used by expiry predicates and server status. The browser receives only { state, endsAt, daysLeft }, calculated against server time, and never decides expiry from its own clock. Migration 20260913150000_explicit_trial_lifecycle backfills both timestamps only from historical TokenCreditGrant.source = TRIAL, so manually provisioned PREMIUM/CUSTOM tenants with no Stripe subscription can never receive a false expiry modal or cron downgrade. Ambiguous pre-wallet trials intentionally require manual reconciliation; customer protection wins over an inferred downgrade.

  • stripeWebhook.ts → marksFirstPayment: a trialing checkout moves plan but must not stamp firstPaidAt, or the trial ends the day it starts. Both checkout.session.completed and the subscription.updated trial-end path call syncPlanFeatures on whatever plan they resolve.

  • trial-expiry cron (*/10 * * * *) only touches explicitly marked, unacknowledged, card-less trials whose stored trialEndsAt elapsed. The stored deadline means changing TRIAL_DAYS affects new trials only. expiredTrialWhere also requires stripeSubscriptionId: null because Stripe owns a card-backed trial's lifecycle — promoting on conversion, revoking through revokesPlan on a failed first invoice — and a cron downgrade would race that webhook mid-conversion. tests/server/tasks/trialExpiry.test.ts pins the SQL filter against the state predicate; drift either strands tenants on the trial plan forever or risks downgrading a provisioned customer.

  • syncPlanFeatures at signup is deliberately non-fatal (try/catch, TRIAL_FEATURE_PROVISIONING_FAILED). It throws on an unseeded Feature catalog — the hazard already documented in §6b — and signup is the one path that must not depend on it; the plan column alone still unlocks the UI.

  • One trial per inbox, ten sign-ups per IP per day. The landing creates accounts server-to-server, so checkout.post.ts takes the visitor IP from X-Landing-Client-Ip, trusted only with a fresh X-Landing-Sig over timestamp.ip (landingClientIp); otherwise the request IP. inboxOwnsSite (server/utils/trialInbox.ts) compares the canonical inbox (+tags dropped, Gmail dots ignored) against existing site owners in SQL that must mirror canonicalEmail; a repeat inbox gets a BASIC account without trial articles or a Stripe trial (TRIAL_REPEAT_INBOX). It relies on users being soft-deleted — a hard delete or e-mail scrub would reopen the trial. Disposable domains are rejected here too; the landing's copy of the list only saves a code e-mail.

  • AI generation requires both an AI-capable plan and one available article. generate/index.post.ts checks hasAiPlan and then reserves through ArticleCreditWallet; scheduled generation additionally requires the cron feature. Trial expiry downgrades the plan, while its expiring article grant independently becomes unavailable.

  • end-trial.post.ts refuses tenants without an actually expired trial and stamps trialAcknowledgedAt when the author chooses "continue free". It never writes firstPaidAt, so dismissing trial UX cannot fabricate payment history. pages/admin/index.vue opens the expired-trial modal once per visit (useState) and refreshes clientsite-status after "continue free"; that snapshot is cached across navigations, so unrefreshed it keeps reporting EXPIRED.

6b. ClientFeature is the only source of truth for AI / Sentiment / Crons

Gating used to be split three ways and the halves disagreed, which is how a PREMIUM tenant ended up staring at a settings panel that showed AI, translations and scheduled generation all switched off — with a monthly price next to each — while the app cheerfully generated and translated in the background:

  1. ClientFeature rows → what the settings panel rendered (activeFeatures), written only by features.patch.ts; nothing created them on a plan grant, so a paying tenant had none.
  2. ClientSite.enableAi/enableCron/enableSentiment → what the translation endpoint and cron actually checked, written only at onboarding and never by the toggle.
  3. Bare plan checks → sentiment-analysis.ts; and generate-article.ts checked nothing at all beyond generationFrequency + tokens, so a tenant who switched AI off still got articles.

Now: ClientFeature decides, everywhere. The booleans are dropped (migration 20260806120000_plan_features_single_source, which also backfills rows for existing PRO/PREMIUM sites). That backfill is an INSERT … FROM "ClientSite" JOIN "Feature", so it silently inserts nothing when the Feature catalog is empty — and the catalog is written only by prisma/seed.ts, a dev seed that also mints test users with a hardcoded password and therefore never runs in production. A prod DB can consequently end up with the gating live and no ClientFeature row anywhere, which takes generate-article, translate-pending and sentiment-analysis dark at once while everything else keeps working. Symptom to recognise: crons stop touching tenant rows with no error in the log, because the batch query simply matches nobody.

  • planFeatures.ts → planFeatureSync(plan, active) is the pure decision (activate what the plan covers, revoke what a downgrade dropped, skip CUSTOM — that is the à-la-carte plan where operators pick by hand and billingLockedUntil carries real billing weight). syncPlanFeatures(tx, siteId, plan) applies it and is called on every plan transition: all stripe/webhook.ts paths (promote, portal plan change, revocation → BASIC), the superadmin plan edit in clients/[id]/index.patch.ts, and the dev switch _dev/plan.patch.ts — each inside the same transaction as the plan write.
  • syncPlanFeatures is "make this site consistent with its plan", not just "write feature rows." After provisioning/revoking it re-asserts the two invariants that outlive a feature: syncAutoRelease (see below) and recalcFeatureBilling. Anything that changes a plan therefore gets all three effects for free, which is why the dev endpoint was wired in — a locally switched plan used to leave features and billing behind and stop reproducing production.
  • Reads go through activeFeatureFilter(code) (a relation where for cron batch queries) or hasActiveFeature(db, siteId, code). Both take a narrow structural DB type, matching translationQueue.ts/translationSlug.ts, so either the base or policy-enhanced ZenStack client is accepted. The read path takes FeatureReadDb (clientFeature.count only); only the write path needs the wider FeatureSyncDb (adds feature + clientSite).
  • Backfilled/plan-granted rows get billingLockedUntil = now() (no lock): billableMonthlyTotal bills nothing outside CUSTOM, so the 30-day anti-gaming window is meaningless for an included feature. That is exactly why billability cannot key off the lock window alone — see billableFeatureWhere below.
  • autoRelease cannot outlive ARTICLE_CRONS (syncAutoRelease). The switch is hidden without scheduled generation (Form/Client/AI.vue) and costs a danger confirm to turn on, so leaving it true through a revocation meant a later re-subscribe silently resumed auto-publishing generated articles. Enforced on both the plan path (syncPlanFeatures) and the manual toggle path (features.patch.ts, after the AI cascade).
  • Enqueuing translations is itself gated on the AI feature (syncArticleTranslationQueue). translate-pending already refused to drain the queue without it, so a revoked tenant still on translationMode: AUTO kept piling up invisible PENDING rows on every publish — which a later re-subscribe would flush as one burst of months-old translations. features is optional in the structural QueueDb (a concrete Prisma row type has no such key and would not satisfy it otherwise); the select always asks for it and a missing value fails closed.
  • The Feature catalog is load-bearing and was seeded by hand. With gating derived from it, an empty catalog would silently switch every feature off for everyone, so syncPlanFeatures throws on a missing code instead of skipping it, and prisma/seed.ts upserts the three rows (update: {} — it never overwrites real prices).
  • Consequence to know: sentiment-analysis.ts used to run for plan IN (PRO, PREMIUM), but getAllowedFeatures only grants SENTIMENT on PREMIUM/CUSTOM. Gating on the feature makes the matrix authoritative and stops sentiment for PRO tenants — that drift was in the cron, not the matrix.
  • Panel side: Form/Client/AI.vue quoted a per-feature monthly price on every plan even though billableMonthlyTotal returns 0 off CUSTOM. Price now renders only for CUSTOM; everyone else gets an "included in your plan" badge (common.features.includedInPlan). The three near-identical 45-line toggle blocks collapsed into Form/Client/FeatureToggle.vue (a card in a lg:grid-cols-3 grid with a USwitch; active state is the inverted border and icon tile).

Feature toggles & dynamic pricing (AI / SENTIMENT / ARTICLE_CRONS)

  • Single source of plan→feature logic: server/utils/planFeatures.ts (pure, unit-tested) — getAllowedFeatures(plan) (AI+Crons on PRO+, Sentiment on PREMIUM+), FEATURE_DEPENDENCIES (Sentiment & Crons require AI), getMissingDependencies, getDependents, isCustomPlan (the à-la-carte plan — features billed individually), billableMonthlyTotal. Consumed by both clients/[id]/index.get.ts (allowedFeatures) and clients/[id]/features.patch.ts so the two never drift.
  • Two billing modes, both applied by the shared recalcFeatureBilling(tx, siteId, plan, billingPlan): standard plans (PRO/PREMIUM) → features are included, toggling is a free capability switch, monthlyPayment stays 0 (the plan itself is the Stripe charge). CUSTOM → à-la-carte: monthlyPayment = Σ billed feature priceMonthly``(USD);PERMANENTbillingPlan is comped to 0.`annualFromMonthly`carries the −20 % annual discount. Called from`features.patch.ts` and every plan transition, so the stored price can no longer drift from the plan.
  • What counts as billable is billableFeatureWhere = isActive: true OR billingLockedUntil > now, not the lock window alone. The window alone under-billed twice: a feature switched on past its 30 days stopped counting, and — the sharp one — a tenant moved from PREMIUM onto CUSTOM inherited all three features with billingLockedUntil already in the past, carrying them à la carte for free until someone happened to toggle something.
  • Server-enforced invariants (not just client gating, closing an IDOR + a broken-access-control gap): admin may only toggle their own clientSiteId (superadmin any); enabling a feature requires getAllowedFeatures[code] (else 403) and its AI prerequisite active (else 400); disabling AI cascades — getDependents('AI') are atomically disabled in the same transaction. Client mirrors this with a danger confirm modal (Form/Client/AI.vue → showAiDisableModal) before disabling AI while dependents are on.
  • Rate card is USD. Feature.priceMonthly holds USD; the whole display layer is USD-base: GET /api/currency?target= → server/utils/currency.ts → usdCrossRate (CNB daily table cached 6 h via defineCachedFunction, cross-converted via CZK, amount-normalized, unit-tested), useCurrencyRate returns a computed USD→target rate (1 for USD) — a ref, because a snapshot taken before the fetch settles froze the USD default into SSR, and Billing.vue/AI.vue multiply USD amounts by the rate (was CZK-base divide). CZK is now just another display currency.

Stripe wiring

  • Client: all handlers (subscribe, checkout, portal, webhook, onboarding/checkout) share the lazy singleton server/utils/stripe.ts → useStripe(), which reads STRIPE_SK and explicitly pins apiVersion: '2025-08-27.basil'. SDK dependency upgrades must preserve that external API contract; the installed SDK version is recorded in package.json/bun.lock. Lazy initialization keeps onboarding's no-key dashboard fallback from throwing at import.
  • Catalog validation and legacy offers: server/utils/stripePricing.ts defines the current PRO/PREMIUM amounts in cents. Pricing reads, configured monthly checkout and annual onboarding reject inactive/wrong-mode products or Prices, incorrect amounts/currencies/intervals, transformed quantities and metered usage before checkout; the monthly no-key dashboard fallback remains. STRIPE_LEGACY_PRICE_PRO_IDS / STRIPE_LEGACY_PRICE_PREMIUM_IDS are comma-separated retired Price IDs recognized only for existing-contract webhook reconciliation, never offered to new checkout. The live PRO catalog was corrected without changing existing subscriptions; rollout instructions are in docs/operations/stripe-catalog-rollout.md.
  • Staged provider configuration: STRIPE_PORTAL_CONFIGURATION selects the prepared non-default portal configuration after deployment, keeping older deployments on their original portal settings. STRIPE_WEBHOOK_SECRET_NEXT permits a second signature during migration to the separately staged, disabled Basil endpoint. The current primary secret and endpoint remain usable until operator cutover. Verification uses await constructEventAsync because Bun resolves Stripe's worker export and its SubtleCrypto provider cannot verify synchronously. Both signatures retain raw-body and timestamp verification; Checkout Session / Invoice IDs retain grant idempotency. Secret values never enter public configuration or documentation.
  • Current commercial pricing is PRO $49/mo and PREMIUM $99/mo (USD). The recorded sandbox catalog still contains the older Pro $39/mo (price_1TsL6URaW639ixKzBpb6SqlR, prod_Us44PFOun811GC) and Premium $79/mo (price_1TsL6VRaW639ixKzUTRL2PRg, prod_Us44yt7w1ngzti) recurring prices; those IDs are historical test fixtures, not the current price source. STRIPE_PRICE_PRO / STRIPE_PRICE_PREMIUM must point at prices matching $49/$99 in the deployed Stripe mode. Earlier CZK attempts and the original 490/990 Kč prices are archived on the same sandbox products.
  • Historical annual sandbox prices (−20 %, based on the old $39/$79 monthly prices): Pro = price_1TsLMoRaW639ixKzZBlbfJWZ ($374.40/yr), Premium = price_1TsLMpRaW639ixKzQlOpJZQB ($758.40/yr). They do not represent current $49/$99 pricing. Landing onboarding now accepts billingInterval and checks the selected annual Stripe Price before creating an account: Pro $470.40/yr, Premium $950.40/yr, active USD/year. Deployed annual price IDs must match those amounts; otherwise annual onboarding returns 503.
  • Subscription checkout: POST /api/stripe/subscribe (mode subscription). Body plan + optional interval (month/year); when interval is absent it falls back to the site's billingPlan === 'ANNUAL' ? 'year' : 'month'. interval is stamped into checkout + subscription metadata.
  • Article packs: POST /api/stripe/checkout (mode payment, ad-hoc price_data). Packs are a server-side catalog (shared/utils/articlePacks.ts, getArticlePack) — 5 articles/$8.99, 12/$14.99 and 25/$22.49. The client sends only a pack id; price and article quantity are resolved server-side.
    • Customer entitlement is ArticleCreditWallet, not provider tokens. Trial grants 5 article units until trial expiry; PRO grants 50 and PREMIUM/managed CUSTOM 100 per monthly allowance period. Purchased articles never expire. Each manual, scheduled or API generation atomically reserves 1/2/3 article units for STANDARD/HIGH/ULTRA, which prevents concurrent overspend; success debits it and errors/cancellation release it. A 30-minute lease recovers abandoned reservations. balance - reserved is the visible balance. Spend order is expiring grants, then carried ROLLOVER grants, then purchased/admin ones. When a PLAN grant expires, expireAvailableGrants moves its unused units into a non-expiring ROLLOVER grant up to articleRolloverCap(plan) (2× the monthly allowance); the same pass trims carried units above the cap of the current plan, so BASIC drops them. Annual plans get a monthly grant from grant-annual-article-credits. An upgrade grants only the difference and a downgrade claws nothing back; an issued grant keeps its amount and policy version. Provider tokens remain telemetry in AiUsage; separate AiAllowanceGrant funding limits provider spending and may pause new work even while article units remain.
    • Provider tokens are internal cost telemetry only: recordAiUsage (server/utils/aiUsage.ts) appends one AiUsage row per AI call and never throws, so a logging outage cannot fail the work it measures. There is no token balance or reservation; the former TokenWallet minted its own capacity whenever it ran short, so its balance carried no information. Translation, prompt enhancement, sentiment, Search Console autopilot and other supporting AI features do not consume customer articles.
  • Customer Portal: POST /api/stripe/portal (billingPortal.sessions.create) — self-serve plan change (with proration), cancel, payment method, and invoice history + PDF. Surfaced from the settings billing tab. Requires the Customer Portal to be enabled/configured in the Stripe Dashboard (test + live).
  • Auth: checkout, subscribe, and portal all require a session and derive clientSiteId from it (superadmin may override via body) — never trust clientSiteId from the body (was an IDOR).
  • Webhook (POST /api/stripe/webhook) handles paid one-time Checkout sessions, subscription lifecycle events, and both Stripe invoice-success event names. A paid invoice grants the plan's monthly article allowance idempotently only while the provider's current subscription is active. The zero-value trial invoice never grants a paid allowance, including when delivered after the trial; fully discounted active paid periods still qualify. Invoice events may precede Checkout, so the invoice transaction can bind an unlinked blog only to its existing/null customer, never replace another subscription, and fill firstPaidAt only when null. A mid-period PRO→PREMIUM change grants only the difference, while downgrades do not claw articles back. Annual subscriptions receive their later monthly grants from grant-annual-article-credits. Plan is derived from the subscription's active price ID via planFromPriceId, falling back to metadata.
  • Losing a plan is not only subscription.deleted. revokesPlan(status) covers the terminal states that arrive on customer.subscription.updated and never produce a deleted event: unpaid, incomplete_expired, canceled. Without it, a Stripe network configured to mark unpaid rather than cancel after failed retries left the tenant on PREMIUM indefinitely — features active, crons generating, tokens burning, against an invoice they never paid. past_due is deliberately not revoking: that is the dunning grace period where Stripe is still retrying.
  • Both revocation paths share revokeToBasic(siteId, { clearSubscription }) (one transaction: plan: 'BASIC' + syncPlanFeatures). clearSubscription only on a terminal deletion — an unpaid subscription still exists in Stripe and revives on payment (updated → active re-promotes it), so its id is kept. stripeCustomerId survives either way, or the tenant loses portal access to their own invoice history.
  • Billing UI: the settings billing tab (Form/Client/Billing.vue) surfaces remaining articles, article packs, the 12 most recent Stripe invoices with hosted/PDF links (GET /api/stripe/invoices), the customer portal, and plan upgrades. Without a subscription it offers every plan above the current one (getUpgradeTargets: BASIC chooses PRO or PREMIUM). Success/cancel/return URLs all land on /settings?tab=billing.
  • Third upgrade surface: Admin/UpgradeBanner.vue on the admin dashboard, the only one that checks out inline (POST /api/stripe/subscribe). It reads the same getUpgradeTarget(plan, hasActiveSubscription), which is what keeps it off active subscribers — mode: 'subscription' opens a second subscription rather than changing the current one, so those upgrades have to go through the portal. The page shows it only on BASIC, as a one-line UAlert closed through its close prop (UAlert has no default slot, so a hand-placed close button never renders). Dismissals are an upsell-dismissed cookie keyed per offered plan (dismissing PRO still leaves PREMIUM to be offered later); unlike localStorage it is readable during SSR. The full pitch stays in Form/Client/Billing.vue.
  • Second purchase surface: the admin article pill Client/Version.vue (bottom-right, layouts/default.vue, isAdmin only). It reads the authoritative status.articleWallet, shows active expiring grants and the paginated /api/clients/:id/article-wallet ledger, and links the same server-owned pack catalog.
    • User-facing copy consistently says articles / články; tests/unit/walletCopy.test.ts rejects leaked token or credit terminology in these surfaces.
    • Pack view models come from app/utils/articlePackPresentation.ts (buildArticlePackViews) over ARTICLE_PACK_LIST. Prices and quantities are never duplicated client-side.
    • The upgrade CTA uses the shared getUpgradeTarget(plan, hasActiveSubscription) (shared/utils/plans.ts) and links to /settings?tab=billing rather than checking out inline.
  • Onboarding recovery and auto-login. onboarding/checkout.post.ts validates email proof before server/utils/onboardingRequest.ts claims a system-only OnboardingRequest. Domain-separated HMACs using AUTH_SECRET bind the verified identity, idempotency key and validated payload (including password, excluding renewable proof); no raw password, proof or bearer URL is stored there. Account creation and its recovery link commit together. A fenced database lease serializes workers; a separate existing billing lease coordinates Checkout with signed-in billing and webhooks. Explicit selections read internal @omit fields. Stable customer/Checkout provider keys recover lost responses for less than 23 hours, before Stripe can prune its keys. A saved session is retrieved, not blindly recreated; a newer signed-in billingCheckoutId or an existing subscription supersedes signup recovery. Old uncertain mutations, expired/completed sessions and changed catalog IDs lead to sign-in rather than another charge. The original absolute trial deadline is retained; late recovery never restarts it.
  • The onboarding login token is a request-bound HMAC (@omit, 30-minute TTL) written once on the new admin. The redirect uses the localized auth route (created=true&token=…[&session_id=…]); shared/utils/routes.ts and tests/unit/routes.test.ts define the path. pages/autorizace/index.vue redeems it at POST /api/auth/sign-in/token (server/utils/auth.ts), which validates expiry and atomically single-uses it. Replay never remints or extends the token. After expiry/consumption, ordinary sign-in remains available; Checkout success URLs may still contain the original expired token, and the page clears it after showing common.auth.onboardingLoginExpired. The first eligible inbox starts on PREMIUM trial; repeat inboxes start BASIC. The sibling landing only coalesces pending calls and forwards Idempotency-Key; completed links/errors are not cached. Ship the matching platform migration before that landing release. Recovery needs no new secret; keep AUTH_SECRET stable.
  • Required env: STRIPE_SK, STRIPE_WEBHOOK_SECRET, STRIPE_PRICE_PRO, STRIPE_PRICE_PREMIUM, STRIPE_PRICE_PRO_ANNUAL, STRIPE_PRICE_PREMIUM_ANNUAL.

Agency accounts (BillingAccount, /admin/agency)

  • Ledger owner. Article credits (ArticleCreditWallet/Grant/Operation) and AI funding (AiAllowanceGrant/Invoice/UsageReservation) carry accountId; clientSiteId is attribution only. The spending account is site.billingAccountId ?? site.id (server/utils/billingAccount.ts → spendingAccountId). Every blog has a DIRECT account with its own id, created by the ClientSite_direct_billing_account trigger and the 20261111120000_billing_accounts backfill; direct billing, subscriptions and debts stay on ClientSite. Settlement and refunds use the operation's stored accountId, so a blog moved between accounts settles where it reserved.
  • Lock order: blog row, then its DIRECT account, then the agency account. Agency invoice transactions lock only the account. AGENCY debts live on BillingAccount (updateAccountTerms).
  • Own credits first. An agency blog keeps whatever its DIRECT account held when it joined. reserveArticleCredit and reserveAiAllowance draw from the DIRECT account when it covers the whole amount (one operation, one account), outside monthlyUnitLimit; otherwise the pool pays all of it. The DIRECT side never runs fund, whose plan terms would read the agency plan as the blog's own; it only mints article funding (fundArticles). getArticleCreditWallet and getAiAllowance add those balances to the pool's (own), and the scheduler counts both (spendableUnits); the DIRECT wallet is read only if it exists.
  • Catalog (shared/utils/agencyPlan.ts): three monthly per-unit Stripe prices, STRIPE_PRICE_AGENCY_BLOG ($10/blog slot), STRIPE_PRICE_AGENCY_PREMIUM ($20 PREMIUM add-on, ≤ blog slots; absent from Checkout at 0, added by the first raise, deleted at 0) and STRIPE_PRICE_AGENCY_UNITS ($30 per 50 units), with their STRIPE_PRODUCT_* ids validated by getConfiguredAgencyPrices; minimum 3 blogs + 4 blocks. AI BUDGET is 50 % of the net agency invoice; SEO is per blog plus per add-on; TRANSLATION per block plus per add-on; EDIT per block; VISIBILITY per add-on. Rollover cap 2× monthly units while live. ClientSite.monthlyUnitLimit caps one blog's period spend (BLOG_UNIT_LIMIT_REACHED).
  • Stripe (server/utils/agency.ts): subscription metadata { kind: 'AGENCY', billingAccountId }; handleAgencyStripeEvent runs first in the webhook and owns those events, direct handlers filter billingAccountId: null. Raises use always_invoice + pending_if_incomplete and grant the unit difference from the paid proration lines; decreases use proration_behavior: 'none'. The agency lease (billingLeaseId) serializes checkout, plan changes and webhooks; webhooks wait up to 5 s for it (one Checkout fires three events at once), the app endpoints answer 409 at once. Live = ACTIVE | PAST_DUE.
  • Blog plans. ClientSite.agencyPlan (BASIC | PRO | PREMIUM, non-null exactly while billingAccountId is set, DB check) is the agency's pick; plan caches it while live, BASIC otherwise (syncAgencySites). PRO and PREMIUM take a blogSlots slot, PREMIUM also a premiumSlots add-on; BASIC takes none and is the ordinary free plan (custom domain retired by syncPlanFeatures). agencySlotShortfall is the one rule for attach, PATCH blogs/:id, checkout and plan changes; deleted blogs take no slot (agencyBlogPlans). syncAgencySites steps picks above the paid slots down, newest blog first. A BASIC agency blog is refused by reserveArticleCredit and AI funding (AI_PLAN_REQUIRED), since its pool stays funded for the others.
  • Access. BillingAccountMember is projected into TenantMembership rows tagged billingAccountId (syncAgencyMemberships); a blog keeps its single OWNER, so agency owners are projected as MEMBER with every scope. Rows the blog team created are never rewritten; projected rows are read-only on the blog Team tab (members.get.ts → agency, delete/patch answer 409 AGENCY_MEMBERSHIP). revokeAgencyAccess drops projected rows and parks sessions off those blogs. Attaching requires a manual OWNER membership on the blog, no Shopify billing and no own subscription; the blog's trial, team-seat limit and direct Stripe offers (rejectAgencyBilled) stop applying. The daily agency-sync task repairs plans and projections.
  • API: server/api/agency/* (all through requireAgencyMember; billing, blogs and people need OWNER). The membership, a blog or member id from the request (requireAgencyBlog) and the overview are read through the policy client, where BillingAccount/BillingAccountMember are readable only to the agency's members; locked ledger, projection and Stripe writes stay on the system client. report.get.ts exports one billing period (?period=<periodStart>, one of agencyPeriods) as CSV with formula-safe cells. Usage comes from the pool's operations, so a blog that left still reports what it spent while attached (former). UI: pages/admin/agency.vue + components/Agency/*, keys in i18n/locales/*/agency.json.
  • Entry without a blog. middleware/agency.ts admits a signed-in person with no blog (reader) on the platform host (canOpenAgencyWithoutBlog); everyone else goes through admin. The landing links to /auth?mode=register&intent=agency&blogs&premium&blocks; the auth page and middleware store it in the agency_intent cookie (agencyIntentFromQuery), /start then opens the agency instead of a blog, and the pick seeds useAgencyDraft until the first subscription. TenantCreate with agencyPlan skips the plan step and attaches the new blog to the agency.

Ad revenue share — HALTED (GAM → AdEarning ledger, retained implementation)

Product status (2026-08-09): platform ads and revenue share are halted until further notice; tenant GAM on the tenant's own network was re-enabled on 2026-10-27. Do not market revenue share, enable it for tenants, or treat the historical plan ratios below as current commercial entitlements. The implementation and schema remain in the repository for a possible restart; production should leave GAM credentials unset so gam-sync no-ops. Restarting monetization requires an explicit product decision and a fresh review of pricing, ratios, consent, reporting and payout operations.

  • Retained historical model (inactive): ad revenue was split creator/platform per plan (BASIC 0/100, PRO 70/30, PREMIUM 90/10, CUSTOM 100/0). Ads serve under one platform GAM account; each impression carries the site's client_id — set as page-level GPT targeting in app/composables/useGam.ts → initialize — so GAM revenue reports break down per site. useGamAds(site) takes the tenant from useClientSite() rather than refetching it.
  • Split is deterministic + server-side (server/utils/adRevenue.ts, pure + unit-tested in tests/unit/adRevenue.test.ts): splitAdRevenue/buildEarningRow apply the plan ratio to GAM gross; the invariant clientCents + platformCents == grossCents holds (incl. negative clawbacks). CLIENT_SHARE is the single source of the ratio.
  • Ledger: AdEarning (prisma/models/ads.zmodel, migration 20260725120000_ad_earnings) — one row per (clientSiteId, periodStart, periodEnd) holding gross/client/platform cents, a shareRatio snapshot, and AdPayoutStatus. Read = superadmin or admin of own site; create/update/delete = superadmin/system only (clients cannot fabricate earnings). shareRatio is snapshotted per row so a later plan change never retroactively rewrites past earnings.
  • Ingest: server/tasks/gam-sync.ts (cron 0 4 * * *) pulls the previous UTC day per-client and idempotently upserts the ledger (re-runs correct the numbers). No-ops when GAM_NETWORK_CODE/GAM_SERVICE_ACCOUNT_KEY are unset; fails loudly on half-configuration so revenue is never silently dropped.
  • GAM Reporting client (server/utils/ads/, no new dependency): gamAuth.ts mints an RS256 service-account JWT with node:crypto and exchanges it for an admanager access token (cached until ~2 min before expiry); gamReport.ts drives the Ad Manager REST v1 report lifecycle — create report → :run → poll the operation → paginate :fetchRows. Attribution comes from the client_id key-value dimension named by GAM_CLIENT_DIMENSION (network-specific, e.g. CUSTOM_DIMENSION_<KEY_ID>_VALUE); missing it throws rather than attributing revenue to nobody. Micros are summed per site before the single microsToCents rounding, so split rows can't drift a cent. Pure helpers (toGamDate, gamDateRange — turns our exclusive periodEnd into GAM's inclusive endDate — buildReportDefinition, readValue, parseReportRows) are unit-tested in tests/unit/gamReport.test.ts; the network path is untested-by-design (no live credentials).
  • Read API: GET /api/clients/earnings (requireDb, minRole: 'admin') — optional from/to (default last 12 months, hard cap 24) and clientSiteId. Tenant isolation is ZenStack policy, not the where clause: an admin passing a foreign clientSiteId simply gets nothing back. Returns summaries (one per currency — summarizeEarnings never sums across currencies), each with totals, byMonth, and clientCentsByPayoutStatus, plus a bySite breakdown and a scope flag (platform for an unfiltered superadmin).
  • Consent and advertising: ConsentManager.vue keeps Technical cookies always active and independently gates first-party statistics and tenant GA4 through Analytics. The versioned host-only topiqu_consent cookie lasts 180 days; service snapshots prevent extending a previous choice to a different tenant or newly configured service. Revocation disables GA4, clears visible GA cookies and calls DELETE /api/consent/analytics for the HttpOnly session. Platform AdSense was removed; tenant GAM is the only ad integration. tenantGamEnabled (shared/utils/advertising.ts) requires a paid plan and a network code; it drives the Marketing category and useGamAds.initialize, which loads GPT only after Marketing consent, serves non-personalized ads and forces SafeFrame, because same-origin creatives inherit the page CSP. The CSP allows securepubads.g.doubleclick.net scripts and SafeFrame frames. Slots: /home/feed (Home/FeedAd.vue), /article/body (Article/BodyAd.vue, at the server-chosen adBreakIndex) and /article/sidebar (≥1280px), all under /{networkCode}/. The consent snapshot keeps its three-element shape, so sites without GAM keep stored choices; enabling or changing a network code asks again. Consent settings use the shared role-aware launcher: privileged users on their own tenant get the action in ClientVersion, readers and guests get ConsentSettingsButton. The bottom-action-bar marker keeps back-to-top above these launchers. GA4 remains manual and host-only; nuxt-gtag owns tag configuration once per ID even when another snippet created dataLayer, and the composable owns consent/private-navigation guards. Sentry server tracing and consented document metrics are described in §9; Session Replay is not configured. The consent dialog uses a div with role=dialog rather than an aside.
  • Pending (fáze 2): payout rail (Stripe Connect / Wise) + EU DAC7 reporting — payoutStatus/payoutRef are the anchors already in place; admin earnings UI (API exists, no page yet).
  • Article packs need no Stripe catalog objects — stripe/checkout builds ad-hoc price_data from the server catalog at checkout.

7. Tooling

  • ESLint 9 (@nuxt/eslint + eslint-plugin-perfectionist), Prettier 3.
  • Testing: Vitest 4 with @nuxt/test-utils, jsdom, V8 coverage. Config in vitest.config.ts (jsdom env). Tests live in tests/**, co-located *.test.ts is also picked up. First suite: tests/server/stripe/webhook.test.ts.
  • Typecheck via vue-tsc (bun run typecheck); kept out of build for fast deploys, run separately in CI.
  • Package manager: bun (bun.lock), pinned to one version in package.json, Docker and CI. tests/unit/releaseToolchain.test.ts prevents those declarations, the frozen install gate, the compiled E2E hook, or complete Prisma migration directories from drifting apart.
  • Scripts: dev, build, typecheck, test, test:watch, test:coverage, lint(:fix), prettier(:fix), fmt, zenstack:generate, db:migrate:deploy.
  • Line endings are LF everywhere, pinned in both .gitattributes (* text=auto eol=lf) and .prettierrc (endOfLine). On Windows a checkout under core.autocrlf=true writes CRLF, Prettier rewrites it to LF, and every file's cached size then mismatches — git status reports the whole tree modified while git diff is empty. Recover with git add --renormalize ..

8. Notable Gaps / Observations

  • Test coverage is partial. Vitest suites exist under tests/server/** (stripe webhook, linkedin publisher + oauthState signing + token refresh, notifications poll, articlePolls, AI translation mask/rebuild + slug dedupe + queue) — server-side pure logic is covered; component/page coverage is still thin.
  • todo/ directory carries working notes inside the repo.
  • Serverless → self-hosted migration (done, 2026-08). The app runs on Dokploy/VPS; see §1 for the runtime and §4 for host topology. The phased playbook (MIGRATION.md) has been deleted now that it describes history. What it was still carrying, and what remains genuinely open, is:
    • scheduledTasks register per replica. nuxt.config.ts::nitro.scheduledTasks defines the current task names/cadences, gated to production outside browser tests; shared/utils/operations.ts defines monitored intervals. Several jobs have DB claims, leases or advisory locks, but this does not certify every task for arbitrary replica counts. Review the scheduler topology and each job's idempotency before scaling or moving execution to a dedicated worker/external scheduler. Retained advertising code remains halted.
    • No standalone queue worker is shipped. Current background work runs through Nitro tasks and database-owned work/claim records. AWS SQS or another queue/worker topology remains a separate design decision, with no installed SQS client or queue implementation to infer from historical notes.
    • Cache provisioning is deployment-specific. Source supports optional REDIS_URL; local Compose does not start Redis/Valkey. Verify availability through the operator status endpoint and deployment configuration rather than treating an old migration note as current runtime evidence.
  • Mixed-locale routing (Czech slugs in pages/) — intentional, paired with @nuxtjs/i18n.
  • Article translations + real hreflang (implemented). Articles are translated per-language via the ArticleTranslation sidecar (see §6 → Article Translations). pages/clanky/[slug].vue resolves content locale-scoped (primary language → source Article; other locale → its PUBLISHED translation, falling back to the source as a legacy i18n alias when none exists). Once ≥1 translation is published, the page emits real hreflang + x-default and self-canonicalises per locale; with no translation it canonicalises untranslated locale aliases onto the primary-language path. hreflang is therefore only emitted for translations that actually exist, never speculatively.
  • One canonical origin for the whole app — useCanonicalOrigin() (app/composables/, wrapping the pure buildCanonicalOrigin/toAbsoluteUrl in shared/utils/seo.ts, tested by tests/unit/seo.test.ts). It derives the origin from the request host, which is mandatory here: every tenant lives on its own domain, so a hardcoded i18n.baseUrl / site.url would stamp the wrong host on every client site. It forces https: outside dev (a proxy that drops x-forwarded-proto would otherwise emit http: canonicals) and strips a leading www.. pages/clanky/[slug], stitky/[slug] and autor/[name] all build canonical/alternate/JSON-LD URLs from it — the last two previously used a raw ${protocol}//${host} and disagreed with the article page on www.
  • The i18n-generated hreflang links are absolutised in app.vue. useLocaleHead() prefixes its link entries with i18n.baseUrl, which is unset (and cannot be set statically — see above), so it emitted relative hrefs on i18n-alt-* / i18n-xd; search engines ignore relative hreflang, which is what a Bing audit of pixbo.topiqu.com flagged. app.vue now maps i18nHead.link through toAbsoluteUrl(href, canonicalOrigin) before spreading it into useHead, so every generated alternate is fully qualified per tenant. Already-absolute hrefs pass through untouched, so this stays correct if baseUrl is ever configured.

8a. AEO / GEO Surface

Everything a search or answer engine reads. All of it is per-tenant, resolved from the request host via server/utils/tenant.ts → tenantByHost (cachedTenantByHost for hooks that fire on every request). Root hosts (topiqu.com, app.topiqu.com) resolve to null and serve none of it.

  • The article body reaches the crawler. Article/Parsed.vue used to split the stored HTML in onMounted behind an import.meta.client guard, so the served markup contained an empty container — and GPTBot, ClaudeBot, PerplexityBot and CCBot do not run JavaScript. The split now happens in articles/[id]/index.get.ts via server/utils/articleBlocks.ts (cheerio) and ships as blocks on the payload, after resolveArticleLinks and resolveProductCards. Product cards and X posts become their own product/xPost blocks (Article/ProductCard.vue, Article/XPost.vue). Shaping rules live once in shared/utils/articleBlocks.ts; the editor preview adapts DOMParser onto the same builder (app/utils/articleBlocks.ts). Adjacent HTML nodes merge into one run — a wrapper per node breaks the adjacent-sibling rules prose depends on. Stored body images remain raw HTML, but Article/Parsed.vue now injects Nuxt Image/IPX WebP candidates through shared/utils/articleImages.ts. Every image retains its direct upload in data-original-src; a failed proxy response removes srcset and retries that upload, so responsive delivery cannot make the article unreadable.
  • Card and homepage lead excerpts go through articleExcerpt (shared/utils/articleBlocks.ts): the author perex is shown whole (≤500 chars by schema), the body fallback is cut at a word near 240 chars. The homepage feed and featured endpoints apply it server-side and drop content from the payload.
  • Heading anchors come from headingSlug (NFKD, so Přehled trhu → prehled-trhu, not p-ehled-trhu). stampHeadingIds writes them on save (POST/PATCH); the block builder stamps any body that predates that, and never overwrites an existing id. Article/TOC.vue only generates ids for the editor preview.
  • Article slugs are normalized by shared/utils/articleSlug.ts in the editor and in POST/PATCH (409 on a tenant clash). Changing the slug of an article that was ever published stores the old one in ArticleSlugRedirect; the article API answers an old slug with 404 + data.movedTo, which clanky/[slug].vue turns into a 301. A live article always wins the lookup, so a redirect is only re-aimed (upsert) when its slug is freed again. The same function builds cron and translation slugs; translation slugs have no redirects yet.
  • Structured data is one linked graph, not three hand-built ld+json blobs. app.vue defines only the Organization identity — nuxt-schema-org's i18n plugin already emits WebSite and WebPage with locale-aware @ids off the per-tenant site config, and redefining them detaches WebPage.isPartOf; useArticleSeo adds Article/BlogPosting, the author Person ({authorUrl}#author, the same @id autor/[name] defines) and any FAQ questions; useBreadcrumbItems({ overrides }) owns BreadcrumbList — passing overrides rather than remapping its result, because it emits the schema from its own items. Article.sources becomes citation, publishedAt becomes datePublished (createdAt is when the draft was opened), and totalWords/readingTime become wordCount/timeRequired. A second bare #organization node next to #identity is the module's own logo node, not a duplicate.
  • Content home: ClientSite.contentHome = EXTERNAL (Settings → publication channels) means the customer's own site carries the indexable copy. hasExternalContentHome then makes article, home, tag and author pages noindex, follow, drops canonical/hreflang and the RSS link, empties the sitemap and 404s /rss.xml, /llms.txt and /md/*. publicationSeoLinks applies at every head owner, including global i18n and author/tag pages: an empty article head cannot remove links registered by its parent. Pages stay crawlable so the noindex is seen.
  • Sitemap is entirely server/api/__sitemap__/urls.get.ts; excludeAppSources and autoI18n: false turn off the module's own discovery, which only ever finds the app shell. It emits alternates only for PUBLISHED translations, and skips the homepage for a tenant with no articles and tag/author pages on BASIC — in both cases the page itself emits noindex, and a sitemap must not contradict it.
  • LLM surfaces: /llms.txt (tenant index, links each article as markdown), /md/{locale}/{segment}/{slug}[.md] (server/utils/articleMarkdown.ts, cheerio → markdown, no new dependency) and /rss.xml. The markdown variant is prefixed rather than a bare <url>.md: Nitro cannot bind a param owning part of a segment, and a top-level catch-all would swallow every page route.
  • shared/utils/routes.ts restates the i18n.pages segments because Nitro has no localePath. tests/unit/routes.test.ts asserts them against nuxt.config.ts — without it, renaming a route silently points every sitemap, feed and llms.txt URL at a 404.
  • Site identity is per-request. server/plugins/siteConfig.ts pushes the tenant's name/description/locale into the site-config stack above SiteConfigPriority.runtime. nuxt-seo-utils builds titleTemplate and ogSiteName from site.name, so without it every tenant's pages were titled … | Topiqu AI Blog and inherited the platform's Czech description. A tenant with no description pushes '', not nothing, so the platform line is suppressed rather than borrowed. seo.metaDataFiles is off: it baked public/favicon.ico (with sizes) into every head and browsers preferred it over the tenant favicon; app.vue owns the icon links.
  • hreflang on articles is owned by the page. useLocaleHead derives alternates from the route alone, so it advertised a translation that may not exist; app.vue filters its alternate links out on the article route. x-default is alternates[0], which the API builds as the source language.
  • Extraction fields. Article.answer / keyTakeaways / faq (mirrored on ArticleTranslation) are what an engine quotes. They are opt-in per format/article — a padded FAQ on every post reads as generated and is the pattern engines demote. They live outside the TipTap HTML body. The editor edits them per language in Article/Editor/Summary.vue and Article/Editor/Faq.vue: a translation keeps them in its draft and saves them through PATCH /api/translations/:id, which marks it HUMAN. Saves trim the text and drop blank rows (cleanTakeaways, cleanFaq); a half-written FAQ entry blocks the save (hasIncompleteFaq), since it would publish a broken FAQPage entry. The dirty checks compare the same normalized values, and changesReaderText treats an empty list as none. The preview renders with the public ArticleSummary / ArticleFaq. readFaq (shared/utils/articleFaq.ts) narrows the Json column; FAQPage is emitted only when it is non-empty. The mirror is only as good as the write — generateTranslation translates and bills all three, but both write sites build their own data object, so a dropped field costs the tokens and then renders the source language under a translated body (tests/server/ai/translationRow.test.ts pins both). [id]/index.get.ts falls back to the source article per field, which is what makes the drop look like a feature gap instead of a bug.
  • Crawl budget: private paths are disallowed in robots.txt (the module re-emits each under every locale prefix, so they are listed bare), answer-engine crawlers get an explicit allow group, and CRAWL_LIMIT raises the global 70/10 s rate limiter on the read surfaces — a crawler working through a sitemap otherwise collects 429s and the pages behind them never get indexed. CRAWL_ROUTES derives those rules from LOCALIZED_SEGMENTS (homepage, article, tag and author paths in every locale) plus /md/**; hardcoded, they once covered only cs/en articles.
  • Article.contentUpdatedAt is the modification date, not updatedAt: the Base @updatedAt also moves on every view counter increment and status flip, which made every visited article claim a fresh edit in dateModified, sitemap lastmod and the .md Updated: line. It is set only after first publication, when reader-visible text changes (changesReaderText in server/utils/articleModified.ts: PATCH, autopilot and its rollback, content actions) and is server-owned (stripped from the PATCH body). articleModifiedAt falls back to publishedAt. Translation rows keep their own updatedAt; a translation leaves the sitemap as STALE and returns only when re-translated, so it stays honest.
  • IndexNow (server/utils/indexNow.ts, Bing/Seznam/Yandex): announceArticle runs next to the article webhooks (create, PATCH status/text/slug, delete, publish-check, cron, autopilot, content actions, translation publish). A live article sends its source and published translation URLs; removals pass the old URLs, computed before they disappear. The key is an HMAC of the tenant id served at /indexnow.txt; production only, never on an unverified custom domain, and failures are reported, never thrown.
  • Homepage pagination is crawlable. "Next" is a real ?page=N link (pages/index.vue); with JavaScript the click appends in place. SSR reads page, so every article is reachable by links, not only through the sitemap. nuxt-seo-utils keeps page in the canonical; a page past the end is noindex.
  • Bot lists live in shared/utils/crawlers.ts, imported by both nuxt.config.ts and the runtime. Three fetching groups (ANSWER_ENGINE_BOTS retrieval/user-triggered, TRAINING_BOTS corpus, SEARCH_BOTS classic) plus AI_GROUNDING_TOKENS, which are not crawlers — Google-Extended and Applebot-Extended have no user agent at all and exist only as robots.txt control tokens for Gemini and Apple Intelligence, so they are absent from detectCrawler. Matching is longest-token-first or Claude-SearchBot resolves as ClaudeBot.
  • server/plugins/crawlerLog.ts ships one Better Stack line per crawler hit on a content route (source: 'crawler', bot, kind, host, path, status, rateLimited). Assets are excluded — they are the volume and say nothing about coverage. This is the only way to answer whether the AI crawlers arrive at all, whether they reach /llms.txt and the .md variants, and whether the rate limiter is turning them away.
  • AI visibility turns those diagnostics into tenant analytics without pretending they are citations. The crawler plugin also upserts one AiCrawlerDaily bucket per tenant/day/bot/path; it stores counts and response outcomes, never IPs or raw user agents. Consented article-view posts classify a small explicit allowlist of AI referrer hosts into deduplicated AiReferralVisit rows. /admin/visibility keeps these observed signals separate from sampled citation coverage. A tenant with no prompts and no AI traffic gets a single onboarding card instead of empty sections. Each prompt carries latestRuns (latest run per provider via DISTINCT ON, configured providers only), which promptOutcome in shared/utils/aiVisibility.ts rolls into one verdict; coverage metrics count every run in the 30-day window. AiVisibilityPrompt checks run manually or weekly per prompt for PREMIUM/CUSTOM tenants (the ai-visibility-monitor task runs daily, interleaves tenants and caps a run at 300 prompts) against every configured native search API: OpenAI, Claude, Grok, Gemini, Meta Muse Spark and Mistral. Missing provider keys disable only that adapter; provider failures are isolated into separate runs. Only machine-readable citations attached to the provider answer become AiCitation rows. Runs retain their answer, model, usage and failure, log aggregated usage to AiUsage, serialize per prompt with a PostgreSQL advisory lock and log completion/failure to Better Stack and the audit chain. Two external-citation samples with no owned citation create an AiVisibilityOpportunity; the prompt's own articleId, else an overlap match, points to an existing article, otherwise the action opens a prefilled new-article AI composer; HIDDEN domains are left out of its citedDomains. Dismissed opportunities stay dismissed, resolved gaps reopen if they regress, and any owned citation resolves an open gap. The UI has no separate opportunity list: an open one sits on its prompt's row (Stats/Visibility/PromptRow.vue: create/improve button, cited-domain chips, dismiss in the row menu), those prompts sort first, and the list shows five before "show all". All coverage copy says “sampled”: provider APIs do not expose every private user conversation. Provider keys are listed in .env.example; Anthropic also needs web search enabled for the organization. Answers are markdown and render through shared/utils/answerMarkdown.ts + Stats/AnswerMarkdown.vue (template-only, no v-html; http(s) links only). Pausing a prompt stops the scheduled checks, not a run in flight.
  • Tenants do not write the prompts. server/utils/ai/visibilityPrompts.ts tops each eligible tenant up to AUTO_PROMPT_LIMIT (10) active generated prompts, at most weekly (ClientSite.aiPromptsSeededAt), with one model call over its top Search Console queries, latest published articles and reader comments containing a question; focus/audience covers a tenant with none of those. The ref the model returns sets source (SEARCH_CONSOLE/ARTICLE/COMMENT/GENERATED) and, for articles, articleId. Every existing prompt, paused ones included, is passed as do-not-repeat, so a paused question is not regenerated; for the same reason the UI offers delete only on MANUAL prompts. retireSilentPrompts pauses a generated prompt after 21 days of successful runs with no citation of any source — no engine searches the web for it, so it can never produce an opportunity — and the next seeding fills the slot. Seeding runs first in the task, so new prompts (lastRunAt: null) are checked the same morning.
  • Competitors are derived, not configured. citedDomains in shared/utils/aiVisibility.ts counts each non-owned domain once per answer over the 30-day window. A domain cited for at least max(2, min(3, ⌈20 % of prompts⌉)) distinct prompts is a competitor; isReferenceDomain (encyclopedias, forums, social and AI platforms, .gov/.edu, gouv.fr, bund.de) is reported as a general source instead, because an article cannot outrank it. share shares its denominator with the tenant's citation coverage so the two are directly comparable. AiVisibilityDomain holds the tenant's override (COMPETITOR promotes, HIDDEN removes), written by PUT /api/ai-visibility/domains.
  • bun run seo:check <origin> (scripts/seo-check.ts) fetches the whole surface as GPTBot and exits non-zero on failure: body present without JavaScript, sitemap lists articles, llms.txt/rss/markdown reachable, canonical self-referencing, tenant brand in the title, one h1, and the schema graph complete with no dangling @id. That last check is what caught WebPage.isPartOf pointing at a WebSite node that no longer existed.

9. Observability — errors, traces and document metrics

Why we adopted this

Error tracking complements database audit, bounded external logging and monitored Nitro tasks. The client/server Sentry initializers and consented Web Vitals plugin describe what ships. Older SQS, Session Replay and provider-cost notes were proposals or historical rationale; they are not enabled capabilities or measured cost evidence.

Delivery and limits

  • NUXT_PUBLIC_SENTRY_DSN selects the Sentry-protocol error/metric destination. Local SDK transport tests do not establish provider-side ingestion, feature compatibility or current prices.
  • server/utils/logger.ts independently uses BETTERSTACK_SOURCE_TOKEN and BETTERSTACK_INGEST_HOST; missing configuration disables that external transport. Console output and database audit remain available.
  • Provider error text stays server-side. AI provider failures (quota, billing, raw response bodies) are kept in audit rows, ArticleGenerationSession.failureReason, AiVisibilityRun.error and the logs, but tenants only receive localized generic messages: the manual generation stream, /api/articles/generations/* and /api/ai-visibility/* never return the raw message. tests/server/ai/manualGenerationStream.test.ts and visibilityMonitoring.test.ts pin this.
  • The client initializer has no explicit trace sampling or Replay integration. The server initializer configures trace sampling; the separate browser Web Vitals plugin samples only consenting production documents and sends coarse metrics. Adding Session Replay would require an explicit product/privacy decision and verified configuration.

Files / wiring

  • sentry.client.config.ts (project root) — SDK init from runtimeConfig.public.sentry. Empty DSN disables the SDK (local / CI).
  • sentry.server.config.ts reads process.env and must also work before a Nitro context exists. Calling useRuntimeConfig() there can kill startup. SDK11 bundles this initializer into Nitro through its module; no separate runtime preload or deprecated autoInjectServerSentry option is needed. Never initialize it twice.
  • shared/utils/telemetryPrivacy.ts explicitly disables automatic user/IP, cookies, headers, bodies, query parameters, AI inputs/outputs, database query data, queue/GraphQL payloads and stack-frame variables. Static tracing preserves beforeSendTransaction redaction and the legacy transaction envelope. Authentication scrubbing covers the envelope's dynamic sampling context as well as its event body. tests/server/telemetryPrivacy.test.ts exercises actual SDK transport with synthetic metadata and no external requests; provider ingestion still needs an authorized runtime smoke.
  • nuxt.config.ts registers @sentry/nuxt/module, public DSN/environment, top-level build upload options (sentryUrl, org, project, authToken) and hidden client source maps. Build-plugin telemetry is disabled.
  • server/utils/logger.ts emits one JSON line per record to the container and the same sanitized record to Better Stack. LOG_LEVEL defaults to info; ordinary successful reads are debug, mutations are info, slow requests and client failures are warn, and server failures are error. Secret/content fields, personal records, email addresses, raw IP/User-Agent fields, URL credentials/query strings and configured credential values are redacted centrally; context depth, strings, arrays and total size are bounded. Login diagnostics use the existing peppered IP fingerprint without retaining the address or browser signature; rate-limit keys and thresholds are unchanged. Error causes retain only bounded name/message/code/status/stack fields, with cycle protection and at most three levels. The external queue remains capped at 200 records with a five-second delivery timeout; delivery/overflow warnings are rate-limited and console records remain available.
  • server/plugins/errorLogging.ts instruments Nitro request, error and afterResponse hooks. utils/requestLog.ts gives each request a server-generated x-request-id, hostname, effective protocol, query-free path, status, duration and a stable reason code. Thrown errors and returned failure statuses are recorded once per request; errors without a request are also reported. Expected HTTP 4xx failures omit stacks throughout their cause chain, while 5xx errors retain diagnostic stacks. Domain authentication rejects explain AUTH_HOST_UNAVAILABLE or AUTH_HTTPS_REQUIRED without exposing domain records. Monitored task completions/failures carry the existing task run ID and outcome, while database audit remains unchanged.
  • Both channels only see errors nobody caught. Sentry auto-captures what escapes a handler; errorLogging.ts sees what Nitro turns into a 5xx. A try/catch that returns a fallback satisfies neither, so every deliberate swallow is invisible in production unless it reports itself — server/utils/reportError.ts → reportCaughtError (Sentry.captureException + logger.error) is that call. The explicit name avoids colliding with h3's one-argument reportError auto-import. It matters most where a failure is designed to be non-fatal: best-effort image generation, stock-provider fallbacks, and articles/generate — a streamed handler has already sent its 200, so it structurally cannot produce a 5xx and no amount of Nitro-level hooking will ever cover it.
  • .env.example — NUXT_PUBLIC_SENTRY_DSN, SENTRY_URL/ORG/PROJECT/AUTH_TOKEN.
  • Session Replay is not configured, so no masking or replay-worker behavior is claimed. Existing CSP allowances do not activate a telemetry feature.

Notification delivery

  • Notification/Bar.vue renders the list and owns optimistic deletion; useNotificationPolling owns single-request polling, cancellation, visibility, retry delays and connection state. A 401 rechecks the session once, a 403 pauses until explicit retry or a changed user, and transient failures back off to five minutes (429 honors Retry-After). Transient session-check failures preserve an established session. User switches and disposal abort pending polls and ignore their late responses; the popover explains a paused connection without repeated error toasts.
  • The list seeds a server-side { createdAt, id } cursor even when empty. Polling with since plus sinceId drains up to 25 rows oldest-first, returns the last delivered cursor and quickly requests remaining batches; equal timestamps are ordered by ID. Cursor progression is independent of displayed/deleted items. Older clients sending only since retain newest-first responses. Notification reads remain policy-enhanced and scoped to the authenticated user; no schema migration is needed.

UX reconstruction foundations

The screenshot-driven repairs and their operational limits are recorded in docs/plans/2026-10-24-ui-and-publishing-repairs.md. Onboarding passwords are hashed only by appMutationPlugin; callers of the shared user writer pass cleartext. Article/GenerationActivity.vue includes persisted translation and generation activity, using owner/tenant-scoped receipts and the existing recovery APIs. Its trigger lives in Client/Version.vue, between the wallet/version and cookie controls in the bottom action bar. Manual translation claims use the nullable ArticleTranslation.translationStartedAt lease and Nitro background work. The additive 20261024020000_translation_background_state migration is required before startup. Published replacements keep their live body and status while running; abandoned leases expire after 30 minutes. Completed manual translations remain READY for explicit approval. Comments reconcile canonical thread snapshots after mutations and poll only while visible. Public series ordinals count published, released episodes; /admin/series owns the management overview. Its Series/Create.vue modal uses Series/ArticlePicker.vue and GET /api/series/articles to search and paginate standalone articles the member can edit, keeping the ordered selection across searches. POST /api/series validates shared/seriesSchemas.ts and creates the series with up to 100 selected parts in one enhanced-client transaction. Each assignment rechecks tenant, author permission and unassigned state; any unavailable part rolls back creation. Existing series are never reassigned by this flow. Creation invalidates the public feed and client article queries, then refreshes the overview. The owner-only settings danger zone uses retained-data deactivation; permanent deletion remains platform maintenance.

  • app/app.config.ts is the central Nuxt UI visual configuration; app/assets/styles/main.css contains global semantic tokens, locally bundled variable fonts, reading typography, and reduced-motion behavior.

  • Dashboard page roots use .topiqu-dashboard-page for the shared 90rem maximum width and responsive horizontal padding. Reading and nested form widths remain separate. Branding groups its live preview only with visual-style and typography controls after identity fields, sticky in the right column at xl and above the pickers on smaller screens. Social profiles and the after slot containing footer/domain settings sit below that grid, outside the sticky preview's bounds.

  • The tenant sidebar groups articles, topic planning and series under Content; media and AI sources under Resources; statistics, AI visibility and backlinks under Performance. Website access and settings stay in its footer beside sign-out. Public article pages request two related articles for one complete desktop row when both are available.

  • Publication social profiles live once in Footer.vue, beside footer navigation, rather than in detached homepage/article rows. Paid blogs follow footerSettings.showSocials; Basic retains its configured social links beside the required Topiqu referral. Client/Socials.vue uses theme-aware controls and translated platform names. Branding edits social profiles in compact rows; footer settings keep visibility, social display and bounded custom links together. Link protocol safety remains unchanged; there is no domain reachability check.

  • Footer text is centered when no custom links or visible social profiles remain. Social controls consume the public site's shared live snapshot, avoiding a second social-links cache. Tenant cache invalidation bypasses cached payloads for both refresh:manual and Nuxt's refresh:hook, while initial rendering still reuses the server payload.

  • Dashboard primary is ink. colors.primary is slate and :root --ui-primary is --ui-bg-inverted (black; white in dark mode), so links, icons, hovers and selected controls match the buttons; .publication-surface swaps --ui-primary for the tenant accent. Solid primary UButtons paint with bg-action (--topiqu-action): ink (--ui-bg-inverted) in the dashboard, the tenant accent inside .publication-surface. On public pages the layout puts that class and the tenant CSS vars on <html> so teleported overlays inherit them; editor/branding previews still scope it locally.

  • Brand gradient (activeBrandGradient, Pro+) is decoration only: homepage hero, the article reading-progress bar (.reading-progress clips instead of translating so the gradient stays anchored), NuxtLoadingIndicator, and the ClientSite OG image. Never on buttons or text. It does not override the accent; the branding editor offers to adopt the first stop when hand-picked stops drift from it. Secondary actions use color="neutral" outline/soft.

  • app/components/AppMedia.vue is the shared aspect-ratio-safe media primitive. Stable local and allowlisted CDN URLs render through NuxtImg as responsive WebP; untrusted or signed URLs stay direct. app/composables/useImageFallback.ts falls back to the original upload when supplied and skips a failed URL for the rest of the session; it never re-requests with a cache-busting query (flicker, defeats caching, breaks signed URLs). New AI article images pass through server/utils/images/optimize.ts before upload (WebP, max 1280 px, immutable cache), while IPX gives existing uploads responsive variants without a database migration. Its stall timeout (2 s priority / 4 s lazy) is armed only after the component mounts in the browser and the element is on screen. The failed-URL registry is a request-isolated useState<Set<string>>, never a module-level server Set; hydration ignores previously recorded failures for its initial source, and server handlers cannot record image failures. A priority-image timer during slow SSR used to poison the shared Set, making later requests render placeholders while a fresh browser expected <img>. The browser-only timer and request isolation prevent that hydration mismatch. The visibility guard remains necessary — a wall-clock timer started at mount declares every loading="lazy" image below the fold failed before the browser requests it, and useImageFallback then skips that URL for the whole session. That is what emptied the favicons in Article/SourceList.vue, which renders under the article.

  • app/utils/sourcePresentation.ts splits a citation into hostname and path and builds its icon URL; Article/SourceList.vue (public) and Article/Sources.vue (editor) share it. Icons come from t1.gstatic.com/faviconV2 keyed on the full URL — www.google.com/s2/favicons?domain= only 301s to the same place, costing a redirect per row.

  • Article interaction surfaces keep state visible at the control that owns it: comment like/dislike buttons carry their own pressed treatment, the terminal comment-page sentinel says there are no more comments instead of claiming there are none, and Article/Drafts.vue uses bounded two-column selectable cards rather than an overlay button that could escape or intercept the card layout.

  • useClientSite() preserves its plain nullable snapshot API for callers that branch on its truthiness. useLiveClientSite() returns the ref for the same useAsyncData entry through one fetchClientSite. Persistent app.vue, Header.vue, Footer.vue and layouts/default.vue consume that live form, so saved branding, metadata and publication languages react without a reload. Settings saves, website-import application and font uploads use the shared invalidator; both refresh:manual and refresh:hook bypass stale payload data. OG image payloads remain initial-render snapshots under the installed module's contract; a fresh server render uses the saved values.

  • The brand-asset editor (Form/Client/LogoUploader.vue) is assembled from system primitives: UFileUpload for the empty state (it owns the dashed dropzone, drag-and-drop and the dragging state), USlider for zoom, UAlert for the failure. It previously hand-built the dropzone from a bare <UButton>, which defaults to solid primary — the whole 224 px zone rendered as a blue slab and its dashed border and hover states were invisible against it. useAvatarCropper exposes acceptFile so a file can arrive from a drop, not only from its own file dialog. Transparency behind a logo or favicon is drawn by .transparency-grid in main.css from --ui-bg-accented, so it follows the theme instead of a hardcoded gray per color scheme.

  • Templates may only render tags that resolve. Form/Client/LogoUploader.vue was written against a <Modal> and Form/Client/IntegrationsCatalog.vue against a <FormField>, neither of which exists — Vue renders an unresolved tag as an empty element, so the brand-asset editor and the analytics fields were mounted but invisible with no error beyond a console warning. Both now use the real components (UModal with #body, AppFormField), and nuxtUiContract.test.ts resolves every PascalCase template tag against app/components/, Nuxt UI and an explicit external allowlist.

  • AppFormField with type="textarea" and maxLength shows a n/max counter in the field hint, amber from 90 %. The bio limit is BIO_MAX_LENGTH in shared/utils/profile.ts, mirroring @length(0, 300) in user.zmodel; users/[id] PATCH enforces it in zod as well.

  • A rejected upload always names its reason. File/Uploader.vue toasts a common.upload.* message with the measured value against the limit instead of a generic failure, and server/api/upload.ts returns the same keys translated, so a client-side refusal and a server-side one read alike. The favicon limits are one source of truth in shared/utils/favicon.ts. A file the browser cannot decode resolves the probe to null — the earlier Image.onload-only promise stayed pending forever, which is what made a bad favicon fail with no message at all.

  • Custom emoji management is optimistic in both directions: create inserts the local object-URL preview while the upload runs and swaps in the server record on success; delete removes immediately and restores the record at its previous position if the request fails. Related and author article queries project only the current viewer's reaction and collapse it to likedByUser, so every ArticleCard renders its initial heart state without exposing reactor identities. Author names are URI-encoded before their SSR data request, including generated AI names containing spaces.

  • Article/Collection.vue keeps pagination outside its card grid so it always spans the collection below the final row. Tenant settings read back the complete editable brand kit (tagline, favicon and typography included); omitting a field from clients/[id] makes a successful PATCH appear to revert as soon as the form refreshes.

  • The tagline is a standfirst, not a kicker: 80 characters is too long to sit beside the logo. The homepage masthead renders it below the name with wrapping, above the lighter description; the empty state uses the same hierarchy. brandTitle() puts it in og:title only — <title> stays the bare name because it is per-page and templated.

  • Visual style (Form/Client/Branding.vue) follows the onboarding look: inline preset/custom swatches with a labelled hex row in BrandColorEditor, selectable font samples in BrandTypographyEditor, and a publication preview with the tenant domain as the tagline fallback. BrandColorInput supports combined, swatch-only and hex-only presentation; logo palettes, gradient editing and BrandFontSlot uploads retain the same state and mutations. Choices are URadioGroup variant="card" indicator="hidden". Grids use container queries (@md:): at xl the form column is ~390 px beside the preview, so viewport breakpoints split it too early. Below hasAdvancedBranding gradient presets are disabled and font slots are not rendered; each shows one unlockPro link to settings?tab=billing. Gradient presets derive from the accent, so a stored gradient can match none — the "Customize" trigger always swatches the stored one.

  • tests/unit/i18nCompleteness.test.ts keeps all four locale key sets synchronized, verifies placeholders and HTML tags against English for German and French (including master and email copy), and rejects unresolved static translation calls.

  • tests/unit/languageRegistry.test.ts checks every place that enumerates languages (schema enums, i18n locales and their files, localized pages, shared language tables, number formats, message/master/email files, language names) against LANGUAGE_OPTIONS or UI_LANGUAGE_OPTIONS, so a new language fails with the list of what is missing. tests/unit/i18nMessageSyntax.test.ts compiles every message, catching stray @, unbalanced braces and broken placeholders before runtime.

  • playwright.config.ts and tests/e2e/ provide deterministic responsive visual and accessibility checks. They require a separate TEST_DATABASE_URL and refuse to reuse DATABASE_URL. The release-smoke project is deliberately separate from the dev-server matrix: CI first installs with the Docker-pinned Bun version and --frozen-lockfile, builds the real Nitro .output, then opens a seeded public article with service workers enabled. It verifies same-release JS/CSS availability, rejects SRI on mutable Nuxt chunks, reloads under the active worker, and pins /sw.js to a non-cached, non-app-shell navigation configuration.

  • Plan and feature mutations use a single database lock order: ClientSite first, then ClientFeature. syncPlanFeatures acquires the parent lock through a normal ORM update (and therefore needs no raw row-lock query); short plan/feature transactions run at serializable isolation and retry PostgreSQL serialization/deadlock errors (40001/40P01) twice. Membership acceptance/removal likewise orders User before Session. Scheduled publishing writes its globally serialized audit-chain entries only after the article transaction commits, so the advisory log lock cannot prolong article row locks.

  • Article generation quality: the review, revision and publication gate are described under "Editorial gate" above. Selected polls, tables and body images are deliverables rather than optional hints; AI image fallback defaults on for AI-enabled tenants but remains individually disableable and visibly labelled. YouTube remains conditional on a verified URL. The editor hides unavailable format modules, keeps billing explanation compact, and replaces the shared shallow wallet state on reservation/settlement; Stop polls briefly past the HTTP cancellation race until the reservation is released. Review/revision tokens join accounting. YouTube lookup considers both model text and provider-returned search URLs before oEmbed verification. Research briefs retain only lines backed by provider-returned source URLs (server/utils/ai/researchEvidence.ts). Image providers require query terms in catalogue metadata before aspect-ratio selection, never shorten subject queries, and server/utils/images/selection.ts deduplicates asset/source URLs across cover and body. Documentary misses remain empty only when synthesis is disabled or fails; body images never become duplicate covers. Unseen stock images do not inherit model-invented captions. Repeated content slot markers render once.

  • Draft fact verification now performs a fresh forced web search over the written body and modules before copy editing, including on the deep-research path. Retrieved contradictions and UNSUPPORTED MATERIAL verdicts cannot be overridden by copy-desk approval; minor NOT VERIFIED qualifications are advisory. Research and verification accept a rule in force (law, regulation, official requirement) only from text the source shows to be in force; drafts, pending amendments and redlines count as evidence of the proposal only. Source-agnostic by design — no legal-source allowlist. Corrections and verified URLs reach revision and final source filtering; verification usage joins editorial accounting. Manual image synthesis is opt-in via allowGeneratedImages (default false); scheduled synthesis respects the AI-capable plan and explicit AI feature disablement. Cover and body both search existing archives even for generate intent before considering permitted illustrative synthesis. When permitted, missing documentary images may become explicitly labelled AI illustrations without documentary captions.

  • Official first-party media is publisher-agnostic: when images are selected, live research must open and identify the developer/publisher's official product, newsroom, press or download pages. server/utils/images/press.ts extracts declared Open Graph, Twitter, JSON-LD, preload, gallery and download assets (including separately hosted CDNs), while ordinary embedded images remain restricted to the official site's host family. Every page and asset fetch (images/publicFetch.ts, shared with fact-check and knowledge URLs) is HTTPS-only, re-validates each redirect, DNS-checks against private/reserved networks, connects to exactly the checked addresses (a pinned lookup on https.request; plain fetch would resolve again and was open to DNS rebinding) and is byte-capped; accepted raster MIME types are decoded, dimension/aspect validated, converted to WebP and cached on the platform CDN. Credits retain the official page, original asset and any explicit license metadata. A verified official YouTube video's thumbnail is the last first-party candidate before Steam/archive search and the visibly labelled AI fallback. There is no publisher hostname allowlist.

  • Game image discovery now queries the official Steam store screenshot catalogue before Openverse/Wikimedia or synthesis, independently of the text citation list (server/utils/images/steam.ts). Exact game-name/number matching prevents substituting prior installments, and per-article catalogue caches supply distinct assets for cover and body. Credits always identify the original game and publisher; catalogue shots are labelled illustrations rather than inheriting unverified scene descriptions.

  • Body image captions (ai/articleMedia.ts → images/caption.ts): the writer's caption describes the image it asked for, so only a freshly generated (kind: 'ai') picture uses it, as alt only; its figcaption holds just the AI label span, which Article/Parsed.vue strips (and then drops the empty caption) for a tenant with discloseAiContent off. Searched photos keep provider metadata and the credit line. A credit renders as Foto: <name> (<licence>) (shared/utils/imageCredit.ts → creditSegments, shared by body captions, the cover and the Shopify export): the name links to the work's page; the licence stays visible because CC 2.0/3.0 require its URI. An illustrative image says so in its credit label (illustrationBy), so only an uncredited one gets the bare prefix. A generated cover is marked by a badge on the picture (Article/Header/Hero.vue, honours discloseAi), not a caption line. The article-level disclosure sits in ActionsBar's meta row: ArticleView hands it to the actions slot and renders it alone when no slot is given (editor preview).

Shopify article publishing

Shopify is a Pro/Premium/Custom integration in Form/Client/IntegrationsCatalog.vue, configured by Form/Client/Shopify.vue; Article/Editor/Shopify.vue is the editor header status button and popover for manual sends of the saved source-language article; Article/Editor/PublishDialog.vue lets the main Publish button target Topiqu and/or Shopify, and a save that remounts the page (new article, changed slug) hands the Shopify send over in history.state. useShopify shares tenant-keyed status. prisma/models/shopify.zmodel adds ShopifyConnection, ShopifyPublication and the system-only ShopifyInstallation (a store installed but not linked to a project). Tokens use AES-256-GCM and expiring offline refresh with a database lease. The publication worker stores an immutable HTML export, claims each send atomically, and retries temporary failures through server/tasks/shopify-publish.ts every five minutes. Unconfirmed creates are reconciled by deterministic handle plus topiqu.article_id, never blindly retried. Every send checks tenant publishing permission and the media-rights review. Secrets and export payloads are omitted from responses. Product import creates one SHOPIFY knowledge source per connection. Webhooks only queue a current-state sync and are never applied directly, so a late payload cannot restore old facts; a revision counter re-queues shopify-catalog when events arrive mid-sync, and a failed read never applies a partial snapshot. Price and stock stay structured facts (no re-embedding). Over the product quota, a stable prefix by Shopify product ID is imported.

The app is embedded with Shopify-managed installation; there is no OAuth redirect and no manual store domain (App Store 2.3). App Home is server/routes/shopify/index.get.ts, a Nitro-rendered document outside the Nuxt shell: App Bridge first, Polaris web components, a nonce-bound inline script and a per-shop frame-ancestors CSP (nuxt-security applies CSP only to Nuxt-rendered pages). It calls /api/shopify/app/session, authenticated only by the App Bridge ID token (verifyShopifyIdToken), which exchanges it for offline tokens. A linked store re-exchanges whenever its grant is not CONNECTED or lacks scopes, so opening the app is the reconnect. An unlinked or REVOKED store gets a ShopifyInstallation and a stateless signed link (signShopifyLink, 15 min). GET /api/shopify/link moves it into an httpOnly cookie plus the readable shopify_install marker and lands on integration settings, via start after sign-in. POST /api/shopify/link needs INTEGRATION_CONTROL on the session's project. It moves the installation into a connection and replaces only REVOKED rows: a store never leaves a project that still has it connected.

Billing (App Store 1.2.1): ClientSite.billingProvider. Linking a project without a Stripe subscription (and not CUSTOM) switches it to SHOPIFY, which also needs BILLING_CHANGE. Existing Stripe customers stay on Stripe. Shopify App Pricing sends no webhooks: syncShopifyPlan (server/utils/shopify/billing.ts) reads the subscription on every App Home load, after linking and hourly in shopify-billing. fetchShopifySubscription asks the Partner API activeSubscription first; it answers only for public apps, so an app under review (or a Partner outage) falls back to the store's Admin API currentAppInstallation.activeSubscriptions. Admin API cycles are derived from currentPeriodEnd, so the allowance idempotency key uses the cycle end, which both APIs report. Plan handles (or, when the Admin API returns none, plan names) must start with pro or premium. When neither API answers, nothing is downgraded.

Plan selection comes before linking (App Store 1.2.2, paused review on 2026-10-29: the reviewer could not reach the listing plans). App Home reads the subscription of an unlinked store too, with the ShopifyInstallation token, and /api/shopify/app/session returns subscribed (null: unknown, or a Stripe-billed project). On subscribed: false the App Home script sends the top frame to Shopify's hosted plan selection page (shopifyPricingUrl) once per browser session. The sessionStorage flag is cleared when a plan is seen, so a reinstall, whose uninstall cancelled the plan, asks again, while a declined selection cannot loop. The welcome link (plan_handle) never redirects, because the new plan may not be readable yet. The plan button stays visible in both states. Without a subscription, the plan drops to BASIC only if Shopify had set it (planHandle), so a running Topiqu trial stays with the trial job. Allowances go through grantPlanArticleCredits (shared with Stripe). Annual plans continue in grant-annual-article-credits. Uninstall hands billing back to Stripe; a Topiqu-side disconnect keeps Shopify billing. rejectShopifyBilled blocks Stripe subscribe/checkout for SHOPIFY projects and while shopify_install is set. The Stripe UI (Billing, article packs, upgrade banner, paid plans in TenantCreate) is hidden accordingly.

shopify.app.toml (embedded, scopes declared here, uninstall/compliance/catalog webhooks) is released with shopify app deploy, which replaces the whole remote config. SHOPIFY_ENCRYPTION_KEY must never rotate: stored tokens become unreadable.