Skip to content

Commit 7bcc28d

Browse files
committed
Add AuthDataMigrationJob model and related migration scripts
- Introduced the AuthDataMigrationJob model in the Prisma schema to manage authentication migration jobs. - Created SQL migration scripts to establish the AuthDataMigrationJob table with necessary constraints and indexes. - Implemented API routes for creating, listing, retrieving, and retrying auth migration jobs. - Added utility functions for handling encryption and decryption of migration credentials. - Developed tests to validate the functionality and constraints of the new migration job model.
1 parent f29ce37 commit 7bcc28d

25 files changed

Lines changed: 2305 additions & 69 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
CREATE TABLE "AuthDataMigrationJob" (
2+
"tenancyId" UUID NOT NULL,
3+
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
4+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
5+
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
6+
"projectId" TEXT NOT NULL,
7+
"branchId" TEXT NOT NULL,
8+
"provider" TEXT NOT NULL,
9+
"status" TEXT NOT NULL DEFAULT 'PENDING',
10+
"encryptedCredentials" JSONB NOT NULL,
11+
"createdByProjectUserId" UUID,
12+
"attemptCount" INTEGER NOT NULL DEFAULT 0,
13+
"maxAttempts" INTEGER NOT NULL DEFAULT 5,
14+
"nextAttemptAt" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP,
15+
"startedAt" TIMESTAMP(3),
16+
"finishedAt" TIMESTAMP(3),
17+
"lastErrorExternalMessage" TEXT,
18+
"lastErrorInternalDetails" JSONB,
19+
"result" JSONB,
20+
21+
CONSTRAINT "AuthDataMigrationJob_pkey" PRIMARY KEY ("tenancyId", "id"),
22+
CONSTRAINT "AuthDataMigrationJob_tenancyId_fkey" FOREIGN KEY ("tenancyId") REFERENCES "Tenancy"("id") ON DELETE CASCADE ON UPDATE CASCADE,
23+
CONSTRAINT "AuthDataMigrationJob_provider_valid" CHECK ("provider" IN ('workos', 'clerk', 'authjs', 'auth0', 'supabase', 'better_auth')),
24+
CONSTRAINT "AuthDataMigrationJob_status_valid" CHECK ("status" IN ('PENDING', 'RUNNING', 'WAITING_RETRY', 'SUCCEEDED', 'FAILED')),
25+
CONSTRAINT "AuthDataMigrationJob_attempts_valid" CHECK ("attemptCount" >= 0 AND "maxAttempts" > 0),
26+
CONSTRAINT "AuthDataMigrationJob_terminal_finished" CHECK (
27+
("status" IN ('SUCCEEDED', 'FAILED') AND "finishedAt" IS NOT NULL)
28+
OR ("status" NOT IN ('SUCCEEDED', 'FAILED'))
29+
)
30+
);
31+
32+
CREATE INDEX "AuthDataMigrationJob_queue_idx" ON "AuthDataMigrationJob"("status", "nextAttemptAt", "createdAt");
33+
CREATE INDEX "AuthDataMigrationJob_tenancy_created_idx" ON "AuthDataMigrationJob"("tenancyId", "createdAt");
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { randomUUID } from "crypto";
2+
import type { Sql } from "postgres";
3+
import { expect } from "vitest";
4+
5+
export const preMigration = async (sql: Sql) => {
6+
const projectId = `test-${randomUUID()}`;
7+
const tenancyId = randomUUID();
8+
9+
await sql`INSERT INTO "Project" ("id", "createdAt", "updatedAt", "displayName", "description", "isProductionMode") VALUES (${projectId}, NOW(), NOW(), 'Auth Migration Test', '', false)`;
10+
await sql`INSERT INTO "Tenancy" ("id", "createdAt", "updatedAt", "projectId", "branchId", "hasNoOrganization") VALUES (${tenancyId}::uuid, NOW(), NOW(), ${projectId}, 'main', 'TRUE'::"BooleanTrue")`;
11+
12+
return { projectId, tenancyId };
13+
};
14+
15+
export const postMigration = async (sql: Sql, ctx: Awaited<ReturnType<typeof preMigration>>) => {
16+
const jobId = randomUUID();
17+
18+
await sql`
19+
INSERT INTO "AuthDataMigrationJob" (
20+
"tenancyId", "id", "projectId", "branchId", "provider", "encryptedCredentials"
21+
)
22+
VALUES (
23+
${ctx.tenancyId}::uuid, ${jobId}::uuid, ${ctx.projectId}, 'main', 'better_auth', '{"ciphertext_base64":"abc"}'::jsonb
24+
)
25+
`;
26+
27+
const rows = await sql`
28+
SELECT "status", "attemptCount", "maxAttempts"
29+
FROM "AuthDataMigrationJob"
30+
WHERE "tenancyId" = ${ctx.tenancyId}::uuid AND "id" = ${jobId}::uuid
31+
`;
32+
expect(rows).toHaveLength(1);
33+
expect(rows[0].status).toBe("PENDING");
34+
expect(rows[0].attemptCount).toBe(0);
35+
expect(rows[0].maxAttempts).toBe(5);
36+
37+
await expect(sql`
38+
INSERT INTO "AuthDataMigrationJob" (
39+
"tenancyId", "projectId", "branchId", "provider", "status", "encryptedCredentials"
40+
)
41+
VALUES (
42+
${ctx.tenancyId}::uuid, ${ctx.projectId}, 'main', 'unknown', 'PENDING', '{"ciphertext_base64":"abc"}'::jsonb
43+
)
44+
`).rejects.toThrow(/AuthDataMigrationJob_provider_valid/);
45+
46+
await expect(sql`
47+
UPDATE "AuthDataMigrationJob"
48+
SET "status" = 'SUCCEEDED', "finishedAt" = NULL
49+
WHERE "tenancyId" = ${ctx.tenancyId}::uuid AND "id" = ${jobId}::uuid
50+
`).rejects.toThrow(/AuthDataMigrationJob_terminal_finished/);
51+
};

apps/backend/prisma/schema.prisma

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ model Tenancy {
7373
organizationId String? @db.Uuid
7474
hasNoOrganization BooleanTrue?
7575
emailOutboxes EmailOutbox[]
76+
authDataMigrationJobs AuthDataMigrationJob[]
7677
sessionReplays SessionReplay[]
7778
sessionReplayChunks SessionReplayChunk[]
7879
managedEmailDomains ManagedEmailDomain[]
@@ -1076,6 +1077,39 @@ model EmailOutboxProcessingMetadata {
10761077
lastExecutedAt DateTime?
10771078
}
10781079

1080+
model AuthDataMigrationJob {
1081+
tenancyId String @db.Uuid
1082+
id String @default(uuid()) @db.Uuid
1083+
1084+
createdAt DateTime @default(now())
1085+
updatedAt DateTime @updatedAt
1086+
1087+
projectId String
1088+
branchId String
1089+
provider String
1090+
status String @default("PENDING")
1091+
1092+
encryptedCredentials Json
1093+
createdByProjectUserId String? @db.Uuid
1094+
1095+
attemptCount Int @default(0)
1096+
maxAttempts Int @default(5)
1097+
nextAttemptAt DateTime? @default(now())
1098+
1099+
startedAt DateTime?
1100+
finishedAt DateTime?
1101+
1102+
lastErrorExternalMessage String?
1103+
lastErrorInternalDetails Json?
1104+
result Json?
1105+
1106+
tenancy Tenancy @relation(fields: [tenancyId], references: [id], onDelete: Cascade)
1107+
1108+
@@id([tenancyId, id])
1109+
@@index([status, nextAttemptAt, createdAt], map: "AuthDataMigrationJob_queue_idx")
1110+
@@index([tenancyId, createdAt], map: "AuthDataMigrationJob_tenancy_created_idx")
1111+
}
1112+
10791113
model EmailDraft {
10801114
tenancyId String @db.Uuid
10811115

apps/backend/scripts/run-cron-jobs.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Result } from "@stackframe/stack-shared/dist/utils/results";
66
const endpoints = [
77
"/api/latest/internal/external-db-sync/sequencer",
88
"/api/latest/internal/external-db-sync/poller",
9+
"/api/latest/internal/auth-migrations/process",
910
];
1011

1112
async function main() {
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { retryAuthMigrationJob } from "@/lib/auth-migrations/jobs";
2+
import { authMigrationProviders } from "@/lib/auth-migrations/types";
3+
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
4+
import { adaptSchema, adminAuthTypeSchema, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
5+
6+
const jobResponseSchema = yupObject({
7+
id: yupString().uuid().defined(),
8+
provider: yupString().oneOf(authMigrationProviders).defined(),
9+
status: yupString().oneOf(["PENDING", "RUNNING", "WAITING_RETRY", "SUCCEEDED", "FAILED"]).defined(),
10+
attempt_count: yupNumber().integer().defined(),
11+
max_attempts: yupNumber().integer().defined(),
12+
next_attempt_at_millis: yupNumber().nullable().defined(),
13+
started_at_millis: yupNumber().nullable().defined(),
14+
finished_at_millis: yupNumber().nullable().defined(),
15+
last_error_external_message: yupString().nullable().defined(),
16+
result: yupMixed().nullable(),
17+
created_at_millis: yupNumber().defined(),
18+
updated_at_millis: yupNumber().defined(),
19+
}).defined();
20+
21+
export const POST = createSmartRouteHandler({
22+
metadata: {
23+
hidden: true,
24+
summary: "Retry auth migration job",
25+
description: "Moves a failed or waiting provider migration job back to the pending queue.",
26+
tags: ["Migrations"],
27+
},
28+
request: yupObject({
29+
auth: yupObject({
30+
type: adminAuthTypeSchema.defined(),
31+
tenancy: adaptSchema.defined(),
32+
}).defined(),
33+
params: yupObject({
34+
id: yupString().uuid().defined(),
35+
}).defined(),
36+
}),
37+
response: yupObject({
38+
statusCode: yupNumber().oneOf([200]).defined(),
39+
bodyType: yupString().oneOf(["json"]).defined(),
40+
body: jobResponseSchema,
41+
}),
42+
handler: async ({ auth, params }) => {
43+
return {
44+
statusCode: 200,
45+
bodyType: "json",
46+
body: await retryAuthMigrationJob(auth.tenancy.id, params.id),
47+
};
48+
},
49+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { getAuthMigrationJob } from "@/lib/auth-migrations/jobs";
2+
import { authMigrationProviders } from "@/lib/auth-migrations/types";
3+
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
4+
import { adaptSchema, adminAuthTypeSchema, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
5+
6+
const jobResponseSchema = yupObject({
7+
id: yupString().uuid().defined(),
8+
provider: yupString().oneOf(authMigrationProviders).defined(),
9+
status: yupString().oneOf(["PENDING", "RUNNING", "WAITING_RETRY", "SUCCEEDED", "FAILED"]).defined(),
10+
attempt_count: yupNumber().integer().defined(),
11+
max_attempts: yupNumber().integer().defined(),
12+
next_attempt_at_millis: yupNumber().nullable().defined(),
13+
started_at_millis: yupNumber().nullable().defined(),
14+
finished_at_millis: yupNumber().nullable().defined(),
15+
last_error_external_message: yupString().nullable().defined(),
16+
result: yupMixed().nullable(),
17+
created_at_millis: yupNumber().defined(),
18+
updated_at_millis: yupNumber().defined(),
19+
}).defined();
20+
21+
export const GET = createSmartRouteHandler({
22+
metadata: {
23+
hidden: true,
24+
summary: "Get auth migration job",
25+
description: "Gets a provider migration job for the current project branch.",
26+
tags: ["Migrations"],
27+
},
28+
request: yupObject({
29+
auth: yupObject({
30+
type: adminAuthTypeSchema.defined(),
31+
tenancy: adaptSchema.defined(),
32+
}).defined(),
33+
params: yupObject({
34+
id: yupString().uuid().defined(),
35+
}).defined(),
36+
}),
37+
response: yupObject({
38+
statusCode: yupNumber().oneOf([200]).defined(),
39+
bodyType: yupString().oneOf(["json"]).defined(),
40+
body: jobResponseSchema,
41+
}),
42+
handler: async ({ auth, params }) => {
43+
return {
44+
statusCode: 200,
45+
bodyType: "json",
46+
body: await getAuthMigrationJob(auth.tenancy.id, params.id),
47+
};
48+
},
49+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { runAuthMigrationQueueStep } from "@/lib/auth-migrations/jobs";
2+
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
3+
import { yupNumber, yupObject, yupString, yupTuple } from "@stackframe/stack-shared/dist/schema-fields";
4+
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
5+
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
6+
7+
export const dynamic = "force-dynamic";
8+
export const fetchCache = "force-no-store";
9+
10+
export const GET = createSmartRouteHandler({
11+
metadata: {
12+
hidden: true,
13+
summary: "Process auth migration queue step",
14+
description: "Internal endpoint invoked by cron to advance auth provider migration jobs.",
15+
tags: ["Migrations"],
16+
},
17+
request: yupObject({
18+
auth: yupObject({}).nullable().optional(),
19+
method: yupString().oneOf(["GET"]).defined(),
20+
headers: yupObject({
21+
"authorization": yupTuple([yupString()]).defined(),
22+
}).defined(),
23+
}),
24+
response: yupObject({
25+
statusCode: yupNumber().oneOf([200]).defined(),
26+
bodyType: yupString().oneOf(["json"]).defined(),
27+
body: yupObject({
28+
claimed: yupNumber().integer().defined(),
29+
reset_stuck: yupNumber().integer().defined(),
30+
}).defined(),
31+
}),
32+
handler: async ({ headers }) => {
33+
const authHeader = headers.authorization[0];
34+
if (authHeader !== `Bearer ${getEnvVariable('CRON_SECRET')}`) {
35+
throw new StatusError(401, "Unauthorized");
36+
}
37+
38+
const result = await runAuthMigrationQueueStep();
39+
return {
40+
statusCode: 200,
41+
bodyType: "json",
42+
body: {
43+
claimed: result.claimed,
44+
reset_stuck: result.resetStuck,
45+
},
46+
};
47+
},
48+
});
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { createAuthMigrationJob, listAuthMigrationJobs } from "@/lib/auth-migrations/jobs";
2+
import { validateAuthMigrationCredentials } from "@/lib/auth-migrations/providers";
3+
import { authMigrationProviders, type AuthMigrationCredentials } from "@/lib/auth-migrations/types";
4+
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
5+
import { adaptSchema, adminAuthTypeSchema, yupArray, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
6+
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
7+
8+
const jobResponseSchema = yupObject({
9+
id: yupString().uuid().defined(),
10+
provider: yupString().oneOf(authMigrationProviders).defined(),
11+
status: yupString().oneOf(["PENDING", "RUNNING", "WAITING_RETRY", "SUCCEEDED", "FAILED"]).defined(),
12+
attempt_count: yupNumber().integer().defined(),
13+
max_attempts: yupNumber().integer().defined(),
14+
next_attempt_at_millis: yupNumber().nullable().defined(),
15+
started_at_millis: yupNumber().nullable().defined(),
16+
finished_at_millis: yupNumber().nullable().defined(),
17+
last_error_external_message: yupString().nullable().defined(),
18+
result: yupMixed().nullable(),
19+
created_at_millis: yupNumber().defined(),
20+
updated_at_millis: yupNumber().defined(),
21+
}).defined();
22+
23+
function assertCredentialsObject(credentials: unknown): AuthMigrationCredentials {
24+
if (typeof credentials !== "object" || credentials === null || Array.isArray(credentials)) {
25+
throw new StatusError(400, "credentials must be an object.");
26+
}
27+
return credentials as AuthMigrationCredentials;
28+
}
29+
30+
export const GET = createSmartRouteHandler({
31+
metadata: {
32+
hidden: true,
33+
summary: "List auth migration jobs",
34+
description: "Lists provider migration jobs for the current project branch.",
35+
tags: ["Migrations"],
36+
},
37+
request: yupObject({
38+
auth: yupObject({
39+
type: adminAuthTypeSchema.defined(),
40+
tenancy: adaptSchema.defined(),
41+
}).defined(),
42+
}),
43+
response: yupObject({
44+
statusCode: yupNumber().oneOf([200]).defined(),
45+
bodyType: yupString().oneOf(["json"]).defined(),
46+
body: yupObject({
47+
items: yupArray(jobResponseSchema).defined(),
48+
}).defined(),
49+
}),
50+
handler: async ({ auth }) => {
51+
return {
52+
statusCode: 200,
53+
bodyType: "json",
54+
body: {
55+
items: await listAuthMigrationJobs(auth.tenancy.id),
56+
},
57+
};
58+
},
59+
});
60+
61+
export const POST = createSmartRouteHandler({
62+
metadata: {
63+
hidden: true,
64+
summary: "Create auth migration job",
65+
description: "Creates a queued auth provider migration job for the current project branch.",
66+
tags: ["Migrations"],
67+
},
68+
request: yupObject({
69+
auth: yupObject({
70+
type: adminAuthTypeSchema.defined(),
71+
tenancy: adaptSchema.defined(),
72+
user: adaptSchema.optional(),
73+
}).defined(),
74+
body: yupObject({
75+
provider: yupString().oneOf(authMigrationProviders).defined(),
76+
credentials: yupMixed<AuthMigrationCredentials>().defined(),
77+
}).defined(),
78+
}),
79+
response: yupObject({
80+
statusCode: yupNumber().oneOf([200]).defined(),
81+
bodyType: yupString().oneOf(["json"]).defined(),
82+
body: jobResponseSchema,
83+
}),
84+
handler: async ({ auth, body }) => {
85+
const credentials = assertCredentialsObject(body.credentials);
86+
validateAuthMigrationCredentials(body.provider, credentials);
87+
const job = await createAuthMigrationJob({
88+
tenancyId: auth.tenancy.id,
89+
projectId: auth.tenancy.project.id,
90+
branchId: auth.tenancy.branchId,
91+
provider: body.provider,
92+
credentials,
93+
createdByProjectUserId: auth.user?.id ?? null,
94+
});
95+
return {
96+
statusCode: 200,
97+
bodyType: "json",
98+
body: job,
99+
};
100+
},
101+
});

0 commit comments

Comments
 (0)