Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,11 @@ Every link expires. Every file is deleted. By design.

| Tier | File size | Retention | Uploads | Sync |
| ---- | --------- | --------- | ------- | ---- |
| Free | 100 MB per file | Up to 24 hours | 3 per day | Local history |
| Free launch access | 2 GB per file | Up to 30 days | Unlimited | Local history |
| Pro | 2 GB per file | Up to 30 days | Unlimited | iCloud metadata sync |

The backend is the source of truth for caps and retention. Beta allowances are
controlled by a secure allowlist, never by a global "free equals pro" default.
The backend is the source of truth for caps and retention. For launch, Free
uses Pro upload ceilings while keeping account-level sync features paid.

## How it works

Expand Down
2 changes: 1 addition & 1 deletion apple/FastSharedApp/Scenes/PaywallView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ struct PaywallView: View {
case .cloudSyncRequested:
return "Pro mirrors your link history to your private iCloud — no extra setup."
case .longRetentionRequested:
return "Pro supports up to 30-day retention. Free tops out at 24 hours."
return "Launch access supports up to 30-day retention. Pro adds sync, support, and Lifetime Family Sharing."
case .largeFileRequested:
return "Pro raises the cap to 2 GB per file and keeps the same one-gesture flow."
case .serverForced:
Expand Down
6 changes: 3 additions & 3 deletions apple/FastSharedApp/Screenshot/AppStoreScreenshotMode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ enum AppStoreScreenshotScene: String, CaseIterable {
case .history:
return "Keep control after you send."
case .pro:
return "Go Pro when you need more room."
return "Go Pro when you want sync."
}
}

Expand All @@ -37,13 +37,13 @@ enum AppStoreScreenshotScene: String, CaseIterable {
case .shareFlow:
return "FastShared turns a file into a temporary short link and copies it automatically."
case .retention:
return "Default 24-hour links keep cleanup automatic. Pro unlocks up to 30 days."
return "Default 24-hour links keep cleanup automatic. Launch access supports up to 30 days."
case .progress:
return "Large files continue cleanly with visible progress and a ready-to-paste link."
case .history:
return "Find recent links, copy again, share, or revoke before expiration."
case .pro:
return "Unlimited daily uploads, 2 GB files, longer retention, and iCloud sync."
return "Sync link history across devices, get priority support, and keep Lifetime in the family."
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ public struct TierCaps: Sendable, Codable, Equatable {
self.allowsCloudSync = allowsCloudSync
}

/// Launch Free defaults — unlimited uploads, 2 GB per file, 30 days retention.
/// iCloud sync stays Pro-only so paid entitlements still map to account-level features.
public static let free = TierCaps(
dailyUploadLimit: 3,
maxFileSizeBytes: 100 * 1024 * 1024,
maxRetentionSeconds: 86_400,
dailyUploadLimit: nil,
maxFileSizeBytes: 2 * 1024 * 1024 * 1024,
maxRetentionSeconds: 2_592_000,
allowsCloudSync: false
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,12 @@ final class UploadServiceTests: XCTestCase {
background: background,
orchestrator: orchestrator,
usageTracker: tracker,
subscriptionStore: nil) // nil → Free fallback
subscriptionStore: CustomCapSubscriptionStore(caps: TierCaps(
dailyUploadLimit: 3,
maxFileSizeBytes: 2 * 1024 * 1024 * 1024,
maxRetentionSeconds: 30 * 86_400,
allowsCloudSync: false
)))

let fileURL = try Self.writeTempFile(bytes: 32)
do {
Expand All @@ -238,8 +243,8 @@ final class UploadServiceTests: XCTestCase {
}

func test_enqueue_onFileTooLargeForFree_throwsSubscriptionGate() async throws {
// 100 MB cap + 1-byte file > cap (using small file + lowered cap via
// a custom FreeCapsSubscriptionStore).
// Use a tiny custom cap so a 64-byte fixture blows it without creating
// a huge test file.
let tracker = UsageTracker(clock: UsageTrackerTests.FakeClock("2026-04-19"),
defaults: isolatedDefaults())
let store = SwiftDataStore.inMemoryForTests()
Expand Down Expand Up @@ -353,8 +358,8 @@ final class UploadServiceTests: XCTestCase {
background: background,
orchestrator: orchestrator,
usageTracker: tracker,
// nil subscriptionStore → falls back to .free caps but uses
// the injected tracker so each test starts with a clean counter.
// Avoid quota gates in the shared happy-path helper; negative
// preflight coverage injects custom caps in focused tests.
subscriptionStore: AlwaysProSubscriptionStore())
return (service, mock, store, clipboard, orchestrator)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,12 @@ final class UsageTrackerTests: XCTestCase {
XCTAssertEqual(newDay, 0)
}

func test_remaining_forFree_cappedAt3() async {
func test_remaining_forLaunchFree_returnsUnlimitedSentinel() async {
let tracker = UsageTracker(clock: FakeClock("2026-04-19"), defaults: isolatedDefaults())
_ = await tracker.increment()
_ = await tracker.increment()
let remaining = await tracker.remaining(for: .free)
XCTAssertEqual(remaining, 1)
XCTAssertEqual(remaining, UsageUnlimited)
}

func test_remaining_forPro_returnsUnlimitedSentinel() async {
Expand Down
4 changes: 3 additions & 1 deletion apple/fastlane/metadata/en-US/description.txt
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,12 @@ YOUR FILES, YOUR RULES
- Bearer links with high-entropy tokens. The link is the credential. Revoke any time.
- noindex, no-referrer, no-store on every resolve so tokens stay out of search engines, referrer chains, and caches.

FASTSHARED PRO
LAUNCH ACCESS
- Unlimited uploads per day.
- Files up to 2 GB.
- Link retention up to 30 days.

FASTSHARED PRO
- iCloud history sync across iPhone, iPad, and Mac.
- Family Sharing on Lifetime.
- Priority support.
Expand Down
2 changes: 1 addition & 1 deletion apple/fastlane/metadata/review_information/notes.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@ To test Pro, use a sandbox Apple ID and these App Store Connect products:
- red.fastsha.pro.annual
- red.fastsha.pro.lifetime

Pro unlocks unlimited uploads, 2 GB file size, 30-day retention, iCloud history sync, and priority support. Lifetime has Family Sharing enabled.
For launch, the basic upload flow is intentionally unlimited: non-Pro users can upload without a daily cap, use files up to 2 GB, and choose retention up to 30 days. Pro remains available for iCloud history sync, priority support, and Lifetime Family Sharing.

User-generated content is limited to private bearer links created by the reviewer during testing. There is no public feed, user search, follower graph, chat, comments, or permanent hosting. Uploaded files are private, revocable, and automatically deleted after their retention window. The public report endpoint for abuse is /v1/report/:token.
7 changes: 4 additions & 3 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,9 +254,10 @@ Response:
}
```

Each file in the batch counts as one event against the daily cap (free tier
= 3/day) — a batch of 4 from a free user fails fast with `429` before any
DB insert. Junction-table idempotency is keyed on `(share_link_id,
Each file in the batch counts as one event when a daily cap is configured.
Launch Free uses the unlimited sentinel, so the KV counter is skipped. If caps
are tightened later, over-cap batches fail fast with `429` before any DB
insert. Junction-table idempotency is keyed on `(share_link_id,
upload_job_id)`: re-completing the same `uploadId` is a no-op. Two slots
that dedup to the same R2 asset still occupy two distinct junction rows
(one per `uploadJobId`).
Expand Down
13 changes: 7 additions & 6 deletions backend/src/lib/tierCaps.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
// Single source of truth for per-tier quota limits. Both the free-tier
// enforcement middleware and `GET /v1/me` read these — no drift allowed.
//
// Values are aligned with the public beta hotfix plan:
// Free: 3 uploads/day, max 100 MB, TTL fixed at 24h, no Cloud Sync
// Pro: unlimited (-1 sentinel) uploads, max 2 GB, TTL up to 30 days
// Values are aligned with the launch plan:
// Free: launch-unlimited uploads (-1 sentinel), max 2 GB, TTL up to 30 days,
// no Cloud Sync
// Pro: same upload caps, plus Cloud Sync

export interface TierCaps {
uploadsPerDay: number; // -1 means unlimited
Expand Down Expand Up @@ -32,9 +33,9 @@ export function toWireCaps(caps: TierCaps): TierCapsWire {
}

export const FREE_CAPS: TierCaps = {
uploadsPerDay: 3,
maxFileSizeMB: 100,
maxRetentionHours: 24,
uploadsPerDay: -1,
maxFileSizeMB: 2048,
maxRetentionHours: 720,
allowsCloudSync: false,
};

Expand Down
9 changes: 5 additions & 4 deletions backend/src/middleware/rateLimitFreeTier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@ const UTC_DAY_SECONDS = 86_400;

// Free-tier enforcement. Runs AFTER auth() — requires `deviceId` in context.
// On Pro: returns immediately; caps live on the DB row, not KV.
// On Free: enforces size cap (100 MB), daily-count cap (3/day), and silently
// clamps retention policies that exceed 24h down to `oneDay`. Silent clamp
// means the response includes `retentionClamped: true` but the request does
// NOT 4xx — Free users get a reduced feature, not a rejection.
// On Free: enforces the current Free caps from FREE_CAPS. During launch those
// caps are intentionally generous (unlimited daily uploads, 2 GB files,
// 30-day retention) while Cloud Sync remains Pro-only. If the caps are later
// tightened, retention policies above the cap are silently clamped instead of
// 4xxing the request.
//
// Body consumption is the big foot-gun here: Hono's `c.req.json()` consumes
// the underlying ReadableStream, so downstream Zod parsing would see an empty
Expand Down
6 changes: 3 additions & 3 deletions backend/src/routes/uploads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,9 +282,9 @@ uploadRoutes.post('/batch', async (c) => {
}
}

// Free-tier enforcement: each file in the bundle counts 1 against the daily
// cap, plus the per-file size cap. Pro skips both. Mirrors the logic in
// rateLimitFreeTier middleware but with count = items.length atomically.
// Free-tier enforcement mirrors rateLimitFreeTier middleware but reserves
// `items.length` atomically when a daily cap is configured. Launch Free caps
// use the unlimited sentinel, so this path skips the KV counter.
const enforce = await enforceBatchFreeTierLimits(c, db, deviceId, body);
if (enforce) return enforce;

Expand Down
18 changes: 18 additions & 0 deletions backend/test/tierCaps.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { FREE_CAPS, PRO_CAPS, toWireCaps } from '~/lib/tierCaps';

describe('launch tier caps', () => {
it('keeps Free upload usage unlimited for launch without enabling Cloud Sync', () => {
expect(FREE_CAPS.uploadsPerDay).toBe(-1);
expect(FREE_CAPS.maxFileSizeMB).toBe(PRO_CAPS.maxFileSizeMB);
expect(FREE_CAPS.maxRetentionHours).toBe(PRO_CAPS.maxRetentionHours);
expect(FREE_CAPS.allowsCloudSync).toBe(false);

expect(toWireCaps(FREE_CAPS)).toEqual({
dailyUploadLimit: null,
maxFileSizeBytes: 2 * 1024 * 1024 * 1024,
maxRetentionSeconds: 30 * 24 * 60 * 60,
allowsCloudSync: false,
});
});
});
2 changes: 1 addition & 1 deletion web/src/components/FAQ.astro
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ interface QA {
const items: QA[] = [
{
q: 'How long do files live?',
a: 'Free links can live up to 24 hours. Pro links can live up to 30 days. Every share has an expiry.',
a: 'Launch links can live up to 30 days. Every share has an expiry.',
},
{
q: 'Does the recipient need FastShared?',
Expand Down
6 changes: 3 additions & 3 deletions web/src/components/Hero.astro
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,12 @@

<dl class="brand-hero__proof reveal reveal-delay-4">
<div>
<dt>Free</dt>
<dd>100 MB · 24 h</dd>
<dt>Launch</dt>
<dd>2 GB · 30 d</dd>
</div>
<div>
<dt>Pro</dt>
<dd>2 GB · 30 d</dd>
<dd>iCloud sync</dd>
</div>
<div>
<dt>Storage</dt>
Expand Down
2 changes: 1 addition & 1 deletion web/src/components/HowItWorks.astro
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const steps = [
{
n: '02',
title: 'Set the deadline',
caption: 'Default 24 hours. Pro can keep links alive up to 30 days.',
caption: 'Default 24 hours. Launch access can keep links alive up to 30 days.',
meta: 'Expiry is required',
},
{
Expand Down
18 changes: 9 additions & 9 deletions web/src/pages/pricing.astro
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ import PlaneArc from '../components/PlaneArc.astro';
---
<Base
title="Pricing — FastShared"
description="FastShared Pro unlocks unlimited uploads, 30-day link retention, and iCloud history sync. Free stays free. Pro from $2.99/mo."
description="FastShared launch access includes unlimited uploads, 2 GB files, and 30-day link retention. Pro adds iCloud history sync and priority support."
>
<Nav />
<main class="frame pt-16 pb-24 max-w-[1080px] mx-auto">
<script is:inline type="application/ld+json" set:html={JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Product',
name: 'FastShared Pro',
description: 'Pro tier of FastShared — unlimited uploads, 30-day retention, iCloud sync, priority support. Available as Monthly, Annual, or one-time Lifetime.',
description: 'Pro tier of FastShared — iCloud sync, priority support, and Family Sharing on Lifetime. Available as Monthly, Annual, or one-time Lifetime.',
brand: { '@type': 'Brand', name: 'FastShared' },
offers: [
{ '@type': 'Offer', name: 'Pro Monthly', price: '2.99', priceCurrency: 'USD', availability: 'https://schema.org/InStock' },
Expand All @@ -39,7 +39,7 @@ import PlaneArc from '../components/PlaneArc.astro';
<span class="block italic text-coral">Your call on the rest.</span>
</h1>
<p class="mt-8 font-mono text-[14px] leading-[1.6] tracking-[0.03em] text-milk/55 max-w-[640px]">
Free is honest. Pro is generous. No dark patterns, no feature-gated anti-virus, no "unlock export" nonsense — just higher ceilings, longer retention, and iCloud sync for the people who share every day.
Launch access keeps the sharing flow generous for everyone: unlimited uploads, 2 GB files, and links up to 30 days. Pro adds iCloud sync, priority support, and Lifetime Family Sharing for people who live in the app every day.
</p>
</section>

Expand All @@ -52,13 +52,13 @@ import PlaneArc from '../components/PlaneArc.astro';
<header class="tier-head">
<p class="tier-name">Free</p>
<p class="tier-price"><span class="amount">$0</span><span class="period">forever</span></p>
<p class="tier-blurb">For occasional shares. No account, no card.</p>
<p class="tier-blurb">Launch access. No account, no card.</p>
</header>
<a href="#app-store" class="btn-ghost tier-cta" data-testid="cta-free">Start free →</a>
<ul class="tier-features" aria-label="Free tier features">
<li><span class="ft-k">Uploads / day</span><span class="ft-v">3</span></li>
<li><span class="ft-k">Max file size</span><span class="ft-v">100 MB</span></li>
<li><span class="ft-k">Link retention</span><span class="ft-v">up to 24 h</span></li>
<li><span class="ft-k">Uploads / day</span><span class="ft-v ft-on">unlimited</span></li>
<li><span class="ft-k">Max file size</span><span class="ft-v">2 GB</span></li>
<li><span class="ft-k">Link retention</span><span class="ft-v">up to 30 days</span></li>
<li><span class="ft-k">iCloud sync</span><span class="ft-v ft-off">—</span></li>
<li><span class="ft-k">Revoke & history</span><span class="ft-v">on this device</span></li>
<li><span class="ft-k">Priority support</span><span class="ft-v ft-off">—</span></li>
Expand Down Expand Up @@ -161,15 +161,15 @@ import PlaneArc from '../components/PlaneArc.astro';
</details>
<details class="faq-item">
<summary>What happens when I downgrade to Free?</summary>
<div class="faq-body">Existing links keep their original expiry (up to 30 days) — we don't retroactively shorten them. After the subscription lapses, new uploads revert to Free limits: 3/day, 100 MB max, 24 h retention ceiling. iCloud sync stops; records already in your private iCloud stay for 30 days, then iCloud prunes them naturally.</div>
<div class="faq-body">Existing links keep their original expiry (up to 30 days) — we don't retroactively shorten them. During launch, new Free uploads keep the same upload ceilings as Pro. iCloud sync stops; records already in your private iCloud stay for 30 days, then iCloud prunes them naturally.</div>
</details>
<details class="faq-item">
<summary>Why $49.99 for Lifetime?</summary>
<div class="faq-body">Early Access pricing for the first three months after launch. It's a thank-you to the people who show up early. After the Early Access window, Lifetime returns to its regular price. One-time purchase, Family Sharing enabled, unlimited future updates to FastShared Pro.</div>
</details>
<details class="faq-item">
<summary>Is there a free trial?</summary>
<div class="faq-body">No trial in v1 — the Free tier is the trial. It's enough (3 uploads/day, 100 MB, 24 h) to let you decide if the Pro limits actually matter to you before paying. TestFlight and internal beta users may receive temporary unlimited access by allowlist while App Store rollout is active.</div>
<div class="faq-body">No trial in v1 — launch access is already generous for core sharing: unlimited uploads, 2 GB files, and up to 30-day links. Pro is for iCloud sync, priority support, and Lifetime Family Sharing.</div>
</details>
</div>
</section>
Expand Down
Loading