max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
29 files changed,
+98 insertions,
-98 deletions
| @@ -1,55 +1,55 @@ | |||
| 1 | - | -- Unified promo_codes table replacing discount_codes + download_codes. | |
| 2 | - | -- Supports three code purposes: | |
| 3 | - | -- discount percentage or fixed price reduction | |
| 4 | - | -- free_access, grants free access to item (replaces download_codes) | |
| 5 | - | -- free_trial N days free on a subscription tier | |
| 6 | - | ||
| 7 | - | CREATE TABLE promo_codes ( | |
| 8 | - | id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | |
| 9 | - | creator_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 10 | - | code TEXT NOT NULL, | |
| 11 | - | code_purpose TEXT NOT NULL CHECK (code_purpose IN ('discount', 'free_access', 'free_trial')), | |
| 12 | - | -- Discount fields (required when purpose = 'discount') | |
| 13 | - | discount_type TEXT CHECK (discount_type IN ('percentage', 'fixed')), | |
| 14 | - | discount_value INT, | |
| 15 | - | min_price_cents INT NOT NULL DEFAULT 0, | |
| 16 | - | -- Trial fields (required when purpose = 'free_trial') | |
| 17 | - | trial_days INT, | |
| 18 | - | -- Scope | |
| 19 | - | item_id UUID REFERENCES items(id) ON DELETE CASCADE, | |
| 20 | - | project_id UUID REFERENCES projects(id) ON DELETE CASCADE, | |
| 21 | - | tier_id UUID REFERENCES subscription_tiers(id) ON DELETE CASCADE, | |
| 22 | - | -- Usage | |
| 23 | - | max_uses INT, | |
| 24 | - | use_count INT NOT NULL DEFAULT 0, | |
| 25 | - | expires_at TIMESTAMPTZ, | |
| 26 | - | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), | |
| 27 | - | -- Integrity | |
| 28 | - | CONSTRAINT chk_discount_fields CHECK ( | |
| 29 | - | code_purpose != 'discount' OR (discount_type IS NOT NULL AND discount_value > 0) | |
| 30 | - | ), | |
| 31 | - | CONSTRAINT chk_trial_fields CHECK ( | |
| 32 | - | code_purpose != 'free_trial' OR (trial_days IS NOT NULL AND trial_days > 0) | |
| 33 | - | ) | |
| 34 | - | ); | |
| 35 | - | ||
| 36 | - | CREATE UNIQUE INDEX idx_promo_codes_creator_code ON promo_codes(creator_id, upper(code)); | |
| 37 | - | CREATE INDEX idx_promo_codes_item ON promo_codes(item_id); | |
| 38 | - | CREATE INDEX idx_promo_codes_project ON promo_codes(project_id); | |
| 39 | - | CREATE INDEX idx_promo_codes_tier ON promo_codes(tier_id); | |
| 40 | - | ||
| 41 | - | -- Migrate existing data | |
| 42 | - | INSERT INTO promo_codes (id, creator_id, code, code_purpose, discount_type, discount_value, | |
| 43 | - | min_price_cents, item_id, project_id, max_uses, use_count, expires_at, created_at) | |
| 44 | - | SELECT id, seller_id, code, 'discount', discount_type, discount_value, | |
| 45 | - | min_price_cents, item_id, project_id, max_uses, use_count, expires_at, created_at | |
| 46 | - | FROM discount_codes; | |
| 47 | - | ||
| 48 | - | INSERT INTO promo_codes (id, creator_id, code, code_purpose, item_id, max_uses, | |
| 49 | - | use_count, expires_at, created_at) | |
| 50 | - | SELECT id, created_by_id, code, 'free_access', item_id, max_uses, | |
| 51 | - | use_count, expires_at, created_at | |
| 52 | - | FROM download_codes; | |
| 53 | - | ||
| 54 | - | DROP TABLE download_codes; | |
| 55 | - | DROP TABLE discount_codes; | |
| 1 | + | -- Unified promo_codes table replacing discount_codes + download_codes. | |
| 2 | + | -- Supports three code purposes: | |
| 3 | + | -- discount — percentage or fixed price reduction | |
| 4 | + | -- free_access — grants free access to item (replaces download_codes) | |
| 5 | + | -- free_trial — N days free on a subscription tier | |
| 6 | + | ||
| 7 | + | CREATE TABLE promo_codes ( | |
| 8 | + | id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | |
| 9 | + | creator_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 10 | + | code TEXT NOT NULL, | |
| 11 | + | code_purpose TEXT NOT NULL CHECK (code_purpose IN ('discount', 'free_access', 'free_trial')), | |
| 12 | + | -- Discount fields (required when purpose = 'discount') | |
| 13 | + | discount_type TEXT CHECK (discount_type IN ('percentage', 'fixed')), | |
| 14 | + | discount_value INT, | |
| 15 | + | min_price_cents INT NOT NULL DEFAULT 0, | |
| 16 | + | -- Trial fields (required when purpose = 'free_trial') | |
| 17 | + | trial_days INT, | |
| 18 | + | -- Scope | |
| 19 | + | item_id UUID REFERENCES items(id) ON DELETE CASCADE, | |
| 20 | + | project_id UUID REFERENCES projects(id) ON DELETE CASCADE, | |
| 21 | + | tier_id UUID REFERENCES subscription_tiers(id) ON DELETE CASCADE, | |
| 22 | + | -- Usage | |
| 23 | + | max_uses INT, | |
| 24 | + | use_count INT NOT NULL DEFAULT 0, | |
| 25 | + | expires_at TIMESTAMPTZ, | |
| 26 | + | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), | |
| 27 | + | -- Integrity | |
| 28 | + | CONSTRAINT chk_discount_fields CHECK ( | |
| 29 | + | code_purpose != 'discount' OR (discount_type IS NOT NULL AND discount_value > 0) | |
| 30 | + | ), | |
| 31 | + | CONSTRAINT chk_trial_fields CHECK ( | |
| 32 | + | code_purpose != 'free_trial' OR (trial_days IS NOT NULL AND trial_days > 0) | |
| 33 | + | ) | |
| 34 | + | ); | |
| 35 | + | ||
| 36 | + | CREATE UNIQUE INDEX idx_promo_codes_creator_code ON promo_codes(creator_id, upper(code)); | |
| 37 | + | CREATE INDEX idx_promo_codes_item ON promo_codes(item_id); | |
| 38 | + | CREATE INDEX idx_promo_codes_project ON promo_codes(project_id); | |
| 39 | + | CREATE INDEX idx_promo_codes_tier ON promo_codes(tier_id); | |
| 40 | + | ||
| 41 | + | -- Migrate existing data | |
| 42 | + | INSERT INTO promo_codes (id, creator_id, code, code_purpose, discount_type, discount_value, | |
| 43 | + | min_price_cents, item_id, project_id, max_uses, use_count, expires_at, created_at) | |
| 44 | + | SELECT id, seller_id, code, 'discount', discount_type, discount_value, | |
| 45 | + | min_price_cents, item_id, project_id, max_uses, use_count, expires_at, created_at | |
| 46 | + | FROM discount_codes; | |
| 47 | + | ||
| 48 | + | INSERT INTO promo_codes (id, creator_id, code, code_purpose, item_id, max_uses, | |
| 49 | + | use_count, expires_at, created_at) | |
| 50 | + | SELECT id, created_by_id, code, 'free_access', item_id, max_uses, | |
| 51 | + | use_count, expires_at, created_at | |
| 52 | + | FROM download_codes; | |
| 53 | + | ||
| 54 | + | DROP TABLE download_codes; | |
| 55 | + | DROP TABLE discount_codes; |
| @@ -3,7 +3,7 @@ | |||
| 3 | 3 | ALTER TABLE users ADD COLUMN max_file_override_bytes BIGINT; | |
| 4 | 4 | ALTER TABLE users ADD COLUMN grandfathered_until TIMESTAMPTZ; | |
| 5 | 5 | ||
| 6 | - | -- File sizes on items (audio + cover, versions already have file_size_bytes) | |
| 6 | + | -- File sizes on items (audio + cover — versions already have file_size_bytes) | |
| 7 | 7 | ALTER TABLE items ADD COLUMN audio_file_size_bytes BIGINT; | |
| 8 | 8 | ALTER TABLE items ADD COLUMN cover_file_size_bytes BIGINT; | |
| 9 | 9 |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | -- Remove download fingerprinting and streaming session tracking tables. | |
| 2 | - | -- These tables were created in migration 051 but never populated. The | |
| 3 | - | -- write paths were never implemented. Shelving the mechanism entirely. | |
| 2 | + | -- These tables were created in migration 051 but never populated — | |
| 3 | + | -- the write paths were never implemented. Shelving the mechanism entirely. | |
| 4 | 4 | ||
| 5 | 5 | DROP TABLE IF EXISTS download_fingerprints; | |
| 6 | 6 | DROP TABLE IF EXISTS streaming_sessions; |
| @@ -1,4 +1,4 @@ | |||
| 1 | - | -- Fix: batch_id unique index was too restrictive, a single push batch | |
| 1 | + | -- Fix: batch_id unique index was too restrictive — a single push batch | |
| 2 | 2 | -- inserts multiple sync_log rows (one per change), all sharing the same | |
| 3 | 3 | -- batch_id. Replace the unique index with a regular index used only by | |
| 4 | 4 | -- the idempotency MAX(seq) query. |
| @@ -15,4 +15,4 @@ | |||
| 15 | 15 | WHERE status = 'completed' AND project_id IS NOT NULL; | |
| 16 | 16 | ||
| 17 | 17 | -- Fix: get_bundle_items should exclude soft-deleted items. | |
| 18 | - | -- No schema change needed, fix is in the query. | |
| 18 | + | -- No schema change needed — fix is in the query. |
| @@ -18,7 +18,7 @@ | |||
| 18 | 18 | DELETE FROM tags; | |
| 19 | 19 | ||
| 20 | 20 | -- ============================================================================ | |
| 21 | - | -- 2. Type-level tags (depth 1), not assignable to items | |
| 21 | + | -- 2. Type-level tags (depth 1) — not assignable to items | |
| 22 | 22 | -- ============================================================================ | |
| 23 | 23 | ||
| 24 | 24 | INSERT INTO tags (name, slug, parent_id, sort_order, path) VALUES | |
| @@ -30,7 +30,7 @@ | |||
| 30 | 30 | ('Education', 'education', NULL, 5, 'education'); | |
| 31 | 31 | ||
| 32 | 32 | -- ============================================================================ | |
| 33 | - | -- 3. Category-level tags (depth 2), not assignable to items | |
| 33 | + | -- 3. Category-level tags (depth 2) — not assignable to items | |
| 34 | 34 | -- ============================================================================ | |
| 35 | 35 | ||
| 36 | 36 | -- Audio categories | |
| @@ -68,7 +68,7 @@ | |||
| 68 | 68 | ('Format', 'education.format', (SELECT id FROM tags WHERE slug = 'education'), 1, 'education.format'); | |
| 69 | 69 | ||
| 70 | 70 | -- ============================================================================ | |
| 71 | - | -- 4. Value-level tags (depth 3). These are assignable to items | |
| 71 | + | -- 4. Value-level tags (depth 3) — these are assignable to items | |
| 72 | 72 | -- ============================================================================ | |
| 73 | 73 | ||
| 74 | 74 | -- audio.genre.* |
| @@ -6,6 +6,6 @@ | |||
| 6 | 6 | -- | |
| 7 | 7 | -- Nullable: not every issue has a corresponding MT thread (e.g., orphan | |
| 8 | 8 | -- repos with no `project_id`, or MT client not configured at issue-creation | |
| 9 | - | -- time). The bridge fails open, missing thread doesn't block the issue. | |
| 9 | + | -- time). The bridge fails open — missing thread doesn't block the issue. | |
| 10 | 10 | ||
| 11 | 11 | ALTER TABLE issues ADD COLUMN mt_thread_id UUID; |
| @@ -10,17 +10,17 @@ | |||
| 10 | 10 | -- + key_cap × $0.02 (per_key mode only)) | |
| 11 | 11 | -- | |
| 12 | 12 | -- Knobs the developer sets: | |
| 13 | - | -- - storage_gb_cap total GB of blob storage available to the app | |
| 14 | - | -- - egress_multiple monthly egress quota = storage_gb_cap × this | |
| 15 | - | -- - key_cap only meaningful in per_key enforcement mode | |
| 13 | + | -- - storage_gb_cap — total GB of blob storage available to the app | |
| 14 | + | -- - egress_multiple — monthly egress quota = storage_gb_cap × this | |
| 15 | + | -- - key_cap — only meaningful in per_key enforcement mode | |
| 16 | 16 | -- | |
| 17 | 17 | -- The base fee is pro-rated: developers always pay at least $5/mo, but usage | |
| 18 | 18 | -- fees count against it. Once usage exceeds $5, the floor disappears. | |
| 19 | 19 | -- | |
| 20 | 20 | -- Enforcement modes (orthogonal to pricing): | |
| 21 | - | -- - per_key when key_cap reached, new key claims refused; existing keys | |
| 21 | + | -- - per_key — when key_cap reached, new key claims refused; existing keys | |
| 22 | 22 | -- keep working until storage/egress caps hit. | |
| 23 | - | -- - app_wide, any cap hit returns quota_exceeded for the whole app. | |
| 23 | + | -- - app_wide — any cap hit returns quota_exceeded for the whole app. | |
| 24 | 24 | -- | |
| 25 | 25 | -- "Key" is a developer-defined opaque unit. The dev's backend calls | |
| 26 | 26 | -- claim_key/release_key as their app's logic dictates (user, workspace, | |
| @@ -28,7 +28,7 @@ | |||
| 28 | 28 | -- | |
| 29 | 29 | -- First-party apps (GO/BB/AF) are marked `is_internal` and bypass billing | |
| 30 | 30 | -- entirely. Their end-user billing (formerly app_sync_subscriptions) is | |
| 31 | - | -- removed. Those apps will grow their own subscription code outside SyncKit. | |
| 31 | + | -- removed — those apps will grow their own subscription code outside SyncKit. | |
| 32 | 32 | ||
| 33 | 33 | -- ── sync_apps: billing columns ── | |
| 34 | 34 | ALTER TABLE sync_apps ADD COLUMN is_internal BOOLEAN NOT NULL DEFAULT FALSE; |
| @@ -7,10 +7,10 @@ | |||
| 7 | 7 | -- | |
| 8 | 8 | -- Two modes (the `enforcement_mode` column drives both pricing and limits): | |
| 9 | 9 | -- | |
| 10 | - | -- bulk developer sets `storage_gb_cap`. Price = storage_gb_cap × rate. | |
| 10 | + | -- bulk — developer sets `storage_gb_cap`. Price = storage_gb_cap × rate. | |
| 11 | 11 | -- When storage fills, the whole app's uploads degrade. | |
| 12 | 12 | -- | |
| 13 | - | -- per_key developer sets `key_cap` AND `gb_per_key`. | |
| 13 | + | -- per_key — developer sets `key_cap` AND `gb_per_key`. | |
| 14 | 14 | -- Price = key_cap × gb_per_key × rate. | |
| 15 | 15 | -- Each key gets its own gb_per_key allotment. A full key | |
| 16 | 16 | -- degrades only that key; other keys keep working. |
| @@ -8,7 +8,7 @@ | |||
| 8 | 8 | -- | |
| 9 | 9 | -- JWT minting now requires a `key` claim (see `synckit_auth.rs`), so every | |
| 10 | 10 | -- new blob upload knows its key. Existing sync_blobs rows are first-party | |
| 11 | - | -- internal app data (GO/BB/AF). We don't need to preserve them and a | |
| 11 | + | -- internal app data (GO/BB/AF) — we don't need to preserve them and a | |
| 12 | 12 | -- backfill default would not be meaningful, so we drop and recreate. | |
| 13 | 13 | ||
| 14 | 14 | -- ── Per-key live counters ── | |
| @@ -32,7 +32,7 @@ | |||
| 32 | 32 | ||
| 33 | 33 | -- ── sync_blobs: attribute each blob to a key ── | |
| 34 | 34 | -- Drop existing rows (internal-app data only; not preserved across this | |
| 35 | - | -- schema change). The `key` column is NOT NULL with no default, every | |
| 35 | + | -- schema change). The `key` column is NOT NULL with no default — every | |
| 36 | 36 | -- future write must supply it. | |
| 37 | 37 | DELETE FROM sync_blobs; | |
| 38 | 38 | ALTER TABLE sync_blobs ADD COLUMN key TEXT NOT NULL; |
| @@ -9,7 +9,7 @@ | |||
| 9 | 9 | -- the next billing cycle (no mid-cycle proration surprises). | |
| 10 | 10 | -- | |
| 11 | 11 | -- The pricing model is now formula-driven (see `payments::synckit_app_pricing`) | |
| 12 | - | --, no tier table, the user picks any cap and the server quotes a price. | |
| 12 | + | -- — no tier table, the user picks any cap and the server quotes a price. | |
| 13 | 13 | CREATE TABLE app_sync_subscriptions ( | |
| 14 | 14 | id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | |
| 15 | 15 | user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, |
| @@ -4,7 +4,7 @@ | |||
| 4 | 4 | -- size could change underfoot (or the HEAD could fail entirely), producing | |
| 5 | 5 | -- a wrong decrement and silently corrupting `creator_tiers.storage_used`. | |
| 6 | 6 | -- | |
| 7 | - | -- NULL means "size not recorded", pre-migration rows fall back to a | |
| 7 | + | -- NULL means "size not recorded" — pre-migration rows fall back to a | |
| 8 | 8 | -- best-effort S3 HEAD until the next replace stores a real value. | |
| 9 | 9 | ||
| 10 | 10 | ALTER TABLE projects |
| @@ -4,7 +4,7 @@ | |||
| 4 | 4 | -- [::1]:PORT/, or localhost:PORT/ regardless of an app's registered | |
| 5 | 5 | -- redirect_uris list. That's correct for native (desktop/mobile) apps that | |
| 6 | 6 | -- can't reserve known ports, but it widens the attack surface for any | |
| 7 | - | -- web-only app, a phishing URL pointing /oauth/authorize at the attacker's | |
| 7 | + | -- web-only app — a phishing URL pointing /oauth/authorize at the attacker's | |
| 8 | 8 | -- loopback can hijack the auth flow even with PKCE in play (attacker | |
| 9 | 9 | -- initiates and supplies their own code_verifier). | |
| 10 | 10 | -- |
| @@ -1,5 +1,5 @@ | |||
| 1 | 1 | -- At most one running build globally. Enforces what | |
| 2 | - | -- `claim_pending_build`'s NOT EXISTS subquery checks at app level, without | |
| 2 | + | -- `claim_pending_build`'s NOT EXISTS subquery checks at app level — without | |
| 3 | 3 | -- this index, two replicas racing through the claim path can each pass the | |
| 4 | 4 | -- check and end up with two concurrent running builds. The losing INSERT/ | |
| 5 | 5 | -- UPDATE surfaces as a 23505 unique violation; `claim_pending_build` swallows |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | -- Mark a session row's role so the "log out everywhere" sweep | |
| 2 | 2 | -- (`delete_all_sessions_for_user`) can also catch sessions stuck in the | |
| 3 | - | -- 2FA-pending intermediate state, a phisher who has the password but not | |
| 3 | + | -- 2FA-pending intermediate state — a phisher who has the password but not | |
| 4 | 4 | -- the TOTP code holds an authenticated-pending session that previously was | |
| 5 | 5 | -- session-storage-only and invisible to user_sessions sweeps. | |
| 6 | 6 | ALTER TABLE user_sessions |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | -- Reject negative `duration_seconds` at the DB level. A negative duration cast | |
| 2 | 2 | -- to u64 in `routes/storage/downloads.rs::stream_url` becomes ~u64::MAX, | |
| 3 | - | -- producing a presigned URL with a multi-decade expiry, effectively a | |
| 3 | + | -- producing a presigned URL with a multi-decade expiry — effectively a | |
| 4 | 4 | -- permanent credential leak for the underlying object. The route now clamps | |
| 5 | 5 | -- defensively, but pinning the invariant in the schema means no future | |
| 6 | 6 | -- writer (admin tool, bulk import, manual SQL) can reintroduce it. |
| @@ -3,7 +3,7 @@ | |||
| 3 | 3 | -- The personal RSS feed URL (`/feed/{user_id}?v={version}&sig={hmac}`) is | |
| 4 | 4 | -- authenticated by an HMAC over `feed:{user_id}:{version}`. Bumping this column | |
| 5 | 5 | -- changes the signed message, so the user's previously-issued feed URL stops | |
| 6 | - | -- verifying, a leaked or compromised feed link can be revoked in isolation | |
| 6 | + | -- verifying — a leaked or compromised feed link can be revoked in isolation | |
| 7 | 7 | -- without rotating the global signing secret (which would invalidate EVERY | |
| 8 | 8 | -- user's feed at once). Starts at 0; the "Regenerate feed URL" action in | |
| 9 | 9 | -- dashboard settings increments it. |
| @@ -1,5 +1,5 @@ | |||
| 1 | 1 | -- Personal access tokens for git over HTTPS (Basic auth). The plaintext token | |
| 2 | - | -- is shown once at creation and never persisted, only its SHA-256 hex hash is | |
| 2 | + | -- is shown once at creation and never persisted — only its SHA-256 hex hash is | |
| 3 | 3 | -- stored. `can_push` gates write (receive-pack) access; `expires_at` NULL means | |
| 4 | 4 | -- no expiry. Lookups are by token_hash (UNIQUE → indexed); listing is by user. | |
| 5 | 5 | CREATE TABLE IF NOT EXISTS git_access_tokens ( |