Skip to content

Commit

Permalink
Improved documentation, fixed bugs (#147)
Browse files Browse the repository at this point in the history
Co-authored-by: Konsti Wohlwend <[email protected]>
  • Loading branch information
fomalhautb and N2D4 authored Jul 24, 2024
1 parent 1d31561 commit 4ec03d0
Show file tree
Hide file tree
Showing 43 changed files with 895 additions and 388 deletions.
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

0 comments on commit 4ec03d0

Please sign in to comment.