Merge dev into update-oauth-docs

This commit is contained in:
Konsti Wohlwend 2025-09-25 04:31:49 -07:00 committed by GitHub
commit 7e95c12faf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
65 changed files with 14486 additions and 318 deletions

View File

@ -84,6 +84,9 @@ jobs:
- name: Create .env.test.local file for examples/supabase
run: cp examples/supabase/.env.development examples/supabase/.env.test.local
- name: Create .env.test.local file for examples/convex
run: cp examples/convex/.env.development examples/convex/.env.test.local
- name: Build
run: pnpm build

View File

@ -85,6 +85,9 @@ jobs:
- name: Create .env.test.local file for examples/supabase
run: cp examples/supabase/.env.development examples/supabase/.env.test.local
- name: Create .env.test.local file for examples/convex
run: cp examples/convex/.env.development examples/convex/.env.test.local
- name: Build
run: pnpm build

View File

@ -68,6 +68,9 @@ jobs:
- name: Create .env.production.local file for examples/supabase
run: cp examples/supabase/.env.development examples/supabase/.env.production.local
- name: Create .env.production.local file for examples/convex
run: cp examples/convex/.env.development examples/convex/.env.production.local
- name: Build
run: pnpm build

View File

@ -13,6 +13,7 @@ import * as jose from 'jose';
import { JOSEError, JWTExpired } from 'jose/errors';
import { SystemEventTypes, logEvent } from './events';
import { Tenancy } from './tenancies';
import { AccessTokenPayload } from '@stackframe/stack-shared/dist/sessions';
export const authorizationHeaderSchema = yupString().matches(/^StackSession [^ ]+$/);
@ -87,7 +88,7 @@ export async function decodeAccessToken(accessToken: string, { allowAnonymous }:
throw error;
}
const isAnonymous = payload.role === 'anon';
const isAnonymous = payload.is_anonymous as boolean | undefined ?? /* legacy, now we always set role to authenticated, TODO next-release remove */ payload.role === 'anon';
if (aud.endsWith(":anon") && !isAnonymous) {
console.warn("Unparsable access token. Role is set to anon, but audience is not an anonymous audience.", { accessToken, payload });
return Result.error(new KnownErrors.UnparsableAccessToken());
@ -108,7 +109,7 @@ export async function decodeAccessToken(accessToken: string, { allowAnonymous }:
branchId: branchId,
refreshTokenId: payload.refresh_token_id ?? payload.refreshTokenId,
exp: payload.exp,
isAnonymous: payload.role === 'anon',
isAnonymous: payload.is_anonymous ?? /* legacy, now we always set role to authenticated, TODO next-release remove */ payload.role === 'anon',
});
return Result.ok(result);
@ -149,20 +150,24 @@ export async function generateAccessToken(options: {
}
);
const payload: Omit<AccessTokenPayload, "iss" | "aud"> = {
sub: options.userId,
project_id: options.tenancy.project.id,
branch_id: options.tenancy.branchId,
refresh_token_id: options.refreshTokenId,
role: 'authenticated',
name: user.display_name,
email: user.primary_email,
email_verified: user.primary_email_verified,
selected_team_id: user.selected_team_id,
is_anonymous: user.is_anonymous,
};
return await signJWT({
issuer: getIssuer(options.tenancy.project.id, user.is_anonymous),
audience: getAudience(options.tenancy.project.id, user.is_anonymous),
payload: {
sub: options.userId,
branch_id: options.tenancy.branchId,
refresh_token_id: options.refreshTokenId,
role: user.is_anonymous ? 'anon' : 'authenticated',
name: user.display_name,
email: user.primary_email,
email_verified: user.primary_email_verified,
selected_team_id: user.selected_team_id,
},
expirationTime: getEnvVariable("STACK_ACCESS_TOKEN_EXPIRATION_TIME", "10min"),
payload,
});
}

View File

@ -1,7 +1,3 @@
import { PrismaClient } from "@prisma/client";
export type PrismaTransaction = Parameters<Parameters<PrismaClient['$transaction']>[0]>[0];
export type Prettify<T> = {
[K in keyof T]: T[K];
} & {};

View File

@ -14,6 +14,7 @@
"@oslojs/otp": "^1.1.0",
"@stackframe/js": "workspace:*",
"@stackframe/stack-shared": "workspace:*",
"convex": "^1.27.0",
"dotenv": "^16.4.5"
},
"devDependencies": {

View File

@ -208,6 +208,8 @@ export namespace Auth {
"email": expect.toSatisfy(() => true),
"email_verified": expect.any(Boolean),
"selected_team_id": expect.toSatisfy(() => true),
"is_anonymous": expect.any(Boolean),
"project_id": payload.aud
});
}
}

View File

@ -34,7 +34,8 @@ it("anonymous JWT has different kid and role", async ({ expect }) => {
JSON.parse(Buffer.from(part, 'base64url').toString())
);
expect(payload.role).toBe('anon');
expect(payload.role).toBe('authenticated');
expect(payload.is_anonymous).toBe(true);
expect(header.kid).toBeTruthy();
// The kid should be different from regular users

View File

@ -2,6 +2,7 @@ import { wait } from "@stackframe/stack-shared/dist/utils/promises";
import { expect } from "vitest";
import { NiceResponse, it } from "../../../../helpers";
import { Auth, InternalApiKey, Project, backendContext, createMailbox, niceBackendFetch } from "../../../backend-helpers";
import { Result } from "@stackframe/stack-shared/dist/utils/results";
async function ensureAnonymousUsersAreStillExcluded(metricsResponse: NiceResponse) {
for (let i = 0; i < 2; i++) {
@ -125,29 +126,38 @@ it("should exclude anonymous users from metrics", async ({ expect }) => {
await Auth.Anonymous.signUp();
}
await wait(3000); // the event log is async, so let's give it some time to be written to the DB
// the event log is async, so let's give it some time to be written to the DB
const result = await Result.retry(async () => {
const response = await niceBackendFetch("/api/v1/internal/metrics", { accessType: 'admin' });
if (JSON.stringify(response.body) === JSON.stringify(beforeMetrics.body)) {
return Result.ok(response);
}
return Result.error(response);
}, 5, { exponentialDelayBase: 200 });
const response = await niceBackendFetch("/api/v1/internal/metrics", { accessType: 'admin' });
expect(beforeMetrics.body).toEqual(response.body);
if (result.status === "error") {
expect(beforeMetrics.body).toEqual(result.error);
throw new Error("Metrics response mismatch, should never be reached");
}
// Verify that total_users only counts the 1 regular user, not the anonymous ones
expect(response.body.total_users).toBe(1);
expect(result.data.body.total_users).toBe(1);
// Verify anonymous users don't appear in recently_registered
expect(response.body.recently_registered.length).toBe(1);
expect(response.body.recently_registered.every((user: any) => !user.is_anonymous)).toBe(true);
expect(result.data.body.recently_registered.length).toBe(1);
expect(result.data.body.recently_registered.every((user: any) => !user.is_anonymous)).toBe(true);
// Verify anonymous users don't appear in recently_active
expect(response.body.recently_active.every((user: any) => !user.is_anonymous)).toBe(true);
expect(result.data.body.recently_active.every((user: any) => !user.is_anonymous)).toBe(true);
// Verify anonymous users aren't counted in daily_users
const lastDayUsers = response.body.daily_users[response.body.daily_users.length - 1];
const lastDayUsers = result.data.body.daily_users[result.data.body.daily_users.length - 1];
expect(lastDayUsers.activity).toBe(1);
// Verify users_by_country only includes regular users
expect(response.body.users_by_country["US"]).toBe(1);
expect(result.data.body.users_by_country["US"]).toBe(1);
await ensureAnonymousUsersAreStillExcluded(response);
await ensureAnonymousUsersAreStillExcluded(result.data);
});
it("should handle anonymous users with activity correctly", async ({ expect }) => {

View File

@ -0,0 +1,180 @@
import { ConvexHttpClient } from "convex/browser";
import { ConvexReactClient } from "convex/react";
import { decodeJwt } from "jose";
import { it } from "../helpers";
import { createApp } from "./js-helpers";
class MockWebSocket {
static last: MockWebSocket | undefined;
url: string;
onopen: ((ev: any) => void) | null = null;
onmessage: ((ev: any) => void) | null = null;
onclose: ((ev: any) => void) | null = null;
sent: Array<{ raw: any, json: any }> = [];
constructor(url: string) {
this.url = url;
MockWebSocket.last = this;
}
send(data: any) {
let json: any;
try {
json = JSON.parse(String(data));
} catch {
json = null;
}
this.sent.push({ raw: data, json });
}
close() {
if (this.onclose) this.onclose({ code: 1000, reason: "" } as any);
}
open() {
if (this.onopen) this.onopen({} as any);
}
}
const signIn = async (clientApp: any) => {
await clientApp.signUpWithCredential({
email: "test@test.com",
password: "password",
verificationCallbackUrl: "http://localhost:3000",
});
await clientApp.signInWithCredential({
email: "test@test.com",
password: "password",
});
};
it("should provide a valid auth getter for Convex React client", async ({ expect }) => {
const { clientApp } = await createApp({});
await signIn(clientApp);
const getter = clientApp.getConvexClientAuth({ tokenStore: "memory" });
const token2 = await getter({ forceRefreshToken: true });
expect(typeof token2).toBe("string");
expect((token2 as string).length).toBeGreaterThan(1);
const convex = new ConvexReactClient("http://localhost:1234", { webSocketConstructor: MockWebSocket as any, expectAuth: true });
convex.setAuth(getter);
MockWebSocket.last?.open();
// wait up to 1s (10 x 100ms) until both Connect and Authenticate messages are seen
let connectMsg: any = undefined;
let authMsg: any = undefined;
for (let i = 0; i < 10; i++) {
const msgs = (MockWebSocket.last?.sent ?? []).map(m => m.json);
connectMsg = msgs.find(m => m?.type === "Connect");
authMsg = msgs.find(m => m?.type === "Authenticate" && m?.tokenType === "User");
if (connectMsg && authMsg) break;
await new Promise(r => setTimeout(r, 100));
}
expect(connectMsg).toBeDefined();
expect(authMsg).toBeDefined();
expect((authMsg as any).value).toBe(token2);
});
it("should provide a valid auth token for Convex HTTP client", async ({ expect }) => {
const { clientApp } = await createApp({});
await signIn(clientApp);
const token = await clientApp.getConvexHttpClientAuth({ tokenStore: "memory" });
expect(typeof token).toBe("string");
expect(token.length).toBeGreaterThan(1);
const user = await clientApp.getUser({ or: "throw" });
const payload: any = decodeJwt(token);
expect(payload.sub).toBe(user.id);
const convex = new ConvexHttpClient("http://localhost:1234");
convex.setAuth(token);
const originalFetch = globalThis.fetch;
let capturedAuth: string | undefined;
globalThis.fetch = (async (_input: any, init?: any) => {
capturedAuth = init?.headers?.Authorization;
return new Response(JSON.stringify({ status: "success", value: null, logLines: [] }), { status: 200, headers: { "Content-Type": "application/json" } });
}) as any;
try {
await (convex as any).function("any");
} finally {
globalThis.fetch = originalFetch;
}
expect(capturedAuth).toBe(`Bearer ${token}`);
});
it("should map convex ctx identity to partial user", async ({ expect }) => {
const { clientApp } = await createApp({});
await signIn(clientApp);
const user = await clientApp.getUser({ or: "throw" });
const identity = {
subject: user.id,
name: user.displayName,
email: user.primaryEmail,
email_verified: user.primaryEmailVerified,
is_anonymous: user.isAnonymous,
} as const;
const ctx: any = {
auth: {
getUserIdentity: async () => identity,
},
};
const partialUser = await clientApp.getPartialUser({ from: "convex", ctx });
expect(partialUser).toEqual({
id: user.id,
displayName: user.displayName,
primaryEmail: user.primaryEmail,
primaryEmailVerified: user.primaryEmailVerified,
isAnonymous: user.isAnonymous,
});
});
it("should return null partial user when convex identity is absent", async ({ expect }) => {
const { clientApp } = await createApp({});
await signIn(clientApp);
const ctx: any = {
auth: {
getUserIdentity: async () => null,
},
};
const partialUser = await clientApp.getPartialUser({ from: "convex", ctx });
expect(partialUser).toBeNull();
});
it("should return the server user when provided Convex ctx identity", async ({ expect }) => {
const { clientApp, serverApp } = await createApp({});
await signIn(clientApp);
const user = await clientApp.getUser({ or: "throw" });
const identity = { subject: user.id } as const;
const ctx: any = {
auth: {
getUserIdentity: async () => identity,
},
};
const serverUser = await serverApp.getUser({ from: "convex", ctx });
expect(serverUser).not.toBeNull();
expect(serverUser!.id).toBe(user.id);
expect(serverUser!.isAnonymous).toBe(false);
});
it("should return null when Convex ctx identity is absent for server getUser", async ({ expect }) => {
const { serverApp } = await createApp({});
const ctx: any = {
auth: {
getUserIdentity: async () => null,
},
};
const serverUser = await serverApp.getUser({ from: "convex", ctx });
expect(serverUser).toBeNull();
});

View File

@ -273,6 +273,9 @@ pages:
- path: others/supabase.mdx
platforms: ["next"] # Next only
- path: others/convex.mdx
platforms: ["next", "react", "js"] # No Python
- path: others/cli-authentication.mdx
platforms: ["python"] # Python only

View File

@ -33,6 +33,7 @@
"others/cli-authentication",
"others/self-host",
"others/supabase",
"others/convex",
"sdk",
"components"
]

101
docs/templates/others/convex.mdx vendored Normal file
View File

@ -0,0 +1,101 @@
---
title: Convex
description: Integrate Stack Auth with Convex
---
This guide shows how to integrate Stack Auth with Convex.
### 1) Create a Convex + Next.js app
```bash title="Terminal"
npx create-next-app --example convex stack-convex
cd stack-convex
npx @stackframe/init-stack@latest
```
Add your Stack environment variables to both `.env.local` and convex env:
- `NEXT_PUBLIC_STACK_PROJECT_ID`
- `NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY`
- `STACK_SECRET_SERVER_KEY`
### 2) Update `convex/auth.config.ts`
Use the exported helper to configure Convex auth providers for Stack. If `NEXT_PUBLIC_STACK_PROJECT_ID` is set, the `projectId` option is optional.
```typescript title="convex/auth.config.ts"
import { getConvexProvidersConfig } from "@stackframe/stack";
export default {
providers: getConvexProvidersConfig({
// Optional: projectId: PROJECT_ID,
}),
};
```
### 3) Create your Stack client
```typescript title="stack/client.ts"
import { StackClientApp } from "@stackframe/stack";
export const stackClientApp = new StackClientApp({
tokenStore: "nextjs-cookie",
});
```
### 4) Use with Convex clients
<Tabs defaultValue="react">
<TabsList>
<TabsTrigger value="react">Convex React client</TabsTrigger>
<TabsTrigger value="http">Convex HTTP client</TabsTrigger>
</TabsList>
<TabsContent value="react">
<Steps>
<Step>
### Set auth for Convex React client
</Step>
```tsx title="convex-react.ts"
import { ConvexReactClient } from "convex/react";
import { stackClientApp } from "../stack/client";
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!, { expectAuth: true });
convex.setAuth(
stackClientApp.getConvexClientAuth({ tokenStore: "nextjs-cookie" })
);
```
</Steps>
</TabsContent>
<TabsContent value="http">
<Steps>
<Step>
### Set auth for Convex HTTP client
</Step>
```ts title="convex-http.ts"
import { ConvexHttpClient } from "convex/browser";
import { stackClientApp } from "../stack/client";
const token = await stackClientApp.getConvexHttpClientAuth({ tokenStore: "nextjs-cookie" });
const convex = new ConvexHttpClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
convex.setAuth(token);
```
</Steps>
</TabsContent>
</Tabs>
### 5) Use in Convex functions (server)
In Convex queries/mutations/actions, map Convex identity to a Stack partial user with `ctx`:
```ts title="convex/myFunctions.ts"
import { v } from "convex/values";
import { query } from "./_generated/server";
import { stackClientApp } from "../stack/client";
export const whoAmI = query({
args: {},
handler: async (ctx) => {
const user = await stackClientApp.getPartialUser({ from: "convex", ctx });
return user; // null when not authenticated
},
});
```

View File

@ -0,0 +1,8 @@
# Contains the credentials for the internal project of Stack's default development environment setup.
# Do not use in a production environment, instead replace it with actual values gathered from https://app.stack-auth.com.
NEXT_PUBLIC_STACK_API_URL=http://localhost:8102
NEXT_PUBLIC_STACK_PROJECT_ID=internal
NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=this-publishable-client-key-is-for-local-development-only
STACK_SECRET_SERVER_KEY=this-secret-server-key-is-for-local-development-only
NEXT_PUBLIC_CONVEX_URL=http://localhost:1234

View File

@ -0,0 +1,22 @@
module.exports = {
"extends": [
"../../configs/eslint/defaults.js",
"../../configs/eslint/next.js",
],
"ignorePatterns": ['/*', '!/src'],
rules: {
"import/order": [
1,
{
groups: [
"external",
"builtin",
"internal",
"sibling",
"parent",
"index",
],
},
],
},
};

41
examples/convex/.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

40
examples/convex/README.md Normal file
View File

@ -0,0 +1,40 @@
# Welcome to your Convex + Next.js app
This is a [Convex](https://convex.dev/) project created with [`npm create convex`](https://www.npmjs.com/package/create-convex).
After the initial setup (<2 minutes) you'll have a working full-stack app using:
- Convex as your backend (database, server logic)
- [React](https://react.dev/) as your frontend (web page interactivity)
- [Next.js](https://nextjs.org/) for optimized web hosting and page routing
- [Tailwind](https://tailwindcss.com/) for building great looking accessible UI
## Get started
If you just cloned this codebase and didn't use `npm create convex`, run:
```
npm install
npm run dev
```
If you're reading this README on GitHub and want to use this template, run:
```
npm create convex@latest -- -t nextjs
```
## Learn more
To learn more about developing your project with Convex, check out:
- The [Tour of Convex](https://docs.convex.dev/get-started) for a thorough introduction to Convex principles.
- The rest of [Convex docs](https://docs.convex.dev/) to learn about all Convex features.
- [Stack](https://stack.convex.dev/) for in-depth articles on advanced topics.
## Join the community
Join thousands of developers building full-stack apps with Convex:
- Join the [Convex Discord community](https://convex.dev/community) to get help in real-time.
- Follow [Convex on GitHub](https://github.com/get-convex/), star and contribute to the open-source implementation of Convex.

View File

@ -0,0 +1,26 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
}

View File

@ -0,0 +1,6 @@
import { StackHandler } from "@stackframe/stack";
import { stackServerApp } from "../../../stack/server";
export default function Handler(props: unknown) {
return <StackHandler fullPage app = { stackServerApp } routeProps = { props } />;
}

View File

@ -0,0 +1,40 @@
import type { Metadata } from "next";
import { StackProvider, StackTheme } from "@stackframe/stack";
import { stackServerApp } from "../stack/server";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import ConvexClientProvider from "@/components/ConvexClientProvider";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
icons: {
icon: "/convex.svg",
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
><StackProvider app={stackServerApp}><StackTheme>
<ConvexClientProvider >{children}</ConvexClientProvider>
</StackTheme></StackProvider></body>
</html>
);
}

View File

@ -0,0 +1,6 @@
export default function Loading() {
// Stack uses React Suspense, which will render this page while user data is being fetched.
// See: https://nextjs.org/docs/app/api-reference/file-conventions/loading
return <></>;
}

View File

@ -0,0 +1,136 @@
"use client";
import { useMutation, useQuery } from "convex/react";
import { api } from "../convex/_generated/api";
import Link from "next/link";
import { UserButton, useUser } from "@stackframe/stack";
export default function Home() {
const user = useUser();
return (
<>
<header className="sticky top-0 z-10 bg-background p-4 border-b-2 border-slate-200 dark:border-slate-800 flex flex-row justify-between items-center">
Convex + Next.js
<UserButton />
</header>
<main className="p-8 flex flex-col gap-16">
<h1 className="text-4xl font-bold text-center">Convex + Next.js</h1>
<p className="text-center">User: {user?.primaryEmail}</p>
<Content />
</main>
</>
);
}
function Content() {
const { viewer, numbers } =
useQuery(api.myFunctions.listNumbers, {
count: 10,
}) ?? {};
const addNumber = useMutation(api.myFunctions.addNumber);
if (viewer === undefined || numbers === undefined) {
return (
<div className="mx-auto">
<p>loading... (consider a loading skeleton)</p>
</div>
);
}
return (
<div className="flex flex-col gap-8 max-w-lg mx-auto">
<p>Welcome {viewer ?? "Anonymous"}!</p>
<p>
Click the button below and open this page in another window - this data
is persisted in the Convex cloud database!
</p>
<p>
<button
className="bg-foreground text-background text-sm px-4 py-2 rounded-md"
onClick={() => {
void addNumber({ value: Math.floor(Math.random() * 10) });
}}
>
Add a random number
</button>
</p>
<p>
Numbers:{" "}
{numbers?.length === 0
? "Click the button!"
: numbers?.join(", ") ?? "..."}
</p>
<p>
Edit{" "}
<code className="text-sm font-bold font-mono bg-slate-200 dark:bg-slate-800 px-1 py-0.5 rounded-md">
convex/myFunctions.ts
</code>{" "}
to change your backend
</p>
<p>
Edit{" "}
<code className="text-sm font-bold font-mono bg-slate-200 dark:bg-slate-800 px-1 py-0.5 rounded-md">
app/page.tsx
</code>{" "}
to change your frontend
</p>
<p>
See the{" "}
<Link href="/server" className="underline hover:no-underline">
/server route
</Link>{" "}
for an example of loading data in a server component
</p>
<div className="flex flex-col">
<p className="text-lg font-bold">Useful resources:</p>
<div className="flex gap-2">
<div className="flex flex-col gap-2 w-1/2">
<ResourceCard
title="Convex docs"
description="Read comprehensive documentation for all Convex features."
href="https://docs.convex.dev/home"
/>
<ResourceCard
title="Stack articles"
description="Learn about best practices, use cases, and more from a growing
collection of articles, videos, and walkthroughs."
href="https://www.typescriptlang.org/docs/handbook/2/basic-types.html"
/>
</div>
<div className="flex flex-col gap-2 w-1/2">
<ResourceCard
title="Templates"
description="Browse our collection of templates to get started quickly."
href="https://www.convex.dev/templates"
/>
<ResourceCard
title="Discord"
description="Join our developer community to ask questions, trade tips & tricks,
and show off your projects."
href="https://www.convex.dev/community"
/>
</div>
</div>
</div>
</div>
);
}
function ResourceCard({
title,
description,
href,
}: {
title: string;
description: string;
href: string;
}) {
return (
<div className="flex flex-col gap-2 bg-slate-200 dark:bg-slate-800 p-4 rounded-md h-28 overflow-auto">
<a href={href} className="text-sm underline hover:no-underline">
{title}
</a>
<p className="text-xs">{description}</p>
</div>
);
}

View File

@ -0,0 +1,31 @@
"use client";
import { Preloaded, useMutation, usePreloadedQuery } from "convex/react";
import { api } from "../../convex/_generated/api";
export default function Home({
preloaded,
}: {
preloaded: Preloaded<typeof api.myFunctions.listNumbers>;
}) {
const data = usePreloadedQuery(preloaded);
const addNumber = useMutation(api.myFunctions.addNumber);
return (
<>
<div className="flex flex-col gap-4 bg-slate-200 dark:bg-slate-800 p-4 rounded-md">
<h2 className="text-xl font-bold">Reactive client-loaded data</h2>
<code>
<pre>{JSON.stringify(data, null, 2)}</pre>
</code>
</div>
<button
className="bg-foreground text-background px-4 py-2 rounded-md mx-auto"
onClick={() => {
void addNumber({ value: Math.floor(Math.random() * 10) });
}}
>
Add a random number
</button>
</>
);
}

View File

@ -0,0 +1,24 @@
import Home from "./inner";
import { preloadQuery, preloadedQueryResult } from "convex/nextjs";
import { api } from "@/convex/_generated/api";
export default async function ServerPage() {
const preloaded = await preloadQuery(api.myFunctions.listNumbers, {
count: 3,
});
const data = preloadedQueryResult(preloaded);
return (
<main className="p-8 flex flex-col gap-4 mx-auto max-w-2xl">
<h1 className="text-4xl font-bold text-center">Convex + Next.js</h1>
<div className="flex flex-col gap-4 bg-slate-200 dark:bg-slate-800 p-4 rounded-md">
<h2 className="text-xl font-bold">Non-reactive server-loaded data</h2>
<code>
<pre>{JSON.stringify(data, null, 2)}</pre>
</code>
</div>
<Home preloaded={preloaded} />
</main>
);
}

View File

@ -0,0 +1,18 @@
"use client";
import { ReactNode } from "react";
import { ConvexProvider, ConvexReactClient } from "convex/react";
import { stackClientApp } from "@/stack/client";
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
convex.setAuth(
stackClientApp.getConvexClientAuth({ tokenStore: "nextjs-cookie" })
);
export default function ConvexClientProvider({
children,
}: {
children: ReactNode;
}) {
return <ConvexProvider client={convex}>{children}</ConvexProvider>;
}

View File

@ -0,0 +1,90 @@
# Welcome to your Convex functions directory!
Write your Convex functions here.
See https://docs.convex.dev/functions for more.
A query function that takes two arguments looks like:
```ts
// functions.js
import { query } from "./_generated/server";
import { v } from "convex/values";
export const myQueryFunction = query({
// Validators for arguments.
args: {
first: v.number(),
second: v.string(),
},
// Function implementation.
handler: async (ctx, args) => {
// Read the database as many times as you need here.
// See https://docs.convex.dev/database/reading-data.
const documents = await ctx.db.query("tablename").collect();
// Arguments passed from the client are properties of the args object.
console.log(args.first, args.second);
// Write arbitrary JavaScript here: filter, aggregate, build derived data,
// remove non-public properties, or create new objects.
return documents;
},
});
```
Using this query function in a React component looks like:
```ts
const data = useQuery(api.functions.myQueryFunction, {
first: 10,
second: "hello",
});
```
A mutation function looks like:
```ts
// functions.js
import { mutation } from "./_generated/server";
import { v } from "convex/values";
export const myMutationFunction = mutation({
// Validators for arguments.
args: {
first: v.string(),
second: v.string(),
},
// Function implementation.
handler: async (ctx, args) => {
// Insert or modify documents in the database here.
// Mutations can also read from the database like queries.
// See https://docs.convex.dev/database/writing-data.
const message = { body: args.first, author: args.second };
const id = await ctx.db.insert("messages", message);
// Optionally, return a value from your mutation.
return await ctx.db.get(id);
},
});
```
Using this mutation function in a React component looks like:
```ts
const mutation = useMutation(api.functions.myMutationFunction);
function handleButtonPress() {
// fire and forget, the most common way to use mutations
mutation({ first: "Hello!", second: "me" });
// OR
// use the result once the mutation has completed
mutation({ first: "Hello!", second: "me" }).then((result) =>
console.log(result),
);
}
```
Use the Convex CLI to push your functions to a deployment. See everything
the Convex CLI can do by running `npx convex -h` in your project root
directory. To learn more, launch the docs with `npx convex docs`.

View File

@ -0,0 +1,36 @@
/* eslint-disable */
/**
* Generated `api` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type {
ApiFromModules,
FilterApi,
FunctionReference,
} from "convex/server";
import type * as myFunctions from "../myFunctions.js";
/**
* A utility for referencing Convex functions in your app's API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
declare const fullApi: ApiFromModules<{
myFunctions: typeof myFunctions;
}>;
export declare const api: FilterApi<
typeof fullApi,
FunctionReference<any, "public">
>;
export declare const internal: FilterApi<
typeof fullApi,
FunctionReference<any, "internal">
>;

View File

@ -0,0 +1,22 @@
/* eslint-disable */
/**
* Generated `api` utility.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import { anyApi } from "convex/server";
/**
* A utility for referencing Convex functions in your app's API.
*
* Usage:
* ```js
* const myFunctionReference = api.myModule.myFunction;
* ```
*/
export const api = anyApi;
export const internal = anyApi;

View File

@ -0,0 +1,60 @@
/* eslint-disable */
/**
* Generated data model types.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import type {
DataModelFromSchemaDefinition,
DocumentByName,
TableNamesInDataModel,
SystemTableNames,
} from "convex/server";
import type { GenericId } from "convex/values";
import schema from "../schema.js";
/**
* The names of all of your Convex tables.
*/
export type TableNames = TableNamesInDataModel<DataModel>;
/**
* The type of a document stored in Convex.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Doc<TableName extends TableNames> = DocumentByName<
DataModel,
TableName
>;
/**
* An identifier for a document in Convex.
*
* Convex documents are uniquely identified by their `Id`, which is accessible
* on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
*
* Documents can be loaded using `db.get(id)` in query and mutation functions.
*
* IDs are just strings at runtime, but this type can be used to distinguish them from other
* strings when type checking.
*
* @typeParam TableName - A string literal type of the table name (like "users").
*/
export type Id<TableName extends TableNames | SystemTableNames> =
GenericId<TableName>;
/**
* A type describing your Convex data model.
*
* This type includes information about what tables you have, the type of
* documents stored in those tables, and the indexes defined on them.
*
* This type is used to parameterize methods like `queryGeneric` and
* `mutationGeneric` to make them type-safe.
*/
export type DataModel = DataModelFromSchemaDefinition<typeof schema>;

View File

@ -0,0 +1,142 @@
/* eslint-disable */
/**
* Generated utilities for implementing server-side Convex query and mutation functions.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import {
ActionBuilder,
HttpActionBuilder,
MutationBuilder,
QueryBuilder,
GenericActionCtx,
GenericMutationCtx,
GenericQueryCtx,
GenericDatabaseReader,
GenericDatabaseWriter,
} from "convex/server";
import type { DataModel } from "./dataModel.js";
/**
* Define a query in this Convex app's public API.
*
* This function will be allowed to read your Convex database and will be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export declare const query: QueryBuilder<DataModel, "public">;
/**
* Define a query that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export declare const internalQuery: QueryBuilder<DataModel, "internal">;
/**
* Define a mutation in this Convex app's public API.
*
* This function will be allowed to modify your Convex database and will be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export declare const mutation: MutationBuilder<DataModel, "public">;
/**
* Define a mutation that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export declare const internalMutation: MutationBuilder<DataModel, "internal">;
/**
* Define an action in this Convex app's public API.
*
* An action is a function which can execute any JavaScript code, including non-deterministic
* code and code with side-effects, like calling third-party services.
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
*
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/
export declare const action: ActionBuilder<DataModel, "public">;
/**
* Define an action that is only accessible from other Convex functions (but not from the client).
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/
export declare const internalAction: ActionBuilder<DataModel, "internal">;
/**
* Define an HTTP action.
*
* This function will be used to respond to HTTP requests received by a Convex
* deployment if the requests matches the path and method where this action
* is routed. Be sure to route your action in `convex/http.js`.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
*/
export declare const httpAction: HttpActionBuilder;
/**
* A set of services for use within Convex query functions.
*
* The query context is passed as the first argument to any Convex query
* function run on the server.
*
* This differs from the {@link MutationCtx} because all of the services are
* read-only.
*/
export type QueryCtx = GenericQueryCtx<DataModel>;
/**
* A set of services for use within Convex mutation functions.
*
* The mutation context is passed as the first argument to any Convex mutation
* function run on the server.
*/
export type MutationCtx = GenericMutationCtx<DataModel>;
/**
* A set of services for use within Convex action functions.
*
* The action context is passed as the first argument to any Convex action
* function run on the server.
*/
export type ActionCtx = GenericActionCtx<DataModel>;
/**
* An interface to read from the database within Convex query functions.
*
* The two entry points are {@link DatabaseReader.get}, which fetches a single
* document by its {@link Id}, or {@link DatabaseReader.query}, which starts
* building a query.
*/
export type DatabaseReader = GenericDatabaseReader<DataModel>;
/**
* An interface to read from and write to the database within Convex mutation
* functions.
*
* Convex guarantees that all writes within a single mutation are
* executed atomically, so you never have to worry about partial writes leaving
* your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
* for the guarantees Convex provides your functions.
*/
export type DatabaseWriter = GenericDatabaseWriter<DataModel>;

View File

@ -0,0 +1,89 @@
/* eslint-disable */
/**
* Generated utilities for implementing server-side Convex query and mutation functions.
*
* THIS CODE IS AUTOMATICALLY GENERATED.
*
* To regenerate, run `npx convex dev`.
* @module
*/
import {
actionGeneric,
httpActionGeneric,
queryGeneric,
mutationGeneric,
internalActionGeneric,
internalMutationGeneric,
internalQueryGeneric,
} from "convex/server";
/**
* Define a query in this Convex app's public API.
*
* This function will be allowed to read your Convex database and will be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const query = queryGeneric;
/**
* Define a query that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to read from your Convex database. It will not be accessible from the client.
*
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/
export const internalQuery = internalQueryGeneric;
/**
* Define a mutation in this Convex app's public API.
*
* This function will be allowed to modify your Convex database and will be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const mutation = mutationGeneric;
/**
* Define a mutation that is only accessible from other Convex functions (but not from the client).
*
* This function will be allowed to modify your Convex database. It will not be accessible from the client.
*
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/
export const internalMutation = internalMutationGeneric;
/**
* Define an action in this Convex app's public API.
*
* An action is a function which can execute any JavaScript code, including non-deterministic
* code and code with side-effects, like calling third-party services.
* They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
* They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
*
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/
export const action = actionGeneric;
/**
* Define an action that is only accessible from other Convex functions (but not from the client).
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/
export const internalAction = internalActionGeneric;
/**
* Define a Convex HTTP action.
*
* @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object
* as its second.
* @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`.
*/
export const httpAction = httpActionGeneric;

View File

@ -0,0 +1,7 @@
import { getConvexProvidersConfig } from "@stackframe/stack";
export default {
providers: getConvexProvidersConfig({
projectId: process.env.NEXT_PUBLIC_STACK_PROJECT_ID
}),
}

View File

@ -0,0 +1,82 @@
import { v } from "convex/values";
import { query, mutation, action } from "./_generated/server";
import { api } from "./_generated/api";
import { stackClientApp } from "../stack/client"
// Write your Convex functions in any file inside this directory (`convex`).
// See https://docs.convex.dev/functions for more.
// You can read data from the database via a query:
export const listNumbers = query({
// Validators for arguments.
args: {
count: v.number(),
},
// Query implementation.
handler: async (ctx, args) => {
//// Read the database as many times as you need here.
//// See https://docs.convex.dev/database/reading-data.
const numbers = await ctx.db
.query("numbers")
// Ordered by _creationTime, return most recent
.order("desc")
.take(args.count);
const partialUser = await stackClientApp.getPartialUser({ from: "convex", ctx });
return {
viewer: partialUser?.primaryEmail ?? null,
numbers: numbers.reverse().map((number) => number.value),
};
},
});
// You can write data to the database via a mutation:
export const addNumber = mutation({
// Validators for arguments.
args: {
value: v.number(),
},
// Mutation implementation.
handler: async (ctx, args) => {
//// Insert or modify documents in the database here.
//// Mutations can also read from the database like queries.
//// See https://docs.convex.dev/database/writing-data.
const id = await ctx.db.insert("numbers", { value: args.value });
console.log("Added new document with id:", id);
// Optionally, return a value from your mutation.
// return id;
},
});
// You can fetch data from and send data to third-party APIs via an action:
export const myAction = action({
// Validators for arguments.
args: {
first: v.number(),
second: v.string(),
},
// Action implementation.
handler: async (ctx, args) => {
//// Use the browser-like `fetch` API to send HTTP requests.
//// See https://docs.convex.dev/functions/actions#calling-third-party-apis-and-using-npm-packages.
// const response = await ctx.fetch("https://api.thirdpartyservice.com");
// const data = await response.json();
//// Query data by running Convex queries.
const data = await ctx.runQuery(api.myFunctions.listNumbers, {
count: 10,
});
console.log(data);
//// Write data by running Convex mutations.
await ctx.runMutation(api.myFunctions.addNumber, {
value: args.first,
});
},
});

View File

@ -0,0 +1,12 @@
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
// The schema is entirely optional.
// You can delete this file (schema.ts) and the
// app will continue to work.
// The schema provides more precise TypeScript types.
export default defineSchema({
numbers: defineTable({
value: v.number(),
}),
});

View File

@ -0,0 +1,25 @@
{
/* This TypeScript project config describes the environment that
* Convex functions run in and is used to typecheck them.
* You can modify it, but some settings required to use Convex.
*/
"compilerOptions": {
/* These settings are not required by Convex and can be modified. */
"allowJs": true,
"strict": true,
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
/* These compiler options are required by Convex */
"target": "ESNext",
"lib": ["ES2021", "dom"],
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"isolatedModules": true,
"noEmit": true
},
"include": ["./**/*"],
"exclude": ["./_generated"]
}

View File

@ -0,0 +1,16 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;

View File

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

11210
examples/convex/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,37 @@
{
"name": "@stackframe/convex-example",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "npm-run-all --parallel dev:frontend dev:backend",
"dev:frontend": "next dev",
"dev:backend": "convex dev",
"predev": "convex dev --until-success && convex dashboard",
"start": "next start",
"build": "next build",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"clean": "rimraf .next && rimraf node_modules"
},
"dependencies": {
"@stackframe/stack": "workspace:*",
"convex": "^1.27.0",
"next": "15.2.3",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^8",
"eslint-config-next": "14.2.5",
"rimraf": "^5.0.5",
"typescript": "5.3.3",
"npm-run-all": "^4.1.5",
"prettier": "^3.5.3",
"tailwindcss": "^4"
}
}

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 367 370" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,-129.225,-127.948)">
<g id="Layer-1" serif:id="Layer 1" transform="matrix(4.16667,0,0,4.16667,0,0)">
<g transform="matrix(1,0,0,1,86.6099,107.074)">
<path d="M0,-6.544C13.098,-7.973 25.449,-14.834 32.255,-26.287C29.037,2.033 -2.48,19.936 -28.196,8.94C-30.569,7.925 -32.605,6.254 -34.008,4.088C-39.789,-4.83 -41.69,-16.18 -38.963,-26.48C-31.158,-13.247 -15.3,-5.131 0,-6.544" style="fill:rgb(245,176,26);fill-rule:nonzero;"/>
</g>
<g transform="matrix(1,0,0,1,47.1708,74.7779)">
<path d="M0,-2.489C-5.312,9.568 -5.545,23.695 0.971,35.316C-21.946,18.37 -21.692,-17.876 0.689,-34.65C2.754,-36.197 5.219,-37.124 7.797,-37.257C18.41,-37.805 29.19,-33.775 36.747,-26.264C21.384,-26.121 6.427,-16.446 0,-2.489" style="fill:rgb(141,37,118);fill-rule:nonzero;"/>
</g>
<g transform="matrix(1,0,0,1,91.325,66.4152)">
<path d="M0,-14.199C-7.749,-24.821 -19.884,-32.044 -33.173,-32.264C-7.482,-43.726 24.112,-25.143 27.557,2.322C27.877,4.876 27.458,7.469 26.305,9.769C21.503,19.345 12.602,26.776 2.203,29.527C9.838,15.64 8.889,-1.328 0,-14.199" style="fill:rgb(238,52,47);fill-rule:nonzero;"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,6 @@
import { StackClientApp } from "@stackframe/stack";
export const stackClientApp = new StackClientApp({
baseUrl: "http://localhost:8102",
tokenStore: "nextjs-cookie",
});

View File

@ -0,0 +1,7 @@
import "server-only";
import { StackServerApp } from "@stackframe/stack";
export const stackServerApp = new StackServerApp({
tokenStore: "nextjs-cookie",
});

View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@ -18,16 +18,16 @@
"ensure-neon": "grep -q '\"@neondatabase/serverless\"' ./test-run-output/package.json && echo 'Initialized Neon successfully!'",
"test-run-neon": "pnpm run test-run-node --neon && pnpm run ensure-neon",
"test-run-neon:manual": "pnpm run test-run-node:manual --neon && pnpm run ensure-neon",
"test-run-no-browser": "rimraf test-run-output && mkdir test-run-output && cd test-run-output && npm init --init-author-name example-author --init-license UNLICENSED --init-author-url http://example.com --init-module test-run-output --init-version 1.0.0 -y && cd .. && pnpm run init-stack:local test-run-output --agent-mode --js --server --npm --no-browser",
"test-run-no-browser": "rimraf test-run-output && mkdir test-run-output && cd test-run-output && npm init --init-author-name example-author --init-license UNLICENSED --init-author-url http://example.com --init-module test-run-output --init-version 1.0.0 -y && cd .. && pnpm run init-stack:local test-run-output --on-question error --js --server --npm --no-browser",
"test-run-node:manual": "rimraf test-run-output && mkdir test-run-output && cd test-run-output && npm init && cd .. && pnpm run init-stack:local test-run-output",
"test-run-node": "rimraf test-run-output && mkdir test-run-output && cd test-run-output && npm init --init-author-name example-author --init-license UNLICENSED --init-author-url http://example.com --init-module test-run-output --init-version 1.0.0 -y && cd .. && pnpm run init-stack:local test-run-output --agent-mode --js --server --npm --no-browser",
"test-run-node": "rimraf test-run-output && mkdir test-run-output && cd test-run-output && npm init --init-author-name example-author --init-license UNLICENSED --init-author-url http://example.com --init-module test-run-output --init-version 1.0.0 -y && cd .. && pnpm run init-stack:local test-run-output --on-question error --js --server --npm --no-browser",
"test-run-js:manual": "rimraf test-run-output && npx -y sv create test-run-output --no-install && pnpm run init-stack:local test-run-output",
"test-run-js": "rimraf test-run-output && npx -y sv create test-run-output --template minimal --types ts --no-add-ons --no-install && pnpm run init-stack:local test-run-output --agent-mode --js --client --npm --no-browser",
"test-run-js": "rimraf test-run-output && npx -y sv create test-run-output --template minimal --types ts --no-add-ons --no-install && pnpm run init-stack:local test-run-output --on-question error --js --client --npm --no-browser",
"test-run-next:manual": "rimraf test-run-output && npx -y create-next-app@latest test-run-output && pnpm run init-stack:local test-run-output",
"test-run-next": "rimraf test-run-output && npx -y create-next-app@latest test-run-output --app --ts --no-src-dir --tailwind --use-npm --eslint --import-alias '##@#/*' --turbopack && pnpm run init-stack:local test-run-output --agent-mode --no-browser",
"test-run-keys-next": "rimraf test-run-output && npx -y create-next-app@latest test-run-output --app --ts --no-src-dir --tailwind --use-npm --eslint --import-alias '##@#/*' --turbopack && pnpm run init-stack:local test-run-output --agent-mode --project-id my-project-id --publishable-client-key my-publishable-client-key",
"test-run-keys-js": "rimraf test-run-output && npx -y sv create test-run-output --template minimal --types ts --no-add-ons --no-install && pnpm run init-stack:local test-run-output --agent-mode --js --client --npm --project-id my-project-id --publishable-client-key my-publishable-client-key",
"test-run-react": "rimraf test-run-output && npx -y create-vite@latest test-run-output --template react-ts && pnpm run init-stack:local test-run-output --agent-mode --no-browser --npm",
"test-run-next": "rimraf test-run-output && npx -y create-next-app@latest test-run-output --app --ts --no-src-dir --tailwind --use-npm --eslint --import-alias '##@#/*' --turbopack && pnpm run init-stack:local test-run-output --on-question error --no-browser",
"test-run-keys-next": "rimraf test-run-output && npx -y create-next-app@latest test-run-output --app --ts --no-src-dir --tailwind --use-npm --eslint --import-alias '##@#/*' --turbopack && pnpm run init-stack:local test-run-output --on-question error --project-id my-project-id --publishable-client-key my-publishable-client-key",
"test-run-keys-js": "rimraf test-run-output && npx -y sv create test-run-output --template minimal --types ts --no-add-ons --no-install && pnpm run init-stack:local test-run-output --on-question error --js --client --npm --project-id my-project-id --publishable-client-key my-publishable-client-key",
"test-run-react": "rimraf test-run-output && npx -y create-vite@latest test-run-output --template react-ts && pnpm run init-stack:local test-run-output --on-question error --no-browser --npm",
"test-run-react:manual": "rimraf test-run-output && npx -y create-vite@latest test-run-output --template react-ts && pnpm run init-stack:local test-run-output --react"
},
"files": [

View File

@ -24,6 +24,51 @@ const jsLikeFileExtensions: string[] = [
"js",
];
class UserError extends Error {
constructor(message: string) {
super(message);
this.name = "UserError";
}
}
class UnansweredQuestionError extends UserError {
constructor(message: string) {
super(message + ", or use --on-question <guess|ask> to answer questions automatically or interactively");
this.name = "UnansweredQuestionError";
}
}
type OnQuestionMode = "ask" | "guess" | "error";
function isTruthyEnv(name: string): boolean {
const v = process.env[name];
if (!v) return false;
const s = String(v).toLowerCase();
return s === "1" || s === "true" || s === "yes";
}
function isNonInteractiveEnv(): boolean {
if (isTruthyEnv("CI")) return true;
if (isTruthyEnv("GITHUB_ACTIONS")) return true;
if (isTruthyEnv("NONINTERACTIVE")) return true;
if (isTruthyEnv("NO_INTERACTIVE")) return true;
if (isTruthyEnv("PNPM_NON_INTERACTIVE")) return true;
if (isTruthyEnv("YARN_ENABLE_NON_INTERACTIVE")) return true;
if (isTruthyEnv("CURSOR_AGENT")) return true;
if (isTruthyEnv("CLAUDECODE")) return true;
return false;
}
function resolveOnQuestionMode(opt: string): OnQuestionMode {
if (!opt || opt === "default") {
return isNonInteractiveEnv() ? "error" : "ask";
}
if (opt === "ask" || opt === "guess" || opt === "error") {
return opt;
}
throw new UserError(`Invalid argument for --on-question: "${opt}". Valid modes are: "ask", "guess", "error", "default".`);
}
// Setup command line parsing
const program = new Command();
program
@ -46,7 +91,7 @@ program
.option("--project-id <project-id>", "Project ID to use in setup")
.option("--publishable-client-key <publishable-client-key>", "Publishable client key to use in setup")
.option("--no-browser", "Don't open browser for environment variable setup")
.option("--agent-mode", "Run without prompting for any input")
.option("--on-question <mode>", "How to handle interactive questions: ask | guess | error | default", "default")
.addHelpText('after', `
For more information, please visit https://docs.stack-auth.com/getting-started/setup`);
@ -64,18 +109,12 @@ const isClient: boolean = options.client || false;
const isServer: boolean = options.server || false;
const projectIdFromArgs: string | undefined = options.projectId;
const publishableClientKeyFromArgs: string | undefined = options.publishableClientKey;
const agentMode = !!options.agentMode;
const onQuestionMode: OnQuestionMode = resolveOnQuestionMode(options.onQuestion);
// Commander negates the boolean options with prefix `--no-`
// so `--no-browser` becomes `browser: false`
const noBrowser: boolean = !options.browser;
class UserError extends Error {
constructor(message: string) {
super(message);
this.name = "UserError";
}
}
type Ansis = {
red: string,
blue: string,
@ -436,9 +475,12 @@ const Steps = {
if (packageJson.dependencies?.["react"] || packageJson.dependencies?.["react-dom"]) {
return "react";
}
if (agentMode) {
if (onQuestionMode === "guess") {
return "js";
}
if (onQuestionMode === "error") {
throw new UnansweredQuestionError("Unable to auto-detect project type (checked for Next.js and React dependencies). Re-run with one of: --js, --react, or --next.");
}
const { type } = await inquirer.prompt([
{
@ -625,15 +667,25 @@ const Steps = {
const tokenStore = type === "next" ? '"nextjs-cookie"' : (clientOrServer === "client" ? '"cookie"' : '"memory"');
const publishableClientKeyWrite = clientOrServer === "server"
? `process.env.STACK_PUBLISHABLE_CLIENT_KEY ${publishableClientKeyFromArgs ? `|| '${publishableClientKeyFromArgs}'` : ""}`
? `process.env.STACK_PUBLISHABLE_CLIENT_KEY ${publishableClientKeyFromArgs ? `|| ${JSON.stringify(publishableClientKeyFromArgs)}` : ""}`
: `'${publishableClientKeyFromArgs ?? 'INSERT_YOUR_PUBLISHABLE_CLIENT_KEY_HERE'}'`;
const jsOptions = type === "js" ? [
`\n\n${indentation}// get your Stack Auth API keys from https://app.stack-auth.com${clientOrServer === "client" ? ` and store them in a safe place (eg. environment variables)` : ""}`,
`${projectIdFromArgs ? `${indentation}projectId: '${projectIdFromArgs}',` : ""}`,
`${projectIdFromArgs ? `${indentation}projectId: ${JSON.stringify(projectIdFromArgs)},` : ""}`,
`${indentation}publishableClientKey: ${publishableClientKeyWrite},`,
`${clientOrServer === "server" ? `${indentation}secretServerKey: process.env.STACK_SECRET_SERVER_KEY,` : ""}`,
].filter(Boolean).join("\n") : "";
const nextClientOptions = (type === "next" && clientOrServer === "client")
? (() => {
const lines = [
projectIdFromArgs ? `${indentation}projectId: process.env.NEXT_PUBLIC_STACK_PROJECT_ID ?? ${JSON.stringify(projectIdFromArgs)},` : "",
publishableClientKeyFromArgs ? `${indentation}publishableClientKey: process.env.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY ?? ${JSON.stringify(publishableClientKeyFromArgs)},` : "",
].filter(Boolean).join("\n");
return lines ? `\n${lines}` : "";
})()
: "";
laterWriteFileIfNotExists(
stackAppPath,
@ -643,7 +695,7 @@ ${type === "next" && clientOrServer === "server" ? `import "server-only";` : ""}
import { Stack${clientOrServerCap}App } from ${JSON.stringify(packageName)};
export const stack${clientOrServerCap}App = new Stack${clientOrServerCap}App({
${indentation}tokenStore: ${tokenStore},${jsOptions}
${indentation}tokenStore: ${tokenStore},${jsOptions}${nextClientOptions}
});
`.trim() + "\n"
);
@ -741,7 +793,7 @@ ${indentation}tokenStore: ${tokenStore},${jsOptions}
react: "React",
} as const;
const typeString = typeStringMap[type];
const isReady = agentMode || (await inquirer.prompt([
const isReady = (onQuestionMode !== "ask") || (await inquirer.prompt([
{
type: "confirm",
name: "ready",
@ -759,8 +811,9 @@ ${indentation}tokenStore: ${tokenStore},${jsOptions}
if (isServer) return ["server"];
if (isClient) return ["client"];
if (agentMode) {
throw new UserError("Please specify the installation type using the --server or --client argument.");
if (onQuestionMode === "guess") return ["server", "client"];
if (onQuestionMode === "error") {
throw new UnansweredQuestionError("Ambiguous installation type. Re-run with --server, --client, or both.");
}
return (await inquirer.prompt([{
@ -816,7 +869,7 @@ async function getUpdatedLayout(originalLayout: string): Promise<LayoutResult |
const importInsertLocationM1 =
firstImportLocationM1 ?? (hasStringAsFirstLine ? layout.indexOf("\n") : -1);
const importInsertLocation = importInsertLocationM1 + 1;
const importStatement = `import { StackProvider, StackTheme } from "@stackframe/stack";\nimport { stackServerApp } from "../stack/server";\n`;
const importStatement = `import { StackProvider, StackTheme } from "@stackframe/stack";\nimport { stackClientApp } from "../stack/client";\n`;
layout =
layout.slice(0, importInsertLocation) +
importStatement +
@ -843,7 +896,7 @@ async function getUpdatedLayout(originalLayout: string): Promise<LayoutResult |
bodyCloseStartIndex
);
const insertOpen = "<StackProvider app={stackServerApp}><StackTheme>";
const insertOpen = "<StackProvider app={stackClientApp}><StackTheme>";
const insertClose = "</StackTheme></StackProvider>";
layout =
@ -899,8 +952,8 @@ async function getProjectPath(): Promise<string> {
path.join(savedProjectPath, "package.json")
);
if (askForPathModification) {
if (agentMode) {
throw new UserError(`No package.json file found in the project directory ${savedProjectPath}. Please specify the correct project path using the --project-path argument, or create a new project before running the wizard.`);
if (onQuestionMode === "guess" || onQuestionMode === "error") {
throw new UserError(`No package.json file found in ${savedProjectPath}. Re-run providing the project path argument (e.g. 'init-stack <project-path>').`);
}
savedProjectPath = (
await inquirer.prompt([
@ -932,7 +985,7 @@ async function promptPackageManager(): Promise<string> {
const yarnLock = fs.existsSync(path.join(projectPath, "yarn.lock"));
const pnpmLock = fs.existsSync(path.join(projectPath, "pnpm-lock.yaml"));
const npmLock = fs.existsSync(path.join(projectPath, "package-lock.json"));
const bunLock = fs.existsSync(path.join(projectPath, "bun.lockb"));
const bunLock = fs.existsSync(path.join(projectPath, "bun.lockb")) || fs.existsSync(path.join(projectPath, "bun.lock"));
if (yarnLock && !pnpmLock && !npmLock && !bunLock) {
return "yarn";
@ -944,8 +997,9 @@ async function promptPackageManager(): Promise<string> {
return "bun";
}
if (agentMode) {
throw new UserError("Unable to determine which package manager to use. Please rerun the init command and specify the package manager using exactly one of the following arguments: --npm, --yarn, --pnpm, or --bun.");
if (onQuestionMode === "guess") return "npm";
if (onQuestionMode === "error") {
throw new UnansweredQuestionError("Unable to determine the package manager. Re-run with one of: --npm, --yarn, --pnpm, or --bun.");
}
const answers = await inquirer.prompt([

View File

@ -63,6 +63,7 @@
"react": "^18.2.0",
"rimraf": "^5.0.5",
"tailwindcss": "^3.4.4",
"tsup": "^8.0.2"
"tsup": "^8.0.2",
"convex": "^1.27.0"
}
}

View File

@ -87,6 +87,7 @@
"react-dom": "^18.2.0",
"rimraf": "^5.0.5",
"tailwindcss": "^3.4.4",
"tsup": "^8.0.2"
"tsup": "^8.0.2",
"convex": "^1.27.0"
}
}

View File

@ -1,8 +1,8 @@
import * as yup from "yup";
import { KnownErrors } from ".";
import { KnownErrors } from "./known-errors";
import { isBase64 } from "./utils/bytes";
import { SUPPORTED_CURRENCIES, type Currency, type MoneyAmount } from "./utils/currency-constants";
import { DayInterval, Interval } from "./utils/dates";
import type { DayInterval, Interval } from "./utils/dates";
import { StackAssertionError, throwErr } from "./utils/errors";
import { decodeBasicAuthorizationHeader } from "./utils/http";
import { allProviders } from "./utils/oauth";
@ -657,6 +657,21 @@ export const userPasswordHashMutationSchema = yupString()
export const userTotpSecretMutationSchema = base64Schema.nullable().meta({ openapiField: { description: 'Enables 2FA and sets a TOTP secret for the user. Set to null to disable 2FA.', exampleValue: 'dG90cC1zZWNyZXQ=' } });
// Auth
export const accessTokenPayloadSchema = yupObject({
sub: yupString().defined(),
exp: yupNumber().optional(),
iss: yupString().defined(),
aud: yupString().defined(),
project_id: yupString().defined(),
branch_id: yupString().defined(),
refresh_token_id: yupString().defined(),
role: yupString().oneOf(["authenticated"]).defined(),
name: yupString().defined().nullable(),
email: yupString().defined().nullable(),
email_verified: yupBoolean().defined(),
selected_team_id: yupString().defined().nullable(),
is_anonymous: yupBoolean().defined(),
});
export const signInEmailSchema = strictEmailSchema(undefined).meta({ openapiField: { description: 'The email to sign in with.', exampleValue: 'johndoe@example.com' } });
export const emailOtpSignInCallbackUrlSchema = urlSchema.meta({ openapiField: { description: 'The base callback URL to construct the magic link from. A query parameter `code` with the verification code will be appended to it. The page should then make a request to the `/auth/otp/sign-in` endpoint.', exampleValue: 'https://example.com/handler/magic-link-callback' } });
export const emailVerificationCallbackUrlSchema = urlSchema.meta({ openapiField: { description: 'The base callback URL to construct a verification link for the verification e-mail. A query parameter `code` with the verification code will be appended to it. The page should then make a request to the `/contact-channels/verify` endpoint.', exampleValue: 'https://example.com/handler/email-verification' } });

View File

@ -1,7 +1,12 @@
import * as jose from 'jose';
import { InferType } from 'yup';
import { accessTokenPayloadSchema } from './schema-fields';
import { StackAssertionError } from "./utils/errors";
import { Store } from "./utils/stores";
export type AccessTokenPayload = InferType<typeof accessTokenPayloadSchema>;
export class AccessToken {
constructor(
public readonly token: string,
@ -11,12 +16,13 @@ export class AccessToken {
}
}
get decoded() {
return jose.decodeJwt(this.token);
get payload() {
const payload = jose.decodeJwt(this.token);
return accessTokenPayloadSchema.validateSync(payload);
}
get expiresAt(): Date {
const { exp } = this.decoded;
const { exp } = this.payload;
if (exp === undefined) return new Date(8640000000000000); // max date value
return new Date(exp * 1000);
}
@ -117,19 +123,28 @@ export class InternalSession {
}
/**
* Returns the access token if it is found in the cache, fetching it otherwise.
*
* This is usually the function you want to call to get an access token. Either set `minMillisUntilExpiration` to a reasonable value, or catch errors that occur if it expires, and call `markAccessTokenExpired` to mark the token as expired if so (after which a call to this function will always refetch the token).
*
* @returns null if the session is known to be invalid, cached tokens if they exist in the cache (which may or may not be valid still), or new tokens otherwise.
* Returns the access token if it is found in the cache and not expired yet, or null otherwise. Never fetches new tokens.
*/
async getOrFetchLikelyValidTokens(minMillisUntilExpiration: number): Promise<{ accessToken: AccessToken, refreshToken: RefreshToken | null } | null> {
if (minMillisUntilExpiration >= 60_000) {
getAccessTokenIfNotExpiredYet(minMillisUntilExpiration: number): AccessToken | null {
if (minMillisUntilExpiration > 60_000) {
throw new Error(`Required access token expiry ${minMillisUntilExpiration}ms is too long; access tokens are too short to be used for more than 60s`);
}
const accessToken = this._getPotentiallyInvalidAccessTokenIfAvailable();
if (!accessToken || accessToken.expiresInMillis < minMillisUntilExpiration) {
if (!accessToken || accessToken.expiresInMillis < minMillisUntilExpiration) return null;
return accessToken;
}
/**
* Returns the access token if it is found in the cache, fetching it otherwise.
*
* This is usually the function you want to call to get an access token. Either set `minMillisUntilExpiration` to a reasonable value, or catch errors that occur if it expires, and call `markAccessTokenExpired` to mark the token as expired if so (after which a call to this function will always refetch the token).
*
* @returns null if the session is known to be invalid, cached tokens if they exist in the cache and the access token hasn't expired yet (the refresh token might still be invalid), or new tokens otherwise.
*/
async getOrFetchLikelyValidTokens(minMillisUntilExpiration: number): Promise<{ accessToken: AccessToken, refreshToken: RefreshToken | null } | null> {
const accessToken = this.getAccessTokenIfNotExpiredYet(minMillisUntilExpiration);
if (!accessToken) {
const newTokens = await this.fetchNewTokens();
const expiresInMillis = newTokens?.accessToken.expiresInMillis;
if (expiresInMillis && expiresInMillis < minMillisUntilExpiration) {

View File

@ -0,0 +1,286 @@
import { range } from "./arrays";
import { StackAssertionError } from "./errors";
type QueryOptions<Type extends 'next' | 'prev', Cursor, Filter, OrderBy> =
& {
filter: Filter,
orderBy: OrderBy,
limit: number,
/**
* Whether the limit should be treated as an exact value, or an approximate value.
*
* If set to 'exact', less items will only be returned if the list item is the first or last item.
*
* If set to 'at-least' or 'approximate', the implementation may decide to return more items than the limit requested if doing so comes at no (or negligible) extra cost.
*
* If set to 'at-most' or 'approximate', the implementation may decide to return less items than the limit requested if requesting more items would come at a non-negligible extra cost. In this case, if limit > 0, the implementation must still make progress towards the end of the list and the returned cursor must be different from the one passed in.
*
* Defaults to 'exact'.
*/
limitPrecision: 'exact' | 'at-least' | 'at-most' | 'approximate',
}
& ([Type] extends [never] ? unknown
: [Type] extends ['next'] ? { after: Cursor }
: [Type] extends ['prev'] ? { before: Cursor }
: { cursor: Cursor });
type ImplQueryOptions<Type extends 'next' | 'prev', Cursor, Filter, OrderBy> = QueryOptions<Type, Cursor, Filter, OrderBy> & { limitPrecision: 'approximate' }
type QueryResult<Item, Cursor> = { items: { item: Item, itemCursor: Cursor }[], isFirst: boolean, isLast: boolean, cursor: Cursor }
type ImplQueryResult<Item, Cursor> = { items: { item: Item, itemCursor: Cursor }[], isFirst: boolean, isLast: boolean, cursor: Cursor }
export abstract class PaginatedList<
Item,
Cursor extends string,
Filter extends unknown,
OrderBy extends unknown,
> {
// Abstract methods
protected abstract _getFirstCursor(): Cursor;
protected abstract _getLastCursor(): Cursor;
protected abstract _compare(orderBy: OrderBy, a: Item, b: Item): number;
protected abstract _nextOrPrev(type: 'next' | 'prev', options: ImplQueryOptions<'next' | 'prev', Cursor, Filter, OrderBy>): Promise<ImplQueryResult<Item, Cursor>>;
// Implementations
public getFirstCursor(): Cursor { return this._getFirstCursor(); }
public getLastCursor(): Cursor { return this._getLastCursor(); }
public compare(orderBy: OrderBy, a: Item, b: Item): number { return this._compare(orderBy, a, b); }
async nextOrPrev(type: 'next' | 'prev', options: QueryOptions<'next' | 'prev', Cursor, Filter, OrderBy>): Promise<QueryResult<Item, Cursor>> {
let result: { item: Item, itemCursor: Cursor }[] = [];
let includesFirst = false;
let includesLast = false;
let cursor = options.cursor;
let limitRemaining = options.limit;
while (limitRemaining > 0 || (type === "next" && includesLast) || (type === "prev" && includesFirst)) {
const iterationRes = await this._nextOrPrev(type, {
cursor,
limit: options.limit,
limitPrecision: "approximate",
filter: options.filter,
orderBy: options.orderBy,
});
result[type === "next" ? "push" : "unshift"](...iterationRes.items);
limitRemaining -= iterationRes.items.length;
includesFirst ||= iterationRes.isFirst;
includesLast ||= iterationRes.isLast;
cursor = iterationRes.cursor;
if (["approximate", "at-most"].includes(options.limitPrecision)) break;
}
// Assert that the result is sorted
for (let i = 1; i < result.length; i++) {
if (this._compare(options.orderBy, result[i].item, result[i - 1].item) < 0) {
throw new StackAssertionError("Paginated list result is not sorted; something is wrong with the implementation", {
i,
options,
result,
});
}
}
if (["exact", "at-most"].includes(options.limitPrecision) && result.length > options.limit) {
if (type === "next") {
result = result.slice(0, options.limit);
includesLast = false;
if (options.limit > 0) cursor = result[result.length - 1].itemCursor;
} else {
result = result.slice(result.length - options.limit);
includesFirst = false;
if (options.limit > 0) cursor = result[0].itemCursor;
}
}
return { items: result, isFirst: includesFirst, isLast: includesLast, cursor };
}
public async next({ after, ...rest }: QueryOptions<'next', Cursor, Filter, OrderBy>): Promise<QueryResult<Item, Cursor>> {
return await this.nextOrPrev("next", {
...rest,
cursor: after,
});
}
public async prev({ before, ...rest }: QueryOptions<'prev', Cursor, Filter, OrderBy>): Promise<QueryResult<Item, Cursor>> {
return await this.nextOrPrev("prev", {
...rest,
cursor: before,
});
}
// Utility methods below
flatMap<Item2, Cursor2 extends string, Filter2 extends unknown, OrderBy2 extends unknown>(options: {
itemMapper: (itemEntry: { item: Item, itemCursor: Cursor }, filter: Filter2, orderBy: OrderBy2) => { item: Item2, itemCursor: Cursor2 }[],
compare: (orderBy: OrderBy2, a: Item2, b: Item2) => number,
newCursorFromOldCursor: (cursor: Cursor) => Cursor2,
oldCursorFromNewCursor: (cursor: Cursor2) => Cursor,
oldFilterFromNewFilter: (filter: Filter2) => Filter,
oldOrderByFromNewOrderBy: (orderBy: OrderBy2) => OrderBy,
estimateItemsToFetch: (options: { filter: Filter2, orderBy: OrderBy2, limit: number }) => number,
}): PaginatedList<Item2, Cursor2, Filter2, OrderBy2> {
const that = this;
class FlatMapPaginatedList extends PaginatedList<Item2, Cursor2, Filter2, OrderBy2> {
override _getFirstCursor(): Cursor2 { return options.newCursorFromOldCursor(that.getFirstCursor()); }
override _getLastCursor(): Cursor2 { return options.newCursorFromOldCursor(that.getLastCursor()); }
override _compare(orderBy: OrderBy2, a: Item2, b: Item2): number {
return options.compare(orderBy, a, b);
}
override async _nextOrPrev(type: 'next' | 'prev', { limit, filter, orderBy, cursor }: ImplQueryOptions<'next' | 'prev', Cursor2, Filter2, OrderBy2>) {
const estimatedItems = options.estimateItemsToFetch({ limit, filter, orderBy });
const original = await that.nextOrPrev(type, {
limit: estimatedItems,
limitPrecision: "approximate",
cursor: options.oldCursorFromNewCursor(cursor),
filter: options.oldFilterFromNewFilter(filter),
orderBy: options.oldOrderByFromNewOrderBy(orderBy),
});
const mapped = original.items.flatMap(itemEntry => options.itemMapper(
itemEntry,
filter,
orderBy,
));
return {
items: mapped,
isFirst: original.isFirst,
isLast: original.isLast,
cursor: options.newCursorFromOldCursor(original.cursor),
};
}
}
return new FlatMapPaginatedList();
}
map<Item2, Filter2 extends unknown, OrderBy2 extends unknown>(options: {
itemMapper: (item: Item) => Item2,
oldItemFromNewItem: (item: Item2) => Item,
oldFilterFromNewFilter: (filter: Filter2) => Filter,
oldOrderByFromNewOrderBy: (orderBy: OrderBy2) => OrderBy,
}): PaginatedList<Item2, Cursor, Filter2, OrderBy2> {
return this.flatMap({
itemMapper: (itemEntry, filter, orderBy) => {
return [{ item: options.itemMapper(itemEntry.item), itemCursor: itemEntry.itemCursor }];
},
compare: (orderBy, a, b) => this.compare(options.oldOrderByFromNewOrderBy(orderBy), options.oldItemFromNewItem(a), options.oldItemFromNewItem(b)),
newCursorFromOldCursor: (cursor) => cursor,
oldCursorFromNewCursor: (cursor) => cursor,
oldFilterFromNewFilter: (filter) => options.oldFilterFromNewFilter(filter),
oldOrderByFromNewOrderBy: (orderBy) => options.oldOrderByFromNewOrderBy(orderBy),
estimateItemsToFetch: (options) => options.limit,
});
}
filter<Filter2 extends unknown>(options: {
filter: (item: Item, filter: Filter2) => boolean,
oldFilterFromNewFilter: (filter: Filter2) => Filter,
estimateItemsToFetch: (options: { filter: Filter2, orderBy: OrderBy, limit: number }) => number,
}): PaginatedList<Item, Cursor, Filter2, OrderBy> {
return this.flatMap({
itemMapper: (itemEntry, filter, orderBy) => (options.filter(itemEntry.item, filter) ? [itemEntry] : []),
compare: (orderBy, a, b) => this.compare(orderBy, a, b),
newCursorFromOldCursor: (cursor) => cursor,
oldCursorFromNewCursor: (cursor) => cursor,
oldFilterFromNewFilter: (filter) => options.oldFilterFromNewFilter(filter),
oldOrderByFromNewOrderBy: (orderBy) => orderBy,
estimateItemsToFetch: (o) => options.estimateItemsToFetch(o),
});
}
addFilter<AddedFilter extends unknown>(options: {
filter: (item: Item, filter: Filter & AddedFilter) => boolean,
estimateItemsToFetch: (options: { filter: Filter & AddedFilter, orderBy: OrderBy, limit: number }) => number,
}): PaginatedList<Item, Cursor, Filter & AddedFilter, OrderBy> {
return this.filter({
filter: (item, filter) => options.filter(item, filter),
oldFilterFromNewFilter: (filter) => filter,
estimateItemsToFetch: (o) => options.estimateItemsToFetch(o),
});
}
static merge<
Item,
Filter extends unknown,
OrderBy extends unknown,
>(
...lists: PaginatedList<Item, any, Filter, OrderBy>[]
): PaginatedList<Item, string, Filter, OrderBy> {
class MergePaginatedList extends PaginatedList<Item, string, Filter, OrderBy> {
override _getFirstCursor() { return JSON.stringify(lists.map(list => list.getFirstCursor())); }
override _getLastCursor() { return JSON.stringify(lists.map(list => list.getLastCursor())); }
override _compare(orderBy: OrderBy, a: Item, b: Item): number {
const listsResults = lists.map(list => list.compare(orderBy, a, b));
if (!listsResults.every(result => result === listsResults[0])) {
throw new StackAssertionError("Lists have different compare results; make sure that they use the same compare function", { lists, listsResults });
}
return listsResults[0];
}
override async _nextOrPrev(type: 'next' | 'prev', { limit, filter, orderBy, cursor }: ImplQueryOptions<'next' | 'prev', "first" | "last" | `[${string}]`, Filter, OrderBy>) {
const cursors = JSON.parse(cursor);
const fetchedLists = await Promise.all(lists.map(async (list, i) => {
return await list.nextOrPrev(type, {
limit,
filter,
orderBy,
cursor: cursors[i],
limitPrecision: "at-least",
});
}));
const combinedItems = fetchedLists.flatMap((list, i) => list.items.map((itemEntry) => ({ itemEntry, listIndex: i })));
const sortedItems = [...combinedItems].sort((a, b) => this._compare(orderBy, a.itemEntry.item, b.itemEntry.item));
const lastCursorForEachList = sortedItems.reduce((acc, item) => {
acc[item.listIndex] = item.itemEntry.itemCursor;
return acc;
}, range(lists.length).map((i) => cursors[i]));
return {
items: sortedItems.map((item) => item.itemEntry),
isFirst: sortedItems.every((item) => item.listIndex === 0),
isLast: sortedItems.every((item) => item.listIndex === lists.length - 1),
cursor: JSON.stringify(lastCursorForEachList),
};
}
}
return new MergePaginatedList();
}
static empty() {
class EmptyPaginatedList extends PaginatedList<never, "first" | "last", any, any> {
override _getFirstCursor() { return "first" as const; }
override _getLastCursor() { return "last" as const; }
override _compare(orderBy: any, a: any, b: any): number {
return 0;
}
override async _nextOrPrev(type: 'next' | 'prev', options: ImplQueryOptions<'next' | 'prev', string, any, any>) {
return { items: [], isFirst: true, isLast: true, cursor: "first" as const };
}
}
return new EmptyPaginatedList();
}
}
export class ArrayPaginatedList<Item> extends PaginatedList<Item, `${number}`, (item: Item) => boolean, (a: Item, b: Item) => number> {
constructor(private readonly array: Item[]) {
super();
}
override _getFirstCursor() { return "0" as const; }
override _getLastCursor() { return `${this.array.length - 1}` as const; }
override _compare(orderBy: (a: Item, b: Item) => number, a: Item, b: Item): number {
return orderBy(a, b);
}
override async _nextOrPrev(type: 'next' | 'prev', options: ImplQueryOptions<'next' | 'prev', `${number}`, (item: Item) => boolean, (a: Item, b: Item) => number>) {
const filteredArray = this.array.filter(options.filter);
const sortedArray = [...filteredArray].sort((a, b) => this._compare(options.orderBy, a, b));
const itemEntriesArray = sortedArray.map((item, index) => ({ item, itemCursor: `${index}` as const }));
const oldCursor = Number(options.cursor);
const newCursor = Math.max(0, Math.min(this.array.length - 1, oldCursor + (type === "next" ? 1 : -1) * options.limit));
return {
items: itemEntriesArray.slice(Math.min(oldCursor, newCursor), Math.max(oldCursor, newCursor)),
isFirst: oldCursor === 0 || newCursor === 0,
isLast: oldCursor === this.array.length - 1 || newCursor === this.array.length - 1,
cursor: `${newCursor}` as const,
};
}
}

View File

@ -95,6 +95,7 @@
"react-dom": "^18.2.0",
"rimraf": "^5.0.5",
"tailwindcss": "^3.4.4",
"tsup": "^8.0.2"
"tsup": "^8.0.2",
"convex": "^1.27.0"
}
}

View File

@ -140,6 +140,7 @@
"react-dom": "^18.2.0",
"rimraf": "^5.0.5",
"tailwindcss": "^3.4.4",
"tsup": "^8.0.2"
"tsup": "^8.0.2",
"convex": "^1.27.0"
}
}

View File

@ -100,6 +100,7 @@
"react-dom": "^18.2.0",
"rimraf": "^5.0.5",
"tailwindcss": "^3.4.4",
"tsup": "^8.0.2"
"tsup": "^8.0.2",
"convex": "^1.27.0"
}
}

View File

@ -1,5 +1,7 @@
export * from './lib/stack-app';
export { getConvexProvidersConfig } from "./integrations/convex";
// IF_PLATFORM react-like
export { default as StackHandler } from "./components-page/stack-handler";
export { useStackApp, useUser } from "./lib/hooks";
@ -8,6 +10,7 @@ export { StackTheme } from './providers/theme-provider';
export { AccountSettings } from "./components-page/account-settings";
export { AuthPage } from "./components-page/auth-page";
export { CliAuthConfirmation } from "./components-page/cli-auth-confirm";
export { EmailVerification } from "./components-page/email-verification";
export { ForgotPassword } from "./components-page/forgot-password";
export { PasswordReset } from "./components-page/password-reset";
@ -23,5 +26,4 @@ export { OAuthButtonGroup } from "./components/oauth-button-group";
export { SelectedTeamSwitcher } from "./components/selected-team-switcher";
export { TeamSwitcher } from "./components/team-switcher";
export { UserButton } from "./components/user-button";
export { CliAuthConfirmation } from "./components-page/cli-auth-confirm";
// END_PLATFORM

View File

@ -0,0 +1,16 @@
import { urlString } from "@stackframe/stack-shared/dist/utils/urls";
import { getDefaultProjectId } from "../lib/stack-app/apps/implementations/common";
export function getConvexProvidersConfig(options: {
projectId?: string,
}) {
const projectId = options.projectId ?? getDefaultProjectId();
return [
{
type: "customJwt",
issuer: urlString`https://api.stack-auth.com/api/v1/projects/${projectId}`,
jwks: urlString`https://api.stack-auth.com/api/v1/projects/${projectId}/.well-known/jwks.json?include_anonymous=true`,
algorithm: "ES256",
},
];
}

View File

@ -36,7 +36,7 @@ import { constructRedirectUrl } from "../../../../utils/url";
import { addNewOAuthProviderOrScope, callOAuthCallback, signInWithOAuth } from "../../../auth";
import { CookieHelper, createBrowserCookieHelper, createCookieHelper, createPlaceholderCookieHelper, deleteCookieClient, getCookieClient, setOrDeleteCookie, setOrDeleteCookieClient } from "../../../cookie";
import { ApiKey, ApiKeyCreationOptions, ApiKeyUpdateOptions, apiKeyCreationOptionsToCrud } from "../../api-keys";
import { GetUserOptions, HandlerUrls, OAuthScopesOnSignIn, RedirectMethod, RedirectToOptions, RequestLike, TokenStoreInit, stackAppInternalsSymbol } from "../../common";
import { ConvexCtx, GetCurrentPartialUserOptions, GetCurrentUserOptions, HandlerUrls, OAuthScopesOnSignIn, RedirectMethod, RedirectToOptions, RequestLike, TokenStoreInit, stackAppInternalsSymbol } from "../../common";
import { OAuthConnection } from "../../connected-accounts";
import { ContactChannel, ContactChannelCreateOptions, ContactChannelUpdateOptions, contactChannelCreateOptionsToCrud, contactChannelUpdateOptionsToCrud } from "../../contact-channels";
import { Customer, Item } from "../../customers";
@ -44,7 +44,7 @@ import { NotificationCategory } from "../../notification-categories";
import { TeamPermission } from "../../permissions";
import { AdminOwnedProject, AdminProjectUpdateOptions, Project, adminProjectCreateOptionsToCrud } from "../../projects";
import { EditableTeamMemberProfile, Team, TeamCreateOptions, TeamInvitation, TeamUpdateOptions, TeamUser, teamCreateOptionsToCrud, teamUpdateOptionsToCrud } from "../../teams";
import { ActiveSession, Auth, BaseUser, CurrentUser, InternalUserExtra, OAuthProvider, ProjectCurrentUser, UserExtra, UserUpdateOptions, userUpdateOptionsToCrud } from "../../users";
import { ActiveSession, Auth, BaseUser, CurrentUser, InternalUserExtra, OAuthProvider, ProjectCurrentUser, SyncedPartialUser, TokenPartialUser, UserExtra, UserUpdateOptions, userUpdateOptionsToCrud } from "../../users";
import { StackClientApp, StackClientAppConstructorOptions, StackClientAppJson } from "../interfaces/client-app";
import { _StackAdminAppImplIncomplete } from "./admin-app-impl";
import { TokenObject, clientVersion, createCache, createCacheBySession, createEmptyTokenStore, getBaseUrl, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getUrls } from "./common";
@ -229,6 +229,10 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
}
);
private readonly _convexPartialUserCache = createCache<[unknown], TokenPartialUser | null>(
async ([ctx]) => await this._getPartialUserFromConvex(ctx as any)
);
private _anonymousSignUpInProgress: Promise<{ accessToken: string, refreshToken: string }> | null = null;
protected async _createCookieHelper(): Promise<CookieHelper> {
@ -583,7 +587,8 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
protected async _getSession(overrideTokenStoreInit?: TokenStoreInit): Promise<InternalSession> {
const tokenStore = this._getOrCreateTokenStore(await this._createCookieHelper(), overrideTokenStoreInit);
return this._getSessionFromTokenStore(tokenStore);
const session = this._getSessionFromTokenStore(tokenStore);
return session;
}
// IF_PLATFORM react-like
@ -1505,11 +1510,11 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
return result;
}
async getUser(options: GetUserOptions<HasTokenStore> & { or: 'redirect' }): Promise<ProjectCurrentUser<ProjectId>>;
async getUser(options: GetUserOptions<HasTokenStore> & { or: 'throw' }): Promise<ProjectCurrentUser<ProjectId>>;
async getUser(options: GetUserOptions<HasTokenStore> & { or: 'anonymous' }): Promise<ProjectCurrentUser<ProjectId>>;
async getUser(options?: GetUserOptions<HasTokenStore>): Promise<ProjectCurrentUser<ProjectId> | null>;
async getUser(options?: GetUserOptions<HasTokenStore>): Promise<ProjectCurrentUser<ProjectId> | null> {
async getUser(options: GetCurrentUserOptions<HasTokenStore> & { or: 'redirect' }): Promise<ProjectCurrentUser<ProjectId>>;
async getUser(options: GetCurrentUserOptions<HasTokenStore> & { or: 'throw' }): Promise<ProjectCurrentUser<ProjectId>>;
async getUser(options: GetCurrentUserOptions<HasTokenStore> & { or: 'anonymous' }): Promise<ProjectCurrentUser<ProjectId>>;
async getUser(options?: GetCurrentUserOptions<HasTokenStore>): Promise<ProjectCurrentUser<ProjectId> | null>;
async getUser(options?: GetCurrentUserOptions<HasTokenStore>): Promise<ProjectCurrentUser<ProjectId> | null> {
this._ensurePersistentTokenStore(options?.tokenStore);
const session = await this._getSession(options?.tokenStore);
let crud = Result.orThrow(await this._currentUserCache.getOrWait([session], "write-only"));
@ -1542,11 +1547,11 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
}
// IF_PLATFORM react-like
useUser(options: GetUserOptions<HasTokenStore> & { or: 'redirect' }): ProjectCurrentUser<ProjectId>;
useUser(options: GetUserOptions<HasTokenStore> & { or: 'throw' }): ProjectCurrentUser<ProjectId>;
useUser(options: GetUserOptions<HasTokenStore> & { or: 'anonymous' }): ProjectCurrentUser<ProjectId>;
useUser(options?: GetUserOptions<HasTokenStore>): ProjectCurrentUser<ProjectId> | null;
useUser(options?: GetUserOptions<HasTokenStore>): ProjectCurrentUser<ProjectId> | null {
useUser(options: GetCurrentUserOptions<HasTokenStore> & { or: 'redirect' }): ProjectCurrentUser<ProjectId>;
useUser(options: GetCurrentUserOptions<HasTokenStore> & { or: 'throw' }): ProjectCurrentUser<ProjectId>;
useUser(options: GetCurrentUserOptions<HasTokenStore> & { or: 'anonymous' }): ProjectCurrentUser<ProjectId>;
useUser(options?: GetCurrentUserOptions<HasTokenStore>): ProjectCurrentUser<ProjectId> | null;
useUser(options?: GetCurrentUserOptions<HasTokenStore>): ProjectCurrentUser<ProjectId> | null {
this._ensurePersistentTokenStore(options?.tokenStore);
const session = this._useSession(options?.tokenStore);
@ -1591,6 +1596,95 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
}
// END_PLATFORM
_getTokenPartialUserFromSession(session: InternalSession, options: GetCurrentPartialUserOptions<HasTokenStore>): TokenPartialUser | null {
const accessToken = session.getAccessTokenIfNotExpiredYet(0);
if (!accessToken) {
return null;
}
const isAnonymous = accessToken.payload.is_anonymous;
if (isAnonymous && options.or !== "anonymous") {
return null;
}
return {
id: accessToken.payload.sub,
primaryEmail: accessToken.payload.email,
displayName: accessToken.payload.name,
primaryEmailVerified: accessToken.payload.email_verified,
isAnonymous,
} satisfies TokenPartialUser;
}
async _getPartialUserFromConvex(ctx: ConvexCtx): Promise<TokenPartialUser | null> {
const auth = await ctx.auth.getUserIdentity();
if (!auth) {
return null;
}
return {
id: auth.subject,
displayName: auth.name ?? null,
primaryEmail: auth.email ?? null,
primaryEmailVerified: auth.email_verified as boolean,
isAnonymous: auth.is_anonymous as boolean,
};
}
async getPartialUser(options: GetCurrentPartialUserOptions<HasTokenStore> & { from: 'token' }): Promise<TokenPartialUser | null>;
async getPartialUser(options: GetCurrentPartialUserOptions<HasTokenStore> & { from: 'convex' }): Promise<TokenPartialUser | null>;
async getPartialUser(options: GetCurrentPartialUserOptions<HasTokenStore>): Promise<SyncedPartialUser | TokenPartialUser | null> {
switch (options.from) {
case "token": {
this._ensurePersistentTokenStore(options.tokenStore ?? this._tokenStoreInit);
const session = await this._getSession(options.tokenStore);
return this._getTokenPartialUserFromSession(session, options);
}
case "convex": {
return await this._getPartialUserFromConvex(options.ctx);
}
default: {
// @ts-expect-error
throw new Error(`Invalid 'from' option: ${options.from}`);
}
}
}
// IF_PLATFORM react-like
usePartialUser(options: GetCurrentPartialUserOptions<HasTokenStore> & { from: 'token' }): TokenPartialUser | null;
usePartialUser(options: GetCurrentPartialUserOptions<HasTokenStore> & { from: 'convex' }): TokenPartialUser | null;
usePartialUser(options: GetCurrentPartialUserOptions<HasTokenStore>): TokenPartialUser | SyncedPartialUser | null {
switch (options.from) {
case "token": {
this._ensurePersistentTokenStore(options.tokenStore ?? this._tokenStoreInit);
const session = this._useSession(options.tokenStore);
return this._getTokenPartialUserFromSession(session, options);
}
case "convex": {
const result = useAsyncCache(this._convexPartialUserCache, [options.ctx] as const, "usePartialUser(convex)");
return result;
}
default: {
// @ts-expect-error
throw new Error(`Invalid 'from' option: ${options.from}`);
}
}
}
// END_PLATFORM
getConvexClientAuth(options: { tokenStore: TokenStoreInit }): (args: { forceRefreshToken: boolean }) => Promise<string | null> {
return async (args: { forceRefreshToken: boolean }) => {
const session = await this._getSession(options.tokenStore);
if (!args.forceRefreshToken) {
const tokens = await session.getOrFetchLikelyValidTokens(20_000);
return tokens?.accessToken.token ?? null;
}
const tokens = await session.fetchNewTokens();
return tokens?.accessToken.token ?? null;
};
}
async getConvexHttpClientAuth(options: { tokenStore: TokenStoreInit }): Promise<string> {
const session = await this._getSession(options.tokenStore);
const tokens = await session.getOrFetchLikelyValidTokens(20_000);
return tokens?.accessToken.token ?? throwErr("No access token available");
}
protected async _updateClientUser(update: UserUpdateOptions, session: InternalSession) {
const res = await this._interface.updateClientUser(userUpdateOptionsToCrud(update), session);
await this._refreshUser(session);

View File

@ -21,7 +21,7 @@ import { useMemo } from "react"; // THIS_LINE_PLATFORM react-like
import * as yup from "yup";
import { constructRedirectUrl } from "../../../../utils/url";
import { ApiKey, ApiKeyCreationOptions, ApiKeyUpdateOptions, apiKeyCreationOptionsToCrud, apiKeyUpdateOptionsToCrud } from "../../api-keys";
import { GetUserOptions, HandlerUrls, OAuthScopesOnSignIn, TokenStoreInit } from "../../common";
import { GetCurrentUserOptions, HandlerUrls, OAuthScopesOnSignIn, TokenStoreInit, ConvexCtx } from "../../common";
import { OAuthConnection } from "../../connected-accounts";
import { ServerContactChannel, ServerContactChannelCreateOptions, ServerContactChannelUpdateOptions, serverContactChannelCreateOptionsToCrud, serverContactChannelUpdateOptionsToCrud } from "../../contact-channels";
import { InlineOffer, ServerItem } from "../../customers";
@ -152,6 +152,13 @@ export class _StackServerAppImplIncomplete<HasTokenStore extends boolean, Projec
}
);
private readonly _convexIdentitySubjectCache = createCache<[ConvexCtx], string | null>(
async ([ctx]) => {
const identity = await ctx.auth.getUserIdentity();
return identity ? identity.subject : null;
}
);
private readonly _serverCheckApiKeyCache = createCache<["user" | "team", string], UserApiKeysCrud['Server']['Read'] | TeamApiKeysCrud['Server']['Read'] | null>(async ([type, apiKey]) => {
const result = await this._interface.checkProjectApiKey(
type,
@ -870,6 +877,31 @@ export class _StackServerAppImplIncomplete<HasTokenStore extends boolean, Projec
}
return await this.getServerUserById(apiKeyObject.userId);
}
protected async _getUserByConvex(ctx: ConvexCtx, includeAnonymous: boolean): Promise<ServerUser | null> {
const identity = await ctx.auth.getUserIdentity();
if (identity === null) {
return null;
}
const user = await this.getServerUserById(identity.subject);
if (user?.isAnonymous && !includeAnonymous) {
return null;
}
return user;
}
// IF_PLATFORM react-like
protected _useUserByConvex(ctx: ConvexCtx, includeAnonymous: boolean): ServerUser | null {
const subject = useAsyncCache(this._convexIdentitySubjectCache, [ctx] as const, "useUserByConvex()");
if (subject === null) {
return null;
}
const user = this.useUserById(subject);
if (user?.isAnonymous && !includeAnonymous) {
return null;
}
return user;
}
// END_PLATFORM
// IF_PLATFORM react-like
protected _useUserByApiKey(apiKey: string): ServerUser | null {
const apiKeyObject = this._useUserApiKey({ apiKey });
@ -903,18 +935,22 @@ export class _StackServerAppImplIncomplete<HasTokenStore extends boolean, Projec
return this._serverUserFromCrud(crud);
}
async getUser(options: GetUserOptions<HasTokenStore> & { or: 'redirect' }): Promise<ProjectCurrentServerUser<ProjectId>>;
async getUser(options: GetUserOptions<HasTokenStore> & { or: 'throw' }): Promise<ProjectCurrentServerUser<ProjectId>>;
async getUser(options: GetUserOptions<HasTokenStore> & { or: 'anonymous' }): Promise<ProjectCurrentServerUser<ProjectId>>;
async getUser(options?: GetUserOptions<HasTokenStore>): Promise<ProjectCurrentServerUser<ProjectId> | null>;
async getUser(options: GetCurrentUserOptions<HasTokenStore> & { or: 'redirect' }): Promise<ProjectCurrentServerUser<ProjectId>>;
async getUser(options: GetCurrentUserOptions<HasTokenStore> & { or: 'throw' }): Promise<ProjectCurrentServerUser<ProjectId>>;
async getUser(options: GetCurrentUserOptions<HasTokenStore> & { or: 'anonymous' }): Promise<ProjectCurrentServerUser<ProjectId>>;
async getUser(options?: GetCurrentUserOptions<HasTokenStore>): Promise<ProjectCurrentServerUser<ProjectId> | null>;
async getUser(id: string): Promise<ServerUser | null>;
async getUser(options: { apiKey: string }): Promise<ServerUser | null>;
async getUser(options?: string | GetUserOptions<HasTokenStore> | { apiKey: string }): Promise<ProjectCurrentServerUser<ProjectId> | ServerUser | null> {
async getUser(options: { from: "convex", ctx: ConvexCtx, or?: "return-null" | "anonymous" }): Promise<ServerUser | null>;
async getUser(options?: string | GetCurrentUserOptions<HasTokenStore> | { apiKey: string } | { from: "convex", ctx: ConvexCtx }): Promise<ProjectCurrentServerUser<ProjectId> | ServerUser | null> {
if (typeof options === "string") {
return await this.getServerUserById(options);
} else if (typeof options === "object" && "apiKey" in options) {
return await this._getUserByApiKey(options.apiKey);
} else if (typeof options === "object" && "from" in options && options.from as string === "convex") {
return await this._getUserByConvex(options.ctx, "or" in options && options.or === "anonymous");
} else {
options = options as GetCurrentUserOptions<HasTokenStore> | undefined;
// TODO this code is duplicated from the client app; fix that
this._ensurePersistentTokenStore(options?.tokenStore);
const session = await this._getSession(options?.tokenStore);
@ -959,18 +995,22 @@ export class _StackServerAppImplIncomplete<HasTokenStore extends boolean, Projec
}
// IF_PLATFORM react-like
useUser(options: GetUserOptions<HasTokenStore> & { or: 'redirect' }): ProjectCurrentServerUser<ProjectId>;
useUser(options: GetUserOptions<HasTokenStore> & { or: 'throw' }): ProjectCurrentServerUser<ProjectId>;
useUser(options: GetUserOptions<HasTokenStore> & { or: 'anonymous' }): ProjectCurrentServerUser<ProjectId>;
useUser(options?: GetUserOptions<HasTokenStore>): ProjectCurrentServerUser<ProjectId> | null;
useUser(options: GetCurrentUserOptions<HasTokenStore> & { or: 'redirect' }): ProjectCurrentServerUser<ProjectId>;
useUser(options: GetCurrentUserOptions<HasTokenStore> & { or: 'throw' }): ProjectCurrentServerUser<ProjectId>;
useUser(options: GetCurrentUserOptions<HasTokenStore> & { or: 'anonymous' }): ProjectCurrentServerUser<ProjectId>;
useUser(options?: GetCurrentUserOptions<HasTokenStore>): ProjectCurrentServerUser<ProjectId> | null;
useUser(id: string): ServerUser | null;
useUser(options: { apiKey: string }): ServerUser | null;
useUser(options?: GetUserOptions<HasTokenStore> | string | { apiKey: string }): ProjectCurrentServerUser<ProjectId> | ServerUser | null {
useUser(options: { from: "convex", ctx: ConvexCtx, or?: "return-null" | "anonymous" }): ServerUser | null;
useUser(options?: GetCurrentUserOptions<HasTokenStore> | string | { apiKey: string } | { from: "convex", ctx: ConvexCtx }): ProjectCurrentServerUser<ProjectId> | ServerUser | null {
if (typeof options === "string") {
return this.useUserById(options);
} else if (typeof options === "object" && "apiKey" in options) {
return this._useUserByApiKey(options.apiKey);
} else if (typeof options === "object" && "from" in options && options.from as string === "convex") {
return this._useUserByConvex(options.ctx, "or" in options && options.or === "anonymous");
} else {
options = options as GetCurrentUserOptions<HasTokenStore> | undefined;
// TODO this code is duplicated from the client app; fix that
this._ensurePersistentTokenStore(options?.tokenStore);

View File

@ -1,13 +1,12 @@
import { KnownErrors } from "@stackframe/stack-shared";
import { CurrentUserCrud } from "@stackframe/stack-shared/dist/interface/crud/current-user";
import { Result } from "@stackframe/stack-shared/dist/utils/results";
import { AsyncStoreProperty, GetUserOptions, HandlerUrls, OAuthScopesOnSignIn, RedirectMethod, RedirectToOptions, TokenStoreInit, stackAppInternalsSymbol } from "../../common";
import { AsyncStoreProperty, GetCurrentPartialUserOptions, GetCurrentUserOptions, HandlerUrls, OAuthScopesOnSignIn, RedirectMethod, RedirectToOptions, TokenStoreInit, stackAppInternalsSymbol } from "../../common";
import { Item } from "../../customers";
import { Project } from "../../projects";
import { ProjectCurrentUser } from "../../users";
import { ProjectCurrentUser, SyncedPartialUser, TokenPartialUser } from "../../users";
import { _StackClientAppImpl } from "../implementations";
export type StackClientAppConstructorOptions<HasTokenStore extends boolean, ProjectId extends string> = {
baseUrl?: string | { browser: string, server: string },
extraRequestHeaders?: Record<string, string>,
@ -41,7 +40,7 @@ export type StackClientApp<HasTokenStore extends boolean = boolean, ProjectId ex
signInWithOAuth(provider: string, options?: { returnTo?: string }): Promise<void>,
signInWithCredential(options: { email: string, password: string, noRedirect?: boolean }): Promise<Result<undefined, KnownErrors["EmailPasswordMismatch"] | KnownErrors["InvalidTotpCode"]>>,
signUpWithCredential(options: { email: string, password: string, noRedirect?: boolean, verificationCallbackUrl?: string }): Promise<Result<undefined, KnownErrors["UserWithEmailAlreadyExists"] | KnownErrors["PasswordRequirementsNotMet"]>>,
signInWithPasskey(): Promise<Result<undefined, KnownErrors["PasskeyAuthenticationFailed"]| KnownErrors["InvalidTotpCode"] | KnownErrors["PasskeyWebAuthnError"]>>,
signInWithPasskey(): Promise<Result<undefined, KnownErrors["PasskeyAuthenticationFailed"] | KnownErrors["InvalidTotpCode"] | KnownErrors["PasskeyWebAuthnError"]>>,
callOAuthCallback(): Promise<boolean>,
promptCliLogin(options: { appUrl: string, expiresInMillis?: number }): Promise<Result<string, KnownErrors["CliAuthError"] | KnownErrors["CliAuthExpiredError"] | KnownErrors["CliAuthUsedError"]>>,
sendForgotPasswordEmail(email: string, options?: { callbackUrl?: string }): Promise<Result<undefined, KnownErrors["UserNotFound"]>>,
@ -57,18 +56,30 @@ export type StackClientApp<HasTokenStore extends boolean = boolean, ProjectId ex
redirectToOAuthCallback(): Promise<void>,
getConvexClientAuth(options: { tokenStore: TokenStoreInit }): (args: { forceRefreshToken: boolean }) => Promise<string | null>,
getConvexHttpClientAuth(options: { tokenStore: TokenStoreInit }): Promise<string>,
// IF_PLATFORM react-like
useUser(options: GetUserOptions<HasTokenStore> & { or: 'redirect' }): ProjectCurrentUser<ProjectId>,
useUser(options: GetUserOptions<HasTokenStore> & { or: 'throw' }): ProjectCurrentUser<ProjectId>,
useUser(options: GetUserOptions<HasTokenStore> & { or: 'anonymous' }): ProjectCurrentUser<ProjectId>,
useUser(options?: GetUserOptions<HasTokenStore>): ProjectCurrentUser<ProjectId> | null,
useUser(options: GetCurrentUserOptions<HasTokenStore> & { or: 'redirect' }): ProjectCurrentUser<ProjectId>,
useUser(options: GetCurrentUserOptions<HasTokenStore> & { or: 'throw' }): ProjectCurrentUser<ProjectId>,
useUser(options: GetCurrentUserOptions<HasTokenStore> & { or: 'anonymous' }): ProjectCurrentUser<ProjectId>,
useUser(options?: GetCurrentUserOptions<HasTokenStore>): ProjectCurrentUser<ProjectId> | null,
// END_PLATFORM
getUser(options: GetUserOptions<HasTokenStore> & { or: 'redirect' }): Promise<ProjectCurrentUser<ProjectId>>,
getUser(options: GetUserOptions<HasTokenStore> & { or: 'throw' }): Promise<ProjectCurrentUser<ProjectId>>,
getUser(options: GetUserOptions<HasTokenStore> & { or: 'anonymous' }): Promise<ProjectCurrentUser<ProjectId>>,
getUser(options?: GetUserOptions<HasTokenStore>): Promise<ProjectCurrentUser<ProjectId> | null>,
getUser(options: GetCurrentUserOptions<HasTokenStore> & { or: 'redirect' }): Promise<ProjectCurrentUser<ProjectId>>,
getUser(options: GetCurrentUserOptions<HasTokenStore> & { or: 'throw' }): Promise<ProjectCurrentUser<ProjectId>>,
getUser(options: GetCurrentUserOptions<HasTokenStore> & { or: 'anonymous' }): Promise<ProjectCurrentUser<ProjectId>>,
getUser(options?: GetCurrentUserOptions<HasTokenStore>): Promise<ProjectCurrentUser<ProjectId> | null>,
// note: we don't special-case 'anonymous' here to return non-null, see GetPartialUserOptions for more details
getPartialUser(options: GetCurrentPartialUserOptions<HasTokenStore> & { from: 'token' }): Promise<TokenPartialUser | null>,
getPartialUser(options: GetCurrentPartialUserOptions<HasTokenStore> & { from: 'convex' }): Promise<TokenPartialUser | null>,
getPartialUser(options: GetCurrentPartialUserOptions<HasTokenStore>): Promise<SyncedPartialUser | TokenPartialUser | null>,
// IF_PLATFORM react-like
usePartialUser(options: GetCurrentPartialUserOptions<HasTokenStore> & { from: 'token' }): TokenPartialUser | null,
usePartialUser(options: GetCurrentPartialUserOptions<HasTokenStore> & { from: 'convex' }): TokenPartialUser | null,
usePartialUser(options: GetCurrentPartialUserOptions<HasTokenStore>): SyncedPartialUser | TokenPartialUser | null,
// END_PLATFORM
useNavigate(): (to: string) => void, // THIS_LINE_PLATFORM react-like
[stackAppInternalsSymbol]: {
@ -91,7 +102,7 @@ export type StackClientAppConstructor = {
HasTokenStore extends (TokenStoreType extends {} ? true : boolean),
ProjectId extends string
>(options: StackClientAppConstructorOptions<HasTokenStore, ProjectId>): StackClientApp<HasTokenStore, ProjectId>,
new (options: StackClientAppConstructorOptions<boolean, string>): StackClientApp<boolean, string>,
new(options: StackClientAppConstructorOptions<boolean, string>): StackClientApp<boolean, string>,
[stackAppInternalsSymbol]: {
fromClientJson<HasTokenStore extends boolean, ProjectId extends string>(

View File

@ -1,11 +1,12 @@
import { KnownErrors } from "@stackframe/stack-shared";
import { Result } from "@stackframe/stack-shared/dist/utils/results";
import { AsyncStoreProperty, GetUserOptions } from "../../common";
import type { GenericQueryCtx } from "convex/server";
import { AsyncStoreProperty, GetCurrentPartialUserOptions, GetCurrentUserOptions } from "../../common";
import { ServerItem } from "../../customers";
import { DataVaultStore } from "../../data-vault";
import { SendEmailOptions } from "../../email";
import { ServerListUsersOptions, ServerTeam, ServerTeamCreateOptions } from "../../teams";
import { ProjectCurrentServerUser, ServerOAuthProvider, ServerUser, ServerUserCreateOptions } from "../../users";
import { ProjectCurrentServerUser, ServerOAuthProvider, ServerUser, ServerUserCreateOptions, SyncedPartialServerUser, TokenPartialUser } from "../../users";
import { _StackServerAppImpl } from "../implementations";
import { StackClientApp, StackClientAppConstructorOptions } from "./client-app";
@ -25,26 +26,36 @@ export type StackServerApp<HasTokenStore extends boolean = boolean, ProjectId ex
createUser(options: ServerUserCreateOptions): Promise<ServerUser>,
// IF_PLATFORM react-like
useUser(options: GetUserOptions<HasTokenStore> & { or: 'redirect' }): ProjectCurrentServerUser<ProjectId>,
useUser(options: GetUserOptions<HasTokenStore> & { or: 'throw' }): ProjectCurrentServerUser<ProjectId>,
useUser(options: GetUserOptions<HasTokenStore> & { or: 'anonymous' }): ProjectCurrentServerUser<ProjectId>,
useUser(options?: GetUserOptions<HasTokenStore>): ProjectCurrentServerUser<ProjectId> | null,
useUser(options: GetCurrentUserOptions<HasTokenStore> & { or: 'redirect' }): ProjectCurrentServerUser<ProjectId>,
useUser(options: GetCurrentUserOptions<HasTokenStore> & { or: 'throw' }): ProjectCurrentServerUser<ProjectId>,
useUser(options: GetCurrentUserOptions<HasTokenStore> & { or: 'anonymous' }): ProjectCurrentServerUser<ProjectId>,
useUser(options?: GetCurrentUserOptions<HasTokenStore>): ProjectCurrentServerUser<ProjectId> | null,
useUser(id: string): ServerUser | null,
useUser(options: { apiKey: string }): ServerUser | null,
useUser(options: { apiKey: string, or?: "return-null" | "anonymous" }): ServerUser | null,
useUser(options: { from: "convex", ctx: GenericQueryCtx<any>, or?: "return-null" | "anonymous" }): ServerUser | null,
// END_PLATFORM
getUser(options: GetUserOptions<HasTokenStore> & { or: 'redirect' }): Promise<ProjectCurrentServerUser<ProjectId>>,
getUser(options: GetUserOptions<HasTokenStore> & { or: 'throw' }): Promise<ProjectCurrentServerUser<ProjectId>>,
getUser(options: GetUserOptions<HasTokenStore> & { or: 'anonymous' }): Promise<ProjectCurrentServerUser<ProjectId>>,
getUser(options?: GetUserOptions<HasTokenStore>): Promise<ProjectCurrentServerUser<ProjectId> | null>,
getUser(options: GetCurrentUserOptions<HasTokenStore> & { or: 'redirect' }): Promise<ProjectCurrentServerUser<ProjectId>>,
getUser(options: GetCurrentUserOptions<HasTokenStore> & { or: 'throw' }): Promise<ProjectCurrentServerUser<ProjectId>>,
getUser(options: GetCurrentUserOptions<HasTokenStore> & { or: 'anonymous' }): Promise<ProjectCurrentServerUser<ProjectId>>,
getUser(options?: GetCurrentUserOptions<HasTokenStore>): Promise<ProjectCurrentServerUser<ProjectId> | null>,
getUser(id: string): Promise<ServerUser | null>,
getUser(options: { apiKey: string }): Promise<ServerUser | null>,
getUser(options: { apiKey: string, or?: "return-null" | "anonymous" }): Promise<ServerUser | null>,
getUser(options: { from: "convex", ctx: GenericQueryCtx<any>, or?: "return-null" | "anonymous" }): Promise<ServerUser | null>,
// note: we don't special-case 'anonymous' here to return non-null, see GetPartialUserOptions for more details
getPartialUser(options: GetCurrentPartialUserOptions<HasTokenStore> & { from: 'token' }): Promise<TokenPartialUser | null>,
getPartialUser(options: GetCurrentPartialUserOptions<HasTokenStore> & { from: 'convex' }): Promise<TokenPartialUser | null>,
getPartialUser(options: GetCurrentPartialUserOptions<HasTokenStore>): Promise<SyncedPartialServerUser | TokenPartialUser | null>,
// IF_PLATFORM react-like
usePartialUser(options: GetCurrentPartialUserOptions<HasTokenStore> & { from: 'token' }): TokenPartialUser | null,
usePartialUser(options: GetCurrentPartialUserOptions<HasTokenStore> & { from: 'convex' }): TokenPartialUser | null,
usePartialUser(options: GetCurrentPartialUserOptions<HasTokenStore>): SyncedPartialServerUser | TokenPartialUser | null,
// END_PLATFORM
// IF_PLATFORM react-like
useTeam(id: string): ServerTeam | null,
useTeam(options: { apiKey: string }): ServerTeam | null,
// END_PLATFORM
getTeam(id: string): Promise<ServerTeam | null>,
getTeam(options: { apiKey: string }): Promise<ServerTeam | null>,

View File

@ -1,4 +1,5 @@
import { ProviderType } from "@stackframe/stack-shared/dist/utils/oauth";
import type { GenericQueryCtx, UserIdentity } from "convex/server";
export type RedirectToOptions = {
replace?: boolean,
@ -27,7 +28,7 @@ export type RedirectMethod = "window"
}
export type GetUserOptions<HasTokenStore> =
export type GetCurrentUserOptions<HasTokenStore> =
& {
or?: 'redirect' | 'throw' | 'return-null' | 'anonymous' | /** @deprecated */ 'anonymous-if-exists[deprecated]',
tokenStore?: TokenStoreInit,
@ -36,6 +37,28 @@ export type GetUserOptions<HasTokenStore> =
tokenStore: TokenStoreInit,
} : {});
export type ConvexCtx =
| GenericQueryCtx<any>
| { auth: { getUserIdentity: () => Promise<UserIdentity | null> } };
export type GetCurrentPartialUserOptions<HasTokenStore> =
& {
or?: 'return-null' | 'anonymous', // note: unlike normal getUser, 'anonymous' still returns null sometimes (eg. if no token is present)
tokenStore?: TokenStoreInit,
}
& (
| {
from: 'token',
}
| {
from: 'convex',
ctx: ConvexCtx,
}
)
& (HasTokenStore extends false ? {
tokenStore: TokenStoreInit,
} : {});
export type RequestLike = {
headers: {
get: (name: string) => string | null,

View File

@ -28,7 +28,9 @@ export {
stackAppInternalsSymbol
} from "./common";
export type {
GetUserOptions,
GetCurrentUserOptions,
/** @deprecated Use GetCurrentUserOptions instead */
GetCurrentUserOptions as GetUserOptions,
HandlerUrls,
OAuthScopesOnSignIn
} from "./common";

View File

@ -276,6 +276,29 @@ export type CurrentInternalUser = CurrentUser & InternalUserExtra;
export type ProjectCurrentUser<ProjectId> = ProjectId extends "internal" ? CurrentInternalUser : CurrentUser;
export type TokenPartialUser = Pick<
User,
| "id"
| "displayName"
| "primaryEmail"
| "primaryEmailVerified"
| "isAnonymous"
>
export type SyncedPartialUser = TokenPartialUser & Pick<
User,
| "id"
| "displayName"
| "primaryEmail"
| "primaryEmailVerified"
| "profileImageUrl"
| "signedUpAt"
| "clientMetadata"
| "clientReadOnlyMetadata"
| "isAnonymous"
| "hasPassword"
>;
export type ActiveSession = {
id: string,
@ -377,6 +400,10 @@ export type CurrentInternalServerUser = CurrentServerUser & InternalUserExtra;
export type ProjectCurrentServerUser<ProjectId> = ProjectId extends "internal" ? CurrentInternalServerUser : CurrentServerUser;
export type SyncedPartialServerUser = SyncedPartialUser & Pick<
ServerUser,
| "serverMetadata"
>;
export type ServerUserUpdateOptions = {
primaryEmail?: string | null,

View File

@ -18,7 +18,6 @@ export function StackProviderClient(props: {
const app = props.serialized
? StackClientApp[stackAppInternalsSymbol].fromClientJson(props.app as StackClientAppJson<true, string>)
: props.app as StackClientApp<true>;
globalVar.__STACK_AUTH__ = { app };
return (

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,6 @@ import { defineWorkspace } from 'vitest/config';
export default defineWorkspace([
'packages/*',
'apps/*',
'examples/*',
'docs',
'examples/*',
]);