Skip to content
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
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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
Expand Down
13 changes: 4 additions & 9 deletions apps/dashboard/src/app/(admin)/layout.tsx
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 (
Expand Down
26 changes: 4 additions & 22 deletions apps/dashboard/src/app/(auth)/login/login-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -121,14 +115,10 @@ function LoginFormInner({ sso }: LoginFormProps) {
{...form.register("email")}
/>
{form.formState.errors.email ? (
<FieldDescription>
{form.formState.errors.email.message}
</FieldDescription>
<FieldDescription>{form.formState.errors.email.message}</FieldDescription>
) : null}
</Field>
<Field
data-invalid={form.formState.errors.password ? true : undefined}
>
<Field data-invalid={form.formState.errors.password ? true : undefined}>
<FieldLabel htmlFor="password">Password</FieldLabel>
<Input
id="password"
Expand All @@ -138,9 +128,7 @@ function LoginFormInner({ sso }: LoginFormProps) {
{...form.register("password")}
/>
{form.formState.errors.password ? (
<FieldDescription>
{form.formState.errors.password.message}
</FieldDescription>
<FieldDescription>{form.formState.errors.password.message}</FieldDescription>
) : null}
</Field>
<Button type="submit" disabled={submitting} className="w-full">
Expand All @@ -167,12 +155,6 @@ function LoginFormInner({ sso }: LoginFormProps) {
<Building2Icon data-icon="inline-start" /> Continue with {sso.label}
</Button>
) : null}
<FieldDescription className="text-center">
Don&apos;t have an account?{" "}
<Link className="underline-offset-4 hover:underline" href="/signup">
Sign up
</Link>
</FieldDescription>
</FieldGroup>
</CardContent>
</form>
Expand Down
13 changes: 3 additions & 10 deletions apps/dashboard/src/app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <LoginForm sso={{ enabled: sso.enabled, label: sso.label }} />
}
136 changes: 0 additions & 136 deletions apps/dashboard/src/app/(auth)/signup/page.tsx

This file was deleted.

48 changes: 0 additions & 48 deletions apps/dashboard/src/app/setup/page.tsx

This file was deleted.

15 changes: 15 additions & 0 deletions apps/dashboard/src/instrumentation.ts
Original file line number Diff line number Diff line change
@@ -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()
}
Loading
Loading