Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improved documentation, fixed bugs #147

Merged
merged 24 commits into from
Jul 24, 2024
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,7 @@ docs/docs/reference/adapter

# Sentry Config File
.sentryclirc

# python
__pycache__/
.venv/
4 changes: 2 additions & 2 deletions apps/backend/scripts/generate-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ async function main() {
for (const audience of ['client', 'server', 'admin'] as const) {
const filePathPrefix = "src/app/api/v1";
const importPathPrefix = "@/app/api/v1";
const filePaths = await glob(filePathPrefix + "/**/route.{js,jsx,ts,tsx}");
const filePaths = [...await glob(filePathPrefix + "/**/route.{js,jsx,ts,tsx}")];
const openAPISchema = yaml.stringify(parseOpenAPI({
endpoints: new Map(await Promise.all(filePaths.map(async (filePath) => {
if (!filePath.startsWith(filePathPrefix)) {
Expand All @@ -18,7 +18,7 @@ async function main() {
const suffix = filePath.slice(filePathPrefix.length);
const midfix = suffix.slice(0, suffix.lastIndexOf("/route."));
const importPath = `${importPathPrefix}${suffix}`;
const urlPath = midfix.replace("[", "{").replace("]", "}");
const urlPath = midfix.replaceAll("[", "{").replaceAll("]", "}");
const module = require(importPath);
const handlersByMethod = new Map(
HTTP_METHODS.map(method => [method, module[method]] as const)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export const GET = createSmartRouteHandler({
response: yupObject({
// we never return as we always redirect
statusCode: yupNumber().oneOf([302]).required(),
bodyType: yupString().oneOf(["empty"]).required(),
}),
async handler({ params, query }, fullReq) {
const project = await getProject(query.client_id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { StackAssertionError, StatusError, throwErr } from "@stackframe/stack-sh
import { extractScopes } from "@stackframe/stack-shared/dist/utils/strings";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { oauthResponseToSmartResponse } from "../../oauth-helpers";

const redirectOrThrowError = (error: KnownError, project: ProjectsCrud["Admin"]["Read"], errorRedirectUrl?: string) => {
if (!errorRedirectUrl || !validateRedirectUrl(errorRedirectUrl, project.config.domains, project.config.allow_localhost)) {
Expand All @@ -33,7 +34,7 @@ export const GET = createSmartRouteHandler({
query: yupMixed().required(),
}),
response: yupObject({
statusCode: yupNumber().required(),
statusCode: yupNumber().oneOf([302]).required(),
bodyType: yupString().oneOf(["json"]).required(),
body: yupMixed().required(),
headers: yupMixed().required(),
Expand All @@ -44,7 +45,7 @@ export const GET = createSmartRouteHandler({
cookies().delete("stack-oauth-inner-" + query.state);

if (cookieInfo?.value !== 'true') {
throw new StatusError(StatusError.BadRequest, "stack-oauth cookie not found");
throw new StatusError(StatusError.BadRequest, "stack-oauth-inner-<xyz> cookie not found");
}

const outerInfoDB = await prismaClient.oAuthOuterInfo.findUnique({
Expand All @@ -54,7 +55,7 @@ export const GET = createSmartRouteHandler({
});

if (!outerInfoDB) {
throw new StatusError(StatusError.BadRequest, "Invalid stack-oauth cookie value. Please try signing in again.");
throw new StatusError(StatusError.BadRequest, "Invalid stack-oauth-inner-<xyz> cookie value. Please try signing in again.");
}

let outerInfo: Awaited<ReturnType<typeof oauthCookieSchema.validate>>;
Expand Down Expand Up @@ -275,11 +276,6 @@ export const GET = createSmartRouteHandler({
throw error;
}

return {
statusCode: oauthResponse.status || 200,
bodyType: "json",
body: oauthResponse.body,
headers: Object.fromEntries(Object.entries(oauthResponse.headers || {}).map(([k, v]) => ([k, [v]]))),
};
return oauthResponseToSmartResponse(oauthResponse);
},
});
46 changes: 46 additions & 0 deletions apps/backend/src/app/api/v1/auth/oauth/oauth-helpers.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { SmartResponse } from "@/route-handlers/smart-response";
import { Response as OAuthResponse } from "@node-oauth/oauth2-server";
import { StackAssertionError, StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";

export function oauthResponseToSmartResponse(oauthResponse: OAuthResponse): SmartResponse {
if (!oauthResponse.status) {
throw new StackAssertionError(`OAuth response status is missing`, { oauthResponse });
} else if (oauthResponse.status >= 500 && oauthResponse.status < 600) {
throw new StackAssertionError(`OAuth server error: ${JSON.stringify(oauthResponse.body)}`, { oauthResponse });
} else if (oauthResponse.status >= 400 && oauthResponse.status < 500) {
throw new StatusError(oauthResponse.status, oauthResponse.body);
} else if (oauthResponse.status >= 200 && oauthResponse.status < 400) {
return {
statusCode: oauthResponse.status,
bodyType: "json",
body: oauthResponse.body,
headers: Object.fromEntries(Object.entries(oauthResponse.headers || {}).map(([k, v]) => ([k, [v]]))),
};
} else {
throw new StackAssertionError(`Invalid OAuth response status code: ${oauthResponse.status}`, { oauthResponse });
}
}

export abstract class OAuthResponseError extends StatusError {
public name = "OAuthResponseError";

constructor(
public readonly oauthResponse: OAuthResponse
) {
super(
oauthResponse.status ?? throwErr(`OAuth response status is missing`),
JSON.stringify(oauthResponse.body),
);
}

public override getBody(): Uint8Array {
return new TextEncoder().encode(JSON.stringify(this.oauthResponse.body, undefined, 2));
}

public override getHeaders(): Record<string, string[]> {
return {
"Content-Type": ["application/json; charset=utf-8"],
...Object.fromEntries(Object.entries(this.oauthResponse.headers || {}).map(([k, v]) => ([k, [v]]))),
};
}
}
10 changes: 3 additions & 7 deletions apps/backend/src/app/api/v1/auth/oauth/token/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { InvalidClientError, InvalidGrantError, Request as OAuthRequest, Response as OAuthResponse } from "@node-oauth/oauth2-server";
import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors";
import { yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { oauthResponseToSmartResponse } from "../oauth-helpers";

export const POST = createSmartRouteHandler({
metadata: {
Expand All @@ -12,7 +13,7 @@ export const POST = createSmartRouteHandler({
},
request: yupObject({}),
response: yupObject({
statusCode: yupNumber().required(),
statusCode: yupNumber().oneOf([200]).required(),
bodyType: yupString().oneOf(["json"]).required(),
body: yupMixed().required(),
headers: yupMixed().required(),
Expand Down Expand Up @@ -50,11 +51,6 @@ export const POST = createSmartRouteHandler({
throw e;
}

return {
statusCode: oauthResponse.status || 200,
bodyType: "json",
body: oauthResponse.body,
headers: Object.fromEntries(Object.entries(oauthResponse.headers || {}).map(([k, v]) => ([k, [v]]))),
};
return oauthResponseToSmartResponse(oauthResponse);
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const signInVerificationCodeHandler = createVerificationCodeHandler({
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).required(),
bodyType: yupString().oneOf(["json"]).required(),
body: signInResponseSchema.required(),
}),
async send(codeObj, createOptions, sendOptions: { user: UsersCrud["Admin"]["Read"] }) {
Expand Down Expand Up @@ -64,6 +65,7 @@ export const signInVerificationCodeHandler = createVerificationCodeHandler({

return {
statusCode: 200,
bodyType: "json",
body: {
refresh_token: refreshToken,
access_token: accessToken,
Expand Down
8 changes: 4 additions & 4 deletions apps/backend/src/app/api/v1/auth/sessions/current/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,15 @@ export const DELETE = createSmartRouteHandler({
project: adaptSchema,
}).required(),
headers: yupObject({
"x-stack-refresh-token": yupTuple([yupString()]),
"x-stack-refresh-token": yupTuple([yupString().required()]).required(),
}),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).required(),
bodyType: yupString().oneOf(["empty"]).required(),
bodyType: yupString().oneOf(["success"]).required(),
}),
async handler({ auth: { project }, headers: { "x-stack-refresh-token": refreshTokenHeaders } }) {
if (!refreshTokenHeaders || !refreshTokenHeaders[0]) {
if (!refreshTokenHeaders[0]) {
throw new StackAssertionError("Signing out without the refresh token is currently not supported. TODO: implement");
}
const refreshToken = refreshTokenHeaders[0];
Expand All @@ -50,7 +50,7 @@ export const DELETE = createSmartRouteHandler({

return {
statusCode: 200,
bodyType: "empty",
bodyType: "success",
};
},
});
3 changes: 3 additions & 0 deletions apps/backend/src/app/api/v1/check-feature-support/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { deindent, typedCapitalize } from "@stackframe/stack-shared/dist/utils/s
import { yupObject, yupString, yupNumber, yupMixed } from "@stackframe/stack-shared/dist/schema-fields";

export const POST = createSmartRouteHandler({
metadata: {
hidden: true,
},
request: yupObject({
auth: yupObject({
type: yupMixed(),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import * as yup from "yup";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { adaptSchema, clientOrHigherAuthTypeSchema, signInEmailSchema, emailVerificationCallbackUrlSchema, yupObject, yupNumber, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { KnownErrors } from "@stackframe/stack-shared";
import { adaptSchema, clientOrHigherAuthTypeSchema, emailVerificationCallbackUrlSchema, signInEmailSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { contactChannelVerificationCodeHandler } from "../verify/verification-code-handler";

export const POST = createSmartRouteHandler({
Expand All @@ -25,7 +24,7 @@ export const POST = createSmartRouteHandler({
statusCode: yupNumber().oneOf([200]).required(),
bodyType: yupString().oneOf(["success"]).required(),
}),
async handler({ auth: { project, user }, body: { email, callback_url: callbackUrl } }, fullReq) {
async handler({ auth: { project, user }, body: { email, callback_url: callbackUrl } }) {
if (user.primary_email !== email) {
throw new KnownErrors.EmailIsNotPrimaryEmail(email, user.primary_email);
}
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/app/api/v1/internal/projects/crud.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export const internalProjectsCrudHandlers = createCrudHandlers(internalProjectsC
if (!auth.user) {
throw new KnownErrors.UserAuthenticationRequired();
}
if (auth.user.project_id !== 'internal') {
if (auth.project.id !== "internal") {
throw new KnownErrors.ExpectedInternalProject();
}
},
Expand Down
20 changes: 17 additions & 3 deletions apps/backend/src/app/api/v1/route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { adaptSchema, projectIdSchema, yupNumber, yupObject, yupString, yupTuple } from "@stackframe/stack-shared/dist/schema-fields";
import { deindent, typedCapitalize } from "@stackframe/stack-shared/dist/utils/strings";
import * as yup from "yup";
import { adaptSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";

export const GET = createSmartRouteHandler({
metadata: {
summary: "/api/v1",
description: "Returns a human-readable message with some useful information about the API.",
tags: [],
},
request: yupObject({
auth: yupObject({
type: adaptSchema,
Expand All @@ -14,12 +18,22 @@ export const GET = createSmartRouteHandler({
// No query parameters
// empty object means that it will fail if query parameters are given regardless
}),
headers: yupObject({
// we list all automatically parsed headers here so the documentation shows them
"X-Stack-Project-Id": yupTuple([projectIdSchema]),
"X-Stack-Access-Type": yupTuple([yupString().oneOf(["client", "server", "admin"])]),
"X-Stack-Access-Token": yupTuple([yupString()]),
"X-Stack-Refresh-Token": yupTuple([yupString()]),
"X-Stack-Publishable-Client-Key": yupTuple([yupString()]),
"X-Stack-Secret-Server-Key": yupTuple([yupString()]),
"X-Stack-Super-Secret-Admin-Key": yupTuple([yupString()]),
}),
method: yupString().oneOf(["GET"]).required(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).required(),
bodyType: yupString().oneOf(["text"]).required(),
body: yupString().required(),
body: yupString().required().meta({ openapiField: { exampleValue: "Welcome to the Stack API endpoint! Please refer to the documentation at https://docs.stack-auth.com/\n\nAuthentication: None" } }),
}),
handler: async (req) => {
return {
Expand Down
1 change: 0 additions & 1 deletion apps/backend/src/app/api/v1/team-memberships/crud.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { isTeamSystemPermission, teamSystemPermissionStringToDBType } from "@/li
import { prismaClient } from "@/prisma-client";
import { createCrudHandlers } from "@/route-handlers/crud-handler";
import { getIdFromUserIdOrMe } from "@/route-handlers/utils";
import { KnownErrors } from "@stackframe/stack-shared";
import { teamMembershipsCrud } from "@stackframe/stack-shared/dist/interface/crud/team-memberships";
import { userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";

Expand Down
8 changes: 4 additions & 4 deletions apps/backend/src/app/api/v1/team-permissions/crud.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";

export const teamPermissionsCrudHandlers = createCrudHandlers(teamPermissionsCrud, {
querySchema: yupObject({
team_id: yupString().uuid().optional(),
user_id: userIdOrMeSchema.optional(),
permission_id: teamPermissionDefinitionIdSchema.optional(),
recursive: yupString().oneOf(['true', 'false']).optional(),
team_id: yupString().uuid().optional().meta({ openapiField: { description: 'Filter with the team ID. If set, only the permissions of the members in a specific team will be returned.', exampleValue: 'cce084a3-28b7-418e-913e-c8ee6d802ea4' } }),
user_id: userIdOrMeSchema.optional().meta({ openapiField: { description: 'Filter with the user ID. If set, only the permissions this user has will be returned. Client request must set `user_id=me`', exampleValue: 'me' } }),
permission_id: teamPermissionDefinitionIdSchema.optional().meta({ openapiField: { description: 'Filter with the permission ID. If set, only the permissions with this specific ID will be returned', exampleValue: '16399452-c4f3-4554-8e44-c2d67bb60360' } }),
recursive: yupString().oneOf(['true', 'false']).optional().meta({ openapiField: { description: 'Whether to list permissions recursively. If set to `false`, only the permission the users directly have will be listed. If set to `true` all the direct and indirect permissions will be listed.', exampleValue: 'true' } }),
}),
paramsSchema: yupObject({
team_id: yupString().uuid().required(),
Expand Down
9 changes: 5 additions & 4 deletions apps/backend/src/app/api/v1/teams/crud.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { KnownErrors } from "@stackframe/stack-shared";
import { teamsCrud } from "@stackframe/stack-shared/dist/interface/crud/teams";
import { userIdOrMeSchema, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies";


export function teamPrismaToCrud(prisma: Prisma.TeamGetPayload<{}>) {
Expand All @@ -20,10 +21,10 @@ export function teamPrismaToCrud(prisma: Prisma.TeamGetPayload<{}>) {
};
}

export const teamsCrudHandlers = createCrudHandlers(teamsCrud, {
export const teamsCrudHandlers = createLazyProxy(() => createCrudHandlers(teamsCrud, {
querySchema: yupObject({
user_id: userIdOrMeSchema.optional(),
add_current_user: yupString().oneOf(["true", "false"]).optional(),
user_id: userIdOrMeSchema.optional().meta({ openapiField: { onlyShowInOperations: ['List'], description: 'Filter for the teams that the user is a member of. Can be either `me` or an ID. Must be `me` in the client API', exampleValue: 'me' } }),
add_current_user: yupString().oneOf(["true", "false"]).optional().meta({ openapiField: { onlyShowInOperations: ['Create'], description: "If to add the current user to the team. If this is not `true`, the newly created team will have no members. Notice that if you didn't specify `add_current_user=true` on the client side, the user cannot join the team again without re-adding them on the server side.", exampleValue: 'true' } }),
}),
paramsSchema: yupObject({
team_id: yupString().uuid().required(),
Expand Down Expand Up @@ -174,4 +175,4 @@ export const teamsCrudHandlers = createCrudHandlers(teamsCrud, {
is_paginated: false,
};
}
});
}));
3 changes: 1 addition & 2 deletions apps/backend/src/app/api/v1/users/crud.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ const prismaToCrud = (prisma: Prisma.ProjectUserGetPayload<{ include: typeof ful
throw new StackAssertionError("User cannot have more than one selected team; this should never happen");
}
return {
project_id: prisma.projectId,
id: prisma.projectUserId,
display_name: prisma.displayName || null,
primary_email: prisma.primaryEmail,
Expand All @@ -52,7 +51,7 @@ const prismaToCrud = (prisma: Prisma.ProjectUserGetPayload<{ include: typeof ful

export const usersCrudHandlers = createLazyProxy(() => createCrudHandlers(usersCrud, {
querySchema: yupObject({
team_id: yupString().uuid().optional(),
team_id: yupString().uuid().optional().meta({ openapiField: { onlyShowInOperations: [ 'List' ] }})
}),
paramsSchema: yupObject({
user_id: userIdOrMeSchema.required(),
Expand Down
Loading
Loading