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
16 changes: 4 additions & 12 deletions .github/workflows/alchemy.yml
Original file line number Diff line number Diff line change
@@ -1,22 +1,17 @@
# Deploys with Alchemy (alchemy.run.ts): a push to main deploys the stage `prod`, the production
# deployment, and nothing else; the hub has no previews. The state is in the `alchemy-state`
# project of the team, under the stack name GITHUB_REPOSITORY. The run needs the team token
# Deploys with Alchemy (alchemy.run.ts): a push to main deploys the stage `prod` of the stack
# `samebase/team-base`, the production deployment of the Convex project `samebase`, and nothing else;
# the hub has no previews. The state is in that same deployment. The run needs the team token
# CONVEX_ACCESS_TOKEN, with which it mints the deploy key itself, plus the Google and GitHub
# clients, which the stack hands to the deployment. Only the Alchemy step gets the secrets.
name: alchemy
on:
push: { branches: [main] }
# Run once on main with adopt to take over a project with the name of the repository that
# exists already.
workflow_dispatch: { inputs: { adopt: { type: boolean, default: false } } }
permissions: { contents: read }
# One deploy at a time: the state store has no lock. A newer push waits, so the last deploy is the
# newest commit.
concurrency: { group: alchemy-prod, queue: max, cancel-in-progress: false }
jobs:
alchemy:
# A dispatch from another branch deploys nothing: only main is production.
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
Expand Down Expand Up @@ -48,11 +43,8 @@ jobs:
fi
- name: Deploy production
if: steps.keys.outputs.skip != 'true'
run: |
if [ "$ADOPT" = "true" ]; then set -- --adopt; fi
pnpm exec alchemy deploy --stage prod --yes --no-input "$@"
run: pnpm exec alchemy deploy --stage prod --yes --no-input
env:
ADOPT: ${{ inputs.adopt }}
CONVEX_ACCESS_TOKEN: ${{ secrets.CONVEX_ACCESS_TOKEN }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
Expand Down
16 changes: 11 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
# AGENTS.md

A Convex app that mounts the identity-broker component and is the sign-in hub of one team. See
README.md. The component's own rules live in its repository.
A Convex app that mounts the identity-broker and alchemyState components: the base app of one team,
which serves its sign-in hub and its Alchemy state. See README.md. Each component's own rules live
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.
- `alchemy.run.ts` is the stack (project, 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.
- `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.
- 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`.
- `pnpm install`, then `pnpm run check`. `convex/_generated` is committed; `npx convex dev --once`
refreshes it.
82 changes: 55 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,27 @@
# identity-hub
# team-base

The sign-in hub of the Samebase team on [Convex](https://convex.dev): one deployment that holds the
team's Google and GitHub connections once and signs users in to all its apps, through the
[identity-broker](https://github.com/samebase/identity-broker) component. It keeps no user accounts;
each app creates its own user from the `id_token` it gets.
The base app of the Samebase team on [Convex](https://convex.dev): the code of the production
deployment of the project `samebase`, which holds the team-level components.

The repository is public and holds no secrets or team values: everything specific to a hub is a
deployment variable or a line in `convex/connectors.ts`, so a copy of it runs another team's hub.
- `identityBroker`, from [identity-broker](https://github.com/samebase/identity-broker), is the
sign-in hub. It holds the team's Google and GitHub connections once and signs users in to all its
apps. It keeps no user accounts; each app creates its own user from the `id_token` it gets.
- `alchemyState`, from [alchemy-convex](https://github.com/samebase/alchemy-convex), is the Alchemy
state of every stack of the team.

The repository is public and holds no secrets. The values of the team are the constants in
`alchemy.run.ts` and the lines in `convex/connectors.ts`, so a copy of it with other values runs
another team's base.

## Deploy

`alchemy.run.ts` is the stack, in the shape of every app of the team with one stage: `prod` is the
production deployment of the Convex project named after this repository, and the push is the
`deploy` package script, which checks the key set and pushes the functions. The state is in the
`alchemy-state` project of the team. The hub has no previews: no app points at one, and Google
cannot sign in on one, since only the production callback is registered at Google.
`.github/workflows/alchemy.yml` deploys each push to `main`; pull requests run the checks only.
production deployment of the Convex project `samebase`, and the push is the `deploy` package script,
which checks the key set and pushes the functions. The stack is named after the repository,
`samebase/team-base`. Its state is in the deployment that it deploys, next to the state of the other
stacks of the team. The hub has no previews: no app points at one, and Google cannot sign in on one,
since only the production callback is registered at Google. `.github/workflows/alchemy.yml` deploys
each push to `main`; pull requests run the checks only.

1. In the repository, set five secrets: `CONVEX_ACCESS_TOKEN`, the team's Convex access token, with
which the run mints the deploy key itself; `GOOGLE_CLIENT_ID` with `GOOGLE_CLIENT_SECRET`, the
Expand All @@ -28,21 +34,37 @@ cannot sign in on one, since only the production callback is registered at Googl
and refuses a deployment that has the variable. Every deploy then checks the variable before the
push and stops when it is missing or holds anything that is not a key set; no deploy ever creates
or replaces it.
4. Merge to `main`. The first run of the workflow on a project that exists already needs `adopt`
checked (Run workflow on the Actions tab). From a laptop, `pnpm exec alchemy deploy --stage prod`
runs the same stack with the Convex CLI login.
4. Merge to `main`. The stack adopts the project and its production deployment, so no run needs
`--adopt`, and it retains both on destroy. Adoption replaces the code and sets the variables of
the stack. It does not change other variables, the data, or the project. From a laptop,
`pnpm exec alchemy deploy --stage prod` runs the same stack with the Convex CLI login.

Back up `IDENTITY_BROKER_KEYS` somewhere safe. It holds the signing keys and the pairwise salt: a
hub that loses it issues new subjects to every user of every app. An established hub that lost the
variable gets the backup restored by hand; its deploys stop until then.

## Base and seed

A push replaces every function of a deployment. `Convex.state()` of `@samebase/alchemy-convex`
pushes a small store app, a seed, to a project `samebase` that has no base, and it never pushes over
a base. Thus this app serves the same store functions as the seed: `convex/state.ts` with the host
`base`, `convex/reader.ts`, and `convex/auth.config.ts`. The `alchemyState` component has no name in
`convex/convex.config.ts`, so this app serves the rows that the seed wrote.

The store version of this base is the store version of the `@samebase/alchemy-convex` in
`package.json`. The stack and the app share that one pin. When the store version rises, this
repository moves first and each consumer follows. A consumer of the team fails on a base that is
older or newer than the consumer.

Push a base for the first time when no stack of the team runs: the push of a seed has no lock.

## Issuer

The hub answers at the deployment's `.convex.site` URL. Set `ISSUER` to a custom domain before the
first app registers: the issuer is the name every app trusts, in discovery, in every `id_token`, and
in the callback URLs registered with GitHub and Google. Changing it later means changing
`HUB_ISSUER` on every app's deployments and the callback URLs at every provider in one go; the app
ids, the clients and the salt stay, so the subjects do not change.
The hub answers at the deployment's `.convex.site` URL and at the custom domain `auth.samebase.com`.
The stack sets `ISSUER` to that domain: the issuer is the name every app trusts, in discovery, in
every `id_token`, and in the callback URLs registered with GitHub and Google. Changing it later
means changing `HUB_ISSUER` on every app's deployments and the callback URLs at every provider in
one go; the app ids, the clients and the salt stay, so the subjects do not change.

## Connectors

Expand Down Expand Up @@ -76,13 +98,19 @@ which also writes the client into the deployment's variables; see the component'

## Operate

The admin functions are internal: `npx convex run --prod identityBroker:<name>`. The deploy key of
the hub is an administrator credential (it can read and replace the keys and run every internal
function); keep it out of every app's CI. The Convex CLI ignores `--prod` when `CONVEX_DEPLOY_KEY`
names another deployment, so unset it before an admin command. A cron runs the broker's cleanup
daily: expired requests and codes, and sign-in records older than 90 days. Idle preview clients are
kept: the broker prunes them only when the host sets `previewClientIdleDays`, and this hub does not,
because a consumer's registration resource does not register a pruned client again.
The admin functions are internal: `npx convex run --prod identityBroker:<name>`, from a checkout
that selects the project `samebase`. The deploy key of the base is an administrator credential: it
can read and replace the keys, read every secret in the Alchemy state of the team, and run every
internal function. Keep it out of every app's CI. The Convex CLI ignores `--prod` when
`CONVEX_DEPLOY_KEY` names another deployment, so unset it before an admin command. A cron runs the
broker's cleanup daily: expired requests and codes, and sign-in records older than 90 days. Idle
preview clients are kept: the broker prunes them only when the host sets `previewClientIdleDays`,
and this hub does not, because a consumer's registration resource does not register a pruned client
again.

Never run `npx convex import --replace-all` on this deployment, also not with `--component`: it also
deletes the rows of the other components. To restore the tables of one component, import a ZIP of
that component with `--component <name> --replace`.

## License

Expand Down
34 changes: 20 additions & 14 deletions alchemy.run.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
// The stack of this hub, in the shape of samebase/base-alchemy with one stage: `prod` is the
// production deployment of the Convex project of this repository, and nothing else is deployed.
// The hub has no previews: no app points at one, and Google cannot sign in on one, since only the
// production callback is registered at Google. The push is the `deploy` package script, which
// checks the key set and pushes the functions. The stack is named after the repository,
// `owner/name`, and its Convex project after `name`. The state is in the `alchemy-state` project
// of the team.
// The stack of the team's base app, with one stage: `prod` is the production deployment of the
// Convex project `samebase`, and nothing else is deployed. The base holds the team-level
// components of that deployment: the sign-in hub (`identityBroker`) and the Alchemy state of every
// stack of the team (`alchemyState`). The hub has no previews: no app points at one, and Google
// cannot sign in on one, since only the production callback is registered at Google. The push is
// the `deploy` package script, which checks the key set and pushes the functions.
//
// The stack is named after the repository, `owner/name`, as every stack of the team is. The
// repository was `samebase/identity-hub`, and this stack never reads the rows of that old stack.
// The project and the deployment exist already, and this stack did not create them, so both are
// adopted and retained. Adoption changes the variables of `env` and
// the code; it leaves every other variable, the data, and the project as they are.
//
// Every run needs the team token CONVEX_ACCESS_TOKEN, or a Convex CLI login (`npx convex login`)
// on a laptop; it mints the deploy key itself. Every run also reads the Google and GitHub client
// ids and secrets and hands them to the deployment, for the two connectors: no connector variable
// is ever set by hand.
// is ever set by hand. IDENTITY_BROKER_KEYS is not in `env`: it stays on the deployment, and the
// `deploy` script stops when it is missing.
// pnpm exec alchemy deploy --stage prod
import { execFileSync } from "node:child_process";
import * as Convex from "@samebase/alchemy-convex";
import * as Alchemy from "alchemy";
import { adopt } from "alchemy/AdoptPolicy";
import * as RemovalPolicy from "alchemy/RemovalPolicy";
import * as Config from "effect/Config";
import * as Effect from "effect/Effect";
Expand All @@ -30,14 +37,13 @@ const repository =
if (repository === undefined) {
throw new Error("Set GITHUB_REPOSITORY to owner/name: the origin remote names no repository.");
}
const projectName = repository.slice(repository.indexOf("/") + 1);

export default Alchemy.Stack(
repository,
{ providers: Convex.providers(), state: Convex.state({ team }) },
Effect.gen(function* () {
const { stage } = yield* Alchemy.Stack;
// One stage: any other name would create a second project and deployment with this name.
// One stage: any other name would push this code over the same production deployment.
if (stage !== "prod") {
throw new Error(`This stack has one stage, prod. The stage ${stage} is not deployed.`);
}
Expand All @@ -50,22 +56,22 @@ export default Alchemy.Stack(
// The variables of the deployment. The secret is a prop, so the state store holds it next to
// the deployment it serves.
const env = {
ISSUER: "https://auth.samebase.com",
GOOGLE_CLIENT_ID: yield* Config.String("GOOGLE_CLIENT_ID"),
GOOGLE_CLIENT_SECRET: yield* Config.Redacted("GOOGLE_CLIENT_SECRET"),
AUTH_GITHUB_CLIENT_ID: yield* Config.String("AUTH_GITHUB_CLIENT_ID"),
AUTH_GITHUB_CLIENT_SECRET: yield* Config.Redacted("AUTH_GITHUB_CLIENT_SECRET"),
};
// The project is kept on destroy: it holds the hub's registrations and the key set. A project
// with this name that the stack did not create is taken over with `alchemy deploy --adopt`.
const project = yield* Convex.Project("Project", { team, name: projectName }).pipe(
const project = yield* Convex.Project("Project", { team, slug: "samebase" }).pipe(
adopt(true),
RemovalPolicy.retain(),
);
const deployment = yield* Convex.Deployment("Deployment", {
project,
type: "prod",
source,
env,
});
}).pipe(adopt(true), RemovalPolicy.retain());
return { url: deployment.siteUrl };
}),
);
5 changes: 5 additions & 0 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import type * as connectors from "../connectors.js";
import type * as crons from "../crons.js";
import type * as http from "../http.js";
import type * as identityBroker from "../identityBroker.js";
import type * as reader from "../reader.js";
import type * as state from "../state.js";

import type {
ApiFromModules,
Expand All @@ -24,6 +26,8 @@ declare const fullApi: ApiFromModules<{
crons: typeof crons;
http: typeof http;
identityBroker: typeof identityBroker;
reader: typeof reader;
state: typeof state;
}>;

/**
Expand Down Expand Up @@ -54,4 +58,5 @@ 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">;
};
5 changes: 5 additions & 0 deletions convex/auth.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
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);
5 changes: 4 additions & 1 deletion convex/convex.config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import alchemyState from "@samebase/alchemy-convex/state/convex.config";
import identityBroker from "@samebase/identity-broker/convex.config";
import { defineApp } from "convex/server";
import { v } from "convex/values";
Expand All @@ -9,7 +10,7 @@ const app = defineApp({
env: {
/** The key set and the pairwise salt: `npx identity-broker keys`. */
IDENTITY_BROKER_KEYS: v.string(),
/** The issuer URL once a custom domain exists. Without it, the deployment's own convex.site URL. */
/** The issuer URL, which the stack sets. Without it, the deployment's own convex.site URL. */
ISSUER: v.optional(v.string()),
/**
* The Google OAuth client of the team, whose redirect URIs include
Expand All @@ -28,5 +29,7 @@ const app = defineApp({
});

app.use(identityBroker);
// Without a name, so that this base serves the rows that the seed of the store wrote.
app.use(alchemyState);

export default app;
5 changes: 5 additions & 0 deletions convex/reader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { readerFunctions } from "@samebase/alchemy-convex/state/functions";
import { components } from "./_generated/api";

// The redacted read of one repository's state, for a reader with a token of `authorize`.
export const { authorize, repository } = readerFunctions(components.alchemyState);
21 changes: 21 additions & 0 deletions convex/state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { stateFunctions } from "@samebase/alchemy-convex/state/functions";
import { components } from "./_generated/api";

// The Alchemy state store of the team. A push replaces every function of the deployment, so this
// base serves the functions of the seed that Convex.state() pushes elsewhere. Each export has its
// name here, so that the Convex analysis finds each function.
export const {
version,
host,
stacks,
stages,
list,
get,
replaced,
set,
remove,
removeStack,
getOutput,
setOutput,
readStack,
} = stateFunctions(components.alchemyState, "base");
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "identity-hub",
"name": "team-base",
"private": true,
"type": "module",
"scripts": {
Expand All @@ -12,12 +12,12 @@
"lint": "vp lint"
},
"dependencies": {
"@samebase/alchemy-convex": "0.14.1",
"@samebase/identity-broker": "0.6.1",
"convex": "1.47.0"
"convex": "1.48.0"
},
"devDependencies": {
"@effect/platform-node": "4.0.5",
"@samebase/alchemy-convex": "0.13.0",
"@types/node": "24.19.2",
"alchemy": "2.0.0-beta.84",
"effect": "4.0.5",
Expand Down
Loading
Loading