diff --git a/.env.example b/.env.example index 4847aca6..3fabeff5 100644 --- a/.env.example +++ b/.env.example @@ -145,6 +145,18 @@ AF_STACK_S3_REGION=us-east-1 # Email (Resend) # RESEND_API_KEY= +# ---------- Default operator account ---------- +# Seeded on first boot so the operator console at http://localhost:3000 is +# usable immediately — no signup wizard. Log in with these, then CHANGE THE +# PASSWORD from the console. Seeding only runs while the operator table is +# empty, so changing these values after first boot has no effect (reset the +# Postgres volume to re-seed). Set AF_STACK_DEFAULT_OPERATOR_DISABLED=true to +# skip seeding entirely (e.g. when you provision operators another way). +# AF_STACK_DEFAULT_OPERATOR_EMAIL=operator@af-stack.local +# AF_STACK_DEFAULT_OPERATOR_PASSWORD=changeme123 +# AF_STACK_DEFAULT_OPERATOR_NAME=Default Operator +# AF_STACK_DEFAULT_OPERATOR_DISABLED=false + # Dashboard sign-in providers (better-auth). These are for humans signing # into the operator console / customer app, not for agents acting as users. # GOOGLE_CLIENT_ID= diff --git a/README.md b/README.md index 4bbf65a8..3531d05c 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ module, and one dashboard plugin. | --- | --- | --- | | Data | Postgres 16 + pgvector | External Postgres, RLS policy shape, workload tables. | | Storage | MinIO in dev, S3 contract in prod | S3, R2, GCS, Azure Blob via adapter/env. | -| Identity | better-auth, first-operator bootstrap | OAuth providers, trusted origins, operator creation CLI. | +| Identity | better-auth, seeded default operator | OAuth providers, trusted origins, default operator credentials. | | LLM routing | AgentField path + LiteLLM sidecar | Provider keys, model map, budgets, virtual-key strategy. | | Sandboxes | Docker in dev, e2b/gVisor/Firecracker options | Adapter choice, limits, provider credentials. | | Delivery | Svix for outbound webhooks, log notifications | Resend/Postmark/etc. notifications, billing adapter. | @@ -151,11 +151,30 @@ curl -X POST http://localhost:8080/api/v1/agents/sample.echo \ Endpoints once up: +- Operator console: `http://localhost:3000/` — sign in with the default operator account - Suite gateway: `http://localhost:8080/api/v1/` - Health + metrics: `http://localhost:8080/health` · `/ready` · `/metrics` - AgentField control plane: `http://localhost:8081/` - MinIO console: `http://localhost:9001/` +### Operator login + +A default operator account is **seeded on first boot**, so the console is +usable immediately — there is no signup wizard. + +| Field | Default | +| -------- | ------------------------- | +| Email | `operator@af-stack.local` | +| Password | `changeme123` | + +**Change the password from the console after your first login.** Override the +defaults before the first `docker compose up` with +`AF_STACK_DEFAULT_OPERATOR_EMAIL` / `AF_STACK_DEFAULT_OPERATOR_PASSWORD` in +`.env` (see [`.env.example`](.env.example)). Seeding only runs while no +operator exists yet, so changing those values later — or changing the +password in the console — is never overwritten on restart. To provision +operators another way, set `AF_STACK_DEFAULT_OPERATOR_DISABLED=true`. + To enable multi-tenancy: set `modules.multi-tenancy.enabled: true` in `apps/backend/config.yaml`. See [`docs/multi-tenancy.md`](docs/multi-tenancy.md) for the full guide, including how to run the end-to-end isolation test diff --git a/apps/dashboard/src/app/(admin)/layout.tsx b/apps/dashboard/src/app/(admin)/layout.tsx index 00c4c63d..de85214b 100644 --- a/apps/dashboard/src/app/(admin)/layout.tsx +++ b/apps/dashboard/src/app/(admin)/layout.tsx @@ -1,11 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 -import { redirect } from "next/navigation" - import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar" import { AppSidebar } from "@/components/layout/app-sidebar" import { Topbar } from "@/components/layout/topbar" -import { operatorCount, requireOperator } from "@/lib/session" +import { requireOperator } from "@/lib/session" // Admin routes are session-dependent and runtime-data-backed. Never // prerender — every request needs a fresh session check + live data. @@ -16,12 +14,9 @@ function billingDisabled(): boolean { } export default async function AdminLayout({ children }: { children: React.ReactNode }) { - // First-run: if no operator exists, divert to setup wizard. - const count = await operatorCount() - if (count === 0) { - redirect("/setup") - } - + // A default operator is seeded at boot, so there is no operator-less + // first-run state to divert to a wizard. requireOperator() sends anyone + // without a valid operator session to /login. const session = await requireOperator() return ( diff --git a/apps/dashboard/src/app/(auth)/login/login-form.tsx b/apps/dashboard/src/app/(auth)/login/login-form.tsx index a716c563..e871367e 100644 --- a/apps/dashboard/src/app/(auth)/login/login-form.tsx +++ b/apps/dashboard/src/app/(auth)/login/login-form.tsx @@ -3,7 +3,6 @@ "use client" import { Suspense, useState } from "react" -import Link from "next/link" import { useRouter, useSearchParams } from "next/navigation" import { useForm } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" @@ -13,12 +12,7 @@ import { Building2Icon, MailIcon } from "lucide-react" import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { - Field, - FieldDescription, - FieldGroup, - FieldLabel, -} from "@/components/ui/field" +import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field" import { Input } from "@/components/ui/input" import { Separator } from "@/components/ui/separator" import { signIn } from "@/lib/auth-client" @@ -121,14 +115,10 @@ function LoginFormInner({ sso }: LoginFormProps) { {...form.register("email")} /> {form.formState.errors.email ? ( - - {form.formState.errors.email.message} - + {form.formState.errors.email.message} ) : null} - + Password {form.formState.errors.password ? ( - - {form.formState.errors.password.message} - + {form.formState.errors.password.message} ) : null} ) : null} - - Don't have an account?{" "} - - Sign up - - diff --git a/apps/dashboard/src/app/(auth)/login/page.tsx b/apps/dashboard/src/app/(auth)/login/page.tsx index 6dc92c39..7bda582c 100644 --- a/apps/dashboard/src/app/(auth)/login/page.tsx +++ b/apps/dashboard/src/app/(auth)/login/page.tsx @@ -1,21 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 -import { redirect } from "next/navigation" - -import { operatorCount } from "@/lib/session" import { getDashboardSSOConfig } from "@/lib/sso" import { LoginForm } from "./login-form" -// Server component — checks first-run state before rendering the form. -// If no operators exist yet, divert to the setup wizard so we don't ask -// people to "sign in" to an empty deployment. +// Server component. A default operator account is seeded at boot +// (lib/bootstrap-operator.ts) and documented in the README, so there is no +// first-run setup wizard to divert to — we always render the sign-in form. export const dynamic = "force-dynamic" export default async function LoginPage() { - const count = await operatorCount() - if (count === 0) { - redirect("/setup") - } const sso = getDashboardSSOConfig() return } diff --git a/apps/dashboard/src/app/(auth)/signup/page.tsx b/apps/dashboard/src/app/(auth)/signup/page.tsx deleted file mode 100644 index e6f9fe6c..00000000 --- a/apps/dashboard/src/app/(auth)/signup/page.tsx +++ /dev/null @@ -1,136 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -"use client" - -import { useState } from "react" -import Link from "next/link" -import { useRouter } from "next/navigation" -import { useForm } from "react-hook-form" -import { zodResolver } from "@hookform/resolvers/zod" -import { z } from "zod" -import { toast } from "sonner" - -import { Button } from "@/components/ui/button" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { - Field, - FieldDescription, - FieldGroup, - FieldLabel, -} from "@/components/ui/field" -import { Input } from "@/components/ui/input" -import { signUp } from "@/lib/auth-client" - -const SignupSchema = z.object({ - name: z.string().min(1, "What should we call you?"), - email: z.email("Enter a valid email"), - password: z.string().min(8, "Use at least 8 characters"), -}) - -type SignupValues = z.infer - -export default function SignupPage() { - const router = useRouter() - const [submitting, setSubmitting] = useState(false) - const form = useForm({ - resolver: zodResolver(SignupSchema), - defaultValues: { name: "", email: "", password: "" }, - mode: "onBlur", - }) - - const handleSubmit = async (values: SignupValues) => { - setSubmitting(true) - try { - const result = await signUp.email({ - name: values.name, - email: values.email, - password: values.password, - }) - if (result.error) { - toast.error(result.error.message ?? "Could not sign up.") - return - } - router.push("/") - router.refresh() - } finally { - setSubmitting(false) - } - } - - return ( - - - Create an account - - Operators sign in here. Customers and end-users live in your product. - - -
- - - - Name - - {form.formState.errors.name ? ( - - {form.formState.errors.name.message} - - ) : null} - - - Email - - {form.formState.errors.email ? ( - - {form.formState.errors.email.message} - - ) : null} - - - Password - - {form.formState.errors.password ? ( - - {form.formState.errors.password.message} - - ) : null} - - - - Already have an account?{" "} - - Sign in - - - - -
-
- ) -} \ No newline at end of file diff --git a/apps/dashboard/src/app/setup/page.tsx b/apps/dashboard/src/app/setup/page.tsx deleted file mode 100644 index d828f915..00000000 --- a/apps/dashboard/src/app/setup/page.tsx +++ /dev/null @@ -1,48 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -import { redirect } from "next/navigation" -import Link from "next/link" -import { Boxes } from "lucide-react" - -import { Button } from "@/components/ui/button" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { brand } from "@/lib/brand" -import { operatorCount } from "@/lib/session" - -export default async function SetupPage() { - const count = await operatorCount() - if (count > 0) { - redirect("/") - } - return ( -
- - -
- {brand.logos.light ? ( - - ) : ( - - )} -
- Welcome to {brand.displayName} - - Create the first operator account. This account governs the entire deployment. - Subsequent users sign up via invitation. - -
- -
-
-
-
-
- ) -} diff --git a/apps/dashboard/src/instrumentation.ts b/apps/dashboard/src/instrumentation.ts new file mode 100644 index 00000000..a195cda6 --- /dev/null +++ b/apps/dashboard/src/instrumentation.ts @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Next.js instrumentation hook — runs once when the server process starts +// (never at build time). We use it to seed the default operator account on +// first boot so the operator console is usable immediately, with no signup +// wizard. See src/lib/bootstrap-operator.ts. + +export async function register() { + // Only the Node.js server runtime can touch Postgres; skip the edge runtime. + if (process.env.NEXT_RUNTIME !== "nodejs") { + return + } + const { seedDefaultOperator } = await import("@/lib/bootstrap-operator") + await seedDefaultOperator() +} diff --git a/apps/dashboard/src/lib/auth.ts b/apps/dashboard/src/lib/auth.ts index 1048eb8e..49bd5155 100644 --- a/apps/dashboard/src/lib/auth.ts +++ b/apps/dashboard/src/lib/auth.ts @@ -65,28 +65,42 @@ function makeAuth() { trustedOrigins, // Mirror every better-auth user into suite_users on create so the // runtime's tenant_resolver can join on email and find the canonical - // suite user id. Without this hook a freshly-signed-up operator gets - // 401 on /api/v1/secrets and friends until someone hand-inserts the - // row. + // suite user id. Without this hook a freshly-created user (via SSO / + // OAuth / magic-link — email/password sign-up is disabled below) gets + // 401 on /api/v1/secrets and friends until someone hand-inserts the row. + // + // Each mirror step runs INDEPENDENTLY and best-effort. The previous + // version wrapped all three in one transaction, so a failure in the + // membership step (e.g. the default tenant row not existing yet) rolled + // back the suite_operators insert too — leaving the deployment with zero + // operators and an infinite /setup redirect. The default operator is now + // seeded at boot (see lib/bootstrap-operator.ts); this hook must never be + // able to undo operator creation. databaseHooks: { user: { create: { after: async (user) => { - const client = await pool.connect() - try { - await client.query("begin") - await ensureOperatorsTable(pool) - await client.query( + const step = async (label: string, fn: () => Promise) => { + try { + await fn() + } catch (e) { + console.error(`[suite mirror] ${label} failed:`, e) + } + } + + await step("suite_users", async () => { + await pool.query( `INSERT INTO suite_users (email, name) VALUES ($1, $2) ON CONFLICT (email) DO NOTHING`, [user.email, user.name ?? null], ) - // Auto-membership: every freshly-signed-up user becomes - // an owner of the default tenant. Once an admin builds - // tenant-management flows we can swap this for an - // invitation-based join. - await client.query( + }) + // Auto-membership: every freshly-created user becomes an owner of + // the default tenant. Once an admin builds tenant-management flows + // we can swap this for an invitation-based join. + await step("suite_memberships", async () => { + await pool.query( `INSERT INTO suite_memberships (user_id, tenant_id, role) SELECT u.id, '00000000-0000-0000-0000-000000000000'::uuid, 'owner' FROM suite_users u @@ -94,10 +108,10 @@ function makeAuth() { ON CONFLICT DO NOTHING`, [user.email], ) - await client.query( - "select pg_advisory_xact_lock(hashtext('af_stack_operator_bootstrap'))", - ) - await client.query( + }) + await step("suite_operators", async () => { + await ensureOperatorsTable(pool) + await pool.query( `INSERT INTO suite_operators (user_id, email, name, role) SELECT $1, $2, $3, 'owner' WHERE NOT EXISTS (SELECT 1 FROM suite_operators) @@ -106,22 +120,19 @@ function makeAuth() { name = COALESCE(excluded.name, suite_operators.name)`, [user.id, user.email, user.name ?? null], ) - await client.query("commit") - } catch (e) { - await client.query("rollback").catch(() => {}) - // Don't block sign-up on the mirror — log and let the - // user in. They'll get 401 on protected APIs until the - // mirror is repaired, which is loud + recoverable. - console.error("[suite_users mirror] failed:", e) - } finally { - client.release() - } + }) }, }, }, }, emailAndPassword: { enabled: true, + // The operator console no longer offers public self-signup — the first + // operator is seeded at boot (lib/bootstrap-operator.ts), documented in + // the README. Disabling sign-up closes the /api/auth/sign-up route so + // nobody can self-provision a user against this deployment. Sign-IN, + // magic-link, OAuth, and SSO all remain enabled. + disableSignUp: true, autoSignIn: true, minPasswordLength: 8, }, diff --git a/apps/dashboard/src/lib/bootstrap-operator.ts b/apps/dashboard/src/lib/bootstrap-operator.ts new file mode 100644 index 00000000..91b8c052 --- /dev/null +++ b/apps/dashboard/src/lib/bootstrap-operator.ts @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: Apache-2.0 + +// First-run bootstrap: seed a default operator account so a fresh +// deployment is usable the moment `docker compose up` finishes — no signup +// wizard, no chicken-and-egg. +// +// Why this exists +// ---------------- +// Operator status lives in `suite_operators`, a table separate from +// better-auth's `user` / `account`. Previously the *first* dashboard signup +// populated it as a side effect, inside one all-or-nothing transaction that +// also wrote `suite_users` + `suite_memberships`. Any partial failure rolled +// the whole thing back, so the deployment was left with zero operators — and +// every route (/login, /, …) bounced to /setup forever. Seeding a known +// account removes that failure mode entirely. +// +// Credentials come from env and are documented in the README / .env.example: +// AF_STACK_DEFAULT_OPERATOR_EMAIL (default: operator@af-stack.local) +// AF_STACK_DEFAULT_OPERATOR_PASSWORD (default: changeme123) +// AF_STACK_DEFAULT_OPERATOR_NAME (default: Default Operator) +// +// The seed is gated on an EMPTY `suite_operators` table, so it runs exactly +// once. It never clobbers a changed password or a hand-rolled operator set: +// once any operator exists, this is a no-op on every subsequent boot. + +import { randomUUID } from "node:crypto" +import { hashPassword } from "better-auth/crypto" +import { Pool } from "pg" + +const DEFAULT_EMAIL = + process.env.AF_STACK_DEFAULT_OPERATOR_EMAIL?.trim() || "operator@af-stack.local" +const DEFAULT_PASSWORD = process.env.AF_STACK_DEFAULT_OPERATOR_PASSWORD?.trim() || "changeme123" +const DEFAULT_NAME = process.env.AF_STACK_DEFAULT_OPERATOR_NAME?.trim() || "Default Operator" + +// Migrations run in the runtime container, which the dashboard only +// `depends_on: service_started` — not healthy. So the auth/operator tables +// may not exist for the first few seconds after boot. Retry until they do. +async function withRetry(fn: () => Promise, attempts = 20, delayMs = 3000): Promise { + let lastErr: unknown + for (let i = 0; i < attempts; i++) { + try { + await fn() + return + } catch (e) { + lastErr = e + await new Promise((resolve) => setTimeout(resolve, delayMs)) + } + } + throw lastErr +} + +export async function seedDefaultOperator(): Promise { + if ((process.env.AF_STACK_DEFAULT_OPERATOR_DISABLED ?? "").trim().toLowerCase() === "true") { + return + } + const connectionString = process.env.DATABASE_URL ?? process.env.AF_STACK_DATABASE_URL + if (!connectionString) { + console.warn("[operator-seed] no DATABASE_URL set; skipping default operator seed") + return + } + + const pool = new Pool({ connectionString, max: 2 }) + try { + await withRetry(async () => { + const client = await pool.connect() + try { + // Probe the tables migrations are responsible for. A missing table + // throws here and the whole attempt is retried. + await client.query('select 1 from "user" limit 1') + await client.query("select 1 from suite_operators limit 1") + + await client.query("begin") + // Serialize concurrent dashboard replicas racing the same seed. + await client.query( + "select pg_advisory_xact_lock(hashtext('af_stack_default_operator_seed'))", + ) + + const { rows } = await client.query<{ count: string }>( + "select count(*)::text as count from suite_operators", + ) + if (Number(rows[0]?.count ?? "0") > 0) { + // Already bootstrapped — never re-seed, never clobber a changed + // password or an operator set the admin curated themselves. + await client.query("commit") + return + } + + const passwordHash = await hashPassword(DEFAULT_PASSWORD) + + // 1. better-auth user row (idempotent on email). + await client.query( + `insert into "user" ("id", "name", "email", "emailVerified", "createdAt", "updatedAt") + values ($1, $2, $3, true, now(), now()) + on conflict ("email") do nothing`, + [randomUUID(), DEFAULT_NAME, DEFAULT_EMAIL], + ) + const userRow = await client.query<{ id: string }>( + 'select "id" from "user" where lower("email") = lower($1)', + [DEFAULT_EMAIL], + ) + const userId = userRow.rows[0]?.id + if (!userId) { + throw new Error("[operator-seed] user row missing after insert") + } + + // 2. Credential account holding the password hash. better-auth keys + // email/password accounts as providerId='credential', + // accountId=. Skip if the user already has one. + await client.query( + `insert into "account" ("id", "accountId", "providerId", "userId", "password", "createdAt", "updatedAt") + select $1, $2, 'credential', $3, $4, now(), now() + where not exists ( + select 1 from "account" where "userId" = $3 and "providerId" = 'credential' + )`, + [randomUUID(), userId, userId, passwordHash], + ) + + // 3. Operator allow-list entry — what actually gates the console. + await client.query( + `insert into suite_operators ("user_id", "email", "name", "role") + values ($1, $2, $3, 'owner') + on conflict ("email") do update set "user_id" = excluded."user_id"`, + [userId, DEFAULT_EMAIL, DEFAULT_NAME], + ) + + // 4. Mirror into suite_users so the runtime's tenant_resolver can + // join on email (otherwise protected /api/v1/* return 401). + await client.query( + `insert into suite_users ("email", "name") values ($1, $2) + on conflict ("email") do nothing`, + [DEFAULT_EMAIL, DEFAULT_NAME], + ) + + await client.query("commit") + console.log( + `[operator-seed] seeded default operator ${DEFAULT_EMAIL} — change this password after first login`, + ) + } catch (e) { + await client.query("rollback").catch(() => {}) + throw e + } finally { + client.release() + } + }) + } catch (e) { + // Never crash the dashboard process over the seed — log loudly and let + // it boot. A restart re-attempts. + console.error("[operator-seed] failed to seed default operator:", e) + } finally { + await pool.end().catch(() => {}) + } +} diff --git a/apps/dashboard/src/middleware.ts b/apps/dashboard/src/middleware.ts index 5ad3b10a..4271ac85 100644 --- a/apps/dashboard/src/middleware.ts +++ b/apps/dashboard/src/middleware.ts @@ -6,18 +6,14 @@ // /login (with the original URL as ?next so we can bounce them back after // signing in). Auth API routes always pass through. // -// First-run setup: if no operator has signed up yet, every route (except -// /setup) redirects to /setup. This is handled by the server-side check -// in `app/(admin)/layout.tsx` rather than here, because Edge middleware -// can't query Postgres cheaply. +// There is no first-run setup wizard: the default operator account is seeded +// at boot (lib/bootstrap-operator.ts), so /login is the only entry point. import { NextResponse, type NextRequest } from "next/server" import { getSessionCookie } from "better-auth/cookies" const PUBLIC_PREFIXES = [ "/login", - "/signup", - "/setup", // Allow ALL /api/* through — better-auth has its own session handling, // and the rewrites in next.config.ts proxy /api/v1/* to the runtime // which has its own auth boundary (Phase 6). @@ -46,4 +42,4 @@ export function middleware(request: NextRequest) { export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.png|.*\\.svg).*)"], -} \ No newline at end of file +} diff --git a/docker-compose.yml b/docker-compose.yml index 368dfaf5..59dd9366 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -208,6 +208,13 @@ services: environment: DATABASE_URL: postgres://afstack:afstack@postgres:5432/afstack?sslmode=disable AF_STACK_AUTH_SECRET: ${AF_STACK_AUTH_SECRET:-dev-secret-change-me-in-prod} + # Default operator account, seeded on first boot so the console is + # usable immediately (no signup wizard). CHANGE THE PASSWORD after your + # first login. Seeding only runs while suite_operators is empty, so + # editing these after first boot has no effect. + AF_STACK_DEFAULT_OPERATOR_EMAIL: ${AF_STACK_DEFAULT_OPERATOR_EMAIL:-operator@af-stack.local} + AF_STACK_DEFAULT_OPERATOR_PASSWORD: ${AF_STACK_DEFAULT_OPERATOR_PASSWORD:-changeme123} + AF_STACK_DEFAULT_OPERATOR_NAME: ${AF_STACK_DEFAULT_OPERATOR_NAME:-Default Operator} # Tell better-auth which URL the operator hits the dashboard at. BETTER_AUTH_URL: http://localhost:${AF_STACK_DASHBOARD_PORT:-3000} BETTER_AUTH_TRUSTED_ORIGINS: ${BETTER_AUTH_TRUSTED_ORIGINS:-}