mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
Merge dev into update-oauth-docs
This commit is contained in:
commit
7e95c12faf
3
.github/workflows/e2e-api-tests.yaml
vendored
3
.github/workflows/e2e-api-tests.yaml
vendored
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
3
.github/workflows/lint-and-build.yaml
vendored
3
.github/workflows/lint-and-build.yaml
vendored
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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];
|
||||
} & {};
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 }) => {
|
||||
|
||||
180
apps/e2e/tests/js/convex.test.ts
Normal file
180
apps/e2e/tests/js/convex.test.ts
Normal 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();
|
||||
});
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
1
docs/templates/meta.json
vendored
1
docs/templates/meta.json
vendored
@ -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
101
docs/templates/others/convex.mdx
vendored
Normal 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
|
||||
},
|
||||
});
|
||||
```
|
||||
8
examples/convex/.env.development
Normal file
8
examples/convex/.env.development
Normal 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
|
||||
22
examples/convex/.eslintrc.js
Normal file
22
examples/convex/.eslintrc.js
Normal 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
41
examples/convex/.gitignore
vendored
Normal 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
40
examples/convex/README.md
Normal 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.
|
||||
26
examples/convex/app/globals.css
Normal file
26
examples/convex/app/globals.css
Normal 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;
|
||||
}
|
||||
6
examples/convex/app/handler/[...stack]/page.tsx
Normal file
6
examples/convex/app/handler/[...stack]/page.tsx
Normal 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 } />;
|
||||
}
|
||||
40
examples/convex/app/layout.tsx
Normal file
40
examples/convex/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
examples/convex/app/loading.tsx
Normal file
6
examples/convex/app/loading.tsx
Normal 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 <></>;
|
||||
}
|
||||
136
examples/convex/app/page.tsx
Normal file
136
examples/convex/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
examples/convex/app/server/inner.tsx
Normal file
31
examples/convex/app/server/inner.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
24
examples/convex/app/server/page.tsx
Normal file
24
examples/convex/app/server/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
examples/convex/components/ConvexClientProvider.tsx
Normal file
18
examples/convex/components/ConvexClientProvider.tsx
Normal 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>;
|
||||
}
|
||||
90
examples/convex/convex/README.md
Normal file
90
examples/convex/convex/README.md
Normal 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`.
|
||||
36
examples/convex/convex/_generated/api.d.ts
vendored
Normal file
36
examples/convex/convex/_generated/api.d.ts
vendored
Normal 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">
|
||||
>;
|
||||
22
examples/convex/convex/_generated/api.js
Normal file
22
examples/convex/convex/_generated/api.js
Normal 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;
|
||||
60
examples/convex/convex/_generated/dataModel.d.ts
vendored
Normal file
60
examples/convex/convex/_generated/dataModel.d.ts
vendored
Normal 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>;
|
||||
142
examples/convex/convex/_generated/server.d.ts
vendored
Normal file
142
examples/convex/convex/_generated/server.d.ts
vendored
Normal 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>;
|
||||
89
examples/convex/convex/_generated/server.js
Normal file
89
examples/convex/convex/_generated/server.js
Normal 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;
|
||||
7
examples/convex/convex/auth.config.ts
Normal file
7
examples/convex/convex/auth.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { getConvexProvidersConfig } from "@stackframe/stack";
|
||||
|
||||
export default {
|
||||
providers: getConvexProvidersConfig({
|
||||
projectId: process.env.NEXT_PUBLIC_STACK_PROJECT_ID
|
||||
}),
|
||||
}
|
||||
82
examples/convex/convex/myFunctions.ts
Normal file
82
examples/convex/convex/myFunctions.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
});
|
||||
12
examples/convex/convex/schema.ts
Normal file
12
examples/convex/convex/schema.ts
Normal 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(),
|
||||
}),
|
||||
});
|
||||
25
examples/convex/convex/tsconfig.json
Normal file
25
examples/convex/convex/tsconfig.json
Normal 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"]
|
||||
}
|
||||
16
examples/convex/eslint.config.mjs
Normal file
16
examples/convex/eslint.config.mjs
Normal 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;
|
||||
7
examples/convex/next.config.ts
Normal file
7
examples/convex/next.config.ts
Normal 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
11210
examples/convex/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
examples/convex/package.json
Normal file
37
examples/convex/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
17
examples/convex/public/convex.svg
Normal file
17
examples/convex/public/convex.svg
Normal 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 |
6
examples/convex/stack/client.tsx
Normal file
6
examples/convex/stack/client.tsx
Normal file
@ -0,0 +1,6 @@
|
||||
import { StackClientApp } from "@stackframe/stack";
|
||||
|
||||
export const stackClientApp = new StackClientApp({
|
||||
baseUrl: "http://localhost:8102",
|
||||
tokenStore: "nextjs-cookie",
|
||||
});
|
||||
7
examples/convex/stack/server.tsx
Normal file
7
examples/convex/stack/server.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import "server-only";
|
||||
|
||||
import { StackServerApp } from "@stackframe/stack";
|
||||
|
||||
export const stackServerApp = new StackServerApp({
|
||||
tokenStore: "nextjs-cookie",
|
||||
});
|
||||
27
examples/convex/tsconfig.json
Normal file
27
examples/convex/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@ -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": [
|
||||
|
||||
@ -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([
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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' } });
|
||||
|
||||
@ -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) {
|
||||
|
||||
286
packages/stack-shared/src/utils/paginated-lists.tsx
Normal file
286
packages/stack-shared/src/utils/paginated-lists.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
16
packages/template/src/integrations/convex.ts
Normal file
16
packages/template/src/integrations/convex.ts
Normal 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",
|
||||
},
|
||||
];
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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>(
|
||||
|
||||
@ -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>,
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -28,7 +28,9 @@ export {
|
||||
stackAppInternalsSymbol
|
||||
} from "./common";
|
||||
export type {
|
||||
GetUserOptions,
|
||||
GetCurrentUserOptions,
|
||||
/** @deprecated Use GetCurrentUserOptions instead */
|
||||
GetCurrentUserOptions as GetUserOptions,
|
||||
HandlerUrls,
|
||||
OAuthScopesOnSignIn
|
||||
} from "./common";
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 (
|
||||
|
||||
1345
pnpm-lock.yaml
1345
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
@ -3,6 +3,6 @@ import { defineWorkspace } from 'vitest/config';
|
||||
export default defineWorkspace([
|
||||
'packages/*',
|
||||
'apps/*',
|
||||
'examples/*',
|
||||
'docs',
|
||||
'examples/*',
|
||||
]);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user