This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"functions": "functions/"
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Deploys this app's Better Auth functions (./functions) to its Better Auth Convex deployment, after
|
||||
// installing the two variables Better Auth reads there: SITE_URL, the app's public origin and the
|
||||
// only origin Better Auth trusts, and BETTER_AUTH_SECRET. The migrate job runs it on the Postgres
|
||||
// profile (apps/worker/src/migrate.ts), the convex-deploy job on the Convex profile; a development
|
||||
// machine runs `node better-auth/deploy.mjs`. The temporary env file is mode 0600 and removed on
|
||||
// success and on failure; no value is printed.
|
||||
//
|
||||
// BETTER_AUTH_CONVEX_URL the Better Auth deployment's URL
|
||||
// BETTER_AUTH_CONVEX_ADMIN_KEY its admin key (this job and the web server's revocation relay only)
|
||||
// BETTER_AUTH_SECRET Better Auth's secret, at least 32 characters (this job only)
|
||||
// CONUSAI_PUBLIC_URL the app's public origin
|
||||
|
||||
import { spawnSync } from "node:child_process"
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const required = (name, value, why) => {
|
||||
const trimmed = value?.trim()
|
||||
if (!trimmed) throw new Error(`${name} is not set: ${why}`)
|
||||
if (/[\r\n]/.test(trimmed)) throw new Error(`${name} holds a line break`)
|
||||
return trimmed
|
||||
}
|
||||
|
||||
export function deployBetterAuth(env = process.env) {
|
||||
const url = required(
|
||||
"BETTER_AUTH_CONVEX_URL",
|
||||
env.BETTER_AUTH_CONVEX_URL,
|
||||
"the URL of this app's Better Auth deployment",
|
||||
)
|
||||
const adminKey = required(
|
||||
"BETTER_AUTH_CONVEX_ADMIN_KEY",
|
||||
env.BETTER_AUTH_CONVEX_ADMIN_KEY,
|
||||
"the admin key of the Better Auth deployment",
|
||||
)
|
||||
const secret = required(
|
||||
"BETTER_AUTH_SECRET",
|
||||
env.BETTER_AUTH_SECRET,
|
||||
"Better Auth's secret, 32 random characters or more",
|
||||
)
|
||||
if (secret.length < 32) throw new Error("BETTER_AUTH_SECRET is shorter than 32 characters")
|
||||
const publicUrl = required(
|
||||
"CONUSAI_PUBLIC_URL",
|
||||
env.CONUSAI_PUBLIC_URL,
|
||||
"the app's public origin, which Better Auth trusts",
|
||||
)
|
||||
const site = new URL(publicUrl).origin
|
||||
const project = fileURLToPath(new URL(".", import.meta.url))
|
||||
const cli = fileURLToPath(new URL("../node_modules/convex/bin/main.js", import.meta.url))
|
||||
const childEnv = { ...env, CONVEX_SELF_HOSTED_URL: url, CONVEX_SELF_HOSTED_ADMIN_KEY: adminKey }
|
||||
delete childEnv.CONVEX_DEPLOYMENT
|
||||
delete childEnv.CONVEX_DEPLOY_KEY
|
||||
|
||||
const directory = mkdtempSync(join(tmpdir(), "conusai-better-auth-env-"))
|
||||
const envFile = join(directory, ".env")
|
||||
try {
|
||||
writeFileSync(envFile, `SITE_URL=${site}\nBETTER_AUTH_SECRET=${secret}\n`, { mode: 0o600, flag: "wx" })
|
||||
const seeded = spawnSync(process.execPath, [cli, "env", "set", "--from-file", envFile, "--force"], {
|
||||
cwd: project,
|
||||
env: childEnv,
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
encoding: "utf8",
|
||||
})
|
||||
if (seeded.error || seeded.status !== 0) {
|
||||
// The CLI names what failed (the connection, the key); it does not print the values it was given.
|
||||
throw new Error(
|
||||
`the Better Auth deployment's environment could not be set: ${seeded.stderr?.trim().split("\n").at(-1) ?? ""}`,
|
||||
)
|
||||
}
|
||||
const deployed = spawnSync(process.execPath, [cli, "deploy", "--yes", "--typecheck", "disable"], {
|
||||
cwd: project,
|
||||
env: childEnv,
|
||||
stdio: "inherit",
|
||||
})
|
||||
if (deployed.error) throw deployed.error
|
||||
if (deployed.status !== 0) throw new Error(`convex deploy of the Better Auth functions exited ${deployed.status}`)
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
try {
|
||||
deployBetterAuth()
|
||||
console.info("better-auth: functions deployed")
|
||||
} catch (error) {
|
||||
console.error(`better-auth: ${error instanceof Error ? error.message : String(error)}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The one token issuer this deployment accepts: Better Auth's Convex plugin, on this deployment's own
|
||||
// site URL. The app server's session validation (conusaiAuth.ts) runs under such a token.
|
||||
import { getAuthConfigProvider } from "@convex-dev/better-auth/auth-config"
|
||||
import type { AuthConfig } from "convex/server"
|
||||
|
||||
export default { providers: [getAuthConfigProvider()] } satisfies AuthConfig
|
||||
@@ -0,0 +1,37 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Better Auth for this app, on Convex. SITE_URL is the app's public origin (CONUSAI_PUBLIC_URL), the
|
||||
// only origin Better Auth trusts; BETTER_AUTH_SECRET signs its cookies and tokens. Both are this
|
||||
// deployment's environment, installed by ../deploy.mjs, and never reach the app's browser bundle.
|
||||
import { revocationTriggers } from "@conusai-kit/auth-convex-better-auth/convex"
|
||||
import { type AuthFunctions, createClient, type GenericCtx } from "@convex-dev/better-auth"
|
||||
import { betterAuth } from "better-auth/minimal"
|
||||
import { components, internal } from "./_generated/api"
|
||||
import type { DataModel } from "./_generated/dataModel"
|
||||
import authConfig from "./auth.config"
|
||||
import { betterAuthOptions } from "./betterAuth/options"
|
||||
import authSchema from "./betterAuth/schema"
|
||||
|
||||
const authFunctions: AuthFunctions = internal.auth
|
||||
|
||||
// The triggers write the revocation outbox in the same mutation that ends a session or an account.
|
||||
export const authComponent = createClient<DataModel, typeof authSchema>(components.betterAuth, {
|
||||
authFunctions,
|
||||
triggers: revocationTriggers(),
|
||||
local: { schema: authSchema },
|
||||
})
|
||||
|
||||
export const { onCreate, onUpdate, onDelete } = authComponent.triggersApi()
|
||||
|
||||
export const createAuthOptions = (ctx: GenericCtx<DataModel>) => {
|
||||
const siteUrl = process.env.SITE_URL
|
||||
return {
|
||||
...(siteUrl ? { baseURL: siteUrl, trustedOrigins: [siteUrl] } : {}),
|
||||
database: authComponent.adapter(ctx),
|
||||
...betterAuthOptions(authConfig),
|
||||
}
|
||||
}
|
||||
|
||||
export const createAuth = (ctx: GenericCtx<DataModel>) => {
|
||||
if (!process.env.SITE_URL) throw new Error("SITE_URL is not set: deploy.mjs installs the app's public origin")
|
||||
return betterAuth(createAuthOptions(ctx))
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The component's database functions over the locally installed schema.
|
||||
import { createApi } from "@convex-dev/better-auth"
|
||||
import { createAuthOptions } from "../auth"
|
||||
import schema from "./schema"
|
||||
|
||||
export const { create, findOne, findMany, updateOne, updateMany, deleteOne, deleteMany } = createApi(
|
||||
schema,
|
||||
createAuthOptions,
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The Better Auth component, installed locally (@convex-dev/better-auth "local install"): its tables
|
||||
// are ./schema.ts, generated from this app's own Better Auth options (./options.ts).
|
||||
import { defineComponent } from "convex/server"
|
||||
|
||||
const component = defineComponent("betterAuth")
|
||||
export default component
|
||||
@@ -0,0 +1,33 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// What of this app's Better Auth configuration shapes its tables. ./schema.ts is generated from it
|
||||
// (test/better-auth.test.ts checks the two agree; `pnpm exec vitest run test/better-auth.test.ts -u`
|
||||
// regenerates the schema after a change here), and ../auth.ts builds Better Auth with it.
|
||||
//
|
||||
// - email and password sign-in, without e-mail verification (an app that sends mail turns it on)
|
||||
// - the person's own account deletion (`/delete-user`): the revocation relay hands it to the app's
|
||||
// account flow, which erases the person's data in every module (apps/web/src/server/better-auth.ts)
|
||||
// - the admin plugin: an administrator disables (`banned`) a user, which ends every session of the
|
||||
// user and refuses the next sign-in; the session validation refuses a disabled user as well
|
||||
// - rate limits kept in the deployment's own table, since a Convex function keeps no memory
|
||||
import { convex } from "@convex-dev/better-auth/plugins"
|
||||
import type { BetterAuthOptions } from "better-auth/minimal"
|
||||
import { admin } from "better-auth/plugins/admin"
|
||||
import type { AuthConfig } from "convex/server"
|
||||
|
||||
export const MIN_PASSWORD_LENGTH = 12
|
||||
|
||||
/** The administrators the admin plugin accepts, by Better Auth user ID (BETTER_AUTH_ADMIN_USER_IDS, comma-separated). */
|
||||
const adminUserIds = (): string[] =>
|
||||
(process.env.BETTER_AUTH_ADMIN_USER_IDS ?? "")
|
||||
.split(",")
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
export function betterAuthOptions(authConfig: AuthConfig) {
|
||||
return {
|
||||
emailAndPassword: { enabled: true, requireEmailVerification: false, minPasswordLength: MIN_PASSWORD_LENGTH },
|
||||
user: { deleteUser: { enabled: true } },
|
||||
rateLimit: { enabled: true, storage: "database" },
|
||||
plugins: [convex({ authConfig }), admin({ adminUserIds: adminUserIds() })],
|
||||
} satisfies Omit<BetterAuthOptions, "database">
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Generated from ./options.ts by the @convex-dev/better-auth schema generator: do not edit it by hand.
|
||||
// After a change of the Better Auth options, regenerate it with
|
||||
// pnpm exec vitest run test/better-auth.test.ts -u
|
||||
// (the "local install" of @convex-dev/better-auth, which is how the admin plugin's fields get here).
|
||||
|
||||
import { defineSchema, defineTable } from "convex/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
export const tables = {
|
||||
user: defineTable({
|
||||
name: v.string(),
|
||||
email: v.string(),
|
||||
emailVerified: v.boolean(),
|
||||
image: v.optional(v.union(v.null(), v.string())),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
userId: v.optional(v.union(v.null(), v.string())),
|
||||
role: v.optional(v.union(v.null(), v.string())),
|
||||
banned: v.optional(v.union(v.null(), v.boolean())),
|
||||
banReason: v.optional(v.union(v.null(), v.string())),
|
||||
banExpires: v.optional(v.union(v.null(), v.number())),
|
||||
})
|
||||
.index("email_name", ["email","name"])
|
||||
.index("name", ["name"])
|
||||
.index("userId", ["userId"]),
|
||||
session: defineTable({
|
||||
expiresAt: v.number(),
|
||||
token: v.string(),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
ipAddress: v.optional(v.union(v.null(), v.string())),
|
||||
userAgent: v.optional(v.union(v.null(), v.string())),
|
||||
userId: v.string(),
|
||||
impersonatedBy: v.optional(v.union(v.null(), v.string())),
|
||||
})
|
||||
.index("expiresAt", ["expiresAt"])
|
||||
.index("expiresAt_userId", ["expiresAt","userId"])
|
||||
.index("token", ["token"])
|
||||
.index("userId", ["userId"]),
|
||||
account: defineTable({
|
||||
accountId: v.string(),
|
||||
providerId: v.string(),
|
||||
userId: v.string(),
|
||||
accessToken: v.optional(v.union(v.null(), v.string())),
|
||||
refreshToken: v.optional(v.union(v.null(), v.string())),
|
||||
idToken: v.optional(v.union(v.null(), v.string())),
|
||||
accessTokenExpiresAt: v.optional(v.union(v.null(), v.number())),
|
||||
refreshTokenExpiresAt: v.optional(v.union(v.null(), v.number())),
|
||||
scope: v.optional(v.union(v.null(), v.string())),
|
||||
password: v.optional(v.union(v.null(), v.string())),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("accountId", ["accountId"])
|
||||
.index("accountId_providerId", ["accountId","providerId"])
|
||||
.index("providerId_userId", ["providerId","userId"])
|
||||
.index("userId", ["userId"]),
|
||||
verification: defineTable({
|
||||
identifier: v.string(),
|
||||
value: v.string(),
|
||||
expiresAt: v.number(),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index("expiresAt", ["expiresAt"])
|
||||
.index("identifier", ["identifier"]),
|
||||
jwks: defineTable({
|
||||
publicKey: v.string(),
|
||||
privateKey: v.string(),
|
||||
createdAt: v.number(),
|
||||
expiresAt: v.optional(v.union(v.null(), v.number())),
|
||||
}),
|
||||
rateLimit: defineTable({
|
||||
key: v.string(),
|
||||
count: v.number(),
|
||||
lastRequest: v.number(),
|
||||
})
|
||||
.index("key", ["key"]),
|
||||
};
|
||||
|
||||
const schema = defineSchema(tables);
|
||||
|
||||
export default schema;
|
||||
@@ -0,0 +1,31 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// What the app server asks of this deployment (@conusai-kit/auth-convex-better-auth):
|
||||
//
|
||||
// validateSession the session check of every request, under the token Better Auth issued for
|
||||
// the request's own session cookie (public: a token is the only way in)
|
||||
// pendingRevocations the revocation outbox, drained by the relay with the deployment's admin key
|
||||
// settleRevocation (internal: only the admin key reaches them)
|
||||
// endAccount the app's own account deletion ends the person's Better Auth account first
|
||||
// (internal), through Better Auth itself, so the component triggers record it
|
||||
import { defineBetterAuthSessionApi } from "@conusai-kit/auth-convex-better-auth/convex"
|
||||
import { v } from "convex/values"
|
||||
import { components } from "./_generated/api"
|
||||
import { internalMutation } from "./_generated/server"
|
||||
import { authComponent, createAuth } from "./auth"
|
||||
|
||||
export const { validateSession, pendingRevocations, settleRevocation } = defineBetterAuthSessionApi({
|
||||
authComponent,
|
||||
component: components.betterAuth,
|
||||
})
|
||||
|
||||
export const endAccount = internalMutation({
|
||||
args: { userId: v.string() },
|
||||
returns: v.null(),
|
||||
handler: async (ctx, { userId }) => {
|
||||
const auth = await createAuth(ctx).$context
|
||||
if (!(await auth.internalAdapter.findUserById(userId))) return null
|
||||
// Its sessions, accounts and the user, each through the component, so every trigger records it.
|
||||
await auth.internalAdapter.deleteUser(userId)
|
||||
return null
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The Better Auth deployment of this app (AUTH_PROVIDER=convex-better-auth, joint contract §14): a
|
||||
// Convex app of its own whose one component is Better Auth, installed locally (./betterAuth) so its
|
||||
// schema carries the admin plugin's fields. The app's own data stays where its profile keeps it.
|
||||
import { defineApp } from "convex/server"
|
||||
import betterAuth from "./betterAuth/convex.config"
|
||||
|
||||
const app = defineApp()
|
||||
app.use(betterAuth)
|
||||
export default app
|
||||
@@ -0,0 +1,9 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Better Auth's routes on this deployment's site URL. A browser never calls them directly: the app's
|
||||
// same-origin proxy (apps/web/app/api/auth) forwards the few a browser needs.
|
||||
import { httpRouter } from "convex/server"
|
||||
import { authComponent, createAuth } from "./auth"
|
||||
|
||||
const http = httpRouter()
|
||||
authComponent.registerRoutes(http, createAuth)
|
||||
export default http
|
||||
@@ -0,0 +1,8 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// This deployment's own table: the revocation outbox the component triggers write and the app
|
||||
// server's relay drains (@conusai-kit/auth-convex-better-auth). Better Auth's tables are the
|
||||
// component's (./betterAuth/schema.ts).
|
||||
import { revocationTables } from "@conusai-kit/auth-convex-better-auth/convex"
|
||||
import { defineSchema } from "convex/server"
|
||||
|
||||
export default defineSchema({ ...revocationTables })
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"noEmit": true,
|
||||
"types": ["node"],
|
||||
"exactOptionalPropertyTypes": false,
|
||||
"noUnusedLocals": true
|
||||
},
|
||||
"include": ["./**/*.ts"],
|
||||
"exclude": ["./_generated"]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@app/better-auth",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "This app's Better Auth functions for AUTH_PROVIDER=convex-better-auth, deployed to its own Convex deployment by deploy.mjs. Not a workspace package: the Convex CLI asks for this manifest, and bundles the functions with the app root's installed packages (the same catalogue pins).",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@conusai-kit/auth-convex-better-auth": "catalog:kit",
|
||||
"@convex-dev/better-auth": "catalog:",
|
||||
"better-auth": "catalog:",
|
||||
"convex": "catalog:"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user