Skip to content
This repository was archived by the owner on Oct 31, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@
node_modules
.env
.alchemy/
dist
.tanstack
14 changes: 12 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,24 @@ in its repository.

- `convex/connectors.ts` lists the providers the hub offers. A provider that needs credentials also
gets its variables declared in `convex/convex.config.ts`.
- `convex/http.ts` mounts the broker; `convex/identityBroker.ts` exports the admin functions for
`npx convex run`; `convex/crons.ts` runs the cleanup.
- `convex/broker.ts` defines the broker, `convex/http.ts` mounts it, `convex/identityBroker.ts`
exports the admin functions for `npx convex run`, and `convex/crons.ts` runs the cleanup.
- `convex/state.ts`, `convex/reader.ts`, and `convex/auth.config.ts` serve the Alchemy state store,
from the exports of `@samebase/alchemy-convex`. A push replaces every function, so a function that
leaves these files leaves every stack of the team without its state.
- `alchemy.run.ts` is the stack `samebase/team-base` (adopted project, adopted production
deployment, push); `scripts/check-keys.ts` checks the key set inside the `deploy` script the push
runs and never creates it; `scripts/init-keys.ts` creates it once, by hand.
- `src/` is the console of the base, a TanStack Start site that `convex/http.ts` serves through
convex-hosting. `convex/console.ts` holds its reads: each one needs a session, and the state comes
through the redacted `reader.repository`, so a secret never reaches the browser. A page cannot use
the paths of the hub or of Convex Auth: `/authorize`, `/continue`, `/token`, `/userinfo`,
`/connectors/*`, `/.well-known/*`, `/auth/*`.
- The console has no sign-up. Run `authAdmin:createOrUpdatePasswordAccount` from the Functions page
of the Convex dashboard, with the email and password you choose, not in a terminal. To end an
account's access at once, delete its row in `users`. A new password does not end its sessions.
- `pnpm run deploy:dev` builds, uploads, and pushes to the development deployment. It must run once
before `pnpm run dev`: `convex/http.ts` imports the build.
- Never use `npx convex import --replace-all` on this deployment, also not with `--component`: it
deletes the rows of the other components.
- Never use the U+2014 em dash character. No `any`, no type assertions except `as const`.
Expand Down
2 changes: 1 addition & 1 deletion alchemy.run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export default Alchemy.Stack(
path: "./convex",
command: ["node", "--run", "deploy"],
// The push inputs besides the functions, package.json, and the lockfile.
include: ["scripts/**", "tsconfig*.json"],
include: ["scripts/**", "tsconfig*.json", "src/**", "public/**", "vite.config.ts"],
} satisfies Convex.Source;
// The variables of the deployment. The secret is a prop, so the state store holds it next to
// the deployment it serves.
Expand Down
11 changes: 11 additions & 0 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@
* @module
*/

import type * as auth from "../auth.js";
import type * as authAdmin from "../authAdmin.js";
import type * as broker from "../broker.js";
import type * as connectors from "../connectors.js";
import type * as console from "../console.js";
import type * as crons from "../crons.js";
import type * as http from "../http.js";
import type * as identityBroker from "../identityBroker.js";
Expand All @@ -22,7 +26,11 @@ import type {
} from "convex/server";

declare const fullApi: ApiFromModules<{
auth: typeof auth;
authAdmin: typeof authAdmin;
broker: typeof broker;
connectors: typeof connectors;
console: typeof console;
crons: typeof crons;
http: typeof http;
identityBroker: typeof identityBroker;
Expand Down Expand Up @@ -59,4 +67,7 @@ export declare const internal: FilterApi<
export declare const components: {
identityBroker: import("@samebase/identity-broker/_generated/component.js").ComponentApi<"identityBroker">;
alchemyState: import("@samebase/alchemy-convex/state/_generated/component.js").ComponentApi<"alchemyState">;
auth: import("@convex-dev/auth/_generated/component.js").ComponentApi<"auth">;
authPassword: import("@convex-dev/auth/components/password/_generated/component.js").ComponentApi<"authPassword">;
hosting: import("@samebase/convex-hosting/_generated/component.js").ComponentApi<"hosting">;
};
34 changes: 18 additions & 16 deletions convex/_generated/dataModel.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,29 +8,29 @@
* @module
*/

import { AnyDataModel } from "convex/server";
import type {
DataModelFromSchemaDefinition,
DocumentByName,
TableNamesInDataModel,
SystemTableNames,
} from "convex/server";
import type { GenericId } from "convex/values";

/**
* No `schema.ts` file found!
*
* This generated code has permissive types like `Doc = any` because
* Convex doesn't know your schema. If you'd like more type safety, see
* https://docs.convex.dev/using/schemas for instructions on how to add a
* schema file.
*
* After you change a schema, rerun codegen with `npx convex dev`.
*/
import schema from "../schema.js";

/**
* The names of all of your Convex tables.
*/
export type TableNames = string;
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 = any;
export type Doc<TableName extends TableNames> = DocumentByName<
DataModel,
TableName
>;

/**
* An identifier for a document in Convex.
Expand All @@ -42,8 +42,10 @@ export type Doc = any;
*
* 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 = TableNames> =
export type Id<TableName extends TableNames | SystemTableNames> =
GenericId<TableName>;

/**
Expand All @@ -55,4 +57,4 @@ export type Id<TableName extends TableNames = TableNames> =
* This type is used to parameterize methods like `queryGeneric` and
* `mutationGeneric` to make them type-safe.
*/
export type DataModel = AnyDataModel;
export type DataModel = DataModelFromSchemaDefinition<typeof schema>;
2 changes: 2 additions & 0 deletions convex/_generated/server.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ type Env = {
readonly CONVEX_SITE_URL: string;
readonly AUTH_GITHUB_CLIENT_ID: string;
readonly AUTH_GITHUB_CLIENT_SECRET: string;
readonly AUTH_JWKS: string;
readonly AUTH_PRIVATE_KEY: string;
readonly GOOGLE_CLIENT_ID: string;
readonly GOOGLE_CLIENT_SECRET: string;
readonly IDENTITY_BROKER_KEYS: string;
Expand Down
17 changes: 15 additions & 2 deletions convex/auth.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
import { authConfig } from "@samebase/alchemy-convex/state/auth.config";
import { env } from "./_generated/server";

// Verifies the read tokens of `reader:authorize`.
export default authConfig(env.CONVEX_CLOUD_URL);
// Two issuers. The deployment's own key signs the read tokens of `reader:authorize`, and the auth
// component signs the sessions of the console for CONVEX_SITE_URL. convex/console.ts accepts only
// the second, and `reader:repository` accepts only the first.
export default {
providers: [
...authConfig(env.CONVEX_CLOUD_URL).providers,
{
type: "customJwt" as const,
applicationID: "convex",
issuer: env.CONVEX_SITE_URL,
jwks: `${env.CONVEX_SITE_URL}/auth/.well-known/jwks.json`,
algorithm: "RS256" as const,
},
],
};
94 changes: 94 additions & 0 deletions convex/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { setupConvexAuth } from "@convex-dev/auth/server";
import { createFunctionHandle } from "convex/server";
import { type Infer, v } from "convex/values";
import { components, internal } from "./_generated/api";
import { env, internalMutation, mutation } from "./_generated/server";

const { literal, number, object, string, union } = v;

const auth = setupConvexAuth({ component: components.auth });
export const { signOut, refreshSession } = auth;

const PROVIDER = "password";
const FAILED = { status: "error", userError: { error: "INVALID_CREDENTIALS" } } as const;

const vTokens = object({
accessToken: string(),
accessTokenExpiresAt: number(),
refreshToken: string(),
refreshTokenExpiresAt: number(),
userId: string(),
});

/** The form of an email in the table `users`. */
export const normalizeEmail = (email: string) => email.trim().toLowerCase();

/**
The core calls this the first time a user signs in. The password check has already found the user,
so this links the core account to it and creates nothing.
*/
export const linkExistingUser = internalMutation({
args: {
provider: object({
name: literal(PROVIDER),
accountId: string(),
profile: object({ userId: v.id("users") }),
}),
},
returns: v.id("users"),
handler: async (_ctx, args) => args.provider.profile.userId,
});

/**
Signs in to an existing account. There is no sign-up: `authAdmin` creates accounts. A failure
returns a result instead of throwing, so the password component keeps the attempt in its limit of
five attempts, then one per minute, for each account. Every failure gets the same answer, also the
rate limit, which only an account that exists can reach: it would tell a caller that the email has
an account.
*/
export const signInWithPassword = mutation({
args: { email: string(), password: string() },
returns: union(
object({ status: literal("complete"), tokens: vTokens }),
object({
status: literal("error"),
userError: object({ error: literal("INVALID_CREDENTIALS") }),
}),
),
handler: async (ctx, args) => {
const user = await ctx.db
.query("users")
.withIndex("email", (q) => q.eq("email", normalizeEmail(args.email)))
.unique();
if (user === null) {
return FAILED;
}
const verification = await ctx.runMutation(components.authPassword.public.verifyPassword, {
userId: user._id,
password: args.password,
});
if (!verification.success) {
return FAILED;
}
const claims = {
providerName: PROVIDER,
providerAccountId: user._id,
profile: { userId: user._id },
};
const issuer = env.CONVEX_SITE_URL;
const linkedUserId = await ctx.runQuery(components.auth.public.getUserIdByAccount, {
provider: PROVIDER,
providerAccountId: user._id,
});
// Annotated: the type of `internal` contains this module, so TypeScript cannot infer it.
const tokens: Infer<typeof vTokens> =
linkedUserId === null
? await ctx.runMutation(components.auth.public.signUp, {
claims,
createUserHandle: await createFunctionHandle(internal.auth.linkExistingUser),
issuer,
})
: await ctx.runMutation(components.auth.public.signIn, { claims, issuer });
return { status: "complete" as const, tokens };
},
});
32 changes: 32 additions & 0 deletions convex/authAdmin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { ConvexError, v } from "convex/values";
import { components } from "./_generated/api";
import { internalMutation } from "./_generated/server";
import { normalizeEmail } from "./auth";

/**
Creates an account of the console, or sets the password of one. The function is internal, because
the console has no sign-up: only a member of the Convex team can run it, from the Functions page of
the dashboard. Run it there and not in a terminal, so that the password stays out of the shell's
history.
*/
export const createOrUpdatePasswordAccount = internalMutation({
args: { email: v.string(), password: v.string() },
returns: v.object({ created: v.boolean(), email: v.string() }),
handler: async (ctx, args) => {
const email = normalizeEmail(args.email);
const existing = await ctx.db
.query("users")
.withIndex("email", (q) => q.eq("email", email))
.unique();
const userId = existing?._id ?? (await ctx.db.insert("users", { email }));
const result = await ctx.runMutation(components.authPassword.public.setPassword, {
userId,
password: args.password,
});
if (!result.success) {
// A throw also takes back the new row. The code names the rule, such as PASSWORD_TOO_SHORT.
throw new ConvexError(result.userError.error);
}
return { created: existing === null, email };
},
});
13 changes: 13 additions & 0 deletions convex/broker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { defineIdentityBroker } from "@samebase/identity-broker";
import { components } from "./_generated/api";
import { env } from "./_generated/server";
import { connectors } from "./connectors";

export const broker = defineIdentityBroker({
component: components.identityBroker,
// Host-only by default: the deployment's own convex.site URL. A custom domain changes the issuer
// that every consuming app names, so ISSUER is set before the first app registers.
issuer: env.ISSUER ?? env.CONVEX_SITE_URL,
keys: () => env.IDENTITY_BROKER_KEYS,
connectors,
});
82 changes: 82 additions & 0 deletions convex/console.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { ConvexError, v } from "convex/values";
import { components } from "./_generated/api";
import { env, query, type QueryCtx } from "./_generated/server";

/**
Every read of the console needs a session. A read token of `reader:authorize` is an identity too, for
one repository, so the issuer decides: a session has the issuer of the auth component. The account
must still exist: deleting a row of `users` ends the access of its sessions at once.
*/
async function requireSession(ctx: QueryCtx) {
const identity = await ctx.auth.getUserIdentity();
const userId =
identity?.issuer === env.CONVEX_SITE_URL ? ctx.db.normalizeId("users", identity.subject) : null;
if (userId === null || (await ctx.db.get("users", userId)) === null) {
throw new ConvexError("A session is required.");
}
}

/** The repository of a stack: `owner/name`, also for a stack in a folder of it. */
const repositoryOf = (stack: string) => stack.split("/").slice(0, 2).join("/");

/**
The stacks of one repository, redacted. `reader.repository` is the read that Samebase serves: secrets
are removed before the reply leaves the component.
*/
const readRepository = (ctx: QueryCtx, repository: string) =>
ctx.runQuery(components.alchemyState.reader.repository, { repository });

/** Every stack of the team with a summary of its stages. A stack with no run record has no stages. */
export const stacks = query({
args: {},
handler: async (ctx) => {
await requireSession(ctx);
const names = await ctx.runQuery(components.alchemyState.state.stacks, {});
const replies = await Promise.all(
[...new Set(names.map(repositoryOf))].map((repository) => readRepository(ctx, repository)),
);
const found = new Map(replies.flatMap((reply) => reply.stacks).map((s) => [s.name, s]));
return names.map((name) => ({
name,
repository: repositoryOf(name),
stages: (found.get(name)?.stages ?? []).map((stage) => ({
name: stage.name,
record: stage.record,
resources: stage.resources.length,
})),
}));
},
});

/** One stack with its stages, resources, and outputs. Null for a stack with no run record. */
export const stack = query({
args: { name: v.string() },
handler: async (ctx, args) => {
await requireSession(ctx);
const reply = await readRepository(ctx, repositoryOf(args.name));
return reply.stacks.find((stack) => stack.name === args.name) ?? null;
},
});

/** The apps of the hub with their clients. The hash of a client secret stays on the server. */
export const hub = query({
args: {},
handler: async (ctx) => {
await requireSession(ctx);
const apps = await ctx.runQuery(components.identityBroker.apps.list, {});
return await Promise.all(
apps.map(async (app) => ({
...app,
clients: (
await ctx.runQuery(components.identityBroker.clients.listByApp, { appId: app.appId })
).map(({ clientId, clientName, deploymentType, redirectUri, lastSignInAt }) => ({
clientId,
clientName,
deploymentType,
redirectUri,
lastSignInAt,
})),
})),
);
},
});
Loading
Loading