feat(relay): activate Cloud Relay — server setup, yearly plan, verified subscriptions (v1.5.0)#23
Conversation
…t status, yearly plan, in-context upsell The biggest gap was that buyers never learned Cloud Relay needs a one-time server-side helper (vaultsync-notify), so most subscriptions delivered nothing. This makes the requirement explicit and improves conversion: - Setup checklist no longer reports Cloud Relay "ready" on subscription alone; it reflects real delivery (waiting for server → finish setup → active once a trigger is actually received). - New RelayServerSetupView: explains the helper and gives a copyable docker command (relay URL pre-filled), shown right after purchase and from Settings. - Settings → Cloud Relay shows a real delivery-status row (delivering / waiting for your server / last wake-up) and an honest footer. - Pricing: raise monthly to a higher reference price and add a yearly plan (eu.vaultsync.app.relay.yearly); SubscriptionManager loads both products and the UI offers both. No free trial. - In-context upsell (CloudRelayUpsellView) at the first-successful-sync "aha moment" plus a persistent dashboard upgrade affordance for non-subscribers; after purchase it flows straight into server setup. - Provisioning now sends the StoreKit signed transaction (JWS) so the relay can verify the subscription with Apple and read its expiry. All localized in en/de/es/zh. Build + tests green (23 tests, 0 failures).
… Store Connect steps - relay-spec.md: provisioning now sends the signed transaction (JWS), verified offline against Apple's certificate chain; verified expiry gates the subscription server-side; legacy numeric IDs still accepted. Multi-device (iPhone + iPad) per Device ID works; invalid tokens self-heal via APNs. Pricing reference updated (monthly + yearly, no trial). - PRIVACY.md: transaction data is verified against Apple's certificate chain; dropped the unimplemented 90-day purge claim in favour of the actual invalid-token cleanup behaviour. - docs/app-store-connect-relay.md: manual App Store Connect steps for the price raise and the new yearly product, plus 3.1.2(a) framing for the setup step.
- ios/project.yml: 1.4.0 → 1.5.0 (build 25 → 26), app + widget targets - CHANGELOG.md: 1.5.0 section (guided server setup, yearly plan, in-context upsell, honest delivery status, verified subscriptions + expiry, iPhone/iPad multi-device wake-ups) - README.md: What's New → v1.5.0, monthly-or-yearly pricing, requirements table
|
Warning Review limit reached
More reviews will be available in 33 seconds. Learn how PR review limits work. Your organization has run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the 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 include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (13)
📝 WalkthroughWalkthroughThis PR implements Cloud Relay v1.5.0, adding yearly subscription support alongside the existing monthly plan, switching from numeric StoreKit transaction IDs to offline-verifiable JWS for provisioning, and introducing guided UI flows for subscription and server setup. ChangesCloud Relay v1.5.0 Implementation
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 7✅ Passed checks (7 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
ios/VaultSync/Services/SubscriptionManager.swift (1)
321-347:⚠️ Potential issue | 🔴 Critical | ⚡ Quick winHandle yearly entitlements in the transaction pipeline.
handle(_:)still checkstransaction.productID == Self.relayProductID, which is the monthly SKU only. A verified yearly purchase or renewal now falls through, getsfinish()ed, and never marks the user subscribed or provisions the relay for the purchased devices.Suggested fix
- if transaction.productID == Self.relayProductID { + if Self.relayProductIDs.contains(transaction.productID) { if let revocationDate = transaction.revocationDate { logger.info("Subscription revoked on \(revocationDate)") isRelaySubscribed = false subscriptionExpiryDate = nil await deprovisionRelay()🤖 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 `@ios/VaultSync/Services/SubscriptionManager.swift` around lines 321 - 347, The code only matches Self.relayProductID (monthly SKU) in handle(_:) so yearly purchases fall through; update the product check to accept both monthly and yearly SKUs (e.g. add a Self.relayYearlyProductID or a helper like isRelayProduct(productID:)) and use that in the existing branch where it sets isRelaySubscribed, subscriptionExpiryDate, stores/loads device IDs (storeDeviceIDs(_:), loadStoredDeviceIDs()), calls ensureProvisionStateEntries(for:), and invokes provisionRelay(deviceIDs:transactionID:) with verificationResult.jwsRepresentation; keep the revocation/expiration handling and deprovisionRelay() path intact for non-matching/expired cases so yearly transactions are processed the same as monthly ones.
🧹 Nitpick comments (1)
ios/VaultSync/Views/RelayServerSetupView.swift (1)
19-27: ⚡ Quick winPin the helper image to a tested tag.
This setup command pulls
vaultsync-notify:latest, so the onboarding flow can drift away from the app/release contract without any app update. Please use a documented version tag or a shared constant for the tested helper release instead.🤖 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 `@ios/VaultSync/Views/RelayServerSetupView.swift` around lines 19 - 27, The dockerCommand currently hardcodes the helper image with :latest which can drift; change it to use a pinned, documented tag by replacing "ghcr.io/psimaker/vaultsync-notify:latest" with a shared constant (e.g., HelperImage.version or RelayService.helperImageTag) and reference that constant in the dockerCommand string interpolation (keep RelayService.relayURL as-is); ensure the constant is defined near related networking/relay code and documented so the onboarding flow uses a tested release rather than latest.
🤖 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 `@docs/relay-spec.md`:
- Around line 223-224: The pricing bullet is inconsistent with the /provision
API contract: remove the line that says "App provisions the relay after
successful purchase using the StoreKit transaction ID" and replace it with a
statement that provisioning uses the signed StoreKit transaction JWS per the
/provision endpoint (i.e., the app submits the signed JWS to /provision rather
than a raw transaction ID), ensuring the pricing summary references the
JWS-based provisioning flow described elsewhere in the spec.
In `@ios/VaultSync/Views/SettingsView.swift`:
- Around line 318-352: The subscribeButton(for:label:accent:) currently allows
purchases when syncthingManager.devices.isEmpty so
subscriptionManager.purchase(product, homeserverDeviceIDs:) can be called with
an empty array; update the button to block purchasing until at least one
homeserver device exists by checking syncthingManager.devices.isEmpty before
starting the Task (or adding .disabled(...) to the Button) and, when empty, set
alertMessage to a user-visible prompt instructing the user to add a homeserver
device and set showAlert = true instead of calling subscriptionManager.purchase;
reference subscribeButton(for:label:accent:), syncthingManager.devices, and
subscriptionManager.purchase(...) when making this change.
In `@PRIVACY.md`:
- Line 17: The GDPR deletion sentence currently only promises deletion of the
"original transaction ID" but the document's data list (`**StoreKit transaction
information**`) now declares both the original transaction ID and the
subscription expiry; update the GDPR deletion sentence to reference deletion of
the full stored transaction records (e.g., "original transaction ID and
subscription expiry" or "all stored transaction records") so the deletion scope
matches the declared StoreKit data; locate the sentence that mentions deletion
of the transaction ID and broaden its wording to cover both fields and any
related transaction metadata.
---
Outside diff comments:
In `@ios/VaultSync/Services/SubscriptionManager.swift`:
- Around line 321-347: The code only matches Self.relayProductID (monthly SKU)
in handle(_:) so yearly purchases fall through; update the product check to
accept both monthly and yearly SKUs (e.g. add a Self.relayYearlyProductID or a
helper like isRelayProduct(productID:)) and use that in the existing branch
where it sets isRelaySubscribed, subscriptionExpiryDate, stores/loads device IDs
(storeDeviceIDs(_:), loadStoredDeviceIDs()), calls
ensureProvisionStateEntries(for:), and invokes
provisionRelay(deviceIDs:transactionID:) with
verificationResult.jwsRepresentation; keep the revocation/expiration handling
and deprovisionRelay() path intact for non-matching/expired cases so yearly
transactions are processed the same as monthly ones.
---
Nitpick comments:
In `@ios/VaultSync/Views/RelayServerSetupView.swift`:
- Around line 19-27: The dockerCommand currently hardcodes the helper image with
:latest which can drift; change it to use a pinned, documented tag by replacing
"ghcr.io/psimaker/vaultsync-notify:latest" with a shared constant (e.g.,
HelperImage.version or RelayService.helperImageTag) and reference that constant
in the dockerCommand string interpolation (keep RelayService.relayURL as-is);
ensure the constant is defined near related networking/relay code and documented
so the onboarding flow uses a tested release rather than latest.
🪄 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: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 7e7e7c19-a51f-4fad-8bcf-9ef6ce9d13f9
📒 Files selected for processing (19)
CHANGELOG.mdPRIVACY.mdREADME.mddocs/app-store-connect-relay.mddocs/relay-spec.mdios/VaultSync.storekitios/VaultSync/Services/RelayService.swiftios/VaultSync/Services/SubscriptionManager.swiftios/VaultSync/ViewModels/SetupChecklistViewModel.swiftios/VaultSync/Views/CloudRelayUpsellView.swiftios/VaultSync/Views/ContentView.swiftios/VaultSync/Views/ExternalLinkButton.swiftios/VaultSync/Views/RelayServerSetupView.swiftios/VaultSync/Views/SettingsView.swiftios/VaultSync/de.lproj/Localizable.stringsios/VaultSync/en.lproj/Localizable.stringsios/VaultSync/es.lproj/Localizable.stringsios/VaultSync/zh-Hans.lproj/Localizable.stringsios/project.yml
📜 Review details
⏰ Context from checks skipped due to timeout of 900000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Build & Test
🧰 Additional context used
📓 Path-based instructions (5)
**/*.swift
📄 CodeRabbit inference engine (Custom checks)
For Swift background execution changes, pass if work is bounded, cancellation-aware, handles expiration callbacks, and records errors without leaking private vault data. Fail only when background work can continue unbounded, miss cleanup, or violate iOS background execution constraints.
Files:
ios/VaultSync/Services/RelayService.swiftios/VaultSync/Views/ExternalLinkButton.swiftios/VaultSync/ViewModels/SetupChecklistViewModel.swiftios/VaultSync/Views/RelayServerSetupView.swiftios/VaultSync/Views/CloudRelayUpsellView.swiftios/VaultSync/Views/ContentView.swiftios/VaultSync/Services/SubscriptionManager.swiftios/VaultSync/Views/SettingsView.swift
ios/**/*.swift
📄 CodeRabbit inference engine (README.md)
Follow Swift API Design Guidelines for iOS/Swift code
Use Swift strict concurrency where applicable in iOS/Swift code
Files:
ios/VaultSync/Services/RelayService.swiftios/VaultSync/Views/ExternalLinkButton.swiftios/VaultSync/ViewModels/SetupChecklistViewModel.swiftios/VaultSync/Views/RelayServerSetupView.swiftios/VaultSync/Views/CloudRelayUpsellView.swiftios/VaultSync/Views/ContentView.swiftios/VaultSync/Services/SubscriptionManager.swiftios/VaultSync/Views/SettingsView.swift
⚙️ CodeRabbit configuration file
ios/**/*.swift: Focus on Swift 6 strict concurrency, Sendable/MainActor correctness, Task cancellation,
retain cycles, memory pressure, SwiftUI observation state, StoreKit/APNs flows, and iOS background execution limits.
Pay special attention to BGAppRefreshTask and BGContinuedProcessingTask behavior, expiration handling,
bounded work, and cleanup when the app is suspended or terminated.
Files:
ios/VaultSync/Services/RelayService.swiftios/VaultSync/Views/ExternalLinkButton.swiftios/VaultSync/ViewModels/SetupChecklistViewModel.swiftios/VaultSync/Views/RelayServerSetupView.swiftios/VaultSync/Views/CloudRelayUpsellView.swiftios/VaultSync/Views/ContentView.swiftios/VaultSync/Services/SubscriptionManager.swiftios/VaultSync/Views/SettingsView.swift
**/*
⚙️ CodeRabbit configuration file
**/*: VaultSync syncs private Obsidian notes through Syncthing. Treat data loss,
privacy leaks, security regressions, and broken sync behavior as high priority.
Do not nitpick formatting unless it affects maintainability, correctness, or public API clarity.
Flag any accidental logging, telemetry, crash reporting, or network transfer of note contents,
vault paths, filenames with private context, API keys, APNs tokens, relay keys, or security-scoped bookmark data.
Files:
ios/VaultSync/Services/RelayService.swiftios/VaultSync/Views/ExternalLinkButton.swiftios/VaultSync/ViewModels/SetupChecklistViewModel.swiftios/project.ymlCHANGELOG.mdREADME.mdPRIVACY.mdios/VaultSync/zh-Hans.lproj/Localizable.stringsios/VaultSync/en.lproj/Localizable.stringsdocs/app-store-connect-relay.mdios/VaultSync/de.lproj/Localizable.stringsios/VaultSync/Views/RelayServerSetupView.swiftios/VaultSync.storekitdocs/relay-spec.mdios/VaultSync/Views/CloudRelayUpsellView.swiftios/VaultSync/es.lproj/Localizable.stringsios/VaultSync/Views/ContentView.swiftios/VaultSync/Services/SubscriptionManager.swiftios/VaultSync/Views/SettingsView.swift
ios/project.yml
⚙️ CodeRabbit configuration file
ios/project.yml: This generates the Xcode project and Info.plist. Review changes for bundle ID,
entitlements, background modes, URL schemes, signing settings, and accidental secret exposure.
Files:
ios/project.yml
**/*.md
⚙️ CodeRabbit configuration file
**/*.md: Review public documentation for technical accuracy, privacy/security claims, App Store-facing wording,
setup correctness, and consistency with the free app plus optional Cloud Relay subscription model.
Files:
CHANGELOG.mdREADME.mdPRIVACY.mddocs/app-store-connect-relay.mddocs/relay-spec.md
🧠 Learnings (1)
📓 Common learnings
Learnt from: CR
Repo: psimaker/vaultsync
Timestamp: 2026-05-31T12:51:59.545Z
Learning: Use Conventional Commits for commit messages
Learnt from: CR
Repo: psimaker/vaultsync
Timestamp: 2026-05-31T12:51:59.545Z
Learning: Provide clear PR descriptions in pull requests
Learnt from: CR
Repo: psimaker/vaultsync
Timestamp: 2026-05-31T12:51:59.545Z
Learning: Reference docs/setup.md and docs/architecture.md when starting development
Learnt from: CR
Repo: psimaker/vaultsync
Timestamp: 2026-05-31T12:51:59.545Z
Learning: Use Go 1.26+ for building the Go xcframework
Learnt from: CR
Repo: psimaker/vaultsync
Timestamp: 2026-05-31T12:51:59.545Z
Learning: Require Xcode 26+ and XcodeGen for building the iOS app from source
Learnt from: CR
Repo: psimaker/vaultsync
Timestamp: 2026-05-31T12:51:59.545Z
Learning: Include iOS/iPadOS version, VaultSync version, server Syncthing version, Cloud Relay/vaultsync-notify status, and relevant logs or screenshots when reporting bugs
Learnt from: CR
Repo: psimaker/vaultsync
Timestamp: 2026-05-31T12:52:07.473Z
Learning: The Cloud Relay server-side helper setup must be framed as part of the product usage (the 'Set Up Your Server' step shown immediately after purchase) rather than as a gate to unlock paid content, to comply with App Store Guideline 3.1.2(a).
Learnt from: CR
Repo: psimaker/vaultsync
Timestamp: 2026-05-31T12:52:07.473Z
Learning: No free trial or introductory offer is configured for Cloud Relay subscriptions (product decision 2026-05-31).
Learnt from: CR
Repo: psimaker/vaultsync
Timestamp: 2026-05-31T12:52:07.473Z
Learning: When raising the monthly Cloud Relay subscription price, preserve the current price for existing subscribers to avoid churn and consent friction.
Learnt from: CR
Repo: psimaker/vaultsync
Timestamp: 2026-05-31T12:52:32.656Z
Learning: Identity is based on Syncthing Device IDs with no user accounts or API keys required
Learnt from: CR
Repo: psimaker/vaultsync
Timestamp: 2026-05-31T12:52:32.656Z
Learning: No file content is ever transmitted from homeserver to relay; only the Syncthing Device ID is sent
Learnt from: CR
Repo: psimaker/vaultsync
Timestamp: 2026-05-31T12:52:32.656Z
Learning: The central relay has no knowledge of what changed or which files/folders are affected
Learnt from: CR
Repo: psimaker/vaultsync
Timestamp: 2026-05-31T12:52:32.656Z
Learning: APNs payload must be a silent push with no visible content to the user
Learnt from: CR
Repo: psimaker/vaultsync
Timestamp: 2026-05-31T12:52:32.656Z
Learning: The homeserver container is stateless except for configuration
Learnt from: CR
Repo: psimaker/vaultsync
Timestamp: 2026-05-31T12:52:32.656Z
Learning: The central relay is horizontally scalable with stateless request handling and shared database
Learnt from: CR
Repo: psimaker/vaultsync
Timestamp: 2026-05-31T12:52:32.656Z
Learning: Self-hosted relay variant requires users to manage their own Apple Developer Account and APNs credentials
Learnt from: CR
Repo: psimaker/vaultsync
Timestamp: 2026-05-31T12:52:32.656Z
Learning: Cloud Relay subscription is managed via App Store StoreKit 2 with auto-renewable monthly or yearly options and no free trial
🪛 LanguageTool
docs/app-store-connect-relay.md
[style] ~12-~12: Consider using a more formal adjective.
Context: ...ibers pay the new price. ## 2. Add the yearly plan 1. Same subscription group **Clou...
(YEARLY_ANNUAL)
🔇 Additional comments (12)
CHANGELOG.md (1)
7-24: LGTM!README.md (1)
43-43: LGTM!Also applies to: 63-66, 182-182
docs/app-store-connect-relay.md (1)
1-28: LGTM!ios/VaultSync/en.lproj/Localizable.strings (1)
356-356: LGTM!Also applies to: 534-574
ios/VaultSync/de.lproj/Localizable.strings (1)
356-356: LGTM!Also applies to: 534-574
ios/VaultSync/es.lproj/Localizable.strings (1)
356-356: LGTM!Also applies to: 534-574
ios/VaultSync/zh-Hans.lproj/Localizable.strings (1)
356-356: LGTM!Also applies to: 534-574
ios/project.yml (1)
62-63: LGTM!Also applies to: 115-116
ios/VaultSync/ViewModels/SetupChecklistViewModel.swift (1)
189-223: ⚡ Quick winVerify
lastRelayTriggerReceivedAtis observable and set correctly.The three-state relay logic is well-designed and matches the PR objectives. However, the implementation depends on
subscriptionManager.lastRelayTriggerReceivedAt(line 204), which is not visible in the provided context. Ensure that:
- The property exists on
SubscriptionManagerand is marked@Publishedor part of an@Observablemacro so SwiftUI observes changes- The property is set when relay triggers are received (likely in APNs handling code)
- UI updates propagate when the property transitions from
nilto a timestampRun the following script to verify the property is defined and observable:
#!/bin/bash # Description: Verify lastRelayTriggerReceivedAt exists on SubscriptionManager and is observable # Check if the property is defined in SubscriptionManager rg -n --type=swift -A2 -B2 'lastRelayTriggerReceivedAt' ios/VaultSync/Services/SubscriptionManager.swift # Verify SubscriptionManager uses `@Observable` or ObservableObject rg -n --type=swift '^(`@Observable`|class.*ObservableObject)' ios/VaultSync/Services/SubscriptionManager.swiftios/VaultSync/Views/CloudRelayUpsellView.swift (1)
1-179: LGTM!ios/VaultSync/Views/ExternalLinkButton.swift (1)
9-9: LGTM!ios/VaultSync/Views/ContentView.swift (1)
17-19: LGTM!Also applies to: 82-99, 102-114, 234-257
…e purchase, align docs Addresses CodeRabbit review on the PR: - SubscriptionManager.handle(_:) matched only the monthly SKU, so a verified yearly purchase or renewal fell through without marking the user subscribed or provisioning the relay. It now matches both SKUs via relayProductIDs. - Settings subscribe button blocks the purchase until at least one Syncthing peer exists and prompts the user to add their server first — otherwise the relay would be provisioned with no Device ID to wake. (Localized en/de/es/zh.) - docs/relay-spec.md pricing bullet and PRIVACY.md deletion scope aligned with the JWS-based provisioning and the stored transaction records (id + expiry). Build + tests green (23 tests, 0 failures).
…ecklist freshness + tests
- StoreKit ranking: make yearly the higher tier (groupNumber 1) and monthly the
lower (2) so monthly → yearly is an immediate upgrade, matching the App Store
Connect setup doc.
- Setup checklist: extract the three-state decision into a pure, unit-tested
function (relayChecklistState) and base "active" on relayDeliveryConfirmed
(48h freshness), so the checklist agrees with Settings and Relay Diagnostics
instead of staying green after the helper stops. Adds a test for all 3 states.
- Localization consistency:
- keep "Vault" untranslated on the upsell screen (es/zh), matching the rest of
the app
- keep the Syncthing web-UI path (Actions → Settings → GUI → API Key) in English
in Step 1 across de/es/zh, since Syncthing's UI language is independent of the
app locale
- reword Step 3 so it no longer quotes a specific Diagnostics label, removing the
EN-vs-translation phrase mismatch
- Hygiene: remove dead localization keys (old checklist copy) in all four languages
and the unused relayPriceText property.
- docs/relay-spec.md: mark JWS verification + server-side expiry as v1.5.0.
Build + tests green (XCTest 23/0, Swift Testing 47/0).
🚀 Commits
393a8bf(iOS)7ecf918(docs)85757df(release v1.5.0)✨ Highlights
vaultsync-notify: Surfaces the mandatory server-side helper with a guided, copyable setup shown right after purchase.waiting-for-servervsdelivering) instead of just 'ready' on subscription alone.⚙️ Backend Changes (Relay v1.1.0)
🏷️ Release & Deployment
v1.5.0.version-check.ymlrequirestag=ios/project.yml=CHANGELOG=1.5.0.🛠️ Manual Tasks (App Store Connect)
eu.vaultsync.app.relay.yearly