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
31 changes: 31 additions & 0 deletions .github/workflows/migration-sequence.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Migration sequence

on:
pull_request:
paths:
- 'supabase/migrations/**'
- 'supabase/config.toml'
- 'supabase/tests/migrations/**'
- '.github/workflows/migration-sequence.yml'

permissions:
contents: read

jobs:
migration-sequence:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: supabase/setup-cli@v1
with:
version: latest
- name: Start local Supabase
run: supabase start
- name: Apply every migration to a fresh database
run: supabase db reset --local
- name: Verify schema baseline
run: |
sudo apt-get update
sudo apt-get install --yes postgresql-client
eval "$(supabase status -o env)"
psql "$DB_URL" -v ON_ERROR_STOP=1 -f supabase/tests/migrations/schema-smoke.sql
11 changes: 11 additions & 0 deletions docs/cross-region-auth-integration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,17 @@ const makeAuthRequest = async (email: string, password: string) => {

## Error Handling

### Reconcile Pending Sign-Ups

If a sign-up profile could not be synchronized to every region, its auth audit record is marked with `syncStatus: "pending"`. Configure a high-entropy `REGIONAL_AUTH_RECONCILIATION_TOKEN` secret for the reconciliation function, then trigger it manually with:

```sh
curl --fail-with-body -X POST "$SUPABASE_URL/functions/v1/regional-auth-reconcile" \
-H "Authorization: Bearer $REGIONAL_AUTH_RECONCILIATION_TOKEN"
```

The function retries up to 100 pending records per region per request and reports the scanned, repaired, and still-pending counts. Repeat the request while `pending` is nonzero; records that remain incomplete stay marked for retry.

### Handle Region Failover

The system automatically fails over, but you should handle the response metadata:
Expand Down
2 changes: 1 addition & 1 deletion packages/sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"build": "tsc",
"dev": "tsc --watch",
"lint": "eslint src/**/*.ts",
"test": "vitest run"
"test": "vitest run --typecheck"
},
"devDependencies": {
"typescript": "^5.3.3",
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

export type { DeploymentStatusType } from '@craft/types';

export type SubscriptionTier = 'free' | 'pro' | 'enterprise';

/**
* Configuration options for CraftClient.
* @property baseUrl - The base URL of the CRAFT API (e.g., https://craft.app)
Expand Down
32 changes: 32 additions & 0 deletions packages/sdk/src/type-parity.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { expectTypeOf } from 'vitest';
import type {
DeploymentStatusType as SharedDeploymentStatusType,
SubscriptionTier as SharedSubscriptionTier,
Template as SharedTemplate,
User as SharedUser,
} from '../../types/src';
import type {
DeploymentStatusType,
SubscriptionTier,
Template,
UserProfile,
} from './client';

expectTypeOf<DeploymentStatusType>().toEqualTypeOf<SharedDeploymentStatusType>();
expectTypeOf<SubscriptionTier>().toEqualTypeOf<SharedSubscriptionTier>();

expectTypeOf<UserProfile['id']>().toEqualTypeOf<SharedUser['id']>();
expectTypeOf<UserProfile['email']>().toEqualTypeOf<SharedUser['email']>();
expectTypeOf<UserProfile['createdAt']>().toEqualTypeOf<SharedUser['createdAt']>();
expectTypeOf<UserProfile['subscriptionTier']>().toEqualTypeOf<SharedUser['subscriptionTier']>();
expectTypeOf<UserProfile['githubConnected']>().toEqualTypeOf<SharedUser['githubConnected']>();
expectTypeOf<UserProfile['githubUsername']>().toEqualTypeOf<SharedUser['githubUsername']>();

expectTypeOf<Template['id']>().toEqualTypeOf<SharedTemplate['id']>();
expectTypeOf<Template['name']>().toEqualTypeOf<SharedTemplate['name']>();
expectTypeOf<Template['description']>().toEqualTypeOf<SharedTemplate['description']>();

export const intentionalTypeDivergences = {
UserProfile: 'The SDK API profile includes fullName, which shared User omits.',
Template: 'The SDK API response omits shared template persistence and customization fields.',
} as const;
11 changes: 11 additions & 0 deletions packages/sdk/tsconfig.typecheck.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": false,
"incremental": false,
"noEmit": true,
"rootDir": ".."
},
"include": ["src/**/*.test-d.ts"],
"exclude": ["node_modules", "dist"]
}
3 changes: 3 additions & 0 deletions packages/sdk/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,7 @@ export default defineConfig({
globals: true,
environment: 'node',
},
typecheck: {
tsconfig: './tsconfig.typecheck.json',
},
});
4 changes: 4 additions & 0 deletions supabase/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ verify_jwt = false
key = "regional-auth-token-refresh"
verify_jwt = false

[functions."regional-auth-reconcile"]
key = "regional-auth-reconcile"
verify_jwt = false

# Regional Health Check
[functions."regional-health-check"]
key = "regional-health-check"
Expand Down
75 changes: 75 additions & 0 deletions supabase/functions/regional-auth-reconcile/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { serve } from 'https://deno.land/std@0.208.0/http/server.ts';
import { SUPPORTED_REGIONS } from '../_shared/regions.ts';
import { getRegionalSupabaseAdmin } from '../regional-auth/auth-utils.ts';
import { repairUserStateConsistency } from '../regional-auth/consistency-validators.ts';

serve(async (req: Request) => {
if (req.method !== 'POST') {
return new Response('Method not allowed', { status: 405 });
}

const reconciliationToken = Deno.env.get('REGIONAL_AUTH_RECONCILIATION_TOKEN');
if (!reconciliationToken || req.headers.get('authorization') !== `Bearer ${reconciliationToken}`) {
return new Response('Unauthorized', { status: 401 });
}

let scanned = 0;
let repaired = 0;
let pending = 0;
const failedRegions: string[] = [];

for (const region of SUPPORTED_REGIONS) {
const admin = getRegionalSupabaseAdmin(region);
const { data: records, error } = await admin
.from('auth_audit_logs')
.select('id, user_id, region, details')
.eq('event_type', 'failure')
.contains('details', { syncStatus: 'pending' })
.not('user_id', 'is', null)
.limit(100);

if (error) {
failedRegions.push(region);
console.error(`Failed to load pending signup repairs in ${region}:`, error.message);
continue;
}

for (const record of records ?? []) {
scanned++;
let isRepaired = false;

try {
const result = await repairUserStateConsistency(record.user_id, record.region);
isRepaired = result.repaired;
} catch (repairError) {
console.error(`Pending signup repair failed for ${record.user_id}:`, repairError);
}

const details = {
...((record.details ?? {}) as Record<string, unknown>),
syncStatus: isRepaired ? 'complete' : 'pending',
needsRepair: !isRepaired,
repairAttemptedAt: new Date().toISOString(),
};
const { error: updateError } = await admin
.from('auth_audit_logs')
.update({ details })
.eq('id', record.id);

if (updateError) {
failedRegions.push(region);
pending++;
console.error(`Failed to update signup repair record ${record.id}:`, updateError.message);
} else if (isRepaired) {
repaired++;
} else {
pending++;
}
}
}

return new Response(JSON.stringify({ scanned, repaired, pending, failedRegions }), {
status: failedRegions.length ? 503 : 200,
headers: { 'Content-Type': 'application/json' },
});
});
31 changes: 31 additions & 0 deletions supabase/functions/regional-auth/partial-sync-recovery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, it, vi } from 'vitest';
import { recoverPartialSignupSync } from './partial-sync-recovery.ts';

describe('partial signup sync recovery', () => {
it('records a pending repair and attempts immediate recovery', async () => {
const recordRetryableFailure = vi.fn().mockResolvedValue(undefined);
const repair = vi.fn().mockRejectedValue(new Error('secondary region unavailable'));
const onRepairFailure = vi.fn();

await recoverPartialSignupSync(
{
userId: 'user-1',
region: 'us-east-1',
requestId: 'request-1',
errors: { 'eu-west-1': 'connection timed out' },
},
{ recordRetryableFailure, repair, onRepairFailure },
);

expect(recordRetryableFailure).toHaveBeenCalledWith(
'request-1-sync-failure',
expect.objectContaining({
failedRegions: ['eu-west-1'],
syncStatus: 'pending',
needsRepair: true,
}),
);
expect(repair).toHaveBeenCalledOnce();
expect(onRepairFailure).toHaveBeenCalledWith(expect.any(Error));
});
});
29 changes: 29 additions & 0 deletions supabase/functions/regional-auth/partial-sync-recovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
export interface PartialSignupSyncFailure {
requestId: string;
errors: Record<string, string>;
}

export interface PartialSignupSyncRecoveryDependencies {
recordRetryableFailure(requestId: string, details: Record<string, unknown>): Promise<void>;
repair(): Promise<void>;
onRepairFailure(error: unknown): void;
}

export async function recoverPartialSignupSync(
failure: PartialSignupSyncFailure,
dependencies: PartialSignupSyncRecoveryDependencies,
): Promise<void> {
await dependencies.recordRetryableFailure(`${failure.requestId}-sync-failure`, {
reason: 'cross-region profile sync incomplete',
failedRegions: Object.keys(failure.errors),
errors: failure.errors,
syncStatus: 'pending',
needsRepair: true,
});

try {
await dependencies.repair();
} catch (error) {
dependencies.onRepairFailure(error);
}
}
34 changes: 11 additions & 23 deletions supabase/functions/regional-auth/sign-up.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
type AuthResponse,
} from './auth-utils.ts';
import { repairUserStateConsistency } from './consistency-validators.ts';
import { recoverPartialSignupSync } from './partial-sync-recovery.ts';

interface SignUpRequest {
email: string;
Expand Down Expand Up @@ -183,29 +184,16 @@ async function handleSignUp(req: Request): Promise<Response> {
);

if (!syncResult.synced) {
// #977 fix: a failed cross-region profile sync must not be silently
// dropped. We:
// 1. Write a durable audit record so operators can query partial-sync
// failures and the follow-up repair job has a discoverable trace.
// 2. Attempt an inline repair via repairUserStateConsistency() so the
// state is corrected as soon as possible without blocking the caller.
// The 201 response contract is unchanged — sign-up succeeds from the
// user's perspective regardless of cross-region replication status.

// Step 1: durable audit log with needsRepair flag
await logAuthEvent(userId, 'failure', region, `${requestId}-sync-failure`, {
reason: 'cross-region profile sync incomplete',
failedRegions: Object.keys(syncResult.errors),
errors: syncResult.errors,
needsRepair: true,
});

// Step 2: attempt inline repair (best-effort; errors are logged, not thrown)
try {
await repairUserStateConsistency(userId, region);
} catch (repairError) {
console.error(`Inline repair failed for user ${userId}:`, repairError);
}
await recoverPartialSignupSync(
{ requestId, errors: syncResult.errors },
{
recordRetryableFailure: (syncFailureRequestId, details) =>
logAuthEvent(userId, 'failure', region, syncFailureRequestId, details),
repair: () => repairUserStateConsistency(userId, region).then(() => undefined),
onRepairFailure: (repairError) =>
console.error(`Inline repair failed for user ${userId}:`, repairError),
},
);
}

// Log successful signup (includes sync outcome so operators have full picture)
Expand Down
34 changes: 10 additions & 24 deletions supabase/functions/regional-router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,7 @@

import { serve } from 'https://deno.land/std@0.208.0/http/server.ts';
import { SUPPORTED_REGIONS, getRegionalEndpointConfig, detectRegionFromRequest } from '../_shared/regions.ts';

interface RegionEndpoint {
region: string;
baseUrl: string;
priority: number;
}
import { NoHealthyRegionsError, orderHealthyEndpoints, type RegionEndpoint } from './routing-policy.ts';

interface RoutingDecision {
targetRegion: string;
Expand Down Expand Up @@ -86,22 +81,7 @@ async function makeRoutingDecision(
// Get health status of regions
const healthStatus = await getRegionHealthStatus();

// Sort endpoints by health and then by whether they match detected region
const sortedEndpoints = [...endpoints].sort((a, b) => {
const aHealthy = healthStatus.get(a.region) ?? false;
const bHealthy = healthStatus.get(b.region) ?? false;

// Prioritize healthy regions
if (aHealthy !== bHealthy) {
return aHealthy ? -1 : 1;
}

// Among healthy/unhealthy, prioritize detected region
const aMatches = a.region === detectedRegion ? 1 : 0;
const bMatches = b.region === detectedRegion ? 1 : 0;

return bMatches - aMatches;
});
const sortedEndpoints = orderHealthyEndpoints(endpoints, healthStatus, detectedRegion);

const selectedEndpoint = sortedEndpoints[0];
const reason =
Expand Down Expand Up @@ -192,7 +172,10 @@ async function handleRouting(req: Request): Promise<Response> {
error: 'Routing failed',
details: String(error),
}),
{ status: 502, headers: { 'Content-Type': 'application/json' } }
{
status: error instanceof NoHealthyRegionsError ? 503 : 502,
headers: { 'Content-Type': 'application/json' },
}
);
}
}
Expand All @@ -217,7 +200,10 @@ async function handleRoutingInfo(req: Request): Promise<Response> {
error: 'Failed to get routing information',
details: String(error),
}),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
{
status: error instanceof NoHealthyRegionsError ? 503 : 500,
headers: { 'Content-Type': 'application/json' },
}
);
}
}
Expand Down
Loading
Loading