Skip to content

Commit

Permalink
Merge branch 'dev' into api-migration
Browse files Browse the repository at this point in the history
  • Loading branch information
fomalhautb committed Dec 24, 2024
2 parents fb0af98 + f364b6b commit d89ae9f
Show file tree
Hide file tree
Showing 46 changed files with 445 additions and 527 deletions.
3 changes: 3 additions & 0 deletions .github/workflows/e2e-api-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ jobs:
- name: Run tests again, to make sure they are stable (attempt 3)
run: pnpm test

- name: Verify data integrity
run: pnpm run verify-data-integrity

- name: Print Docker Compose logs
if: always()
run: docker compose -f dependencies.compose.yaml logs
4 changes: 3 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"ctsx",
"datapoints",
"deindent",
"deindented",
"EAUTH",
"EDNS",
"EMESSAGE",
Expand Down Expand Up @@ -95,5 +96,6 @@
"**/$KNOWN_TOOLS$/**",
"**/start-server.js",
"**/turbo/**"
]
],
"files.insertFinalNewline": true
}
6 changes: 2 additions & 4 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
"watch-docs": "pnpm run with-env tsx watch --clear-screen=false scripts/generate-docs.ts",
"generate-docs": "pnpm run with-env tsx scripts/generate-docs.ts",
"generate-keys": "pnpm run with-env tsx scripts/generate-keys.ts",
"db-seed-script": "pnpm run with-env tsx prisma/seed.ts"
"db-seed-script": "pnpm run with-env tsx prisma/seed.ts",
"verify-data-integrity": "pnpm run with-env tsx scripts/verify-data-integrity.ts"
},
"prisma": {
"seed": "pnpm run db-seed-script"
Expand All @@ -51,7 +52,6 @@
"@simplewebauthn/server": "^11.0.0",
"@stackframe/stack-emails": "workspace:*",
"@stackframe/stack-shared": "workspace:*",
"@vercel/analytics": "^1.2.2",
"@vercel/functions": "^1.4.2",
"@vercel/otel": "^1.10.0",
"bcrypt": "^5.1.1",
Expand All @@ -62,13 +62,11 @@
"oidc-provider": "^8.5.1",
"openid-client": "^5.6.4",
"oslo": "^1.2.1",
"pg": "^8.11.3",
"posthog-node": "^4.1.0",
"prettier": "^3.4.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"semver": "^7.6.3",
"server-only": "^0.0.1",
"sharp": "^0.32.6",
"svix": "^1.25.0",
"yaml": "^2.4.5",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- Some older versions allowed the empty string for OAuth provider clientId and clientSecret values.
-- We fix that.

UPDATE "StandardOAuthProviderConfig" SET "clientId" = 'invalid' WHERE "clientId" = '';
UPDATE "StandardOAuthProviderConfig" SET "clientSecret" = 'invalid' WHERE "clientSecret" = '';
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- Some older versions allowed the empty string as a team profile image.
-- We fix that.

UPDATE "Team" SET "profileImageUrl" = NULL WHERE "profileImageUrl" = '';
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- Some older versions allowed http:// URLs as trusted domains, instead of just https://.
-- We fix that.

UPDATE "ProjectDomain" SET "domain" = 'https://example.com' WHERE "domain" LIKE 'http://%';
163 changes: 163 additions & 0 deletions apps/backend/scripts/verify-data-integrity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { PrismaClient } from "@prisma/client";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
import { filterUndefined } from "@stackframe/stack-shared/dist/utils/objects";
import { wait } from "@stackframe/stack-shared/dist/utils/promises";
import { deindent } from "@stackframe/stack-shared/dist/utils/strings";

const prismaClient = new PrismaClient();

async function main() {
console.log();
console.log();
console.log();
console.log();
console.log();
console.log();
console.log();
console.log();
console.log("===================================================");
console.log("Welcome to verify-data-integrity.ts.");
console.log();
console.log("This script will ensure that the data in the");
console.log("database is not corrupted.");
console.log();
console.log("It will call the most important endpoints for");
console.log("each project and every user, and ensure that");
console.log("the status codes are what they should be.");
console.log();
console.log("It's a good idea to run this script on REPLICAS");
console.log("of the production database regularly (not the actual");
console.log("prod db!); it should never fail at any point in time.");
console.log();
console.log("");
console.log("\x1b[41mIMPORTANT\x1b[0m: This script may modify");
console.log("the database during its execution in all sorts of");
console.log("ways, so don't run it on production!");
console.log();
console.log("===================================================");
console.log();
console.log();
console.log();
console.log();
console.log();
console.log();
console.log();
console.log();
console.log("Starting in 3 seconds...");
await wait(1000);
console.log("2...");
await wait(1000);
console.log("1...");
await wait(1000);
console.log();
console.log();
console.log();
console.log();


const projects = await prismaClient.project.findMany({
select: {
id: true,
displayName: true,
},
orderBy: {
id: "asc",
},
});
console.log(`Found ${projects.length} projects, iterating over them.`);

for (let i = 0; i < projects.length; i++) {
const projectId = projects[i].id;
await recurse(`[project ${i + 1}/${projects.length}] ${projectId} ${projects[i].displayName}`, async (recurse) => {
await Promise.all([
expectStatusCode(200, `/api/v1/projects/current`, {
method: "GET",
headers: {
"x-stack-project-id": projectId,
"x-stack-access-type": "admin",
"x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"),
},
}),
expectStatusCode(200, `/api/v1/users`, {
method: "GET",
headers: {
"x-stack-project-id": projectId,
"x-stack-access-type": "admin",
"x-stack-development-override-key": getEnvVariable("STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"),
},
}),
]);
});
}

console.log();
console.log();
console.log();
console.log();
console.log();
console.log();
console.log();
console.log();
console.log("===================================================");
console.log("All good!");
console.log();
console.log("Goodbye.");
console.log("===================================================");
console.log();
console.log();
}
main().catch((...args) => {
console.error();
console.error();
console.error(`\x1b[41mERROR\x1b[0m! Could not verify data integrity. See the error message for more details.`);
console.error(...args);
process.exit(1);
});

async function expectStatusCode(expectedStatusCode: number, endpoint: string, request: RequestInit) {
const apiUrl = new URL(getEnvVariable("NEXT_PUBLIC_STACK_API_URL"));
const response = await fetch(new URL(endpoint, apiUrl), {
...request,
headers: {
"x-stack-disable-artificial-development-delay": "yes",
"x-stack-development-disable-extended-logging": "yes",
...filterUndefined(request.headers ?? {}),
},
});
if (response.status !== expectedStatusCode) {
throw new StackAssertionError(deindent`
Expected status code ${expectedStatusCode} but got ${response.status} for ${endpoint}:
${await response.text()}
`, { request, response });
}
const json = await response.json();
return json;
}

let lastProgress = performance.now() - 9999999999;

type RecurseFunction = (progressPrefix: string, inner: (recurse: RecurseFunction) => Promise<void>) => Promise<void>;

const _recurse = async (progressPrefix: string | ((...args: any[]) => void), inner: Parameters<RecurseFunction>[1]): Promise<void> => {
const progressFunc = typeof progressPrefix === "function" ? progressPrefix : (...args: any[]) => {
console.log(`${progressPrefix}`, ...args);
};
if (performance.now() - lastProgress > 1000) {
progressFunc();
lastProgress = performance.now();
}
try {
return await inner(
(progressPrefix, inner) => _recurse(
(...args) => progressFunc(progressPrefix, ...args),
inner,
),
);
} catch (error) {
progressFunc(`\x1b[41mERROR\x1b[0m!`);
throw error;
}
};
const recurse: RecurseFunction = _recurse;
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export const POST = createSmartRouteHandler({
code: yupString().defined(),
}).defined(),
}),
async handler({ auth: { project }}) {
async handler({ auth: { project } }) {

if (!project.config.passkey_enabled) {
throw new KnownErrors.PasskeyAuthenticationNotEnabled();
Expand All @@ -45,7 +45,7 @@ export const POST = createSmartRouteHandler({
});


const {code} = await passkeySignInVerificationCodeHandler.createCode({
const { code } = await passkeySignInVerificationCodeHandler.createCode({
project,
method: {},
expiresInMs: SIGN_IN_TIMEOUT_MS + 5000,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export const POST = createSmartRouteHandler({
code: yupString().defined(),
}),
}),
async handler({ auth: { project, user }}) {
async handler({ auth: { project, user } }) {
if (!project.config.passkey_enabled) {
throw new KnownErrors.PasskeyAuthenticationNotEnabled();
}
Expand All @@ -56,7 +56,7 @@ export const POST = createSmartRouteHandler({

const registrationOptions = await generateRegistrationOptions(opts);

const {code} = await registerVerificationCodeHandler.createCode({
const { code } = await registerVerificationCodeHandler.createCode({
project,
method: {},
expiresInMs: REGISTRATION_TIMEOUT_MS + 5000,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export const registerVerificationCodeHandler = createVerificationCodeHandler({
async send() {
throw new StackAssertionError("send() called on a Passkey registration verification code handler");
},
async handler(project, _, {challenge}, {credential}, user) {
async handler(project, _, { challenge }, { credential }, user) {
if (!project.config.passkey_enabled) {
throw new KnownErrors.PasskeyAuthenticationNotEnabled();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { prismaClient } from "@/prisma-client";
import { createVerificationCodeHandler } from "@/route-handlers/verification-code-handler";
import { VerificationCodeType } from "@prisma/client";
import { verifyAuthenticationResponse } from "@simplewebauthn/server";
import {decodeClientDataJSON} from "@simplewebauthn/server/helpers";
import { decodeClientDataJSON } from "@simplewebauthn/server/helpers";
import { KnownErrors } from "@stackframe/stack-shared";
import { signInResponseSchema, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { captureError, StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
Expand Down Expand Up @@ -36,7 +36,7 @@ export const passkeySignInVerificationCodeHandler = createVerificationCodeHandle
async send() {
throw new StackAssertionError("send() called on a Passkey sign in verification code handler");
},
async handler(project, _, {challenge}, {authentication_response}) {
async handler(project, _, { challenge }, { authentication_response }) {

if (!project.config.passkey_enabled) {
throw new KnownErrors.PasskeyAuthenticationNotEnabled();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,14 @@ import * as yup from "yup";
const oauthProviderReadSchema = yupObject({
id: schemaFields.oauthIdSchema.defined(),
type: schemaFields.oauthTypeSchema.defined(),
client_id: schemaFields.yupDefinedWhen(schemaFields.oauthClientIdSchema, 'type', 'standard'),
client_secret: schemaFields.yupDefinedWhen(schemaFields.oauthClientSecretSchema, 'type', 'standard'),
client_id: schemaFields.yupDefinedAndNonEmptyWhen(schemaFields.oauthClientIdSchema, {
when: 'type',
is: 'standard',
}),
client_secret: schemaFields.yupDefinedAndNonEmptyWhen(schemaFields.oauthClientSecretSchema, {
when: 'type',
is: 'standard',
}),

// extra params
facebook_config_id: schemaFields.oauthFacebookConfigIdSchema.optional(),
Expand All @@ -24,8 +30,14 @@ const oauthProviderReadSchema = yupObject({

const oauthProviderUpdateSchema = yupObject({
type: schemaFields.oauthTypeSchema.optional(),
client_id: schemaFields.yupDefinedWhen(schemaFields.oauthClientIdSchema, 'type', 'standard').optional(),
client_secret: schemaFields.yupDefinedWhen(schemaFields.oauthClientSecretSchema, 'type', 'standard').optional(),
client_id: schemaFields.yupDefinedAndNonEmptyWhen(schemaFields.oauthClientIdSchema, {
when: 'type',
is: 'standard',
}).optional(),
client_secret: schemaFields.yupDefinedAndNonEmptyWhen(schemaFields.oauthClientSecretSchema, {
when: 'type',
is: 'standard',
}).optional(),

// extra params
facebook_config_id: schemaFields.oauthFacebookConfigIdSchema.optional(),
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/app/api/v1/team-invitations/crud.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { teamInvitationCodeHandler } from "./accept/verification-code-handler";

export const teamInvitationsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamInvitationCrud, {
querySchema: yupObject({
team_id: yupString().uuid().defined().meta({ openapiField: { onlyShowInOperations: ['List'] }}),
team_id: yupString().uuid().defined().meta({ openapiField: { onlyShowInOperations: ['List'] } }),
}),
paramsSchema: yupObject({
id: yupString().uuid().defined(),
Expand Down
4 changes: 2 additions & 2 deletions apps/backend/src/app/api/v1/team-member-profiles/crud.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ function prismaToCrud(prisma: Prisma.TeamMemberGetPayload<{ include: typeof full

export const teamMemberProfilesCrudHandlers = createLazyProxy(() => createCrudHandlers(teamMemberProfilesCrud, {
querySchema: yupObject({
user_id: userIdOrMeSchema.optional().meta({ openapiField: { onlyShowInOperations: ['List'] }}),
team_id: yupString().uuid().optional().meta({ openapiField: { onlyShowInOperations: ['List'] }}),
user_id: userIdOrMeSchema.optional().meta({ openapiField: { onlyShowInOperations: ['List'] } }),
team_id: yupString().uuid().optional().meta({ openapiField: { onlyShowInOperations: ['List'] } }),
}),
paramsSchema: yupObject({
team_id: yupString().uuid().defined(),
Expand Down
12 changes: 6 additions & 6 deletions apps/backend/src/app/api/v1/users/crud.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -269,12 +269,12 @@ export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersC
user_id: userIdOrMeSchema.defined(),
}),
querySchema: yupObject({
team_id: yupString().uuid().optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "Only return users who are members of the given team" }}),
limit: yupNumber().integer().min(1).optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "The maximum number of items to return" }}),
cursor: yupString().optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "The cursor to start the result set from." }}),
order_by: yupString().oneOf(['signed_up_at']).optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "The field to sort the results by. Defaults to signed_up_at" }}),
desc: yupBoolean().optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "Whether to sort the results in descending order. Defaults to false" }}),
query: yupString().optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "A search query to filter the results by. This is a free-text search that is applied to the user's display name and primary email." }}),
team_id: yupString().uuid().optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "Only return users who are members of the given team" } }),
limit: yupNumber().integer().min(1).optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "The maximum number of items to return" } }),
cursor: yupString().optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "The cursor to start the result set from." } }),
order_by: yupString().oneOf(['signed_up_at']).optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "The field to sort the results by. Defaults to signed_up_at" } }),
desc: yupBoolean().optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "Whether to sort the results in descending order. Defaults to false" } }),
query: yupString().optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ], description: "A search query to filter the results by. This is a free-text search that is applied to the user's display name and primary email." } }),
}),
onRead: async ({ auth, params }) => {
const user = await getUser({ projectId: auth.project.id, userId: params.user_id });
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/lib/openapi.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export function parseWebhookOpenAPI(options: {
method: 'POST',
path: webhook.type,
requestBodyDesc: undefinedIfMixed(yupObject({
type: yupString().defined().meta({ openapiField: { description: webhook.type, exampleValue: webhook.type }}),
type: yupString().defined().meta({ openapiField: { description: webhook.type, exampleValue: webhook.type } }),
data: webhook.schema.defined(),
}).describe()) || yupObject().describe(),
responseTypeDesc: yupString().oneOf(['json']).describe(),
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/lib/webhooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ async function sendWebhooks(options: {
} catch (e: any) {
if (e.message.includes("409")) {
// This is a Svix bug; they are working on fixing it
// TODO next-release: remove this once it no longer appears on Sentry
// TODO: remove this once it no longer appears on Sentry
captureError("svix-409-hack", "Svix bug: 409 error when creating application. Remove this warning once Svix fixes this.");
} else {
throw e;
Expand Down
Loading

0 comments on commit d89ae9f

Please sign in to comment.