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.
-
-
-
-
- )
-}
\ 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:-}