feat(ai-tts): per-block AI narration for articles - #2781
Conversation
uploadBuffer gains optional objectKey (used verbatim, no template/prefix) and skipReference (no FileReferenceService row), plus storageBackend/storageKey on the return value. audio joins the S3 routing whitelist alongside image|file|video. Both additions are needed by AI TTS: the audio filename must stay stable across regenerations without overwriting the CDN-cached object in place, and the ai_tts_blocks row is its own reference so cleanupOrphanFiles must not sweep it after 60 minutes.
…urse list separator into nested lists - splitIntoChunks(text, maxChars<=0) previously looped unboundedly, eventually surfacing an opaque V8 RangeError after wasted work; now rejects immediately with a clear message. - extractSpeakableText only inserted the sentence separator between list items at the top level; a list nested inside a listitem fell through the generic no-separator join, reproducing the concatenation collision this module exists to prevent one level down. The separator logic now lives in collectInlineText itself, so it applies at every recursion depth.
An AbortError from fetch was never an HttpStatusError, so isRetryable treated it as retryable and burned the full backoff/retry budget before surfacing TTS_GENERATION_FAILED. Check opts.signal.aborted in the retry loop before continuing, matching the openrouter-images-api.ts convention.
Add AiTtsRepository wrapping ai_tts / ai_tts_blocks: findByRefAndLang, findAllByRef, findBlocks, upsertParent/upsertBlock (ON CONFLICT replace-in-place keyed on the unique indexes), deleteBlocksByIds, deleteById/deleteByRefId (return removed blocks for object cleanup), listPaginated with an optional search filter, and findMeta (block count via jsonb_array_length, no block rows loaded). Register the aiTts token in POSTGRES_REPOSITORY_TOKENS.
deleteById/deleteByRefId ran an un-transacted SELECT of block rows
followed by a separate parent DELETE relying on ON DELETE CASCADE. A
concurrent upsertBlock landing between the two would be cascaded away
without ever appearing in the returned rows, orphaning its stored
object. Both now run inside a transaction that takes a `for('update')`
lock on the parent row(s) first, so a concurrent block insert blocks
on the FK check until the transaction resolves, and delete the block
rows via `.returning()` directly instead of a separate select.
Removed listPaginated's `search` parameter (and the now-unused
buildSearchFilter/ilike/or/tryParseEntityId imports) per YAGNI — no
planned caller reaches it, and the design doc's search intent for this
endpoint is an article-title join at the service layer, not a
substring filter over the TTS row's own columns.
Test coverage: the parent-delete test now upserts a second, distinct
block before deleting so the removed-block assertion covers every row,
not just one; deleteByRefId's test now also re-queries findBlocks for
both deleted parents to confirm the block rows are gone from the
database, not just the parent rows; the listPaginated test drops the
search assertions and instead asserts page/size/total/hasNextPage/
hasPrevPage precisely across two pages.
…eshness, lock safety
- AiTtsService.handleArticleDeleted deletes ai_tts/ai_tts_blocks rows via the existing AiTtsRepository.deleteByRefId and best-effort deletes their storage objects; wired to POST_DELETE/NOTE_DELETE/PAGE_DELETE via @onevent, mirroring AiSummaryService's handleDeleteArticle. - AiTtsService.reconcileOrphans lists objects under the tts/ prefix (S3, via a new S3Uploader.listObjects ListObjectsV2 call) or walks STATIC_FILE_DIR/audio/tts (local, via FileService.listObjectsUnderPrefix), diffs against every storage_key in ai_tts_blocks, and deletes anything unreferenced whose own storage mtime/LastModified is older than 60 minutes. The diff/delete/count orchestration lives in the new pure tts-orphan-reconciliation.ts module, kept separate to hold ai-tts.service.ts under the 500-line cap. - Wired to a new hourly cron (CronTaskType.CleanupTtsOrphans) through the existing CronTaskScheduler/CronBusinessService/TaskQueueProcessor pipeline. Note: cleanupOrphanFiles itself has had no cron since 2b4e946 (5 months ago) - only a manual admin endpoint - so this adds a fresh cron task rather than piggybacking on a call site that no longer exists, following the same removed CleanupOrphanImages job's EVERY_HOUR cadence.
…of a parallel orphan sweep Reworked after rebase onto 65736d7 (feat: reconcile isolated file references), which generalizes orphan-file handling with file_usages, FileReferenceUsageRepository, FileReferenceReconciliationService, and a local-storage inventory that already walks the `audio` FileType. - Register `ai_tts_blocks` (keyed on its `url` column) as a usage source in FileReferenceUsageRepository.findReferencedUrls and findUsageMatches, identically shaped to the existing ai_summary/ai_insight/ai_translation registrations. A TTS audio object with a live block row is now recognized as referenced, not isolated, by both the live cleanupOrphanFiles/ batchDeleteOrphans path and the new reconcile() usage bookkeeping. - Stop passing `skipReference: true` on TTS chunk uploads. A real file_reference row (created via FileService.writeTrackedOwnerFile, same as any other owner upload) plus the ai_tts_blocks usage row above is what makes the audio show as used instead of isolated in the admin's orphan review UI and in storage accounting. `uploadBuffer`'s `skipReference` option itself is unchanged - only the TTS caller stopped opting out. - Deleted the parallel machinery this replaces: CronTaskType.CleanupTtsOrphans and its scheduler/business-service wiring, tts-orphan-reconciliation.ts, FileService.listObjectsUnderPrefix, S3Uploader.listObjects (and the signedRequest bucket-root signing tweak it needed), AiTtsRepository .findAllStorageKeys, and their tests. - Kept AiTtsService.handleArticleDeleted / handleDeleteArticle: deleting rows and objects immediately on POST/NOTE/PAGE_DELETE is strictly better than waiting on a reconcile pass, and ai_tts's polymorphic ref_id still has no FK to cascade on. - Added coverage in file-reference-reconciliation.pg.e2e.spec.ts proving a TTS audio object is classified referenced while its ai_tts_blocks row exists, and isolated once that row is deleted. Known limitation, not a regression: the upstream local-storage inventory does not enumerate S3-hosted objects; this is a pre-existing property of the whole file-reference system, out of scope here.
…ai_tts_blocks row
The "becomes isolated" reconciliation test never checked that the reference
actually reached Active before the block row was deleted, so it passed
identically whether or not the ai_tts_blocks usage-source registration
existed - it only proved "unreferenced stays unreferenced". Added
`expect(referencedBefore?.status).toBe(FileReferenceStatus.Active)`
immediately after the first `reconcile({ apply: true })` call, before the
row is deleted.
Verified by temporarily reverting the ai_tts_blocks registration in
file-reference-usage.repository.ts: both TTS tests now fail, and the
"becomes isolated" test fails specifically on the new assertion
(expected "active", received "pending") rather than later in the test -
confirming it now discriminates. Restored the registration afterward
(git checkout, zero diff).
Adds the AI TTS HTTP surface: task creation, admin ref/list/delete endpoints, and a public per-article narration endpoint gated by both the shared article-visibility guard and a premium/paywall check copied from AiInsightsService. AiTtsService gains only deleteById (reuses the existing deleteObjects cleanup helper). The other three owned methods -- getPublicNarration, getDetailsByRefId, list -- live in a new AiTtsQueryService: adding them to ai-tts.service.ts would have pushed it past the 500-line cap, and they are a natural read-model seam distinct from the write/lifecycle concerns already there.
- TtsMetaSchema on PostResponseMeta/NoteResponseMeta, .tts() on both meta builders - AiTtsQueryService.getMetaForArticle: single existence-only lookup, stale derived from article.modifiedAt vs the stored sourceModifiedAt (which already carries the translation's own vintage for translated rows) - Wired into post detail, note detail (shared assembly), and note /latest alongside their existing .insights() calls; failure-safe via .catch() - Locked-paywall posts report tts.available:false, mirroring summary suppression - Update every e2e/contract spec that DI-constructs PostController/ NoteController with the new AiTtsQueryService dependency
…ange - Delete-by-row belongs to Task 16's management page per the plan; remove the trash button, its confirm wiring, the delete mutation, and the four now-unused i18n keys from the editor drawer. deleteTts stays exported in api/ai.ts for Task 16. - The write route keeps the article id in useSearchParams rather than a route param, so switching articles reuses the same hook instance. Reset open/activeLang/pendingTaskId/runStatus/runError whenever refId changes so an in-flight task's completion effect can no longer attribute its banner or cache invalidation to a different article.
GET /ai/tts previously returned pagination-only meta, leaving the admin fleet view with no way to resolve a row's article title from its refId. Mirror ai-summary's flat-list precedent: AiTtsQueryService.list() now also resolves an ArticleRefMap via DatabaseService.getRefArticleMap, and the controller builds meta with PostMetaBuilder().articles(...) alongside pagination.
Fleet view over every generated narration, mirroring AiTranslationEntriesRouteView's flat paginated table (useUrlListState + CompactPagination + GroupedResourceStates) rather than the grouped article view, since GET /ai/tts is a flat list, not article-grouped. - Columns: article title (resolved from response meta via refId), language, block count, character count, updated at. - Row actions: Regenerate (force: true, per-row lang) and Delete (confirmDialog-gated, consuming the previously-unused deleteTts). - Header action: batch enqueue (no force) over checkbox-selected rows, using the shared useListSelection hook; selection resets on page change since classic pagination replaces the row set wholesale. - api/http.ts: requestJson's meta-unwrap only ever surfaced meta.pagination, discarding sibling meta fields. Generalized it to spread the rest of meta (e.g. articles) alongside data/pagination - needed for any endpoint using withMeta() beyond just pagination. - Nav entry is automatic via the file-based route scanner (vite-plugins/admin-routes) reading page.tsx's metadata export; no manual nav list exists to edit. - Fixed an adjacent gap: AITaskType enum and taskTypeLabelKeys were missing Tts entirely, so TTS jobs rendered unlabeled (falling back to a raw refId) on the generic Tasks page. New i18n keys added to both en-US.ts and zh-CN.ts; reused existing generic ai.action.regenerate/delete, ai.confirm.deleteRecord, and ai.toast.* keys where they already covered the message.
…um-blind The meta surface and the public endpoint applied different guards, so three populations were told narration existed and then handed null: active members, the site owner, and every reader on a site that has not enabled membership. Password-holding note readers had the same problem. getPublicNarration now takes reader identity (@HasAdminAccess, @CurrentReaderId and an optional ?password= for notes) and judges access through the same machinery PostController.applyPaywall uses. EntitlementService gains isEntitledToPremium / isPremiumLocked as the single membership rule, and applyPaywall delegates to it so the two sides cannot drift again. isGlobalArticleVisible is generalized to isArticleVisibleToViewer(article, viewer); the anonymous call keeps its exact previous behaviour. Its note-password branch was reading document.password, which NoteRepository projects away in favour of hasPassword, so it never fired on a loaded row — it now accepts either field, which also tightens summary, insights and translation. Note detail suppresses meta.tts for an anonymous reader of a future-dated secret note, whose text is blanked in the same response. Caching needs no change: HttpCacheInterceptor returns before any Redis access whenever the request carries identity, guards run before interceptors, and this route sets no force cache option — so an entitled reader's narration is never served from or written into the shared cache.
…egments deterministically getMetaForArticle tested only row existence, but the generation pipeline creates the parent with an empty block_order before any synthesis. A run that died mid-flight therefore advertised available: true, blockCount: 0 forever. Both the meta and the public endpoint now treat an empty block_order as not published. findBlocks ordered by chunkIndex alone, which is 0 for every row in the common single-chunk case, so Postgres returned segments in scan order. It now orders by (blockId, chunkIndex), and toSegments sorts by the parent's blockOrder so the array's natural reading is article order.
…ingerprint The stored object is addressed by text AND the voice triple, but planTts keyed reuse on the text fingerprint alone. A force run that died after rewriting some rows left the parent pointing at the old voice while some rows held the new one, and the next incremental run reused every row because the text matched — a permanently mixed-voice article the system could not detect. PlanTtsInput now carries objectKeyFor(chunk). The service builds that closure once from the resolved voice and passes the same one to planTts and synthesize, so the key the planner compares against is provably the key the uploader writes. A row whose storageKey is not what this run would produce regenerates.
…lerless skipReference The design doc still prescribed the skipReference opt-out model, a voice-agnostic object key, and an orphan-reconciliation cron — all reversed or deleted by human rulings during implementation. Rather than rewrite the original prose, a 'Post-implementation deviations' section records what changed and why, so the reasoning behind each reversal survives; it is authoritative where the two disagree. It also corrects the premium paragraph to describe the entitlement-aware endpoint, and records the caching verification. With the doc no longer instructing readers to use it, uploadBuffer's skipReference option is deleted — no production caller passed it after the Task 11 rework. file-upload-audio.spec.ts is retargeted accordingly: the tests that defended skipReference now assert the pending reference the orphan system depends on, and the S3 path every TTS upload actually takes gains coverage, as does deleteObject's S3 branch.
… action real use-tts-generation cleared pendingTaskId only from the completion effect, which early-returns without task data. A 404 or a network drop therefore left the panel disabled and the running banner up until the user reloaded. An error effect now surfaces the failure and clears the pending task. The TTS management page's bulk action had four compounding defects: an unreachable onError behind Promise.allSettled, dedup hits counted as successes, no force flag (so on a page where every row already has narration, planTts reused everything and the headline action enqueued nothing), and discarded failure reasons. summarizeTaskBatch now reads the created flag, the batch forces, and reasons reach the toast.
isArticleVisibleToViewer returned early from the password branch, so a note that is both password-protected and future-dated became visible to an anonymous reader supplying the correct password — while note detail blanks that note's text for that same reader and reports meta.tts unavailable. The endpoint was looser than the meta. At base this was blocked only accidentally: the dead document.password check never matched, so control fell through to isNoteSecret. Repairing the field name activated the branch and exposed the early return. Password and secret are independent gates; both must pass. The owner short-circuit above them is unchanged.
… key comment The batch action now passes force, so one click on a full page re-synthesizes 20 narrations — from a button labelled 'Enqueue selected', while the strictly cheaper delete already confirmed. It is relabelled to regenerate wording in both locales and routed through the existing confirmDialog with a cost hint. buildTtsRegeneratePayload is extracted so the row action and the batch build the same payload, making the force flag structurally impossible to drop from one without the other, and testable on its own. tts-object-key.ts's comment still asserted that reuse keys on the speech fingerprint alone — the invariant the object-key reuse fix removed.
SafeDep Report SummaryNo dependency changes detected. Nothing to scan. This report is generated by SafeDep Github App |
|
| GitGuardian id | GitGuardian status | Secret | Commit | Filename | |
|---|---|---|---|---|---|
| 35820980 | Triggered | Generic Password | 3da0ba9 | apps/core/test/src/modules/ai/ai-tts/ai-tts-query.service.spec.ts | View secret |
🛠 Guidelines to remediate hardcoded secrets
- Understand the implications of revoking this secret by investigating where it is used in your code.
- Replace and store your secret safely. Learn here the best practices.
- Revoke and rotate this secret.
- If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.
To avoid such incidents in the future consider
- following these best practices for managing and storing secrets including API keys and other credentials
- install secret detection on pre-commit to catch secret before it leaves your machine and ease remediation.
🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b74d5a911a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| }) | ||
| } | ||
|
|
||
| await this.repository.upsertBlock({ |
There was a problem hiding this comment.
Stage regenerated chunks before touching live rows
When regenerating a narration that already has a published parent and the article is edited while synthesis is running, this upsert mutates the live ai_tts_blocks rows before the later modifiedAt guard skips finalizing. Because the public query still loads all blocks for the existing parent, the old blockOrder can now serve overwritten or extra chunks from an unpublished partial run; keep regenerated chunks staged (or otherwise invisible) until the source-stability check passes.
Useful? React with 👍 / 👎.
| const current = await this.loadDocument(refId) | ||
| if (!sameInstant(current.modifiedAt, document.modifiedAt)) { |
There was a problem hiding this comment.
Validate translation source before publishing TTS
For translated narration, the synthesized content comes from the ai_translations row, but this final guard only reloads the article and compares the article modifiedAt. If the translation is edited or regenerated while TTS is running, the article timestamp can stay unchanged, so stale audio for the old translation is published as current; capture and re-check the translation row/hash before upserting the parent for translated languages.
Useful? React with 👍 / 👎.
…, inline regenerate - TtsSegmentPlayer: timeline-style segments with vertical rail and node buttons; add optional onRegenerate prop rendered before PlayAll - TtsPlaybackBody: simplify top to lang badge + inline count, drop stat box and chip row; pass regenerate mutation to player - ArticleDetailPane: remove duplicate section title, keep only item count
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5eaf1aa873
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if ((error as { code?: string })?.code !== AppErrorCode.FILE_EXISTS) { | ||
| throw error | ||
| } | ||
| return { | ||
| url: await this.fileService.resolveFileUrl('audio', objectKey), |
There was a problem hiding this comment.
Persist audio produced by forced regeneration
When force=true but the text and voice settings are unchanged, the content-derived object key is also unchanged; on local storage this FILE_EXISTS path discards the newly synthesized buffer and returns the old file. Consequently Regenerate incurs provider work and reports success while the audible result never changes. Give forced runs a fresh object key/version or explicitly replace the existing local object.
Useful? React with 👍 / 👎.
| const runTask = (force: boolean) => { | ||
| if (!refId || isRunning) return | ||
| setRunStatus('running') | ||
| setRunError(undefined) | ||
| createTaskMutation.mutate({ force, refId }) |
There was a problem hiding this comment.
Regenerate the selected narration language
When the drawer contains multiple language rows and the user selects a translated narration, Regenerate still submits only { force, refId }. The backend interprets an omitted langs field as the article's source language, so the selected translation is left unchanged while an unrelated source-language narration is regenerated; include activeLang in langs for this action.
Useful? React with 👍 / 👎.
| async getTts({ articleId, lang }: { articleId: string; lang?: string }) { | ||
| return this.proxy.tts.article(articleId).get<AITtsModel | null>({ | ||
| params: { lang }, | ||
| }) |
There was a problem hiding this comment.
Forward note passwords through the API client
For a password-protected note, the server's public TTS endpoint requires the verified password in query.password, but this official client method exposes and forwards only lang. Consumers using the client therefore always receive null for narration even after unlocking the note; accept an optional password and include it in params.
Useful? React with 👍 / 👎.
| Readable.from(buffer), | ||
| ) |
There was a problem hiding this comment.
Avoid tracking directly owned local narration files
Local TTS uploads now go through writeTrackedOwnerFile, creating a pending file_references row for every chunk even though narration cleanup deletes storage objects directly. After a narration block is replaced or deleted, that database row becomes isolated, and the orphan cleanup cannot remove it because resolveLocalFileType does not recognize /objects/audio/; repeated article edits therefore accumulate stale reference rows. Either opt these explicit audio uploads out of reference tracking or remove their references alongside the objects.
Useful? React with 👍 / 👎.
Add a shared useAiQuickActions(refId) hook that returns context menu items for generating summary, insights, translation, and TTS tasks. Wire it into both NoteRow and PostRow so the AI submenu appears in their context menus, alongside the existing edit/publish/copy/delete actions.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d387a430d8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const s3Uploader = new S3Uploader({ | ||
| endpoint: config.endpoint, | ||
| accessKey: config.secretId, | ||
| secretKey: config.secretKey, | ||
| bucket: config.bucket, | ||
| region: config.region || 'auto', | ||
| }) | ||
| await s3Uploader.deleteObject(key) |
There was a problem hiding this comment.
Route S3 deletes through the endpoint strategy
When narration audio is stored on a virtual-hosted endpoint such as https://<bucket>.s3... or Tencent COS, uploads use S3Uploader.resolveEndpoint() and address the object as /<key>, but this call reaches S3Uploader.deleteObject(), which hard-codes /<bucket>/<key> against the same host. Regenerating or deleting a narration therefore fails to remove its S3 objects (the caller merely logs the failure), leaving every displaced chunk behind; make deletion use the same endpoint strategy as upload.
Useful? React with 👍 / 👎.
| const metaBuilder = new NoteMetaBuilder() | ||
| .view('detail') | ||
| .insights({ hasInLocale: hasInsightsInLocale }) | ||
| .tts(ttsMeta) |
There was a problem hiding this comment.
Hide narration metadata on secret latest notes
For an anonymous GET /notes/latest request whose latest published note has a future publicAt, the controller blanks the note text at line 627 but still publishes ttsMeta here. The public narration endpoint rejects that same secret note, so clients are told narration is available and then receive null when they attempt playback; apply the same secret-note visibility guard used by buildPublicNoteResponse before adding this metadata.
Useful? React with 👍 / 👎.



Adds AI text-to-speech narration for articles, generated per Lexical block so that editing one paragraph regenerates only that paragraph.
Design:
docs/superpowers/specs/2026-08-05-ai-tts-design.md(see its Post-implementation deviations section — it is authoritative where it disagrees with the original prose).Plan:
docs/superpowers/plans/2026-08-05-ai-tts-implementation.md.How it works
An admin enqueues a task. The handler splits the article's Lexical content into speakable root blocks, synthesizes each changed chunk through an OpenAI-compatible
/audio/speechendpoint, uploads each result to a content-addressed object key, and commits oneai_tts_blocksrow per chunk. A parentai_ttsrow per(refId, lang)holds the playback order and the voice config locked at generation time. Readers fetch segments from a public endpoint; article detail responses advertise availability throughmeta.tts.Translated languages are narrated too, from
ai_translations.content, and only when that translation's stored hash is still current — a stale translation is never voiced.Three properties the pipeline is built around
block_orderpublishes only at finalize. A partially generated language is simply not published yet; the previously published order keeps serving.Object keys are content-addressed
{prefix}/tts/{refId}/{lang}/{blockId}-{chunkIndex}-{fingerprint12}.mp3, where the fingerprint covers the chunk text and the model/voice/speed triple. Audio is served with a one-year cache header and sits behind a CDN, so a regenerated chunk must never reuse its old URL. Nothing is ever overwritten in place; re-uploading an identical chunk lands on the same key with the same bytes, which makes retries idempotent.Audio is an ordinary tracked file
TTS audio rides the existing isolated-file system rather than carrying its own sweep:
ai_tts_blocks.urlis registered as a usage source, so an object with a live row reads as used and one without reads as isolated, handled by the existing admin flow. Article deletion still cleans up explicitly, sinceref_idis polymorphic and cascades nothing.Surface
POST /ai/tts/task,GET /ai/tts/ref/:id,GET /ai/tts,DELETE /ai/tts/:id— adminGET /ai/tts/article/:id?lang=&password=— public, entitlement-awaremeta.ttson post and note detail responses, carryingavailable/blockCount/stale@mx-space/api-client:getTts({ articleId, lang })/ai/ttsttsOptionssection (openrouter | openai | custom)Access control
The public endpoint runs two guards before any repository read: article visibility, then a premium/entitlement check. A paying member, the owner, and every reader on a site without membership enabled can all hear narration of a premium article; an unentitled reader gets
null.meta.ttsand the endpoint now share one entitlement predicate so they cannot drift.Incidental security fix, outside the TTS blast radius:
isGlobalArticleVisibletesteddocument.password, butNoteRepositoryprojects that column away in favour ofhasPassword— so the note-password gate never fired on a loaded row. Public summary, insights and translation of a password-protected note were reachable. Fixed for all four AI features; this is a live tightening of three already-shipped features.Verification
lint:migrationsclean;tsc --noEmitclean in both apps; migration chain verified against a live fresh-database applyOne pre-existing failure is unrelated and left alone:
aggregate.controller.e2e-spec.ts's on-this-day test mixes host-local date math with PostgresEXTRACT(UTC), so it fails when run between 00:00–08:00 on a UTC+8 host. Zero diff in the implicated files versus base.Known limitations
imageStorageOptions.prefix, so changing that prefix — or flipping S3 on/off — invalidates every stored key and makes the next incremental run a full paid regeneration. A templated prefix such asblog/{Y}/{m}/{d}would churn daily.speedis arealcolumn interpolated into the object fingerprint. Key equality drives reuse, so a float round-trip difference would silently force full regeneration. Common values round-trip fine; unusual ones are untested.?password=to the narration endpoint, ormeta.tts.available: truestill yieldsnull. Correct for a stateless endpoint, but it is a client contract Shiro/Yohaku need to know about.meta.tts.stalereports the consequence instead.Pre-existing bugs found along the way, not fixed here
Each deserves its own change:
translated.sourceLangbut re-derived asgetMetaLang(document) || translation.sourceLang, so an article withmeta.lang: 'zh-CN'and a detected'zh'reports stale forever (ai-translation.service.ts:606-618,translation-consistency.service.ts:150).0025_snapshot.jsoncarries aconcurrentlyflag drift on two unrelated indexes, so a plaindrizzle-kit generateemits an unsafe bareDROP INDEXondrafts_ref_uniq. This branch's own chain was verified clean; the drift belongs to whoever regenerates next.apps/admin'slintscript is broken in any clean checkout — it runsoxlint, which is not a dependency anywhere. Every lint run on this branch had to fetch it live.packages/api-client'smockResponseresolves with an{ error: 1, … }marker instead of throwing on a URL mismatch, so every.resolves.not.toThrowError()test there is blind to a wrong path.apps/adminruns eslint withreact: false— there is no rules-of-hooks or hooks-deps lint, which is how a stranded-loading-state hook defect reached the final review.Follow-ups worth filing
EntitlementService.isPremiumLockedhas no direct test; the membership-disabled branch is verified only by reading.use-cover-generation.tshas the same in-flight state-reset gap that was fixed here foruse-tts-generation.ts.skipReferenceas live guidance; the option no longer exists.SET NXRedis locks in six places; a shared helper would fix them all.