diff --git a/apps/backend/src/lib/stellar/validate-asset-pairs.ts b/apps/backend/src/lib/stellar/validate-asset-pairs.ts index 1b454470..025938a7 100644 --- a/apps/backend/src/lib/stellar/validate-asset-pairs.ts +++ b/apps/backend/src/lib/stellar/validate-asset-pairs.ts @@ -16,6 +16,7 @@ import type { AssetPair, StellarAsset } from '@craft/types'; import type { ValidationError } from '@craft/types'; +import { makeStellarAsset } from '@craft/stellar'; // ── Constants ───────────────────────────────────────────────────────────────── @@ -153,7 +154,32 @@ export function validateAssetPairs(pairs: unknown): ValidationError[] { const pairHasAssetErrors = errors.some(e => e.field.startsWith(pairPrefix)); if (pairHasAssetErrors) continue; - const typedPair = pair as AssetPair; + const rawBase = p['base'] as Record; + const rawCounter = p['counter'] as Record; + const typedPair: AssetPair = { + base: rawBase['type'] === 'native' + ? { + type: 'native', + code: typeof rawBase['code'] === 'string' ? rawBase['code'] : 'XLM', + issuer: '', + } + : makeStellarAsset( + rawBase['type'] as StellarAsset['type'], + rawBase['code'] as string, + rawBase['issuer'] as string, + ), + counter: rawCounter['type'] === 'native' + ? { + type: 'native', + code: typeof rawCounter['code'] === 'string' ? rawCounter['code'] : 'XLM', + issuer: '', + } + : makeStellarAsset( + rawCounter['type'] as StellarAsset['type'], + rawCounter['code'] as string, + rawCounter['issuer'] as string, + ), + }; // Base and counter must differ if (assetKey(typedPair.base) === assetKey(typedPair.counter)) { diff --git a/apps/backend/src/services/github-app-installation.service.ts b/apps/backend/src/services/github-app-installation.service.ts index 43b2ff6e..33314faf 100644 --- a/apps/backend/src/services/github-app-installation.service.ts +++ b/apps/backend/src/services/github-app-installation.service.ts @@ -14,7 +14,7 @@ * via a non-atomic read-modify-write, so handleInstallationRepositoriesAdded * and handleInstallationRepositoriesRemoved use optimistic concurrency * control keyed on the row's `updated_at` (maintained by a DB trigger, - * see supabase/migrations/010_github_app_installations.sql): the update is + * see supabase/migrations/013_github_app_installations.sql): the update is * conditioned on `updated_at` still matching the value read, and a losing * writer retries against the freshly-read state instead of clobbering the * other handler's change. diff --git a/apps/backend/src/services/job-queue.service.ts b/apps/backend/src/services/job-queue.service.ts index 743c55e0..02cb749e 100644 --- a/apps/backend/src/services/job-queue.service.ts +++ b/apps/backend/src/services/job-queue.service.ts @@ -26,7 +26,7 @@ * * Persistence * ──────────── - * All state lives in Supabase (see migration 014_job_queue.sql), so jobs + * All state lives in Supabase (see migration 025_job_queue.sql), so jobs * survive server restarts automatically. * * Design doc properties satisfied: diff --git a/apps/backend/src/services/multi-provider-auth.service.test.ts b/apps/backend/src/services/multi-provider-auth.service.test.ts index e4681eec..b0295ae4 100644 --- a/apps/backend/src/services/multi-provider-auth.service.test.ts +++ b/apps/backend/src/services/multi-provider-auth.service.test.ts @@ -101,7 +101,7 @@ describe('MultiProviderAuthService', () => { // provider_connections, merge in memory, then write the whole object // back — a race between two concurrent requests could silently drop // one side's change. The merge now happens inside a single row-locked - // Postgres function (see supabase/migrations/017_provider_connections_atomic_rpc.sql), + // Postgres function (see supabase/migrations/034_provider_connections_atomic_rpc.sql), // so the client only ever sends its own provider's value, never a // merged snapshot of the whole column. const supabase = makeSupabase({ provider_connections: { someOther: { data: 'value' } } }); diff --git a/apps/backend/src/services/template.service.ts b/apps/backend/src/services/template.service.ts index 3cc4dd8c..d8fbd45b 100644 --- a/apps/backend/src/services/template.service.ts +++ b/apps/backend/src/services/template.service.ts @@ -5,7 +5,7 @@ * * Full-text search (searchTemplates / listTemplates with `q` filter): * - Backed by a Postgres tsvector column on name (weight A), tags (weight B), - * and description (weight C) — see migration 016_template_fulltext_search.sql. + * and description (weight C) — see migration 030_template_fulltext_search.sql. * - Results are ranked by ts_rank (most relevant first). * - Supports: keyword search, phrase search ("liquidity pool"), * prefix search (decentral:*), and category filter combined with text search. diff --git a/apps/backend/src/services/webhook-delivery.service.ts b/apps/backend/src/services/webhook-delivery.service.ts index ff11856d..a101a841 100644 --- a/apps/backend/src/services/webhook-delivery.service.ts +++ b/apps/backend/src/services/webhook-delivery.service.ts @@ -12,7 +12,7 @@ * - Support replay of failed or missed deliveries * - Query deliveries for monitoring and troubleshooting * - * Database schema: supabase/migrations/013_github_webhook_delivery_tracking.sql + * Database schema: supabase/migrations/020_github_webhook_delivery_tracking.sql */ import { createClient } from '@/lib/supabase/server'; diff --git a/docs/admin-ops-dlq.md b/docs/admin-ops-dlq.md index 0db88874..f17a9fa2 100644 --- a/docs/admin-ops-dlq.md +++ b/docs/admin-ops-dlq.md @@ -82,7 +82,7 @@ operators discover the combined view. ``` apps/backend/src/services/job-queue.service.ts (class JobQueueService) -supabase/migrations/014_job_queue.sql (schema) +supabase/migrations/025_job_queue.sql (schema) ``` ### What goes in diff --git a/docs/cross-region-deployment-guide.md b/docs/cross-region-deployment-guide.md index f68794c3..69661386 100644 --- a/docs/cross-region-deployment-guide.md +++ b/docs/cross-region-deployment-guide.md @@ -74,7 +74,7 @@ Or use the SQL migration directly: ```bash # For each region's Supabase project: -psql -h your-region.db.supabase.co -U postgres -d postgres < supabase/migrations/010_auth_audit_logs_cross_region.sql +psql -h your-region.db.supabase.co -U postgres -d postgres < supabase/migrations/011_auth_audit_logs_cross_region.sql ``` ### Step 3: Deploy Edge Functions diff --git a/docs/github-vercel-deployment-triggering.md b/docs/github-vercel-deployment-triggering.md index 87985bbd..7fcd672b 100644 --- a/docs/github-vercel-deployment-triggering.md +++ b/docs/github-vercel-deployment-triggering.md @@ -408,4 +408,4 @@ Logs are structured JSON and can be shipped to your log aggregation service. - `apps/backend/src/lib/github/webhook-verification.ts` - Signature verification - `apps/backend/src/services/github-to-vercel-deployment.service.ts` - Deployment service - `apps/backend/src/services/vercel.service.ts` - Vercel API client -- `supabase/migrations/008_github_vercel_deployments.sql` - Database schema +- `supabase/migrations/009_github_vercel_deployments.sql` - Database schema diff --git a/package.json b/package.json index 91ecc15c..57824271 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,8 @@ "scripts": { "dev": "turbo run dev --filter=@craft/frontend --filter=@craft/backend", "build": "turbo run build", - "test": "turbo run test", - "lint": "turbo run lint", + "test": "node --test scripts/check-migration-numbering.test.js && turbo run test", + "lint": "node scripts/check-migration-numbering.js && turbo run lint", "format": "prettier --write \"**/*.{ts,tsx,md}\"" }, "devDependencies": { diff --git a/packages/stellar/src/index.ts b/packages/stellar/src/index.ts index 000333a1..5abac399 100644 --- a/packages/stellar/src/index.ts +++ b/packages/stellar/src/index.ts @@ -18,6 +18,7 @@ export * from './account-merge-protection'; export * from './asset-compliance'; export * from './circuit-breaker'; export * from './horizon-client'; +export * from './stellar-asset'; export * from './fixtures/strkey-addresses'; export * from './abi-binding-generator'; export * from './asset-auth'; diff --git a/packages/stellar/src/stellar-asset.test.ts b/packages/stellar/src/stellar-asset.test.ts new file mode 100644 index 00000000..b0bcce08 --- /dev/null +++ b/packages/stellar/src/stellar-asset.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { makeStellarAsset } from './stellar-asset'; + +describe('makeStellarAsset', () => { + it('constructs valid native and credit assets', () => { + expect(makeStellarAsset('native', 'XLM', '')).toEqual({ type: 'native', code: 'XLM', issuer: '' }); + expect(makeStellarAsset('credit_alphanum4', 'USDC', 'GISSUER')).toEqual({ + type: 'credit_alphanum4', code: 'USDC', issuer: 'GISSUER', + }); + expect(makeStellarAsset('credit_alphanum12', 'LONGCOIN', 'GISSUER')).toEqual({ + type: 'credit_alphanum12', code: 'LONGCOIN', issuer: 'GISSUER', + }); + }); + + it('rejects a code/type length mismatch with a clear error', () => { + expect(() => makeStellarAsset('credit_alphanum4', 'TOOLONG', 'GISSUER')) + .toThrow('credit_alphanum4 asset codes must be 1-4 characters.'); + expect(() => makeStellarAsset('credit_alphanum12', 'USDC', 'GISSUER')) + .toThrow('credit_alphanum12 asset codes must be 5-12 characters.'); + }); +}); \ No newline at end of file diff --git a/packages/stellar/src/stellar-asset.ts b/packages/stellar/src/stellar-asset.ts new file mode 100644 index 00000000..f6b8207e --- /dev/null +++ b/packages/stellar/src/stellar-asset.ts @@ -0,0 +1,31 @@ +import type { StellarAsset, StellarAssetType } from '@craft/types'; + +/** Create a Stellar asset after enforcing its code/type length invariant. */ +export function makeStellarAsset( + type: StellarAssetType, + code: string, + issuer: string, +): StellarAsset { + if (type !== 'native' && type !== 'credit_alphanum4' && type !== 'credit_alphanum12') { + throw new Error('Stellar asset type must be native, credit_alphanum4, or credit_alphanum12.'); + } + + if (type === 'native') { + if (code !== 'XLM' || issuer !== '') { + throw new Error('A native Stellar asset must use code XLM and an empty issuer.'); + } + return { type, code, issuer }; + } + + if (!/^[A-Z0-9]{1,12}$/.test(code)) { + throw new Error('A credit Stellar asset code must be 1-12 uppercase alphanumeric characters.'); + } + if (type === 'credit_alphanum4' && code.length > 4) { + throw new Error('credit_alphanum4 asset codes must be 1-4 characters.'); + } + if (type === 'credit_alphanum12' && code.length < 5) { + throw new Error('credit_alphanum12 asset codes must be 5-12 characters.'); + } + + return { type, code, issuer }; +} \ No newline at end of file diff --git a/packages/stellar/src/upgrade-orchestrator.test.ts b/packages/stellar/src/upgrade-orchestrator.test.ts index b141fe19..3a3e762a 100644 --- a/packages/stellar/src/upgrade-orchestrator.test.ts +++ b/packages/stellar/src/upgrade-orchestrator.test.ts @@ -3,6 +3,7 @@ import type { ContractAbiSchema } from './upgrade-orchestrator'; const mocks = vi.hoisted(() => ({ mockSimulate: vi.fn(), + mockSubmitTransaction: vi.fn(), mockSend: vi.fn(), mockFromXDR: vi.fn(), })); @@ -353,6 +354,8 @@ describe('orchestrateContractUpgrade', () => { expect(result.dryRun).toBe(false); expect(result.diffReport.safe).toBe(true); } + expect(mocks.mockSimulate).not.toHaveBeenCalled(); + expect(mocks.mockSubmitTransaction).not.toHaveBeenCalled(); }); it('blocks upgrade when breaking changes are detected', async () => { @@ -419,6 +422,7 @@ describe('orchestrateContractUpgrade', () => { 'Test SDF Network ; September 2015', ); expect(mocks.mockSimulate).toHaveBeenCalledWith(mockTx); + expect(mocks.mockSubmitTransaction).not.toHaveBeenCalled(); }); it('handles simulation errors gracefully', async () => { diff --git a/scripts/check-migration-numbering.js b/scripts/check-migration-numbering.js new file mode 100644 index 00000000..1d274b77 --- /dev/null +++ b/scripts/check-migration-numbering.js @@ -0,0 +1,32 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +function findDuplicateMigrationPrefixes(directory) { + const migrations = new Map(); + for (const filename of fs.readdirSync(directory)) { + const match = /^(\d+)_.*\.sql$/.exec(filename); + if (!match) continue; + const files = migrations.get(match[1]) ?? []; + files.push(filename); + migrations.set(match[1], files); + } + return [...migrations.entries()].filter(([, files]) => files.length > 1); +} + +function checkMigrationNumbering(directory) { + const duplicates = findDuplicateMigrationPrefixes(directory); + if (duplicates.length === 0) return true; + + console.error('Duplicate Supabase migration number prefixes:'); + for (const [prefix, files] of duplicates) { + console.error(` ${prefix}: ${files.join(', ')}`); + } + return false; +} + +if (require.main === module) { + const migrationDirectory = path.join(__dirname, '..', 'supabase', 'migrations'); + if (!checkMigrationNumbering(migrationDirectory)) process.exitCode = 1; +} + +module.exports = { checkMigrationNumbering, findDuplicateMigrationPrefixes }; \ No newline at end of file diff --git a/scripts/check-migration-numbering.test.js b/scripts/check-migration-numbering.test.js new file mode 100644 index 00000000..8679a136 --- /dev/null +++ b/scripts/check-migration-numbering.test.js @@ -0,0 +1,35 @@ +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { afterEach, describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { findDuplicateMigrationPrefixes } = require('./check-migration-numbering'); + +const fixtureDirectories = []; + +function makeFixture(filenames) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'migration-numbering-')); + fixtureDirectories.push(directory); + for (const filename of filenames) fs.writeFileSync(path.join(directory, filename), ''); + return directory; +} + +afterEach(() => { + for (const directory of fixtureDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +describe('findDuplicateMigrationPrefixes', () => { + it('returns no groups when migration numbers are unique', () => { + const directory = makeFixture(['001_initial.sql', '002_next.sql']); + assert.deepEqual(findDuplicateMigrationPrefixes(directory), []); + }); + + it('lists every file in each duplicate prefix group', () => { + const directory = makeFixture(['014_first.sql', '014_second.sql', '015_next.sql']); + assert.deepEqual(findDuplicateMigrationPrefixes(directory), [ + ['014', ['014_first.sql', '014_second.sql']], + ]); + }); +}); \ No newline at end of file diff --git a/supabase/migrations/006_secure_token_storage.sql b/supabase/migrations/006_secure_token_storage.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/007_field_encryption_stripe.sql b/supabase/migrations/007_field_encryption_stripe.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/007_secure_token_storage.sql b/supabase/migrations/007_secure_token_storage.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/008_field_encryption_stripe.sql b/supabase/migrations/008_field_encryption_stripe.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/008_github_vercel_deployments.sql b/supabase/migrations/008_github_vercel_deployments.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/009_deployment_updates_rollout.sql b/supabase/migrations/009_deployment_updates_rollout.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/009_github_vercel_deployments.sql b/supabase/migrations/009_github_vercel_deployments.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/010_auth_audit_logs_cross_region.sql b/supabase/migrations/010_auth_audit_logs_cross_region.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/010_deployment_soft_delete.sql b/supabase/migrations/010_deployment_soft_delete.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/010_deployment_updates_rollout.sql b/supabase/migrations/010_deployment_updates_rollout.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/010_github_app_installations.sql b/supabase/migrations/010_github_app_installations.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/010_vercel_blue_green_aliases.sql b/supabase/migrations/010_vercel_blue_green_aliases.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/011_analytics_query_optimization.sql b/supabase/migrations/011_analytics_query_optimization.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/011_auth_audit_logs_cross_region.sql b/supabase/migrations/011_auth_audit_logs_cross_region.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/011_branding_asset_storage_policy.sql b/supabase/migrations/011_branding_asset_storage_policy.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/011_metered_billing_usage_tracking.sql b/supabase/migrations/011_metered_billing_usage_tracking.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/012_deployment_soft_delete.sql b/supabase/migrations/012_deployment_soft_delete.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/012_multi_provider_oauth.sql b/supabase/migrations/012_multi_provider_oauth.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/012_pgcron_automated_cleanup.sql b/supabase/migrations/012_pgcron_automated_cleanup.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/013_github_app_installations.sql b/supabase/migrations/013_github_app_installations.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/013_github_webhook_delivery_tracking.sql b/supabase/migrations/013_github_webhook_delivery_tracking.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/014_artifact_storage_namespace.sql b/supabase/migrations/014_artifact_storage_namespace.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/014_cron_checkpoints.sql b/supabase/migrations/014_cron_checkpoints.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/014_cron_job_failures.sql b/supabase/migrations/014_cron_job_failures.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/014_email_deliveries.sql b/supabase/migrations/014_email_deliveries.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/014_job_queue.sql b/supabase/migrations/014_job_queue.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/014_orphaned_artifact_cleanup_audit.sql b/supabase/migrations/014_orphaned_artifact_cleanup_audit.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/014_region_latency_cache.sql b/supabase/migrations/014_region_latency_cache.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/014_vercel_blue_green_aliases.sql b/supabase/migrations/014_vercel_blue_green_aliases.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/015_analytics_query_optimization.sql b/supabase/migrations/015_analytics_query_optimization.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/015_job_queue_claim_rpc.sql b/supabase/migrations/015_job_queue_claim_rpc.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/016_analytics_rollups.sql b/supabase/migrations/016_analytics_rollups.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/016_branding_asset_storage_policy.sql b/supabase/migrations/016_branding_asset_storage_policy.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/016_template_fulltext_search.sql b/supabase/migrations/016_template_fulltext_search.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/017_atomic_provider_connections.sql b/supabase/migrations/017_atomic_provider_connections.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/017_cron_failure_atomic_increment.sql b/supabase/migrations/017_cron_failure_atomic_increment.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/017_cron_job_failure_increment_rpc.sql b/supabase/migrations/017_cron_job_failure_increment_rpc.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/017_metered_billing_usage_tracking.sql b/supabase/migrations/017_metered_billing_usage_tracking.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/017_provider_connections_atomic_rpc.sql b/supabase/migrations/017_provider_connections_atomic_rpc.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/018_atomic_deployment_build_cache_hash.sql b/supabase/migrations/018_atomic_deployment_build_cache_hash.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/018_multi_provider_oauth.sql b/supabase/migrations/018_multi_provider_oauth.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/019_fix_auth_audit_logs_rls.sql b/supabase/migrations/019_fix_auth_audit_logs_rls.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/019_pgcron_automated_cleanup.sql b/supabase/migrations/019_pgcron_automated_cleanup.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/020_fee_bump_usage_records.sql b/supabase/migrations/020_fee_bump_usage_records.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/020_github_webhook_delivery_tracking.sql b/supabase/migrations/020_github_webhook_delivery_tracking.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/021_artifact_storage_namespace.sql b/supabase/migrations/021_artifact_storage_namespace.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/021_payment_idempotency_atomic_key_generation.sql b/supabase/migrations/021_payment_idempotency_atomic_key_generation.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/021_usage_records_atomic_increment_rpc.sql b/supabase/migrations/021_usage_records_atomic_increment_rpc.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/022_cron_checkpoints.sql b/supabase/migrations/022_cron_checkpoints.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/023_cron_job_failures.sql b/supabase/migrations/023_cron_job_failures.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/024_email_deliveries.sql b/supabase/migrations/024_email_deliveries.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/025_job_queue.sql b/supabase/migrations/025_job_queue.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/026_orphaned_artifact_cleanup_audit.sql b/supabase/migrations/026_orphaned_artifact_cleanup_audit.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/027_region_latency_cache.sql b/supabase/migrations/027_region_latency_cache.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/028_job_queue_claim_rpc.sql b/supabase/migrations/028_job_queue_claim_rpc.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/029_analytics_rollups.sql b/supabase/migrations/029_analytics_rollups.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/030_template_fulltext_search.sql b/supabase/migrations/030_template_fulltext_search.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/031_atomic_provider_connections.sql b/supabase/migrations/031_atomic_provider_connections.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/032_cron_failure_atomic_increment.sql b/supabase/migrations/032_cron_failure_atomic_increment.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/033_cron_job_failure_increment_rpc.sql b/supabase/migrations/033_cron_job_failure_increment_rpc.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/034_provider_connections_atomic_rpc.sql b/supabase/migrations/034_provider_connections_atomic_rpc.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/035_atomic_deployment_build_cache_hash.sql b/supabase/migrations/035_atomic_deployment_build_cache_hash.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/036_dlq_reprocess_atomicity.sql b/supabase/migrations/036_dlq_reprocess_atomicity.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/037_fix_auth_audit_logs_rls.sql b/supabase/migrations/037_fix_auth_audit_logs_rls.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/038_fee_bump_usage_records.sql b/supabase/migrations/038_fee_bump_usage_records.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/039_payment_idempotency_atomic_key_generation.sql b/supabase/migrations/039_payment_idempotency_atomic_key_generation.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/040_usage_records_atomic_increment_rpc.sql b/supabase/migrations/040_usage_records_atomic_increment_rpc.sql new file mode 100644 index 00000000..e69de29b diff --git a/supabase/migrations/20260926000007_secure_token_storage.sql b/supabase/migrations/20260926000007_secure_token_storage.sql index 52846768..e69de29b 100644 --- a/supabase/migrations/20260926000007_secure_token_storage.sql +++ b/supabase/migrations/20260926000007_secure_token_storage.sql @@ -1,42 +0,0 @@ --- Migration 006: secure token storage hardening (#236) --- --- Context: --- github_token_encrypted already stores AES-256-GCM ciphertext (added in 001). --- github_token_expires_at / github_token_refreshed_at added in 004. --- --- This migration: --- 1. Adds an index to speed up the nightly expired-token cleanup cron --- (WHERE github_token_expires_at < now() AND github_token_expires_at IS NOT NULL). --- 2. Adds a CHECK constraint that prevents storing a value in --- github_token_encrypted that looks like a raw GitHub token --- (prefix ghu_, ghp_, gho_, ghs_, ghr_). This is a defence-in-depth --- guard; the application layer must always encrypt before writing. --- --- Key management assumptions (out of scope for this migration): --- - The encryption key is read from GITHUB_TOKEN_ENCRYPTION_KEY (env var). --- - Key rotation (re-encrypting rows with a new key) requires a separate --- migration and application-layer support for key versioning. --- - HSM / KMS integration is a follow-up item. - --- Index for the cleanup cron query -CREATE INDEX IF NOT EXISTS idx_profiles_github_token_expires_at - ON profiles (github_token_expires_at) - WHERE github_token_expires_at IS NOT NULL; - --- Prevent accidental plaintext token storage --- GitHub token prefixes: https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/about-authentication-to-github -ALTER TABLE profiles - ADD CONSTRAINT profiles_github_token_not_plaintext - CHECK ( - github_token_encrypted IS NULL - OR ( - github_token_encrypted NOT LIKE 'ghu\_%' ESCAPE '\' - AND github_token_encrypted NOT LIKE 'ghp\_%' ESCAPE '\' - AND github_token_encrypted NOT LIKE 'gho\_%' ESCAPE '\' - AND github_token_encrypted NOT LIKE 'ghs\_%' ESCAPE '\' - AND github_token_encrypted NOT LIKE 'ghr\_%' ESCAPE '\' - ) - ); - --- rollback: ALTER TABLE profiles DROP CONSTRAINT IF EXISTS profiles_github_token_not_plaintext; --- rollback: DROP INDEX IF EXISTS idx_profiles_github_token_expires_at; diff --git a/supabase/migrations/20260926000008_field_encryption_stripe.sql b/supabase/migrations/20260926000008_field_encryption_stripe.sql index cc00663d..e69de29b 100644 --- a/supabase/migrations/20260926000008_field_encryption_stripe.sql +++ b/supabase/migrations/20260926000008_field_encryption_stripe.sql @@ -1,52 +0,0 @@ --- Migration 007: field-level encryption for Stripe credential columns (#234) --- --- Context: --- github_token_encrypted already uses AES-256-GCM (lib/github/token-encryption.ts). --- This migration extends field-level encryption to the remaining sensitive --- columns in the profiles table: --- - stripe_customer_id → stripe_customer_id_encrypted --- - stripe_subscription_id → stripe_subscription_id_encrypted --- --- Stripe customer/subscription IDs are not secret keys, but they are --- third-party identifiers that uniquely link a user to their billing account. --- Encrypting them limits exposure in the event of a DB read compromise. --- --- Migration strategy (zero-downtime): --- 1. Add new _encrypted columns (nullable). --- 2. The application layer writes to the new columns going forward. --- 3. Run the key-rotation utility (lib/crypto/key-rotation.ts) to --- back-fill any existing rows that were written before this migration. --- 4. Once all rows are migrated, the old plaintext columns can be dropped --- in a follow-up migration after verifying no reads target them. --- --- Encryption format: v... --- Key: FIELD_ENCRYPTION_KEY environment variable (64-char hex / 32 bytes). --- --- Key management assumptions (out of scope): --- - Key rotation uses FIELD_ENCRYPTION_KEY_ versioned env vars. --- - HSM / KMS integration is a follow-up item. - -ALTER TABLE profiles - ADD COLUMN IF NOT EXISTS stripe_customer_id_encrypted TEXT, - ADD COLUMN IF NOT EXISTS stripe_subscription_id_encrypted TEXT; - --- Prevent accidental plaintext Stripe ID storage. --- Stripe customer IDs start with "cus_", subscription IDs with "sub_". -ALTER TABLE profiles - ADD CONSTRAINT profiles_stripe_customer_not_plaintext - CHECK ( - stripe_customer_id_encrypted IS NULL - OR stripe_customer_id_encrypted NOT LIKE 'cus\_%' ESCAPE '\' - ); - -ALTER TABLE profiles - ADD CONSTRAINT profiles_stripe_subscription_not_plaintext - CHECK ( - stripe_subscription_id_encrypted IS NULL - OR stripe_subscription_id_encrypted NOT LIKE 'sub\_%' ESCAPE '\' - ); - --- rollback: ALTER TABLE profiles DROP CONSTRAINT IF EXISTS profiles_stripe_subscription_not_plaintext; --- rollback: ALTER TABLE profiles DROP CONSTRAINT IF EXISTS profiles_stripe_customer_not_plaintext; --- rollback: ALTER TABLE profiles DROP COLUMN IF EXISTS stripe_subscription_id_encrypted; --- rollback: ALTER TABLE profiles DROP COLUMN IF EXISTS stripe_customer_id_encrypted; diff --git a/supabase/migrations/20260926000009_github_vercel_deployments.sql b/supabase/migrations/20260926000009_github_vercel_deployments.sql index 5f48e135..e69de29b 100644 --- a/supabase/migrations/20260926000009_github_vercel_deployments.sql +++ b/supabase/migrations/20260926000009_github_vercel_deployments.sql @@ -1,60 +0,0 @@ --- GitHub → Vercel Deployment Tracking Table --- Stores deployment metadata for GitHub webhook-triggered Vercel deployments --- Used for tracking deployment status and providing observability - -CREATE TABLE IF NOT EXISTS github_vercel_deployments ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - repo_full_name TEXT NOT NULL, - repo_name TEXT NOT NULL, - branch TEXT NOT NULL, - commit_sha TEXT NOT NULL, - commit_message TEXT, - pusher_name TEXT, - vercel_deployment_id TEXT NOT NULL UNIQUE, - vercel_deployment_url TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'queued' CHECK ( - status IN ('queued', 'building', 'ready', 'error', 'failed', 'canceled') - ), - created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL -); - --- Indexes for performance -CREATE INDEX idx_github_vercel_deployments_repo_full_name ON github_vercel_deployments(repo_full_name); -CREATE INDEX idx_github_vercel_deployments_vercel_deployment_id ON github_vercel_deployments(vercel_deployment_id); -CREATE INDEX idx_github_vercel_deployments_status ON github_vercel_deployments(status); -CREATE INDEX idx_github_vercel_deployments_created_at ON github_vercel_deployments(created_at); - --- Updated_at trigger -CREATE TRIGGER update_github_vercel_deployments_updated_at - BEFORE UPDATE ON github_vercel_deployments - FOR EACH ROW - EXECUTE FUNCTION update_updated_at_column(); - --- Row Level Security -ALTER TABLE github_vercel_deployments ENABLE ROW LEVEL SECURITY; - --- Allow service role to read/write (for webhook handler) -CREATE POLICY "Service role can manage github_vercel_deployments" - ON github_vercel_deployments - FOR ALL - TO service_role - USING (true) - WITH CHECK (true); - --- Allow authenticated users to read deployments -CREATE POLICY "Authenticated users can read github_vercel_deployments" - ON github_vercel_deployments - FOR SELECT - TO authenticated - USING (true); - --- rollback: DROP POLICY IF EXISTS "Authenticated users can read github_vercel_deployments" ON github_vercel_deployments; --- rollback: DROP POLICY IF EXISTS "Service role can manage github_vercel_deployments" ON github_vercel_deployments; --- rollback: ALTER TABLE github_vercel_deployments DISABLE ROW LEVEL SECURITY; --- rollback: DROP TRIGGER IF EXISTS update_github_vercel_deployments_updated_at ON github_vercel_deployments; --- rollback: DROP INDEX IF EXISTS idx_github_vercel_deployments_created_at; --- rollback: DROP INDEX IF EXISTS idx_github_vercel_deployments_status; --- rollback: DROP INDEX IF EXISTS idx_github_vercel_deployments_vercel_deployment_id; --- rollback: DROP INDEX IF EXISTS idx_github_vercel_deployments_repo_full_name; --- rollback: DROP TABLE IF EXISTS github_vercel_deployments; diff --git a/supabase/migrations/20260926000010_deployment_updates_rollout.sql b/supabase/migrations/20260926000010_deployment_updates_rollout.sql index 96fb93a7..e69de29b 100644 --- a/supabase/migrations/20260926000010_deployment_updates_rollout.sql +++ b/supabase/migrations/20260926000010_deployment_updates_rollout.sql @@ -1,81 +0,0 @@ -CREATE TABLE IF NOT EXISTS deployment_updates ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - deployment_id UUID NOT NULL REFERENCES deployments(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, - new_customization_config JSONB NOT NULL, - previous_state JSONB, - status TEXT NOT NULL DEFAULT 'pending' CHECK ( - status IN ( - 'pending', - 'validating', - 'generating', - 'updating_repo', - 'redeploying', - 'completed', - 'rolled_back', - 'failed' - ) - ), - canary_percent INTEGER NOT NULL DEFAULT 0 CHECK (canary_percent BETWEEN 0 AND 100), - error_message TEXT, - created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, - completed_at TIMESTAMPTZ -); - -ALTER TABLE deployment_updates - ADD COLUMN IF NOT EXISTS canary_percent INTEGER NOT NULL DEFAULT 0; - -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 - FROM pg_constraint - WHERE conname = 'deployment_updates_canary_percent_check' - ) THEN - ALTER TABLE deployment_updates - ADD CONSTRAINT deployment_updates_canary_percent_check - CHECK (canary_percent BETWEEN 0 AND 100); - END IF; -END $$; - -CREATE INDEX IF NOT EXISTS idx_deployment_updates_deployment_id - ON deployment_updates(deployment_id); - -CREATE INDEX IF NOT EXISTS idx_deployment_updates_status - ON deployment_updates(status); - -DROP TRIGGER IF EXISTS update_deployment_updates_updated_at ON deployment_updates; -CREATE TRIGGER update_deployment_updates_updated_at - BEFORE UPDATE ON deployment_updates - FOR EACH ROW - EXECUTE FUNCTION update_updated_at_column(); - -ALTER TABLE deployment_updates ENABLE ROW LEVEL SECURITY; - -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 - FROM pg_policies - WHERE schemaname = 'public' - AND tablename = 'deployment_updates' - AND policyname = 'Users can manage their own deployment updates' - ) THEN - CREATE POLICY "Users can manage their own deployment updates" - ON deployment_updates - FOR ALL - TO authenticated - USING (auth.uid() = user_id) - WITH CHECK (auth.uid() = user_id); - END IF; -END $$; - --- rollback: DROP POLICY IF EXISTS "Users can manage their own deployment updates" ON deployment_updates; --- rollback: ALTER TABLE deployment_updates DISABLE ROW LEVEL SECURITY; --- rollback: DROP TRIGGER IF EXISTS update_deployment_updates_updated_at ON deployment_updates; --- rollback: DROP INDEX IF EXISTS idx_deployment_updates_status; --- rollback: DROP INDEX IF EXISTS idx_deployment_updates_deployment_id; --- rollback: ALTER TABLE deployment_updates DROP CONSTRAINT IF EXISTS deployment_updates_canary_percent_check; --- rollback: ALTER TABLE deployment_updates DROP COLUMN IF EXISTS canary_percent; --- rollback: DROP TABLE IF EXISTS deployment_updates; diff --git a/supabase/migrations/20260926000011_auth_audit_logs_cross_region.sql b/supabase/migrations/20260926000011_auth_audit_logs_cross_region.sql index 6969af41..e69de29b 100644 --- a/supabase/migrations/20260926000011_auth_audit_logs_cross_region.sql +++ b/supabase/migrations/20260926000011_auth_audit_logs_cross_region.sql @@ -1,55 +0,0 @@ --- Create auth_audit_logs table for cross-region audit trail --- Tracks authentication events across all regional deployments - -CREATE TABLE IF NOT EXISTS auth_audit_logs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, - event_type TEXT NOT NULL CHECK (event_type IN ('signin', 'signup', 'refresh', 'logout', 'failure')), - region TEXT NOT NULL CHECK (region IN ('us-east', 'eu-west', 'ap-southeast')), - request_id TEXT UNIQUE NOT NULL, - details JSONB, - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() -); - --- Create indexes for efficient querying -CREATE INDEX IF NOT EXISTS idx_auth_audit_logs_user_id ON auth_audit_logs(user_id); -CREATE INDEX IF NOT EXISTS idx_auth_audit_logs_region ON auth_audit_logs(region); -CREATE INDEX IF NOT EXISTS idx_auth_audit_logs_event_type ON auth_audit_logs(event_type); -CREATE INDEX IF NOT EXISTS idx_auth_audit_logs_created_at ON auth_audit_logs(created_at); -CREATE INDEX IF NOT EXISTS idx_auth_audit_logs_user_created ON auth_audit_logs(user_id, created_at); -CREATE INDEX IF NOT EXISTS idx_auth_audit_logs_request_id ON auth_audit_logs(request_id); - --- Enable row level security -ALTER TABLE auth_audit_logs ENABLE ROW LEVEL SECURITY; - --- RLS Policy: Users can only view their own audit logs -CREATE POLICY "auth_audit_logs_user_policy" ON auth_audit_logs - FOR SELECT USING ( - auth.uid() = user_id OR - EXISTS ( - SELECT 1 FROM profiles - WHERE profiles.id = auth.uid() - AND profiles.subscription_tier IN ('premium', 'enterprise') - ) - ); - --- RLS Policy: Service role can insert audit logs -CREATE POLICY "auth_audit_logs_insert_policy" ON auth_audit_logs - FOR INSERT WITH CHECK ( - auth.role() = 'authenticated' OR - auth.role() = 'service_role' - ); - --- Comment for documentation -COMMENT ON TABLE auth_audit_logs IS 'Cross-region authentication audit trail. Tracks all auth events (signin, signup, token refresh, failures) across regional deployments for security monitoring and state consistency verification.'; - --- rollback: DROP POLICY IF EXISTS auth_audit_logs_insert_policy ON auth_audit_logs; --- rollback: DROP POLICY IF EXISTS auth_audit_logs_user_policy ON auth_audit_logs; --- rollback: ALTER TABLE auth_audit_logs DISABLE ROW LEVEL SECURITY; --- rollback: DROP INDEX IF EXISTS idx_auth_audit_logs_request_id; --- rollback: DROP INDEX IF EXISTS idx_auth_audit_logs_user_created; --- rollback: DROP INDEX IF EXISTS idx_auth_audit_logs_created_at; --- rollback: DROP INDEX IF EXISTS idx_auth_audit_logs_event_type; --- rollback: DROP INDEX IF EXISTS idx_auth_audit_logs_region; --- rollback: DROP INDEX IF EXISTS idx_auth_audit_logs_user_id; --- rollback: DROP TABLE IF EXISTS auth_audit_logs; diff --git a/supabase/migrations/20260926000012_deployment_soft_delete.sql b/supabase/migrations/20260926000012_deployment_soft_delete.sql index 0c153940..e69de29b 100644 --- a/supabase/migrations/20260926000012_deployment_soft_delete.sql +++ b/supabase/migrations/20260926000012_deployment_soft_delete.sql @@ -1,23 +0,0 @@ --- Migration 010: Soft-Delete Tombstone for Deployments --- --- Adds a deleted_at tombstone column so that DELETE operations archive the record --- instead of hard-deleting it. Records are excluded from default queries via the --- application layer (is('deleted_at', null)). A scheduled purge permanently removes --- tombstoned records after the retention window (DEPLOYMENT_TOMBSTONE_RETENTION_DAYS, --- default 30). - -ALTER TABLE deployments ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; - --- Efficient filter for the common "active deployments" query pattern -CREATE INDEX IF NOT EXISTS idx_deployments_user_active - ON deployments(user_id, deleted_at) - WHERE deleted_at IS NULL; - --- Efficient purge scan: find all tombstoned records older than the cutoff -CREATE INDEX IF NOT EXISTS idx_deployments_deleted_at - ON deployments(deleted_at) - WHERE deleted_at IS NOT NULL; - --- rollback: DROP INDEX IF EXISTS idx_deployments_deleted_at; --- rollback: DROP INDEX IF EXISTS idx_deployments_user_active; --- rollback: ALTER TABLE deployments DROP COLUMN IF EXISTS deleted_at; diff --git a/supabase/migrations/20260926000013_github_app_installations.sql b/supabase/migrations/20260926000013_github_app_installations.sql index d081ce2d..e69de29b 100644 --- a/supabase/migrations/20260926000013_github_app_installations.sql +++ b/supabase/migrations/20260926000013_github_app_installations.sql @@ -1,29 +0,0 @@ --- GitHub App installations table -CREATE TABLE IF NOT EXISTS github_app_installations ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - installation_id BIGINT NOT NULL UNIQUE, - app_id BIGINT NOT NULL, - account_login TEXT NOT NULL, - account_type TEXT NOT NULL CHECK (account_type IN ('User', 'Organization')), - account_id BIGINT NOT NULL, - repositories JSONB NOT NULL DEFAULT '[]'::jsonb, - organizations JSONB NOT NULL DEFAULT '[]'::jsonb, - created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, - deleted_at TIMESTAMPTZ -); - --- Index for fast lookups by installation_id -CREATE INDEX idx_github_app_installations_installation_id ON github_app_installations(installation_id); -CREATE INDEX idx_github_app_installations_account_login ON github_app_installations(account_login); -CREATE INDEX idx_github_app_installations_deleted_at ON github_app_installations(deleted_at); - --- Apply updated_at trigger -CREATE TRIGGER update_github_app_installations_updated_at BEFORE -UPDATE ON github_app_installations FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - --- rollback: DROP INDEX IF EXISTS idx_github_app_installations_deleted_at; --- rollback: DROP INDEX IF EXISTS idx_github_app_installations_account_login; --- rollback: DROP INDEX IF EXISTS idx_github_app_installations_installation_id; --- rollback: DROP TRIGGER IF EXISTS update_github_app_installations_updated_at ON github_app_installations; --- rollback: DROP TABLE IF EXISTS github_app_installations; diff --git a/supabase/migrations/20260926000014_vercel_blue_green_aliases.sql b/supabase/migrations/20260926000014_vercel_blue_green_aliases.sql index e5551d9d..e69de29b 100644 --- a/supabase/migrations/20260926000014_vercel_blue_green_aliases.sql +++ b/supabase/migrations/20260926000014_vercel_blue_green_aliases.sql @@ -1,29 +0,0 @@ --- Vercel Blue-Green Alias Promotion Strategy --- --- Stores staging and production alias assignments for Vercel projects. --- Enables atomic promotion of staging deployments to production with rollback capability. --- --- Schema: --- staging_deployment_id: ID of the current staging deployment --- production_deployment_id: ID of the current production deployment --- previous_production_deployment_id: ID of the previous production deployment (for rollback) --- --- Access rules: --- Service role can read/write (for deployment orchestration) --- Authenticated users can read their own projects' alias state - -ALTER TABLE deployments ADD COLUMN IF NOT EXISTS staging_deployment_id TEXT; -ALTER TABLE deployments ADD COLUMN IF NOT EXISTS production_deployment_id TEXT; -ALTER TABLE deployments ADD COLUMN IF NOT EXISTS previous_production_deployment_id TEXT; - --- Index for quick lookup of deployments by alias state -CREATE INDEX IF NOT EXISTS idx_deployments_staging_deployment_id ON deployments(staging_deployment_id); -CREATE INDEX IF NOT EXISTS idx_deployments_production_deployment_id ON deployments(production_deployment_id); -CREATE INDEX IF NOT EXISTS idx_deployments_previous_production_deployment_id ON deployments(previous_production_deployment_id); - --- rollback: DROP INDEX IF EXISTS idx_deployments_previous_production_deployment_id; --- rollback: DROP INDEX IF EXISTS idx_deployments_production_deployment_id; --- rollback: DROP INDEX IF EXISTS idx_deployments_staging_deployment_id; --- rollback: ALTER TABLE deployments DROP COLUMN IF EXISTS previous_production_deployment_id; --- rollback: ALTER TABLE deployments DROP COLUMN IF EXISTS production_deployment_id; --- rollback: ALTER TABLE deployments DROP COLUMN IF EXISTS staging_deployment_id; diff --git a/supabase/migrations/20260926000015_analytics_query_optimization.sql b/supabase/migrations/20260926000015_analytics_query_optimization.sql index 204173ab..e69de29b 100644 --- a/supabase/migrations/20260926000015_analytics_query_optimization.sql +++ b/supabase/migrations/20260926000015_analytics_query_optimization.sql @@ -1,44 +0,0 @@ --- Migration 011: Analytics Query Optimization --- --- Adds composite indexes to accelerate the deployment analytics aggregation --- queries and replaces the previous N+1 per-metric-type scan pattern with a --- single GROUP BY aggregation via a SECURITY DEFINER function callable through --- the Supabase RPC interface. - --- Composite index: covers the GROUP BY aggregation query (deployment_id, metric_type) --- and the metric_value column used in uptime percentage calculation. -CREATE INDEX IF NOT EXISTS idx_analytics_deployment_metric - ON deployment_analytics(deployment_id, metric_type, metric_value); - --- Index for time-range filtered queries (used by getAnalytics with date bounds). -CREATE INDEX IF NOT EXISTS idx_analytics_deployment_recorded_at - ON deployment_analytics(deployment_id, recorded_at DESC); - --- Aggregate function: replaces the three per-metric-type SELECT queries in --- getAnalyticsSummary with a single pass over the table. -CREATE OR REPLACE FUNCTION get_analytics_summary(p_deployment_id UUID) -RETURNS TABLE( - metric_type TEXT, - total_value NUMERIC, - record_count BIGINT, - up_count BIGINT, - latest_recorded TIMESTAMPTZ -) -LANGUAGE SQL -STABLE -SECURITY DEFINER -AS $$ - SELECT - metric_type, - SUM(metric_value) AS total_value, - COUNT(*) AS record_count, - COUNT(*) FILTER (WHERE metric_value = 1) AS up_count, - MAX(recorded_at) AS latest_recorded - FROM deployment_analytics - WHERE deployment_id = p_deployment_id - GROUP BY metric_type; -$$; - --- rollback: DROP FUNCTION IF EXISTS get_analytics_summary; --- rollback: DROP INDEX IF EXISTS idx_analytics_deployment_recorded_at; --- rollback: DROP INDEX IF EXISTS idx_analytics_deployment_metric; diff --git a/supabase/migrations/20260926000016_branding_asset_storage_policy.sql b/supabase/migrations/20260926000016_branding_asset_storage_policy.sql index 84b10764..e69de29b 100644 --- a/supabase/migrations/20260926000016_branding_asset_storage_policy.sql +++ b/supabase/migrations/20260926000016_branding_asset_storage_policy.sql @@ -1,48 +0,0 @@ --- Supabase Storage Bucket Policy for Branding Assets --- --- Creates the "branding_assets" bucket with Row Level Security (RLS) policies. --- --- Path-based namespacing scheme: --- User uploads are stored at: {user_id}/{asset_name} --- Only users can read/write their own {user_id} namespace. --- Cross-user access is denied by RLS policies. --- --- Access rules: --- - INSERT: User can upload only to paths starting with their own user_id --- - SELECT: User can read only from paths starting with their own user_id --- - UPDATE/DELETE: Not allowed via policy --- --- Storage operations always validate the path against the authenticated user's ID --- before hitting the storage layer, providing defense-in-depth security. - -INSERT INTO storage.buckets (id, name, public) VALUES ('branding_assets', 'branding_assets', false) ON CONFLICT (id) DO NOTHING; - --- Policy: Allow authenticated users to upload to their own user_id namespace -CREATE POLICY "Users can upload to own branding namespace" - ON storage.objects FOR INSERT - WITH CHECK ( - bucket_id = 'branding_assets' - AND (storage.foldername(name))[1] = auth.uid()::text - ); - --- Policy: Allow authenticated users to read from their own user_id namespace -CREATE POLICY "Users can read own branding assets" - ON storage.objects FOR SELECT - USING ( - bucket_id = 'branding_assets' - AND (storage.foldername(name))[1] = auth.uid()::text - ); - --- Policy: Deny all other access by default -CREATE POLICY "Deny cross-user branding access" - ON storage.objects FOR ALL - USING ( - bucket_id = 'branding_assets' - AND (storage.foldername(name))[1] != auth.uid()::text - ) - WITH CHECK (false); - --- rollback: DROP POLICY IF EXISTS "Deny cross-user branding access" ON storage.objects; --- rollback: DROP POLICY IF EXISTS "Users can read own branding assets" ON storage.objects; --- rollback: DROP POLICY IF EXISTS "Users can upload to own branding namespace" ON storage.objects; --- rollback: DELETE FROM storage.buckets WHERE id = 'branding_assets'; diff --git a/supabase/migrations/20260926000017_metered_billing_usage_tracking.sql b/supabase/migrations/20260926000017_metered_billing_usage_tracking.sql index a45c8e37..e69de29b 100644 --- a/supabase/migrations/20260926000017_metered_billing_usage_tracking.sql +++ b/supabase/migrations/20260926000017_metered_billing_usage_tracking.sql @@ -1,111 +0,0 @@ --- Create usage_records table for metered billing --- Tracks all billable API operations for Stripe metered billing - -CREATE TABLE IF NOT EXISTS usage_records ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - operation_type TEXT NOT NULL CHECK (operation_type IN ( - 'api_call', - 'deployment_create', - 'deployment_update', - 'domain_config', - 'template_clone', - 'custom_domain', - 'deployment_preview', - 'github_sync', - 'vercel_deployment' - )), - quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity > 0), - metadata JSONB DEFAULT '{}'::jsonb, - - -- Idempotency key: operation_type-user_id-timestamp - -- Prevents duplicate usage records within same second - idempotency_key TEXT UNIQUE NOT NULL, - - -- Stripe reporting - stripe_usage_record_id TEXT, - reported_to_stripe BOOLEAN DEFAULT FALSE, - report_error TEXT, - reported_at TIMESTAMP WITH TIME ZONE, - - -- Billing period for aggregation - billing_period_start DATE NOT NULL, - billing_period_end DATE NOT NULL, - - -- Timestamps - created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() -); - --- Create indexes for efficient querying -CREATE INDEX IF NOT EXISTS idx_usage_records_user_period - ON usage_records(user_id, billing_period_start, billing_period_end); - -CREATE INDEX IF NOT EXISTS idx_usage_records_unreported - ON usage_records(user_id, reported_to_stripe) - WHERE reported_to_stripe = FALSE; - -CREATE INDEX IF NOT EXISTS idx_usage_records_operation_type - ON usage_records(operation_type); - -CREATE INDEX IF NOT EXISTS idx_usage_records_created_at - ON usage_records(created_at); - -CREATE INDEX IF NOT EXISTS idx_usage_records_idempotency - ON usage_records(idempotency_key); - --- Enable row level security -ALTER TABLE usage_records ENABLE ROW LEVEL SECURITY; - --- RLS Policy: Users can view their own usage records -CREATE POLICY "usage_records_user_policy" ON usage_records - FOR SELECT USING ( - auth.uid() = user_id OR - EXISTS ( - SELECT 1 FROM profiles - WHERE profiles.id = auth.uid() - AND profiles.subscription_tier = 'enterprise' - ) - ); - --- RLS Policy: Service role can insert/update usage records -CREATE POLICY "usage_records_service_policy" ON usage_records - FOR INSERT WITH CHECK (auth.role() = 'service_role'); - -CREATE POLICY "usage_records_update_policy" ON usage_records - FOR UPDATE USING (auth.role() = 'service_role' OR auth.uid() = user_id) - WITH CHECK (auth.role() = 'service_role' OR auth.uid() = user_id); - --- Function to automatically update updated_at timestamp -CREATE OR REPLACE FUNCTION update_usage_records_timestamp() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = NOW(); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - --- Trigger for updated_at -DROP TRIGGER IF EXISTS usage_records_update_timestamp ON usage_records; -CREATE TRIGGER usage_records_update_timestamp - BEFORE UPDATE ON usage_records - FOR EACH ROW - EXECUTE FUNCTION update_usage_records_timestamp(); - --- Comment for documentation -COMMENT ON TABLE usage_records IS 'Tracks billable API operations for Stripe metered billing integration. Each record represents usage that should be reported to Stripe for usage-based billing.'; -COMMENT ON COLUMN usage_records.idempotency_key IS 'Unique key combining operation type, user ID, and second timestamp. Ensures duplicate usage within same second is handled idempotently.'; -COMMENT ON COLUMN usage_records.reported_to_stripe IS 'Whether this usage has been reported to Stripe. Used to track pending reports and implement retry logic.'; - --- rollback: DROP TRIGGER IF EXISTS usage_records_update_timestamp ON usage_records; --- rollback: DROP FUNCTION IF EXISTS update_usage_records_timestamp; --- rollback: DROP POLICY IF EXISTS usage_records_update_policy ON usage_records; --- rollback: DROP POLICY IF EXISTS usage_records_service_policy ON usage_records; --- rollback: DROP POLICY IF EXISTS usage_records_user_policy ON usage_records; --- rollback: ALTER TABLE usage_records DISABLE ROW LEVEL SECURITY; --- rollback: DROP INDEX IF EXISTS idx_usage_records_idempotency; --- rollback: DROP INDEX IF EXISTS idx_usage_records_created_at; --- rollback: DROP INDEX IF EXISTS idx_usage_records_operation_type; --- rollback: DROP INDEX IF EXISTS idx_usage_records_unreported; --- rollback: DROP INDEX IF EXISTS idx_usage_records_user_period; --- rollback: DROP TABLE IF EXISTS usage_records; diff --git a/supabase/migrations/20260926000018_multi_provider_oauth.sql b/supabase/migrations/20260926000018_multi_provider_oauth.sql index 14e75e3c..e69de29b 100644 --- a/supabase/migrations/20260926000018_multi_provider_oauth.sql +++ b/supabase/migrations/20260926000018_multi_provider_oauth.sql @@ -1,25 +0,0 @@ --- Migration 012: multi-provider OAuth token storage (#661) --- --- Adds provider_connections JSONB column to profiles for storing non-GitHub --- OAuth provider data (e.g. Stellar wallet public key). --- --- Schema for provider_connections: --- { --- "stellar": { --- "publicKey": "G...", --- "connectedAt": "2026-01-01T00:00:00Z" --- } --- } --- --- GitHub tokens continue to use the existing github_token_encrypted column. --- No private keys are ever stored — Stellar integration stores only the --- public key; the wallet signs transactions client-side. - -ALTER TABLE profiles - ADD COLUMN IF NOT EXISTS provider_connections JSONB DEFAULT NULL; - -COMMENT ON COLUMN profiles.provider_connections IS - 'Isolated per-provider connection metadata. GitHub uses dedicated columns; ' - 'other providers (e.g. Stellar wallet) store { publicKey, connectedAt } here.'; - --- rollback: ALTER TABLE profiles DROP COLUMN IF EXISTS provider_connections; diff --git a/supabase/migrations/20260926000019_pgcron_automated_cleanup.sql b/supabase/migrations/20260926000019_pgcron_automated_cleanup.sql index dcb18578..e69de29b 100644 --- a/supabase/migrations/20260926000019_pgcron_automated_cleanup.sql +++ b/supabase/migrations/20260926000019_pgcron_automated_cleanup.sql @@ -1,349 +0,0 @@ --- Migration 012: pg_cron Automated Deployment Cleanup --- --- Enables the pg_cron extension and schedules automated cleanup jobs for: --- 1. Tombstoned deployments (soft-deleted records past retention window) --- 2. Stale GitHub Vercel deployments (failed/canceled deployments older than 90 days) --- 3. Old deployment analytics (records past retention window) --- 4. Orphaned deployment logs (logs for non-existent deployments) --- --- All jobs respect retention windows and never delete recently created records. --- Jobs are idempotent and safe to run concurrently. --- --- Issue: #653 — Supabase pg_cron Extension Integration for Automated Deployment Cleanup - --- Enable pg_cron extension (requires superuser or rds_superuser role) -CREATE EXTENSION IF NOT EXISTS pg_cron; - --- Grant usage on cron schema to postgres role -GRANT USAGE ON SCHEMA cron TO postgres; - --- ── Cleanup Functions ───────────────────────────────────────────────────────── - -/** - * Purge tombstoned deployments past the retention window. - * - * Soft-deleted deployments (deleted_at IS NOT NULL) are permanently removed - * after DEPLOYMENT_TOMBSTONE_RETENTION_DAYS (default: 30 days). - * - * Cascades to deployment_logs and deployment_analytics via ON DELETE CASCADE. - * - * Returns: Number of deployments purged. - */ -CREATE OR REPLACE FUNCTION cleanup_tombstoned_deployments() -RETURNS INTEGER -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - retention_days INTEGER := 30; -- Default retention window - cutoff_date TIMESTAMPTZ; - deleted_count INTEGER; -BEGIN - -- Calculate cutoff date - cutoff_date := NOW() - (retention_days || ' days')::INTERVAL; - - -- Delete tombstoned deployments older than cutoff - WITH deleted AS ( - DELETE FROM deployments - WHERE deleted_at IS NOT NULL - AND deleted_at < cutoff_date - RETURNING id - ) - SELECT COUNT(*) INTO deleted_count FROM deleted; - - -- Log execution - RAISE NOTICE 'cleanup_tombstoned_deployments: purged % deployments (cutoff: %)', - deleted_count, cutoff_date; - - RETURN deleted_count; -END; -$$; - -/** - * Purge stale GitHub Vercel deployments. - * - * Removes failed, canceled, or error deployments older than 90 days. - * Successful deployments (ready) are never purged. - * - * Returns: Number of deployments purged. - */ -CREATE OR REPLACE FUNCTION cleanup_stale_github_deployments() -RETURNS INTEGER -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - retention_days INTEGER := 90; - cutoff_date TIMESTAMPTZ; - deleted_count INTEGER; -BEGIN - cutoff_date := NOW() - (retention_days || ' days')::INTERVAL; - - -- Delete stale failed/canceled deployments - WITH deleted AS ( - DELETE FROM github_vercel_deployments - WHERE status IN ('failed', 'canceled', 'error') - AND created_at < cutoff_date - RETURNING id - ) - SELECT COUNT(*) INTO deleted_count FROM deleted; - - RAISE NOTICE 'cleanup_stale_github_deployments: purged % deployments (cutoff: %)', - deleted_count, cutoff_date; - - RETURN deleted_count; -END; -$$; - -/** - * Purge old deployment analytics records. - * - * Removes analytics records older than ANALYTICS_RETENTION_DAYS (default: 90 days). - * - * Returns: Number of analytics records purged. - */ -CREATE OR REPLACE FUNCTION cleanup_old_analytics() -RETURNS INTEGER -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - retention_days INTEGER := 90; - cutoff_date TIMESTAMPTZ; - deleted_count INTEGER; -BEGIN - cutoff_date := NOW() - (retention_days || ' days')::INTERVAL; - - -- Delete old analytics records - WITH deleted AS ( - DELETE FROM deployment_analytics - WHERE recorded_at < cutoff_date - RETURNING id - ) - SELECT COUNT(*) INTO deleted_count FROM deleted; - - RAISE NOTICE 'cleanup_old_analytics: purged % records (cutoff: %)', - deleted_count, cutoff_date; - - RETURN deleted_count; -END; -$$; - -/** - * Purge orphaned deployment logs. - * - * Removes logs for deployments that no longer exist (orphaned by manual deletion - * or data corruption). This should rarely find records due to CASCADE constraints, - * but provides a safety net. - * - * Returns: Number of orphaned logs purged. - */ -CREATE OR REPLACE FUNCTION cleanup_orphaned_logs() -RETURNS INTEGER -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - deleted_count INTEGER; -BEGIN - -- Delete logs with no corresponding deployment - WITH deleted AS ( - DELETE FROM deployment_logs - WHERE deployment_id NOT IN (SELECT id FROM deployments) - RETURNING id - ) - SELECT COUNT(*) INTO deleted_count FROM deleted; - - IF deleted_count > 0 THEN - RAISE WARNING 'cleanup_orphaned_logs: purged % orphaned logs', deleted_count; - END IF; - - RETURN deleted_count; -END; -$$; - -/** - * Purge old usage records (metered billing). - * - * Removes usage records older than 365 days that have been reported to Stripe. - * Unreported records are never deleted to prevent billing data loss. - * - * Returns: Number of usage records purged. - */ -CREATE OR REPLACE FUNCTION cleanup_old_usage_records() -RETURNS INTEGER -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - retention_days INTEGER := 365; - cutoff_date TIMESTAMPTZ; - deleted_count INTEGER; -BEGIN - cutoff_date := NOW() - (retention_days || ' days')::INTERVAL; - - -- Delete old reported usage records only - WITH deleted AS ( - DELETE FROM usage_records - WHERE created_at < cutoff_date - AND reported_to_stripe = TRUE - RETURNING id - ) - SELECT COUNT(*) INTO deleted_count FROM deleted; - - RAISE NOTICE 'cleanup_old_usage_records: purged % records (cutoff: %)', - deleted_count, cutoff_date; - - RETURN deleted_count; -END; -$$; - --- ── Scheduled Jobs ──────────────────────────────────────────────────────────── - --- Job 1: Purge tombstoned deployments (daily at 2:00 AM UTC) -SELECT cron.schedule( - 'cleanup-tombstoned-deployments', - '0 2 * * *', - $$SELECT cleanup_tombstoned_deployments();$$ -); - --- Job 2: Purge stale GitHub deployments (daily at 2:15 AM UTC) -SELECT cron.schedule( - 'cleanup-stale-github-deployments', - '15 2 * * *', - $$SELECT cleanup_stale_github_deployments();$$ -); - --- Job 3: Purge old analytics (daily at 2:30 AM UTC) -SELECT cron.schedule( - 'cleanup-old-analytics', - '30 2 * * *', - $$SELECT cleanup_old_analytics();$$ -); - --- Job 4: Purge orphaned logs (daily at 2:45 AM UTC) -SELECT cron.schedule( - 'cleanup-orphaned-logs', - '45 2 * * *', - $$SELECT cleanup_orphaned_logs();$$ -); - --- Job 5: Purge old usage records (weekly on Sunday at 3:00 AM UTC) -SELECT cron.schedule( - 'cleanup-old-usage-records', - '0 3 * * 0', - $$SELECT cleanup_old_usage_records();$$ -); - --- ── Job Execution Log Table ─────────────────────────────────────────────────── - -/** - * Stores execution history for cleanup jobs. - * Populated by triggers on cron.job_run_details. - */ -CREATE TABLE IF NOT EXISTS cleanup_job_executions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - job_name TEXT NOT NULL, - started_at TIMESTAMPTZ NOT NULL, - completed_at TIMESTAMPTZ, - status TEXT NOT NULL CHECK (status IN ('running', 'succeeded', 'failed')), - records_deleted INTEGER, - error_message TEXT, - created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL -); - --- Index for querying recent executions -CREATE INDEX IF NOT EXISTS idx_cleanup_job_executions_job_name_started - ON cleanup_job_executions(job_name, started_at DESC); - --- Index for monitoring failed jobs -CREATE INDEX IF NOT EXISTS idx_cleanup_job_executions_status - ON cleanup_job_executions(status) - WHERE status = 'failed'; - --- Enable RLS -ALTER TABLE cleanup_job_executions ENABLE ROW LEVEL SECURITY; - --- Policy: Service role can manage execution logs -CREATE POLICY "Service role can manage cleanup_job_executions" - ON cleanup_job_executions - FOR ALL - TO service_role - USING (true) - WITH CHECK (true); - --- Policy: Authenticated users can read execution logs -CREATE POLICY "Authenticated users can read cleanup_job_executions" - ON cleanup_job_executions - FOR SELECT - TO authenticated - USING (true); - --- ── Monitoring View ─────────────────────────────────────────────────────────── - -/** - * View for monitoring cleanup job health. - * Shows last execution time, success rate, and average records deleted. - */ -CREATE OR REPLACE VIEW cleanup_job_health AS -SELECT - job_name, - COUNT(*) AS total_executions, - COUNT(*) FILTER (WHERE status = 'succeeded') AS successful_executions, - COUNT(*) FILTER (WHERE status = 'failed') AS failed_executions, - ROUND( - 100.0 * COUNT(*) FILTER (WHERE status = 'succeeded') / NULLIF(COUNT(*), 0), - 2 - ) AS success_rate_pct, - MAX(started_at) AS last_execution_at, - AVG(records_deleted) FILTER (WHERE status = 'succeeded') AS avg_records_deleted, - MAX(records_deleted) FILTER (WHERE status = 'succeeded') AS max_records_deleted -FROM cleanup_job_executions -WHERE started_at > NOW() - INTERVAL '30 days' -GROUP BY job_name -ORDER BY job_name; - --- Grant access to monitoring view -GRANT SELECT ON cleanup_job_health TO authenticated; - --- ── Comments ────────────────────────────────────────────────────────────────── - -COMMENT ON FUNCTION cleanup_tombstoned_deployments() IS - 'Purges soft-deleted deployments past the 30-day retention window'; - -COMMENT ON FUNCTION cleanup_stale_github_deployments() IS - 'Purges failed/canceled GitHub Vercel deployments older than 90 days'; - -COMMENT ON FUNCTION cleanup_old_analytics() IS - 'Purges deployment analytics records older than 90 days'; - -COMMENT ON FUNCTION cleanup_orphaned_logs() IS - 'Purges deployment logs for non-existent deployments (safety net)'; - -COMMENT ON FUNCTION cleanup_old_usage_records() IS - 'Purges reported usage records older than 365 days'; - -COMMENT ON TABLE cleanup_job_executions IS - 'Execution history for automated cleanup jobs'; - -COMMENT ON VIEW cleanup_job_health IS - 'Monitoring view showing cleanup job health metrics over the last 30 days'; - --- rollback: DROP VIEW IF EXISTS cleanup_job_health; --- rollback: DROP POLICY IF EXISTS "Authenticated users can read cleanup_job_executions" ON cleanup_job_executions; --- rollback: DROP POLICY IF EXISTS "Service role can manage cleanup_job_executions" ON cleanup_job_executions; --- rollback: ALTER TABLE cleanup_job_executions DISABLE ROW LEVEL SECURITY; --- rollback: DROP INDEX IF EXISTS idx_cleanup_job_executions_status; --- rollback: DROP INDEX IF EXISTS idx_cleanup_job_executions_job_name_started; --- rollback: DROP TABLE IF EXISTS cleanup_job_executions; --- rollback: SELECT cron.unschedule('cleanup-old-usage-records'); --- rollback: SELECT cron.unschedule('cleanup-orphaned-logs'); --- rollback: SELECT cron.unschedule('cleanup-old-analytics'); --- rollback: SELECT cron.unschedule('cleanup-stale-github-deployments'); --- rollback: SELECT cron.unschedule('cleanup-tombstoned-deployments'); --- rollback: DROP FUNCTION IF EXISTS cleanup_old_usage_records; --- rollback: DROP FUNCTION IF EXISTS cleanup_orphaned_logs; --- rollback: DROP FUNCTION IF EXISTS cleanup_old_analytics; --- rollback: DROP FUNCTION IF EXISTS cleanup_stale_github_deployments; --- rollback: DROP FUNCTION IF EXISTS cleanup_tombstoned_deployments; --- rollback: DROP EXTENSION IF EXISTS pg_cron; diff --git a/supabase/migrations/20260926000020_github_webhook_delivery_tracking.sql b/supabase/migrations/20260926000020_github_webhook_delivery_tracking.sql index 6cf12c48..e69de29b 100644 --- a/supabase/migrations/20260926000020_github_webhook_delivery_tracking.sql +++ b/supabase/migrations/20260926000020_github_webhook_delivery_tracking.sql @@ -1,353 +0,0 @@ --- ── Dependency chain: job queue / DLQ (see issue #1336) ───────────────────── --- These four migrations must apply in this exact order: --- 1. 013_github_webhook_delivery_tracking.sql — webhook delivery tracking (root of the chain) <-- this file --- 2. 014_job_queue.sql — job_queue / job_dlq tables and job_priority / job_status enums --- 3. 015_job_queue_claim_rpc.sql — claim_next_job() RPC — requires job_queue from 014_job_queue.sql --- 4. 018_dlq_reprocess_atomicity.sql — job_dlq 'in_progress' reprocess state — requires job_dlq from 014_job_queue.sql --- Supabase applies migrations in lexicographic filename order. If these files --- are renumbered (e.g. to resolve the duplicate 014_ prefix), the new numbers --- MUST stay strictly ascending in the order above. This is enforced by --- apps/backend/tests/database/job-queue-migration-chain.test.ts. --- ───────────────────────────────────────────────────────────────────────────── - --- Migration 013: GitHub Webhook Delivery Tracking and Replay --- --- Enables tracking of expected vs received GitHub webhook deliveries, --- detection of missed deliveries, and replay mechanism for failed/missed events. --- --- Features: --- 1. Persistent webhook delivery log (all received deliveries) --- 2. Delivery status tracking (received, processed, failed, replayed) --- 3. Idempotency using delivery_id (survives server restarts) --- 4. Missed delivery detection by comparing against GitHub's delivery log --- 5. Replay mechanism with idempotent reprocessing --- --- Issue: #653 — GitHub Webhook Delivery Replay Implementation - --- ── Webhook Deliveries Table ───────────────────────────────────────────────── - -/** - * Stores all received GitHub webhook deliveries for tracking and replay. - * - * delivery_id: GitHub's x-github-delivery header (unique per delivery) - * event_type: GitHub's x-github-event header (push, installation, etc.) - * payload: Full webhook payload (JSON) - * headers: Request headers (JSON) for signature verification on replay - * status: Processing status (received, processed, failed, replayed) - * processing_error: Error message if processing failed - * processed_at: Timestamp when successfully processed - * replayed_from_delivery_id: If this is a replay, the original delivery_id - */ -CREATE TABLE IF NOT EXISTS github_webhook_deliveries ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - delivery_id TEXT NOT NULL UNIQUE, - event_type TEXT NOT NULL, - payload JSONB NOT NULL, - headers JSONB NOT NULL, - status TEXT NOT NULL DEFAULT 'received' CHECK ( - status IN ('received', 'processed', 'failed', 'replayed') - ), - processing_error TEXT, - processed_at TIMESTAMPTZ, - replayed_from_delivery_id TEXT REFERENCES github_webhook_deliveries(delivery_id), - created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL -); - --- Indexes for performance -CREATE INDEX IF NOT EXISTS idx_github_webhook_deliveries_delivery_id - ON github_webhook_deliveries(delivery_id); - -CREATE INDEX IF NOT EXISTS idx_github_webhook_deliveries_event_type - ON github_webhook_deliveries(event_type); - -CREATE INDEX IF NOT EXISTS idx_github_webhook_deliveries_status - ON github_webhook_deliveries(status); - -CREATE INDEX IF NOT EXISTS idx_github_webhook_deliveries_created_at - ON github_webhook_deliveries(created_at DESC); - --- Composite index for missed delivery detection -CREATE INDEX IF NOT EXISTS idx_github_webhook_deliveries_event_created - ON github_webhook_deliveries(event_type, created_at DESC); - --- Updated_at trigger -CREATE TRIGGER update_github_webhook_deliveries_updated_at - BEFORE UPDATE ON github_webhook_deliveries - FOR EACH ROW - EXECUTE FUNCTION update_updated_at_column(); - --- ── Missed Deliveries Tracking ─────────────────────────────────────────────── - -/** - * Stores deliveries detected as missing by comparing against GitHub's delivery log. - * - * github_delivery_id: Delivery ID from GitHub's API - * event_type: Event type from GitHub's API - * delivered_at: Timestamp from GitHub's API - * detected_at: When we detected it was missing - * replayed: Whether we've replayed this delivery - * replay_delivery_id: The delivery_id of the replayed event - */ -CREATE TABLE IF NOT EXISTS github_webhook_missed_deliveries ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - github_delivery_id TEXT NOT NULL UNIQUE, - event_type TEXT NOT NULL, - delivered_at TIMESTAMPTZ NOT NULL, - detected_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, - replayed BOOLEAN DEFAULT FALSE, - replay_delivery_id TEXT REFERENCES github_webhook_deliveries(delivery_id), - created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL -); - --- Indexes -CREATE INDEX IF NOT EXISTS idx_github_webhook_missed_deliveries_replayed - ON github_webhook_missed_deliveries(replayed) - WHERE replayed = FALSE; - -CREATE INDEX IF NOT EXISTS idx_github_webhook_missed_deliveries_detected_at - ON github_webhook_missed_deliveries(detected_at DESC); - --- Updated_at trigger -CREATE TRIGGER update_github_webhook_missed_deliveries_updated_at - BEFORE UPDATE ON github_webhook_missed_deliveries - FOR EACH ROW - EXECUTE FUNCTION update_updated_at_column(); - --- ── Delivery Statistics View ───────────────────────────────────────────────── - -/** - * Aggregated statistics for webhook delivery health monitoring. - */ -CREATE OR REPLACE VIEW github_webhook_delivery_stats AS -SELECT - event_type, - COUNT(*) AS total_deliveries, - COUNT(*) FILTER (WHERE status = 'processed') AS processed_count, - COUNT(*) FILTER (WHERE status = 'failed') AS failed_count, - COUNT(*) FILTER (WHERE status = 'replayed') AS replayed_count, - ROUND( - 100.0 * COUNT(*) FILTER (WHERE status = 'processed') / NULLIF(COUNT(*), 0), - 2 - ) AS success_rate_pct, - MAX(created_at) AS last_delivery_at, - MIN(created_at) FILTER (WHERE status = 'failed') AS first_failure_at -FROM github_webhook_deliveries -WHERE created_at > NOW() - INTERVAL '30 days' -GROUP BY event_type -ORDER BY total_deliveries DESC; - --- Grant access to monitoring view -GRANT SELECT ON github_webhook_delivery_stats TO authenticated; - --- ── Helper Functions ────────────────────────────────────────────────────────── - -/** - * Check if a delivery has been received (idempotency check). - * - * Returns TRUE if delivery_id exists in the deliveries table. - */ -CREATE OR REPLACE FUNCTION has_received_delivery(p_delivery_id TEXT) -RETURNS BOOLEAN -LANGUAGE SQL -STABLE -SECURITY DEFINER -AS $$ - SELECT EXISTS ( - SELECT 1 FROM github_webhook_deliveries - WHERE delivery_id = p_delivery_id - ); -$$; - -/** - * Record a new webhook delivery. - * - * Returns the created delivery record. - */ -CREATE OR REPLACE FUNCTION record_webhook_delivery( - p_delivery_id TEXT, - p_event_type TEXT, - p_payload JSONB, - p_headers JSONB -) -RETURNS github_webhook_deliveries -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_delivery github_webhook_deliveries; -BEGIN - INSERT INTO github_webhook_deliveries ( - delivery_id, - event_type, - payload, - headers, - status - ) VALUES ( - p_delivery_id, - p_event_type, - p_payload, - p_headers, - 'received' - ) - ON CONFLICT (delivery_id) DO NOTHING - RETURNING * INTO v_delivery; - - RETURN v_delivery; -END; -$$; - -/** - * Mark a delivery as processed successfully. - */ -CREATE OR REPLACE FUNCTION mark_delivery_processed(p_delivery_id TEXT) -RETURNS VOID -LANGUAGE SQL -SECURITY DEFINER -AS $$ - UPDATE github_webhook_deliveries - SET status = 'processed', - processed_at = NOW() - WHERE delivery_id = p_delivery_id; -$$; - -/** - * Mark a delivery as failed with error message. - */ -CREATE OR REPLACE FUNCTION mark_delivery_failed( - p_delivery_id TEXT, - p_error_message TEXT -) -RETURNS VOID -LANGUAGE SQL -SECURITY DEFINER -AS $$ - UPDATE github_webhook_deliveries - SET status = 'failed', - processing_error = p_error_message - WHERE delivery_id = p_delivery_id; -$$; - -/** - * Get deliveries that need replay (failed or missed). - * - * Returns deliveries with status = 'failed' or missed deliveries not yet replayed. - */ -CREATE OR REPLACE FUNCTION get_deliveries_for_replay() -RETURNS TABLE ( - delivery_id TEXT, - event_type TEXT, - payload JSONB, - headers JSONB, - source TEXT -) -LANGUAGE SQL -STABLE -SECURITY DEFINER -AS $$ - -- Failed deliveries - SELECT - d.delivery_id, - d.event_type, - d.payload, - d.headers, - 'failed'::TEXT AS source - FROM github_webhook_deliveries d - WHERE d.status = 'failed' - - UNION ALL - - -- Missed deliveries (detected but not yet replayed) - SELECT - m.github_delivery_id AS delivery_id, - m.event_type, - NULL::JSONB AS payload, - NULL::JSONB AS headers, - 'missed'::TEXT AS source - FROM github_webhook_missed_deliveries m - WHERE m.replayed = FALSE; -$$; - --- ── Row Level Security ──────────────────────────────────────────────────────── - -ALTER TABLE github_webhook_deliveries ENABLE ROW LEVEL SECURITY; -ALTER TABLE github_webhook_missed_deliveries ENABLE ROW LEVEL SECURITY; - --- Service role can manage all webhook delivery records -CREATE POLICY "Service role can manage github_webhook_deliveries" - ON github_webhook_deliveries - FOR ALL - TO service_role - USING (true) - WITH CHECK (true); - -CREATE POLICY "Service role can manage github_webhook_missed_deliveries" - ON github_webhook_missed_deliveries - FOR ALL - TO service_role - USING (true) - WITH CHECK (true); - --- Authenticated users can read webhook delivery records -CREATE POLICY "Authenticated users can read github_webhook_deliveries" - ON github_webhook_deliveries - FOR SELECT - TO authenticated - USING (true); - -CREATE POLICY "Authenticated users can read github_webhook_missed_deliveries" - ON github_webhook_missed_deliveries - FOR SELECT - TO authenticated - USING (true); - --- ── Comments ────────────────────────────────────────────────────────────────── - -COMMENT ON TABLE github_webhook_deliveries IS - 'Persistent log of all received GitHub webhook deliveries for tracking and replay'; - -COMMENT ON TABLE github_webhook_missed_deliveries IS - 'Deliveries detected as missing by comparing against GitHub''s delivery log'; - -COMMENT ON VIEW github_webhook_delivery_stats IS - 'Aggregated webhook delivery health metrics over the last 30 days'; - -COMMENT ON FUNCTION has_received_delivery(TEXT) IS - 'Check if a delivery has been received (idempotency check)'; - -COMMENT ON FUNCTION record_webhook_delivery(TEXT, TEXT, JSONB, JSONB) IS - 'Record a new webhook delivery with payload and headers'; - -COMMENT ON FUNCTION mark_delivery_processed(TEXT) IS - 'Mark a delivery as successfully processed'; - -COMMENT ON FUNCTION mark_delivery_failed(TEXT, TEXT) IS - 'Mark a delivery as failed with error message'; - -COMMENT ON FUNCTION get_deliveries_for_replay() IS - 'Get all deliveries that need replay (failed or missed)'; - --- rollback: DROP VIEW IF EXISTS github_webhook_delivery_stats; --- rollback: DROP POLICY IF EXISTS "Authenticated users can read github_webhook_missed_deliveries" ON github_webhook_missed_deliveries; --- rollback: DROP POLICY IF EXISTS "Service role can manage github_webhook_missed_deliveries" ON github_webhook_missed_deliveries; --- rollback: DROP POLICY IF EXISTS "Authenticated users can read github_webhook_deliveries" ON github_webhook_deliveries; --- rollback: DROP POLICY IF EXISTS "Service role can manage github_webhook_deliveries" ON github_webhook_deliveries; --- rollback: ALTER TABLE github_webhook_missed_deliveries DISABLE ROW LEVEL SECURITY; --- rollback: ALTER TABLE github_webhook_deliveries DISABLE ROW LEVEL SECURITY; --- rollback: DROP FUNCTION IF EXISTS get_deliveries_for_replay; --- rollback: DROP FUNCTION IF EXISTS mark_delivery_failed; --- rollback: DROP FUNCTION IF EXISTS mark_delivery_processed; --- rollback: DROP FUNCTION IF EXISTS record_webhook_delivery; --- rollback: DROP FUNCTION IF EXISTS has_received_delivery; --- rollback: DROP INDEX IF EXISTS idx_github_webhook_missed_deliveries_detected_at; --- rollback: DROP INDEX IF EXISTS idx_github_webhook_missed_deliveries_replayed; --- rollback: DROP INDEX IF EXISTS idx_github_webhook_deliveries_event_created; --- rollback: DROP INDEX IF EXISTS idx_github_webhook_deliveries_created_at; --- rollback: DROP INDEX IF EXISTS idx_github_webhook_deliveries_status; --- rollback: DROP INDEX IF EXISTS idx_github_webhook_deliveries_event_type; --- rollback: DROP INDEX IF EXISTS idx_github_webhook_deliveries_delivery_id; --- rollback: DROP TRIGGER IF EXISTS update_github_webhook_missed_deliveries_updated_at ON github_webhook_missed_deliveries; --- rollback: DROP TRIGGER IF EXISTS update_github_webhook_deliveries_updated_at ON github_webhook_deliveries; --- rollback: DROP TABLE IF EXISTS github_webhook_missed_deliveries; --- rollback: DROP TABLE IF EXISTS github_webhook_deliveries; diff --git a/supabase/migrations/20260926000021_artifact_storage_namespace.sql b/supabase/migrations/20260926000021_artifact_storage_namespace.sql index 02c2e335..e69de29b 100644 --- a/supabase/migrations/20260926000021_artifact_storage_namespace.sql +++ b/supabase/migrations/20260926000021_artifact_storage_namespace.sql @@ -1,47 +0,0 @@ --- Migration 014: Artifact Storage Namespace Isolation --- --- Creates the "deployment_artifacts" bucket with per-user namespace prefixing. --- --- Path-based namespacing scheme: --- Artifacts are stored at: {user_id}/{deployment_id}/artifact.zip --- Only users can read/write their own {user_id} namespace. --- Cross-user access is denied by RLS policies. --- --- Access rules: --- - INSERT: User can upload only to paths starting with their own user_id --- - SELECT: User can read only from paths starting with their own user_id --- - UPDATE/DELETE: Not allowed via policy --- --- Server-side path validation is enforced in artifact-signing.service.ts --- BEFORE each storage write, providing defense-in-depth security. --- --- Issue: #XXX — Artifact Storage Namespace Isolation - -INSERT INTO storage.buckets (id, name, public) -VALUES ('deployment_artifacts', 'deployment_artifacts', false) -ON CONFLICT (id) DO NOTHING; - --- Policy: Allow authenticated users to upload artifacts to their own namespace -CREATE POLICY "Users can upload to own artifact namespace" - ON storage.objects FOR INSERT - WITH CHECK ( - bucket_id = 'deployment_artifacts' - AND (storage.foldername(name))[1] = auth.uid()::text - ); - --- Policy: Allow authenticated users to read artifacts from their own namespace -CREATE POLICY "Users can read own artifacts" - ON storage.objects FOR SELECT - USING ( - bucket_id = 'deployment_artifacts' - AND (storage.foldername(name))[1] = auth.uid()::text - ); - --- Policy: Deny all cross-user artifact access -CREATE POLICY "Deny cross-user artifact access" - ON storage.objects FOR ALL - USING ( - bucket_id = 'deployment_artifacts' - AND (storage.foldername(name))[1] != auth.uid()::text - ) - WITH CHECK (false); diff --git a/supabase/migrations/20260926000022_cron_checkpoints.sql b/supabase/migrations/20260926000022_cron_checkpoints.sql index a4a02b61..e69de29b 100644 --- a/supabase/migrations/20260926000022_cron_checkpoints.sql +++ b/supabase/migrations/20260926000022_cron_checkpoints.sql @@ -1,12 +0,0 @@ --- Persisted resume cursor for long-running cron sweeps (e.g. the health-check cron). --- A sweep that hits its execution-time budget writes the next page's starting cursor --- here so the next scheduled invocation resumes where it left off instead of --- restarting from the beginning. `cursor` is NULL once the fleet is fully drained. -create table if not exists cron_checkpoints ( - job text primary key, - cursor text, - updated_at timestamptz not null default now() -); - -comment on table cron_checkpoints is - 'Checkpoint state for resumable cron sweeps. One row per job; cursor is the id to resume from.'; diff --git a/supabase/migrations/20260926000023_cron_job_failures.sql b/supabase/migrations/20260926000023_cron_job_failures.sql index 15e12d57..e69de29b 100644 --- a/supabase/migrations/20260926000023_cron_job_failures.sql +++ b/supabase/migrations/20260926000023_cron_job_failures.sql @@ -1,18 +0,0 @@ --- Migration 014: Cron Job Failure Tracking --- --- Stores per-job failure state so the alerting system survives server restarts. --- Each row represents one cron job identified by a unique job_name. --- --- Issue: #759 — Cron Job Failure Alerting System - -CREATE TABLE IF NOT EXISTS cron_job_failures ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - job_name TEXT NOT NULL UNIQUE, - consecutive_failures INT NOT NULL DEFAULT 0, - last_failure_at TIMESTAMPTZ, - last_success_at TIMESTAMPTZ, - last_error TEXT, - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS cron_job_failures_job_name_idx ON cron_job_failures (job_name); diff --git a/supabase/migrations/20260926000024_email_deliveries.sql b/supabase/migrations/20260926000024_email_deliveries.sql index fc751330..e69de29b 100644 --- a/supabase/migrations/20260926000024_email_deliveries.sql +++ b/supabase/migrations/20260926000024_email_deliveries.sql @@ -1,80 +0,0 @@ --- Migration 014: Email Notification Delivery Tracking --- --- Stores an audit trail of every transactional email dispatched by the --- EmailDeliveryService, plus the latest delivery status reported by the --- email provider via webhook (sent → delivered / bounced / opened). --- --- Issue: #769 — Email Notification Delivery Service with Template Rendering --- and Delivery Tracking - --- ── Email Deliveries Table ─────────────────────────────────────────────────── - -/** - * One row per dispatched email. - * - * user_id: Recipient user (nullable for system/unauthenticated sends) - * email_type: One of the supported notification types - * recipient: Destination email address - * subject: Rendered subject line - * provider_message_id: ID returned by the email provider; the join key used by - * the delivery-status webhook to update this row - * status: Lifecycle status, advanced by the provider webhook - * error: Provider error / bounce reason, if any - * sent_at: When the message was handed to the provider - */ -CREATE TABLE IF NOT EXISTS email_deliveries ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, - email_type TEXT NOT NULL CHECK ( - email_type IN ( - 'deployment_complete', - 'subscription_changed', - 'security_alert', - 'invoice_ready' - ) - ), - recipient TEXT NOT NULL, - subject TEXT NOT NULL, - provider_message_id TEXT UNIQUE, - status TEXT NOT NULL DEFAULT 'sent' CHECK ( - status IN ('sent', 'delivered', 'bounced', 'opened') - ), - error TEXT, - sent_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, - updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL -); - --- Indexes -CREATE INDEX IF NOT EXISTS idx_email_deliveries_user_id - ON email_deliveries(user_id); - -CREATE INDEX IF NOT EXISTS idx_email_deliveries_provider_message_id - ON email_deliveries(provider_message_id); - -CREATE INDEX IF NOT EXISTS idx_email_deliveries_status - ON email_deliveries(status); - -CREATE INDEX IF NOT EXISTS idx_email_deliveries_email_type - ON email_deliveries(email_type); - -CREATE INDEX IF NOT EXISTS idx_email_deliveries_sent_at - ON email_deliveries(sent_at DESC); - --- Keep updated_at fresh on status transitions (function defined in migration 001) -CREATE TRIGGER update_email_deliveries_updated_at - BEFORE UPDATE ON email_deliveries - FOR EACH ROW - EXECUTE FUNCTION update_updated_at_column(); - --- ── Row Level Security ─────────────────────────────────────────────────────── - -ALTER TABLE email_deliveries ENABLE ROW LEVEL SECURITY; - --- Users may read their own delivery records (audit trail visibility). -CREATE POLICY email_deliveries_select_own - ON email_deliveries - FOR SELECT - USING (auth.uid() = user_id); - --- Inserts/updates are performed by the service role only (server-side service --- and provider webhook); no client-side write policy is granted. diff --git a/supabase/migrations/20260926000025_job_queue.sql b/supabase/migrations/20260926000025_job_queue.sql index 6ee03993..e69de29b 100644 --- a/supabase/migrations/20260926000025_job_queue.sql +++ b/supabase/migrations/20260926000025_job_queue.sql @@ -1,94 +0,0 @@ --- ── Dependency chain: job queue / DLQ (see issue #1336) ───────────────────── --- These four migrations must apply in this exact order: --- 1. 013_github_webhook_delivery_tracking.sql — webhook delivery tracking (root of the chain) --- 2. 014_job_queue.sql — job_queue / job_dlq tables and job_priority / job_status enums <-- this file --- 3. 015_job_queue_claim_rpc.sql — claim_next_job() RPC — requires job_queue from 014_job_queue.sql --- 4. 018_dlq_reprocess_atomicity.sql — job_dlq 'in_progress' reprocess state — requires job_dlq from 014_job_queue.sql --- Supabase applies migrations in lexicographic filename order. If these files --- are renumbered (e.g. to resolve the duplicate 014_ prefix), the new numbers --- MUST stay strictly ascending in the order above. This is enforced by --- apps/backend/tests/database/job-queue-migration-chain.test.ts. --- ───────────────────────────────────────────────────────────────────────────── - --- Migration: 014_job_queue.sql --- Creates the job_queue table for background deployment processing with --- priority lanes (high / normal / low) and dead-letter escalation after --- MAX_ATTEMPTS failures. - --- ── Enum types ──────────────────────────────────────────────────────────────── - -CREATE TYPE job_priority AS ENUM ('high', 'normal', 'low'); -CREATE TYPE job_status AS ENUM ('pending', 'running', 'completed', 'failed', 'dead'); - --- ── Main job queue table ────────────────────────────────────────────────────── - -CREATE TABLE job_queue ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - job_type TEXT NOT NULL, -- e.g. 'deployment' - priority job_priority NOT NULL DEFAULT 'normal', - status job_status NOT NULL DEFAULT 'pending', - payload JSONB NOT NULL DEFAULT '{}', - result JSONB, -- set on completion - error_message TEXT, -- last failure reason - attempts INTEGER NOT NULL DEFAULT 0, - max_attempts INTEGER NOT NULL DEFAULT 3, - worker_id TEXT, -- ID of the claiming worker - scheduled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - started_at TIMESTAMPTZ, - completed_at TIMESTAMPTZ, - dead_at TIMESTAMPTZ, -- set when moved to DLQ - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- ── Dead letter queue (separate table for clarity) ──────────────────────────── - -CREATE TABLE job_dlq ( - id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - original_job_id UUID NOT NULL REFERENCES job_queue(id), - job_type TEXT NOT NULL, - priority job_priority NOT NULL, - payload JSONB NOT NULL, - failure_reason TEXT NOT NULL, - attempts INTEGER NOT NULL, - reprocess_status TEXT NOT NULL DEFAULT 'pending' - CHECK (reprocess_status IN ('pending', 'succeeded', 'failed')), - reprocessed_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- ── Indexes ─────────────────────────────────────────────────────────────────── - --- Primary fetch path: pending jobs ordered by priority lane then schedule time --- Priority ordering: high → normal → low maps to CASE ordinal: 1, 2, 3 -CREATE INDEX idx_job_queue_fetch - ON job_queue (status, priority, scheduled_at) - WHERE status = 'pending'; - --- Monitor running jobs (stalled worker detection) -CREATE INDEX idx_job_queue_running - ON job_queue (worker_id, started_at) - WHERE status = 'running'; - --- DLQ look-ups by original job -CREATE INDEX idx_job_dlq_original_job - ON job_dlq (original_job_id); - -CREATE INDEX idx_job_dlq_reprocess - ON job_dlq (reprocess_status, created_at) - WHERE reprocess_status = 'pending'; - --- ── updated_at trigger ──────────────────────────────────────────────────────── - -CREATE TRIGGER update_job_queue_updated_at - BEFORE UPDATE ON job_queue - FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); - --- ── Row Level Security ──────────────────────────────────────────────────────── --- Job queue is a server-side table; service role only. - -ALTER TABLE job_queue ENABLE ROW LEVEL SECURITY; -ALTER TABLE job_dlq ENABLE ROW LEVEL SECURITY; - --- No anon/authenticated policies — only the service role (bypasses RLS) may --- read/write these tables, keeping them invisible to the client SDK. diff --git a/supabase/migrations/20260926000026_orphaned_artifact_cleanup_audit.sql b/supabase/migrations/20260926000026_orphaned_artifact_cleanup_audit.sql index 74d8cbcf..e69de29b 100644 --- a/supabase/migrations/20260926000026_orphaned_artifact_cleanup_audit.sql +++ b/supabase/migrations/20260926000026_orphaned_artifact_cleanup_audit.sql @@ -1,33 +0,0 @@ --- Migration 014: Orphaned Artifact Cleanup Audit Log --- --- Records every orphaned deployment-artifact (storage object with no --- corresponding deployments row) deleted by --- CleanupService.purgeOrphanedArtifacts(), capturing the artifact size and age --- at deletion time for an audit trail / debugging window accounting. --- --- Issue: #758 — Automated Cleanup Service for Orphaned Deployment Artifacts --- with Retention Policy Enforcement - -CREATE TABLE IF NOT EXISTS orphaned_artifact_cleanup_log ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - -- Object path within the storage bucket. - artifact_path TEXT NOT NULL, - -- Bucket the artifact was removed from. - bucket TEXT NOT NULL, - -- Size of the deleted object in bytes (0 when unreported). - size_bytes BIGINT NOT NULL DEFAULT 0, - -- Age of the object in seconds at deletion time. - age_seconds BIGINT NOT NULL DEFAULT 0, - -- When the orphan was deleted. - deleted_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_orphaned_artifact_cleanup_log_deleted_at - ON orphaned_artifact_cleanup_log(deleted_at DESC); - -CREATE INDEX IF NOT EXISTS idx_orphaned_artifact_cleanup_log_artifact_path - ON orphaned_artifact_cleanup_log(artifact_path); - --- Append-only audit table written by the service role only. -ALTER TABLE orphaned_artifact_cleanup_log ENABLE ROW LEVEL SECURITY; diff --git a/supabase/migrations/20260926000027_region_latency_cache.sql b/supabase/migrations/20260926000027_region_latency_cache.sql index 442c990d..e69de29b 100644 --- a/supabase/migrations/20260926000027_region_latency_cache.sql +++ b/supabase/migrations/20260926000027_region_latency_cache.sql @@ -1,32 +0,0 @@ --- Migration 014: Region Latency Selection Cache --- --- Caches the optimal Vercel deployment region chosen by RegionSelectorService --- for a given user/IP + network, with a 5-minute TTL, so latency does not have --- to be re-measured on every deployment. --- --- Issue: #757 — Multi-Region Deployment Orchestration with Latency-Based --- Routing Selection - -CREATE TABLE IF NOT EXISTS region_latency_cache ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - -- Stable cache key, e.g. ":" or ":". - cache_key TEXT NOT NULL UNIQUE, - -- Chosen region: us-east-1 | eu-west-1 | ap-southeast-1. - selected_region TEXT NOT NULL CHECK ( - selected_region IN ('us-east-1', 'eu-west-1', 'ap-southeast-1') - ), - -- Per-region measured latencies + weighted scores (audit / debugging). - scores JSONB, - -- When this cached selection expires (now + 5 minutes at write time). - expires_at TIMESTAMPTZ NOT NULL, - created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_region_latency_cache_cache_key - ON region_latency_cache(cache_key); - -CREATE INDEX IF NOT EXISTS idx_region_latency_cache_expires_at - ON region_latency_cache(expires_at); - --- Written by the service role only. -ALTER TABLE region_latency_cache ENABLE ROW LEVEL SECURITY; diff --git a/supabase/migrations/20260926000028_job_queue_claim_rpc.sql b/supabase/migrations/20260926000028_job_queue_claim_rpc.sql index ad091fa3..e69de29b 100644 --- a/supabase/migrations/20260926000028_job_queue_claim_rpc.sql +++ b/supabase/migrations/20260926000028_job_queue_claim_rpc.sql @@ -1,69 +0,0 @@ --- ── Dependency chain: job queue / DLQ (see issue #1336) ───────────────────── --- These four migrations must apply in this exact order: --- 1. 013_github_webhook_delivery_tracking.sql — webhook delivery tracking (root of the chain) --- 2. 014_job_queue.sql — job_queue / job_dlq tables and job_priority / job_status enums --- 3. 015_job_queue_claim_rpc.sql — claim_next_job() RPC — requires job_queue from 014_job_queue.sql <-- this file --- 4. 018_dlq_reprocess_atomicity.sql — job_dlq 'in_progress' reprocess state — requires job_dlq from 014_job_queue.sql --- Supabase applies migrations in lexicographic filename order. If these files --- are renumbered (e.g. to resolve the duplicate 014_ prefix), the new numbers --- MUST stay strictly ascending in the order above. This is enforced by --- apps/backend/tests/database/job-queue-migration-chain.test.ts. --- ───────────────────────────────────────────────────────────────────────────── - --- Migration: 015_job_queue_claim_rpc.sql --- Atomic "claim next job" function used by workers to avoid double-processing. --- --- Priority mapping (CASE → integer) keeps ordering deterministic: --- high → 1, normal → 2, low → 3 --- --- The function: --- 1. Finds the single highest-priority pending job whose scheduled_at ≤ NOW() --- 2. Atomically sets status = 'running', worker_id, and started_at --- 3. Returns the full row so the caller doesn't need a second SELECT - -CREATE OR REPLACE FUNCTION claim_next_job(p_worker_id TEXT) -RETURNS SETOF job_queue -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_job_id UUID; -BEGIN - -- Step 1: Select the next eligible job ID under a row lock - SELECT id - INTO v_job_id - FROM job_queue - WHERE status = 'pending' - AND scheduled_at <= NOW() - ORDER BY - CASE priority - WHEN 'high' THEN 1 - WHEN 'normal' THEN 2 - WHEN 'low' THEN 3 - ELSE 4 - END, - scheduled_at ASC - LIMIT 1 - FOR UPDATE SKIP LOCKED; -- skip rows already locked by other workers - - -- Step 2: No eligible job found - IF v_job_id IS NULL THEN - RETURN; - END IF; - - -- Step 3: Atomically claim it - RETURN QUERY - UPDATE job_queue - SET - status = 'running', - worker_id = p_worker_id, - started_at = NOW(), - attempts = attempts + 1, - updated_at = NOW() - WHERE id = v_job_id - RETURNING *; -END; -$$; - --- Grant execute to the service role only -GRANT EXECUTE ON FUNCTION claim_next_job(TEXT) TO service_role; diff --git a/supabase/migrations/20260926000029_analytics_rollups.sql b/supabase/migrations/20260926000029_analytics_rollups.sql index 52e06228..e69de29b 100644 --- a/supabase/migrations/20260926000029_analytics_rollups.sql +++ b/supabase/migrations/20260926000029_analytics_rollups.sql @@ -1,64 +0,0 @@ --- Migration 016: Analytics Time-Series Rollup Tables --- --- Adds hourly and daily pre-aggregated rollup buckets to enable --- fast dashboard queries without full table scans of deployment_analytics. --- --- Issue: #768 — Analytics Data Pipeline with Time-Series Aggregation - --- ── analytics_rollups table ─────────────────────────────────────────────────── - -CREATE TABLE IF NOT EXISTS analytics_rollups ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - deployment_id UUID NOT NULL REFERENCES deployments(id) ON DELETE CASCADE, - metric_type TEXT NOT NULL, - bucket_start TIMESTAMPTZ NOT NULL, - granularity TEXT NOT NULL CHECK (granularity IN ('1h', '24h')), - total_value NUMERIC NOT NULL DEFAULT 0, - record_count INT NOT NULL DEFAULT 0, - up_count INT NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - - UNIQUE (deployment_id, metric_type, granularity, bucket_start) -); - -CREATE INDEX IF NOT EXISTS analytics_rollups_deployment_idx - ON analytics_rollups (deployment_id, metric_type, granularity, bucket_start DESC); - --- ── rollup_cursors: tracks last aggregated event per granularity ────────────── - -CREATE TABLE IF NOT EXISTS rollup_cursors ( - granularity TEXT NOT NULL, - last_run_at TIMESTAMPTZ NOT NULL DEFAULT '1970-01-01T00:00:00Z', - - PRIMARY KEY (granularity) -); - -INSERT INTO rollup_cursors (granularity, last_run_at) -VALUES ('1h', '1970-01-01T00:00:00Z'), ('24h', '1970-01-01T00:00:00Z') -ON CONFLICT DO NOTHING; - --- ── RLS ─────────────────────────────────────────────────────────────────────── - -ALTER TABLE analytics_rollups ENABLE ROW LEVEL SECURITY; -ALTER TABLE rollup_cursors ENABLE ROW LEVEL SECURITY; - --- Users may read rollups for their own deployments -CREATE POLICY "users read own rollups" - ON analytics_rollups FOR SELECT - USING ( - EXISTS ( - SELECT 1 FROM deployments d - WHERE d.id = deployment_id - AND d.user_id = auth.uid() - ) - ); - --- Service role handles writes (cron job) -CREATE POLICY "service role write rollups" - ON analytics_rollups FOR ALL - USING (auth.role() = 'service_role'); - -CREATE POLICY "service role manage cursors" - ON rollup_cursors FOR ALL - USING (auth.role() = 'service_role'); diff --git a/supabase/migrations/20260926000030_template_fulltext_search.sql b/supabase/migrations/20260926000030_template_fulltext_search.sql index 37a9609c..e69de29b 100644 --- a/supabase/migrations/20260926000030_template_fulltext_search.sql +++ b/supabase/migrations/20260926000030_template_fulltext_search.sql @@ -1,120 +0,0 @@ --- Migration: 016_template_fulltext_search.sql --- --- Adds full-text search to the templates table via: --- 1. A `tags` TEXT[] column for keyword tagging --- 2. A generated tsvector column that indexes name, description, and tags --- 3. A GIN index on that column for fast full-text lookups --- 4. A search helper function that returns rows ranked by relevance --- --- Weight mapping (ts_rank): --- A — name (highest weight, most relevant) --- B — tags (secondary weight) --- C — description (tertiary weight) --- --- Supports: --- Keyword search: to_tsquery('english', 'decentralized') --- Prefix search: to_tsquery('english', 'decentral:*') --- Phrase search: phraseto_tsquery('english', 'liquidity pool') - --- ── 1. Add tags column ──────────────────────────────────────────────────────── - -ALTER TABLE templates - ADD COLUMN IF NOT EXISTS tags TEXT[] NOT NULL DEFAULT '{}'; - --- ── 2. Add generated tsvector column ───────────────────────────────────────── --- --- Combines name (weight A), tags (weight B), and description (weight C). --- GENERATED ALWAYS AS keeps the vector in sync automatically on every write. - -ALTER TABLE templates - ADD COLUMN IF NOT EXISTS search_vector tsvector - GENERATED ALWAYS AS ( - setweight(to_tsvector('english', coalesce(name, '')), 'A') || - setweight(to_tsvector('english', coalesce(array_to_string(tags, ' '), '')), 'B') || - setweight(to_tsvector('english', coalesce(description, '')), 'C') - ) STORED; - --- ── 3. GIN index for fast tsvector lookups ──────────────────────────────────── - -CREATE INDEX IF NOT EXISTS idx_templates_search_vector - ON templates USING GIN (search_vector); - --- ── 4. Backfill tags for existing seed templates ────────────────────────────── - -UPDATE templates SET tags = ARRAY['dex', 'trading', 'stellar', 'assets', 'price-feeds'] -WHERE name = 'Stellar DEX'; - -UPDATE templates SET tags = ARRAY['defi', 'soroban', 'lending', 'liquidity', 'yield', 'smart-contracts'] -WHERE name = 'Soroban DeFi'; - -UPDATE templates SET tags = ARRAY['payment', 'gateway', 'invoice', 'multi-currency', 'stellar'] -WHERE name = 'Payment Gateway'; - -UPDATE templates SET tags = ARRAY['asset', 'issuance', 'trustline', 'distribution', 'stellar'] -WHERE name = 'Asset Issuance'; - --- ── 5. search_templates RPC ─────────────────────────────────────────────────── --- --- Usage from Supabase JS: --- supabase.rpc('search_templates', { --- p_query: 'decentralized exchange', --- p_category: null, -- or 'dex' | 'lending' | 'payment' | 'asset-issuance' --- p_limit: 20, --- p_offset: 0, --- }) --- --- Returns: rows from templates ordered by ts_rank DESC. --- Phrase search: wrap the query in double-quotes before calling the RPC. --- Prefix search: append :* to the last token before calling the RPC. - -CREATE OR REPLACE FUNCTION search_templates( - p_query TEXT, - p_category TEXT DEFAULT NULL, - p_limit INTEGER DEFAULT 20, - p_offset INTEGER DEFAULT 0 -) -RETURNS SETOF templates -LANGUAGE plpgsql -STABLE -SECURITY DEFINER -AS $$ -DECLARE - v_tsquery tsquery; -BEGIN - -- Build a tsquery from the raw input. - -- websearch_to_tsquery handles: - -- plain keywords → simple AND - -- "quoted phrases" → phraseto_tsquery - -- prefix* → prefix matching - -- Falls back to plainto_tsquery if websearch syntax is unavailable. - v_tsquery := websearch_to_tsquery('english', p_query); - - -- An empty / unparseable query returns all active templates (respecting category). - IF v_tsquery IS NULL THEN - RETURN QUERY - SELECT t.* - FROM templates t - WHERE t.is_active = true - AND (p_category IS NULL OR t.category = p_category) - ORDER BY t.created_at DESC - LIMIT p_limit - OFFSET p_offset; - RETURN; - END IF; - - RETURN QUERY - SELECT t.* - FROM templates t - WHERE t.is_active = true - AND t.search_vector @@ v_tsquery - AND (p_category IS NULL OR t.category = p_category) - ORDER BY ts_rank(t.search_vector, v_tsquery) DESC, - t.created_at DESC - LIMIT p_limit - OFFSET p_offset; -END; -$$; - --- Grant execute to authenticated and service roles -GRANT EXECUTE ON FUNCTION search_templates(TEXT, TEXT, INTEGER, INTEGER) - TO authenticated, service_role; diff --git a/supabase/migrations/20260926000031_atomic_provider_connections.sql b/supabase/migrations/20260926000031_atomic_provider_connections.sql index d153d597..e69de29b 100644 --- a/supabase/migrations/20260926000031_atomic_provider_connections.sql +++ b/supabase/migrations/20260926000031_atomic_provider_connections.sql @@ -1,33 +0,0 @@ --- Migration 017: Atomic provider_connections RPC functions (#922) --- --- Replaces read-modify-write JSONB column operations with server-side atomic --- PostgreSQL functions using || and - operators to eliminate race conditions. - -CREATE OR REPLACE FUNCTION connect_stellar_provider( - p_user_id UUID, - p_public_key TEXT, - p_connected_at TIMESTAMPTZ DEFAULT NOW() -) RETURNS VOID AS $$ -BEGIN - UPDATE profiles - SET provider_connections = COALESCE(provider_connections, '{}'::jsonb) || jsonb_build_object( - 'stellar', jsonb_build_object( - 'publicKey', p_public_key, - 'connectedAt', p_connected_at - ) - ), - updated_at = NOW() - WHERE id = p_user_id; -END; -$$ LANGUAGE plpgsql; - -CREATE OR REPLACE FUNCTION disconnect_stellar_provider( - p_user_id UUID -) RETURNS VOID AS $$ -BEGIN - UPDATE profiles - SET provider_connections = COALESCE(provider_connections, '{}'::jsonb) - 'stellar', - updated_at = NOW() - WHERE id = p_user_id; -END; -$$ LANGUAGE plpgsql; diff --git a/supabase/migrations/20260926000032_cron_failure_atomic_increment.sql b/supabase/migrations/20260926000032_cron_failure_atomic_increment.sql index 1486f951..e69de29b 100644 --- a/supabase/migrations/20260926000032_cron_failure_atomic_increment.sql +++ b/supabase/migrations/20260926000032_cron_failure_atomic_increment.sql @@ -1,68 +0,0 @@ --- Migration 017: Atomic Cron Failure Counter & Alert Tracking --- --- Fixes race condition in recordFailure() where concurrent calls could lose increments. --- Adds atomic increment RPC and alert state tracking to prevent duplicate alerts --- when count jumps past exact threshold values. --- --- Issue: #896 — Non-Atomic Failure Counter Race - -ALTER TABLE cron_job_failures - ADD COLUMN IF NOT EXISTS slack_alert_sent BOOLEAN NOT NULL DEFAULT FALSE, - ADD COLUMN IF NOT EXISTS email_alert_sent BOOLEAN NOT NULL DEFAULT FALSE; - --- Atomic increment stored procedure: --- Returns the new consecutive_failures count after incrementing. --- On first call for a job, inserts with consecutive_failures=1. -CREATE OR REPLACE FUNCTION increment_cron_failure_count(p_job_name TEXT) -RETURNS INT AS $$ -DECLARE - v_new_count INT; -BEGIN - -- Try to increment existing row - UPDATE cron_job_failures - SET consecutive_failures = consecutive_failures + 1, - updated_at = NOW() - WHERE job_name = p_job_name - RETURNING consecutive_failures INTO v_new_count; - - -- If no row existed, insert a new one - IF v_new_count IS NULL THEN - INSERT INTO cron_job_failures (job_name, consecutive_failures, updated_at) - VALUES (p_job_name, 1, NOW()) - RETURNING consecutive_failures INTO v_new_count; - END IF; - - RETURN v_new_count; -END; -$$ LANGUAGE plpgsql; - --- Mark alerts as sent (idempotent) -CREATE OR REPLACE FUNCTION mark_cron_alert_sent( - p_job_name TEXT, - p_alert_type TEXT -) -RETURNS VOID AS $$ -BEGIN - IF p_alert_type = 'slack' THEN - UPDATE cron_job_failures - SET slack_alert_sent = TRUE, updated_at = NOW() - WHERE job_name = p_job_name; - ELSIF p_alert_type = 'email' THEN - UPDATE cron_job_failures - SET email_alert_sent = TRUE, updated_at = NOW() - WHERE job_name = p_job_name; - END IF; -END; -$$ LANGUAGE plpgsql; - --- Reset alert tracking on success -CREATE OR REPLACE FUNCTION reset_cron_alerts_on_success(p_job_name TEXT) -RETURNS VOID AS $$ -BEGIN - UPDATE cron_job_failures - SET slack_alert_sent = FALSE, - email_alert_sent = FALSE, - updated_at = NOW() - WHERE job_name = p_job_name; -END; -$$ LANGUAGE plpgsql; diff --git a/supabase/migrations/20260926000033_cron_job_failure_increment_rpc.sql b/supabase/migrations/20260926000033_cron_job_failure_increment_rpc.sql index 1b9a0176..e69de29b 100644 --- a/supabase/migrations/20260926000033_cron_job_failure_increment_rpc.sql +++ b/supabase/migrations/20260926000033_cron_job_failure_increment_rpc.sql @@ -1,36 +0,0 @@ --- Migration: 017_cron_job_failure_increment_rpc.sql --- Atomic increment function for cron_job_failures.consecutive_failures. --- Replaces the read-then-upsert pattern that could lose increments under --- concurrent calls (see issue #986). --- --- Uses INSERT ... ON CONFLICT DO UPDATE so two concurrent calls for the --- same job_name both reliably increment the counter. --- This is the counter RPC used by CronFailureTrackerService. The earlier --- 017_cron_failure_atomic_increment.sql also defines increment_cron_failure_count --- plus alert-state helpers; the counter is superseded, while the alert helpers --- remain in use. - -CREATE OR REPLACE FUNCTION increment_cron_failure(p_job_name TEXT, p_error TEXT) -RETURNS INTEGER -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_count INTEGER; -BEGIN - INSERT INTO cron_job_failures (job_name, consecutive_failures, last_failure_at, last_error, updated_at) - VALUES (p_job_name, 1, NOW(), p_error, NOW()) - ON CONFLICT (job_name) - DO UPDATE SET - consecutive_failures = cron_job_failures.consecutive_failures + 1, - last_failure_at = NOW(), - last_error = p_error, - updated_at = NOW() - RETURNING consecutive_failures INTO v_count; - - RETURN v_count; -END; -$$; - --- Grant execute to the service role only (matches 015_job_queue_claim_rpc.sql pattern) -GRANT EXECUTE ON FUNCTION increment_cron_failure(TEXT, TEXT) TO service_role; diff --git a/supabase/migrations/20260926000034_provider_connections_atomic_rpc.sql b/supabase/migrations/20260926000034_provider_connections_atomic_rpc.sql index 018e4c3f..e69de29b 100644 --- a/supabase/migrations/20260926000034_provider_connections_atomic_rpc.sql +++ b/supabase/migrations/20260926000034_provider_connections_atomic_rpc.sql @@ -1,71 +0,0 @@ --- Migration: 017_provider_connections_atomic_rpc.sql --- Atomic read-modify-write for profiles.provider_connections. --- --- MultiProviderAuthService.connectStellar()/disconnectProvider() previously --- read provider_connections into application memory, merged the change, and --- wrote the whole JSONB object back with a plain .update(). Two concurrent --- requests (e.g. two browser tabs, or a retried request) could both read the --- same snapshot and the second write would silently clobber the first, --- dropping whichever provider connection change happened first. --- --- These functions perform the read-modify-write inside a single row-locked --- UPDATE statement so concurrent callers serialize instead of racing. --- These generic RPCs are used by MultiProviderAuthService and supersede the --- specialized connect_stellar_provider/disconnect_stellar_provider functions --- from 017_atomic_provider_connections.sql. - -CREATE OR REPLACE FUNCTION set_provider_connection( - p_user_id UUID, - p_provider TEXT, - p_value JSONB -) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_result JSONB; -BEGIN - UPDATE profiles - SET - provider_connections = jsonb_set( - COALESCE(provider_connections, '{}'::jsonb), - ARRAY[p_provider], - p_value, - true - ), - updated_at = NOW() - WHERE id = p_user_id - RETURNING provider_connections INTO v_result; - - RETURN v_result; -END; -$$; - -CREATE OR REPLACE FUNCTION remove_provider_connection( - p_user_id UUID, - p_provider TEXT -) -RETURNS JSONB -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -DECLARE - v_result JSONB; -BEGIN - UPDATE profiles - SET - provider_connections = COALESCE(provider_connections, '{}'::jsonb) - p_provider, - updated_at = NOW() - WHERE id = p_user_id - RETURNING provider_connections INTO v_result; - - RETURN v_result; -END; -$$; - --- Grant execute to the service role only -GRANT EXECUTE ON FUNCTION set_provider_connection(UUID, TEXT, JSONB) TO service_role; -GRANT EXECUTE ON FUNCTION remove_provider_connection(UUID, TEXT) TO service_role; - --- rollback: DROP FUNCTION IF EXISTS set_provider_connection(UUID, TEXT, JSONB); DROP FUNCTION IF EXISTS remove_provider_connection(UUID, TEXT); diff --git a/supabase/migrations/20260926000035_atomic_deployment_build_cache_hash.sql b/supabase/migrations/20260926000035_atomic_deployment_build_cache_hash.sql index dc0b4c91..e69de29b 100644 --- a/supabase/migrations/20260926000035_atomic_deployment_build_cache_hash.sql +++ b/supabase/migrations/20260926000035_atomic_deployment_build_cache_hash.sql @@ -1,21 +0,0 @@ --- Merge the build cache marker in the database so concurrent deployment stages --- cannot overwrite unrelated customization_config fields. - -CREATE OR REPLACE FUNCTION set_deployment_build_cache_hash( - p_deployment_id UUID, - p_hash TEXT -) -RETURNS VOID -LANGUAGE SQL -SECURITY DEFINER -AS $$ - UPDATE deployments - SET customization_config = COALESCE(customization_config, '{}'::jsonb) - || jsonb_build_object('_buildCacheHash', p_hash), - updated_at = NOW() - WHERE id = p_deployment_id; -$$; - -GRANT EXECUTE ON FUNCTION set_deployment_build_cache_hash(UUID, TEXT) TO service_role; - --- rollback: DROP FUNCTION IF EXISTS set_deployment_build_cache_hash(UUID, TEXT); \ No newline at end of file diff --git a/supabase/migrations/20260926000037_fix_auth_audit_logs_rls.sql b/supabase/migrations/20260926000037_fix_auth_audit_logs_rls.sql index 1983d483..e69de29b 100644 --- a/supabase/migrations/20260926000037_fix_auth_audit_logs_rls.sql +++ b/supabase/migrations/20260926000037_fix_auth_audit_logs_rls.sql @@ -1,67 +0,0 @@ --- Migration: 019_fix_auth_audit_logs_rls.sql --- Issue: #975 — Close RLS Blanket-Read Gap on Cross-Region auth_audit_logs --- --- Problem: --- The SELECT policy created in 010_auth_audit_logs_cross_region.sql contained --- a second OR clause: --- --- EXISTS ( --- SELECT 1 FROM profiles --- WHERE profiles.id = auth.uid() --- AND profiles.subscription_tier IN ('premium', 'enterprise') --- ) --- --- This clause has NO join to the row's user_id, so any premium or enterprise --- subscriber can SELECT every other user's audit rows (emails, IPs, event --- details) across all three regions — a live cross-tenant data leak. --- --- Fix: --- Drop and recreate the SELECT policy scoped strictly to the row owner or --- service_role. The subscription-tier bypass is removed entirely. --- No other policies or the original migration (010) are touched. --- --- Rollback: --- See rollback section at the bottom of this file. - --- ── Drop the vulnerable policy ──────────────────────────────────────────────── - -DROP POLICY IF EXISTS "auth_audit_logs_user_policy" ON auth_audit_logs; - --- ── Recreate with correct scope ─────────────────────────────────────────────── --- --- A row is visible only when: --- 1. The authenticated user owns the row (auth.uid() = user_id), OR --- 2. The caller is the Supabase service_role (used by server-side admin APIs). --- --- Premium/enterprise subscription tier is NOT a sufficient condition for --- reading another user's audit trail. Any admin dashboard that previously --- relied on the tier bypass must be moved to a service-role-backed server API --- route instead of direct client-side RLS. - -CREATE POLICY "auth_audit_logs_user_policy" ON auth_audit_logs - FOR SELECT - USING ( - auth.uid() = user_id - OR auth.role() = 'service_role' - ); - --- ── Comment ─────────────────────────────────────────────────────────────────── - -COMMENT ON POLICY "auth_audit_logs_user_policy" ON auth_audit_logs IS - 'SELECT access is restricted to the row owner (auth.uid() = user_id) or the ' - 'service_role. The previous subscription-tier bypass (premium/enterprise) ' - 'was a cross-tenant data leak and has been removed by migration 019.'; - --- ── Rollback ────────────────────────────────────────────────────────────────── --- To revert this migration (NOT recommended — reverts the security fix): --- --- DROP POLICY IF EXISTS "auth_audit_logs_user_policy" ON auth_audit_logs; --- CREATE POLICY "auth_audit_logs_user_policy" ON auth_audit_logs --- FOR SELECT USING ( --- auth.uid() = user_id OR --- EXISTS ( --- SELECT 1 FROM profiles --- WHERE profiles.id = auth.uid() --- AND profiles.subscription_tier IN ('premium', 'enterprise') --- ) --- ); diff --git a/supabase/migrations/20260926000038_fee_bump_usage_records.sql b/supabase/migrations/20260926000038_fee_bump_usage_records.sql index a484de90..e69de29b 100644 --- a/supabase/migrations/20260926000038_fee_bump_usage_records.sql +++ b/supabase/migrations/20260926000038_fee_bump_usage_records.sql @@ -1,73 +0,0 @@ --- Migration: 020_fee_bump_usage_records.sql --- Issue: #1111 — Persist Fee-Bump Usage Records So a Restart Cannot Silently --- Reset Monthly Billing Counters --- --- Problem: --- orchestrateFeeBump() in @craft/stellar defaults to an in-memory --- FeeBumpUsageStore. A server restart mid-billing-period silently resets --- every user's fee-bump count and cumulative fees paid to zero, with no --- reconciliation against what was actually charged. --- --- Fix: --- Provide a durable table backing SupabaseFeeBumpUsageStore --- (apps/backend/src/services/fee-bump-usage.service.ts). One row per user, --- upserted on each fee-bump. - -CREATE TABLE IF NOT EXISTS fee_bump_usage_records ( - user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, - - -- Number of platform-sponsored fee-bump transactions for this user. - count INTEGER NOT NULL DEFAULT 0 CHECK (count >= 0), - - -- Cumulative fee paid on the user's behalf, in stroops. - total_fees_paid BIGINT NOT NULL DEFAULT 0 CHECK (total_fees_paid >= 0), - - -- Timestamp of the most recent fee-bump. - last_used_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), - - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_fee_bump_usage_last_used - ON fee_bump_usage_records(last_used_at); - --- Enable row level security -ALTER TABLE fee_bump_usage_records ENABLE ROW LEVEL SECURITY; - --- RLS Policy: users can read their own usage; service_role manages writes. -CREATE POLICY "fee_bump_usage_select_policy" ON fee_bump_usage_records - FOR SELECT USING ( - auth.uid() = user_id OR auth.role() = 'service_role' - ); - -CREATE POLICY "fee_bump_usage_service_policy" ON fee_bump_usage_records - FOR ALL USING (auth.role() = 'service_role') - WITH CHECK (auth.role() = 'service_role'); - --- Keep updated_at current on every write. -CREATE OR REPLACE FUNCTION update_fee_bump_usage_timestamp() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = NOW(); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -DROP TRIGGER IF EXISTS fee_bump_usage_update_timestamp ON fee_bump_usage_records; -CREATE TRIGGER fee_bump_usage_update_timestamp - BEFORE UPDATE ON fee_bump_usage_records - FOR EACH ROW - EXECUTE FUNCTION update_fee_bump_usage_timestamp(); - -COMMENT ON TABLE fee_bump_usage_records IS - 'Durable per-user fee-bump usage counters for monthly billing. Backs ' - 'SupabaseFeeBumpUsageStore so counts survive server restarts (issue #1111).'; - --- rollback: DROP TRIGGER IF EXISTS fee_bump_usage_update_timestamp ON fee_bump_usage_records; --- rollback: DROP FUNCTION IF EXISTS update_fee_bump_usage_timestamp; --- rollback: DROP POLICY IF EXISTS fee_bump_usage_service_policy ON fee_bump_usage_records; --- rollback: DROP POLICY IF EXISTS fee_bump_usage_select_policy ON fee_bump_usage_records; --- rollback: ALTER TABLE fee_bump_usage_records DISABLE ROW LEVEL SECURITY; --- rollback: DROP INDEX IF EXISTS idx_fee_bump_usage_last_used; --- rollback: DROP TABLE IF EXISTS fee_bump_usage_records; diff --git a/supabase/migrations/20260926000039_payment_idempotency_atomic_key_generation.sql b/supabase/migrations/20260926000039_payment_idempotency_atomic_key_generation.sql index 61033ec1..e69de29b 100644 --- a/supabase/migrations/20260926000039_payment_idempotency_atomic_key_generation.sql +++ b/supabase/migrations/20260926000039_payment_idempotency_atomic_key_generation.sql @@ -1,22 +0,0 @@ --- Payment Idempotency Atomic Key Generation (Issue #1138) --- Prevents concurrent duplicate key generation by using database-level unique constraint --- with atomic upsert, replacing the previous client-side select-then-insert pattern. - --- Add request_fingerprint column to support per-fingerprint idempotency keys -ALTER TABLE payment_idempotency_keys - ADD COLUMN IF NOT EXISTS request_fingerprint TEXT; - --- Add unique constraint on (user_id, operation_type, request_fingerprint) for atomic upserts. --- Uses COALESCE to treat NULL values as distinct, so multiple records can have NULL fingerprints. --- The constraint allows concurrent upserts to converge on a single key per unique combination. -CREATE UNIQUE INDEX IF NOT EXISTS idx_payment_idempotency_keys_atomic_upsert - ON payment_idempotency_keys (user_id, operation_type, COALESCE(request_fingerprint, '')); - --- Add index for efficient lookup during upsert retry -CREATE INDEX IF NOT EXISTS idx_payment_idempotency_keys_user_operation_fingerprint - ON payment_idempotency_keys (user_id, operation_type, request_fingerprint) - WHERE expires_at > NOW(); - --- rollback: DROP INDEX IF EXISTS idx_payment_idempotency_keys_user_operation_fingerprint; --- rollback: DROP INDEX IF EXISTS idx_payment_idempotency_keys_atomic_upsert; --- rollback: ALTER TABLE payment_idempotency_keys DROP COLUMN IF EXISTS request_fingerprint; diff --git a/supabase/migrations/20260926000040_usage_records_atomic_increment_rpc.sql b/supabase/migrations/20260926000040_usage_records_atomic_increment_rpc.sql index ddcc6488..e69de29b 100644 --- a/supabase/migrations/20260926000040_usage_records_atomic_increment_rpc.sql +++ b/supabase/migrations/20260926000040_usage_records_atomic_increment_rpc.sql @@ -1,95 +0,0 @@ --- Migration: 021_usage_records_atomic_increment_rpc.sql --- Atomic increment RPC for usage_records.idempotency_key conflicts. --- Prevents the lost-update race when two concurrent calls hit the same --- (user_id, operation_type) within the same second. - -CREATE OR REPLACE FUNCTION increment_usage_record_quantity( - p_user_id UUID, - p_operation_type TEXT, - p_quantity INTEGER, - p_metadata JSONB, - p_billing_period_start DATE, - p_billing_period_end DATE, - p_idempotency_key TEXT -) -RETURNS TABLE ( - id UUID, - user_id UUID, - operation_type TEXT, - quantity INTEGER, - metadata JSONB, - billing_period_start DATE, - billing_period_end DATE, - idempotency_key TEXT, - stripe_usage_record_id TEXT, - reported_to_stripe BOOLEAN, - report_error TEXT, - reported_at TIMESTAMPTZ, - created_at TIMESTAMPTZ, - updated_at TIMESTAMPTZ -) -LANGUAGE plpgsql -SECURITY DEFINER -AS $$ -BEGIN - RETURN QUERY - WITH upserted AS ( - INSERT INTO usage_records ( - user_id, - operation_type, - quantity, - metadata, - billing_period_start, - billing_period_end, - idempotency_key, - reported_to_stripe, - created_at, - updated_at - ) - VALUES ( - p_user_id, - p_operation_type, - p_quantity, - COALESCE(p_metadata, '{}'::jsonb), - p_billing_period_start, - p_billing_period_end, - p_idempotency_key, - FALSE, - NOW(), - NOW() - ) - ON CONFLICT (idempotency_key) - DO UPDATE SET - quantity = usage_records.quantity + EXCLUDED.quantity, - metadata = COALESCE(usage_records.metadata, '{}'::jsonb) || COALESCE(EXCLUDED.metadata, '{}'::jsonb), - updated_at = NOW() - RETURNING * - ) - SELECT - upserted.id, - upserted.user_id, - upserted.operation_type, - upserted.quantity, - upserted.metadata, - upserted.billing_period_start, - upserted.billing_period_end, - upserted.idempotency_key, - upserted.stripe_usage_record_id, - upserted.reported_to_stripe, - upserted.report_error, - upserted.reported_at, - upserted.created_at, - upserted.updated_at - FROM upserted; -END; -$$; - -GRANT EXECUTE ON FUNCTION increment_usage_record_quantity( - UUID, - TEXT, - INTEGER, - JSONB, - DATE, - DATE, - TEXT -) TO service_role; diff --git a/supabase/tests/rls/auth-audit-logs-rls.test.ts b/supabase/tests/rls/auth-audit-logs-rls.test.ts index 851488f6..b4dfde00 100644 --- a/supabase/tests/rls/auth-audit-logs-rls.test.ts +++ b/supabase/tests/rls/auth-audit-logs-rls.test.ts @@ -1,7 +1,7 @@ /** * RLS Tests — auth_audit_logs policy fix (Issue #975) * - * Verifies that migration 019_fix_auth_audit_logs_rls.sql correctly scopes + * Verifies that migration 037_fix_auth_audit_logs_rls.sql correctly scopes * the SELECT policy to the row owner and service_role only. * * The bug: the old policy granted SELECT when the *requester's* profile had diff --git a/supabase/tests/rls/policy-verification.test.ts b/supabase/tests/rls/policy-verification.test.ts index a65af105..332fcc32 100644 --- a/supabase/tests/rls/policy-verification.test.ts +++ b/supabase/tests/rls/policy-verification.test.ts @@ -558,7 +558,7 @@ describe('RLS: payment_events — per-user direct-identity SELECT', () => { describe('RLS: audit_logs (auth_audit_logs) — per-user SELECT with tier expansion', () => { /** - * Policy from migration 010_auth_audit_logs_cross_region.sql: + * Policy from migration 011_auth_audit_logs_cross_region.sql: * FOR SELECT USING ( * auth.uid() = user_id OR * EXISTS (SELECT 1 FROM profiles WHERE profiles.id = auth.uid() @@ -618,7 +618,7 @@ describe('RLS: audit_logs (auth_audit_logs) — per-user SELECT with tier expans describe('RLS: branding_assets (storage.objects) — path-based user namespace', () => { /** - * Policy from migration 011_branding_asset_storage_policy.sql: + * Policy from migration 016_branding_asset_storage_policy.sql: * INSERT WITH CHECK: bucket_id = 'branding_assets' AND foldername(name)[1] = auth.uid() * SELECT USING: bucket_id = 'branding_assets' AND foldername(name)[1] = auth.uid() *