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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion apps/backend/src/lib/stellar/validate-asset-pairs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import type { AssetPair, StellarAsset } from '@craft/types';
import type { ValidationError } from '@craft/types';
import { makeStellarAsset } from '@craft/stellar';

// ── Constants ─────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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<string, unknown>;
const rawCounter = p['counter'] as Record<string, unknown>;
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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/services/job-queue.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' } } });
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/services/template.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/services/webhook-delivery.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 1 addition & 1 deletion docs/admin-ops-dlq.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/cross-region-deployment-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/github-vercel-deployment-triggering.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
1 change: 1 addition & 0 deletions packages/stellar/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
21 changes: 21 additions & 0 deletions packages/stellar/src/stellar-asset.test.ts
Original file line number Diff line number Diff line change
@@ -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.');
});
});
31 changes: 31 additions & 0 deletions packages/stellar/src/stellar-asset.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
4 changes: 4 additions & 0 deletions packages/stellar/src/upgrade-orchestrator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}));
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
32 changes: 32 additions & 0 deletions scripts/check-migration-numbering.js
Original file line number Diff line number Diff line change
@@ -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 };
35 changes: 35 additions & 0 deletions scripts/check-migration-numbering.test.js
Original file line number Diff line number Diff line change
@@ -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']],
]);
});
});
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
42 changes: 0 additions & 42 deletions supabase/migrations/20260926000007_secure_token_storage.sql
Original file line number Diff line number Diff line change
@@ -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;
52 changes: 0 additions & 52 deletions supabase/migrations/20260926000008_field_encryption_stripe.sql
Original file line number Diff line number Diff line change
@@ -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<version>.<iv_base64url>.<ciphertext_base64url>.<tag_base64url>
-- Key: FIELD_ENCRYPTION_KEY environment variable (64-char hex / 32 bytes).
--
-- Key management assumptions (out of scope):
-- - Key rotation uses FIELD_ENCRYPTION_KEY_<N> 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;
Loading
Loading