diff --git a/packages/stack-server/src/app/(main)/(protected)/projects/[projectId]/layout.tsx b/packages/stack-server/src/app/(main)/(protected)/projects/[projectId]/layout.tsx index cfe928c7a..31a65e87c 100644 --- a/packages/stack-server/src/app/(main)/(protected)/projects/[projectId]/layout.tsx +++ b/packages/stack-server/src/app/(main)/(protected)/projects/[projectId]/layout.tsx @@ -1,95 +1,14 @@ -"use client"; - -import { Box, Drawer, Stack, useTheme } from "@mui/joy"; -import { useState } from "react"; -import { Sidebar } from "./sidebar"; import { AdminAppProvider } from "./use-admin-app"; -import { Header } from "./header"; -import { Icon } from '@/components/icon'; import { OnboardingDialog } from "./onboarding-dialog"; - -const navigationItems = [ - { - name: "Users", - href: "/auth/users", - icon: , - }, - { - name: "Providers", - href: "/auth/providers", - icon: , - }, - { - name: "Domains & Handlers", - href: "/auth/urls-and-callbacks", - icon: , - }, - { - name: "Environment", - href: "/settings/environment", - icon: , - }, - { - name: "API Keys", - href: "/settings/api-keys", - icon: , - }, -]; - +import SidebarLayout from "./sidebar-layout"; export default function Layout(props: { children: React.ReactNode, params: { projectId: string } }) { - const theme = useTheme(); - const [isSidebarOpen, setIsSidebarOpen] = useState(false); - - const isCompactMediaQuery = theme.breakpoints.down("md"); - - const headerHeight = 50; - return ( - - - -
setIsSidebarOpen(true)} - /> - - - {props.children} - - - - - setIsSidebarOpen(false)} - > - - + + {props.children} + ); } diff --git a/packages/stack-server/src/app/(main)/(protected)/projects/[projectId]/onboarding-dialog.tsx b/packages/stack-server/src/app/(main)/(protected)/projects/[projectId]/onboarding-dialog.tsx index 3eeb7cd23..a069d7931 100644 --- a/packages/stack-server/src/app/(main)/(protected)/projects/[projectId]/onboarding-dialog.tsx +++ b/packages/stack-server/src/app/(main)/(protected)/projects/[projectId]/onboarding-dialog.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Dialog } from "@/components/dialog"; import { useEffect, useState } from "react"; import { useAdminApp } from "./use-admin-app"; @@ -37,7 +39,6 @@ export function OnboardingDialog() { setApiKey(null), diff --git a/packages/stack-server/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx b/packages/stack-server/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx new file mode 100644 index 000000000..c6567a932 --- /dev/null +++ b/packages/stack-server/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { Drawer, Stack, useTheme } from "@mui/joy"; +import { useState } from "react"; +import { Sidebar } from "./sidebar"; +import { Header } from "./header"; +import { Icon } from '@/components/icon'; + +const navigationItems = [ + { + name: "Users", + href: "/auth/users", + icon: , + }, + { + name: "Providers", + href: "/auth/providers", + icon: , + }, + { + name: "Domains & Handlers", + href: "/auth/urls-and-callbacks", + icon: , + }, + { + name: "Environment", + href: "/settings/environment", + icon: , + }, + { + name: "API Keys", + href: "/settings/api-keys", + icon: , + }, +]; + + +export default function SidebarLayout(props: { children: React.ReactNode, params: { projectId: string } }) { + const theme = useTheme(); + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + + const isCompactMediaQuery = theme.breakpoints.down("md"); + + const headerHeight = 50; + + return ( + <> + + + +
setIsSidebarOpen(true)} + /> + + + {props.children} + + + + + setIsSidebarOpen(false)} + > + + + + ); +} diff --git a/packages/stack-server/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx b/packages/stack-server/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx index acd89b9e0..1b59fe728 100644 --- a/packages/stack-server/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx +++ b/packages/stack-server/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx @@ -6,16 +6,15 @@ import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { cacheFunction } from "@stackframe/stack-shared/dist/utils/caches"; import { CurrentUser, StackAdminApp } from "@stackframe/stack/dist/lib/stack-app"; -const StackAdminAppContext = React.createContext | null>(null); +const StackAdminAppContext = React.createContext | null>(null); const usersMap = new Map(); const createAdminApp = cacheFunction((baseUrl: string, projectId: string, userId: string) => { - console.log("new app", baseUrl, projectId, userId, usersMap); - return new StackAdminApp({ + return new StackAdminApp({ baseUrl, projectId, - tokenStore: "nextjs-cookie", + tokenStore: null, projectOwnerTokens: usersMap.get(userId)!.tokenStore, }); }); diff --git a/packages/stack-server/src/app/(main)/(protected)/projects/header.tsx b/packages/stack-server/src/app/(main)/(protected)/projects/header.tsx index 01768545e..db9eefa3f 100644 --- a/packages/stack-server/src/app/(main)/(protected)/projects/header.tsx +++ b/packages/stack-server/src/app/(main)/(protected)/projects/header.tsx @@ -1,4 +1,4 @@ -"use client";; +"use client"; import { Logo } from "@/components/logo"; import { Sheet, SheetProps, Stack } from "@mui/joy"; diff --git a/packages/stack-server/src/app/api/v1/api-keys/route.tsx b/packages/stack-server/src/app/api/v1/api-keys/route.tsx index 8819b92b1..eb795009b 100644 --- a/packages/stack-server/src/app/api/v1/api-keys/route.tsx +++ b/packages/stack-server/src/app/api/v1/api-keys/route.tsx @@ -23,7 +23,7 @@ export const GET = deprecatedSmartRouteHandler(async (req: NextRequest) => { } = await deprecatedParseRequest(req, getSchema); if (!await checkApiKeySet(projectId, { superSecretAdminKey }) && !await isProjectAdmin(projectId, adminAccessToken)) { - throw new StatusError(StatusError.Forbidden); + throw new StatusError(StatusError.Forbidden, "Invalid API key or insufficient permissions"); } const apiKeys = await listApiKeySets( diff --git a/packages/stack-server/src/components/smart-link.tsx b/packages/stack-server/src/components/smart-link.tsx index 9c62e5074..51feb2dfb 100644 --- a/packages/stack-server/src/components/smart-link.tsx +++ b/packages/stack-server/src/components/smart-link.tsx @@ -7,6 +7,7 @@ import { Icon } from "./icon"; export const SmartLink = React.forwardRef((props: LinkProps & { hideExternalIndicator?: boolean }, ref) => { const [isExternal, setIsExternal] = useState(!!props.href?.match(/^[a-z]+:/)); + const [isClicked, setIsClicked] = useState(false); const { hideExternalIndicator, ...linkProps } = props; diff --git a/packages/stack-server/src/lib/api-keys.tsx b/packages/stack-server/src/lib/api-keys.tsx index f211e3d16..939c2b99c 100644 --- a/packages/stack-server/src/lib/api-keys.tsx +++ b/packages/stack-server/src/lib/api-keys.tsx @@ -2,7 +2,6 @@ import * as yup from 'yup'; import { ApiKeySetFirstViewJson, ApiKeySetJson } from '@stackframe/stack-shared'; import { ApiKeySet } from '@prisma/client'; import { generateSecureRandomString } from '@stackframe/stack-shared/dist/utils/crypto'; -import * as crypto from 'node:crypto'; import { prismaClient } from '@/prisma-client'; import { generateUuid } from '@stackframe/stack-shared/dist/utils/uuids'; diff --git a/packages/stack-server/src/lib/route-handlers.tsx b/packages/stack-server/src/lib/route-handlers.tsx index 9caded7a7..cf8ab9109 100644 --- a/packages/stack-server/src/lib/route-handlers.tsx +++ b/packages/stack-server/src/lib/route-handlers.tsx @@ -93,7 +93,7 @@ async function parseBody(req: NextRequest): Promise { } } -export async function parseRequest>(req: NextRequest, schema: yup.Schema, options?: { params: Record }): Promise { +async function parseRequest>(req: NextRequest, schema: yup.Schema, options?: { params: Record }): Promise { const urlObject = new URL(req.url); const toValidate: SmartRequest = { url: req.url, @@ -132,7 +132,7 @@ function isBinaryBody(body: unknown): body is BodyInit { || ArrayBuffer.isView(body); } -export async function createResponse(req: NextRequest, requestId: string, obj: T, schema: yup.Schema): Promise { +async function createResponse(req: NextRequest, requestId: string, obj: T, schema: yup.Schema): Promise { const validated = await validate(obj, schema); let status = validated.statusCode; @@ -241,7 +241,7 @@ export function deprecatedSmartRouteHandler(handler: (req: NextRequest, options: } console.log(`[ ERR] [${requestId}] ${req.method} ${req.url}: ${statusError.message}`); - console.debug(`For the error above with request ID ${requestId}, the full error is:`, statusError); + console.log(`For the error above with request ID ${requestId}, the full error is:`, statusError); const res = await createResponse(req, requestId, { statusCode: statusError.statusCode, diff --git a/packages/stack-shared/src/interface/clientInterface.ts b/packages/stack-shared/src/interface/clientInterface.ts index 8af34f9ce..c2a787d75 100644 --- a/packages/stack-shared/src/interface/clientInterface.ts +++ b/packages/stack-shared/src/interface/clientInterface.ts @@ -2,8 +2,7 @@ import * as oauth from 'oauth4webapi'; import crypto from "crypto"; import { AsyncResult, Result } from "../utils/results"; -import { ReadonlyJson, parseJson } from '../utils/json'; -import { typedAssign } from '../utils/objects'; +import { ReadonlyJson } from '../utils/json'; import { AsyncStore, ReadonlyAsyncStore } from '../utils/stores'; import { KnownError, KnownErrors } from '../known-errors'; @@ -280,7 +279,15 @@ export class StackClientInterface { } const url = this.getApiUrl() + path; - const params = { + const params: RequestInit = { + /** + * This fetch may be cross-origin, in which case we don't want to send cookies of the + * original origin (this is the default behaviour of `credentials`). + * + * To help debugging, also omit cookies on same-origin, so we don't accidentally + * implement reliance on cookies anywhere. + */ + credentials: "omit", ...options, headers: { "X-Stack-Override-Error-Status": "true", @@ -292,7 +299,7 @@ export class StackClientInterface { "X-Stack-Publishable-Client-Key": this.options.publishableClientKey, } : {}, ...'projectOwnerTokens' in this.options ? { - "X-Stack-Admin-Access-Token": AsyncResult.or(this.options.projectOwnerTokens?.get(), null)?.accessToken ?? "", + "X-Stack-Admin-Access-Token": (await this.options.projectOwnerTokens?.getOrWait())?.accessToken ?? "", } : {}, ...options.headers, }, diff --git a/packages/stack-shared/src/utils/caches.tsx b/packages/stack-shared/src/utils/caches.tsx index 454e86222..0f4504ba7 100644 --- a/packages/stack-shared/src/utils/caches.tsx +++ b/packages/stack-shared/src/utils/caches.tsx @@ -63,6 +63,7 @@ export class AsyncCache { readonly isCacheAvailable = this._createKeyed("isCacheAvailable"); readonly getIfCached = this._createKeyed("getIfCached"); readonly getOrWait = this._createKeyed("getOrWait"); + readonly forceSetCachedValue = this._createKeyed("forceSetCachedValue"); readonly refresh = this._createKeyed("refresh"); readonly invalidate = this._createKeyed("invalidate"); readonly onChange = this._createKeyed("onChange"); @@ -70,6 +71,7 @@ export class AsyncCache { class AsyncValueCache { private _store: AsyncStore; + private _pendingPromise: ReactPromise | undefined; private _fetcher: () => Promise; private readonly _rateLimitOptions: Omit; private _subscriptionsCount = 0; @@ -85,7 +87,7 @@ class AsyncValueCache { this._store = new AsyncStore(); this._rateLimitOptions = { concurrency: 1, - debounceMs: 300, + throttleMs: 300, ...filterUndefined(_options.rateLimiter ?? {}), }; @@ -110,28 +112,31 @@ class AsyncValueCache { return resolved(cached.data); } - return pending(this._refetch(cacheStrategy === "read-write" ? "write-only" : cacheStrategy)); + return this._refetch(cacheStrategy); } private _set(value: T): void { this._store.set(value); } - private async _setAsync(value: Promise): Promise { - return await this._store.setAsync(value); + private _setAsync(value: Promise): ReactPromise { + return pending(this._store.setAsync(value)); } - private async _refetch(cacheStrategy: "write-only" | "never"): Promise { - try { - const res = this._fetcher(); - if (cacheStrategy === "write-only") { - await this._setAsync(res); - } - return await res; - } catch (e) { - this._store.setRejected(e); - throw e; + private _refetch(cacheStrategy: CacheStrategy): ReactPromise { + if (cacheStrategy === "read-write" && this._pendingPromise) { + return this._pendingPromise; } + const promise = pending(this._fetcher()); + if (cacheStrategy === "never") { + return promise; + } + this._pendingPromise = promise; + return pending(this._setAsync(promise).then(() => promise)); + } + + forceSetCachedValue(value: T): void { + this._set(value); } async refresh(): Promise { @@ -140,6 +145,7 @@ class AsyncValueCache { async invalidate(): Promise { this._store.setUnavailable(); + this._pendingPromise = undefined; return await this.refresh(); } diff --git a/packages/stack-shared/src/utils/promises.tsx b/packages/stack-shared/src/utils/promises.tsx index 93c343a40..2ea2155e6 100644 --- a/packages/stack-shared/src/utils/promises.tsx +++ b/packages/stack-shared/src/utils/promises.tsx @@ -63,9 +63,20 @@ export function neverResolve(): ReactPromise { } export function pending(promise: Promise): ReactPromise { - return Object.assign(promise, { + const res = Object.assign(promise, { status: "pending", - } as const); + } as Pick, "status"> & { value: T, reason: unknown }); + res.then( + value => { + res.status = "fulfilled"; + res.value = value; + }, + reason => { + res.status = "rejected"; + res.reason = reason; + }, + ); + return res; } export async function wait(ms: number) { @@ -78,12 +89,17 @@ export async function waitUntil(date: Date) { class ErrorDuringRunAsynchronously extends Error { constructor() { - super("The error above originated in a runAsynchronously() call. Below is the stacktrace associated with it."); + super("The error above originated in a runAsynchronously() call. Here is the stacktrace associated with it."); this.name = "ErrorDuringRunAsynchronously"; } } -export function runAsynchronously(promiseOrFunc: Promise | (() => Promise) | undefined): void { +export function runAsynchronously( + promiseOrFunc: Promise | (() => Promise) | undefined, + options: { + ignoreErrors?: boolean, + } = {}, +): void { if (typeof promiseOrFunc === "function") { promiseOrFunc = promiseOrFunc(); } @@ -95,8 +111,10 @@ export function runAsynchronously(promiseOrFunc: Promise | (() => Promi cause: error, } ); - console.error(newError); - console.error(duringError); + if (!options.ignoreErrors) { + console.error(newError); + console.error(duringError); + } }); } diff --git a/packages/stack-shared/src/utils/stores.tsx b/packages/stack-shared/src/utils/stores.tsx index 7b0142b26..875573eee 100644 --- a/packages/stack-shared/src/utils/stores.tsx +++ b/packages/stack-shared/src/utils/stores.tsx @@ -1,5 +1,5 @@ import * as crypto from "crypto"; -import { AsyncResult } from "./results"; +import { AsyncResult, Result } from "./results"; import { generateUuid } from "./uuids"; import { ReactPromise, pending, rejected, resolved } from "./promises"; @@ -70,24 +70,38 @@ export class AsyncStore implements ReadonlyAsyncStore { return pending(withFinally); } - _setIfLatest(value: T, curCounter: number) { - if (!this._isAvailable || this._isRejected || this._value !== value) { - const oldValue = this._value; - if (curCounter > this._lastSuccessfulUpdate) { - this._lastSuccessfulUpdate = curCounter; - this._isAvailable = true; - this._isRejected = false; - this._value = value; - this._callbacks.forEach((callback) => callback(value, oldValue)); - return true; + _setIfLatest(result: Result, curCounter: number) { + if (curCounter > this._lastSuccessfulUpdate) { + switch (result.status) { + case "ok": { + if (!this._isAvailable || this._isRejected || this._value !== result.data) { + const oldValue = this._value; + this._lastSuccessfulUpdate = curCounter; + this._isAvailable = true; + this._isRejected = false; + this._value = result.data; + this._rejectionError = undefined; + this._callbacks.forEach((callback) => callback(result.data, oldValue)); + return true; + } + return false; + } + case "error": { + this._lastSuccessfulUpdate = curCounter; + this._isAvailable = false; + this._isRejected = true; + this._value = undefined; + this._rejectionError = result.error; + this._waitingRejectFunctions.forEach((reject) => reject(result.error)); + return true; + } } - return false; } return false; } set(value: T): void { - this._setIfLatest(value, ++this._updateCounter); + this._setIfLatest(Result.ok(value), ++this._updateCounter); } update(updater: (value: T | undefined) => T): T { @@ -98,21 +112,20 @@ export class AsyncStore implements ReadonlyAsyncStore { async setAsync(promise: Promise): Promise { const curCounter = ++this._updateCounter; - const value = await promise; - return this._setIfLatest(value, curCounter); + const result = await Result.fromPromise(promise); + return this._setIfLatest(result, curCounter); } setUnavailable(): void { + this._lastSuccessfulUpdate = ++this._updateCounter; this._isAvailable = false; this._isRejected = false; this._value = undefined; + this._rejectionError = undefined; } setRejected(error: unknown): void { - this._isRejected = true; - this._value = undefined; - this._rejectionError = error; - this._waitingRejectFunctions.forEach((reject) => reject(error)); + this._setIfLatest(Result.error(error), ++this._updateCounter); } map(mapper: (value: T) => U): AsyncStore { diff --git a/packages/stack/src/lib/stack-app.ts b/packages/stack/src/lib/stack-app.ts index d7db1e6fe..f2162068e 100644 --- a/packages/stack/src/lib/stack-app.ts +++ b/packages/stack/src/lib/stack-app.ts @@ -13,7 +13,7 @@ import { RedirectType, redirect, useRouter } from "next/navigation"; import { ReadonlyJson } from "@stackframe/stack-shared/dist/utils/json"; import { constructRedirectUrl } from "../utils/url"; import { filterUndefined } from "@stackframe/stack-shared/dist/utils/objects"; -import { neverResolve, resolved } from "@stackframe/stack-shared/dist/utils/promises"; +import { neverResolve, resolved, runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; import { AsyncCache } from "@stackframe/stack-shared/dist/utils/caches"; import { ApiKeySetBaseJson, ApiKeySetCreateOptions, ApiKeySetFirstViewJson, ApiKeySetJson, ProjectUpdateOptions } from "@stackframe/stack-shared/dist/interface/adminInterface"; import { suspend } from "@stackframe/stack-shared/dist/utils/react"; @@ -109,6 +109,8 @@ export type StackAdminAppConstructorOptions = StackClientAppConstructorOptions & { uniqueIdentifier: string, + currentClientUserJson: UserJson | null, + currentProjectJson: ClientProjectJson, }; const defaultBaseUrl = "https://app.stackframe.co"; @@ -267,13 +269,13 @@ class _StackClientAppImpl | Pick, "tokenStore" | "urls"> & { interface: StackClientInterface, - tokenStore: TokenStoreOptions, - urls: Partial | undefined, } ) ) { @@ -295,16 +297,36 @@ class _StackClientAppImpl this.getUser()); + } + if (options.currentProjectJson !== undefined) { + this._currentProjectCache.forceSetCachedValue([], options.currentProjectJson); + } else { + runAsynchronously(this.getProject()); + } + } + + protected hasPersistentTokenStore(): this is StackClientApp { + return this._tokenStoreOptions !== null; } protected _ensurePersistentTokenStore(): asserts this is StackClientApp { - if (!this._tokenStoreOptions) { + if (!this.hasPersistentTokenStore()) { throw new Error("Cannot call this function on a Stack app without a persistent token store. Make sure the tokenStore option is set to a non-null value when initializing Stack."); } } + protected isInternalProject(): this is { projectId: "internal" } { + return this.projectId === "internal"; + } + protected _ensureInternalProject(): asserts this is { projectId: "internal" } { - if (this.projectId !== "internal") { + if (!this.isInternalProject()) { throw new Error("Cannot call this function on a Stack app with a project ID other than 'internal'."); } } @@ -453,7 +475,7 @@ class _StackClientAppImpl { this._ensurePersistentTokenStore(); const tokenStore = getTokenStore(this._tokenStoreOptions); - const userJson = await this._currentUserCache.getOrWait([tokenStore], "never"); + const userJson = await this._currentUserCache.getOrWait([tokenStore], "write-only"); if (userJson === null) { switch (options?.or) { @@ -580,7 +602,7 @@ class _StackClientAppImpl { - return await this._currentProjectCache.getOrWait([], "never"); + return await this._currentProjectCache.getOrWait([], "write-only"); } useProject(): ClientProjectJson { @@ -594,7 +616,7 @@ class _StackClientAppImpl { this._ensureInternalProject(); const tokenStore = getTokenStore(this._tokenStoreOptions); - const json = await this._ownedProjectsCache.getOrWait([tokenStore], "never"); + const json = await this._ownedProjectsCache.getOrWait([tokenStore], "write-only"); return json.map((j) => this._projectAdminFromJson( j, this._createAdminInterface(j.id, tokenStore), @@ -683,6 +705,12 @@ class _StackClientAppImpl { this._ensurePersistentTokenStore(); const tokenStore = getTokenStore(this._tokenStoreOptions); - const userJson = await this._currentServerUserCache.getOrWait([tokenStore], "never"); + const userJson = await this._currentServerUserCache.getOrWait([tokenStore], "write-only"); return this._currentServerUserFromJson(userJson, tokenStore); } @@ -839,7 +869,7 @@ class _StackServerAppImpl { - const json = await this._serverUsersCache.getOrWait([], "never"); + const json = await this._serverUsersCache.getOrWait([], "write-only"); return json.map((j) => this._serverUserFromJson(j)); } @@ -945,7 +975,7 @@ class _StackAdminAppImpl { return this._projectAdminFromJson( - await this._adminProjectCache.getOrWait([], "never"), + await this._adminProjectCache.getOrWait([], "write-only"), this._interface, () => this._refreshProject() ); @@ -971,7 +1001,7 @@ class _StackAdminAppImpl { - const json = await this._apiKeySetsCache.getOrWait([], "never"); + const json = await this._apiKeySetsCache.getOrWait([], "write-only"); return json.map((j) => this._createApiKeySetFromJson(j)); } @@ -1212,8 +1242,7 @@ export type StackAdminApp(options: StackAdminAppConstructorOptions): StackAdminApp, new (options: StackAdminAppConstructorOptions): StackAdminApp,