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.
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
AppMediaimages 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 areno-store, and the service worker no longer runtime-caches responses; its activation script removes the oldapi-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:
requireCronfails 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'seditVersionunder a shared row lock before committing; changed sources return toSTALE, 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 ORMupdateManydoes 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::articleTextMetricsowns 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 inshared/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
Verificationtable; 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 underbun;scripts/prepare-migrations.tspackages 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.vueowns the wordmark, account, interface language, theme and notifications. Its drawer trigger uses the samelgbreakpoint as Nuxt UI's sidebar;Sidebar.vueputs 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.vueonly allows revisiting steps reached through Continue, and revalidates details before Review. -
Dashboard setup:
Admin/SetupChecklist.vuesits directly above theClient/Version.vuebar 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.buildSetupChecklistinshared/utils/siteSetup.tssplits the steps into two phases.startis 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.laterholds 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 collapsedlatergroup opens oncestartis 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, orAdmin/SetupBrief.vuefor a blog without a website. The draft step lists the import's topics (WebsiteImport/Topics.vue); while the website step is open,WebsiteImportNoticehides (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 smallerbuildSetupStepsrequirements on the empty publication, where logos and knowledge imports remain optional./api/clients/statususes 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.hasDraftalso counts a COMPLETEDArticleGenerationSession, read with the system client because the model denies every policy read: a new editor autosaves toArticleDraft, so noArticlerow exists yet. Article invalidation, knowledge-source changes and saved author uploads refresh the shared status snapshot; the checklist never changes generation or publication settings.ensureMinAccountAgerefuses withACCOUNT_TOO_NEWandretryAfterMs;useArticleGenerationwaits 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.vuereads the active session tenant status and displays the existing verification API's TXT/CNAME instructions on the dashboard and branding settings./api/domain/pendingalso 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].vueowns route middleware/meta, an immediate Suspense boundary and a local error boundary with an explicit reload action. The existing editor lives inArticle/Editor/Workspace.vue;Article/Editor/Skeleton.vuematches 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.vueis 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 becauseUSliderhardcodes the thumbaria-labeland 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 bytests/unit/controversyScale.test.ts. -
Content settings:
Form/Client/Content.vueandForm/Client/Community.vuefollow the AI tab layout:h2page header, then groups with anh3outside a card, each group using a different card type (field grid, divided rows, switch rows with icon tiles). Keywords useUInputTagswithaddKeywordsas the setter for case-insensitive dedupe; language usesUSelectbecauseUSelectMenu's triggeraria-labeloverrides<label for>. Covered bytests/unit/contentBrief.test.tsandtests/unit/tenantPublicationSettings.test.ts. -
Structured AI output: use
generateText/streamTextwithOutput.object({ schema }); read the validated result fromoutput.server/utils/ai/articleStream.tscombines the SDK'sstreamactivity/errors withpartialOutputStreamdrafts so manual generation keeps its progress signals, watchdog and recovery snapshots. The endpoint's NDJSON contract remains unchanged.tests/server/ai/articleStream.test.tscovers 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.mdunder 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 inmain.css. -
Article tables use shared
ARTICLE_TABLE_CLASS/EDITOR_TABLE_CLASShooks fromshared/utils/articleProse.ts.Article/Parsed.vuewraps published tables in a scrollable surface;Tiptap/Editor.vueadds editor-only chrome such as selected cells and resize handles. Both are styled inmain.css. -
Article spacing lives in
main.css.ARTICLE_PROSE_CLASShides legacy empty paragraphs and trailing line breaks in both the reader and editor preview.dropBlankLinesnormalizes the same markup when saving/generating content. -
Reading progress is scoped to the article body, not the full page.
useArticleScrollContextmeasures 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 markeddata-article-scroller(the editor preview) takes precedence over the dashboard scroller. -
Article/View.vueis the one article layout — hero, tags, summary, body, FAQ, series, sources, TOC.pages/clanky/[slug].vueadds engagement (follow, likes, share, comments, ads) through slots;Article/Editor/Preview.vuefeeds it unsaved editor state inside a fullscreenUModal. The preview wraps it in.publication-surfacewithtenantThemeStylefromclientsite-status, because the editor runs on the platform host whereuseClientSite()isnull. A capture-phase click handler blocks in-app links so the preview never navigates out of the editor. Global.article-contentrules belong inmain.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.tsowns supported codes, language tags and picker labels;shared/utils/routes.tsowns localized article, tag, author and auth segments outside Vue. TheLanguagedatabase 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 toUser.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→ ignoredgenerated/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 singleFigcaptionchild, 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-alignincl.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 anystyle. 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 inlineimagenode.AiDisclosurekeeps<span data-ai-disclosure>through edits. Selecting an image (thefigureSelectplugin; ProseMirror only click-selects leaves) showsTiptap/ToolbarImageBubble.vue: alt/title/link (ImageDetails.vue, optional write-back toMediaAsset.defaultAltText), width presets, alignment, replace from library, delete.File/TiptapImageFrame.vueadds drag-resize handles and a missing-alt badge. Floats apply fromsmup (main.css); a linked image navigates instead of opening the lightbox. -
AI: AI SDK 7 with
@ai-sdk/anthropicand@ai-sdk/openai. All task, image, embedding and visibility model IDs live inserver/utils/ai/modelRegistry.ts. OpenAIgpt-6-lunathrough Responses handles article writing, translation, text editing, brief enhancement, LinkedIn posts, backlink outreach and the ChatGPT visibility probe. Other text tasks run onclaude-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 efforthigh(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, soai/article.ts::REVISION_TIMEOUT_MSallows 5 minutes; 90 s left no margin under load, and an aborted revision silently kept the unrevised draft. Research and editorial/factual reviews usehigh; translations, text edits, short copy, helper tasks and the native Claude visibility adapter usemedium.ai/models.tspicks the provider per task (aiModel,aiWebSearchTool(task, depth): Anthropicweb_search_20250305withmaxUses3/5/8, or OpenAIsearchContextSize). Both providers count reasoning against the output limit, so middleware adds 8k/16k/32k/64k tokens atmedium/high/xhigh/maxto the caller's answer budget; explicitly disabled thinking gets no extra headroom. Haiku rejectstemperature/top_p/top_k. Guarded bytests/unit/aiModels.test.ts,tests/server/ai/articleWritingQuality.test.tsand actual SDK request/response tests intests/server/ai/models.test.ts, without live API calls. Images usegpt-image-2.5-flarewith explicitmediumquality; knowledge embeddings remaintext-embedding-3-smallat 1536 dimensions. Visibility HTTP adapters read the centralAI_VISIBILITY_MODELSregistry 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.articleQualitydefaults toSTANDARD;Form/Client/ArticleQuality.vueedits the setting through the existing tenant settings form.shared/utils/articleCredits.tsprices 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. The20261022170000_article_credit_quality_countsmigration 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.articlesRemainingremains the API field for available credits.ai/articleUsage.tsattributes language, image and embedding token usage to an attempt, operation and stage through request-local middleware;ai/spend.tsandaiAllowance.tsenforce separate provider-cost funding, detailed under AI allowances and invoice receipts below. -
Default editorial author:
server/utils/ensureAiAuthor.tscreates or reuses the tenant's oldest active AI author, with a localized publication-based name fromshared/utils/aiAuthor.tsand 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.tssplits intoresearchTopic(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_CONFIGtimeouts 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/generateemits NDJSON progress, the actual reservation, finalized content, then an authoritativebillingevent with the new balance. Events carry outcomes, not just counts: research fallback reason, the YouTube outcome (foundwith oEmbed title /noCandidates/rejected/failed/researchOff), per-slot image source (official/screenshot/library/ai/null), review issues, server-computedmissingModulesonfinal, andstage+creditReturnedonerror. The client folds them with the purereduceGenerationRun/generationSteps(shared/utils/articleGeneration.ts) into oneGenerationRun, rendered byArticle/Editor/GenerationRun.vueabove the title; the sidebar only holds the preset form. While running it shows a step track, the active step live (the streamed H2 outline viawrittenHeadings, 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/Pickerfor a missing image, the YouTube prompt, the cover setting. A clean run folds into its header. Themediaevent carriescoverUrl/slotUrlsfor thumbnails and, oncomplete,gaps: each unfilled slot with its query andafter, the whitespace-free text of the block the marker followed (imageSlotAnchors), because the marker itself is stripped. The editor (Tiptap/Editor.vuefillImage/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_LIMITSin 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. UntiltextAtthe 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 (salvageBodyinserver/utils/ai/draftCut.tsdrops the unfinished last block),completeDraftwrites the remaining fields in one more call with the writer's config, the run is reviewed and finished as usual,final.cutmarks it and it is not charged. The run itself lives in thearticleGenerationPinia store, not the page: leaving the editor keeps it going, the editor re-attaches by target (new/ article id) andArticle/GenerationActivity.vueshows 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 tosessionStorageand 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 pollsGET /api/articles/generations/[id]/statusand takes the finished article from the session snapshot, marking the steps it never heard about asresumed. A disconnected stream keeps generating on the server; onlyPOST /api/articles/generations/[id]/stopaborts 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:unwrapContentSlotslifts a marker out of an<img>/<figure>the writer wrapped around it, anddropAuthoredImagesremoves images the writer wrote itself. -
The generation report expands the research source count into the existing
Article/SourceList.vue. Editorialreview-progressevents 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'ssettingsTab): Article (cover, sources, series, tags, release), AI (GenerationForm.vue, orAiUpsell.vuewithouthasAiPlan), Checks (optimization, fact check — locked without an AI plan, since/api/articles/fact-checkis 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), butfocusOptimizationTargetstill switches to Article before scrolling, because a hidden pane has no box. -
Phone editor layout (
Article/Editor/Workspace.vue,Tiptap/Toolbar.vue). Belowsmthe 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 fromxl, where--editor-command-bar-heightalso offsets the toolbar. The toolbar collapses again after a command and when the text takes focus; the character count moves under the text ascompact;main.csshides[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;openEditorActionsintests/e2e/helpers.tsopens the panel for specs that use secondary actions. -
No
v-ifon 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 togglesedit, which froze the whole editor sidebar. They hide throughshouldShow+editor.isEditableinstead. -
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'scoverImage.broaderQuery(the place, organisation or field) is searched next and credited as illustrative, then a labelled AI illustration follows — for aphotosubject throughillustrationPrompt, which excludes people, logos, text and the real event. Body images keep the empty-slot rule, except in a manualstory(illustrative): its scenes were never photographed, so the writer asks for real places and franchise material asphotoand invented scenes asgeneratewithout named characters, and a photo slot nothing matches falls back to the same people-freeillustrationPromptwhen 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 thephotoSubjectQueryretry) is therefore downloaded through the SSRF-safefetchPublicUrl(Openversethumbnailpreview, ≤ 4 MB, raster types only) and shown to Haiku (imageCheck, thinking off) with the article title and the wanted subject;photointent requires the subject itself,stock/generateany 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 animage_checkaudit step with the verdict. Cost is roughly $0.0001–0.0002 per look. Commons matching also reads only the first 200 characters ofImageDescription, since a long description contains half the words of almost any generic query. Provideracceptcallbacks may be async (ImageAccept);createImageSelection().takenpeeks 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/applyFormatuntouched;allowedModulesonly 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 preselectsARTICLE_GENERATION_DEFAULT_MODULESon a format change and keeps the author's media choices. The picker also getsaiToneOfVoiceandaiControversyLevel(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_VERIFICATIONinarticleQuality.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 (
editorialPolicyinserver/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.aiControversyLevelsets 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,revisionEvidencedrops NOT VERIFIED lines before revision (they stay in the audit), and the copy desk reports remaining caveats as blockinghedging. Astoryis 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), soSTORY_CRAFTinformats.tssets 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 asbroken_structure; a list in a story body is a deterministic issue. A requested video is owed only when research appended aVerified YouTube videoline (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 (theX-UI-Localeheader the generation request sends, else thei18n_langcookie, viauseServerI18n().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 Englishnotefor the reviser and carry akey/paramsthatGenerationRun.vuerenders fromarticles.editor.ai.run.note.*. After a verified revision the follow-up desk pass (factsChecked) treats its ownunsupported_claimas advisory and blocks onlyinvented_detail(a quote, number, date or source absent from the grounding); the editor shows those remaining issues instead of a green review step. Measure withbun run eval:ai(tests/eval/, live model calls, skipped in CI). -
Assignment fidelity is part of editorial approval.
editorialPolicycarriesaiToneOfVoiceto 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 blockingassignment_mismatch. HIGH analysis/comparison get a lighter directive (committed): argue a thesis in the requested tone, and a neutral explainer is a blockingassignment_mismatch. A civil assignment still overrides a profane publisher tone. SharedAUTHORIAL_REASONINGdistinguishes expressive hyperbole from concrete factual allegations for the verifier too; factual repairs preserve the rest of the argument's force.articleQuality.tstreats substantiveassignment_mismatchas 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.tsuses 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, thenarticleQuality.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 carriesblocking. The copy desk may set it only forinvented_detail,assignment_mismatch,missing_specificswhen 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_MS180 s, counts as incomplete) and the code-checked structure rules always block. A first review that throws keeps the draft with a blockingreviewUnavailable(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'smissing_specificswas 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.tsrecords 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 asARTICLE_GENERATION_FALLBACKoutcomepartial); 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.recheckRevisionthen receives only thechanges(before/after) instead of two full drafts. A full rewrite (buildRevisionPrompt) remains for a blockingassignment_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.revisionModerecords which ran. Edits run at reasoning effortmediumwhatever 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 nosourcesfield, so facts the reviser took from the independent verification reached the prose while their URLs could never joinsources. 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;applyRevisionEditsappends them (never in removal-only mode) and the usualfilterResearchSourcesagainst 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_claimother than an incomplete verification), the copy-desk model returns delete-only edits andapplyRevisionEdits(..., { 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_claimedits may ask only to narrow a claim to the grounding or delete it, never to verify or add specifics.recheckRevisionresolves 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-sentencereasonper unresolved finding, logged with the review step (unresolved). Revision steps logrevisionMode(edits/rewrite/removal), because the audit's ownmode(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.tswritesArticle.releaseHold(ReleaseHoldfromshared/utils/articleGeneration.ts::releaseHoldOf: reasonsreview/research/media/coverplus the blocking findings).Article/Editor/ReleaseHold.vueshows it on the source-language draft with "Publish anyway" through the normal publish flow (media-rights confirmation included). Admin-only (ADMIN_FIELDSinarticles/[id]/index.get.ts); the article PATCH strips client-supplied values and clears it on publish or schedule. The same run callsserver/utils/articleHeld.ts::notifyArticleHeld: an in-appSYSTEMnotification linking to the editor and thearticleHeldemail, to owners and members withARTICLE_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
maxLengthby cutting the text mid-word (a German perex ended "welche Kosten mitged").articleSchematherefore leaves headroom (title +35, perex +90 characters) andformats.ts::applyFormattrims the perex to the SEO maximum withshared/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/enhancetakes anactionfor the brief (questionsreturns questions whose answers are appended as the author's details;sharpen/shorten/grammarrewrite it).POST /api/articles/rewriteedits a selected prose passage from the Tiptap text bubble (useTiptapRewrite); input and output go throughsanitizePassage(prose tags +hrefonly), and a range the author changed meanwhile is not overwritten.improverequires a free-textinstruction(TiptapRewritePrompt). The toolbar's whole-article variant sends only the text runs of paragraphs and headings asblocks(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 toAiUsage, 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.languagerecords each article's source language independently ofClientSite.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 migration20260927180000_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.useArticleDraftskeeps the returnedArticleDraft.idand sends it on later autosaves so one editing session updates its recovery draft;POST /api/articles/draftscopes 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 containskind, 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.tsholds six broad formats, several dramaturgical variants per format, and each format's eligible modules (answer, takeaways, FAQ, poll, table, body images, YouTube).topicSchemarequires 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 andapplyFormatclears unselected structured modules.server/utils/ai/articleQuality.tsis 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;newsand 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.generationFrequencyisDAILY/WEEKLY(localreleaseHour±FIXED_JITTER_MINUTES) orINTERVAL(a random gap ofintervalMinHours–intervalMaxHours, min 6 h, landing inside the localreleaseWindowStart–EndonreleaseDays), all in the tenant'stimeZone. Slots come from the pureshared/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 innextReleaseAt.generate-articleruns every 15 minutes and picks sites whose slot is withinGENERATION_LEAD_MS(3 h).planRun(server/utils/generationSchedule.ts) decides; a guardedupdateManyon the readnextReleaseAtclaims 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 = slotplus its media-rights snapshot, andpublish-checkreleases it — the same path as a human-scheduled article. A slot already in the past publishes at once. Without auto-release noreleaseAtis set, becausepublish-checkpublishes every draft that has one. - Stock origin follows the licence (
stockOrigininarticleMedia.ts): CC0,Public Domain Markand CommonsPD-…register asPUBLIC_DOMAIN, otherCC …(except NC — a tenant blog is commercial) asCREATIVE_COMMONS, anything else asEXTERNAL, which always needs a person to confirm. Store media registers asPUBLISHER(uploaded by the publisher for promotion; cleared with a source):images/stores.ts → STORE_MEDIAlists 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'ssourceUrl. 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 asOFFICIAL MEDIA. Press-page images and YouTube thumbnails stayEXTERNALunless a licence is declared: a schema.orgImageObject.licenseper image, or the page'srel="license"for files on the page's own host (press.ts,ccLicenseName). Images from the publisher's own knowledge pages (publisherPagesinarticleConfig.ts: feed products and pages on the blog orwebsiteUrlhost family) register asPUBLISHER, matched by the credit'sauthorUrl; those pages load apart from the research pages (4 each) and rank first on ties. A re-find upgrades an unconfirmedEXTERNALasset to the more specific origin (registerMediaAsset). With auto-release on, generation passesrequireClearRights: providers (ImageProvider.searchaccept) skip candidates whose prospective asset failsmediaRightsIssuesand 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 anyRELEASE_SCHEDULE_FIELDSis validated as a whole (releaseScheduleError) and restarts the rhythm from now; revokingARTICLE_CRONSclears the slot. CRON_ARTICLE_SKIPPEDcarries an idempotency key per slot (or per day without one), so the 15-minute cadence logs one entry per missed slot.- An
insufficient_articlesskip emails the tenant's verified owners andBILLING_CHANGEmembers (server/utils/creditsDepleted.ts, templateemails/templates/creditsDepleted.mjml). TheCREDITS_DEPLETED_NOTIFIEDaudit 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.aiSeriesEnabledenables a conservative classifier after a scheduled article is finished;server/utils/ai/articleSeries.tsmay attach it to an eligible recent series or create one only by grouping it with one or two real, recent standaloneFULL-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};insertArticleLinkskeeps a link only for an offered target whose anchor is verbatim whole-word text in a bodyp/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 recordssiteCandidates/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 thesubject, 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), followsArticleSlugRedirect, 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 nodata-article-idto keep alive through TipTap or translation. The editor'scontentstays unresolved. Theinternal-linkoptimisation check counts only links to an article, not the homepage. -
Search Console Autopilot is an explicit write opt-in, gated by active
SEARCH_CONSOLE+AIfeatures andSEO_AUTOPILOT_PLANS(PREMIUM; configurable CUSTOM). TheSEARCH_CONSOLEfeature itself (connection, sync, opportunities) is granted from PRO. The nightlysearch-console-syncstores finaliseddate/page/queryperformance, thensearch-console-autopilotatomically 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 togenerate-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.
articleImagemoved off Imagen 4 Fast because Google shuts the Imagen family down on 2026-08-17;tests/unit/aiModels.test.tsnow fails on anyimagen-*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, somodels.tsis unchanged, but Gemini image models ignoren/maxImagesPerCall(dropped fromai/image.ts) and return PNG rather than WebP, whichimageExtension(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 throughDockerfile(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 anddocs/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/yesterdayand local days apply once mounted. -
Series management:
/admin/seriesedits (Series/Edit.vue) and deletes series directly; deleting detaches the articles and keeps them.ArticleSeriesalso allows superadmins, like the neighbouring article models. Tests:tests/server/seriesManage.test.ts,tests/unit/seriesManage.test.ts. -
Media Library:
MediaAssetis 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/mediaandcomponents/Media/{Library,Picker,Detail}.vueshare search/filter/select behavior across cover and body images.ArticleMediaUsageis 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 onMediaAssetfor 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.tsowns 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 throughuseArticleFeed. Default factories inapp/utils/asyncDataDefaults.tsretain 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-gcreviews 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 physicalpurged: 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.tsis dry-run by default and registers legacy media/enriches records/rebuilds usage rows. Itsassets → articles → translations → draftsphases 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 emittednextCursor(PHASE:LAST_ID, or a bare phase);nullmeans 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.
- 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.
-
Cache / Redis:
server/utils/cache.tsusesioredisover the Redis RESP2 protocol viaREDIS_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.tscloses sockets/retries on Nitro shutdown.docker-compose.ymlstarts PostgreSQL only, so cache provisioning is separate; repository code cannot establish whether a live deployment has a ready cache./master/operationsreports 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
initialtokens 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:
mjmltemplates inemails/, sent via SES (server/utils/sendEmail.ts). -
Observability:
@sentry/nuxterror 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.
.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)
-
Nuxt and Nitro auto-import exports from root
types/andshared/through their respectiveimports.dirs. Nuxt's defaultshared/types/andshared/utils/scans remain enabled. Ambient roottypes/**/*.d.tsdeclarations are included separately throughtypescript.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.tsexposes their selected real or mocked dependencies throughvi.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 usev-model:openand@confirm. Cancel, Escape and outside dismissal return false. Article tag selection usesTags/Manager.vuefrom 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;mutedfor 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 throughnuxt.config.ts::i18n.pages. Admin sections live underadmin/andmaster/. Thestitky/[slug]andautor/[name]listings share the presentationalArticle/Collection.vue(search/sort bar, skeleton, card grid andhasMorepagination); 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 thev-if-collapsedNuxtImgthat made the page look broken), the name as theh1, the bio — andstitky/[slug]renders#tag(the#isaria-hidden, so the accessible name stays the bare tag). The removed copy is replaced by information: both endpoints return a policy-scopedtotal(db.article.countunder enhanced Prisma, so drafts stay invisible to non-owners) rendered via the pluralizedarticles.articlesCount.totalis deliberately unfiltered bysearch— it describes the author/tag, not the current query, so it does not flicker while typing.- While there:
api/tags/slug/[slug].tsselected each article's author withuser: { 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.ArticleCardDataonly ever readsuser.username, so it is now an explicitselect: { username: true }, matchingby-author. Rule of thumb this encodes:omitis a deny-list and leaks by default — public projections must beselect.
- While there:
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 usesarticles.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
primaryaccent rather than a fixed platform color. - A zero-article tenant renders a dedicated page, not a hole in the feed.
isBlankSiterequires a successful featured response withtotalArticles === 0, andArticle/Empty/Page.vuereplaces 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.vueis the shell (centred full-height section, gradient/dot backdrop, logo or name monogram) and picks one of two bodies byisOwner.Visitor.vue: site name ash1, description, a "coming soon" pill (articles.empty.badge) andarticles.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 isnoindexuntil the first article, primary/secondary CTAs toadmin-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.isOwnerisrole === '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 bytests/unit/dashboardSetup.test.tsandtests/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].tsreturns thetotalArticlescount it already computed, and the page emitsrobots: '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 blockinguseFetchrather thanuseLazyFetch: robots meta must be correct in the SSR HTML, and a lazy fetch would rendernoindexfor 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 drivesisBlankSite, sonoindexand the dedicated empty page always agree. - Series shelf and discussions are self-fetching lazy blocks (
Home/Series.vueunder Recommended,Home/Discussions.vueabove poll/top) that render nothing when empty.GET /api/series/by-clientsite/[slug]caches the series list (≥2 published parts, newest first, max 3) underfeedGen, then adds per-readerread/nextfromArticleViewoutside the cache, keyed by the same identityview.post.tswrites (user id, else anon cookie).GET /api/comments/by-clientsite/[slug]returns the latest comment per article (max 4), excludes actively banned users, returns[]whencommentsEnabledis 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/feedwhentenantGamEnabledand Marketing consent allow it. The placement is afterFEED_AD_AFTER(6) cards when more follow, with reserved creative space.
settings/index.vue(adminmiddleware) — full client settings, migrated from the formerClient/Preferences.vuemodal into a dedicated page. Sections are tabs driven by?tab=(branding/content/integrations/ai/billing, gated by plan +tokenLimit+billingPlan). Owns the client fetch + sharedform(seeded by the pureapp/utils/buildClientSettingsForm.ts), one stickyPATCH /api/clients/:idsave, and anonBeforeRouteLeaveunsaved-changes guard whose dirty check is afast-deep-equalofformvs apristinesnapshot (replaces the old hand-maintained field-by-field boolean). Consumescommon.preferences.*(incl. newpreferences.tabs.*). Opened fromSidebar.vuecog →router.push('/settings').
- Both listing headers are identity headers, not sentences. They previously led with
-
components/— grouped by domain:Admin/,App/,Article/(incl.Article/Empty/— the zero-article homepage, seepages/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 editorTiptapEditor.vue. The client settings sections live inForm/Client/(Branding/Content/LinkedIn/AI/Billing) +Settings/Nav.vue(tab rail), composed bypages/settings/index.vue. -
Activity comment media:
GET /api/users/activityincludesgifUrlfor comments and replies only when the comment is not deleted and its readable article's site enables comment GIFs.User/ActivityComment.vuerenders them with the sameGifcomponent as article discussions. -
Homepage discussion GIFs: the public discussion cache retains the selected
gifUrl;GET /api/comments/by-clientsite/[slug]applies the site's currentcommentGifsEnabledsetting after reading that cache so disabling GIFs takes effect immediately.Home/Discussions.vueuses the smallGifsize, 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, pastreleaseAt); taking a like back stays allowed. Whoever may already see the article (tenant admins viacanReadArticle, the author viacanSeeUnreleasedArticle) 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 returnsengagementOpen, so the page rendersLikeButtonandFeedbackclosed, andGET .../votereturnsopenforArticle/Poll.vue. Tests:tests/server/articles/{vote,reaction,feedback}.test.ts. -
Article likes:
Article/LikeButton.vueis 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 themdbutton size of the detail action bar and the sticky header; thesmoverlay buttons on cards and the homepage lead passsize="sm". The sticky header uses its icon-only form;useArticleHeaderContextcarries the same liked/count/pending state. All surfaces retainuseArticleReactionfor 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; userg --files app/composablesfor the current inventory.useTimeshares the time contract described below. -
useFollowStatuslazily reads the authenticated viewer's state for one author throughGET /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 forUser/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,timeasIntl.DateTimeFormatOptions, plus therelativepreset).<AppTime :datetime preset>(App/Time.vue) wraps the built-in<NuxtTime>— SSR-safe, live-ticking relative, locale auto-injected fromuseI18n— for templates;useTime().formatTime(date, preset, localeOverride?)is the JS-context counterpart (table cell renderers,$tinterpolation) using the same presets viaIntl. Prefer these overdate-fnsformat(). The smart hybrid relative/absolute timestampformatDate(shared/utils/index.ts, custom thresholds +articles.dateFormats.*keys) is a deliberately separate concern and still usesdate-fns. -
Date inputs —
<AppDateInput v-model [time] [min] [max]>uses Nuxt UIUInputDateandUCalendar, with locale fromUApp.app/utils/dateInput.tspreserves 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 =
clientIpinserver/utils/ip.ts, the only place that reads it (getIp, login rate limits, sessions, audit). Traefik replaces a client-sentX-Forwarded-Forwith its peer, so only the last entry is trusted; when that peer is a Cloudflare edge (hardcoded Cloudflare ranges) the visitor comes fromcf-connecting-ip, otherwise the peer is the visitor (DNS-only tenant domains). Taking the first entry, orcf-connecting-ipunconditionally, 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 andPOST /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 (onlyemail_not_verifiedandrate_limitedget their own code).lastLoginis written only when a session is created, andAuth/Form.vuestops 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)./accountreturns onlytotpEnabled. Setup keeps the pending secret in a signedtotpSetuptoken, because a stored but unconfirmed secret already gates login, and writes it only after the first code, guarded ontotpSecret: null. Turning 2FA off needs a current code (a superadmin clearing another user's is recovery). Every code check goes throughcheckTotpCode(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.PATCHdropsdeletedAt, 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 aCUSTOMblog 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 todeletionRequestedAt, so it dies once used and cannot restore a later deletion. A reminder goes outACCOUNT_DELETION_REMINDER_DAYSbefore the end. - Billing. Blog deactivation leaves subscriptions running, so deletion sets
cancel_at_period_endon every subscription of the blogs it closes, read from Stripe by customer andmetadata.clientSiteId, including blogs already deactivated. The row lock onUseris 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.closingSubscriptionIdsrecords 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. WhileclosingAtis set,assertBlogNotClosingfreezes 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/closingSubscriptionIdsmirror the blog fields and the same end/resume code runs on them (ClosingStore), matching subscriptions bymetadata.billingAccountId. Checkout and plan changes answer 409AGENCY_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-deletiontask, hourly). TheUserrow stays:Articlecascades from it, which would remove articles from other people's blogs;Logrows form an HMAC chain;OnboardingRequestrestricts 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:@usernamein comments and the name in notifications, by whole-word regular expression, plusArticleRevision.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.
Logrows 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 toprivacy@topiqu.com.OnboardingRequestkeeps its keyed email hash, so onboarding with the same address stays refused while direct registration and OAuth work.
- Restorable period. The account is deactivated for
-
Account export is
GET /api/users/export(session user only, no ID in the URL, 5 per hour, audited asUSER_DATA_EXPORT).server/utils/userExport.tsstreams 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 onAccount,ArticleView,ApiKey,MediaAssetandKnowledgeSourceexist for it. Article translations,ArticleGenerationSession,OnboardingRequest(policies deny all) and LinkedIn approval notes are not included; those requests go throughprivacy@topiqu.com. An audit row exports its IP only forSECURITY_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>, andnode_modules/<name>is another version.pdfkitneeds@noble/hashes1.x, which imports itself by name, so under Bun that import reached 2.x and every PDF route answered 500 at load.scripts/linkNitroSelfReferences.tsruns on thecompiledhook and links each versioned package to itself.tests/e2e/release-smoke.spec.tsrenders 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.Useris 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) isadmin && user.clientSiteId === article.clientSiteId— the exact@@allow('all', …)onArticle, 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].vuehadrole === 'admin' && session.user.id === data.user.idinline and hid the edit/status/comments controls the server would have accepted;pages/index.vue'sisOwnernever had that clause. -
Comments expose only public commenter fields.
GET /api/comments/:articleIdapplies 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.useCommentThreadsmerges/indexes iteratively, preserves loaded replies and owns cancellation/retry;Comment/Section.vuerenders flat parent-before-child siblings rather than a recursively deep component tree.publicationLikescounts active tenant members.useCommentPermissionsmirrors server moderation gates, and mutations additionally requireCONTENT_MODERATE. New-comment notifications/reports useserver/utils/commentAudience.tsfor 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 asdescription, 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.tsreads thecreateErrormessage off a $fetch error with a fallback. -
Publication links use
app/utils/publicationUrl.tsand 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.vuesizes 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.vuesupplies 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
Articleroots through the enhanced client before pagination, because publicArticleTagjoins 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.footerSettingsstores 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. Apply20261020010000_publication_footerbefore deploying code that selects this field. -
Data-fetching split (HARD RULE) — two layers, chosen by whether the response is SSR/SEO-critical:
useFetch/useAsyncDatafor 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 driverobotsmeta — do not migrate them.- A
useFetchpayload you write back into needsdeep: true. Nuxt 4 defaultsdeeptofalse, sodatais ashallowRef.pages/clanky/[slug].vuewriteslikes/shared/followerCountback into the article after each action; atriggerRefre-renders the page butArticle/ActionsBar.vuegets the same object identity, sohasPropsChangedbails and the counts only moved on a manual refresh. AtriggerRefbeside auseFetchpayload 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 throughgetEnhancedPrisma), 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.tsand are the invalidation contract; the prefix hierarchy (['articles'] ⊃ ['articles','list'] / ['articles','detail',id] ⊃ …tags / …available-tags, plus['clients']and['stats']) is pinned bytests/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 viaonServerPrefetchand bare$fetchdoes not forward the session cookie (401). Mutations are client-only and use plain$fetch. - Global defaults live in
colada.options.tsat the repo root, not innuxt.config.ts— the module declaresconfigKey: 'colada'but reads a root-level file, and silently falls back toexport default {}if it is missing.staleTimeis 300 s (library default is 5 s, which would refetch on practically every remount and defeat the point of the cache);gcTime30 min. The module also auto-importsuseQuery/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
dataundefined,rowsempty, and the table showed "no articles" for what was actually a broken request. Each migrated component derivesloadFailedfromerrorand an absence of data (so a refetch failure overplaceholderDatakeeps the stale rows visible instead of blanking them), and offersrefetch(). - 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 asArticle/Empty/Owner.vue, gated byARTICLE_WRITE,AI_USE, an AI plan and enough credits, plus the website import topics under them. The publicArticle/Collection.vue(author and tag pages) uses only existing reader keys. - Route-type blowups: typing a query as
requestFetch('/api/…')can triggerTS2321 Excessive stack depth— Nitro's route-matching types leak into Colada's generic inference. Fix by passing an explicit generic sourced fromInternalApi['/api/route']['default' | 'get'](nitropack/types), which keeps the real response type without re-resolving the route. For mutations, anasync … => { await $fetch(…) }body (concretePromise<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 dirtiesarticlesandstats; editing dirties onlyarticles; a tag edit dirties just that article's detail). Call it directly after the mutation — do not reintroduce an event bus. mittis fully removed (useArticleEvent/useClientEventdeleted, dependency dropped). It was only ever a cache-invalidation mechanism — every handler was a barerefresh()— 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) andTags/Manager.vue(embedded inArticle/Modal.vueand the editor) both hit/api/articles/:id/tagsand are both keyedqueryKeys.articles.tags(id). They previously held two independent caches, so adding a tag in one left the other stale — andArticle/Modal.vue'sinvalidateArticleDetail()only reached half its own subtree.Tags/Manager.vuekeeps its localtagBufferstaging 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 byenabled: () => !!props.article?.id. - Creating a tag dirties two roots — the article's detail and
['tags'], the tenant tag catalogue behind/api/tagsshared byTags/Manager.vueandTags/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 inUModal'sonEscapeKeyDownso it cancels the rename instead of closing the modal. ItsuseFetchisdeep: truebecause the optimistic edits mutate the list in place.- Migrated:
Article/Table.vueandClient/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 byenabled: () => !isBasicPlan). Mutation sites (Article/Modal.vue,Client/Create.vue,pages/admin/editor/[id].vue) invalidate rather than emit. Still onuseFetch: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+refetchIntervalcandidate).
-
Connection state is a state, not a toast —
Network/Indicator.vue(mounted once inapp.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 iscomposables/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 globalHeader.vueis a fixedh-18overlay: 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 attop-24, and the editor header attop-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 inlayouts/default.vue: the root wrapper isoverflow-x-clip, notoverflow-hidden.hiddenmakes that box a scroll container, andposition: stickyresolves 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 andFooter.vue— all four had been written as sticky and none of them stuck.clipstill contains horizontal bleed, which is the reason the class is there next tomax-w-screen, without establishing a scrollport (andoverflow-ystaysvisiblebesideclip, unlike besidehidden). Don't swap it back. -
assets/styles/— globalmain.css, loaded throughnuxt.config.css, imports Tailwind CSS, Nuxt UI and variable fonts. It owns shared theme tokens, publication and article styles, and editor/vendor overrides.
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 standaloneupload.ts.tasks/— Nitro scheduled tasks. All wrapped viadefineMonitoredTask(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 manualrunTask()cron routes. Missing heartbeat URLs disable only the external ping.SystemTaskStatusretains 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/operationsreads 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 — resolvesgetSessionUser, enforces optionalrole/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), plusai/andlinkedin/subdirs.linkedin/publisher.tspublishes drafts to LinkedIn behind an atomic claim (executePublishflipsDraftStatus→PUBLISHINGvia a guardedupdateMany, so concurrent cron runs / manual triggers can never double-post;PublishedPost.draftIdunique is the DB backstop).publishApprovedDraftis the cron/manual entry for human-approved drafts;publishDecisionAndExecuteis 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 bypublisher.tsand thelinkedin-synccron (which caches the token per company for the run). Integration OAuth now uses the centrally confirmed, one-use grants inintegrationOAuth.ts; see the integration contract below. The retainedlinkedin/oauthState.tshelper is not the connection-flow authority.
- Development auth accepts HTTP loopback origins, including
*.localhosttenant subdomains on development ports. Production keeps the existing Topiqu origin allowlist; local session cookies remain host-only. server/utils/auth.tsis the instance;getSessionUser(event)(cached onevent.context.authUser) andrequireUserare the only server entry points. Client state isuseAuth()(app/composables/useAuth.ts), filled byapp/plugins/auth.tsduring SSR; the Better Auth client library is not shipped.server/api/auth/[...all].tsforwards only an allowlist of paths. Better Auth also mounts/update-user,/change-email,/link-social, … which would bypass our validation, anddisabledPathsonly matches exact paths.- Credentials and the onboarding token are a local plugin (
/sign-in/credentials,/sign-in/token): passwords stay onUser.password, so there is no credentialAccountrow.Accountholds 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.createblocks deactivated users and stamps device, geo and the initial tenant (initialTenant);customSession→toSessionUserderives role/plan/tenant/scopes per read and drops a tenant the user left. - Session role
adminmeans "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 callrequireTenantScope(orrequireArticleAccess/requireDomainAccess/requireShopifyAccess/requireWebhookAccess/resolveAiUser);hasTenantScopegates fields and secondary checks.SessionUser.scopes(owner/superadmin: all) feedsuseTenantScope()for UI gating only.tests/server/tenantGuardWiring.test.tsfails on a non-GET route with no guard unless it is in its exemption list;tenantScopeMatrix.test.tsruns 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 inshared/utils/authError.ts. - Audit:
logActionwritesLogrows under an HMAC hash chain (advisory lock) and mirrors them to the logger asaudit:<action>. The account Security panel shows only session/password/role events, and/api/clients/:id/logkeeps a compact whitelist, so generation steps and config diffs stay out of both.ARTICLE_GENERATION_STEP_*/ARTICLE_GENERATION_FALLBACK(articleGenerationAudit.ts, keyed byattemptId/mode/stage) are best effort and never carry prompts, briefs, article bodies or provider responses.CLIENT_SITE_UPDATElogs 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-Forentry, which is the Cloudflare edge here.checkLoginRateLimit(clientIp) and thenuxt-securitylimiter on/api/auth/sign-in/**cover it.
components/Dev/Console.vue— a draggable, dev-only floating panel (Teleport to body,useDraggable+useLocalStoragefor persisted position/collapse). Rendered inapp.vuevia 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 onlocalhost; 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 viaCtrl+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 fromserver/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 tolanding.topiqu.comwas removed; the app no longer routes anyone to a landing subdomain. app.topiqu.com+*.topiqu.comcatch-all → this app project.- Wildcard TLS for
*.topiqu.comis managed by Dokploy itself (its Traefik certificate handling), not by a Cloudflare Origin CA cert or a hand-editedtraefik.ymlresolver. 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.
- Apex
- Auth / OAuth: OAuth completes on
app.topiqu.com(Google/GitHub authorized redirect URIs includehttps://app.topiqu.com/api/auth/callback/*; the apextopiqu.comURI also stays registered). Without a pinnedAUTH_ORIGIN, Better Auth derivesbaseURL(and so theredirect_uri) from the request host, limited totopiqu.com/*.topiqu.com.Auth/Form.vue#handleSocialAuth: onapp.topiqu.comit POSTs/api/auth/sign-in/socialdirectly; on any other host (tenant subdomain) it hops toapp.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.comsubdomain;trustedOriginsadmits only those ascallbackURL. - 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-transferrequires explicit account/hostname confirmation before minting a separate target session throughserver/api/auth/transfer/. The flow stores hashed browser proof and a single-use, 60-second authorization code in existingVerificationrows, 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. Seedocs/custom-domain-auth.md. - Interface preferences are account-global: Better Auth includes validated
language/themein session projections, andplugins/preferences.tsapplies them after auth, including custom-domain handoffs.topiqu-language/topiqu-themecookies share.topiqu.comonly 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.tsredirects 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.tsand/integrations-connecton the central auth origin, independently of login handoffs. A same-origin authenticated start creates a hashed ten-minuteVerificationgrant 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.tsstrips credentials from OAuth/handoff breadcrumbs, error events and traces.billingReturnUrlvalidates same-origin destinations and supported locale prefixes for Stripe. Session.originrecords 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/othersrequires 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 PostgreSQLArticle_revision_before_updatetrigger in20261022010000_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 requiresexpectedVersionfor 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.tslazily importsweb-vitalsonly 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.tsreads built Nitro manifests and compressed assets, deduplicates startup/preload scripts and enforces committed numerical CI ceilings. Field p75 targets and operator interpretation are indocs/operations/production-workflows.md.scripts/verify-backup-restore.tsis 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. Seedocs/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 onlyunknownand 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 standaloneofetchclient. 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.comis 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-appLanding/tree was removed as a duplicate of it. - Dev resolver note:
server/api/clients/slug/[slug].tsmatches{ OR: [domain, name] }outside production solocalhost(seededClientSite.domain = localhost) resolves to a tenant; production still matchesdomainonly. It returnspublicClientSiteSelectonly — see Who may read whichClientSitefield — souseClientSite()is typedPublicClientSite, and anything privileged has to come fromuseClientSiteStatus(). - Admin surfaces are bound to their own tenant's host (
middleware/admin.ts, coveringadmin,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 readauth.user.plan, whichtoSessionUserre-reads from the DB on every session read.
- Enrichment for the
generate-articletopic picker, never a prerequisite: failures log and fall back to "none". Everything needs DataForSEO credentials; outside productiondataforseo.tscalls the free sandbox. - Principle: keyword names, demand and news come from DataForSEO data; models judge relevance/reader intent or pick from that data.
keywordSeedsrewords the tenant's own focus into niche search phrases;keywordOpportunityproposes 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_suggestionsper seed (max 5,allSettled, fails only if all fail);dataforseo.tspreserves 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 passinScopebefore 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 Labsmonthly_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.tsruns onekeywordOpportunityassessment 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 inKeywordCluster.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.keywordPlanAttemptedAtclaims paid work with a one-day retry cooldown;keywordPlanRefreshedAtrecords only successful refreshes. A versioned profile binds usable candidates to the current focus, audience, seeds, language, market and domain;GET /api/keyword-planapplies 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,SKIPPEDandRESERVEDtopics with their synonyms, open topics of the current profile checked within 30 days, andKeywordRejectionrows of the current profile from the last 90 days, all matched byphraseKey(word order and accents, keeping the short tokenskeywordKeydrops, soiphone 15does not blockiphone 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 onKeywordCluster, and carry a reason:covered,duplicate,poor,navigational,other,rankingormerged. A pin is never remembered, nor is a candidate judged without SERP evidence, nor a scope rejection:inScopeskips when in doubt, so one bad answer would lock a niche's topics out for months, and asking again costs only tokens.KEYWORD_PLAN_REFRESHEDlogssuggestions,fresh,known,retainedandrejectedper 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-articleasks for three topics.keyword-plan-refill(hourly, at most five rebuilds a run) asks for two weeks of the rhythm (keywordPlanStock) on sites withARTICLE_CRONS, a schedule, an AI plan and credits for an article. A site without a schedule refills only when someone proposes a batch.shortageDuecounts 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.tsinScope: onetopicScopeclassifier 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 (videohryreturns 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, sonewsQuerystrips them. Stories older than 72 h are dropped; the rest go throughinScopewith 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 oknaanddotace na střechucould sit in different pillars while the homepage reads as one subject. The assessment labels each topic with a cross-seedKeywordCluster.theme(reusing known labels); the picker labels non-plan articles, andArticle.themestores 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 itspeakMonthis ≤ 3 months away or nothing else is left. The penalty is computed at pick time, never stored:scoreis 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 byassessOpportunitiesagainst articles created after theiropportunity.checkedAt(one call, only when such articles exist). Covered → SKIPPED witharticleIdof the covering article; a gap that article misses → EXPAND suggestion (published only); the rest get a freshcheckedAt. A failed check offers the topics unchanged. - Picker: up to 3 plan candidates (pinned first) and the news list.
topicSchema.planKeywordadds a## SEARCH INTENTwriter block and marks the cluster USED.newsItem> 0 forces research (no sources = held for review) and adds a## NEWS HOOKblock; the story URL is stored inArticle.newsUrl. News is not fetched when 2 of the last 5 articles have anewsUrl. CRON_GENERATE_ARTICLEmetadata carrieskeywordPlan { offered, chosen, linked }andnews { queries, found, relevant, used, costUsd }or{ skipped }. UI: sidebar page/admin/keywords(pages/admin/keywords.vue,components/Keywords/*, one shareduseKeywordPlanquery 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 orderkeywordPlanForoffers them; load failures are distinct from an empty plan.tests/eval/keywordScope.test.tstests relevance andkeywordOpportunity.test.tstests intent, semantic coverage, within-plan duplicates and specific gaps with controlled SERP fixtures; both requireAI_EVAL=1and write local reports totests/eval/.results/.tests/eval/keywordDiscovery.test.tsadditionally requiresDATAFORSEO_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.jsoncaptures 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.tsverifies 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.tsclaims each due paid check, filters to active ARTICLE_CRONS tenants and schedules from actualArticle.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-actionsreturnsallowedper 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 separateprocessingUntillease 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-actionstask 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.tsand PostgreSQLrefresh.integration.test.ts(TEST_DATABASE_URLmust identify a separate test database).
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).recommendedArticleQualityis the best quality the allowance sustains at the current rhythm; it adds no pages to the site.recommendedAutopilotIntervalis the rhythm that spends the allowance, capped at two articles a day, and needs a confirmation. -
Article batch.
ArticleBatchis a plan the client approves at once:PROPOSED→ACTIVE→FINISHEDorCANCELLED. A partial unique index keeps one live batch per tenant. Progress is derived fromArticleBatchItem, 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
proposeArticleBatchrefuses withNO_PROFILEand 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 inorderCandidatesorder with theme-capped topics last, without the paid rebuild), leaving a running autopilot a week of topics, seven at most. If that falls short,topUpArticleBatchProposalruns after the response (proposingUntil, polled by the page) because Bun drops silent requests after 10 seconds. On a site withARTICLE_CRONSit first rebuilds the plan if the gate allows, drops proposed topics the rebuild removed and takes its new ones, then asksproposeArticleTopicsfor 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 inArticleBatch.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.clusterPinnedrestores 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_MSof itsscheduledForand releases it through the same gates andpublish-checkpath as the autopilot. Research does not go stale, credits are taken only when an article is written, and cancelling costs nothing. Slots come frombatchSlots(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 withreleaseHoldandnotifyArticleHeld. 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.
- Proposal is free and server-held. Without a focus or keywords
-
Worker (
server/tasks/article-batches.ts, every minute).api-generationswaits 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 mostMAX_RUNNINGacross tenants and one per tenant. One per tenant also gives variety, becauseclient.articlesincludes drafts, so each run sees the previous article of the batch; the remaining topics reach the picker asplannedTopics. 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).processClientwritesarticleIdin 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.pausedUntilis 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 becausereserveArticleCreditrefuses 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_WRITEandARTICLE_WRITE_OTHERS, plusARTICLE_PUBLISHfor 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 withoutARTICLE_WRITE_OTHERSthe requester could not open the drafts.hasArticleBatchAccessrechecks 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_batchesand20261106190100_keyword_rejectionsare additive (a new enum value and three tables) and go out before the code.RESERVEDalso 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 andatRolloverCapstays false. -
generationClientSelectmust includearticleQuality. 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.tspins it, because its fixture client always carried the field.
- 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/backlinksfinds 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 (
backlinkAllowanceinshared/utils/backlinks.ts): PRO gets unlinked mentions and verification; PREMIUM/CUSTOM add manufacturer pages and AI outreach drafts. Discovery also needs theAIfeature.BACKLINK_PLANSgates both tasks. - When both links and opportunities are empty,
Backlinks/Empty.vuereplaces 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 withClientSite.backlinksAttemptedAt(claimBacklinkDiscovery): monthly from the dailybacklink-discoverytask (20 tenants per run), or manually from the page after seven days. The manual route claims before answering, so the page seesrunningimmediately and polls; the run itself continues in the background.backlinksRefreshedAtis 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 logsBACKLINKS_DISCOVEREDwithcostUsd. - 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 onebacklinkMatchmodel call, because a name search finds every "Alfa". Up to 25 pages are then fetched: a page linking already becomes aBacklink, 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 fiveKnowledgeProduct.brandvalues (HeurekaMANUFACTURER, Googleg:brand, Shopifyvendor), excluding the shop's own label, each searched as<brand> <dealer term>in the tenant market; onebacklinkMatchcall 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.tsthroughfetchPublicUrl(SSRF-pinned, https only, so an http URL is upgraded; 3 MB cap; charset from header or meta).readPagereturns links to owned hosts withreland anchor, followed links first.gone(404/410) counts against a link,failed(blocked, timeout, 5xx) does not. BacklinkOpportunitystatuses: 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 guardedupdateManyon the expected status; a lost race is a 409.- Verification (
check.ts, dailybacklink-check): SENT requests weekly; verifiedBacklinkrows weekly, with a miss re-read the next day and the link marked lost afterBACKLINK_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 itsnextCheckAtbefore fetching. Backlinkhas 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]) useANALYTICS_READ, like AI visibility; drafts additionally needAI_USEand 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 andmailto: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). Migration20261007120000_backlinks; addingbrandto the hashed feed facts makes every feed sync once as changed without re-embedding, which fills the column.
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.KnowledgeChunkcarriesembedding vector(1536)(pgvector) and a generatedsearchVectortsvector overknowledge_unaccent(content). Postgres must ship pgvector —pgvector/pgvector:pg18-trixielocally, in CI and in production (same major version and Debian base as the former plainpostgres: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,fetchPublicUrlfor URLs; HTML goes throughextractReadableSourceinfactCheckSources.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.tsaccepts a side as in force only when the page says so) → rowPENDING→knowledge/indexing.ts(heading-aware chunks prefixed with title › heading path,embedMany, atomic claim; old chunks replaced in the same transaction that marksINDEXED).knowledge-indexcron (every 5 min) is the safety net behind the immediate kick;PROCESSINGolder than 10 min is reclaimed, 3 attempts thenFAILED. An edit bumpsversion, so an in-flight run for older text cannot mark the source indexed. - Retrieval (
knowledge/retrieve.ts) runs besideresearchTopicinbuildArticleConfig: 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 →knowledgeSelectmodel gate, which may return nothing (ranks cannot say "unrelated"). No tenant with indexed sources = no model call. The query isknowledgeQuery— the manual prompt, or the cron topic — so knowledge still applies with web research off. Selection bumpsusageCount/lastUsedAt; the playground (POST /api/knowledge/test) passestrack: 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 currentaiEmbeddingModelIdare 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 intoTEST_DATABASE_URLwith 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 scoresindirectcases (topics that never name what the source covers) apart, ungated.expectmust be selected,allowmay 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 itSTALEin code past 12 months, so the verifier never does date arithmetic.validAsOfis filled from PDFModDate/CreationDateor 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 movesfetchedAt; changed text becomes a new version; a failed fetch keeps the last good index and recordserror. The same run requeues sources whoseembeddingModelis 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 viaKNOWLEDGE_SOURCE_INDEXED, playground viaKNOWLEDGE_TEST_QUERY; in articles they joinresearchTokens. - Sitemap import:
POST /api/knowledge/sitemaponly discovers pages (same site, https, one sitemap-index level, capped bymaxSitemapPagesand 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 atmaxSitemapPages) skips discovery and uses the same loop. For website addresses,knowledge/sitemap.tsreads up to 512 KiB ofrobots.txt, tries at most five unique same-site HTTPSSitemap: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 (wwwand bare host are equivalent), with public-address validation and DNS pinning at every hop. - Company website onboarding: the sibling landing forwards optional
websiteUrland versioned processing confirmation through its signed checkout request. Only system code createsWebsiteImport: the account-creation transaction andPOST /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 byWEBSITE_IMPORT_LIMITS.startsPerDay/startsPerMonth, counting onboarding and deleted rows because each run is paid work. Its baseline is the currentwebsiteSnapshot.WebsiteImport/Start.vuesits in the settings panel on AI plans: after a failure, or opened fromWebsiteImport/Entry.vuein the settings header (first import or re-run).useWebsiteImportCardshows 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/websiteImportruns immediately in the background, with the minutewebsite-importtask 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-articletopics({ topic, reason }) intoresult, never into settings; the model schema keeps them loose andwebsiteTopicsfilters them, so one bad topic cannot fail the settings, andWebsiteResultSchemadefaults them to[]for older results. A topic opens the editor as?ai=1&prompt=. Existing knowledge rows are deduplicated and created withuseInArticles: falseunder an atomic tenant quota check. The notice, checklist and settings panel share one client polling scope throughuseWebsiteImport; stale reads are cancelled and its cached handler releases component state on disposal. The persistentAdmin/WebsiteImportNotice.vueopensWebsiteImport/Panel.vuein 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_USDdefaults 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 inwebsiteImport/policy.tsis tied to the centrally configured Haiku model; model changes require reviewing the reservation and rate card together.logActioncaptures queue/start/defer/ready/failure/review andAiUsagecaptures tokens, searches, estimates and durations; neither receives page bodies or provider responses. Migration20261022140000_website_importadds the import table, active-tenant uniqueness and nullableKnowledgeSource.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 withoutMETA_CAPI_TOKEN). The landing pixel sets_fbp/_fbcon.topiqu.comonly 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-requestX-Landing-*headers, outside the payload fingerprint),POST /api/auth/registeron the platform host only (tenant readers use it too), and the first payment (recordFirstPaymentin the Stripe webhook,firstPaidAtincreditAgencyInvoice), 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.CompleteRegistrationusesmetaRegistrationId(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 stepaddress/search/pages/brand/analysis, plus each fetched page; only own-site URLs, counts and codes).WebsiteImport/Progress.vuerenders it in the panel, polled every 2 s while PROCESSING.errorCodeon a PENDING row is the wait reason (WEBSITE_IMPORT_WAIT_CODES), on a FAILED row the stop reason (WEBSITE_IMPORT_ERROR_CODES);websiteImportReasonmaps both to copy, anything else reads asPROCESSING_FAILED.websiteFailureclassifies 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 ascause.hasAiPlanreads only the plan name, so the manual start and each paid stage also checkaiBudgetError(funded period and BUDGET remaining). A bought AI pack wakes imports waiting onAI_ALLOWANCE_EXHAUSTED. Migration20261107120000_website_import_progress. - Superadmin plan changes on a blog without a Stripe subscription set
billingPlanthroughmanualBillingPlan, as creation does. Otherwisefund()finds no invoice-backed PLAN grant and the paid plan has no AI budget. - Publish confirmation is enforced server-side:
POST /api/knowledgeand a note text change inPATCHreturn 400KNOWLEDGE_CONSENTwithoutconfirmed, and the audit row recordsconsent: { 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.useInArticlessurvives as pause/resume in the list; retrieval (eligibleand 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 (syncFeedinindexing.ts): stream the XML (htmlparser2, no DTD, so no XXE/entity expansion;maxFeedBytes) → HeurekaSHOPITEMor Google Merchantitem/entryadapters →normalizeVariant(the only cleanup: HTML → text, https product links without tracking params, ISO 4217 currency, unambiguous prices ornull) → variants merged byitem_group_id(lowest price, best availability). OneKnowledgeProductrow + one chunk per product; price and availability are never embedded, so a price change is an UPDATE and only products whosetextHashchanged 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 addsproduct · 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/feedchecks a link before it is added (count, sample, what fits the quota; nothing stored). A failed sync stores aKNOWLEDGE_FEED_ERRORScode the list translates; onlyunreachableand unexpected errors are retried, and only unexpected ones reach Sentry.- Product images (Heureka
IMGURL, Googleg:image_link, ShopifyfeaturedMedia) are stored asimageUrl. Cards showcdnImageUrl, a 480 px WebP copy made byknowledge/productImage.tson first use, because shop hosts are not on the IPX allowlist.cdnImageSourcerecords whichimageUrlwas 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.currencyis the billing currency and must not stand in. Product quota is per tenant across feeds (maxProducts).
- Retrieval treats a feed as eligible in any status (chunks are swapped per product, never half-written), only for articles in the feed's
- Manual generation can skip knowledge (
options.useKnowledge, default on). Audits carryknowledgeShortlistedandknowledgeSelected(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);
clientSiteIdcomes 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.8hnsw.iterative_scanonce 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 asLISTING DIFFERS: it never blocks, and only the audit'sverificationkeeps 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 (
namedProductsinretrieve.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 markedNAMED IN THE BRIEF, so a similarly named variant cannot stand in for it. - Citations: internal entries reach the writer as
[Kn] … internalwith no URL; onlypublicUrls joincitationAllowlist, which replaces the research brief as the input tofilterResearchSources. 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 manualMANUAL_GENERATION_WRITER_STARTEDaudit and the cron generation audit. - Why a claim survived review:
editorialReview.verificationin the generation audit holds every verdict line of each verification round (verdictLinesinarticleQuality.ts), notverificationBrief, which keeps only URL-backed lines and so drops UNSUPPORTED and first-party-supported verdicts.
-
Email unsubscribe is a signed link, not an address.
server/utils/unsubscribe.tsHMACs the user id withAUTH_SECRET(non-expiring, so old mails keep working);pages/unsubscribe.vueposts it toPOST /api/users/unsubscribe, which only ever setsallowEmail = 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) pollsGET /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 → notifyArticlePublishedacross 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.vuerenders 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/readwith the newest seencreatedAtasbefore, 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), inEurope/Praguelike other timestamps. A row is one link, using a stretched::afterso the delete button is not nested in it: storedlinkfirst, 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 itsroot: 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.tsand are translated at display time, includingcuriosity. The generated insight schema uses that vocabulary; historical unknown identifiers are omitted rather than exposed as raw labels. -
Article/Series.vueuses 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
articleEditorSnapshotvalues 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+ theuseRealtimecomposable), which is unviable on serverless/Vercel: held connections incur per-request/wall-clock billing and isolated function memory means apublishin 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 inserver/utils/notificationsPoll.tsand 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).
-
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 sumstotalWordsoveraiInvolvement: 'FULL'only (the editor demotes toASSISTon the first human edit) and prices it with the tenant's current rate.Article.savedAmount/savedTimeMinutesare 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 getslinkedinModeon every save, because the settings form defaults it — so that branch runs for tenants who never connected LinkedIn. It must not create aLinkedinCompany: only the OAuth callback has a reallinkedinOrgId, 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.
humanHourlyRateheld a CZK figure while the UI formatted it as the tenant'scurrency(default USD), inflating the number ~23×. Migration20260808120000_human_rate_usdrenames it tohumanHourlyRateUsd; display multiplies byuseCurrencyRatelikeBilling.vue/AI.vue. The default (35) is stated twice —client.zmodelandDEFAULT_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:publicationNavigationUrlreturns 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.Chartsis 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 tomin-width: autoand 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 amin-w-0parent with ashrink-0metric beside it. -
Modal/index.vuehad no dark tokens at all — a white panel in dark mode, with the one card that did carrydark:variants floating in it. The panel and its title gradients now have them.h-11/12is untouched and still forces every modal to ~92% viewport height regardless of content. -
Charts.vuetakes semantics, not a Chart.js config —kind(trendbar↔line /breakdownbar↔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.vuealways 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
ArticleReactiongates a like:ArticleSharecarriesuserId/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 throughcomposables/useArticleShare.ts, which adds the fingerprint for anonymous visitors (without it the POST is a 400).Article/TOC.vueonly emitsshareup throughArticle/View.vue, so the editor preview never counts one. -
ArticleViewis the view-event log (migration20260819120000_article_view_events);Article.viewsstays the running counter, so sorting and totals keep their fast path. One row per visitor per article per UTC day, andsessionIdis 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.tsinserts withON CONFLICT DO NOTHINGand 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, hencetrackingSince(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.viewedOnoverVIEW_TREND_DAYS(30). It replaced a 7-day series bucketed byCOALESCE(publishedAt, createdAt), which counted views accumulated by articles published that day — not views that happened that day. -
Views were forgeable.
view.post.tshad no session, no dedup, no rate limit and nostatusfilter: a loop ofcurlmoved the number, and an admin previewing a draft inflated it. The identity is now server-issued (theanon_sessioncookie, never read from the body) and drafts are rejected. Totals andtopArticlefilterstatus: '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. -
topTagsranks 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 andORDER BY SUM(views). Note the metric double-counts by design: an article with three tags gives its views to all three.
-
DomainConnectionreserves 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. Seedocs/custom-domains.mdfor 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.tsretains 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-jsonresponses; 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, sobilling.topiqu.comwould be a live platform host), and a custom domain needs a non-BASIC plan.isDomainTaken(server/utils/domainVerification.ts) also counts old hosts inDomainRedirect, so nobody can take over another blog's redirect. Onboarding, tenant creation and the availability check all go through both. -
domainis superadmin-only inPATCH /api/clients/[id]. A tenant moves through/api/domain/pending: the new domain is stored aspendingDomainwith its own TXT token whiledomainkeeps serving;pending/verifychecks TXT + CNAME and then, in one transaction, swaps the two, adopts the token fordomain-reverificationand writes aDomainRedirectfor the old host. A direct swap would take the blog offline, becausetenantByHostrefuses an unverified custom domain. Settings → Branding →Form/Client/Domain.vue. -
server/middleware/domainRedirect.tsanswers 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
syncPlanFeaturesto BASIC callsdomains/entitlement.tsinside 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 minutedomain-cleanuptask 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 andADOPTION_REQUIREDuntil 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.tsdefines BASIC 1, PRO 2 and PREMIUM 3, including the owner.paidTeamSeatsdistinguishes 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.tsserializes changes with a database lease and signs short-lived proration quotes./api/stripe/seatspreviews 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.tsis the server/UI commercial source.aiAllowance.tslocks 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-onlyAiAllowanceGrant,AiUsageReservationandAiAllowanceInvoicedistinguish 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 requirespaidThrough; subscriptions without a funded period cannot mint new AI capacity. Article purchases and carried plan units (ARTICLE_PURCHASE/ARTICLE_ROLLOVERgrants keyedarticle-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 viaAI_BUDGET_MICROS_PER_CENT) andSEO(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 reportsAI cost cap reached, the customer seescommon.aiAllowance.paused, and/api/stripe/ai-usageomits both.ai/spend.tsattributes 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 additive20261104*migrations ship these ledgers, leases and paid-period fields;20261106200000_ai_cost_capadds theSEOkind and scales stored 15 % budgets by 10/3. Details and calibration limits:docs/operations/ai-economics-proposal.md. -
Independent provider-cost debt:
ClientSite.aiBudgetDebtMicrosandseoBudgetDebtMicrosretain 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_overrunadds 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 indocs/operations/ai-economics-proposal.md.
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.tsuses 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/connect.get.ts403sappType=pagesand 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.tsfails if an organization scope reappears.- Legacy company-page data and publishing utilities remain supported:
LinkedinCompany.typestill carries'pages' | 'personal', andtoken.tsstill readsLINKEDIN_CLIENT_ID_COMPANYfor 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 apagesrow when applyinglinkedinMode— it takes the tenant's singleLinkedinCompanyregardless oftype, 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 formerBrandProfiletable duplicated them and was never filled in.
zod/— split intocommon/,enums/,input/,models/,objects/with a barrelindex.ts. Schemas reused by both client forms and server validation.types/— hand-written cross-cut TS types.article.ts → ArticleCardDatais the row shape consumed byArticle/Collection.vue(shared by thestitkyandautorlisting pages).utils/— pure helpers shared by app + server.tenantTheme.tsturns a tenant's theme/accent/typography into the publication CSS variables and custom@font-face(used bylayouts/default.vueand the branding editors).savings.tsis the single "what would a human have charged for these words" formula — see Blog statistics in §4.
app/utils/also holdsqueryKeys.ts(Pinia Colada key factory, see §3 Data-fetching split) alongsidebuildClientSettingsForm.ts.
- Stacking order is defined by the
--topiqu-layer-*variables andz-*utilities inapp/assets/styles/main.css; the unused TypeScript layer map has been removed.
openapi/v1.jsonand the sibling landing'scontent/docs/{en,cs}/are the consumer-facing API contract and guides. Generate the contract fromscripts/generate-openapi.tsand keep the landing'spublic/openapi/v1.jsonsynchronized. This section records the implementation and access invariants.
- Keys (
server/utils/apiKeys.ts,prisma/models/apiKey.zmodel). A tenant has up toMAX_API_KEYSnamedApiKeyrows. Only the SHA-256 of the key (sk_+ 256 random bits) is stored (hash, unique,@omit) with an 11-characterprefixfor display; the key is returned once byPOST /api/api-keysand never again. List and revoke viaGET /api/api-keysandDELETE /api/api-keys/:id(setsrevokedAt); all three needAPI_KEY_CONTROL, and a member can only mint scopes they hold themselves (requiredTenantScopes). Settings → Integrations rendersForm/Client/ApiKeys.vue. The migration20261104120000_api_keyshashed the former plaintextClientSite.apiKeyinto 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 throttleslastUsedAtwrites. Every external route authenticates through it;requireExternalClientis its READ shorthand. Authenticated responses areprivate, no-store. - Writes act as the key's creator.
actAsApiKeyMembersetsevent.context.authUserto the member who created the key (roleadmin, never superadmin, so ZenStack keeps the tenant boundary) andevent.context.apiKeyMembershipto that member's current scopes narrowed by the key (apiKeyTenantScopes, roleMEMBER).requireTenantMemberreturns that membership, so the v1 write routes run the editor's owncreateArticle/updateArticle(server/utils/articleWrite.ts, shared withPOST /api/articlesandPATCH /api/articles/:id) with every existing check: publish scope, media-rights review (409MEDIA_RIGHTS_REVIEW_REQUIRED, answered withmediaRightsReview), 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 logsAPI_*withapiKeyId(logApiAction). - Idempotency.
withIdempotencystores the response of a write sent withIdempotency-KeyinApiIdempotencyKey(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 onPOST /api/v1/articles/generate.api-generationsprunes only completed expired rows. - Write routes (
/api/v1).POST /articles(WRITE, + PUBLISH to publish or schedule; tags by slug viaresolveTagSlugs),PATCH /articles/:id(WRITE, + PUBLISH whenstatus/releaseAtis sent;expectedVersionoptional, last-write-wins without it),GET /articles/:idreturns drafts withstatus/editVersion/releaseAtto 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'sAI_USEandARTICLE_WRITE) checks the plan, at mostMAX_PENDING_API_GENERATIONSqueued or running per site and that the wallet covers every queued request plus this one (402), then stores anApiGenerationand answers 202 withLocation: /api/v1/generations/:id. Theapi-generationstask (every minute) claims queued rows with a guarded update, revalidates the key and its creator before spending capacity, and runs the autopilot pipeline (processClientwith aRequestedArticle): the picker keeps the requested subject (TopicInput.requested), news hooks are skipped, the article is always a draft,lastGeneratedAtand the schedule are untouched, and the credit reservation key isapi-generation:<id>, so a retry cannot charge twice. The outcome sets the row to SUCCEEDED/FAILED (guarded on RUNNING) and sendsgeneration.succeeded/generation.failed. A run older thanAPI_GENERATION_TIMEOUT_MSis failed as interrupted. GET /api/external/articlesremains the backwards-compatible collection endpoint. It returns source-language, publishedArticlerows only, newest first. Its original fields and nestedtags[].tagshape remain intact; additive fields now exposeupdatedAt,publishedAt, reading time/word count, sources, cover credit, series and metadata for published translations. Pagination ispage+limit(defaults 1/10, maximum 100), and response metadata includes the primary language and applied tag filters. Optional comma-separatedtagfiltering is trimmed/deduplicated and retains AND semantics: an article must carry every requested tag slug.GET /api/external/articles/:idreturns one published tenant article with the complete safe external projection. Its response uses flattags, identifies the sourcelanguage, and exposes published translation summaries asavailableTranslations; a foreign, draft, archived or missing id is the same 404.GET /api/external/tagsis discovery for filtering: only tags actually attached to at least one published article of the authenticated tenant, sorted by name, witharticleCount. This deliberately avoids leaking global/other-tenant tags and omits empty tags.GET /api/external/sitereturns 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 byopenapi/v1.json(generated bybun run openapi:generatefromscripts/generate-openapi.ts;openapi:checkfails when stale; the landing site serves a copy). Scopes are machine-readable there as vendor extensions, becauseapiKeyschemes cannot carry scopes:x-api-key-scopesat the root,x-required-scopesper operation andx-conditional-scopesfor 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.
- Pro+ tenants register up to
MAX_WEBHOOKSHTTPS endpoints (Settings → Integrations,/api/webhooks,INTEGRATION_CONTROLscope) forarticle.published/updated/unpublished/deletedon the source-language article, andgeneration.succeeded/generation.failedfor API generation requests. Published/updated payloads reuse the External API projection plus the publicurl; removals carry onlyid/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. enqueueArticleWebhookwrites oneWebhookDeliveryoutbox 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
updateManytoSENDINGwhosenextAttemptAtdoubles as a 5-minute lease, so overlapping runs never double-send and a crashed send is retried (at-least-once; receivers dedupe onTopiqu-Delivery). Topiqu-Signature: t=<unix>,v1=<hmac-sha256(secret, "t.body")>. The secret is@omit, returned only on create/rotate. Sends go throughpostPublicUrl(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.
- Deleting from a widget card or its editor uses the shared
useConfirmdialog 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. SiteWidgetrows (Settings → Widgets,GET/PUT /api/widgets,TENANT_SETTINGSscope).PUTreplaces the tenant's whole set in one transaction; array order isposition, 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.configis Json validated perkind.slotis a string key ofWIDGET_SLOTS, not an enum. The layout owns the slots; when a redesign drops one, map it inRETIRED_SLOTSso its widgets move, andresolveWidgetSlotskips anything unknown. Pages render a slot with<WidgetSlot name>:HOME_TOP/HOME_MIDDLE/HOME_BOTTOMinpages/index.vue,ARTICLE_ENDin the article footer,FOOTERin the layout. At mostMAX_PER_SLOTactive widgets each.- Active widgets ride in
publicClientSiteSelect, so rendering costs no extra request. Both routes that return it pass them throughpublicWidgets, which drops kinds the plan lacks and strips the HTML body. Plan limits apply at render, not onPUT, so a downgraded tenant keeps and can still save its widgets. ANNOUNCEMENT(all plans) renders asWidget/Bar.vueabove the content, only on the publication shell. Time windows are evaluated at render, not stored as state.widgetsInputSchemarefuses two active bars that share a language and overlap in time. Closing writesannouncementKey(id plus a hash of text and link) to thetopiqu_barcookie, read during SSR, so a closed bar is never rendered and an edited one shows again.DIVIDER,CTA,PRICE,WEATHERneed Pro+. A CTA image is aMediaAssetid;PUTchecks the asset belongs to the tenant and writes its URL itself.PRICEandWEATHERload 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.HTMLneeds Pro+ and a verified own domain: on*.topiqu.coma 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, withCSP: sandboxminusallow-same-origin. The header, not just the iframe attribute, is what keeps the document opaque when opened directly.routeRulesturns the global security headers off for/widgets/**, since its CSP would block the tenant's scripts. The frame reports its height bypostMessage; the parent checksevent.source(the origin is"null") and clamps it.server/middleware/opaqueOrigin.tsrefuses cookie-carrying API writes withOrigin: null.- Editor (
Form/Client/Widgets.vue) groups widgets by slot, with the active count againstMAX_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/WEATHERpreview live data before saving viaPOST /api/widgets/preview(TENANT_SETTINGS, plan-checked, 30/min per tenant, same providers and cache);Widget/Price.vueandWidget/Weather.vuetake that as adataprop instead of fetching.HTMLhas no inline preview: asrcdocframe 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.
- The installable
Topiqu Syncplugin consumes the existing authenticated external API and stores source-language articles as native WordPresspostrows. 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:
safehashes the last imported title/slug/excerpt/body and preserves a locally edited post,overwritemakes Topiqu authoritative, andnew_onlyimports once._topiqu_updated_atavoids 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.zipand is generated fromwordpress/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]andfeatured/[slug]may carrylikedByUseronly because their 10-min shared cache is reached without a session (tests/server/articles/feedPresentation.test.tspins that condition).- One anonymous identity:
server/utils/anonSession.ts. Writes issue theanon_sessioncookie, GETs only read it — a minting GET hands one to every crawler. Reactions must never take it from the body asf4ece21did with a FingerprintJSvisitorId: the caller picks that value, so it farms freely and never matches what reads resolve. Abuse budget isconsumeRateLimitonipKey(event)inreaction.post.ts, create path only. Client/PreferencesGuide.vuerenders the live prompt block fromserver/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, pasti18nCompleteness;tests/unit/preferencesGuide.test.tscovers them.- Unsaved changes are communicated by
app/components/UnsavedBar.vue— one body-teleported floating bar shared by/settingsand/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.vuemoves 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:useDiscardChangesresets 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.vuekeeps 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 intests/e2e/comment-actions.spec.tsfor long names and nested replies at 320/360/768 px.- Feature cards emit intent;
useClientFeaturesowns one confirmation, pending state and PATCH, including dependent deactivation and catalog-backed paid-feature terms.FEATURE_MIN_BILLING_DAYSinserver/utils/planFeatures.tsdrives both the actual activation lease and the/api/featuresmetadata shown in confirmation; the catalog also exposesannualBillingPricefrom the sharedannualFromMonthlypolicy, so cards and confirmation do not recompute annual pricing in the browser. Billing permission and a loaded paid-feature catalog gate card interaction. Success callsuseCacheInvalidation.invalidateClientFeatureseven after leaving settings: shared site/status, client lists, visibility and stats refresh together. Refresh failure does not misclassify an already persisted change as failed.reconcileClientSettingsFormrebases untouched fields from the server while retaining local edits./api/clients/statusreturns 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.
- Authored in ZenStack, split by domain: a thin root
prisma/schema.zmodel(datasource/policy plugin +imports) pulls inprisma/models/*.zmodel(base,article,poll,client,user,comment,notification,linkedin,log). Enums colocate with their domain; cross-domain enums + reusableBase/Ownabletypes live inbase.zmodel.bun zenstack:generatewrites the runtime schema and TypeScript types to ignoredgenerated/zenstack/;shared/databaseSchemas.tsderives server-side Zod v4 model validators from the same metadata. Browser-used language/theme schemas live inshared/siteSchemas.tsso the 320 kB runtime metadata stays out of client chunks;tests/unit/siteSchemas.test.tspins those option lists to the generated enums. Imports must be at the top of each file; cross-file relations need explicitimports (ZenStack resolves symbols only over a file's transitive imports). - Migrations in
prisma/migrations/. bun buildpipeline:zen generate→nuxt build. Releases apply existing SQL migrations separately withbun db:migrate:deploy(zen migrate deploy).
ArticleViewis the only event table with a non-nullable identity column (sessionId). That is deliberate:ArticleShare,ArticleReactionandArticleFeedbackall put nullableuserId/sessionIdin their@@unique, and Postgres treats NULLs as distinct, so those indexes never fire and the handlers carry the dedup alone. CopyArticleView's shape, not theirs.- Fully normalized:
Poll(question, order) →PollOption(label, order) →PollResult(vote). All cascade-delete fromArticle.PollResultreferences bothPolland the chosenPollOptionby real FK (+articleIdkept denormalized so engagement stats useArticle._count.pollResults). Two@@uniqueconstraints —(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 intoArticle.contentas<div data-type="poll" data-poll-id data-question data-options>, wheredata-optionsis 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'sclientSiteId, overwrites everydata-*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: nowidgets.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: canonicalx.com/{handle}/status/{id}) and leave anything else as plain HTML. - Editor endpoints (
ARTICLE_WRITEscope):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:
buildArticleConfigoffers[[XPOST1]]only for post URLs found verbatim in the research brief (findXPostUrls).finalizeArticlefetches the oEmbed alongside the images; a deleted or private post drops its slot.
- On article create/edit,
server/utils/articlePolls.ts → syncArticlePollsreconciles the embedded blocks with thePoll/PollOptionrows and stamps each block with server-assigned ids (poll id on the block, option ids insidedata-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 inshared/utils/polls.ts(normalizePollOptions). syncArticlePollsis mandatory on every write path that persistsArticle.content— it is the only code that createsPoll/PollOptionrows 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.finalizeArticleemits only a cosmeticdata-idand label-only options, so thegenerate-articlecron (which writes AI output straight to the row) must run the same sync +sanitizeHtmlasarticles/index.post.tsand[id]/index.patch.ts; it previously did neither, shipping polls whose every click was a silent no-op (Poll.vuebails before$fetchon a missing option id, and the GET counts endpoint happily returns an empty tally for an unknownpollId, so the widget looked live).scripts/backfill-polls.tsrepairs rows written before the fix (dry-run by default,APPLY=1to write). Covered bytests/server/articles/articlePolls.test.ts.- The editor rewrites
data-optionson every keystroke (Tiptap/Editor.vue → validateContent, fed byonChange), so that pass must round-trip the option ids and be idempotent. It went throughshared/utils/polls.ts → pollOptionsAttr(tests/unit/polls.test.ts) instead of an inlineString(x)that assumed the legacystring[]shape — that wrote[object Object]over every label and dropped the ids, whichPoll.vuethen 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 viagroupBy) +vote.post.ts(cast; maps PostgreSQL unique violation23505to 409). - Render:
Article/Parsed.vue(client-side parse) →Article/Poll.vue(votes byoptionId); homepage "latest poll" viaextractPollDatainby-clientsite/[slug].ts. Both requiredata-poll-idand do not fall back todata-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_normalizedmigration wipes any pre-existingPollResultrows (old text-based votes couldn't be remapped to option ids without parsing HTML) — a one-time, intentional reset.
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(+STALEon source edit,FAILED,SKIPPEDby 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 activeAIClientFeature + planPRO/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 intests/server/ai/translationQueue.test.ts. Migration20261101120000_content_languageswrote 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 → generateTranslationusesaiModel('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/imagesrcnever travel through the LLM as free text; poll question+labels, imagealt/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 onrebuildContent(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 viarecordAiUsage('TRANSLATE_ARTICLE'). - Two trigger paths. (1) On-demand
POST /api/articles/[id]/translate(MANUAL/HYBRID); (2) cronserver/tasks/translate-pending.ts(every 5 min) drainsPENDING/STALEwith a per-row atomic claim (guardedupdateMany→TRANSLATING) so concurrent runs never double-translate — AUTO →PUBLISHED, HYBRID →READY(awaiting review), out-of-budget rows release back toPENDING. Enqueue/STALE is wired into all four publish paths viaserver/utils/ai/translationQueue.ts → syncArticleTranslationQueue(articles/index.post,[id]/index.patch,publish-check,generate-article). STALE keys off an explicit content-change signal, notArticle.updatedAt(bumped by the view counter), to avoid token churn. - Smart selection (
ClientSite.translationSelection:SMARTdefault,ALL). Before a language's first AI translation the cron callsserver/utils/ai/marketFit.ts → assessMarketFit(Haiku, article text and both keyword markets) and storesArticleTranslation.marketFit:TRANSLATE/ADAPT/SKIP, a reason in the source language,localPassages.SKIP→SKIPPEDwith no translation.ADAPTpasses the passages togenerateTranslation, 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.
SKIPPEDis never re-enqueued or markedSTALE; the manual endpoint translates it without re-judging (editor: "Translate anyway"), and switching toALLrequeues everySKIPPEDrow.
- Rows with text are never re-judged, and a failed assessment translates anyway.
- 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 theArticleTranslationslug, which does not exist onArticleat all.[id]/index.get.tshandled that;[id]/relatednever did, and itsfindUniqueOrThrowturned it into an unhandled 500 on every/enarticle 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.relatedalso localizes its cards vialocalizeArticlesand is scoped toclientSiteId—Tag.clientSiteIdis nullable, so a global tag matched articles across tenants. - Translation review.
READYmeans "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, usinghasArticleBody; real text, media and formatting edits still count.translateNowconfirms 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
clientSiteIdexplicitly on top of the ZenStack policy.ArticleTranslation's read rule isstatus == '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
sourcetoHUMAN— the audit column must stop claiming a machine wrote text a person rewrote.PUBLISHEDis refused unless title+content exist, so a body-less queue row (PENDING/TRANSLATING/FAILED) can't be published into a blank localized page with ahreflangpointing 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].vuenow carriesArticle/Editor/LanguageTabs.vue(a segmented control in the sticky header, stacked below it undersm): 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.tsowns that state:activeLang === ''means the source, so the page branches on one flag instead of tracking two ideas of "current language". It isreactive()-wrapped at the call site so the template readstr.isSourcerather thantr.isSource.value. The page routes the shared textareas throughtitleModel/excerptModel/bodyModelcomputeds, souseTextareaAutosizestays bound to one thing while the underlying target changes.hasChangesfolds intr.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, andtranslationStatusDot.resolveActiveLanguagealso fixes a latent bug in the old inlinewatchEffect— it picked the firstREADYrow 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 neitherby-clientsite/[slug].tsnorfeatured/[slug].tstouchedArticleTranslationat all, so an/envisitor got Czech titles linking to Czech slugs and a published translation was reachable only viahreflangor Google — tokens were being spent on pages nobody could click to. Both endpoints now takelocaleand overlay each card with itsPUBLISHEDtranslation.overlayTranslationonly 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), andlocalizeArticlesfiltersstatus: '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 localizedNuxtLinks 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 ashreflang, so a language is never offered that would only fall back. - Admin
Article/Table.vue— alanguagescolumn, not extra rows: the bloat guard from §6b holds.articles/search.tsgainedinclude: { 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 oneArticle/TranslationMenu.vuetrigger (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,ArticleStatusCellis the single status presentation and emits its update directly toActionsBar— 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 anmdi:open-in-newlink to the live page of whichever language is on screen. It is passed asuseArticleTranslations(id, initialLang)rather than assigned toactiveLangafterwards, and the reconcilingwatchEffectis gated onstatus === 'success'. Both matter: an emptytargetLanguagesmeans either "this site has no targets" or "the request has not resolved", and reconciling against the second silently reset?lang=ento the source tab before the payload arrived — the deep link looked like it did nothing.
- Article detail — gated on
- SEO framing that drove the above: translated pages are not duplicate content — that is what
hreflangis 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.
- Dirty tracking treats empty editor HTML (
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.
esis Spain's Spanish andptEuropean 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/i18nlocales with their own route segments (nuxt.config.ts::i18n.pages, mirrored inshared/utils/routes.ts), so articles get/es/articulos/…URLs and proper hreflang. Each ships onlyi18n/locales/<code>/reader.json: the keys the public pages actually use, found byscripts/reader-i18n.ts(walks the reader pages, the components and helpers they reach, and collects key literals).i18n/i18n.config.ts::fallbackLocalefills the rest from English (Slovak from Czech); interface locales have no fallback, so they load nothing extra.tests/unit/readerLocales.test.tsholds everyreader.jsonto exactly that key set, with placeholders intact. - Everything else stays in the interface.
app/middleware/content-locale.global.tslets 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_SEGMENTSisUiLanguage-keyed). - hreflang on listings.
app.vuedropsuseLocaleHeadalternates 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, thesimple+ 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.
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 @ 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 byuseClientSite()on every page → lands in the SSR payload) andclients/[id]/by-userid.get.ts(public, powers the author card) select through the whitelist. Both previously returned the whole row from rawprisma, i.e. every tenant'sapiKeyand Stripe ids were readable bycurl-ing any blog.@omitwould not have helped: these go through rawprisma, andClientSiteis@@allow('read', true)anyway, so the select is the control.clients/index.get.tsisrole: 'superadmin'viarequireDb. It was session-optional withgetEnhancedPrisma(user), and because the model's read policy istruethat 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 inApiKeyas hashes) now 403s unlessid === user.clientSiteIdor the caller is superadmin. The old check was['superadmin','admin'].includes(role)with an attacker-controlledid— any tenant admin could read any other tenant's full row.clients/status.get.tsis where owner-only entitlement data lives now (plan,articlesRemaining,articleWallet,createdAt,firstPaidAt,focus,audience, a server-derivedtrialsummary,hasActiveSubscription, andtheme/typographyPreset/discloseAiContentfor the editor preview). The site id comes from the session (user.clientSiteId), never a route param, so it has no IDOR surface. Exposed asuseClientSiteStatus()and consumed by the trial modal, article balance panel, editor reservation state and first-article checklist.useClientSiteStatus()fetches throughuseRequestFetch()and returns theuseAsyncDatahandle, notdata.value. Both halves were bugs: bare$fetchduring SSR sends no session cookie, so the endpoint 401'd, a.catch(() => null)swallowed it, and thenullwas 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). Returningdata.valuethen froze the snapshot at setup time, so nothing re-rendered after a top-up. The sameuseRequestFetch()rule as the Colada queries above — it applies to any authenticateduseAsyncDatahandler. 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.
-
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.tschecks 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
pausedsubscriptions also revoke paid entitlements, retaining the subscription ID so a lateractiveupdate can restore the plan.past_duecontinues to use the existing dunning grace. -
checkout.post.tscreates every trial tenant onTRIAL_PLAN(=PREMIUM) and stamps immutabletrialStartedAt+trialEndsAt; the trial exercises the whole product including the crons.ClientSite.planis only the entitlement andfirstPaidAtonly records money movement — neither proves trial provenance.shared/utils/trial.tsis 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. Migration20260913150000_explicit_trial_lifecyclebackfills both timestamps only from historicalTokenCreditGrant.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: atrialingcheckout movesplanbut must not stampfirstPaidAt, or the trial ends the day it starts. Bothcheckout.session.completedand thesubscription.updatedtrial-end path callsyncPlanFeatureson whatever plan they resolve. -
trial-expirycron (*/10 * * * *) only touches explicitly marked, unacknowledged, card-less trials whose storedtrialEndsAtelapsed. The stored deadline means changingTRIAL_DAYSaffects new trials only.expiredTrialWherealso requiresstripeSubscriptionId: nullbecause Stripe owns a card-backed trial's lifecycle — promoting on conversion, revoking throughrevokesPlanon a failed first invoice — and a cron downgrade would race that webhook mid-conversion.tests/server/tasks/trialExpiry.test.tspins the SQL filter against the state predicate; drift either strands tenants on the trial plan forever or risks downgrading a provisioned customer. -
syncPlanFeaturesat signup is deliberately non-fatal (try/catch,TRIAL_FEATURE_PROVISIONING_FAILED). It throws on an unseededFeaturecatalog — 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.tstakes the visitor IP fromX-Landing-Client-Ip, trusted only with a freshX-Landing-Sigovertimestamp.ip(landingClientIp); otherwise the request IP.inboxOwnsSite(server/utils/trialInbox.ts) compares the canonical inbox (+tagsdropped, Gmail dots ignored) against existing site owners in SQL that must mirrorcanonicalEmail; 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.tscheckshasAiPlanand then reserves throughArticleCreditWallet; scheduled generation additionally requires the cron feature. Trial expiry downgrades the plan, while its expiring article grant independently becomes unavailable. -
end-trial.post.tsrefuses tenants without an actually expired trial and stampstrialAcknowledgedAtwhen the author chooses "continue free". It never writesfirstPaidAt, so dismissing trial UX cannot fabricate payment history.pages/admin/index.vueopens the expired-trial modal once per visit (useState) and refreshesclientsite-statusafter "continue free"; that snapshot is cached across navigations, so unrefreshed it keeps reportingEXPIRED.
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:
ClientFeaturerows → what the settings panel rendered (activeFeatures), written only byfeatures.patch.ts; nothing created them on a plan grant, so a paying tenant had none.ClientSite.enableAi/enableCron/enableSentiment→ what the translation endpoint and cron actually checked, written only at onboarding and never by the toggle.- Bare plan checks →
sentiment-analysis.ts; andgenerate-article.tschecked nothing at all beyondgenerationFrequency+ 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 andbillingLockedUntilcarries real billing weight).syncPlanFeatures(tx, siteId, plan)applies it and is called on every plan transition: allstripe/webhook.tspaths (promote, portal plan change, revocation → BASIC), the superadmin plan edit inclients/[id]/index.patch.ts, and the dev switch_dev/plan.patch.ts— each inside the same transaction as theplanwrite.syncPlanFeaturesis "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) andrecalcFeatureBilling. 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 relationwherefor cron batch queries) orhasActiveFeature(db, siteId, code). Both take a narrow structural DB type, matchingtranslationQueue.ts/translationSlug.ts, so either the base or policy-enhanced ZenStack client is accepted. The read path takesFeatureReadDb(clientFeature.countonly); only the write path needs the widerFeatureSyncDb(addsfeature+clientSite). - Backfilled/plan-granted rows get
billingLockedUntil = now()(no lock):billableMonthlyTotalbills 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 — seebillableFeatureWherebelow. autoReleasecannot outliveARTICLE_CRONS(syncAutoRelease). The switch is hidden without scheduled generation (Form/Client/AI.vue) and costs a danger confirm to turn on, so leaving ittruethrough 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-pendingalready refused to drain the queue without it, so a revoked tenant still ontranslationMode: AUTOkept piling up invisiblePENDINGrows on every publish — which a later re-subscribe would flush as one burst of months-old translations.featuresis optional in the structuralQueueDb(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
Featurecatalog is load-bearing and was seeded by hand. With gating derived from it, an empty catalog would silently switch every feature off for everyone, sosyncPlanFeaturesthrows on a missing code instead of skipping it, andprisma/seed.tsupserts the three rows (update: {}— it never overwrites real prices). - Consequence to know:
sentiment-analysis.tsused to run forplan IN (PRO, PREMIUM), butgetAllowedFeaturesonly grantsSENTIMENTon 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.vuequoted a per-feature monthly price on every plan even thoughbillableMonthlyTotalreturns 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 intoForm/Client/FeatureToggle.vue(a card in alg:grid-cols-3grid with aUSwitch; active state is the inverted border and icon tile).
- 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 bothclients/[id]/index.get.ts(allowedFeatures) andclients/[id]/features.patch.tsso 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,monthlyPaymentstays 0 (the plan itself is the Stripe charge). CUSTOM → à-la-carte:monthlyPayment = Σ billed featurepriceMonthly``(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: trueORbillingLockedUntil > 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 withbillingLockedUntilalready 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 requiresgetAllowedFeatures[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.priceMonthlyholds USD; the whole display layer is USD-base:GET /api/currency?target=→server/utils/currency.ts → usdCrossRate(CNB daily table cached 6 h viadefineCachedFunction, cross-converted via CZK,amount-normalized, unit-tested),useCurrencyRatereturns a computed USD→target rate (1for USD) — a ref, because a snapshot taken before the fetch settles froze the USD default into SSR, andBilling.vue/AI.vuemultiply USD amounts by the rate (was CZK-base divide). CZK is now just another display currency.
- Client: all handlers (
subscribe,checkout,portal,webhook,onboarding/checkout) share the lazy singletonserver/utils/stripe.ts → useStripe(), which readsSTRIPE_SKand explicitly pinsapiVersion: '2025-08-27.basil'. SDK dependency upgrades must preserve that external API contract; the installed SDK version is recorded inpackage.json/bun.lock. Lazy initialization keeps onboarding's no-key dashboard fallback from throwing at import. - Catalog validation and legacy offers:
server/utils/stripePricing.tsdefines 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_IDSare 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 indocs/operations/stripe-catalog-rollout.md. - Staged provider configuration:
STRIPE_PORTAL_CONFIGURATIONselects the prepared non-default portal configuration after deployment, keeping older deployments on their original portal settings.STRIPE_WEBHOOK_SECRET_NEXTpermits a second signature during migration to the separately staged, disabled Basil endpoint. The current primary secret and endpoint remain usable until operator cutover. Verification usesawait constructEventAsyncbecause 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_PREMIUMmust 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 acceptsbillingIntervaland 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(modesubscription). Bodyplan+ optionalinterval(month/year); whenintervalis absent it falls back to the site'sbillingPlan === 'ANNUAL' ? 'year' : 'month'.intervalis stamped into checkout + subscriptionmetadata. - Article packs:
POST /api/stripe/checkout(modepayment, ad-hocprice_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 apackid; 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 - reservedis the visible balance. Spend order is expiring grants, then carriedROLLOVERgrants, then purchased/admin ones. When aPLANgrant expires,expireAvailableGrantsmoves its unused units into a non-expiringROLLOVERgrant up toarticleRolloverCap(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 fromgrant-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 inAiUsage; separateAiAllowanceGrantfunding 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 oneAiUsagerow per AI call and never throws, so a logging outage cannot fail the work it measures. There is no token balance or reservation; the formerTokenWalletminted 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 entitlement is
- Customer Portal:
POST /api/stripe/portal(billingPortal.sessions.create) — self-serve plan change (with proration), cancel, payment method, and invoice history + PDF. Surfaced from thesettingsbilling tab. Requires the Customer Portal to be enabled/configured in the Stripe Dashboard (test + live). - Auth:
checkout,subscribe, andportalall require a session and deriveclientSiteIdfrom it (superadmin may override via body) — never trustclientSiteIdfrom 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 fillfirstPaidAtonly 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 fromgrant-annual-article-credits. Plan is derived from the subscription's active price ID viaplanFromPriceId, falling back to metadata. - Losing a plan is not only
subscription.deleted.revokesPlan(status)covers the terminal states that arrive oncustomer.subscription.updatedand never produce adeletedevent: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_dueis 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).clearSubscriptiononly on a terminal deletion — anunpaidsubscription still exists in Stripe and revives on payment (updated → activere-promotes it), so its id is kept.stripeCustomerIdsurvives either way, or the tenant loses portal access to their own invoice history. - Billing UI: the
settingsbilling 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.vueon the admin dashboard, the only one that checks out inline (POST /api/stripe/subscribe). It reads the samegetUpgradeTarget(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-lineUAlertclosed through itscloseprop (UAlerthas no default slot, so a hand-placed close button never renders). Dismissals are anupsell-dismissedcookie keyed per offered plan (dismissing PRO still leaves PREMIUM to be offered later); unlike localStorage it is readable during SSR. The full pitch stays inForm/Client/Billing.vue. - Second purchase surface: the admin article pill
Client/Version.vue(bottom-right,layouts/default.vue,isAdminonly). It reads the authoritativestatus.articleWallet, shows active expiring grants and the paginated/api/clients/:id/article-walletledger, and links the same server-owned pack catalog.- User-facing copy consistently says articles / články;
tests/unit/walletCopy.test.tsrejects leaked token or credit terminology in these surfaces. - Pack view models come from
app/utils/articlePackPresentation.ts(buildArticlePackViews) overARTICLE_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=billingrather than checking out inline.
- User-facing copy consistently says articles / články;
- Onboarding recovery and auto-login.
onboarding/checkout.post.tsvalidates email proof beforeserver/utils/onboardingRequest.tsclaims a system-onlyOnboardingRequest. Domain-separated HMACs usingAUTH_SECRETbind 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@omitfields. 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-inbillingCheckoutIdor 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.tsandtests/unit/routes.test.tsdefine the path.pages/autorizace/index.vueredeems it atPOST /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 showingcommon.auth.onboardingLoginExpired. The first eligible inbox starts on PREMIUM trial; repeat inboxes start BASIC. The sibling landing only coalesces pending calls and forwardsIdempotency-Key; completed links/errors are not cached. Ship the matching platform migration before that landing release. Recovery needs no new secret; keepAUTH_SECRETstable. - Required env:
STRIPE_SK,STRIPE_WEBHOOK_SECRET,STRIPE_PRICE_PRO,STRIPE_PRICE_PREMIUM,STRIPE_PRICE_PRO_ANNUAL,STRIPE_PRICE_PREMIUM_ANNUAL.
- Ledger owner. Article credits (
ArticleCreditWallet/Grant/Operation) and AI funding (AiAllowanceGrant/Invoice/UsageReservation) carryaccountId;clientSiteIdis attribution only. The spending account issite.billingAccountId ?? site.id(server/utils/billingAccount.ts → spendingAccountId). Every blog has a DIRECT account with its own id, created by theClientSite_direct_billing_accounttrigger and the20261111120000_billing_accountsbackfill; direct billing, subscriptions and debts stay onClientSite. Settlement and refunds use the operation's storedaccountId, 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.
reserveArticleCreditandreserveAiAllowancedraw from the DIRECT account when it covers the whole amount (one operation, one account), outsidemonthlyUnitLimit; otherwise the pool pays all of it. The DIRECT side never runsfund, whose plan terms would read the agency plan as the blog's own; it only mints article funding (fundArticles).getArticleCreditWalletandgetAiAllowanceadd 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) andSTRIPE_PRICE_AGENCY_UNITS($30 per 50 units), with theirSTRIPE_PRODUCT_*ids validated bygetConfiguredAgencyPrices; minimum 3 blogs + 4 blocks. AIBUDGETis 50 % of the net agency invoice;SEOis per blog plus per add-on;TRANSLATIONper block plus per add-on;EDITper block;VISIBILITYper add-on. Rollover cap 2× monthly units while live.ClientSite.monthlyUnitLimitcaps one blog's period spend (BLOG_UNIT_LIMIT_REACHED). - Stripe (
server/utils/agency.ts): subscription metadata{ kind: 'AGENCY', billingAccountId };handleAgencyStripeEventruns first in the webhook and owns those events, direct handlers filterbillingAccountId: null. Raises usealways_invoice+pending_if_incompleteand grant the unit difference from the paid proration lines; decreases useproration_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 whilebillingAccountIdis set, DB check) is the agency's pick;plancaches it while live, BASIC otherwise (syncAgencySites). PRO and PREMIUM take ablogSlotsslot, PREMIUM also apremiumSlotsadd-on; BASIC takes none and is the ordinary free plan (custom domain retired bysyncPlanFeatures).agencySlotShortfallis the one rule for attach,PATCH blogs/:id, checkout and plan changes; deleted blogs take no slot (agencyBlogPlans).syncAgencySitessteps picks above the paid slots down, newest blog first. A BASIC agency blog is refused byreserveArticleCreditand AI funding (AI_PLAN_REQUIRED), since its pool stays funded for the others. - Access.
BillingAccountMemberis projected intoTenantMembershiprows taggedbillingAccountId(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 409AGENCY_MEMBERSHIP).revokeAgencyAccessdrops 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 dailyagency-synctask repairs plans and projections. - API:
server/api/agency/*(all throughrequireAgencyMember; 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, whereBillingAccount/BillingAccountMemberare readable only to the agency's members; locked ledger, projection and Stripe writes stay on the system client.report.get.tsexports one billing period (?period=<periodStart>, one ofagencyPeriods) 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 ini18n/locales/*/agency.json. - Entry without a blog.
middleware/agency.tsadmits a signed-in person with no blog (reader) on the platform host (canOpenAgencyWithoutBlog); everyone else goes throughadmin. The landing links to/auth?mode=register&intent=agency&blogs&premium&blocks; the auth page and middleware store it in theagency_intentcookie (agencyIntentFromQuery),/startthen opens the agency instead of a blog, and the pick seedsuseAgencyDraftuntil the first subscription.TenantCreatewithagencyPlanskips the plan step and attaches the new blog to the agency.
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 inapp/composables/useGam.ts → initialize— so GAM revenue reports break down per site.useGamAds(site)takes the tenant fromuseClientSite()rather than refetching it. - Split is deterministic + server-side (
server/utils/adRevenue.ts, pure + unit-tested intests/unit/adRevenue.test.ts):splitAdRevenue/buildEarningRowapply the plan ratio to GAM gross; the invariantclientCents + platformCents == grossCentsholds (incl. negative clawbacks).CLIENT_SHAREis the single source of the ratio. - Ledger:
AdEarning(prisma/models/ads.zmodel, migration20260725120000_ad_earnings) — one row per(clientSiteId, periodStart, periodEnd)holding gross/client/platform cents, ashareRatiosnapshot, andAdPayoutStatus. Read = superadmin or admin of own site; create/update/delete = superadmin/system only (clients cannot fabricate earnings).shareRatiois snapshotted per row so a later plan change never retroactively rewrites past earnings. - Ingest:
server/tasks/gam-sync.ts(cron0 4 * * *) pulls the previous UTC day per-client and idempotently upserts the ledger (re-runs correct the numbers). No-ops whenGAM_NETWORK_CODE/GAM_SERVICE_ACCOUNT_KEYare unset; fails loudly on half-configuration so revenue is never silently dropped. - GAM Reporting client (
server/utils/ads/, no new dependency):gamAuth.tsmints an RS256 service-account JWT withnode:cryptoand exchanges it for anadmanageraccess token (cached until ~2 min before expiry);gamReport.tsdrives the Ad Manager REST v1 report lifecycle — create report →:run→ poll the operation → paginate:fetchRows. Attribution comes from theclient_idkey-value dimension named byGAM_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 singlemicrosToCentsrounding, so split rows can't drift a cent. Pure helpers (toGamDate,gamDateRange— turns our exclusiveperiodEndinto GAM's inclusiveendDate—buildReportDefinition,readValue,parseReportRows) are unit-tested intests/unit/gamReport.test.ts; the network path is untested-by-design (no live credentials). - Read API:
GET /api/clients/earnings(requireDb,minRole: 'admin') — optionalfrom/to(default last 12 months, hard cap 24) andclientSiteId. Tenant isolation is ZenStack policy, not thewhereclause: an admin passing a foreignclientSiteIdsimply gets nothing back. Returnssummaries(one per currency —summarizeEarningsnever sums across currencies), each with totals,byMonth, andclientCentsByPayoutStatus, plus abySitebreakdown and ascopeflag (platformfor an unfiltered superadmin). - Consent and advertising:
ConsentManager.vuekeeps Technical cookies always active and independently gates first-party statistics and tenant GA4 through Analytics. The versioned host-onlytopiqu_consentcookie 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 callsDELETE /api/consent/analyticsfor 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 anduseGamAds.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 allowssecurepubads.g.doubleclick.netscripts and SafeFrame frames. Slots:/home/feed(Home/FeedAd.vue),/article/body(Article/BodyAd.vue, at the server-chosenadBreakIndex) 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 inClientVersion, readers and guests getConsentSettingsButton. 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 createddataLayer, 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/payoutRefare the anchors already in place; admin earnings UI (API exists, no page yet). - Article packs need no Stripe catalog objects —
stripe/checkoutbuilds ad-hocprice_datafrom the server catalog at checkout.
- ESLint 9 (
@nuxt/eslint+eslint-plugin-perfectionist), Prettier 3. - Testing: Vitest 4 with
@nuxt/test-utils, jsdom, V8 coverage. Config invitest.config.ts(jsdom env). Tests live intests/**, co-located*.test.tsis also picked up. First suite:tests/server/stripe/webhook.test.ts. - Typecheck via
vue-tsc(bun run typecheck); kept out ofbuildfor fast deploys, run separately in CI. - Package manager: bun (
bun.lock), pinned to one version inpackage.json, Docker and CI.tests/unit/releaseToolchain.test.tsprevents 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 undercore.autocrlf=truewrites CRLF, Prettier rewrites it to LF, and every file's cached size then mismatches —git statusreports the whole tree modified whilegit diffis empty. Recover withgit add --renormalize ..
- 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:scheduledTasksregister per replica.nuxt.config.ts::nitro.scheduledTasksdefines the current task names/cadences, gated to production outside browser tests;shared/utils/operations.tsdefines 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 theArticleTranslationsidecar (see §6 → Article Translations).pages/clanky/[slug].vueresolves content locale-scoped (primary language → sourceArticle; other locale → itsPUBLISHEDtranslation, falling back to the source as a legacy i18n alias when none exists). Once ≥1 translation is published, the page emits realhreflang+x-defaultand 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 purebuildCanonicalOrigin/toAbsoluteUrlinshared/utils/seo.ts, tested bytests/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 hardcodedi18n.baseUrl/site.urlwould stamp the wrong host on every client site. It forceshttps:outside dev (a proxy that dropsx-forwarded-protowould otherwise emithttp:canonicals) and strips a leadingwww..pages/clanky/[slug],stitky/[slug]andautor/[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 onwww. - The i18n-generated
hreflanglinks are absolutised inapp.vue.useLocaleHead()prefixes itslinkentries withi18n.baseUrl, which is unset (and cannot be set statically — see above), so it emitted relativehrefs oni18n-alt-*/i18n-xd; search engines ignore relative hreflang, which is what a Bing audit ofpixbo.topiqu.comflagged.app.vuenow mapsi18nHead.linkthroughtoAbsoluteUrl(href, canonicalOrigin)before spreading it intouseHead, so every generated alternate is fully qualified per tenant. Already-absolute hrefs pass through untouched, so this stays correct ifbaseUrlis ever configured.
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.vueused to split the stored HTML inonMountedbehind animport.meta.clientguard, so the served markup contained an empty container — and GPTBot, ClaudeBot, PerplexityBot and CCBot do not run JavaScript. The split now happens inarticles/[id]/index.get.tsviaserver/utils/articleBlocks.ts(cheerio) and ships asblockson the payload, afterresolveArticleLinksandresolveProductCards. Product cards and X posts become their ownproduct/xPostblocks (Article/ProductCard.vue,Article/XPost.vue). Shaping rules live once inshared/utils/articleBlocks.ts; the editor preview adaptsDOMParseronto the same builder (app/utils/articleBlocks.ts). Adjacent HTML nodes merge into one run — a wrapper per node breaks the adjacent-sibling rulesprosedepends on. Stored body images remain raw HTML, butArticle/Parsed.vuenow injects Nuxt Image/IPX WebP candidates throughshared/utils/articleImages.ts. Every image retains its direct upload indata-original-src; a failed proxy response removessrcsetand 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 dropcontentfrom the payload. - Heading anchors come from
headingSlug(NFKD, soPřehled trhu→prehled-trhu, notp-ehled-trhu).stampHeadingIdswrites them on save (POST/PATCH); the block builder stamps any body that predates that, and never overwrites an existing id.Article/TOC.vueonly generates ids for the editor preview. - Article slugs are normalized by
shared/utils/articleSlug.tsin 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 inArticleSlugRedirect; the article API answers an old slug with 404 +data.movedTo, whichclanky/[slug].vueturns 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+jsonblobs.app.vuedefines 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 detachesWebPage.isPartOf;useArticleSeoadds Article/BlogPosting, the author Person ({authorUrl}#author, the same@idautor/[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.sourcesbecomescitation,publishedAtbecomesdatePublished(createdAtis when the draft was opened), andtotalWords/readingTimebecomewordCount/timeRequired. A second bare#organizationnode next to#identityis 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.hasExternalContentHomethen makes article, home, tag and author pagesnoindex, follow, drops canonical/hreflang and the RSS link, empties the sitemap and 404s/rss.xml,/llms.txtand/md/*.publicationSeoLinksapplies 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;excludeAppSourcesandautoI18n: falseturn off the module's own discovery, which only ever finds the app shell. It emits alternates only forPUBLISHEDtranslations, and skips the homepage for a tenant with no articles and tag/author pages on BASIC — in both cases the page itself emitsnoindex, 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.tsrestates thei18n.pagessegments because Nitro has nolocalePath.tests/unit/routes.test.tsasserts them againstnuxt.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.tspushes the tenant's name/description/locale into the site-config stack aboveSiteConfigPriority.runtime.nuxt-seo-utilsbuildstitleTemplateandogSiteNamefromsite.name, so without it every tenant's pages were titled… | Topiqu AI Blogand inherited the platform's Czech description. A tenant with no description pushes'', not nothing, so the platform line is suppressed rather than borrowed.seo.metaDataFilesis off: it bakedpublic/favicon.ico(withsizes) into every head and browsers preferred it over the tenant favicon;app.vueowns the icon links. - hreflang on articles is owned by the page.
useLocaleHeadderives alternates from the route alone, so it advertised a translation that may not exist;app.vuefilters itsalternatelinks out on the article route.x-defaultisalternates[0], which the API builds as the source language. - Extraction fields.
Article.answer/keyTakeaways/faq(mirrored onArticleTranslation) 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 inArticle/Editor/Summary.vueandArticle/Editor/Faq.vue: a translation keeps them in its draft and saves them throughPATCH /api/translations/:id, which marks itHUMAN. 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, andchangesReaderTexttreats an empty list as none. The preview renders with the publicArticleSummary/ArticleFaq.readFaq(shared/utils/articleFaq.ts) narrows the Json column;FAQPageis emitted only when it is non-empty. The mirror is only as good as the write —generateTranslationtranslates and bills all three, but both write sites build their owndataobject, so a dropped field costs the tokens and then renders the source language under a translated body (tests/server/ai/translationRow.test.tspins both).[id]/index.get.tsfalls 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, andCRAWL_LIMITraises 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_ROUTESderives those rules fromLOCALIZED_SEGMENTS(homepage, article, tag and author paths in every locale) plus/md/**; hardcoded, they once covered onlycs/enarticles. Article.contentUpdatedAtis the modification date, notupdatedAt: theBase@updatedAtalso moves on every view counter increment and status flip, which made every visited article claim a fresh edit indateModified, sitemaplastmodand the.mdUpdated:line. It is set only after first publication, when reader-visible text changes (changesReaderTextinserver/utils/articleModified.ts: PATCH, autopilot and its rollback, content actions) and is server-owned (stripped from the PATCH body).articleModifiedAtfalls back topublishedAt. Translation rows keep their ownupdatedAt; a translation leaves the sitemap asSTALEand returns only when re-translated, so it stays honest.- IndexNow (
server/utils/indexNow.ts, Bing/Seznam/Yandex):announceArticleruns 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=Nlink (pages/index.vue); with JavaScript the click appends in place. SSR readspage, so every article is reachable by links, not only through the sitemap.nuxt-seo-utilskeepspagein the canonical; a page past the end isnoindex. - Bot lists live in
shared/utils/crawlers.ts, imported by bothnuxt.config.tsand the runtime. Three fetching groups (ANSWER_ENGINE_BOTSretrieval/user-triggered,TRAINING_BOTScorpus,SEARCH_BOTSclassic) plusAI_GROUNDING_TOKENS, which are not crawlers —Google-ExtendedandApplebot-Extendedhave no user agent at all and exist only as robots.txt control tokens for Gemini and Apple Intelligence, so they are absent fromdetectCrawler. Matching is longest-token-first orClaude-SearchBotresolves asClaudeBot. server/plugins/crawlerLog.tsships 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.txtand the.mdvariants, 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
AiCrawlerDailybucket 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 deduplicatedAiReferralVisitrows./admin/visibilitykeeps 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 carrieslatestRuns(latest run per provider viaDISTINCT ON, configured providers only), whichpromptOutcomeinshared/utils/aiVisibility.tsrolls into one verdict; coverage metrics count every run in the 30-day window.AiVisibilityPromptchecks run manually or weekly per prompt for PREMIUM/CUSTOM tenants (theai-visibility-monitortask 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 becomeAiCitationrows. Runs retain their answer, model, usage and failure, log aggregated usage toAiUsage, 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 anAiVisibilityOpportunity; the prompt's ownarticleId, else an overlap match, points to an existing article, otherwise the action opens a prefilled new-article AI composer;HIDDENdomains are left out of itscitedDomains. 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 throughshared/utils/answerMarkdown.ts+Stats/AnswerMarkdown.vue(template-only, nov-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.tstops each eligible tenant up toAUTO_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/audiencecovers a tenant with none of those. Therefthe model returns setssource(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 onMANUALprompts.retireSilentPromptspauses 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.
citedDomainsinshared/utils/aiVisibility.tscounts each non-owned domain once per answer over the 30-day window. A domain cited for at leastmax(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.shareshares its denominator with the tenant's citation coverage so the two are directly comparable.AiVisibilityDomainholds the tenant's override (COMPETITORpromotes,HIDDENremoves), written byPUT /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, oneh1, and the schema graph complete with no dangling@id. That last check is what caughtWebPage.isPartOfpointing at a WebSite node that no longer existed.
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.
NUXT_PUBLIC_SENTRY_DSNselects the Sentry-protocol error/metric destination. Local SDK transport tests do not establish provider-side ingestion, feature compatibility or current prices.server/utils/logger.tsindependently usesBETTERSTACK_SOURCE_TOKENandBETTERSTACK_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.errorand 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.tsandvisibilityMonitoring.test.tspin 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.
sentry.client.config.ts(project root) — SDK init fromruntimeConfig.public.sentry. Empty DSN disables the SDK (local / CI).sentry.server.config.tsreadsprocess.envand must also work before a Nitro context exists. CallinguseRuntimeConfig()there can kill startup. SDK11 bundles this initializer into Nitro through its module; no separate runtime preload or deprecatedautoInjectServerSentryoption is needed. Never initialize it twice.shared/utils/telemetryPrivacy.tsexplicitly disables automatic user/IP, cookies, headers, bodies, query parameters, AI inputs/outputs, database query data, queue/GraphQL payloads and stack-frame variables. Static tracing preservesbeforeSendTransactionredaction and the legacy transaction envelope. Authentication scrubbing covers the envelope's dynamic sampling context as well as its event body.tests/server/telemetryPrivacy.test.tsexercises actual SDK transport with synthetic metadata and no external requests; provider ingestion still needs an authorized runtime smoke.nuxt.config.tsregisters@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.tsemits one JSON line per record to the container and the same sanitized record to Better Stack.LOG_LEVELdefaults toinfo; ordinary successful reads aredebug, mutations areinfo, slow requests and client failures arewarn, and server failures areerror. 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.tsinstruments Nitrorequest,errorandafterResponsehooks.utils/requestLog.tsgives each request a server-generatedx-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 explainAUTH_HOST_UNAVAILABLEorAUTH_HTTPS_REQUIREDwithout 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.tssees what Nitro turns into a 5xx. Atry/catchthat 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-argumentreportErrorauto-import. It matters most where a failure is designed to be non-fatal: best-effort image generation, stock-provider fallbacks, andarticles/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/Bar.vuerenders the list and owns optimistic deletion;useNotificationPollingowns 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 honorsRetry-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 withsinceplussinceIddrains 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 onlysinceretain newest-first responses. Notification reads remain policy-enhanced and scoped to the authenticated user; no schema migration is needed.
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.tsis the central Nuxt UI visual configuration;app/assets/styles/main.csscontains global semantic tokens, locally bundled variable fonts, reading typography, and reduced-motion behavior. -
Dashboard page roots use
.topiqu-dashboard-pagefor 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 atxland above the pickers on smaller screens. Social profiles and theafterslot 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 followfooterSettings.showSocials; Basic retains its configured social links beside the required Topiqu referral.Client/Socials.vueuses 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:manualand Nuxt'srefresh:hook, while initial rendering still reuses the server payload. -
Dashboard primary is ink.
colors.primaryisslateand:root --ui-primaryis--ui-bg-inverted(black; white in dark mode), so links, icons, hovers and selected controls match the buttons;.publication-surfaceswaps--ui-primaryfor the tenant accent. Solid primaryUButtons paint withbg-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-progressclips instead of translating so the gradient stays anchored),NuxtLoadingIndicator, and theClientSiteOG 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 usecolor="neutral"outline/soft. -
app/components/AppMedia.vueis the shared aspect-ratio-safe media primitive. Stable local and allowlisted CDN URLs render throughNuxtImgas responsive WebP; untrusted or signed URLs stay direct.app/composables/useImageFallback.tsfalls 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 throughserver/utils/images/optimize.tsbefore 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-isolateduseState<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 everyloading="lazy"image below the fold failed before the browser requests it, anduseImageFallbackthen skips that URL for the whole session. That is what emptied the favicons inArticle/SourceList.vue, which renders under the article. -
app/utils/sourcePresentation.tssplits a citation into hostname and path and builds its icon URL;Article/SourceList.vue(public) andArticle/Sources.vue(editor) share it. Icons come fromt1.gstatic.com/faviconV2keyed 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.vueuses 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 sameuseAsyncDataentry through onefetchClientSite. Persistentapp.vue,Header.vue,Footer.vueandlayouts/default.vueconsume 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; bothrefresh:manualandrefresh:hookbypass 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:UFileUploadfor the empty state (it owns the dashed dropzone, drag-and-drop and the dragging state),USliderfor zoom,UAlertfor 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.useAvatarCropperexposesacceptFileso a file can arrive from a drop, not only from its own file dialog. Transparency behind a logo or favicon is drawn by.transparency-gridinmain.cssfrom--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.vuewas written against a<Modal>andForm/Client/IntegrationsCatalog.vueagainst 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 (UModalwith#body,AppFormField), andnuxtUiContract.test.tsresolves every PascalCase template tag againstapp/components/, Nuxt UI and an explicit external allowlist. -
AppFormFieldwithtype="textarea"andmaxLengthshows an/maxcounter in the field hint, amber from 90 %. The bio limit isBIO_MAX_LENGTHinshared/utils/profile.ts, mirroring@length(0, 300)inuser.zmodel;users/[id]PATCH enforces it in zod as well. -
A rejected upload always names its reason.
File/Uploader.vuetoasts acommon.upload.*message with the measured value against the limit instead of a generic failure, andserver/api/upload.tsreturns the same keys translated, so a client-side refusal and a server-side one read alike. The favicon limits are one source of truth inshared/utils/favicon.ts. A file the browser cannot decode resolves the probe tonull— the earlierImage.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 everyArticleCardrenders 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.vuekeeps 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 fromclients/[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 inog:titleonly —<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 inBrandColorEditor, selectable font samples inBrandTypographyEditor, and a publication preview with the tenant domain as the tagline fallback.BrandColorInputsupports combined, swatch-only and hex-only presentation; logo palettes, gradient editing andBrandFontSlotuploads retain the same state and mutations. Choices areURadioGroup variant="card" indicator="hidden". Grids use container queries (@md:): atxlthe form column is ~390 px beside the preview, so viewport breakpoints split it too early. BelowhasAdvancedBrandinggradient presets are disabled and font slots are not rendered; each shows oneunlockProlink tosettings?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.tskeeps 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.tschecks every place that enumerates languages (schema enums, i18n locales and theirfiles, localized pages, shared language tables, number formats, message/master/email files, language names) againstLANGUAGE_OPTIONSorUI_LANGUAGE_OPTIONS, so a new language fails with the list of what is missing.tests/unit/i18nMessageSyntax.test.tscompiles every message, catching stray@, unbalanced braces and broken placeholders before runtime. -
playwright.config.tsandtests/e2e/provide deterministic responsive visual and accessibility checks. They require a separateTEST_DATABASE_URLand refuse to reuseDATABASE_URL. Therelease-smokeproject 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.jsto a non-cached, non-app-shell navigation configuration. -
Plan and feature mutations use a single database lock order:
ClientSitefirst, thenClientFeature.syncPlanFeaturesacquires 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 ordersUserbeforeSession. 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, andserver/utils/images/selection.tsdeduplicates 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 forgenerateintent 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.tsextracts 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 pinnedlookuponhttps.request; plainfetchwould 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, asaltonly; its figcaption holds just the AI label span, whichArticle/Parsed.vuestrips (and then drops the empty caption) for a tenant withdiscloseAiContentoff. Searched photos keep provider metadata and the credit line. A credit renders asFoto: <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, honoursdiscloseAi), not a caption line. The article-level disclosure sits inActionsBar's meta row:ArticleViewhands it to theactionsslot and renders it alone when no slot is given (editor preview).
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.