Skip to content

Fix multi-agent review findings from PR #73#74

Merged
Saqoosha merged 2 commits into
developfrom
fix/premium-tts-review
May 20, 2026
Merged

Fix multi-agent review findings from PR #73#74
Saqoosha merged 2 commits into
developfrom
fix/premium-tts-review

Conversation

@Saqoosha
Copy link
Copy Markdown
Owner

@Saqoosha Saqoosha commented May 20, 2026

Summary

Aggregated Critical/High findings from a /total-review pass (7 agents: pr-review-toolkit code-reviewer × 2, silent-failure-hunter, comment-analyzer, type-design-analyzer, feature-dev code-reviewer, coderabbit, codex) on PR #73. Bundled into one atomic fix commit per project convention.

Worker — Critical

  • appleJws.ts chain anchoring: x5c chain now DER-equality-checked against the embedded Apple Root CA G3, not just fingerprint-matched. Closes the bypass where an attacker-controlled chain with the public Apple Root tacked on top could pass.
  • appleJws.ts validity windows: every cert in the chain is checked for notBefore/notAfter so an expired Apple intermediate can't slip through.
  • index.ts Polly sample-rate header: X-HDZap-SampleRate: 2205016000 (Polly Neural PCM is only ever 16 kHz; the 22050 leftover would have pitch-shifted cache hits 37% if iOS started honouring it).
  • index.ts content-type guard: upstream 200 responses whose Content-Type isn't audio/* (Polly/Azure) or text/event-stream (Cartesia) are downgraded to 502 — protects iOS from playing an Azure maintenance page as PCM.

iOS — Critical

  • PremiumSpeechSynthesizer.speakAsync re-entry: now calls full cancel() first instead of just currentTask.cancel(). Previously the prior utterance's player node, engine, and scheduled buffers all kept running and bled into the head of the new voice mid-preview.
  • PremiumSpeechSynthesizer.cancel: stops the AVAudioEngine + bumps a per-utterance generation counter. Scheduled-buffer completion callbacks now ignore decrements whose captured generation is stale, so a queued callback from a cancelled utterance can't flip isPlaying = false on a fresh one.
  • LapAnnouncer Premium branch: increments inflightUtteranceCount before calling speakAsync and decrements via a new PremiumSpeechSynthesizer.onUtteranceEnd callback. The warm-keeper engine and session-hold lifecycle were silently broken on the Premium branch (counter stuck at 0 forever).

iOS — High

  • SubscriptionManager.refreshEntitlement: picks the strongest entitlement (active > grace, then latest expiry) instead of whichever transaction Transaction.currentEntitlements happened to yield last. Apple can return both monthly + yearly on upgrades.
  • SubscriptionManager: clears currentJWS when a grace-period transaction is >16 days past its expiry. SubscriptionStatus.inGracePeriod carries a non-optional Date (grace implies a known expiry by definition).
  • SubscriptionManager.purchase / .unverified: both now populate lastError so the paywall can surface failures.
  • PremiumSpeechSynthesizer.playPCMFromStream + parseSSE: trim odd trailing byte before writing PCM to TTSCache (would have produced a 0.5-sample click on every cache hit). Cartesia SSE drain with zero chunks but non-zero lines now throws streamFailure so provider schema drift surfaces instead of silently falling back to System voice.

Docs + CI

  • Fix the broken Cartesia privacy link in both EN and JA privacy pages (/privacy returned 404, correct is /legal/privacy.html — App Privacy review smell).
  • Guard each manual-page cp in flasher.yml so a page added on one branch but not the other doesn't break the composite build (we hit this with the JA privacy page after the merge of Premium TTS subscription (AWS Polly, Azure, Cartesia) #73).

Misc

Verification

Test plan

  • Voice picker rapid-tap (audition multiple voices fast) — no audio overlap from the prior utterance
  • Sandbox-tester JWS verification on a TestFlight build with real Apple chain
  • Subscription expire → confirm Premium tap falls back to System voice once grace window passes (no Worker round-trip)
  • Privacy page renders + Cartesia link returns 200

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • 改善されたプレミアム音声合成の再生安定性と複数サブスクリプション権利の処理。
    • Apple署名検証の強化によるセキュリティ向上。
  • Documentation

    • Cartesiaのプライバシーポリシーリンクを更新しました。
  • Chores

    • バージョンを1.1.0にアップデート。

Review Change Stack

Aggregated Critical/High findings from /total-review (7 agents) on the
Premium TTS subscription PR. Bundled into one atomic fix commit.

Worker (workers/hdzap-premium):
- appleJws.ts: anchor the x5c chain to the embedded Apple Root CA G3 by DER
  equality (not just fingerprint match), and check every cert's validity
  window so expired intermediates can't pass. Closes the bypass where an
  attacker-controlled chain with the public Apple Root tacked on top would
  satisfy the previous fingerprint-only check.
- index.ts: drop the wrong "X-HDZap-SampleRate: 22050" header on the Polly
  miss path (Polly Neural PCM is always 16 kHz — the 22050 leftover would
  pitch-shift cache hits 37% if iOS ever started honouring the header).
- index.ts: reject upstream 200 responses whose Content-Type isn't audio/*
  (Polly/Azure) or text/event-stream (Cartesia). Without this an Azure
  maintenance page or Cartesia plaintext quota notice would be served to
  iOS as PCM and play as static.
- Drop stale "backwards compat with iOS build that doesn't yet send
  provider" comment + clarify the deferred `userId` binding.

iOS Speech (app/HDZap/Models/Speech, app/HDZap/Models):
- PremiumSpeechSynthesizer: speakAsync() now tears down audio via cancel()
  before spawning a new task. Previously only Task.cancel() ran, which left
  the player node + AVAudioEngine running and the prior utterance's tail
  bled into the new voice's head.
- PremiumSpeechSynthesizer: cancel() now stops the engine and bumps a
  per-utterance generation counter; scheduled-buffer completion callbacks
  ignore decrements whose captured generation no longer matches, so a
  stale callback from a superseded utterance can't flip isPlaying false
  on a fresh one. cancel() also resets accumulatedPCM so a previous
  partial stream can't bleed into the next entry.
- PremiumSpeechSynthesizer: new onUtteranceEnd callback fires exactly once
  per utterance end (drain / cancel / error). LapAnnouncer wires it to
  utteranceDidEnd() so Premium playback now participates in the same
  inflight-utterance bookkeeping the System path always had — the warm-
  keeper engine and session-hold lifecycle were silently broken on the
  Premium branch (inflightUtteranceCount stayed at 0 forever).
- PremiumSpeechSynthesizer: trim odd trailing byte before writing PCM to
  TTSCache (the cached file would otherwise have a 0.5-sample garbage
  tail that played back as a click on every cache hit).
- PremiumSpeechSynthesizer: throw streamFailure when Cartesia SSE drains
  with zero audio chunks despite non-zero lines — provider schema drift
  would otherwise silently fall back to System voice with no signal.
- LapAnnouncer: increment inflightUtteranceCount on the Premium branch
  and configure the shared audio session up front; Premium now matches
  the System path's lifecycle.

iOS IAP (app/HDZap/Models/IAP):
- SubscriptionManager: pick the strongest entitlement (active > grace, then
  latest expiry) instead of whichever transaction the loop visited last.
  Apple can hand back multiple entitlements on monthly→yearly upgrades.
- SubscriptionManager: clear currentJWS once a grace-period transaction
  is more than 16 days past its expiry so we stop pinging the Worker
  with a token it'll reject. SubscriptionStatus.inGracePeriod now carries
  a non-optional Date (grace by definition implies a known expiry).
- SubscriptionManager: purchase() populates lastError before re-throwing
  so the paywall can surface the error without callers plumbing it.
- SubscriptionManager: .unverified branch sets lastError so a tampered
  transaction shows a banner instead of leaving the operator wondering
  why Subscribe did nothing.

iOS Views (app/HDZap/Views/Settings):
- PaywallView: drop the misleading comment that claimed
  SubscriptionManager.lastError was set inside purchase() (it now is).
- AudioSettingsView: drop stale "before that wiring lands" reference.

Privacy + CI (docs, .github):
- Fix the broken Cartesia privacy link in both EN and JA pages
  (https://cartesia.ai/privacy → /legal/privacy.html — the old URL returns
  404, which is an App Privacy review smell).
- Guard each manual-page cp in flasher.yml so a page added on one branch
  but not the other doesn't break the composite build.

Misc:
- Sync app/HDZap.xcodeproj/project.pbxproj to the 1.1.0/12 versions in
  project.yml (xcodegen drift after PR #73 merged).
- Drop iOS 26 typo + 2026-05-19 datestamp + Phase 2 SpeechRouter
  references that all rotted after PR #73 landed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 20, 2026

Warning

Rate limit exceeded

@Saqoosha has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 43 minutes and 5 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4fa3e393-bc11-41d6-a2e5-6d2388e9d630

📥 Commits

Reviewing files that changed from the base of the PR and between cd3d185 and f8fa0d7.

📒 Files selected for processing (7)
  • app/HDZap/Models/IAP/SubscriptionManager.swift
  • app/HDZap/Models/LapAnnouncer.swift
  • app/HDZap/Models/Speech/PremiumSpeechSynthesizer.swift
  • app/HDZap/Models/Speech/TTSCache.swift
  • app/HDZap/Views/Settings/PaywallView.swift
  • workers/hdzap-premium/src/appleJws.ts
  • workers/hdzap-premium/src/index.ts

Walkthrough

クライアント側の複数権利ランク付けロジック、Worker側の証明書検証強化、PremiumSpeechSynthesizerの世代管理再設計、およびLapAnnouncerとの統合により、JWS不整合排除、無効トークン送信回避、再生ライフサイクル不整合防止が実装されます。ビルド版更新とドキュメント統一により一貫性を確保。

Changes

JWS認証と権利選択ロジック

Layer / File(s) Summary
SubscriptionStatus契約とエラー伝播
app/HDZap/Models/IAP/SubscriptionManager.swift
inGracePeriodexpiresDate?Date に変更し、エラーを lastError に記録してから再スロー/設定するエラー伝播パターンを確立。
複数権利のランク付けと選択
app/HDZap/Models/IAP/SubscriptionManager.swift
refreshEntitlement() が複数の有効な権利を「アクティブ(期限あり/なし)」「猶予」でランク付けし、期限の遠い方を優先選択。猶予切れのみの場合は status を .none、JWS を nil にして無効トークン送信を防止。
Worker側 Apple JWS検証強化
workers/hdzap-premium/src/appleJws.ts
証明書の有効期間チェック(validFrom/validTo)を追加。トップ証明書を埋め込み Apple Root CA G3 と DER バイト同一性で検証し、フィンガープリント一致だけの検証では不足というリスク軽減。
Content-Type検証とPollyサンプルレート
workers/hdzap-premium/src/index.ts
アップストリーム Content-Type を provider ごとに検証(cartesia は text/event-stream、他は audio/ プレフィックス)し、不正レスポンスを 502 でダウングレード。Polly のサンプルレート 2205016000 に統一。

PremiumSpeechSynthesizer ライフサイクル管理

Layer / File(s) Summary
世代管理とonUtteranceEndコールバック基盤
app/HDZap/Models/Speech/PremiumSpeechSynthesizer.swift
currentGenerationonUtteranceEnd コールバックを導入し、キャンセル時に generation を増分して古い完了イベントを無効化。notifyEnd() に終了処理を集約し、utterance 単位で一度だけ発火。
cancel()と終了処理の統一
app/HDZap/Models/Speech/PremiumSpeechSynthesizer.swift
cancel() は generation 増分・エンジン停止・バッファクリアを実行。speakAsync エラー時も cancel() に統一し、キャッシュ再生・ストリーミング・バッファ完了のすべてが notifyEnd() 経由で終了を報告。
ストリーミングとPCMキャッシュ処理
app/HDZap/Models/Speech/PremiumSpeechSynthesizer.swift
PCM 保存をバイト境界(s16le フレーム境界)に丸めて奇数バイトを切り捨て。Cartesia SSE パースで行数あり・デコード 0 チャンクの場合を例外にしてキャッシュ誤記を防止。

LapAnnouncerおよびドキュメント・設定更新

Layer / File(s) Summary
LapAnnouncerでのPremium TTS統合
app/HDZap/Models/LapAnnouncer.swift
premiumSynth.onUtteranceEndutteranceDidEnd() に接続し、inflight カウンタ減算と warm-keeper 停止の共通フロー統合。speak() の Premium 分岐で inflight を即時インクリメント。
ビルド版と関連ドキュメント更新
app/HDZap.xcodeproj/project.pbxproj, app/HDZap/Models/Speech/BuildSecrets.swift.tpl, app/HDZap/Models/Speech/PremiumSpeechSynthesizer.swift, app/HDZap/Models/Speech/TTSCache.swift, app/HDZap/Views/Settings/AudioSettingsView.swift, app/HDZap/Views/Settings/PaywallView.swift
プロジェクトバージョン 1112、マーケティング版 1.0.11.1.0。Worker 認証ティア、JWS 合成条件、PCM キャッシュ形式、エラー伝播意図を明確化するコメント更新。
プライバシーポリシーURLとdeployment修正
.github/workflows/flasher.yml, docs/manual/_pages/privacy/index.html, docs/manual/_pages/privacy/ja/index.html
Cartesia プライバシーポリシー URL を cartesia.ai/privacycartesia.ai/legal/privacy.html へ更新。flasher.yml の privacy ページ複製を条件付き(存在チェック)に変更。

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • Saqoosha/HDZap#67: LapAnnouncer.swiftutteranceDidEnd()/inflight カウンタ/warm-keeper フロー強化に直結し、本 PR の Premium TTS 統合基盤として機能。
  • Saqoosha/HDZap#73: flasher.yml の privacy ページ複製・存在チェック条件に関する共通修正。

Poem

🐰 ジェネレーション数えて、古い声捨てよう
権利ランク付け、正しい選択をえらぶ
Worker検証、DERバイト比べて
キャッシュ偶数境界、フレーム揃えて
Premium TTS、ついに調和した✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 78.95% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PRタイトル「Fix multi-agent review findings from PR #73」は、PR #73の多エージェントレビュー結果を修正するものであり、変更内容(Worker、iOS、ドキュメント、CI の複数領域にわたる修正)と一致しています。
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/premium-tts-review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/HDZap/Models/IAP/SubscriptionManager.swift (1)

107-137: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

lastError を購入開始時にクリアしてください。

このメソッドで lastError を保持するようになった一方、次の購入開始時や .success / .userCancelled / .pending では消していないので、直前の失敗メッセージが paywall に残り続けます。エラー伝播を UI に出す契約なら、非エラー終了後に古いエラーが見え続ける状態は避けたいです。

💡 最小修正案
 func purchase(_ product: Product) async throws -> Bool {
+    lastError = nil
     purchasing = product.id
     defer { purchasing = nil }
     let result: Product.PurchaseResult
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/HDZap/Models/IAP/SubscriptionManager.swift` around lines 107 - 137, Clear
stale error state by resetting lastError at the start of purchase(_ product:
Product) and ensure it is cleared on non-error completion paths: set lastError =
nil before returning in the .success (after await handle(verificationResult:) /
await refreshEntitlement()), .userCancelled, and .pending branches so the
previous failure message does not remain visible; keep existing logging and
error handling for the catch block that populates lastError on actual failures.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/HDZap/Models/LapAnnouncer.swift`:
- Around line 332-338: The premiumSynth.onUtteranceEnd closure currently only
calls utteranceDidEnd(), causing AVAudioSession to remain active; update the
closure (premiumSynth.onUtteranceEnd) to mirror the System synth path by calling
utteranceDidEnd() and then invoking deactivateSession() when appropriate (e.g.,
only if self?.sessionHoldActive == false) so the shared AVAudioSession is
released after Premium playback finishes.

In `@app/HDZap/Models/Speech/PremiumSpeechSynthesizer.swift`:
- Around line 282-290: The notifyEnd() early-return on guard isPlaying causes
missing end notifications when an utterance was accepted but never started
(cancel/error/overwrite before playback), leaking LapAnnouncer's inflight count;
add a separate boolean (e.g. utteranceAccepted or hasPendingUtterance) tracked
when the utterance is accepted (where speakAsync/cancel paths mark it) and
change notifyEnd() to check and clear that flag (and still clear isPlaying if
set) so onUtteranceEnd() is invoked exactly once per accepted utterance; update
speakAsync, cancel, and any error/overwrite handlers to set/clear this new flag
consistently.

In `@app/HDZap/Models/Speech/TTSCache.swift`:
- Around line 13-17: Update the conflicting comment section so the capacity
estimate matches the PCM caching policy described in the header ("Every cached
payload is raw s16le mono PCM..."). Replace the mp3-based size assumptions in
the later comment string that mentions "Polly/Azure mp3" with PCM math using
each provider's native sample rate (Polly 16 kHz, Cartesia/Azure 24 kHz), mono
channel count, and 2 bytes per sample (s16le): bytes ≈ sampleRate *
duration_seconds * 1 * 2. Remove any references to mp3 bitrate and ensure the
explanatory text (the comment that currently assumes mp3 sizes) clearly states
the PCM calculation and example numbers for common utterance lengths.

In `@workers/hdzap-premium/src/index.ts`:
- Around line 182-185: Remove the silent fallback to "cartesia" so missing
provider requests don't proceed upstream: stop using (body.provider ||
"cartesia") in the assignment to provider in index.ts and instead validate
body.provider exists first; if absent, return the proper 400 error (with an
explicit message naming the missing field) rather than normalizing and
continuing, and when present use provider = body.provider.trim().toLowerCase();
ensure this change affects the same logic that interacts with
PremiumSpeechSynthesizer/upstream so no request without an explicit provider is
forwarded.
- Around line 280-292: The current Content-Type check inspects the wrapped
Response (`upstream`) set by proxyCartesia/proxyPolly/proxyAzure and so misses
bad upstream bodies; instead, inside each proxy function (proxyCartesia,
proxyPolly, proxyAzure) validate the raw provider response via
resp.headers.get("Content-Type") before you rewrap it: compute expectedPrefix
(audio/ or text/event-stream for cartesia) and if resp.headers.get(...) doesn't
startWith that prefix, log the provider and raw content-type and return the
502/json error immediately (same shape currently returned), so you never wrap
and cache a non-audio body as audio. Ensure the existing upstream check is
removed or kept as a redundant safeguard only after proxies perform the real
validation.

---

Outside diff comments:
In `@app/HDZap/Models/IAP/SubscriptionManager.swift`:
- Around line 107-137: Clear stale error state by resetting lastError at the
start of purchase(_ product: Product) and ensure it is cleared on non-error
completion paths: set lastError = nil before returning in the .success (after
await handle(verificationResult:) / await refreshEntitlement()), .userCancelled,
and .pending branches so the previous failure message does not remain visible;
keep existing logging and error handling for the catch block that populates
lastError on actual failures.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 615f452b-ce98-4a61-b6a0-f2e47752b0db

📥 Commits

Reviewing files that changed from the base of the PR and between 1b42073 and cd3d185.

📒 Files selected for processing (13)
  • .github/workflows/flasher.yml
  • app/HDZap.xcodeproj/project.pbxproj
  • app/HDZap/Models/IAP/SubscriptionManager.swift
  • app/HDZap/Models/LapAnnouncer.swift
  • app/HDZap/Models/Speech/BuildSecrets.swift.tpl
  • app/HDZap/Models/Speech/PremiumSpeechSynthesizer.swift
  • app/HDZap/Models/Speech/TTSCache.swift
  • app/HDZap/Views/Settings/AudioSettingsView.swift
  • app/HDZap/Views/Settings/PaywallView.swift
  • docs/manual/_pages/privacy/index.html
  • docs/manual/_pages/privacy/ja/index.html
  • workers/hdzap-premium/src/appleJws.ts
  • workers/hdzap-premium/src/index.ts

Comment thread app/HDZap/Models/LapAnnouncer.swift Outdated
Comment thread app/HDZap/Models/Speech/PremiumSpeechSynthesizer.swift Outdated
Comment thread app/HDZap/Models/Speech/TTSCache.swift
Comment thread workers/hdzap-premium/src/index.ts Outdated
Comment thread workers/hdzap-premium/src/index.ts Outdated
Critical regressions from the previous fix commit:

- Premium speakAsync re-entry races: the catch handler called `self.cancel()`
  unconditionally, which would tear down the NEW currentTask if a fresh
  speakAsync replaced the cancelled one before its catch ran. Catch now
  captures the generation token at speakAsync entry and bails when the
  generation has been bumped (i.e., we've been superseded).

- Inflight counter leak: `notifyEnd`'s `guard isPlaying` swallowed the end
  notification when `speak()` threw BEFORE setting `isPlaying = true`
  (`bearer.isEmpty`, bad URL, `configureSession()` failure). The companion
  fix moves `isPlaying = true` to `speakAsync` BEFORE spawning the Task so
  any early error still produces a clean end notification, and notifyEnd
  no longer guards on isPlaying — it fires pendingOnEnd at most once per
  speakAsync invocation regardless of whether playback actually started.

- Picker / paywall preview counter underflow: PaywallView and
  PremiumVoicePickerView call `announcer.premiumSynth.speakAsync(...)`
  directly, bypassing `LapAnnouncer.speak`'s inflight increment. The
  global `onUtteranceEnd` callback that fix-PR-#73 wired into LapAnnouncer
  would have fired `utteranceDidEnd()` on every preview end, driving the
  counter negative (debug crash via underflow assertion; release lifecycle
  corruption). Replaced the global callback with a per-call `onEnd:`
  parameter on speakAsync — LapAnnouncer.speak passes a decrement closure,
  preview surfaces don't, so previews neither increment nor decrement.

- Premium end now also calls `deactivateSession()` symmetric with the
  System path's `didFinish` / `didCancel` delegate — PR-#73's wiring only
  decremented the counter, leaving the audio session active until the
  next System utterance happened to deactivate it.

Worker:

- `provider` defaulting to `"cartesia"` on missing field forwarded
  malformed requests to a real (billable) upstream call. Reject with
  `400 missing-provider` instead.

- Content-type guard moved INSIDE each proxy function so it checks the
  actual provider response, not Worker-wrapped headers. The previous
  placement read our own normalized Content-Type and was a no-op against
  the original concern (Azure maintenance HTML, Cartesia plaintext quota
  notice). Polly accepts `audio/*` or `application/*; ...pcm...` because
  AWS has tweaked the type string in the past.

- Cert-chain DER comparison uses `Buffer.from(...).equals(...)` so it
  works whether `X509Certificate.raw` is a Buffer or an ArrayBuffer
  (Workers `nodejs_compat` has varied between releases). Indexing a raw
  ArrayBuffer with `[i]` returns undefined and would have made every
  Premium JWS reject.

Misc:

- TTSCache capacity-estimate comment matched the new raw-PCM layout
  (32-48 KB/sec depending on sample rate) instead of the old mp3 sizing.

- Comment polish per the comment-analyzer (drop dead `_ = error`,
  trim "previous behavior" historical reference in refreshEntitlement,
  tighten verifyChain doc parenthetical, remove `void userId` dead
  binding — userId is genuinely used in the auth log line above).

- `cancel()` doc calls out the `&+=` overflow-wrap choice explicitly so
  a future reader doesn't read it as a hand-rolled bug.

Verification: `xcodebuild build -configuration Debug` succeeds locally.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@Saqoosha Saqoosha merged commit 4903763 into develop May 20, 2026
3 checks passed
@Saqoosha Saqoosha deleted the fix/premium-tts-review branch May 20, 2026 16:19
Saqoosha pushed a commit that referenced this pull request May 21, 2026
Resolves conflicts against develop's StoreKit2 paywall (PR #73 + #74)
on top of the maintainer's review-feedback follow-ups for PR #60.

Subscription gate:
- TimerView.publishWatchSnapshot sends
  hapticsEnabled = (watchHapticsEnabled && subscription.isEntitled),
  so the watch only acts on what the iPhone says. Single gate — no
  parallel check on the wrist.
- PublishWatchSnapshotModifier picks up subscription.isEntitled as a
  tracked input, and the .onChange handler clears
  watchHapticsEnabled when entitlement lapses (mirrors the TTS-engine
  rollback in AudioSettingsView).
- WatchSettingsView replaces the toggle (and the Try-haptics audition
  section) with a "Subscribe to enable" paywall card when not
  entitled. PaywallView presented via .sheet.
- SettingsView's row summary shows "Premium" for non-subscribers.

Conflict resolution notes:
- project.pbxproj: took ours (xcodegen will regenerate from
  project.yml after merge).
- Localizable.xcstrings: took theirs to keep develop's full Premium
  TTS translation catalog. Watch-specific keys regenerate on next
  build.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant