summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChia <Chia@93.nz>2026-08-05 14:48:00 +1200
committerChia <Chia@93.nz>2026-08-05 14:48:00 +1200
commitcd0dd91ab93653631904f2ea0e574ccde6d60339 (patch)
treec65417b880a3f4a35c504c44edae821bc2122f70
parent86b1f42e3c5601ff10621a9779cf0076590797a1 (diff)
add passkey, totp.
-rw-r--r--.env.control.example21
-rw-r--r--.env.example5
-rw-r--r--README.md26
-rw-r--r--cmd/aigw/main.go32
-rw-r--r--config.control.example.json24
-rw-r--r--config.example.json7
-rw-r--r--config.local.json7
-rw-r--r--docker-compose.yml35
-rw-r--r--docs/architecture.md6
-rw-r--r--docs/commercial-readiness.md87
-rw-r--r--go.mod18
-rw-r--r--go.sum42
-rw-r--r--internal/adminapi/api.go566
-rw-r--r--internal/adminui/assets/app.js92
-rw-r--r--internal/adminui/assets/index.html43
-rw-r--r--internal/adminui/assets/style.css7
-rw-r--r--internal/billing/ledger.go38
-rw-r--r--internal/billing/service_test.go12
-rw-r--r--internal/billing/stripe.go23
-rw-r--r--internal/billing/types.go14
-rw-r--r--internal/config/config.go171
-rw-r--r--internal/config/config_test.go80
-rw-r--r--internal/controlplane/access.go114
-rw-r--r--internal/controlplane/identity.go291
-rw-r--r--internal/controlplane/mfa.go344
-rw-r--r--internal/controlplane/outbox.go152
-rw-r--r--internal/controlplane/outbox_test.go33
-rw-r--r--internal/controlplane/passkeys.go294
-rw-r--r--internal/controlplane/passkeys_test.go27
-rw-r--r--internal/controlplane/schema.sql113
-rw-r--r--internal/controlplane/types.go104
-rw-r--r--internal/mailer/mailer.go170
-rwxr-xr-xscripts/start-debug.sh31
-rwxr-xr-xscripts/stop-debug.sh8
34 files changed, 2901 insertions, 136 deletions
diff --git a/.env.control.example b/.env.control.example
index 8152d40..8122e07 100644
--- a/.env.control.example
+++ b/.env.control.example
@@ -1,8 +1,27 @@
-AIGW_DATABASE_URL=postgres://aigw:aigw@127.0.0.1:5432/aigw?sslmode=disable
+AIGW_SERVER_ADDRESS=:8080
+AIGW_POSTGRES_USER=aigw-local
+AIGW_POSTGRES_PASSWORD=replace-with-a-long-random-password
+AIGW_POSTGRES_DB=aigw-local
+AIGW_DATABASE_URL=postgres://aigw-local:replace-with-a-long-random-password@127.0.0.1:5432/aigw-local?sslmode=disable
+AIGW_DATABASE_URL_DOCKER=postgres://aigw-local:replace-with-a-long-random-password@postgres:5432/aigw-local?sslmode=disable
AIGW_REDIS_URL=redis://127.0.0.1:6379/0
+AIGW_REDIS_URL_DOCKER=redis://redis:6379/0
# base64 of exactly 32 random bytes; generate with: openssl rand -base64 32
AIGW_CREDENTIAL_KEY=replace-with-base64-32-byte-key
AIGW_ADMIN_TOKEN=replace-with-a-long-random-admin-token
+AIGW_PUBLIC_URL=http://localhost:8080/admin/
+AIGW_WEBAUTHN_RP_ID=localhost
+AIGW_WEBAUTHN_ORIGINS=http://localhost:8080
+AIGW_SMTP_FROM_ADDRESS=no-reply@aigw.local
+AIGW_SMTP_ADDRESS=127.0.0.1:1025
+AIGW_SMTP_ADDRESS_DOCKER=mailpit:1025
+# Leave both empty for a local Mailpit server; set both through a secret manager in production.
+AIGW_SMTP_USERNAME=
+AIGW_SMTP_PASSWORD=
# Prefer a restricted test key (rk_test_) with Checkout Session write access.
AIGW_STRIPE_API_KEY=replace-with-a-stripe-restricted-key
+# Development only: use a separate key with Debugging Tools Write for Stripe CLI.
+AIGW_STRIPE_CLI_API_KEY=replace-with-a-separate-cli-restricted-key
AIGW_STRIPE_WEBHOOK_SECRET=replace-with-a-webhook-signing-secret
+AIGW_STRIPE_SUCCESS_URL=http://localhost:8080/admin/?topup=success
+AIGW_STRIPE_CANCEL_URL=http://localhost:8080/admin/?topup=cancel
diff --git a/.env.example b/.env.example
index 4024793..0f3b5d8 100644
--- a/.env.example
+++ b/.env.example
@@ -1,5 +1,8 @@
# Use shell quotes when exporting this JSON value.
AIGW_API_KEYS=[{"key":"sk-local-change-me","key_id":"local-key","tenant_id":"tenant-demo","project_id":"project-default","scopes":["inference"]}]
+AIGW_SERVER_ADDRESS=:8080
+OPENAI_BASE_URL=https://api.openai.com/v1
OPENAI_API_KEY=replace-me
+ANTHROPIC_BASE_URL=https://api.anthropic.com/v1
ANTHROPIC_API_KEY=replace-me
-
+MOCK_UPSTREAM_BASE_URL=http://127.0.0.1:18080/v1
diff --git a/README.md b/README.md
index a266de9..dfec494 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,9 @@ AIGW 是一个轻量、无状态的 AI API 中转后端。当前阶段聚焦上æ
- PostgreSQL 预付余额、请求额度冻结、实际 token 结算和不可变账本
- Stripe 托管 Checkout 充值、签名 Webhook 与事件/订单双重幂等
- PostgreSQL Usage Ledger、月度项目汇总和单请求成本追溯
-- 注册/登录账号、数据库会话、CSRF 防护、六种 RBAC 角色和管理 API 审计日志
+- 邮箱验证、邀请注册、密码重置、登录限流、可撤销设备会话和管理 API 审计日志
+- TOTP(含一次性恢复码)与 WebAuthn Passkey 注册、二次验证和无密码登录
+- 六种 RBAC 角色、租户数据隔离和 CSRF 防护
- 项目级 RPM、估算 TPM、并发限制和月度消费配额
## 快速运行
@@ -28,7 +30,10 @@ AIGW 是一个轻量、无状态的 AI API 中转后端。当前阶段聚焦上æ
```bash
cp config.example.json config.json
export AIGW_API_KEYS='[{"key":"sk-local-change-me","key_id":"local-key","tenant_id":"tenant-demo","project_id":"project-default","scopes":["inference"]}]'
+export AIGW_SERVER_ADDRESS=':8080'
+export OPENAI_BASE_URL='https://api.openai.com/v1'
export OPENAI_API_KEY='your-upstream-key'
+export ANTHROPIC_BASE_URL='https://api.anthropic.com/v1'
export ANTHROPIC_API_KEY='your-upstream-key'
go run ./cmd/aigw -config config.json
```
@@ -43,6 +48,8 @@ go run ./cmd/mockupstream
# terminal 2
export MOCK_UPSTREAM_KEY='local-only'
+export MOCK_UPSTREAM_BASE_URL='http://127.0.0.1:18080/v1'
+export AIGW_SERVER_ADDRESS='127.0.0.1:18081'
export AIGW_API_KEYS='[{"key":"sk-local-change-me","key_id":"local-key","tenant_id":"tenant-demo","project_id":"project-default","scopes":["inference"]}]'
go run ./cmd/aigw -config config.local.json
```
@@ -109,17 +116,19 @@ curl http://127.0.0.1:8080/anthropic/v1/messages \
./scripts/start-debug.sh
```
-脚本会首次生成仅当前用户可读的 `.env.debug`,构建并等待 PostgreSQL、Redis 和网关健康,然后打印后台地址和管理员 token。关闭调试服务:
+脚本会首次生成仅当前用户可读的 `.env.debug`,其中包括随机 PostgreSQL 密码、凭证加密密钥和 bootstrap token;同时启动 Mailpit 作为本地 SMTP 收件箱。构建并等待 PostgreSQL、Redis 和网关健康后只打印后台与 Mailpit 地址和环境文件位置,不把密钥回显到终端。关闭调试服务:
```bash
./scripts/stop-debug.sh
```
-关闭脚本保留 PostgreSQL/Redis 数据卷,下一次启动仍可继续使用已有控制面数据。需要测试 Stripe Checkout 时,先把 `.env.debug` 中两个 Stripe 占位值替换成测试环境 restricted key 和 Webhook signing secret。
+关闭脚本保留 PostgreSQL/Redis 数据卷,下一次启动仍可继续使用已有控制面数据。需要测试 Stripe Checkout 时,把 `.env.debug` 中的 restricted key、Webhook signing secret、成功 URL 和取消 URL 设置为对应环境的值。JSON 配置只保存环境变量名称,不保存外部服务 URL 或密钥。
源码未变化时可跳过镜像构建以快速重启:`AIGW_DEBUG_SKIP_BUILD=1 ./scripts/start-debug.sh`。默认构建使用 Docker host network;特殊环境可以通过 `AIGW_DOCKER_BUILD_NETWORK=default` 覆盖。
-然后打开 `http://127.0.0.1:8080/admin/`。启用注册时,首次用户可直接创建组织和租户管理员账号;平台管理员也可以在 Operator 页面使用脚本打印的 `AIGW_ADMIN_TOKEN` 作为 bootstrap/break-glass 凭证。日常操作使用邮箱/密码登录,服务端创建可撤销的数据库会话,所有写请求需要 CSRF token。第一套资源的创建顺序是:Tenant → Project → API key → Provider → Model route。客户 API Key 明文只在创建成功时返回一次;团队成员使用自己的账号,不共享管理员令牌。
+然后打开 `http://127.0.0.1:8080/admin/`,本地邮件在 `http://127.0.0.1:8025/` 查看。启用注册时,新账号必须通过一次性邮件链接验证;团队成员由管理员邀请并自行设置密码。平台管理员也可以从权限为 `0600` 的环境文件读取 `AIGW_ADMIN_TOKEN`,将其作为 bootstrap/break-glass 凭证。日常操作使用邮箱/密码、TOTP 或 Passkey,服务端创建可逐设备撤销的数据库会话,所有写请求需要 CSRF token。第一套资源的创建顺序是:Tenant → Project → API key → Provider → Model route。客户 API Key 明文只在创建成功时返回一次;团队成员使用自己的账号,不共享管理员令牌。
+
+账号邮件先在 PostgreSQL outbox 中加密持久化,再由后台 worker 发送;SMTP 临时不可用不会回滚注册、邀请或重置请求,worker 会重试并记录失败。生产环境把 `AIGW_PUBLIC_URL` 设置为 HTTPS 控制台 URL,把 `AIGW_SMTP_ADDRESS`、`AIGW_SMTP_FROM_ADDRESS`、`AIGW_SMTP_USERNAME`、`AIGW_SMTP_PASSWORD` 通过密钥管理服务注入,并将 `admin.mail.tls_mode` 改为 `starttls` 或 `tls`。WebAuthn 的 `AIGW_WEBAUTHN_RP_ID` 必须是控制台有效域名,`AIGW_WEBAUTHN_ORIGINS` 是逗号分隔的 HTTPS origin。
控制台角色分为:`platform_admin`、`platform_viewer`、`tenant_admin`、`tenant_billing`、`tenant_developer`、`tenant_viewer`。租户角色的查询条件在服务端下推到 PostgreSQL,不能读取其他租户的项目、密钥、余额、Usage 或审计事件;供应商凭证和路由管理只对平台角色开放。
@@ -147,8 +156,9 @@ Stripe 使用托管 Checkout,服务端不会接触卡号,也没有硬编码æ
```bash
cp .env.control.example .env
# 将 AIGW_STRIPE_API_KEY 设为最小权限的 rk_test_ restricted key
+# AIGW_STRIPE_CLI_API_KEY 使用另一把仅有 Debugging Tools Write 的测试 key
# 本地转发会显示 whsec_...,填入 AIGW_STRIPE_WEBHOOK_SECRET
-stripe listen --forward-to http://127.0.0.1:8080/billing/stripe/webhook
+stripe listen --api-key "$AIGW_STRIPE_CLI_API_KEY" --forward-to http://127.0.0.1:8080/billing/stripe/webhook
docker compose up --build
```
@@ -161,15 +171,17 @@ Webhook 至少订阅:
当前没有默认启用 Stripe Tax,因为是否有有效税务注册不能由代码推断。确认注册和税务处理方案后再显式加入 `automatic_tax`。生产环境应把 Stripe restricted key 和 Webhook signing secret 放入云平台的密钥管理服务,并限制密钥权限和来源 IP,不要放进镜像或仓库。
+静态模式的上游 Base URL 与 API Key 分别使用 `base_url_env` 和 `api_key_env`;控制面、Redis、Stripe、SMTP、WebAuthn 和监听地址同样只通过环境变量或密钥管理服务注入。严格 JSON 解析会拒绝旧的 `address`、`base_url`、`success_url` 和 `cancel_url` 字面量字段,避免环境隔离被配置文件绕过。版本化配置只保存 `*_env` 名称,不保存外部服务密钥或部署域名。
+
生产环境建议把 `auto_migrate` 改为 `false`,先执行:
```bash
AIGW_DATABASE_URL="postgres://..." go run ./cmd/migrate
```
-管理 API 支持账号密码会话和仅用于初始化/故障恢复的 bootstrap token。即使已经启用 RBAC,管理监听端口仍应放在内网、VPN 或身份感知反向代理后;生产环境应关闭公开注册、设置 HTTPS、配置 MFA/OIDC,并把 bootstrap token 存入密钥管理服务。
+管理 API 支持账号密码、TOTP、Passkey 会话和仅用于初始化/故障恢复的 bootstrap token。即使已经启用 RBAC,生产环境仍应设置 HTTPS、把推理端口和管理端口分离、按需关闭公开注册,并把 bootstrap token 存入密钥管理服务;企业部署可再接 OIDC/SAML 与强制 MFA 策略。
-完整的扩展边界见 [架构说明](docs/architecture.md)。
+完整的扩展边界见 [架构说明](docs/architecture.md),与 ZenMux 模型目录对照后的上线缺口见 [商业化就绪清单](docs/commercial-readiness.md)。
## 验证
diff --git a/cmd/aigw/main.go b/cmd/aigw/main.go
index 44a22d0..c7d9ed3 100644
--- a/cmd/aigw/main.go
+++ b/cmd/aigw/main.go
@@ -20,9 +20,12 @@ import (
"aigw/internal/controlplane"
"aigw/internal/httpapi"
"aigw/internal/limits"
+ "aigw/internal/mailer"
"aigw/internal/provider"
"aigw/internal/routing"
"aigw/internal/telemetry"
+
+ "github.com/go-webauthn/webauthn/webauthn"
)
func main() {
@@ -60,6 +63,7 @@ func run(ctx context.Context, cfg config.Config, logger *slog.Logger) error {
var managerCancel context.CancelFunc
var managerDone chan struct{}
var requestLimiter *limits.Limiter
+ var webAuthn *webauthn.WebAuthn
if cfg.ControlPlane.Enabled {
var err error
store, err = controlplane.NewStore(ctx, controlplane.Options{
@@ -108,6 +112,33 @@ func run(ctx context.Context, cfg config.Config, logger *slog.Logger) error {
}
modelCatalog = catalog.New(cfg)
}
+ if cfg.Admin.Enabled && cfg.Admin.WebAuthn.Enabled {
+ configured, err := webauthn.New(&webauthn.Config{
+ RPDisplayName: cfg.Admin.WebAuthn.RPDisplayName,
+ RPID: cfg.Admin.WebAuthn.RPID,
+ RPOrigins: cfg.Admin.WebAuthn.Origins,
+ EncodeUserIDAsString: true,
+ Timeouts: webauthn.TimeoutsConfig{
+ Login: webauthn.TimeoutConfig{Enforce: true},
+ Registration: webauthn.TimeoutConfig{Enforce: true},
+ },
+ })
+ if err != nil {
+ return fmt.Errorf("configure WebAuthn: %w", err)
+ }
+ webAuthn = configured
+ }
+ if cfg.Admin.Enabled && cfg.Admin.Mail.Enabled {
+ sender, err := mailer.NewSender(mailer.SMTPConfig{
+ Address: cfg.Admin.Mail.SMTPAddress, Username: cfg.Admin.Mail.SMTPUsername,
+ Password: cfg.Admin.Mail.SMTPPassword, FromName: cfg.Admin.Mail.FromName,
+ FromAddress: cfg.Admin.Mail.FromAddress, TLSMode: cfg.Admin.Mail.TLSMode,
+ })
+ if err != nil {
+ return fmt.Errorf("configure SMTP: %w", err)
+ }
+ go mailer.NewWorker(store, sender, logger).Run(ctx)
+ }
var billingService *billing.Service
if cfg.Billing.Enabled {
@@ -151,6 +182,7 @@ func run(ctx context.Context, cfg config.Config, logger *slog.Logger) error {
Store: store, Manager: manager, Billing: billingService, Token: cfg.Admin.Token,
Logger: logger, Prefix: cfg.Admin.BasePath, RegistrationEnabled: cfg.Admin.RegistrationEnabled,
SessionTTL: time.Duration(cfg.Admin.SessionTTLHours) * time.Hour, Currency: cfg.Billing.Currency,
+ PublicURL: cfg.Admin.PublicURL, WebAuthn: webAuthn, MailEnabled: cfg.Admin.Mail.Enabled,
}).Handler()
root.Handle(cfg.Admin.BasePath, adminHandler)
root.Handle(cfg.Admin.BasePath+"/", adminHandler)
diff --git a/config.control.example.json b/config.control.example.json
index 2cad589..2b05636 100644
--- a/config.control.example.json
+++ b/config.control.example.json
@@ -1,6 +1,6 @@
{
"server": {
- "address": ":8080",
+ "address_env": "AIGW_SERVER_ADDRESS",
"max_body_bytes": 16777216,
"read_header_timeout_seconds": 10,
"idle_timeout_seconds": 120,
@@ -24,7 +24,23 @@
"token_env": "AIGW_ADMIN_TOKEN",
"base_path": "/admin",
"registration_enabled": true,
- "session_ttl_hours": 12
+ "session_ttl_hours": 12,
+ "public_url_env": "AIGW_PUBLIC_URL",
+ "mail": {
+ "enabled": true,
+ "from_name": "AIGW",
+ "from_address_env": "AIGW_SMTP_FROM_ADDRESS",
+ "smtp_address_env": "AIGW_SMTP_ADDRESS",
+ "smtp_username_env": "AIGW_SMTP_USERNAME",
+ "smtp_password_env": "AIGW_SMTP_PASSWORD",
+ "tls_mode": "none"
+ },
+ "webauthn": {
+ "enabled": true,
+ "rp_display_name": "AIGW Console",
+ "rp_id_env": "AIGW_WEBAUTHN_RP_ID",
+ "origins_env": "AIGW_WEBAUTHN_ORIGINS"
+ }
},
"billing": {
"enabled": true,
@@ -36,8 +52,8 @@
"enabled": true,
"api_key_env": "AIGW_STRIPE_API_KEY",
"webhook_secret_env": "AIGW_STRIPE_WEBHOOK_SECRET",
- "success_url": "http://127.0.0.1:8080/admin/?topup=success",
- "cancel_url": "http://127.0.0.1:8080/admin/?topup=cancel"
+ "success_url_env": "AIGW_STRIPE_SUCCESS_URL",
+ "cancel_url_env": "AIGW_STRIPE_CANCEL_URL"
}
},
"upstream_http": {
diff --git a/config.example.json b/config.example.json
index fbf677d..8ac2d0c 100644
--- a/config.example.json
+++ b/config.example.json
@@ -1,6 +1,6 @@
{
"server": {
- "address": ":8080",
+ "address_env": "AIGW_SERVER_ADDRESS",
"max_body_bytes": 16777216,
"read_header_timeout_seconds": 10,
"idle_timeout_seconds": 120,
@@ -24,13 +24,13 @@
{
"id": "openai-primary",
"protocol": "openai",
- "base_url": "https://api.openai.com/v1",
+ "base_url_env": "OPENAI_BASE_URL",
"api_key_env": "OPENAI_API_KEY"
},
{
"id": "anthropic-primary",
"protocol": "anthropic",
- "base_url": "https://api.anthropic.com/v1",
+ "base_url_env": "ANTHROPIC_BASE_URL",
"api_key_env": "ANTHROPIC_API_KEY"
}
],
@@ -61,4 +61,3 @@
}
]
}
-
diff --git a/config.local.json b/config.local.json
index d4273b1..74a324f 100644
--- a/config.local.json
+++ b/config.local.json
@@ -1,6 +1,6 @@
{
"server": {
- "address": "127.0.0.1:18081",
+ "address_env": "AIGW_SERVER_ADDRESS",
"max_body_bytes": 16777216
},
"auth": {
@@ -20,13 +20,13 @@
{
"id": "local-openai",
"protocol": "openai",
- "base_url": "http://127.0.0.1:18080/v1",
+ "base_url_env": "MOCK_UPSTREAM_BASE_URL",
"api_key_env": "MOCK_UPSTREAM_KEY"
},
{
"id": "local-anthropic",
"protocol": "anthropic",
- "base_url": "http://127.0.0.1:18080/v1",
+ "base_url_env": "MOCK_UPSTREAM_BASE_URL",
"api_key_env": "MOCK_UPSTREAM_KEY"
}
],
@@ -47,4 +47,3 @@
}
]
}
-
diff --git a/docker-compose.yml b/docker-compose.yml
index e2dde2a..0fc531b 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -2,15 +2,15 @@ services:
postgres:
image: postgres:16-alpine
environment:
- POSTGRES_USER: aigw
- POSTGRES_PASSWORD: aigw
- POSTGRES_DB: aigw
+ POSTGRES_USER: ${AIGW_POSTGRES_USER:?set AIGW_POSTGRES_USER}
+ POSTGRES_PASSWORD: ${AIGW_POSTGRES_PASSWORD:?set AIGW_POSTGRES_PASSWORD}
+ POSTGRES_DB: ${AIGW_POSTGRES_DB:?set AIGW_POSTGRES_DB}
ports:
- "127.0.0.1:5432:5432"
volumes:
- aigw-postgres:/var/lib/postgresql/data
healthcheck:
- test: ["CMD-SHELL", "pg_isready -U aigw -d aigw"]
+ test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 3s
retries: 20
@@ -27,21 +27,44 @@ services:
timeout: 3s
retries: 20
+ mailpit:
+ image: axllent/mailpit:v1.27
+ ports:
+ - "127.0.0.1:8025:8025"
+ - "127.0.0.1:1025:1025"
+ healthcheck:
+ test: ["CMD", "/mailpit", "readyz"]
+ interval: 5s
+ timeout: 3s
+ retries: 20
+
aigw:
image: aigw-debug:local
build: .
depends_on:
postgres:
condition: service_healthy
+ mailpit:
+ condition: service_healthy
ports:
- "127.0.0.1:8080:8080"
environment:
- AIGW_DATABASE_URL: postgres://aigw:aigw@postgres:5432/aigw?sslmode=disable
- AIGW_REDIS_URL: redis://redis:6379/0
+ AIGW_SERVER_ADDRESS: ${AIGW_SERVER_ADDRESS:?set AIGW_SERVER_ADDRESS}
+ AIGW_DATABASE_URL: ${AIGW_DATABASE_URL_DOCKER:?set AIGW_DATABASE_URL_DOCKER}
+ AIGW_REDIS_URL: ${AIGW_REDIS_URL_DOCKER:-}
AIGW_CREDENTIAL_KEY: ${AIGW_CREDENTIAL_KEY:?set AIGW_CREDENTIAL_KEY}
AIGW_ADMIN_TOKEN: ${AIGW_ADMIN_TOKEN:?set AIGW_ADMIN_TOKEN}
+ AIGW_PUBLIC_URL: ${AIGW_PUBLIC_URL:?set AIGW_PUBLIC_URL}
+ AIGW_WEBAUTHN_RP_ID: ${AIGW_WEBAUTHN_RP_ID:?set AIGW_WEBAUTHN_RP_ID}
+ AIGW_WEBAUTHN_ORIGINS: ${AIGW_WEBAUTHN_ORIGINS:?set AIGW_WEBAUTHN_ORIGINS}
+ AIGW_SMTP_FROM_ADDRESS: ${AIGW_SMTP_FROM_ADDRESS:?set AIGW_SMTP_FROM_ADDRESS}
+ AIGW_SMTP_ADDRESS: ${AIGW_SMTP_ADDRESS_DOCKER:?set AIGW_SMTP_ADDRESS_DOCKER}
+ AIGW_SMTP_USERNAME: ${AIGW_SMTP_USERNAME:-}
+ AIGW_SMTP_PASSWORD: ${AIGW_SMTP_PASSWORD:-}
AIGW_STRIPE_API_KEY: ${AIGW_STRIPE_API_KEY:?set AIGW_STRIPE_API_KEY}
AIGW_STRIPE_WEBHOOK_SECRET: ${AIGW_STRIPE_WEBHOOK_SECRET:?set AIGW_STRIPE_WEBHOOK_SECRET}
+ AIGW_STRIPE_SUCCESS_URL: ${AIGW_STRIPE_SUCCESS_URL:?set AIGW_STRIPE_SUCCESS_URL}
+ AIGW_STRIPE_CANCEL_URL: ${AIGW_STRIPE_CANCEL_URL:?set AIGW_STRIPE_CANCEL_URL}
volumes:
- ./config.control.example.json:/etc/aigw/config.json:ro
healthcheck:
diff --git a/docs/architecture.md b/docs/architecture.md
index 1193151..c17c65e 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -42,7 +42,7 @@ flowchart LR
| 边界 | 当前实现 | 下一阶段替换 |
| --- | --- | --- |
| 客户身份 | PostgreSQL 快照、内存 SHA-256 索引 | SSO/OIDC、SCIM、模型 allowlist |
-| 控制台权限 | 注册/密码登录、数据库会话、CSRF、六角色 RBAC、租户 SQL scope、审计日志 | SSO/OIDC、MFA、细粒度自定义角色、审批流 |
+| 控制台权限 | 邮箱验证/邀请/重置、登录限流、设备会话、TOTP/恢复码、Passkey、CSRF、六角色 RBAC、租户 SQL scope、审计日志 | SSO/OIDC、SCIM、组织级 MFA 策略、自定义角色、审批流 |
| 权限 | `Principal.Scopes` 中的 `inference` + 控制台 RBAC | ABAC、IP 与模型策略 |
| 模型目录 | PostgreSQL 快照 + 可选 Redis generation 广播 + PG 轮询兜底 | 版本化控制面、热更新、灰度发布 |
| 路由 | priority + weighted selection + failover | 健康评分、延迟 EWMA、成本/质量策略、熔断 |
@@ -69,13 +69,13 @@ Stripe 充值使用 Checkout Session:本地先创建 top-up order,Stripe 请
## 管理面安全
-bootstrap token 只映射为 `platform_admin`,用于首次建号和故障恢复,不是日常用户凭证。租户注册在同一 PostgreSQL 事务内创建租户、默认项目、钱包和 `tenant_admin` 账号;密码使用 PBKDF2-HMAC-SHA-256 哈希,服务端只保存盐和摘要。登录创建 HttpOnly、SameSite 会话 Cookie,并为所有写请求校验独立 CSRF Cookie/header;改密和撤销成员会话会立即失效旧会话。平台角色没有 `tenant_id`,租户角色必须绑定一个 tenant。所有管理 API 在 handler 执行前校验 permission,租户过滤在 SQL 查询或资源所有权检查中完成,前端隐藏菜单不承担安全职责。
+bootstrap token 只映射为 `platform_admin`,用于首次建号和故障恢复,不是日常用户凭证。租户注册在同一 PostgreSQL 事务内创建租户、默认项目、钱包和待验证的 `tenant_admin` 账号;邮件动作使用仅保存摘要的一次性 token,邮件正文在 outbox 中加密。密码使用 PBKDF2-HMAC-SHA-256 哈希,TOTP secret、Passkey credential 和 WebAuthn challenge 使用 AES-256-GCM 加密。登录创建 HttpOnly、SameSite 会话 Cookie,并为所有写请求校验独立 CSRF Cookie/header;改密、密码重置和撤销成员会立即失效旧会话。平台角色没有 `tenant_id`,租户角色必须绑定一个 tenant。所有管理 API 在 handler 执行前校验 permission,租户过滤在 SQL 查询或资源所有权检查中完成,前端隐藏菜单不承担安全职责。
每个通过认证的管理请求都写入 `audit_logs`,包含 actor、角色、tenant、action、状态码、请求 ID、IP 和 User-Agent。审计写入失败不会回滚已成功的资源事务,但会输出结构化告警。
## 建议的后续顺序
-1. 将管理监听端口与公网推理端口分离,并接入 OIDC/SSO、MFA 与短期会话。
+1. 将管理监听端口与公网推理端口分离,并为企业客户接入 OIDC/SAML、SCIM 与组织级强制 MFA 策略。
2. 增加价格版本、退款/冲正、Stripe dispute 处理和供应商日账单对账。
3. 为不返回 usage 的上游增加可靠 token 计算器,并监控 `uncollected_micros`。
4. 把 UsageEvent 做时间分区和归档,增加 CSV 导出与对账作业。
diff --git a/docs/commercial-readiness.md b/docs/commercial-readiness.md
new file mode 100644
index 0000000..b78ae2d
--- /dev/null
+++ b/docs/commercial-readiness.md
@@ -0,0 +1,87 @@
+# Commercial readiness gap analysis
+
+This document compares the current gateway and console with the customer-facing
+model catalog at `https://zenmux.ai/models?sort=newest`. It separates working
+capabilities from product gaps so an unfinished control is never presented as a
+commercial feature.
+
+## What works now
+
+- OpenAI Chat Completions and Anthropic Messages proxying, streaming, routing,
+ retry, authentication, persistent usage, prepaid billing, quotas, rate limits,
+ concurrent request limits, RBAC, audit logs, and a PostgreSQL-backed console.
+- Stripe-hosted Checkout with signed, idempotent Webhook crediting. The gateway
+ never accepts card details and never credits a success redirect.
+- PostgreSQL is the source of truth. Redis accelerates invalidation and shared
+ counters but is not required for startup, control-plane writes, billing, or
+ balance correctness.
+- Verified self-service registration, invitation acceptance, password reset,
+ persistent login throttles, per-device session revocation, encrypted email
+ outbox delivery, TOTP with recovery codes, and WebAuthn Passkeys.
+
+## Customer product gaps
+
+### P0 before a public commercial launch
+
+- Production transactional-email provider selection, domain authentication,
+ bounce/complaint handling, low-balance notifications, and monitoring for the
+ durable mail outbox. Local development currently uses Mailpit.
+- A public model catalog and detail page containing provider/developer, release
+ and retirement dates, input/output modalities, context and maximum output,
+ supported parameters and protocols, regional availability, and versioned price
+ dimensions.
+- Tenant and API-key model allowlists, budget alerts, low-balance notifications,
+ downloadable invoices/receipts, payment history, refunds/disputes operations,
+ and explicit tax handling after registrations are confirmed.
+- Operational separation of the public inference listener from the management
+ listener, HTTPS-only cookies behind a trusted proxy, backup/restore drills,
+ migration rollback policy, secret rotation, and alerting for usage settlement
+ or Webhook backlogs.
+- A durable settlement retry/outbox and reconciliation worker. Persistence and
+ balance settlement currently use bounded synchronous database calls after the
+ response; a database timeout is logged but is not queued for retry, so a long
+ outage can leave reservations pending or usage unbilled.
+
+### P1 for ZenMux-like breadth
+
+- OpenAI Responses, Embeddings, Images, Speech and Transcriptions; Gemini native
+ APIs; rerank and other media endpoints. The existing protocol field does not
+ make these APIs implemented.
+- Provider health measurements per model and route: availability, first-token
+ latency, throughput, error history, health-aware routing, and customer-visible
+ status history.
+- Provider comparison and price ranges, cache/search/image/audio pricing units,
+ lifecycle aliases and deprecation notices, searchable filters, release sorting,
+ SDK examples, and copyable endpoint snippets.
+- Usage exports, cost attribution, budgets, scheduled reports, organization
+ invites, custom roles, OIDC/SAML SSO, SCIM, and support impersonation with
+ approval and full audit evidence.
+
+## Environment boundary
+
+Versioned JSON configuration stores only environment variable names for external
+services. Local values live in `.env.debug`, which is ignored by Git and created
+with mode `0600`.
+
+| Integration value | Environment variable |
+| --- | --- |
+| HTTP listen address | `AIGW_SERVER_ADDRESS` |
+| PostgreSQL URLs | `AIGW_DATABASE_URL`, `AIGW_DATABASE_URL_DOCKER` |
+| Redis URLs | `AIGW_REDIS_URL`, `AIGW_REDIS_URL_DOCKER` |
+| Provider credential encryption | `AIGW_CREDENTIAL_KEY` |
+| Bootstrap administrator | `AIGW_ADMIN_TOKEN` |
+| Stripe application key | `AIGW_STRIPE_API_KEY` |
+| Stripe CLI development key | `AIGW_STRIPE_CLI_API_KEY` |
+| Stripe Webhook signing secret | `AIGW_STRIPE_WEBHOOK_SECRET` |
+| Stripe result URLs | `AIGW_STRIPE_SUCCESS_URL`, `AIGW_STRIPE_CANCEL_URL` |
+| Console public URL | `AIGW_PUBLIC_URL` |
+| SMTP endpoint/sender | `AIGW_SMTP_ADDRESS`, `AIGW_SMTP_FROM_ADDRESS` |
+| SMTP credentials | `AIGW_SMTP_USERNAME`, `AIGW_SMTP_PASSWORD` |
+| WebAuthn RP/origins | `AIGW_WEBAUTHN_RP_ID`, `AIGW_WEBAUTHN_ORIGINS` |
+| Static upstream endpoint/key | provider `base_url_env`, `api_key_env` |
+
+External service values are never embedded in versioned JSON. When mail is
+enabled, startup validates the sender and SMTP endpoint; username/password must
+either both be provided or both be empty. Production should use authenticated
+`starttls` or implicit `tls`; the checked-in local example uses unauthenticated
+Mailpit with `tls_mode: none`.
diff --git a/go.mod b/go.mod
index 7ab901a..99ebf01 100644
--- a/go.mod
+++ b/go.mod
@@ -3,17 +3,31 @@ module aigw
go 1.26
require (
+ github.com/go-webauthn/webauthn v0.17.4
github.com/jackc/pgx/v5 v5.10.0
+ github.com/pquerna/otp v1.5.0
github.com/redis/go-redis/v9 v9.21.0
github.com/stripe/stripe-go/v86 v86.2.0
)
require (
+ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/fxamacker/cbor/v2 v2.9.2 // indirect
+ github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
+ github.com/go-webauthn/x v0.2.6 // indirect
+ github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
+ github.com/google/go-tpm v0.9.8 // indirect
+ github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
+ github.com/philhofer/fwd v1.2.0 // indirect
+ github.com/tinylib/msgp v1.6.4 // indirect
+ github.com/x448/float16 v0.8.4 // indirect
go.uber.org/atomic v1.11.0 // indirect
- golang.org/x/sync v0.17.0 // indirect
- golang.org/x/text v0.29.0 // indirect
+ golang.org/x/crypto v0.52.0 // indirect
+ golang.org/x/sync v0.20.0 // indirect
+ golang.org/x/sys v0.45.0 // indirect
+ golang.org/x/text v0.37.0 // indirect
)
diff --git a/go.sum b/go.sum
index 4a3b753..66e63de 100644
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,5 @@
+github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
+github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@@ -7,6 +9,22 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
+github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
+github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
+github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
+github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
+github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
+github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
+github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
+github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -17,8 +35,12 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
+github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
+github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
+github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E=
github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -28,16 +50,24 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stripe/stripe-go/v86 v86.2.0 h1:d6yW7JDnk03DEVGqct82j0rys8nPPCE3+RDPsIvUcrY=
github.com/stripe/stripe-go/v86 v86.2.0/go.mod h1:Co7QRXCKGNOPTugAdvjgRo+KcMtd9hxy+pZMN0yThsQ=
+github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
+github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
+github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
+github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
-golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
-golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
-golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
-golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
-golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
+go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
+go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
+golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
+golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
+golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
+golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
diff --git a/internal/adminapi/api.go b/internal/adminapi/api.go
index adbaea8..4e66d7f 100644
--- a/internal/adminapi/api.go
+++ b/internal/adminapi/api.go
@@ -1,6 +1,7 @@
package adminapi
import (
+ "bytes"
"context"
"crypto/rand"
"crypto/sha256"
@@ -8,6 +9,7 @@ import (
"encoding/hex"
"encoding/json"
"errors"
+ "io"
"log/slog"
"net"
"net/http"
@@ -19,6 +21,7 @@ import (
"aigw/internal/billing"
"aigw/internal/controlplane"
+ "github.com/go-webauthn/webauthn/webauthn"
"github.com/jackc/pgx/v5/pgconn"
)
@@ -32,6 +35,9 @@ type API struct {
registrationEnabled bool
sessionTTL time.Duration
currency string
+ publicURL string
+ webauthn *webauthn.WebAuthn
+ mailEnabled bool
}
type actorKey struct{}
@@ -61,6 +67,9 @@ type Options struct {
RegistrationEnabled bool
SessionTTL time.Duration
Currency string
+ PublicURL string
+ WebAuthn *webauthn.WebAuthn
+ MailEnabled bool
}
func New(options Options) *API {
@@ -79,22 +88,47 @@ func New(options Options) *API {
}
return &API{store: options.Store, manager: options.Manager, billing: options.Billing, token: []byte(options.Token),
logger: options.Logger, prefix: prefix, registrationEnabled: options.RegistrationEnabled,
- sessionTTL: options.SessionTTL, currency: options.Currency}
+ sessionTTL: options.SessionTTL, currency: options.Currency, publicURL: strings.TrimRight(options.PublicURL, "/") + "/",
+ webauthn: options.WebAuthn, mailEnabled: options.MailEnabled}
}
func (a *API) Handler() http.Handler {
mux := http.NewServeMux()
apiPrefix := a.prefix + "/api"
mux.HandleFunc("GET "+a.prefix, func(w http.ResponseWriter, r *http.Request) {
- http.Redirect(w, r, a.prefix+"/", http.StatusTemporaryRedirect)
+ target := a.prefix + "/"
+ if r.URL.RawQuery != "" {
+ target += "?" + r.URL.RawQuery
+ }
+ http.Redirect(w, r, target, http.StatusTemporaryRedirect)
})
mux.Handle(a.prefix+"/", http.StripPrefix(a.prefix, adminui.Handler()))
mux.HandleFunc("GET "+apiPrefix+"/auth/config", a.public(a.authConfig))
mux.HandleFunc("GET "+apiPrefix+"/auth/session", a.public(a.authSession))
mux.HandleFunc("POST "+apiPrefix+"/auth/register", a.public(a.register))
mux.HandleFunc("POST "+apiPrefix+"/auth/login", a.public(a.login))
+ mux.HandleFunc("POST "+apiPrefix+"/auth/email/verify", a.public(a.verifyEmail))
+ mux.HandleFunc("POST "+apiPrefix+"/auth/email/resend", a.public(a.resendVerification))
+ mux.HandleFunc("POST "+apiPrefix+"/auth/password-reset/request", a.public(a.requestPasswordReset))
+ mux.HandleFunc("POST "+apiPrefix+"/auth/password-reset/complete", a.public(a.completePasswordReset))
+ mux.HandleFunc("POST "+apiPrefix+"/auth/invite/accept", a.public(a.acceptInvite))
+ mux.HandleFunc("POST "+apiPrefix+"/auth/mfa/totp", a.public(a.completeMFA))
+ mux.HandleFunc("POST "+apiPrefix+"/auth/passkey/login/options", a.public(a.passkeyLoginOptions))
+ mux.HandleFunc("POST "+apiPrefix+"/auth/passkey/login", a.public(a.passkeyLogin))
+ mux.HandleFunc("POST "+apiPrefix+"/auth/mfa/passkey/login/options", a.public(a.beginPasskeyMFA))
+ mux.HandleFunc("POST "+apiPrefix+"/auth/mfa/passkey/login", a.public(a.finishPasskeyMFA))
mux.HandleFunc("POST "+apiPrefix+"/auth/logout", a.withAuth("overview.read", a.logout))
mux.HandleFunc("POST "+apiPrefix+"/auth/password", a.withAuth("overview.read", a.changePassword))
+ mux.HandleFunc("GET "+apiPrefix+"/auth/mfa", a.withAuth("overview.read", a.mfaStatus))
+ mux.HandleFunc("POST "+apiPrefix+"/auth/mfa/totp/begin", a.withAuth("overview.read", a.beginTOTP))
+ mux.HandleFunc("POST "+apiPrefix+"/auth/mfa/totp/confirm", a.withAuth("overview.read", a.confirmTOTP))
+ mux.HandleFunc("POST "+apiPrefix+"/auth/mfa/totp/disable", a.withAuth("overview.read", a.disableTOTP))
+ mux.HandleFunc("POST "+apiPrefix+"/auth/mfa/passkey/options", a.withAuth("overview.read", a.beginPasskeyRegistration))
+ mux.HandleFunc("POST "+apiPrefix+"/auth/mfa/passkey", a.withAuth("overview.read", a.finishPasskeyRegistration))
+ mux.HandleFunc("GET "+apiPrefix+"/auth/sessions", a.withAuth("overview.read", a.listSessions))
+ mux.HandleFunc("POST "+apiPrefix+"/auth/sessions/{id}/revoke", a.withAuth("overview.read", a.revokeSession))
+ mux.HandleFunc("POST "+apiPrefix+"/auth/sessions/revoke-others", a.withAuth("overview.read", a.revokeOtherSessions))
+ mux.HandleFunc("POST "+apiPrefix+"/auth/passkeys/{id}/delete", a.withAuth("overview.read", a.deletePasskey))
mux.HandleFunc("GET "+apiPrefix+"/overview", a.withAuth("overview.read", a.overview))
mux.HandleFunc("GET "+apiPrefix+"/tenants", a.withAuth("tenants.read", a.listTenants))
@@ -114,6 +148,8 @@ func (a *API) Handler() http.Handler {
if a.billing != nil {
mux.HandleFunc("GET "+apiPrefix+"/billing/accounts", a.withAuth("billing.read", a.listBillingAccounts))
mux.HandleFunc("GET "+apiPrefix+"/billing/ledger", a.withAuth("billing.read", a.listBillingLedger))
+ mux.HandleFunc("GET "+apiPrefix+"/billing/orders", a.withAuth("billing.read", a.listTopUpOrders))
+ mux.HandleFunc("GET "+apiPrefix+"/billing/orders/{id}", a.withAuth("billing.read", a.getTopUpOrder))
mux.HandleFunc("POST "+apiPrefix+"/billing/adjustments", a.withAuth("billing.adjust", a.adjustBalance))
mux.HandleFunc("POST "+apiPrefix+"/billing/checkout-sessions", a.withAuth("billing.topup", a.createCheckoutSession))
}
@@ -149,7 +185,7 @@ func (a *API) securityHeaders(next http.Handler) http.Handler {
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
- w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()")
+ w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), publickey-credentials-create=(self), publickey-credentials-get=(self)")
if strings.Contains(r.URL.Path, "/api/") {
w.Header().Set("Cache-Control", "no-store")
}
@@ -239,7 +275,8 @@ func adminRequestID(r *http.Request) string {
}
func (a *API) authConfig(w http.ResponseWriter, _ *http.Request) {
- writeJSON(w, map[string]any{"registration_enabled": a.registrationEnabled})
+ writeJSON(w, map[string]any{"registration_enabled": a.registrationEnabled, "email_delivery_enabled": a.mailEnabled,
+ "passkeys_enabled": a.webauthn != nil})
}
func (a *API) authSession(w http.ResponseWriter, r *http.Request) {
@@ -270,7 +307,11 @@ func (a *API) register(w http.ResponseWriter, r *http.Request) {
if !decodeBody(w, r, &input) {
return
}
- actor, generation, err := a.store.RegisterTenant(r.Context(), input, a.currency)
+ if !a.consumePublicRateLimit(w, r, "register_ip", remoteIP(r), 5, time.Hour) ||
+ !a.consumePublicRateLimit(w, r, "register_email", input.Email, 3, 24*time.Hour) {
+ return
+ }
+ actor, generation, err := a.store.RegisterTenantPending(r.Context(), input, a.currency, a.publicURL, remoteIP(r))
if err != nil {
a.writeAudit(r, controlplane.ConsoleActor{}, "auth.register", http.StatusBadRequest)
a.mutationError(w, r, err)
@@ -281,14 +322,9 @@ func (a *API) register(w http.ResponseWriter, r *http.Request) {
a.logger.Warn("registration_snapshot_reload_failed", "tenant_id", actor.TenantID, "error", err)
}
}
- session, err := a.store.CreateConsoleSession(r.Context(), actor, a.sessionTTL, remoteIP(r), r.UserAgent())
- if err != nil {
- a.databaseError(w, r, err)
- return
- }
- a.setSessionCookie(w, r, session)
a.writeAudit(r, actor, "auth.register", http.StatusCreated)
- writeStatusJSON(w, http.StatusCreated, sessionPayload(session))
+ writeStatusJSON(w, http.StatusCreated, map[string]any{"status": "verification_required", "email": actor.Email,
+ "delivery": map[bool]string{true: "queued", false: "pending_configuration"}[a.mailEnabled]})
}
func (a *API) login(w http.ResponseWriter, r *http.Request) {
@@ -316,7 +352,18 @@ func (a *API) login(w http.ResponseWriter, r *http.Request) {
apierror.Write(w, apierror.Error{Status: status, Type: typeName, Message: message}, requestID(r))
return
}
- session, err := a.store.CreateConsoleSession(r.Context(), actor, a.sessionTTL, remoteIP(r), r.UserAgent())
+ if len(actor.MFAMethods) > 0 {
+ challenge, err := a.store.BeginMFAChallenge(r.Context(), actor, remoteIP(r), r.UserAgent())
+ if err != nil {
+ a.databaseError(w, r, err)
+ return
+ }
+ a.writeAudit(r, actor, "auth.login.password", http.StatusAccepted)
+ writeStatusJSON(w, http.StatusAccepted, map[string]any{"mfa_required": true, "challenge_token": challenge.Token,
+ "methods": challenge.Methods, "expires_at": challenge.ExpiresAt})
+ return
+ }
+ session, err := a.store.CreateConsoleSessionWithMethod(r.Context(), actor, a.sessionTTL, remoteIP(r), r.UserAgent(), "password", false)
if err != nil {
a.databaseError(w, r, err)
return
@@ -326,6 +373,218 @@ func (a *API) login(w http.ResponseWriter, r *http.Request) {
writeJSON(w, sessionPayload(session))
}
+func (a *API) verifyEmail(w http.ResponseWriter, r *http.Request) {
+ var input struct {
+ Token string `json:"token"`
+ }
+ if !decodeBody(w, r, &input) {
+ return
+ }
+ actor, err := a.store.VerifyEmailToken(r.Context(), input.Token)
+ if err != nil {
+ a.actionError(w, r, err)
+ return
+ }
+ session, err := a.store.CreateConsoleSessionWithMethod(r.Context(), actor, a.sessionTTL, remoteIP(r), r.UserAgent(), "email_link", false)
+ if err != nil {
+ a.databaseError(w, r, err)
+ return
+ }
+ a.setSessionCookie(w, r, session)
+ a.writeAudit(r, actor, "auth.email.verify", http.StatusOK)
+ writeJSON(w, sessionPayload(session))
+}
+
+func (a *API) resendVerification(w http.ResponseWriter, r *http.Request) {
+ var input struct {
+ Email string `json:"email"`
+ }
+ if !decodeBody(w, r, &input) {
+ return
+ }
+ if !a.consumePublicRateLimit(w, r, "verify_resend_ip", remoteIP(r), 10, time.Hour) ||
+ !a.consumePublicRateLimit(w, r, "verify_resend_email", input.Email, 3, time.Hour) {
+ return
+ }
+ if err := a.store.ResendVerification(r.Context(), input.Email, a.publicURL, remoteIP(r)); err != nil {
+ a.databaseError(w, r, err)
+ return
+ }
+ writeStatusJSON(w, http.StatusAccepted, map[string]any{"status": "queued"})
+}
+
+func (a *API) requestPasswordReset(w http.ResponseWriter, r *http.Request) {
+ var input struct {
+ Email string `json:"email"`
+ }
+ if !decodeBody(w, r, &input) {
+ return
+ }
+ if !a.consumePublicRateLimit(w, r, "password_reset_ip", remoteIP(r), 20, time.Hour) ||
+ !a.consumePublicRateLimit(w, r, "password_reset_email", input.Email, 3, time.Hour) {
+ return
+ }
+ if err := a.store.RequestPasswordReset(r.Context(), input.Email, a.publicURL, remoteIP(r)); err != nil {
+ a.databaseError(w, r, err)
+ return
+ }
+ writeStatusJSON(w, http.StatusAccepted, map[string]any{"status": "queued"})
+}
+
+func (a *API) completePasswordReset(w http.ResponseWriter, r *http.Request) {
+ var input controlplane.PasswordResetInput
+ if !decodeBody(w, r, &input) {
+ return
+ }
+ if err := a.store.ResetPassword(r.Context(), input); err != nil {
+ a.actionError(w, r, err)
+ return
+ }
+ writeJSON(w, map[string]any{"status": "password_reset", "reauthentication_required": true})
+}
+
+func (a *API) acceptInvite(w http.ResponseWriter, r *http.Request) {
+ var input controlplane.InviteAcceptInput
+ if !decodeBody(w, r, &input) {
+ return
+ }
+ actor, err := a.store.AcceptInvite(r.Context(), input)
+ if err != nil {
+ a.actionError(w, r, err)
+ return
+ }
+ session, err := a.store.CreateConsoleSessionWithMethod(r.Context(), actor, a.sessionTTL, remoteIP(r), r.UserAgent(), "invite_link", false)
+ if err != nil {
+ a.databaseError(w, r, err)
+ return
+ }
+ a.setSessionCookie(w, r, session)
+ a.writeAudit(r, actor, "auth.invite.accept", http.StatusOK)
+ writeJSON(w, sessionPayload(session))
+}
+
+func (a *API) completeMFA(w http.ResponseWriter, r *http.Request) {
+ var input controlplane.MFACodeInput
+ if !decodeBody(w, r, &input) {
+ return
+ }
+ actor, method, err := a.store.CompleteMFAChallenge(r.Context(), input, remoteIP(r))
+ if err != nil {
+ a.actionError(w, r, err)
+ return
+ }
+ session, err := a.store.CreateConsoleSessionWithMethod(r.Context(), actor, a.sessionTTL, remoteIP(r), r.UserAgent(), "password+"+method, true)
+ if err != nil {
+ a.databaseError(w, r, err)
+ return
+ }
+ a.setSessionCookie(w, r, session)
+ a.writeAudit(r, actor, "auth.login."+method, http.StatusOK)
+ writeJSON(w, sessionPayload(session))
+}
+
+func (a *API) passkeyLoginOptions(w http.ResponseWriter, r *http.Request) {
+ if !a.requireWebAuthn(w, r) {
+ return
+ }
+ var input struct {
+ Email string `json:"email"`
+ }
+ if !decodeBody(w, r, &input) {
+ return
+ }
+ if !a.consumePublicRateLimit(w, r, "passkey_login_ip", remoteIP(r), 30, 15*time.Minute) ||
+ !a.consumePublicRateLimit(w, r, "passkey_login_email", input.Email, 10, 15*time.Minute) {
+ return
+ }
+ actor, err := a.store.FindActiveConsoleUser(r.Context(), input.Email)
+ if err != nil {
+ a.passkeyAuthError(w, r, err)
+ return
+ }
+ token, options, err := a.store.BeginWebAuthnLogin(r.Context(), actor.ID, "login", "", a.webauthn)
+ if err != nil {
+ a.passkeyAuthError(w, r, err)
+ return
+ }
+ writeJSON(w, map[string]any{"challenge_token": token, "options": options})
+}
+
+func (a *API) passkeyLogin(w http.ResponseWriter, r *http.Request) {
+ a.finishPasskeyLogin(w, r, "login", "passkey")
+}
+
+func (a *API) beginPasskeyMFA(w http.ResponseWriter, r *http.Request) {
+ if !a.requireWebAuthn(w, r) {
+ return
+ }
+ var input struct {
+ ChallengeToken string `json:"challenge_token"`
+ }
+ if !decodeBody(w, r, &input) {
+ return
+ }
+ authChallengeID, userID, err := a.store.ResolveMFAChallenge(r.Context(), input.ChallengeToken, remoteIP(r))
+ if err != nil {
+ a.actionError(w, r, err)
+ return
+ }
+ token, options, err := a.store.BeginWebAuthnLogin(r.Context(), userID, "mfa_login", authChallengeID, a.webauthn)
+ if err != nil {
+ a.passkeyAuthError(w, r, err)
+ return
+ }
+ writeJSON(w, map[string]any{"challenge_token": token, "options": options})
+}
+
+func (a *API) finishPasskeyMFA(w http.ResponseWriter, r *http.Request) {
+ a.finishPasskeyLogin(w, r, "mfa_login", "password+passkey")
+}
+
+type webAuthnFinishInput struct {
+ ChallengeToken string `json:"challenge_token"`
+ Name string `json:"name"`
+ Credential json.RawMessage `json:"credential"`
+}
+
+func (a *API) finishPasskeyLogin(w http.ResponseWriter, r *http.Request, purpose, authMethod string) {
+ if !a.requireWebAuthn(w, r) {
+ return
+ }
+ var input webAuthnFinishInput
+ if !decodeBody(w, r, &input) {
+ return
+ }
+ userID, authChallengeID, sessionData, err := a.store.WebAuthnSession(r.Context(), input.ChallengeToken, purpose)
+ if err != nil {
+ a.actionError(w, r, err)
+ return
+ }
+ user, err := a.store.WebAuthnUser(r.Context(), userID)
+ if err != nil {
+ a.passkeyAuthError(w, r, err)
+ return
+ }
+ credential, err := a.webauthn.FinishLogin(user, sessionData, credentialRequest(r, input.Credential))
+ if err != nil {
+ a.passkeyAuthError(w, r, err)
+ return
+ }
+ actor, err := a.store.FinishWebAuthnLogin(r.Context(), input.ChallengeToken, userID, authChallengeID, credential, purpose)
+ if err != nil {
+ a.passkeyAuthError(w, r, err)
+ return
+ }
+ session, err := a.store.CreateConsoleSessionWithMethod(r.Context(), actor, a.sessionTTL, remoteIP(r), r.UserAgent(), authMethod, true)
+ if err != nil {
+ a.databaseError(w, r, err)
+ return
+ }
+ a.setSessionCookie(w, r, session)
+ a.writeAudit(r, actor, "auth.login.passkey", http.StatusOK)
+ writeJSON(w, sessionPayload(session))
+}
+
func (a *API) logout(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie("aigw_session"); err == nil {
if err := a.store.RevokeConsoleSession(r.Context(), cookie.Value); err != nil {
@@ -359,10 +618,201 @@ func (a *API) changePassword(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"status": "password_changed", "reauthentication_required": true})
}
+func (a *API) mfaStatus(w http.ResponseWriter, r *http.Request) {
+ actor := a.actor(r)
+ if actor.ID == "" {
+ apierror.Write(w, apierror.Error{Status: http.StatusBadRequest, Type: "bootstrap_account", Message: "Bootstrap access has no MFA profile"}, requestID(r))
+ return
+ }
+ result, err := a.store.MFAStatus(r.Context(), actor.ID)
+ if err != nil {
+ a.databaseError(w, r, err)
+ return
+ }
+ writeJSON(w, result)
+}
+
+func (a *API) beginTOTP(w http.ResponseWriter, r *http.Request) {
+ actor := a.actor(r)
+ var input struct {
+ CurrentPassword string `json:"current_password"`
+ }
+ if !decodeBody(w, r, &input) {
+ return
+ }
+ if actor.ID == "" || a.store.VerifyConsolePassword(r.Context(), actor.ID, input.CurrentPassword) != nil {
+ apierror.Write(w, apierror.Error{Status: http.StatusUnauthorized, Type: "invalid_credentials", Message: "Current password is incorrect"}, requestID(r))
+ return
+ }
+ result, err := a.store.BeginTOTP(r.Context(), actor)
+ if err != nil {
+ a.mutationError(w, r, err)
+ return
+ }
+ writeJSON(w, result)
+}
+
+func (a *API) confirmTOTP(w http.ResponseWriter, r *http.Request) {
+ var input struct {
+ Code string `json:"code"`
+ }
+ if !decodeBody(w, r, &input) {
+ return
+ }
+ codes, err := a.store.ConfirmTOTP(r.Context(), a.actor(r).ID, input.Code)
+ if err != nil {
+ a.actionError(w, r, err)
+ return
+ }
+ writeJSON(w, map[string]any{"status": "enabled", "recovery_codes": codes})
+}
+
+func (a *API) disableTOTP(w http.ResponseWriter, r *http.Request) {
+ actor := a.actor(r)
+ var input struct {
+ CurrentPassword string `json:"current_password"`
+ Code string `json:"code"`
+ }
+ if !decodeBody(w, r, &input) {
+ return
+ }
+ if a.store.VerifyConsolePassword(r.Context(), actor.ID, input.CurrentPassword) != nil ||
+ a.store.VerifyTOTPForUser(r.Context(), actor.ID, input.Code) != nil {
+ apierror.Write(w, apierror.Error{Status: http.StatusUnauthorized, Type: "invalid_credentials", Message: "Password or authenticator code is incorrect"}, requestID(r))
+ return
+ }
+ if err := a.store.DisableTOTP(r.Context(), actor.ID); err != nil {
+ a.databaseError(w, r, err)
+ return
+ }
+ writeJSON(w, map[string]any{"status": "disabled"})
+}
+
+func (a *API) beginPasskeyRegistration(w http.ResponseWriter, r *http.Request) {
+ if !a.requireWebAuthn(w, r) {
+ return
+ }
+ actor := a.actor(r)
+ var input struct {
+ CurrentPassword string `json:"current_password"`
+ }
+ if !decodeBody(w, r, &input) {
+ return
+ }
+ if actor.ID == "" || a.store.VerifyConsolePassword(r.Context(), actor.ID, input.CurrentPassword) != nil {
+ apierror.Write(w, apierror.Error{Status: http.StatusUnauthorized, Type: "invalid_credentials", Message: "Current password is incorrect"}, requestID(r))
+ return
+ }
+ token, options, err := a.store.BeginWebAuthnRegistration(r.Context(), actor.ID, a.webauthn)
+ if err != nil {
+ a.passkeyAuthError(w, r, err)
+ return
+ }
+ writeJSON(w, map[string]any{"challenge_token": token, "options": options})
+}
+
+func (a *API) finishPasskeyRegistration(w http.ResponseWriter, r *http.Request) {
+ if !a.requireWebAuthn(w, r) {
+ return
+ }
+ var input webAuthnFinishInput
+ if !decodeBody(w, r, &input) {
+ return
+ }
+ actor := a.actor(r)
+ userID, _, sessionData, err := a.store.WebAuthnSession(r.Context(), input.ChallengeToken, "register")
+ if err != nil || userID != actor.ID {
+ a.actionError(w, r, controlplane.ErrActionTokenInvalid)
+ return
+ }
+ user, err := a.store.WebAuthnUser(r.Context(), actor.ID)
+ if err != nil {
+ a.passkeyAuthError(w, r, err)
+ return
+ }
+ credential, err := a.webauthn.FinishRegistration(user, sessionData, credentialRequest(r, input.Credential))
+ if err != nil {
+ a.passkeyAuthError(w, r, err)
+ return
+ }
+ if _, err := a.store.FinishWebAuthnRegistration(r.Context(), input.ChallengeToken, actor.ID, input.Name, credential); err != nil {
+ a.passkeyAuthError(w, r, err)
+ return
+ }
+ writeStatusJSON(w, http.StatusCreated, map[string]any{"status": "registered"})
+}
+
+func (a *API) deletePasskey(w http.ResponseWriter, r *http.Request) {
+ actor := a.actor(r)
+ var input struct {
+ CurrentPassword string `json:"current_password"`
+ }
+ if !decodeBody(w, r, &input) {
+ return
+ }
+ if a.store.VerifyConsolePassword(r.Context(), actor.ID, input.CurrentPassword) != nil {
+ apierror.Write(w, apierror.Error{Status: http.StatusUnauthorized, Type: "invalid_credentials", Message: "Current password is incorrect"}, requestID(r))
+ return
+ }
+ if err := a.store.DeletePasskey(r.Context(), actor.ID, r.PathValue("id")); err != nil {
+ a.mutationError(w, r, err)
+ return
+ }
+ writeJSON(w, map[string]any{"status": "deleted"})
+}
+
+func (a *API) listSessions(w http.ResponseWriter, r *http.Request) {
+ actor := a.actor(r)
+ if actor.ID == "" {
+ writeJSON(w, []controlplane.DeviceSession{})
+ return
+ }
+ result, err := a.store.ListDeviceSessions(r.Context(), actor.ID, sessionCookie(r))
+ if err != nil {
+ a.databaseError(w, r, err)
+ return
+ }
+ writeJSON(w, result)
+}
+
+func (a *API) revokeSession(w http.ResponseWriter, r *http.Request) {
+ actor := a.actor(r)
+ current := sessionCookie(r)
+ sessions, err := a.store.ListDeviceSessions(r.Context(), actor.ID, current)
+ if err != nil {
+ a.databaseError(w, r, err)
+ return
+ }
+ currentRevoked := false
+ for _, session := range sessions {
+ if session.ID == r.PathValue("id") {
+ currentRevoked = session.Current
+ break
+ }
+ }
+ if _, err := a.store.RevokeDeviceSession(r.Context(), actor.ID, r.PathValue("id")); err != nil {
+ a.mutationError(w, r, err)
+ return
+ }
+ if currentRevoked {
+ a.clearSessionCookie(w, r)
+ }
+ writeJSON(w, map[string]any{"status": "revoked", "current": currentRevoked})
+}
+
+func (a *API) revokeOtherSessions(w http.ResponseWriter, r *http.Request) {
+ if err := a.store.RevokeOtherDeviceSessions(r.Context(), a.actor(r).ID, sessionCookie(r)); err != nil {
+ a.databaseError(w, r, err)
+ return
+ }
+ writeJSON(w, map[string]any{"status": "revoked"})
+}
+
func sessionPayload(session controlplane.ConsoleSession) map[string]any {
return map[string]any{
"actor": session.Actor, "permissions": session.Actor.Permissions(),
"csrf_token": session.CSRFToken, "expires_at": session.ExpiresAt,
+ "session_id": session.ID, "auth_method": session.AuthMethod,
}
}
@@ -385,6 +835,62 @@ func requestIsHTTPS(r *http.Request) bool {
return r.TLS != nil || strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https")
}
+func sessionCookie(r *http.Request) string {
+ if cookie, err := r.Cookie("aigw_session"); err == nil {
+ return cookie.Value
+ }
+ return ""
+}
+
+func credentialRequest(r *http.Request, credential json.RawMessage) *http.Request {
+ request := r.Clone(r.Context())
+ request.Body = io.NopCloser(bytes.NewReader(credential))
+ request.ContentLength = int64(len(credential))
+ request.Header = r.Header.Clone()
+ request.Header.Set("Content-Type", "application/json")
+ return request
+}
+
+func (a *API) requireWebAuthn(w http.ResponseWriter, r *http.Request) bool {
+ if a.webauthn != nil {
+ return true
+ }
+ apierror.Write(w, apierror.Error{Status: http.StatusNotImplemented, Type: "passkeys_disabled", Message: "Passkeys are not configured for this console"}, requestID(r))
+ return false
+}
+
+func (a *API) consumePublicRateLimit(w http.ResponseWriter, r *http.Request, scope, key string, limit int, window time.Duration) bool {
+ if err := a.store.ConsumeRateLimit(r.Context(), scope, key, limit, window); err != nil {
+ if errors.Is(err, controlplane.ErrConsoleRateLimited) {
+ w.Header().Set("Retry-After", "900")
+ apierror.Write(w, apierror.Error{Status: http.StatusTooManyRequests, Type: "rate_limited", Message: "Too many requests; try again later"}, requestID(r))
+ return false
+ }
+ a.databaseError(w, r, err)
+ return false
+ }
+ return true
+}
+
+func (a *API) actionError(w http.ResponseWriter, r *http.Request, err error) {
+ if errors.Is(err, controlplane.ErrActionTokenInvalid) {
+ apierror.Write(w, apierror.Error{Status: http.StatusBadRequest, Type: "invalid_or_expired_token", Message: "This link or authentication challenge is invalid or expired"}, requestID(r))
+ return
+ }
+ if errors.Is(err, controlplane.ErrConsoleUnauthorized) {
+ apierror.Write(w, apierror.Error{Status: http.StatusUnauthorized, Type: "invalid_authenticator", Message: "The authentication code is incorrect"}, requestID(r))
+ return
+ }
+ a.mutationError(w, r, err)
+}
+
+func (a *API) passkeyAuthError(w http.ResponseWriter, r *http.Request, err error) {
+ if !errors.Is(err, controlplane.ErrConsoleUnauthorized) && !errors.Is(err, controlplane.ErrNoPasskeys) && !errors.Is(err, controlplane.ErrActionTokenInvalid) {
+ a.logger.Warn("passkey_authentication_failed", "error", err)
+ }
+ apierror.Write(w, apierror.Error{Status: http.StatusUnauthorized, Type: "passkey_failed", Message: "Passkey authentication could not be completed"}, requestID(r))
+}
+
func isUnsafeMethod(method string) bool {
return method != http.MethodGet && method != http.MethodHead && method != http.MethodOptions
}
@@ -437,6 +943,34 @@ func (a *API) listBillingLedger(w http.ResponseWriter, r *http.Request) {
writeJSON(w, result)
}
+func (a *API) listTopUpOrders(w http.ResponseWriter, r *http.Request) {
+ actor := a.actor(r)
+ if actor.TenantID == "" {
+ apierror.Write(w, apierror.Error{Status: http.StatusBadRequest, Type: "tenant_required", Message: "A tenant is required"}, requestID(r))
+ return
+ }
+ result, err := a.billing.ListTopUpOrders(r.Context(), actor.TenantID, 50)
+ if err != nil {
+ a.billingError(w, r, err)
+ return
+ }
+ writeJSON(w, result)
+}
+
+func (a *API) getTopUpOrder(w http.ResponseWriter, r *http.Request) {
+ actor := a.actor(r)
+ if actor.TenantID == "" {
+ apierror.Write(w, apierror.Error{Status: http.StatusBadRequest, Type: "tenant_required", Message: "A tenant is required"}, requestID(r))
+ return
+ }
+ result, err := a.billing.GetTopUpOrder(r.Context(), actor.TenantID, r.PathValue("id"))
+ if err != nil {
+ a.billingError(w, r, err)
+ return
+ }
+ writeJSON(w, result)
+}
+
func (a *API) adjustBalance(w http.ResponseWriter, r *http.Request) {
var input billing.AdjustmentInput
if !decodeBody(w, r, &input) {
@@ -730,7 +1264,7 @@ func (a *API) createUser(w http.ResponseWriter, r *http.Request) {
return
}
}
- result, err := a.store.CreateConsoleUser(r.Context(), input)
+ result, err := a.store.InviteConsoleUser(r.Context(), input, a.publicURL, remoteIP(r))
if err != nil {
a.mutationError(w, r, err)
return
@@ -837,6 +1371,10 @@ func (a *API) billingError(w http.ResponseWriter, r *http.Request, err error) {
status = http.StatusServiceUnavailable
typeName = "stripe_disabled"
message = "Stripe top-ups are disabled"
+ case errors.Is(err, billing.ErrTopUpOrderNotFound):
+ status = http.StatusNotFound
+ typeName = "topup_order_not_found"
+ message = "Top-up order was not found"
default:
a.logger.Error("admin_billing_error", "error", err)
}
diff --git a/internal/adminui/assets/app.js b/internal/adminui/assets/app.js
index 1a7469b..1a965fc 100644
--- a/internal/adminui/assets/app.js
+++ b/internal/adminui/assets/app.js
@@ -1,7 +1,8 @@
const state = {
token: '', csrf: '', actor: {}, permissions: new Set(), overview: {},
tenants: [], projects: [], keys: [], providers: [], models: [], billingAccounts: [], ledger: [],
- usage: [], usageSummary: [], limits: [], users: [], audit: []
+ usage: [], usageSummary: [], limits: [], users: [], audit: [], orders: [], sessions: [],
+ mfa: {totp_enabled:false,passkeys:[]}, pendingMFA: null, authConfig: {}
};
const $ = (selector) => document.querySelector(selector);
const $$ = (selector) => [...document.querySelectorAll(selector)];
@@ -44,7 +45,49 @@ function integer(value) { return new Intl.NumberFormat().format(Number(value ||
function emptyRow(span) { return `<tr><td colspan="${span}" class="empty">No records yet</td></tr>`; }
function showSecret(title, value) { $('#secret-title').textContent = title; $('#created-secret').textContent = value; $('#secret-dialog').showModal(); }
function cookie(name) { const prefix=`${encodeURIComponent(name)}=`; const value=document.cookie.split('; ').find(item=>item.startsWith(prefix)); return value ? decodeURIComponent(value.slice(prefix.length)) : ''; }
-function authError(message='') { $('#auth-error').textContent=message; }
+function authError(message='', success=false) { const node=$('#auth-error');node.textContent=message;node.classList.toggle('success',Boolean(message)&&success); }
+function showAuthPane(id) {
+ $$('.auth-pane').forEach(node => node.classList.toggle('active', node.id === id));
+ $$('.auth-tab').forEach(node => node.classList.toggle('active', node.dataset.authPane === id));
+}
+
+function base64URLBytes(value) {
+ const normalized=String(value).replaceAll('-','+').replaceAll('_','/');
+ const padded=normalized+'='.repeat((4-normalized.length%4)%4);
+ return Uint8Array.from(atob(padded),char=>char.charCodeAt(0));
+}
+function bytesBase64URL(value) {
+ if (value == null) return null;
+ const bytes=new Uint8Array(value); let text=''; bytes.forEach(byte=>{text+=String.fromCharCode(byte);});
+ return btoa(text).replaceAll('+','-').replaceAll('/','_').replace(/=+$/,'');
+}
+function browserPublicKey(options) {
+ const value=structuredClone(options.publicKey||options);
+ value.challenge=base64URLBytes(value.challenge);
+ if(value.user?.id)value.user.id=base64URLBytes(value.user.id);
+ if(value.excludeCredentials)value.excludeCredentials=value.excludeCredentials.map(item=>({...item,id:base64URLBytes(item.id)}));
+ if(value.allowCredentials)value.allowCredentials=value.allowCredentials.map(item=>({...item,id:base64URLBytes(item.id)}));
+ return value;
+}
+function browserCredential(value) {
+ const response={clientDataJSON:bytesBase64URL(value.response.clientDataJSON)};
+ for(const key of ['attestationObject','authenticatorData','signature','userHandle']){
+ if(value.response[key]!=null)response[key]=bytesBase64URL(value.response[key]);
+ }
+ if(typeof value.response.getTransports==='function')response.transports=value.response.getTransports();
+ return {id:value.id,rawId:bytesBase64URL(value.rawId),type:value.type,response,
+ authenticatorAttachment:value.authenticatorAttachment,clientExtensionResults:value.getClientExtensionResults()};
+}
+async function createPasskey(options) {
+ const credential=await navigator.credentials.create({publicKey:browserPublicKey(options)});
+ if(!credential)throw new Error('Passkey creation was cancelled');
+ return browserCredential(credential);
+}
+async function getPasskey(options) {
+ const credential=await navigator.credentials.get({publicKey:browserPublicKey(options)});
+ if(!credential)throw new Error('Passkey authentication was cancelled');
+ return browserCredential(credential);
+}
async function permitted(permission, path) { if (!can(permission)) return []; return api(path); }
async function loadAll(knownSession = null) {
@@ -56,9 +99,12 @@ async function loadAll(knownSession = null) {
permitted('platform.read','/providers'), permitted('platform.read','/models'), permitted('usage.read','/usage'),
permitted('usage.read','/usage/summary'), permitted('limits.read','/limits'), permitted('users.read','/users'),
permitted('audit.read','/audit'), state.overview.billing_enabled ? permitted('billing.read','/billing/accounts') : [],
- state.overview.billing_enabled ? permitted('billing.read','/billing/ledger') : []
+ state.overview.billing_enabled ? permitted('billing.read','/billing/ledger') : [],
+ state.actor.id ? api('/auth/mfa') : {totp_enabled:false,passkeys:[]},
+ state.actor.id ? api('/auth/sessions') : [],
+ state.actor.tenant_id && state.overview.billing_enabled && can('billing.read') ? api('/billing/orders') : []
]);
- [state.tenants,state.projects,state.keys,state.providers,state.models,state.usage,state.usageSummary,state.limits,state.users,state.audit,state.billingAccounts,state.ledger] = results;
+ [state.tenants,state.projects,state.keys,state.providers,state.models,state.usage,state.usageSummary,state.limits,state.users,state.audit,state.billingAccounts,state.ledger,state.mfa,state.sessions,state.orders] = results;
renderAll(); setConnected(true); return true;
} catch (error) { setConnected(false); if (error.status !== 401) toast(error.message, true); return false; }
}
@@ -72,7 +118,7 @@ function applyPermissions() {
}
function renderAll() {
applyPermissions(); renderOverview(); renderTenants(); renderProjects(); renderKeys(); renderProviders(); renderModels(); renderBilling();
- renderUsage(); renderLimits(); renderUsers(); renderAudit(); renderRouteEditor();
+ renderUsage(); renderLimits(); renderUsers(); renderAudit(); renderRouteEditor(); renderSecurity();
}
function renderOverview() {
const data = state.overview; const propagation = !data.redis_configured ? 'PG polling' : data.redis_connected ? 'Redis live' : 'PG fallback';
@@ -97,6 +143,16 @@ function renderBilling() {
$('#billing-accounts-body').innerHTML=state.billingAccounts.map(item=>`<tr><td><strong>${esc(item.tenant_name)}</strong><br><code>${shortID(item.tenant_id)}</code></td><td>${money(item.balance_micros,item.currency)}</td><td>${money(item.reserved_micros,item.currency)}</td><td><strong>${money(item.available_micros,item.currency)}</strong></td><td>${date(item.updated_at)}</td></tr>`).join('')||emptyRow(5);
$('#billing-ledger-body').innerHTML=state.ledger.map(item=>`<tr><td>${date(item.created_at)}</td><td><code>${shortID(item.tenant_id)}</code></td><td><span class="tag">${esc(item.kind)}</span></td><td class="${item.amount_micros>=0?'money-positive':'money-negative'}">${money(item.amount_micros,item.currency)}</td><td>${money(item.balance_after_micros,item.currency)}</td><td title="${esc(item.description)}"><code>${shortID(item.source_id)}</code></td></tr>`).join('')||emptyRow(6);
}
+function renderSecurity() {
+ const totp=Boolean(state.mfa?.totp_enabled);
+ $$('#account [autocomplete="username"]').forEach(node=>{node.value=state.actor.email||'';});
+ $('#totp-action').textContent=totp?'Disable TOTP':'Set up TOTP';
+ $('#totp-disable-code').classList.toggle('hidden',!totp);
+ $('#totp-disable-code input').required=totp;
+ $('#sessions-body').innerHTML=(state.sessions||[]).map(item=>`<tr><td><strong>${esc(item.current?'Current device':'Browser session')}</strong><br><span class="muted">${esc(item.user_agent||'Unknown client')}</span></td><td><code>${esc(item.remote_ip||'—')}</code></td><td><span class="tag">${esc(item.auth_method)}</span></td><td>${date(item.last_seen_at)}</td><td><button class="text-button danger" data-revoke-session="${esc(item.id)}">Sign out</button></td></tr>`).join('')||emptyRow(5);
+ $('#passkeys-body').innerHTML=(state.mfa?.passkeys||[]).map(item=>`<tr><td><strong>${esc(item.name)}</strong></td><td>${date(item.created_at)}</td><td>${date(item.last_used_at)}</td><td><button class="text-button danger" data-delete-passkey="${esc(item.id)}">Delete</button></td></tr>`).join('')||emptyRow(4);
+ $('#orders-body').innerHTML=(state.orders||[]).map(item=>`<tr><td>${date(item.created_at)}</td><td>${money(item.amount_micros,item.currency)}</td><td><span class="badge ${item.status==='paid'?'active':'suspended'}">${esc(item.status)}</span></td><td><code>${shortID(item.id)}</code></td></tr>`).join('')||emptyRow(4);
+}
function renderUsage() {
$('#usage-summary-body').innerHTML=state.usageSummary.map(item=>`<tr><td>${new Date(item.period_start).toLocaleDateString(undefined,{year:'numeric',month:'short'})}</td><td><strong>${esc(item.project_name)}</strong></td><td>${integer(item.request_count)}</td><td>${percent(item.successful_requests,item.request_count)}</td><td>${integer(item.input_tokens)}</td><td>${integer(item.output_tokens)}</td><td>${money(item.cost_micros)}</td></tr>`).join('')||emptyRow(7);
$('#usage-events-body').innerHTML=state.usage.map(item=>`<tr><td>${date(item.started_at)}</td><td><code title="${esc(item.request_id)}">${shortID(item.request_id)}</code></td><td>${esc(item.public_model)}</td><td><span class="badge ${item.success?'active':'suspended'}">${item.status_code}</span>${item.error_type?`<small class="error-label">${esc(item.error_type)}</small>`:''}</td><td>${integer(item.total_tokens)}</td><td>${money(item.cost_micros)}</td><td>${integer(item.duration_ms)} ms</td></tr>`).join('')||emptyRow(7);
@@ -108,7 +164,7 @@ function renderLimits() {
function renderUsers() {
const roles = state.actor.tenant_id ? [['tenant_admin','Tenant admin'],['tenant_billing','Billing'],['tenant_developer','Developer'],['tenant_viewer','Viewer']] : [['platform_admin','Platform admin'],['platform_viewer','Platform viewer'],['tenant_admin','Tenant admin'],['tenant_billing','Billing'],['tenant_developer','Developer'],['tenant_viewer','Viewer']];
$('#user-role').innerHTML=roles.map(([value,label])=>`<option value="${value}">${label}</option>`).join('');
- $('#users-body').innerHTML=state.users.map(item=>`<tr><td><strong>${esc(item.display_name)}</strong><br><span class="muted">${esc(item.email)}</span></td><td><span class="tag">${esc(item.role.replaceAll('_',' '))}</span></td><td><code>${shortID(item.tenant_id)}</code></td><td><span class="badge ${item.has_password?'active':'suspended'}">${item.has_password?'password':'legacy token'}</span></td><td>${date(item.last_used_at)}</td><td><span class="badge ${item.status}">${esc(item.status)}</span></td><td>${item.status==='active'&&can('users.write')?`<button class="text-button danger" data-revoke-user="${esc(item.id)}">Revoke</button>`:''}</td></tr>`).join('')||emptyRow(7);
+ $('#users-body').innerHTML=state.users.map(item=>`<tr><td><strong>${esc(item.display_name)}</strong><br><span class="muted">${esc(item.email)}</span></td><td><span class="tag">${esc(item.role.replaceAll('_',' '))}</span></td><td><code>${shortID(item.tenant_id)}</code></td><td>${item.email_verified?'<span class="badge active">verified</span>':'<span class="badge suspended">pending</span>'} ${(item.mfa_methods||[]).map(method=>`<span class="tag">${esc(method)}</span>`).join('')}</td><td>${date(item.last_used_at)}</td><td><span class="badge ${item.status}">${esc(item.status)}</span></td><td>${item.status!=='revoked'&&can('users.write')?`<button class="text-button danger" data-revoke-user="${esc(item.id)}">Revoke</button>`:''}</td></tr>`).join('')||emptyRow(7);
}
function renderAudit() { $('#audit-body').innerHTML=state.audit.map(item=>`<tr><td>${date(item.created_at)}</td><td><span class="tag">${esc(item.actor_role.replaceAll('_',' '))}</span></td><td>${esc(item.action)}</td><td><code>${esc(item.method)}</code></td><td><span class="badge ${item.status_code<400?'active':'suspended'}">${item.status_code}</span></td><td><code>${shortID(item.request_id)}</code></td><td><code>${esc(item.remote_ip||'—')}</code></td></tr>`).join('')||emptyRow(7); }
function renderRouteEditor() { const current=$('#route-editor');if(!current.children.length&&can('platform.write'))addRoute();$$('.route-provider').forEach(select=>{const selected=select.value;select.innerHTML=selectOptions(state.providers.filter(item=>item.enabled),'id','name','Provider…');select.value=selected;}); }
@@ -123,12 +179,21 @@ document.addEventListener('click',async(event)=>{
const revokeUser=event.target.closest('[data-revoke-user]');if(revokeUser&&confirm('Revoke this console credential?')){try{await api(`/users/${revokeUser.dataset.revokeUser}/revoke`,{method:'POST',body:'{}'});await loadAll();toast('Console credential revoked');}catch(error){toast(error.message,true);}}
const provider=event.target.closest('[data-toggle-provider]');if(provider){try{await api(`/providers/${provider.dataset.toggleProvider}/toggle`,{method:'POST',body:JSON.stringify({enabled:provider.dataset.enabled==='true'})});await loadAll();toast('Provider updated');}catch(error){toast(error.message,true);}}
const model=event.target.closest('[data-toggle-model]');if(model){try{await api(`/models/${model.dataset.toggleModel}/toggle`,{method:'POST',body:JSON.stringify({enabled:model.dataset.enabled==='true'})});await loadAll();toast('Model updated');}catch(error){toast(error.message,true);}}
+ const session=event.target.closest('[data-revoke-session]');if(session&&confirm('Sign out this device?')){try{const result=await api(`/auth/sessions/${session.dataset.revokeSession}/revoke`,{method:'POST',body:'{}'});if(result.current){state.csrf='';state.actor={};setConnected(false);}else{await loadAll();toast('Device signed out');}}catch(error){toast(error.message,true);}}
+ const passkey=event.target.closest('[data-delete-passkey]');if(passkey&&confirm('Delete this passkey?')){const password=$('#passkey-form [name=current_password]').value;if(!password){toast('Enter your current password in the Passkey panel',true);}else{try{await api(`/auth/passkeys/${passkey.dataset.deletePasskey}/delete`,{method:'POST',body:JSON.stringify({current_password:password})});await loadAll();toast('Passkey deleted');}catch(error){toast(error.message,true);}}}
const save=event.target.closest('.save-limit');if(save){const row=save.closest('[data-limit-project]');try{await api(`/limits/${row.dataset.limitProject}`,{method:'POST',body:JSON.stringify({requests_per_minute:Number(row.querySelector('.limit-rpm').value),tokens_per_minute:Number(row.querySelector('.limit-tpm').value),concurrent_requests:Number(row.querySelector('.limit-concurrency').value),monthly_spend_micros:decimalToScaled(row.querySelector('.limit-spend').value,6)})});await loadAll();toast('Project limits updated');}catch(error){toast(error.message,true);}}
});
$('#key-tenant').addEventListener('change',renderKeyProjects);
-$('#login-pane').addEventListener('submit',async(event)=>{event.preventDefault();authError();try{state.token='';state.csrf='';const result=await api('/auth/login',{method:'POST',body:JSON.stringify(formJSON(event.target))});state.csrf=result.csrf_token||cookie('aigw_csrf');await loadAll();event.target.reset();}catch(error){authError(error.message);}});
-$('#register-pane').addEventListener('submit',async(event)=>{event.preventDefault();authError();try{state.token='';state.csrf='';const result=await api('/auth/register',{method:'POST',body:JSON.stringify(formJSON(event.target))});state.csrf=result.csrf_token||cookie('aigw_csrf');await loadAll();event.target.reset();}catch(error){authError(error.message);}});
+async function completeBrowserLogin(result,form){state.csrf=result.csrf_token||cookie('aigw_csrf');state.pendingMFA=null;if(form)form.reset();await loadAll(result);}
+$('#login-pane').addEventListener('submit',async(event)=>{event.preventDefault();authError();try{state.token='';state.csrf='';const result=await api('/auth/login',{method:'POST',body:JSON.stringify(formJSON(event.target))});if(result.mfa_required){state.pendingMFA=result;$('#mfa-passkey').classList.toggle('hidden',!result.methods.includes('passkey'));showAuthPane('mfa-pane');}else await completeBrowserLogin(result,event.target);}catch(error){authError(error.message);}});
+$('#register-pane').addEventListener('submit',async(event)=>{event.preventDefault();authError();try{state.token='';state.csrf='';await api('/auth/register',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();showAuthPane('login-pane');authError('Verification email sent.',true);}catch(error){authError(error.message);}});
+$('#mfa-pane').addEventListener('submit',async(event)=>{event.preventDefault();authError();if(!state.pendingMFA)return showAuthPane('login-pane');try{const result=await api('/auth/mfa/totp',{method:'POST',body:JSON.stringify({challenge_token:state.pendingMFA.challenge_token,code:formJSON(event.target).code})});await completeBrowserLogin(result,event.target);}catch(error){authError(error.message);}});
+$('#reset-request-pane').addEventListener('submit',async(event)=>{event.preventDefault();authError();try{await api('/auth/password-reset/request',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();showAuthPane('login-pane');authError('Password reset email sent if the account exists.',true);}catch(error){authError(error.message);}});
+$('#reset-complete-pane').addEventListener('submit',async(event)=>{event.preventDefault();authError();try{await api('/auth/password-reset/complete',{method:'POST',body:JSON.stringify(formJSON(event.target))});history.replaceState({},'',location.pathname);event.target.reset();showAuthPane('login-pane');authError('Password updated. Sign in again.',true);}catch(error){authError(error.message);}});
+$('#invite-pane').addEventListener('submit',async(event)=>{event.preventDefault();authError();try{const result=await api('/auth/invite/accept',{method:'POST',body:JSON.stringify(formJSON(event.target))});history.replaceState({},'',location.pathname);await completeBrowserLogin(result,event.target);}catch(error){authError(error.message);}});
+$('#passkey-login').addEventListener('click',async()=>{authError();if(!state.authConfig.passkeys_enabled)return authError('Passkeys are not configured.');const email=$('#login-pane [name=email]').value;if(!email)return authError('Enter your email first.');try{const begin=await api('/auth/passkey/login/options',{method:'POST',body:JSON.stringify({email})});const credential=await getPasskey(begin.options);const result=await api('/auth/passkey/login',{method:'POST',body:JSON.stringify({challenge_token:begin.challenge_token,credential})});await completeBrowserLogin(result,$('#login-pane'));}catch(error){authError(error.message);}});
+$('#mfa-passkey').addEventListener('click',async()=>{authError();if(!state.pendingMFA)return showAuthPane('login-pane');try{const begin=await api('/auth/mfa/passkey/login/options',{method:'POST',body:JSON.stringify({challenge_token:state.pendingMFA.challenge_token})});const credential=await getPasskey(begin.options);const result=await api('/auth/mfa/passkey/login',{method:'POST',body:JSON.stringify({challenge_token:begin.challenge_token,credential})});await completeBrowserLogin(result,$('#mfa-pane'));}catch(error){authError(error.message);}});
$('#bootstrap-pane').addEventListener('submit',async(event)=>{event.preventDefault();authError();state.csrf='';state.token=formJSON(event.target).token.trim();if(!await loadAll()){state.token='';authError('Bootstrap token is invalid');}event.target.reset();});
$('#sign-out').addEventListener('click',async()=>{try{if(!state.token)await api('/auth/logout',{method:'POST',body:'{}'});}catch(error){if(error.status!==401)toast(error.message,true);}state.token='';state.csrf='';state.actor={};state.permissions=new Set();setConnected(false);});
$('#tenant-form').addEventListener('submit',async(event)=>{event.preventDefault();try{await api('/tenants',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();await loadAll();toast('Tenant created');}catch(error){toast(error.message,true);}});
@@ -138,8 +203,13 @@ $('#provider-form').addEventListener('submit',async(event)=>{event.preventDefaul
$('#model-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);data.input_price_micros_per_million=decimalToScaled(data.input_price,6);data.output_price_micros_per_million=decimalToScaled(data.output_price,6);data.cache_read_price_micros_per_million=decimalToScaled(data.cache_read_price,6);data.cache_write_price_micros_per_million=decimalToScaled(data.cache_write_price,6);delete data.input_price;delete data.output_price;delete data.cache_read_price;delete data.cache_write_price;data.routes=$$('.route-row').map(row=>({provider_id:row.querySelector('.route-provider').value,upstream_model:row.querySelector('.route-upstream').value,priority:Number(row.querySelector('.route-priority').value),weight:Number(row.querySelector('.route-weight').value)}));await api('/models',{method:'POST',body:JSON.stringify(data)});event.target.reset();$('#route-editor').innerHTML='';renderRouteEditor();await loadAll();toast('Model created');}catch(error){toast(error.message,true);}});
$('#topup-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);const digits=currencyDigits(state.overview.billing_currency||'usd');const result=await api('/billing/checkout-sessions',{method:'POST',body:JSON.stringify({tenant_id:data.tenant_id,amount_minor:decimalToScaled(data.amount,digits)})});window.location.assign(result.url);}catch(error){toast(error.message,true);}});
$('#adjustment-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);await api('/billing/adjustments',{method:'POST',body:JSON.stringify({tenant_id:data.tenant_id,amount_micros:decimalToScaled(data.amount,6),description:data.description})});event.target.reset();await loadAll();toast('Balance adjusted');}catch(error){toast(error.message,true);}});
-$('#user-form').addEventListener('submit',async(event)=>{event.preventDefault();try{await api('/users',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();await loadAll();toast('Member created');}catch(error){toast(error.message,true);}});
-$('#password-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);await api('/auth/password',{method:'POST',body:JSON.stringify({current_password:data.current_password,new_password:data.new_password})});event.target.reset();state.csrf='';state.actor={};state.permissions=new Set();setConnected(false);authError('Password changed. Sign in again.');}catch(error){toast(error.message,true);}});
+$('#user-form').addEventListener('submit',async(event)=>{event.preventDefault();try{await api('/users',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();await loadAll();toast('Invitation sent');}catch(error){toast(error.message,true);}});
+$('#password-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);await api('/auth/password',{method:'POST',body:JSON.stringify({current_password:data.current_password,new_password:data.new_password})});event.target.reset();state.csrf='';state.actor={};state.permissions=new Set();setConnected(false);authError('Password changed. Sign in again.',true);}catch(error){toast(error.message,true);}});
+$('#totp-begin-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const data=formJSON(event.target);if(state.mfa.totp_enabled){await api('/auth/mfa/totp/disable',{method:'POST',body:JSON.stringify(data)});event.target.reset();await loadAll();toast('TOTP disabled');return;}const result=await api('/auth/mfa/totp/begin',{method:'POST',body:JSON.stringify({current_password:data.current_password})});$('#totp-qr').src=result.qr_code;$('#totp-secret').textContent=result.secret;$('#totp-confirm-form').classList.remove('hidden');toast('Authenticator enrollment started');}catch(error){toast(error.message,true);}});
+$('#totp-confirm-form').addEventListener('submit',async(event)=>{event.preventDefault();try{const result=await api('/auth/mfa/totp/confirm',{method:'POST',body:JSON.stringify(formJSON(event.target))});event.target.reset();$('#totp-confirm-form').classList.add('hidden');showSecret('Recovery codes',result.recovery_codes.join('\n'));await loadAll();}catch(error){toast(error.message,true);}});
+$('#passkey-form').addEventListener('submit',async(event)=>{event.preventDefault();try{if(!state.authConfig.passkeys_enabled)throw new Error('Passkeys are not configured');const data=formJSON(event.target);const begin=await api('/auth/mfa/passkey/options',{method:'POST',body:JSON.stringify({current_password:data.current_password})});const credential=await createPasskey(begin.options);await api('/auth/mfa/passkey',{method:'POST',body:JSON.stringify({challenge_token:begin.challenge_token,name:data.name,credential})});event.target.reset();await loadAll();toast('Passkey added');}catch(error){toast(error.message,true);}});
+$('#revoke-other-sessions').addEventListener('click',async()=>{if(!confirm('Sign out every other device?'))return;try{await api('/auth/sessions/revoke-others',{method:'POST',body:'{}'});await loadAll();toast('Other devices signed out');}catch(error){toast(error.message,true);}});
$('#close-dialog').addEventListener('click',()=>$('#secret-dialog').close());$('#copy-secret').addEventListener('click',async()=>{await navigator.clipboard.writeText($('#created-secret').textContent);toast('Credential copied');});
-async function start(){try{const config=await api('/auth/config');$('#register-tab').classList.toggle('hidden',!config.registration_enabled);if(!config.registration_enabled&&$('#register-tab').classList.contains('active'))$('.auth-tab[data-auth-pane="login-pane"]').click();state.csrf=cookie('aigw_csrf');const session=await api('/auth/session');if(session.authenticated)await loadAll(session);else setConnected(false);}catch(error){setConnected(false);authError(error.message);}}
+async function pollTopUp(orderID){for(let attempt=0;attempt<20;attempt++){const order=await api(`/billing/orders/${encodeURIComponent(orderID)}`);if(order.status==='paid'){await loadAll();toast('Balance credited');return;}if(['failed','expired'].includes(order.status)){toast(`Top-up ${order.status}`,true);return;}await new Promise(resolve=>setTimeout(resolve,1500));}toast('Payment is still processing');}
+async function start(){try{state.authConfig=await api('/auth/config');$('#register-tab').classList.toggle('hidden',!state.authConfig.registration_enabled);if(!state.authConfig.registration_enabled&&$('#register-tab').classList.contains('active'))showAuthPane('login-pane');const params=new URLSearchParams(location.search);const action=params.get('action');const token=params.get('token');if(action==='reset-password'&&token){$('#reset-token').value=token;showAuthPane('reset-complete-pane');setConnected(false);return;}if(action==='accept-invite'&&token){$('#invite-token').value=token;showAuthPane('invite-pane');setConnected(false);return;}state.csrf=cookie('aigw_csrf');if(action==='verify-email'&&token){const result=await api('/auth/email/verify',{method:'POST',body:JSON.stringify({token})});history.replaceState({},'',location.pathname);await completeBrowserLogin(result);return;}const session=await api('/auth/session');if(session.authenticated){await loadAll(session);const orderID=params.get('order_id');if(params.get('topup')==='success'&&orderID){history.replaceState({},'',location.pathname);pollTopUp(orderID).catch(error=>toast(error.message,true));}else if(params.get('topup')==='cancel'){history.replaceState({},'',location.pathname);toast('Top-up cancelled');}}else setConnected(false);}catch(error){setConnected(false);authError(error.message);}}
start();
diff --git a/internal/adminui/assets/index.html b/internal/adminui/assets/index.html
index ca9f282..95ab153 100644
--- a/internal/adminui/assets/index.html
+++ b/internal/adminui/assets/index.html
@@ -14,23 +14,50 @@
<div class="auth-tabs" role="tablist" aria-label="Account access">
<button class="auth-tab active" type="button" data-auth-pane="login-pane">Sign in</button>
<button class="auth-tab" type="button" data-auth-pane="register-pane" id="register-tab">Create account</button>
+ <button class="auth-tab" type="button" data-auth-pane="reset-request-pane">Reset password</button>
+ <button class="auth-tab" type="button" data-auth-pane="invite-pane">Accept invite</button>
<button class="auth-tab" type="button" data-auth-pane="bootstrap-pane">Operator</button>
</div>
<form class="auth-pane active" id="login-pane">
<div><span class="eyebrow">ACCOUNT ACCESS</span><h1>Sign in</h1></div>
<label>Email<input name="email" type="email" required autocomplete="username" placeholder="you@company.com"></label>
<label>Password<input name="password" type="password" required autocomplete="current-password"></label>
- <button class="button primary" type="submit">Sign in</button>
+ <div class="form-actions"><button class="button primary" type="submit">Sign in</button><button class="button subtle" type="button" id="passkey-login">Use passkey</button></div>
</form>
<form class="auth-pane" id="register-pane">
<div><span class="eyebrow">NEW WORKSPACE</span><h1>Create account</h1></div>
<label>Organization<input name="organization" required autocomplete="organization" placeholder="Acme Inc."></label>
- <label>Workspace slug<input name="tenant_slug" required pattern="[a-z0-9][a-z0-9-]{1,62}[a-z0-9]" placeholder="acme"></label>
+ <label>Workspace slug<input name="tenant_slug" required pattern="[a-z0-9][a-z0-9\-]{1,62}[a-z0-9]" placeholder="acme"></label>
<label>Your name<input name="display_name" required autocomplete="name"></label>
<label>Email<input name="email" type="email" required autocomplete="email"></label>
<label>Password<input name="password" type="password" required minlength="12" maxlength="128" autocomplete="new-password"></label>
<button class="button primary" type="submit">Create account</button>
</form>
+ <form class="auth-pane" id="mfa-pane">
+ <div><span class="eyebrow">SECOND FACTOR</span><h1>Verify sign in</h1></div>
+ <label>Code<input name="code" inputmode="numeric" autocomplete="one-time-code" required></label>
+ <div class="form-actions"><button class="button primary" type="submit">Verify</button><button class="button subtle" type="button" id="mfa-passkey">Use passkey</button></div>
+ </form>
+ <form class="auth-pane" id="reset-request-pane">
+ <div><span class="eyebrow">ACCOUNT RECOVERY</span><h1>Reset password</h1></div>
+ <label>Email<input name="email" type="email" required autocomplete="email"></label>
+ <button class="button primary" type="submit">Send reset link</button>
+ </form>
+ <form class="auth-pane" id="reset-complete-pane">
+ <div><span class="eyebrow">ACCOUNT RECOVERY</span><h1>Choose a new password</h1></div>
+ <input name="token" id="reset-token" type="hidden">
+ <input class="visually-hidden" autocomplete="username" aria-hidden="true" tabindex="-1">
+ <label>New password<input name="new_password" type="password" minlength="12" maxlength="128" required autocomplete="new-password"></label>
+ <button class="button primary" type="submit">Update password</button>
+ </form>
+ <form class="auth-pane" id="invite-pane">
+ <div><span class="eyebrow">TEAM ACCESS</span><h1>Accept invitation</h1></div>
+ <input name="token" id="invite-token" type="hidden">
+ <input class="visually-hidden" autocomplete="username" aria-hidden="true" tabindex="-1">
+ <label>Name<input name="display_name" required autocomplete="name"></label>
+ <label>Password<input name="password" type="password" minlength="12" maxlength="128" required autocomplete="new-password"></label>
+ <button class="button primary" type="submit">Join workspace</button>
+ </form>
<form class="auth-pane" id="bootstrap-pane">
<div><span class="eyebrow">BREAK GLASS</span><h1>Operator access</h1></div>
<input class="visually-hidden" name="username" autocomplete="username" value="aigw-operator" aria-hidden="true" tabindex="-1">
@@ -78,7 +105,7 @@
<section id="tenants" class="section">
<div class="section-heading"><div><span class="eyebrow">IDENTITY</span><h1>Tenants</h1></div></div>
- <form class="panel form-grid" id="tenant-form" data-permission="tenants.write"><label>Slug<input name="slug" required pattern="[a-z0-9][a-z0-9-]{1,62}[a-z0-9]" placeholder="acme"></label><label>Name<input name="name" required placeholder="Acme Inc."></label><button class="button primary" type="submit">Create tenant</button></form>
+ <form class="panel form-grid" id="tenant-form" data-permission="tenants.write"><label>Slug<input name="slug" required pattern="[a-z0-9][a-z0-9\-]{1,62}[a-z0-9]" placeholder="acme"></label><label>Name<input name="name" required placeholder="Acme Inc."></label><button class="button primary" type="submit">Create tenant</button></form>
<div class="panel table-wrap"><table><thead><tr><th>Name</th><th>Slug</th><th>Status</th><th>Created</th></tr></thead><tbody id="tenants-body"></tbody></table></div>
</section>
@@ -125,13 +152,21 @@
<section id="team" class="section">
<div class="section-heading"><div><span class="eyebrow">RBAC</span><h1>Console access</h1></div></div>
- <form class="panel form-grid" id="user-form" data-permission="users.write"><label>Tenant<select name="tenant_id" id="user-tenant"></select></label><label>Email<input name="email" type="email" required autocomplete="email" placeholder="operator@example.com"></label><label>Display name<input name="display_name" required placeholder="Operations"></label><label>Role<select name="role" id="user-role" required></select></label><label>Temporary password<input name="password" type="password" required minlength="12" maxlength="128" autocomplete="new-password"></label><button class="button primary" type="submit">Create member</button></form>
+ <form class="panel form-grid" id="user-form" data-permission="users.write"><label>Tenant<select name="tenant_id" id="user-tenant"></select></label><label>Email<input name="email" type="email" required autocomplete="email" placeholder="operator@example.com"></label><label>Display name<input name="display_name" required placeholder="Operations"></label><label>Role<select name="role" id="user-role" required></select></label><button class="button primary" type="submit">Invite member</button></form>
<div class="panel table-wrap"><table><thead><tr><th>User</th><th>Role</th><th>Tenant</th><th>Login</th><th>Last used</th><th>Status</th><th></th></tr></thead><tbody id="users-body"></tbody></table></div>
</section>
<section id="account" class="section">
<div class="section-heading"><div><span class="eyebrow">SECURITY</span><h1>Account</h1></div></div>
<form class="panel form-grid compact-form" id="password-form"><input class="visually-hidden" name="username" autocomplete="username" aria-hidden="true" tabindex="-1"><label>Current password<input name="current_password" type="password" required autocomplete="current-password"></label><label>New password<input name="new_password" type="password" required minlength="12" maxlength="128" autocomplete="new-password"></label><button class="button primary" type="submit">Change password</button></form>
+ <div class="account-grid">
+ <form class="panel form-grid compact-form" id="totp-begin-form"><h2>Authenticator app</h2><input class="visually-hidden" autocomplete="username" aria-hidden="true" tabindex="-1"><label>Current password<input name="current_password" type="password" required autocomplete="current-password"></label><label class="hidden" id="totp-disable-code">Authenticator code<input name="code" inputmode="numeric" autocomplete="one-time-code"></label><button class="button secondary" id="totp-action" type="submit">Set up TOTP</button></form>
+ <form class="panel form-grid compact-form hidden" id="totp-confirm-form"><h2>Confirm authenticator</h2><img id="totp-qr" alt="TOTP QR code"><code id="totp-secret"></code><label>Code<input name="code" inputmode="numeric" autocomplete="one-time-code" required></label><button class="button primary" type="submit">Enable TOTP</button></form>
+ <form class="panel form-grid compact-form" id="passkey-form"><h2>Passkey</h2><input class="visually-hidden" autocomplete="username" aria-hidden="true" tabindex="-1"><label>Current password<input name="current_password" type="password" required autocomplete="current-password"></label><label>Passkey name<input name="name" value="This device" required></label><button class="button secondary" type="submit">Add passkey</button></form>
+ </div>
+ <div class="panel table-wrap"><div class="section-heading"><h2>Devices</h2><button class="button subtle" id="revoke-other-sessions" type="button">Sign out other devices</button></div><table><thead><tr><th>Device</th><th>IP</th><th>Method</th><th>Last seen</th><th></th></tr></thead><tbody id="sessions-body"></tbody></table></div>
+ <div class="panel table-wrap"><div class="section-heading"><h2>Passkeys</h2></div><table><thead><tr><th>Name</th><th>Created</th><th>Last used</th><th></th></tr></thead><tbody id="passkeys-body"></tbody></table></div>
+ <div class="panel table-wrap"><div class="section-heading"><h2>Top-up orders</h2></div><table><thead><tr><th>Created</th><th>Amount</th><th>Status</th><th>Reference</th></tr></thead><tbody id="orders-body"></tbody></table></div>
</section>
<section id="audit" class="section">
diff --git a/internal/adminui/assets/style.css b/internal/adminui/assets/style.css
index 745378f..e712da3 100644
--- a/internal/adminui/assets/style.css
+++ b/internal/adminui/assets/style.css
@@ -1,6 +1,6 @@
:root { --bg:#f3f5f7; --panel:#fff; --ink:#18212b; --muted:#71808e; --line:#dce3e8; --accent:#146c94; --accent-soft:#e5f2f7; --danger:#b4494d; --shadow:0 8px 24px rgba(29,47,61,.06); font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
* { box-sizing:border-box; } body { margin:0; color:var(--ink); background:var(--bg); font-size:14px; } button,input,select { font:inherit; } button { cursor:pointer; }
-.auth-screen { min-height:100vh; display:grid; grid-template-columns:minmax(260px,1fr) minmax(360px,520px); background:#102a3a; } .auth-brand { color:#fff; display:flex; align-items:flex-start; gap:12px; padding:38px; } .auth-brand strong { display:block; font-size:18px; } .auth-brand small { display:block; color:#8ba9b9; font-size:10px; margin-top:3px; } .auth-panel { background:#fff; padding:clamp(30px,7vh,72px) clamp(28px,6vw,72px); overflow:auto; } .auth-tabs { display:flex; border-bottom:1px solid var(--line); margin-bottom:34px; } .auth-tab { border:0; border-bottom:2px solid transparent; background:transparent; color:var(--muted); padding:11px 12px; white-space:nowrap; } .auth-tab.active { color:var(--accent); border-bottom-color:var(--accent); font-weight:700; } .auth-pane { display:none; gap:18px; } .auth-pane.active { display:grid; } .auth-pane h1 { margin-bottom:8px; } .auth-pane .button { margin-top:4px; } .auth-error { color:var(--danger); min-height:20px; margin:18px 0 0; font-size:12px; }
+.auth-screen { min-height:100vh; display:grid; grid-template-columns:minmax(260px,1fr) minmax(360px,560px); background:#102a3a; } .auth-brand { color:#fff; display:flex; align-items:flex-start; gap:12px; padding:38px; } .auth-brand strong { display:block; font-size:18px; } .auth-brand small { display:block; color:#8ba9b9; font-size:10px; margin-top:3px; } .auth-panel { background:#fff; padding:clamp(30px,7vh,72px) clamp(28px,5vw,64px); overflow:auto; } .auth-tabs { display:flex; overflow:auto; border-bottom:1px solid var(--line); margin-bottom:34px; } .auth-tab { border:0; border-bottom:2px solid transparent; background:transparent; color:var(--muted); padding:11px 12px; white-space:nowrap; } .auth-tab.active { color:var(--accent); border-bottom-color:var(--accent); font-weight:700; } .auth-pane { display:none; gap:18px; } .auth-pane.active { display:grid; } .auth-pane h1 { margin-bottom:8px; } .auth-pane .button { margin-top:4px; } .auth-error { color:var(--danger); min-height:20px; margin:18px 0 0; font-size:12px; } .auth-error.success { color:#187151; } .form-actions { display:flex; gap:8px; } .form-actions .button { flex:1; }
.topbar { height:72px; background:#102a3a; color:#fff; padding:0 32px; display:flex; align-items:center; justify-content:space-between; gap:24px; } .brand { display:flex; align-items:center; gap:11px; letter-spacing:0; } .brand-mark { width:32px; height:32px; display:grid; place-items:center; border:1px solid #8fd0df; color:#b8eef7; font-weight:800; } .brand strong { display:block; font-size:15px; } .brand small { color:#8ba9b9; font-size:9px; letter-spacing:0; } .session { display:flex; align-items:center; gap:12px; } .session div { display:flex; flex-direction:column; align-items:flex-end; gap:2px; } .session button { min-height:36px; border:1px solid #8fd0df; background:transparent; color:#b8eef7; padding:0 13px; font-weight:700; } .session button:hover { background:#18384b; } .state { color:#86d5ad; font-size:11px; text-transform:capitalize; } .actor-label { max-width:220px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#fff; font-size:12px; }
.shell { width:min(1240px,calc(100% - 48px)); margin:28px auto 60px; } .tabs { display:flex; flex-wrap:wrap; gap:4px; border-bottom:1px solid var(--line); margin-bottom:26px; } .tab { white-space:nowrap; border:0; background:transparent; color:var(--muted); padding:12px 15px; border-bottom:2px solid transparent; } .tab.active { color:var(--accent); border-bottom-color:var(--accent); font-weight:700; }
.section { display:none; } .section.active { display:block; } .section-heading { display:flex; justify-content:space-between; align-items:flex-end; gap:20px; margin-bottom:19px; } .eyebrow { color:var(--accent); font-size:10px; letter-spacing:0; font-weight:800; } h1 { font-size:28px; line-height:1.1; margin:7px 0 0; letter-spacing:0; } h2 { margin:4px 0 0; font-size:20px; }
@@ -10,8 +10,9 @@
.table-wrap { overflow:auto; padding:0; } table { width:100%; border-collapse:collapse; min-width:700px; } th,td { padding:14px 18px; text-align:left; border-bottom:1px solid var(--line); vertical-align:middle; } th { color:var(--muted); font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:0; background:#fbfcfd; } tbody tr:last-child td { border-bottom:0; } td { font-size:13px; } code { font-family:"SFMono-Regular",Consolas,monospace; font-size:12px; color:#486071; } .badge,.tag { display:inline-flex; align-items:center; padding:4px 7px; font-size:11px; line-height:1; } .badge { border:1px solid #d9e0e4; color:var(--muted); } .badge.active { color:#187151; background:#e9f7f0; border-color:#c7e9d9; } .badge.revoked,.badge.suspended { color:var(--danger); background:#fff0f0; border-color:#f0cccc; } .tag { color:#4c6572; background:#eef3f5; margin:2px 3px 2px 0; } .text-button { border:0; background:transparent; color:var(--accent); padding:5px 0; } .text-button.danger { color:var(--danger); } .empty { color:var(--muted); text-align:center; padding:32px; } .truncate { max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.route-editor { grid-column:1/-1; display:flex; flex-direction:column; gap:8px; } .route-row { display:grid; grid-template-columns:1.4fr 1.4fr .6fr .6fr 34px; gap:8px; } .icon-button { border:1px solid var(--line); background:#fff; color:var(--muted); width:34px; height:34px; font-size:19px; } .route-list { display:flex; flex-direction:column; gap:3px; color:#486071; font-size:12px; } .route-list em { color:var(--muted); font-style:normal; margin-left:4px; }
.hidden { display:none !important; } .visually-hidden { position:absolute !important; width:1px !important; height:1px !important; padding:0 !important; margin:-1px !important; overflow:hidden !important; clip:rect(0,0,0,0) !important; white-space:nowrap !important; border:0 !important; } .billing-actions { display:grid; grid-template-columns:1fr 1fr; gap:16px; } .compact-form { grid-template-columns:1fr 1fr; } .compact-form .button { grid-column:1/-1; } .currency-label { color:var(--muted); font-size:12px; text-transform:uppercase; } .ledger-heading { margin-top:28px; } .money-positive { color:#187151; } .money-negative { color:var(--danger); }
+.account-grid { display:grid; grid-template-columns:1fr 1fr; gap:16px; } .account-grid .panel { align-content:start; } #totp-qr { width:180px; height:180px; border:1px solid var(--line); } #totp-secret { overflow-wrap:anywhere; } .table-wrap > .section-heading { padding:18px; margin:0; align-items:center; border-bottom:1px solid var(--line); }
.price-line { display:block; color:var(--muted); font-size:10px; margin-top:5px; white-space:nowrap; }
.muted,.error-label { display:block; color:var(--muted); font-size:11px; margin-top:4px; } .error-label { color:var(--danger); } .limits-table input { min-width:118px; padding:8px 9px; } .limits-table .button { min-height:36px; } input:disabled,select:disabled { background:#f5f7f8; color:#697985; cursor:not-allowed; }
-.toast { position:fixed; bottom:24px; right:24px; background:#102a3a; color:#fff; padding:12px 16px; opacity:0; transform:translateY(8px); pointer-events:none; transition:.2s; } .toast.visible { opacity:1; transform:none; } .toast.error { background:#8f3d42; } dialog { border:0; padding:0; width:min(460px,calc(100% - 32px)); box-shadow:0 18px 70px rgba(0,0,0,.22); } dialog::backdrop { background:rgba(16,42,58,.45); } .dialog-content { padding:24px; } .dialog-content p { color:var(--muted); } .dialog-content code { display:block; background:#f3f5f7; padding:15px; overflow:auto; color:var(--ink); margin:18px 0; }
-@media (max-width:900px) { .metric-grid { grid-template-columns:repeat(3,1fr); } .form-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } .form-grid .button.primary { grid-column:1/-1; } .billing-actions { grid-template-columns:1fr; } }
+.toast { position:fixed; bottom:24px; right:24px; background:#102a3a; color:#fff; padding:12px 16px; opacity:0; transform:translateY(8px); pointer-events:none; transition:.2s; } .toast.visible { opacity:1; transform:none; } .toast.error { background:#8f3d42; } dialog { border:0; padding:0; width:min(460px,calc(100% - 32px)); box-shadow:0 18px 70px rgba(0,0,0,.22); } dialog::backdrop { background:rgba(16,42,58,.45); } .dialog-content { padding:24px; } .dialog-content p { color:var(--muted); } .dialog-content code { display:block; background:#f3f5f7; padding:15px; overflow:auto; color:var(--ink); margin:18px 0; white-space:pre-wrap; }
+@media (max-width:900px) { .metric-grid { grid-template-columns:repeat(3,1fr); } .form-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } .form-grid .button.primary { grid-column:1/-1; } .billing-actions,.account-grid { grid-template-columns:1fr; } }
@media (max-width:620px) { .auth-screen { grid-template-columns:1fr; background:#fff; } .auth-brand { background:#102a3a; padding:22px; } .auth-panel { padding:28px 22px 50px; } .auth-tabs { overflow:auto; } .topbar { height:auto; padding:16px; align-items:flex-start; } .session { margin-left:auto; } .session div { align-items:flex-end; max-width:150px; } .session .actor-label { max-width:150px; } .shell { width:calc(100% - 24px); margin-top:18px; } .tabs { margin-bottom:20px; flex-wrap:nowrap; overflow:auto; } .metric-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } .metric { padding:14px; } .metric strong { font-size:22px; overflow-wrap:anywhere; } .form-grid { grid-template-columns:1fr; } .route-row { grid-template-columns:minmax(0,1fr) minmax(0,1fr) 34px; } .route-provider,.route-upstream { grid-column:1/-1; } h1 { font-size:24px; } .section-heading { align-items:flex-start; } }
diff --git a/internal/billing/ledger.go b/internal/billing/ledger.go
index 28cbe4c..c408cfe 100644
--- a/internal/billing/ledger.go
+++ b/internal/billing/ledger.go
@@ -73,6 +73,44 @@ func (s *Service) ListLedger(ctx context.Context, tenantID string, limit int) ([
return result, rows.Err()
}
+func (s *Service) ListTopUpOrders(ctx context.Context, tenantID string, limit int) ([]TopUpOrder, error) {
+ if limit < 1 || limit > 200 {
+ limit = 50
+ }
+ rows, err := s.db.Query(ctx, `SELECT id::text,tenant_id::text,amount_minor,amount_micros,currency,status,
+ COALESCE(stripe_session_id,''),COALESCE(checkout_url,''),created_at,paid_at FROM topup_orders
+ WHERE tenant_id=$1 ORDER BY created_at DESC LIMIT $2`, tenantID, limit)
+ if err != nil {
+ return nil, fmt.Errorf("query top-up orders: %w", err)
+ }
+ defer rows.Close()
+ result := make([]TopUpOrder, 0)
+ for rows.Next() {
+ var item TopUpOrder
+ if err := rows.Scan(&item.ID, &item.TenantID, &item.AmountMinor, &item.AmountMicros, &item.Currency, &item.Status,
+ &item.StripeSessionID, &item.CheckoutURL, &item.CreatedAt, &item.PaidAt); err != nil {
+ return nil, err
+ }
+ result = append(result, item)
+ }
+ return result, rows.Err()
+}
+
+func (s *Service) GetTopUpOrder(ctx context.Context, tenantID, orderID string) (TopUpOrder, error) {
+ var result TopUpOrder
+ err := s.db.QueryRow(ctx, `SELECT id::text,tenant_id::text,amount_minor,amount_micros,currency,status,
+ COALESCE(stripe_session_id,''),COALESCE(checkout_url,''),created_at,paid_at FROM topup_orders
+ WHERE id=$1 AND tenant_id=$2`, orderID, tenantID).Scan(&result.ID, &result.TenantID, &result.AmountMinor, &result.AmountMicros,
+ &result.Currency, &result.Status, &result.StripeSessionID, &result.CheckoutURL, &result.CreatedAt, &result.PaidAt)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return TopUpOrder{}, ErrTopUpOrderNotFound
+ }
+ if err != nil {
+ return TopUpOrder{}, fmt.Errorf("get top-up order: %w", err)
+ }
+ return result, nil
+}
+
func (s *Service) AdjustBalance(ctx context.Context, input AdjustmentInput) (LedgerEntry, error) {
input.TenantID = strings.TrimSpace(input.TenantID)
input.Description = normalizeDescription(input.Description)
diff --git a/internal/billing/service_test.go b/internal/billing/service_test.go
index 17f8143..f675da7 100644
--- a/internal/billing/service_test.go
+++ b/internal/billing/service_test.go
@@ -85,6 +85,18 @@ func TestIntegrationIdentifierSuffixUsesLetters(t *testing.T) {
}
}
+func TestCheckoutReturnURLPreservesCallbackAndSessionPlaceholder(t *testing.T) {
+ success := checkoutReturnURL("https://console.example.test/admin/?topup=success", "order-123", true)
+ if !strings.Contains(success, "topup=success") || !strings.Contains(success, "order_id=order-123") ||
+ !strings.Contains(success, "session_id={CHECKOUT_SESSION_ID}") {
+ t.Fatalf("unexpected success URL %q", success)
+ }
+ cancel := checkoutReturnURL("https://console.example.test/admin/?topup=cancel&session_id=stale", "order-123", false)
+ if !strings.Contains(cancel, "topup=cancel") || !strings.Contains(cancel, "order_id=order-123") || strings.Contains(cancel, "session_id=") {
+ t.Fatalf("unexpected cancel URL %q", cancel)
+ }
+}
+
func TestWebhookRejectsInvalidSignatureBeforeProcessing(t *testing.T) {
service := &Service{stripeWebhookSecret: "whsec_test"}
request := httptest.NewRequest(http.MethodPost, "/billing/stripe/webhook", strings.NewReader(`{"id":"evt_fake"}`))
diff --git a/internal/billing/stripe.go b/internal/billing/stripe.go
index b06eb6a..b887035 100644
--- a/internal/billing/stripe.go
+++ b/internal/billing/stripe.go
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/http"
+ "net/url"
"strings"
"github.com/jackc/pgx/v5"
@@ -32,8 +33,8 @@ func (s *Service) CreateCheckout(ctx context.Context, input CheckoutInput) (Chec
Mode: stripe.String("payment"),
ClientReferenceID: stripe.String(orderID),
IntegrationIdentifier: stripe.String(s.integrationIdentifier),
- SuccessURL: stripe.String(s.stripeSuccessURL),
- CancelURL: stripe.String(s.stripeCancelURL),
+ SuccessURL: stripe.String(checkoutReturnURL(s.stripeSuccessURL, orderID, true)),
+ CancelURL: stripe.String(checkoutReturnURL(s.stripeCancelURL, orderID, false)),
Metadata: map[string]string{
"aigw_topup_order_id": orderID,
"aigw_tenant_id": strings.TrimSpace(input.TenantID),
@@ -67,6 +68,24 @@ func (s *Service) CreateCheckout(ctx context.Context, input CheckoutInput) (Chec
return CheckoutResult{OrderID: orderID, SessionID: session.ID, URL: session.URL}, nil
}
+func checkoutReturnURL(raw, orderID string, includeStripeSession bool) string {
+ parsed, err := url.Parse(raw)
+ if err != nil {
+ return raw
+ }
+ query := parsed.Query()
+ query.Set("order_id", orderID)
+ if includeStripeSession {
+ query.Set("session_id", "{CHECKOUT_SESSION_ID}")
+ } else {
+ query.Del("session_id")
+ }
+ encoded := query.Encode()
+ encoded = strings.ReplaceAll(encoded, url.QueryEscape("{CHECKOUT_SESSION_ID}"), "{CHECKOUT_SESSION_ID}")
+ parsed.RawQuery = encoded
+ return parsed.String()
+}
+
func (s *Service) WebhookHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
diff --git a/internal/billing/types.go b/internal/billing/types.go
index 24694cc..3d0461e 100644
--- a/internal/billing/types.go
+++ b/internal/billing/types.go
@@ -13,6 +13,7 @@ var (
ErrStripeDisabled = errors.New("Stripe top-ups are disabled")
ErrInvalidAmount = errors.New("invalid amount")
ErrQuotaExceeded = errors.New("monthly spend quota exceeded")
+ ErrTopUpOrderNotFound = errors.New("top-up order not found")
)
type Meter interface {
@@ -81,3 +82,16 @@ type CheckoutResult struct {
SessionID string `json:"session_id"`
URL string `json:"url"`
}
+
+type TopUpOrder struct {
+ ID string `json:"id"`
+ TenantID string `json:"tenant_id"`
+ AmountMinor int64 `json:"amount_minor"`
+ AmountMicros int64 `json:"amount_micros"`
+ Currency string `json:"currency"`
+ Status string `json:"status"`
+ StripeSessionID string `json:"stripe_session_id,omitempty"`
+ CheckoutURL string `json:"checkout_url,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ PaidAt *time.Time `json:"paid_at,omitempty"`
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 97ecb21..376cf77 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -26,7 +26,8 @@ type Config struct {
}
type ServerConfig struct {
- Address string `json:"address"`
+ Address string `json:"-"`
+ AddressEnv string `json:"address_env"`
MaxBodyBytes int64 `json:"max_body_bytes"`
ReadHeaderTimeoutSecs int `json:"read_header_timeout_seconds"`
IdleTimeoutSecs int `json:"idle_timeout_seconds"`
@@ -53,12 +54,40 @@ type ControlPlaneConfig struct {
}
type AdminConfig struct {
- Enabled bool `json:"enabled"`
- TokenEnv string `json:"token_env"`
- BasePath string `json:"base_path"`
- RegistrationEnabled bool `json:"registration_enabled"`
- SessionTTLHours int `json:"session_ttl_hours"`
- Token string `json:"-"`
+ Enabled bool `json:"enabled"`
+ TokenEnv string `json:"token_env"`
+ BasePath string `json:"base_path"`
+ RegistrationEnabled bool `json:"registration_enabled"`
+ SessionTTLHours int `json:"session_ttl_hours"`
+ PublicURL string `json:"-"`
+ PublicURLEnv string `json:"public_url_env"`
+ Mail MailConfig `json:"mail"`
+ WebAuthn WebAuthnConfig `json:"webauthn"`
+ Token string `json:"-"`
+}
+
+type MailConfig struct {
+ Enabled bool `json:"enabled"`
+ FromName string `json:"from_name"`
+ TLSMode string `json:"tls_mode"`
+ FromAddressEnv string `json:"from_address_env"`
+ SMTPAddressEnv string `json:"smtp_address_env"`
+ SMTPUsernameEnv string `json:"smtp_username_env"`
+ SMTPPasswordEnv string `json:"smtp_password_env"`
+ SMTPImplicitTLS bool `json:"smtp_implicit_tls"`
+ FromAddress string `json:"-"`
+ SMTPAddress string `json:"-"`
+ SMTPUsername string `json:"-"`
+ SMTPPassword string `json:"-"`
+}
+
+type WebAuthnConfig struct {
+ Enabled bool `json:"enabled"`
+ RPDisplayName string `json:"rp_display_name"`
+ RPIDEnv string `json:"rp_id_env"`
+ OriginsEnv string `json:"origins_env"`
+ RPID string `json:"-"`
+ Origins []string `json:"-"`
}
type UpstreamHTTPConfig struct {
@@ -69,11 +98,12 @@ type UpstreamHTTPConfig struct {
}
type ProviderConfig struct {
- ID string `json:"id"`
- Protocol domain.Protocol `json:"protocol"`
- BaseURL string `json:"base_url"`
- APIKeyEnv string `json:"api_key_env"`
- APIKey string `json:"-"`
+ ID string `json:"id"`
+ Protocol domain.Protocol `json:"protocol"`
+ BaseURL string `json:"-"`
+ BaseURLEnv string `json:"base_url_env"`
+ APIKeyEnv string `json:"api_key_env"`
+ APIKey string `json:"-"`
}
type ModelConfig struct {
@@ -111,8 +141,10 @@ type StripeConfig struct {
Enabled bool `json:"enabled"`
APIKeyEnv string `json:"api_key_env"`
WebhookSecretEnv string `json:"webhook_secret_env"`
- SuccessURL string `json:"success_url"`
- CancelURL string `json:"cancel_url"`
+ SuccessURL string `json:"-"`
+ CancelURL string `json:"-"`
+ SuccessURLEnv string `json:"success_url_env"`
+ CancelURLEnv string `json:"cancel_url_env"`
APIKey string `json:"-"`
WebhookSecret string `json:"-"`
}
@@ -148,6 +180,9 @@ func Load(path string) (Config, error) {
}
func applyDefaults(cfg *Config) {
+ if cfg.Server.AddressEnv == "" {
+ cfg.Server.AddressEnv = "AIGW_SERVER_ADDRESS"
+ }
if cfg.Server.Address == "" {
cfg.Server.Address = ":8080"
}
@@ -193,6 +228,36 @@ func applyDefaults(cfg *Config) {
if cfg.Admin.SessionTTLHours == 0 {
cfg.Admin.SessionTTLHours = 12
}
+ if cfg.Admin.PublicURLEnv == "" {
+ cfg.Admin.PublicURLEnv = "AIGW_PUBLIC_URL"
+ }
+ if cfg.Admin.Mail.FromName == "" {
+ cfg.Admin.Mail.FromName = "AIGW"
+ }
+ if cfg.Admin.Mail.TLSMode == "" {
+ cfg.Admin.Mail.TLSMode = "starttls"
+ }
+ if cfg.Admin.Mail.FromAddressEnv == "" {
+ cfg.Admin.Mail.FromAddressEnv = "AIGW_SMTP_FROM_ADDRESS"
+ }
+ if cfg.Admin.Mail.SMTPAddressEnv == "" {
+ cfg.Admin.Mail.SMTPAddressEnv = "AIGW_SMTP_ADDRESS"
+ }
+ if cfg.Admin.Mail.SMTPUsernameEnv == "" {
+ cfg.Admin.Mail.SMTPUsernameEnv = "AIGW_SMTP_USERNAME"
+ }
+ if cfg.Admin.Mail.SMTPPasswordEnv == "" {
+ cfg.Admin.Mail.SMTPPasswordEnv = "AIGW_SMTP_PASSWORD"
+ }
+ if cfg.Admin.WebAuthn.RPDisplayName == "" {
+ cfg.Admin.WebAuthn.RPDisplayName = "AIGW Console"
+ }
+ if cfg.Admin.WebAuthn.RPIDEnv == "" {
+ cfg.Admin.WebAuthn.RPIDEnv = "AIGW_WEBAUTHN_RP_ID"
+ }
+ if cfg.Admin.WebAuthn.OriginsEnv == "" {
+ cfg.Admin.WebAuthn.OriginsEnv = "AIGW_WEBAUTHN_ORIGINS"
+ }
if cfg.UpstreamHTTP.MaxIdleConnections == 0 {
cfg.UpstreamHTTP.MaxIdleConnections = 4096
}
@@ -226,6 +291,12 @@ func applyDefaults(cfg *Config) {
if cfg.Billing.Stripe.WebhookSecretEnv == "" {
cfg.Billing.Stripe.WebhookSecretEnv = "AIGW_STRIPE_WEBHOOK_SECRET"
}
+ if cfg.Billing.Stripe.SuccessURLEnv == "" {
+ cfg.Billing.Stripe.SuccessURLEnv = "AIGW_STRIPE_SUCCESS_URL"
+ }
+ if cfg.Billing.Stripe.CancelURLEnv == "" {
+ cfg.Billing.Stripe.CancelURLEnv = "AIGW_STRIPE_CANCEL_URL"
+ }
for i := range cfg.Models {
for j := range cfg.Models[i].Routes {
if cfg.Models[i].Routes[j].Weight == 0 {
@@ -236,6 +307,9 @@ func applyDefaults(cfg *Config) {
}
func resolveSecrets(cfg *Config) error {
+ if value := strings.TrimSpace(os.Getenv(cfg.Server.AddressEnv)); value != "" {
+ cfg.Server.Address = value
+ }
if cfg.ControlPlane.Enabled {
cfg.ControlPlane.DatabaseURL = os.Getenv(cfg.ControlPlane.DatabaseURLEnv)
cfg.ControlPlane.RedisURL = os.Getenv(cfg.ControlPlane.RedisURLEnv)
@@ -243,13 +317,42 @@ func resolveSecrets(cfg *Config) error {
}
if cfg.Admin.Enabled {
cfg.Admin.Token = os.Getenv(cfg.Admin.TokenEnv)
+ if err := resolveRequiredEnv(&cfg.Admin.PublicURL, cfg.Admin.PublicURLEnv, "admin.public_url"); err != nil {
+ return err
+ }
+ if cfg.Admin.Mail.Enabled {
+ cfg.Admin.Mail.FromAddress = strings.TrimSpace(os.Getenv(cfg.Admin.Mail.FromAddressEnv))
+ cfg.Admin.Mail.SMTPAddress = strings.TrimSpace(os.Getenv(cfg.Admin.Mail.SMTPAddressEnv))
+ cfg.Admin.Mail.SMTPUsername = os.Getenv(cfg.Admin.Mail.SMTPUsernameEnv)
+ cfg.Admin.Mail.SMTPPassword = os.Getenv(cfg.Admin.Mail.SMTPPasswordEnv)
+ }
+ if cfg.Admin.WebAuthn.Enabled {
+ cfg.Admin.WebAuthn.RPID = strings.TrimSpace(os.Getenv(cfg.Admin.WebAuthn.RPIDEnv))
+ for _, origin := range strings.Split(os.Getenv(cfg.Admin.WebAuthn.OriginsEnv), ",") {
+ if origin = strings.TrimSpace(origin); origin != "" {
+ cfg.Admin.WebAuthn.Origins = append(cfg.Admin.WebAuthn.Origins, origin)
+ }
+ }
+ }
}
if cfg.Billing.Enabled && cfg.Billing.Stripe.Enabled {
cfg.Billing.Stripe.APIKey = os.Getenv(cfg.Billing.Stripe.APIKeyEnv)
cfg.Billing.Stripe.WebhookSecret = os.Getenv(cfg.Billing.Stripe.WebhookSecretEnv)
+ if err := resolveRequiredEnv(&cfg.Billing.Stripe.SuccessURL, cfg.Billing.Stripe.SuccessURLEnv, "billing.stripe.success_url"); err != nil {
+ return err
+ }
+ if err := resolveRequiredEnv(&cfg.Billing.Stripe.CancelURL, cfg.Billing.Stripe.CancelURLEnv, "billing.stripe.cancel_url"); err != nil {
+ return err
+ }
}
for i := range cfg.Providers {
provider := &cfg.Providers[i]
+ if provider.BaseURLEnv == "" {
+ return fmt.Errorf("provider %q: base_url_env is required", provider.ID)
+ }
+ if err := resolveRequiredEnv(&provider.BaseURL, provider.BaseURLEnv, fmt.Sprintf("provider %q base_url", provider.ID)); err != nil {
+ return err
+ }
if provider.APIKeyEnv == "" {
continue
}
@@ -261,6 +364,18 @@ func resolveSecrets(cfg *Config) error {
return nil
}
+func resolveRequiredEnv(target *string, environment, field string) error {
+ if environment == "" {
+ return nil
+ }
+ value := strings.TrimSpace(os.Getenv(environment))
+ if value == "" {
+ return fmt.Errorf("%s: environment variable %s is empty", field, environment)
+ }
+ *target = value
+ return nil
+}
+
func Validate(cfg Config) error {
if cfg.Server.MaxBodyBytes < 1024 {
return errors.New("server.max_body_bytes must be at least 1024")
@@ -293,6 +408,34 @@ func Validate(cfg Config) error {
if cfg.Admin.SessionTTLHours < 1 || cfg.Admin.SessionTTLHours > 720 {
return errors.New("admin.session_ttl_hours must be between 1 and 720")
}
+ publicURL, err := url.Parse(cfg.Admin.PublicURL)
+ if err != nil || publicURL.Host == "" || (publicURL.Scheme != "http" && publicURL.Scheme != "https") {
+ return errors.New("admin.public_url must resolve from an environment variable to an absolute http(s) URL")
+ }
+ if cfg.Admin.Mail.Enabled {
+ if cfg.Admin.Mail.FromAddress == "" || cfg.Admin.Mail.SMTPAddress == "" {
+ return errors.New("admin.mail requires SMTP address and from address environment variables")
+ }
+ if (cfg.Admin.Mail.SMTPUsername == "") != (cfg.Admin.Mail.SMTPPassword == "") {
+ return errors.New("admin.mail SMTP username and password must both be set or both be empty")
+ }
+ switch cfg.Admin.Mail.TLSMode {
+ case "starttls", "tls", "none":
+ default:
+ return errors.New("admin.mail.tls_mode must be starttls, tls, or none")
+ }
+ }
+ if cfg.Admin.WebAuthn.Enabled {
+ if cfg.Admin.WebAuthn.RPID == "" || len(cfg.Admin.WebAuthn.Origins) == 0 {
+ return errors.New("admin.webauthn requires RP ID and origins environment variables")
+ }
+ for _, origin := range cfg.Admin.WebAuthn.Origins {
+ parsed, err := url.Parse(origin)
+ if err != nil || parsed.Host == "" || (parsed.Scheme != "https" && !(parsed.Scheme == "http" && parsed.Hostname() == "localhost")) {
+ return fmt.Errorf("admin.webauthn origin %q must use https (http is allowed only for localhost)", origin)
+ }
+ }
+ }
}
if cfg.Billing.Enabled {
if !cfg.ControlPlane.Enabled {
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 2331b0f..1ecf70f 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -8,20 +8,25 @@ import (
func TestLoadAppliesDefaultsAndResolvesSecrets(t *testing.T) {
t.Setenv("TEST_UPSTREAM_KEY", "secret")
+ t.Setenv("TEST_UPSTREAM_URL", "https://example.com/v1")
+ t.Setenv("AIGW_SERVER_ADDRESS", "127.0.0.1:9090")
path := writeConfig(t, `{
- "providers": [{"id":"primary","protocol":"openai","base_url":"https://example.com/v1","api_key_env":"TEST_UPSTREAM_KEY"}],
+ "providers": [{"id":"primary","protocol":"openai","base_url_env":"TEST_UPSTREAM_URL","api_key_env":"TEST_UPSTREAM_KEY"}],
"models": [{"id":"example/model","routes":[{"provider":"primary","upstream_model":"model"}]}]
}`)
cfg, err := Load(path)
if err != nil {
t.Fatal(err)
}
- if cfg.Server.Address != ":8080" || cfg.Server.MaxBodyBytes == 0 {
+ if cfg.Server.Address != "127.0.0.1:9090" || cfg.Server.MaxBodyBytes == 0 {
t.Fatalf("defaults not applied: %+v", cfg.Server)
}
if cfg.Providers[0].APIKey != "secret" {
t.Fatal("provider secret was not resolved")
}
+ if cfg.Providers[0].BaseURL != "https://example.com/v1" {
+ t.Fatal("provider URL was not resolved")
+ }
if cfg.Models[0].Routes[0].Weight != 1 {
t.Fatalf("expected default route weight 1, got %d", cfg.Models[0].Routes[0].Weight)
}
@@ -40,11 +45,24 @@ func TestLoadRejectsUnknownFieldsAndTrailingData(t *testing.T) {
}
}
+func TestLoadRejectsLiteralExternalServiceValues(t *testing.T) {
+ for _, content := range []string{
+ `{"server":{"address":":9090"}}`,
+ `{"providers":[{"id":"primary","protocol":"openai","base_url":"https://example.test","api_key_env":"TEST_KEY"}]}`,
+ `{"billing":{"stripe":{"success_url":"https://console.example.test"}}}`,
+ } {
+ if _, err := Load(writeConfig(t, content)); err == nil {
+ t.Fatalf("expected literal external service value to be rejected: %s", content)
+ }
+ }
+}
+
func TestLoadControlPlaneModeWithoutStaticRoutes(t *testing.T) {
t.Setenv("AIGW_DATABASE_URL", "postgres://aigw:aigw@postgres/aigw")
t.Setenv("AIGW_REDIS_URL", "redis://redis:6379/0")
t.Setenv("AIGW_CREDENTIAL_KEY", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
t.Setenv("AIGW_ADMIN_TOKEN", "admin-secret")
+ t.Setenv("AIGW_PUBLIC_URL", "http://localhost:8080/admin/")
path := writeConfig(t, `{
"control_plane": {"enabled":true},
"admin": {"enabled":true}
@@ -90,9 +108,11 @@ func TestLoadResolvesStripeSecrets(t *testing.T) {
t.Setenv("AIGW_CREDENTIAL_KEY", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
t.Setenv("TEST_STRIPE_KEY", "rk_test_example")
t.Setenv("TEST_STRIPE_WEBHOOK", "whsec_example")
+ t.Setenv("TEST_STRIPE_SUCCESS", "https://console.example.test/admin/?topup=success")
+ t.Setenv("TEST_STRIPE_CANCEL", "https://console.example.test/admin/?topup=cancel")
path := writeConfig(t, `{
"control_plane":{"enabled":true},
- "billing":{"enabled":true,"stripe":{"enabled":true,"api_key_env":"TEST_STRIPE_KEY","webhook_secret_env":"TEST_STRIPE_WEBHOOK","success_url":"http://localhost/admin/?topup=success","cancel_url":"http://localhost/admin/?topup=cancel"}}
+ "billing":{"enabled":true,"stripe":{"enabled":true,"api_key_env":"TEST_STRIPE_KEY","webhook_secret_env":"TEST_STRIPE_WEBHOOK","success_url_env":"TEST_STRIPE_SUCCESS","cancel_url_env":"TEST_STRIPE_CANCEL"}}
}`)
cfg, err := Load(path)
if err != nil {
@@ -101,6 +121,21 @@ func TestLoadResolvesStripeSecrets(t *testing.T) {
if cfg.Billing.Stripe.APIKey != "rk_test_example" || cfg.Billing.Stripe.WebhookSecret != "whsec_example" {
t.Fatal("Stripe secrets were not resolved")
}
+ if cfg.Billing.Stripe.SuccessURL != "https://console.example.test/admin/?topup=success" || cfg.Billing.Stripe.CancelURL != "https://console.example.test/admin/?topup=cancel" {
+ t.Fatal("Stripe callback URLs were not resolved")
+ }
+}
+
+func TestLoadRejectsEmptyExternalServiceEnvironment(t *testing.T) {
+ t.Setenv("TEST_UPSTREAM_KEY", "secret")
+ t.Setenv("TEST_UPSTREAM_URL", "")
+ path := writeConfig(t, `{
+ "providers": [{"id":"primary","protocol":"openai","base_url_env":"TEST_UPSTREAM_URL","api_key_env":"TEST_UPSTREAM_KEY"}],
+ "models": [{"id":"example/model","routes":[{"provider":"primary","upstream_model":"model"}]}]
+}`)
+ if _, err := Load(path); err == nil {
+ t.Fatal("expected empty provider URL environment variable to be rejected")
+ }
}
func TestLoadRejectsBillingWithoutControlPlane(t *testing.T) {
@@ -110,6 +145,45 @@ func TestLoadRejectsBillingWithoutControlPlane(t *testing.T) {
}
}
+func TestVersionedExamplesResolveExternalServicesFromEnvironment(t *testing.T) {
+ t.Setenv("AIGW_SERVER_ADDRESS", "127.0.0.1:18081")
+ t.Setenv("AIGW_API_KEYS", `[{"key":"test","key_id":"key","tenant_id":"tenant","project_id":"project","scopes":["inference"]}]`)
+ t.Setenv("OPENAI_BASE_URL", "https://openai.example.test/v1")
+ t.Setenv("OPENAI_API_KEY", "openai-secret")
+ t.Setenv("ANTHROPIC_BASE_URL", "https://anthropic.example.test/v1")
+ t.Setenv("ANTHROPIC_API_KEY", "anthropic-secret")
+ staticConfig, err := Load(filepath.Join("..", "..", "config.example.json"))
+ if err != nil {
+ t.Fatalf("load static example: %v", err)
+ }
+ if staticConfig.Providers[0].BaseURL != "https://openai.example.test/v1" {
+ t.Fatalf("static provider URL = %q", staticConfig.Providers[0].BaseURL)
+ }
+
+ t.Setenv("AIGW_DATABASE_URL", "postgres://example:secret@postgres.example.test/aigw")
+ t.Setenv("AIGW_REDIS_URL", "redis://redis.example.test:6379/0")
+ t.Setenv("AIGW_CREDENTIAL_KEY", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
+ t.Setenv("AIGW_ADMIN_TOKEN", "bootstrap-secret")
+ t.Setenv("AIGW_PUBLIC_URL", "https://console.example.test/admin/")
+ t.Setenv("AIGW_SMTP_FROM_ADDRESS", "no-reply@example.test")
+ t.Setenv("AIGW_SMTP_ADDRESS", "smtp.example.test:587")
+ t.Setenv("AIGW_SMTP_USERNAME", "")
+ t.Setenv("AIGW_SMTP_PASSWORD", "")
+ t.Setenv("AIGW_WEBAUTHN_RP_ID", "console.example.test")
+ t.Setenv("AIGW_WEBAUTHN_ORIGINS", "https://console.example.test")
+ t.Setenv("AIGW_STRIPE_API_KEY", "rk_test_example")
+ t.Setenv("AIGW_STRIPE_WEBHOOK_SECRET", "whsec_example")
+ t.Setenv("AIGW_STRIPE_SUCCESS_URL", "https://console.example.test/admin/?topup=success")
+ t.Setenv("AIGW_STRIPE_CANCEL_URL", "https://console.example.test/admin/?topup=cancel")
+ controlConfig, err := Load(filepath.Join("..", "..", "config.control.example.json"))
+ if err != nil {
+ t.Fatalf("load control-plane example: %v", err)
+ }
+ if controlConfig.Billing.Stripe.SuccessURL != "https://console.example.test/admin/?topup=success" {
+ t.Fatalf("Stripe success URL = %q", controlConfig.Billing.Stripe.SuccessURL)
+ }
+}
+
func writeConfig(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.json")
diff --git a/internal/controlplane/access.go b/internal/controlplane/access.go
index 0792e8b..47e9a8f 100644
--- a/internal/controlplane/access.go
+++ b/internal/controlplane/access.go
@@ -88,7 +88,11 @@ func (s *Store) AuthenticateConsoleToken(ctx context.Context, raw string) (Conso
func (s *Store) ListConsoleUsers(ctx context.Context, tenantID string) ([]ConsoleUser, error) {
query := `SELECT id::text, COALESCE(tenant_id::text, ''), email, display_name, role,
- COALESCE(token_prefix, ''), password_hash IS NOT NULL, status, last_used_at, created_at FROM console_users`
+ COALESCE(token_prefix, ''), password_hash IS NOT NULL, status, email_verified_at IS NOT NULL,
+ ARRAY_REMOVE(ARRAY[
+ CASE WHEN EXISTS(SELECT 1 FROM console_totp_credentials tc WHERE tc.user_id=console_users.id AND tc.confirmed_at IS NOT NULL) THEN 'totp' END,
+ CASE WHEN EXISTS(SELECT 1 FROM console_passkeys pk WHERE pk.user_id=console_users.id) THEN 'passkey' END
+ ],NULL), last_used_at, created_at FROM console_users`
args := []any{}
if tenantID != "" {
query += ` WHERE tenant_id = $1`
@@ -103,7 +107,8 @@ func (s *Store) ListConsoleUsers(ctx context.Context, tenantID string) ([]Consol
result := make([]ConsoleUser, 0)
for rows.Next() {
var item ConsoleUser
- if err := rows.Scan(&item.ID, &item.TenantID, &item.Email, &item.DisplayName, &item.Role, &item.TokenPrefix, &item.HasPassword, &item.Status, &item.LastUsedAt, &item.CreatedAt); err != nil {
+ if err := rows.Scan(&item.ID, &item.TenantID, &item.Email, &item.DisplayName, &item.Role, &item.TokenPrefix, &item.HasPassword,
+ &item.Status, &item.EmailVerified, &item.MFAMethods, &item.LastUsedAt, &item.CreatedAt); err != nil {
return nil, fmt.Errorf("scan console user: %w", err)
}
result = append(result, item)
@@ -217,20 +222,23 @@ func (s *Store) RegisterTenant(ctx context.Context, input RegisterInput, currenc
func (s *Store) AuthenticateConsolePassword(ctx context.Context, input PasswordLoginInput, remoteIP string) (ConsoleActor, error) {
email := strings.ToLower(strings.TrimSpace(input.Email))
- identity := sha256.Sum256([]byte(email + "\x00" + remoteIP))
- var lockedUntil *time.Time
- err := s.db.QueryRow(ctx, `SELECT locked_until FROM console_login_throttles WHERE identity_hash=$1`, identity[:]).Scan(&lockedUntil)
- if err != nil && !errors.Is(err, pgx.ErrNoRows) {
- return ConsoleActor{}, fmt.Errorf("read console login throttle: %w", err)
- }
- if lockedUntil != nil && lockedUntil.After(time.Now()) {
- return ConsoleActor{}, ErrConsoleRateLimited
+ emailIdentity := sha256.Sum256([]byte("email\x00" + email))
+ ipIdentity := sha256.Sum256([]byte("ip\x00" + remoteIP))
+ for _, identity := range [][]byte{emailIdentity[:], ipIdentity[:]} {
+ var lockedUntil *time.Time
+ err := s.db.QueryRow(ctx, `SELECT locked_until FROM console_login_throttles WHERE identity_hash=$1`, identity).Scan(&lockedUntil)
+ if err != nil && !errors.Is(err, pgx.ErrNoRows) {
+ return ConsoleActor{}, fmt.Errorf("read console login throttle: %w", err)
+ }
+ if lockedUntil != nil && lockedUntil.After(time.Now()) {
+ return ConsoleActor{}, ErrConsoleRateLimited
+ }
}
var actor ConsoleActor
var expectedHash, salt []byte
var iterations int
- err = s.db.QueryRow(ctx, `SELECT u.id::text, COALESCE(u.tenant_id::text,''), u.email, u.display_name, u.role,
+ err := s.db.QueryRow(ctx, `SELECT u.id::text, COALESCE(u.tenant_id::text,''), u.email, u.display_name, u.role,
u.password_hash, u.password_salt, u.password_iterations
FROM console_users u LEFT JOIN tenants t ON t.id=u.tenant_id
WHERE lower(u.email)=$1 AND u.status='active' AND u.password_hash IS NOT NULL
@@ -240,7 +248,7 @@ func (s *Store) AuthenticateConsolePassword(ctx context.Context, input PasswordL
dummyHash := make([]byte, security.PasswordHashBytes)
dummySalt := make([]byte, security.PasswordSaltBytes)
_ = security.VerifyPassword(input.Password, dummyHash, dummySalt, security.PasswordIterations)
- if failureErr := s.recordLoginFailure(ctx, identity[:]); failureErr != nil {
+ if failureErr := s.recordLoginFailures(ctx, emailIdentity[:], ipIdentity[:]); failureErr != nil {
return ConsoleActor{}, failureErr
}
return ConsoleActor{}, ErrConsoleUnauthorized
@@ -249,27 +257,38 @@ func (s *Store) AuthenticateConsolePassword(ctx context.Context, input PasswordL
return ConsoleActor{}, fmt.Errorf("query console login: %w", err)
}
if !security.VerifyPassword(input.Password, expectedHash, salt, iterations) {
- if failureErr := s.recordLoginFailure(ctx, identity[:]); failureErr != nil {
+ if failureErr := s.recordLoginFailures(ctx, emailIdentity[:], ipIdentity[:]); failureErr != nil {
return ConsoleActor{}, failureErr
}
return ConsoleActor{}, ErrConsoleUnauthorized
}
- if _, err := s.db.Exec(ctx, `DELETE FROM console_login_throttles WHERE identity_hash=$1`, identity[:]); err != nil {
+ if _, err := s.db.Exec(ctx, `DELETE FROM console_login_throttles WHERE identity_hash=$1`, emailIdentity[:]); err != nil {
return ConsoleActor{}, fmt.Errorf("clear console login throttle: %w", err)
}
if _, err := s.db.Exec(ctx, `UPDATE console_users SET last_used_at=now() WHERE id=$1`, actor.ID); err != nil {
return ConsoleActor{}, fmt.Errorf("update console login time: %w", err)
}
+ actor.MFAMethods, err = s.MFAMethods(ctx, actor.ID)
+ if err != nil {
+ return ConsoleActor{}, err
+ }
return actor, nil
}
-func (s *Store) recordLoginFailure(ctx context.Context, identityHash []byte) error {
+func (s *Store) recordLoginFailures(ctx context.Context, emailIdentity, ipIdentity []byte) error {
+ if err := s.recordLoginFailure(ctx, emailIdentity, 5); err != nil {
+ return err
+ }
+ return s.recordLoginFailure(ctx, ipIdentity, 25)
+}
+
+func (s *Store) recordLoginFailure(ctx context.Context, identityHash []byte, threshold int) error {
_, err := s.db.Exec(ctx, `INSERT INTO console_login_throttles (identity_hash,failures) VALUES ($1,1)
ON CONFLICT (identity_hash) DO UPDATE SET
failures=CASE WHEN console_login_throttles.window_started_at < now()-interval '15 minutes' THEN 1 ELSE console_login_throttles.failures+1 END,
window_started_at=CASE WHEN console_login_throttles.window_started_at < now()-interval '15 minutes' THEN now() ELSE console_login_throttles.window_started_at END,
- locked_until=CASE WHEN (CASE WHEN console_login_throttles.window_started_at < now()-interval '15 minutes' THEN 1 ELSE console_login_throttles.failures+1 END) >= 5 THEN now()+interval '15 minutes' ELSE NULL END,
- updated_at=now()`, identityHash)
+ locked_until=CASE WHEN (CASE WHEN console_login_throttles.window_started_at < now()-interval '15 minutes' THEN 1 ELSE console_login_throttles.failures+1 END) >= $2 THEN now()+interval '15 minutes' ELSE NULL END,
+ updated_at=now()`, identityHash, threshold)
if err != nil {
return fmt.Errorf("record console login failure: %w", err)
}
@@ -277,6 +296,10 @@ func (s *Store) recordLoginFailure(ctx context.Context, identityHash []byte) err
}
func (s *Store) CreateConsoleSession(ctx context.Context, actor ConsoleActor, ttl time.Duration, remoteIP, userAgent string) (ConsoleSession, error) {
+ return s.CreateConsoleSessionWithMethod(ctx, actor, ttl, remoteIP, userAgent, "password", false)
+}
+
+func (s *Store) CreateConsoleSessionWithMethod(ctx context.Context, actor ConsoleActor, ttl time.Duration, remoteIP, userAgent, authMethod string, mfaVerified bool) (ConsoleSession, error) {
if actor.ID == "" || ttl < time.Minute {
return ConsoleSession{}, errors.New("session user and ttl are required")
}
@@ -289,12 +312,19 @@ func (s *Store) CreateConsoleSession(ctx context.Context, actor ConsoleActor, tt
return ConsoleSession{}, err
}
expiresAt := time.Now().UTC().Add(ttl)
- _, err = s.db.Exec(ctx, `INSERT INTO console_sessions (user_id,token_hash,csrf_hash,expires_at,remote_ip,user_agent)
- VALUES ($1,$2,$3,$4,NULLIF($5,'')::inet,$6)`, actor.ID, tokenHash, csrfHash, expiresAt, remoteIP, userAgent)
+ var sessionID string
+ var mfaVerifiedAt any
+ if mfaVerified {
+ mfaVerifiedAt = time.Now().UTC()
+ }
+ err = s.db.QueryRow(ctx, `INSERT INTO console_sessions
+ (user_id,token_hash,csrf_hash,expires_at,remote_ip,user_agent,auth_method,mfa_verified_at)
+ VALUES ($1,$2,$3,$4,NULLIF($5,'')::inet,$6,$7,$8) RETURNING id::text`,
+ actor.ID, tokenHash, csrfHash, expiresAt, remoteIP, userAgent, authMethod, mfaVerifiedAt).Scan(&sessionID)
if err != nil {
return ConsoleSession{}, fmt.Errorf("create console session: %w", err)
}
- return ConsoleSession{Actor: actor, Token: token, CSRFToken: csrf, ExpiresAt: expiresAt}, nil
+ return ConsoleSession{Actor: actor, Token: token, CSRFToken: csrf, ExpiresAt: expiresAt, ID: sessionID, AuthMethod: authMethod}, nil
}
func (s *Store) AuthenticateConsoleSession(ctx context.Context, rawToken string) (ConsoleActor, []byte, error) {
@@ -313,6 +343,10 @@ func (s *Store) AuthenticateConsoleSession(ctx context.Context, rawToken string)
if err != nil {
return ConsoleActor{}, nil, fmt.Errorf("authenticate console session: %w", err)
}
+ actor.MFAMethods, err = s.MFAMethods(ctx, actor.ID)
+ if err != nil {
+ return ConsoleActor{}, nil, err
+ }
return actor, csrfHash, nil
}
@@ -325,6 +359,46 @@ func (s *Store) RevokeConsoleSession(ctx context.Context, rawToken string) error
return err
}
+func (s *Store) ListDeviceSessions(ctx context.Context, actorID, currentToken string) ([]DeviceSession, error) {
+ currentHash := sha256.Sum256([]byte(currentToken))
+ rows, err := s.db.Query(ctx, `SELECT id::text,COALESCE(host(remote_ip),''),user_agent,auth_method,mfa_verified_at,
+ created_at,last_seen_at,expires_at,token_hash=$2 FROM console_sessions
+ WHERE user_id=$1 AND revoked_at IS NULL AND expires_at>now() ORDER BY last_seen_at DESC`, actorID, currentHash[:])
+ if err != nil {
+ return nil, fmt.Errorf("list console sessions: %w", err)
+ }
+ defer rows.Close()
+ result := make([]DeviceSession, 0)
+ for rows.Next() {
+ var item DeviceSession
+ if err := rows.Scan(&item.ID, &item.RemoteIP, &item.UserAgent, &item.AuthMethod, &item.MFAVerifiedAt,
+ &item.CreatedAt, &item.LastSeenAt, &item.ExpiresAt, &item.Current); err != nil {
+ return nil, err
+ }
+ result = append(result, item)
+ }
+ return result, rows.Err()
+}
+
+func (s *Store) RevokeDeviceSession(ctx context.Context, actorID, sessionID string) (bool, error) {
+ tag, err := s.db.Exec(ctx, `UPDATE console_sessions SET revoked_at=now()
+ WHERE id=$1 AND user_id=$2 AND revoked_at IS NULL`, sessionID, actorID)
+ if err != nil {
+ return false, err
+ }
+ if tag.RowsAffected() == 0 {
+ return false, ErrNotFound
+ }
+ return true, nil
+}
+
+func (s *Store) RevokeOtherDeviceSessions(ctx context.Context, actorID, currentToken string) error {
+ currentHash := sha256.Sum256([]byte(currentToken))
+ _, err := s.db.Exec(ctx, `UPDATE console_sessions SET revoked_at=now()
+ WHERE user_id=$1 AND token_hash<>$2 AND revoked_at IS NULL`, actorID, currentHash[:])
+ return err
+}
+
func (s *Store) ChangeConsolePassword(ctx context.Context, actorID string, input PasswordChangeInput) error {
var expectedHash, salt []byte
var iterations int
diff --git a/internal/controlplane/identity.go b/internal/controlplane/identity.go
new file mode 100644
index 0000000..07fe0d7
--- /dev/null
+++ b/internal/controlplane/identity.go
@@ -0,0 +1,291 @@
+package controlplane
+
+import (
+ "context"
+ "crypto/sha256"
+ "errors"
+ "fmt"
+ "net/mail"
+ "strings"
+ "time"
+
+ "aigw/internal/security"
+
+ "github.com/jackc/pgx/v5"
+)
+
+var (
+ ErrActionTokenInvalid = errors.New("account action link is invalid or expired")
+ ErrMFARequired = errors.New("multi-factor authentication is required")
+)
+
+func (s *Store) RegisterTenantPending(ctx context.Context, input RegisterInput, currency, publicURL, remoteIP string) (ConsoleActor, int64, error) {
+ input.Organization = strings.TrimSpace(input.Organization)
+ input.TenantSlug = strings.ToLower(strings.TrimSpace(input.TenantSlug))
+ input.DisplayName = strings.TrimSpace(input.DisplayName)
+ input.Email = strings.ToLower(strings.TrimSpace(input.Email))
+ if input.Organization == "" || input.DisplayName == "" || !slugPattern.MatchString(input.TenantSlug) || !validEmail(input.Email) {
+ return ConsoleActor{}, 0, errors.New("registration requires organization, a valid tenant_slug, display_name, and email")
+ }
+ if len(currency) != 3 {
+ return ConsoleActor{}, 0, errors.New("registration currency is invalid")
+ }
+ hash, salt, iterations, err := security.HashPassword(input.Password)
+ if err != nil {
+ return ConsoleActor{}, 0, err
+ }
+ tx, err := s.db.Begin(ctx)
+ if err != nil {
+ return ConsoleActor{}, 0, err
+ }
+ defer tx.Rollback(ctx)
+ var tenantID string
+ if err := tx.QueryRow(ctx, `INSERT INTO tenants (slug,name) VALUES ($1,$2) RETURNING id::text`, input.TenantSlug, input.Organization).Scan(&tenantID); err != nil {
+ return ConsoleActor{}, 0, fmt.Errorf("create registered tenant: %w", err)
+ }
+ if _, err := tx.Exec(ctx, `INSERT INTO projects (tenant_id,slug,name) VALUES ($1,'default','Default project')`, tenantID); err != nil {
+ return ConsoleActor{}, 0, fmt.Errorf("create default project: %w", err)
+ }
+ if _, err := tx.Exec(ctx, `INSERT INTO tenant_wallets (tenant_id,currency) VALUES ($1,$2)`, tenantID, strings.ToLower(currency)); err != nil {
+ return ConsoleActor{}, 0, fmt.Errorf("create tenant wallet: %w", err)
+ }
+ actor := ConsoleActor{TenantID: tenantID, Email: input.Email, DisplayName: input.DisplayName, Role: RoleTenantAdmin}
+ if err := tx.QueryRow(ctx, `INSERT INTO console_users
+ (tenant_id,email,display_name,role,password_hash,password_salt,password_iterations,password_changed_at,status)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,now(),'pending_verification') RETURNING id::text`, tenantID, input.Email,
+ input.DisplayName, RoleTenantAdmin, hash, salt, iterations).Scan(&actor.ID); err != nil {
+ return ConsoleActor{}, 0, fmt.Errorf("create pending tenant administrator: %w", err)
+ }
+ if _, err := s.issueActionToken(ctx, tx, actor.ID, actor.Email, actor.DisplayName, actionVerifyEmail, publicURL, remoteIP, 24*time.Hour); err != nil {
+ return ConsoleActor{}, 0, err
+ }
+ generation, err := bumpGeneration(ctx, tx)
+ if err != nil {
+ return ConsoleActor{}, 0, err
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return ConsoleActor{}, 0, err
+ }
+ return actor, generation, nil
+}
+
+func (s *Store) InviteConsoleUser(ctx context.Context, input CreateConsoleUserInput, publicURL, remoteIP string) (CreatedConsoleUser, error) {
+ input.Email = strings.ToLower(strings.TrimSpace(input.Email))
+ input.DisplayName = strings.TrimSpace(input.DisplayName)
+ input.TenantID = strings.TrimSpace(input.TenantID)
+ address, addressErr := mail.ParseAddress(input.Email)
+ if addressErr != nil || address.Address != input.Email || input.DisplayName == "" {
+ return CreatedConsoleUser{}, errors.New("invitation requires a valid email and display_name")
+ }
+ if err := validateConsoleRole(input.Role, input.TenantID); err != nil {
+ return CreatedConsoleUser{}, err
+ }
+ tx, err := s.db.Begin(ctx)
+ if err != nil {
+ return CreatedConsoleUser{}, err
+ }
+ defer tx.Rollback(ctx)
+ var result CreatedConsoleUser
+ err = tx.QueryRow(ctx, `INSERT INTO console_users
+ (tenant_id,email,display_name,role,status,invited_at)
+ VALUES (NULLIF($1,'')::uuid,$2,$3,$4,'invited',now())
+ RETURNING id::text,COALESCE(tenant_id::text,''),email,display_name,role,COALESCE(token_prefix,''),
+ password_hash IS NOT NULL,status,FALSE,'{}'::text[],last_used_at,created_at`,
+ input.TenantID, input.Email, input.DisplayName, input.Role,
+ ).Scan(&result.ID, &result.TenantID, &result.Email, &result.DisplayName, &result.Role, &result.TokenPrefix,
+ &result.HasPassword, &result.Status, &result.EmailVerified, &result.MFAMethods, &result.LastUsedAt, &result.CreatedAt)
+ if err != nil {
+ return CreatedConsoleUser{}, fmt.Errorf("create invited console user: %w", err)
+ }
+ if _, err := s.issueActionToken(ctx, tx, result.ID, result.Email, result.DisplayName, actionInvite, publicURL, remoteIP, 7*24*time.Hour); err != nil {
+ return CreatedConsoleUser{}, err
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return CreatedConsoleUser{}, err
+ }
+ return result, nil
+}
+
+func validateConsoleRole(role, tenantID string) error {
+ platform := role == RolePlatformAdmin || role == RolePlatformViewer
+ tenant := role == RoleTenantAdmin || role == RoleTenantBilling || role == RoleTenantDeveloper || role == RoleTenantViewer
+ if (!platform && !tenant) || (platform && tenantID != "") || (tenant && tenantID == "") {
+ return errors.New("console user role and tenant_id are inconsistent")
+ }
+ return nil
+}
+
+func (s *Store) VerifyEmailToken(ctx context.Context, rawToken string) (ConsoleActor, error) {
+ tokenHash := sha256.Sum256([]byte(strings.TrimSpace(rawToken)))
+ tx, err := s.db.Begin(ctx)
+ if err != nil {
+ return ConsoleActor{}, err
+ }
+ defer tx.Rollback(ctx)
+ var tokenID string
+ var actor ConsoleActor
+ err = tx.QueryRow(ctx, `SELECT a.id::text,u.id::text,COALESCE(u.tenant_id::text,''),u.email,u.display_name,u.role
+ FROM console_action_tokens a JOIN console_users u ON u.id=a.user_id
+ WHERE a.token_hash=$1 AND a.purpose='verify_email' AND a.consumed_at IS NULL AND a.expires_at>now()
+ AND u.status='pending_verification' FOR UPDATE OF a,u`, tokenHash[:],
+ ).Scan(&tokenID, &actor.ID, &actor.TenantID, &actor.Email, &actor.DisplayName, &actor.Role)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return ConsoleActor{}, ErrActionTokenInvalid
+ }
+ if err != nil {
+ return ConsoleActor{}, err
+ }
+ if _, err := tx.Exec(ctx, `UPDATE console_action_tokens SET consumed_at=now() WHERE id=$1`, tokenID); err != nil {
+ return ConsoleActor{}, err
+ }
+ if _, err := tx.Exec(ctx, `UPDATE console_users SET status='active',email_verified_at=now(),accepted_at=COALESCE(accepted_at,now()) WHERE id=$1`, actor.ID); err != nil {
+ return ConsoleActor{}, err
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return ConsoleActor{}, err
+ }
+ return actor, nil
+}
+
+func (s *Store) ResendVerification(ctx context.Context, email, publicURL, remoteIP string) error {
+ email = strings.ToLower(strings.TrimSpace(email))
+ tx, err := s.db.Begin(ctx)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback(ctx)
+ var id, displayName string
+ err = tx.QueryRow(ctx, `SELECT id::text,display_name FROM console_users
+ WHERE lower(email)=$1 AND status='pending_verification' FOR UPDATE`, email).Scan(&id, &displayName)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ if _, err := s.issueActionToken(ctx, tx, id, email, displayName, actionVerifyEmail, publicURL, remoteIP, 24*time.Hour); err != nil {
+ return err
+ }
+ return tx.Commit(ctx)
+}
+
+func (s *Store) RequestPasswordReset(ctx context.Context, email, publicURL, remoteIP string) error {
+ email = strings.ToLower(strings.TrimSpace(email))
+ tx, err := s.db.Begin(ctx)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback(ctx)
+ var id, displayName string
+ err = tx.QueryRow(ctx, `SELECT id::text,display_name FROM console_users
+ WHERE lower(email)=$1 AND status='active' AND email_verified_at IS NOT NULL FOR UPDATE`, email).Scan(&id, &displayName)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ if _, err := s.issueActionToken(ctx, tx, id, email, displayName, actionPasswordReset, publicURL, remoteIP, 30*time.Minute); err != nil {
+ return err
+ }
+ return tx.Commit(ctx)
+}
+
+func (s *Store) ResetPassword(ctx context.Context, input PasswordResetInput) error {
+ hash, salt, iterations, err := security.HashPassword(input.NewPassword)
+ if err != nil {
+ return err
+ }
+ tokenHash := sha256.Sum256([]byte(strings.TrimSpace(input.Token)))
+ tx, err := s.db.Begin(ctx)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback(ctx)
+ var tokenID, userID string
+ err = tx.QueryRow(ctx, `SELECT a.id::text,u.id::text FROM console_action_tokens a
+ JOIN console_users u ON u.id=a.user_id WHERE a.token_hash=$1 AND a.purpose='password_reset'
+ AND a.consumed_at IS NULL AND a.expires_at>now() AND u.status='active' FOR UPDATE OF a,u`, tokenHash[:]).Scan(&tokenID, &userID)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return ErrActionTokenInvalid
+ }
+ if err != nil {
+ return err
+ }
+ if _, err := tx.Exec(ctx, `UPDATE console_users SET password_hash=$2,password_salt=$3,password_iterations=$4,password_changed_at=now() WHERE id=$1`, userID, hash, salt, iterations); err != nil {
+ return err
+ }
+ if _, err := tx.Exec(ctx, `UPDATE console_action_tokens SET consumed_at=now() WHERE id=$1`, tokenID); err != nil {
+ return err
+ }
+ if _, err := tx.Exec(ctx, `UPDATE console_sessions SET revoked_at=now() WHERE user_id=$1 AND revoked_at IS NULL`, userID); err != nil {
+ return err
+ }
+ if _, err := tx.Exec(ctx, `UPDATE console_auth_challenges SET consumed_at=now() WHERE user_id=$1 AND consumed_at IS NULL`, userID); err != nil {
+ return err
+ }
+ return tx.Commit(ctx)
+}
+
+func (s *Store) AcceptInvite(ctx context.Context, input InviteAcceptInput) (ConsoleActor, error) {
+ hash, salt, iterations, err := security.HashPassword(input.Password)
+ if err != nil {
+ return ConsoleActor{}, err
+ }
+ input.DisplayName = strings.TrimSpace(input.DisplayName)
+ if input.DisplayName == "" {
+ return ConsoleActor{}, errors.New("display_name is required")
+ }
+ tokenHash := sha256.Sum256([]byte(strings.TrimSpace(input.Token)))
+ tx, err := s.db.Begin(ctx)
+ if err != nil {
+ return ConsoleActor{}, err
+ }
+ defer tx.Rollback(ctx)
+ var tokenID string
+ var actor ConsoleActor
+ err = tx.QueryRow(ctx, `SELECT a.id::text,u.id::text,COALESCE(u.tenant_id::text,''),u.email,u.role
+ FROM console_action_tokens a JOIN console_users u ON u.id=a.user_id
+ WHERE a.token_hash=$1 AND a.purpose='invite' AND a.consumed_at IS NULL AND a.expires_at>now()
+ AND u.status='invited' FOR UPDATE OF a,u`, tokenHash[:],
+ ).Scan(&tokenID, &actor.ID, &actor.TenantID, &actor.Email, &actor.Role)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return ConsoleActor{}, ErrActionTokenInvalid
+ }
+ if err != nil {
+ return ConsoleActor{}, err
+ }
+ actor.DisplayName = input.DisplayName
+ if _, err := tx.Exec(ctx, `UPDATE console_users SET display_name=$2,password_hash=$3,password_salt=$4,
+ password_iterations=$5,password_changed_at=now(),status='active',email_verified_at=now(),accepted_at=now()
+ WHERE id=$1`, actor.ID, actor.DisplayName, hash, salt, iterations); err != nil {
+ return ConsoleActor{}, err
+ }
+ if _, err := tx.Exec(ctx, `UPDATE console_action_tokens SET consumed_at=now() WHERE id=$1`, tokenID); err != nil {
+ return ConsoleActor{}, err
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return ConsoleActor{}, err
+ }
+ return actor, nil
+}
+
+func (s *Store) ConsumeRateLimit(ctx context.Context, scope, key string, limit int, window time.Duration) error {
+ if limit < 1 || window < time.Second {
+ return errors.New("invalid rate limit")
+ }
+ bucket := sha256.Sum256([]byte(scope + "\x00" + strings.ToLower(strings.TrimSpace(key))))
+ var hits int
+ err := s.db.QueryRow(ctx, `INSERT INTO console_rate_limits (bucket_hash,hits) VALUES ($1,1)
+ ON CONFLICT (bucket_hash) DO UPDATE SET
+ hits=CASE WHEN console_rate_limits.window_started_at < now()-($2::bigint * interval '1 millisecond') THEN 1 ELSE console_rate_limits.hits+1 END,
+ window_started_at=CASE WHEN console_rate_limits.window_started_at < now()-($2::bigint * interval '1 millisecond') THEN now() ELSE console_rate_limits.window_started_at END,
+ updated_at=now() RETURNING hits`, bucket[:], window.Milliseconds()).Scan(&hits)
+ if err != nil {
+ return fmt.Errorf("consume account rate limit: %w", err)
+ }
+ if hits > limit {
+ return ErrConsoleRateLimited
+ }
+ return nil
+}
diff --git a/internal/controlplane/mfa.go b/internal/controlplane/mfa.go
new file mode 100644
index 0000000..c96cac1
--- /dev/null
+++ b/internal/controlplane/mfa.go
@@ -0,0 +1,344 @@
+package controlplane
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "image/png"
+ "strings"
+ "time"
+
+ "aigw/internal/security"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/pquerna/otp"
+ "github.com/pquerna/otp/totp"
+)
+
+func (s *Store) MFAMethods(ctx context.Context, userID string) ([]string, error) {
+ var totpEnabled, passkeyEnabled, recoveryEnabled bool
+ if err := s.db.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM console_totp_credentials WHERE user_id=$1 AND confirmed_at IS NOT NULL),
+ EXISTS(SELECT 1 FROM console_passkeys WHERE user_id=$1),
+ EXISTS(SELECT 1 FROM console_recovery_codes WHERE user_id=$1 AND used_at IS NULL)`, userID).
+ Scan(&totpEnabled, &passkeyEnabled, &recoveryEnabled); err != nil {
+ return nil, err
+ }
+ methods := make([]string, 0, 3)
+ if totpEnabled {
+ methods = append(methods, "totp")
+ }
+ if passkeyEnabled {
+ methods = append(methods, "passkey")
+ }
+ if recoveryEnabled {
+ methods = append(methods, "recovery")
+ }
+ return methods, nil
+}
+
+func (s *Store) MFAStatus(ctx context.Context, userID string) (MFAStatus, error) {
+ var result MFAStatus
+ if err := s.db.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM console_totp_credentials
+ WHERE user_id=$1 AND confirmed_at IS NOT NULL)`, userID).Scan(&result.TOTPEnabled); err != nil {
+ return MFAStatus{}, err
+ }
+ passkeys, err := s.ListPasskeys(ctx, userID)
+ if err != nil {
+ return MFAStatus{}, err
+ }
+ result.Passkeys = passkeys
+ return result, nil
+}
+
+func (s *Store) FindActiveConsoleUser(ctx context.Context, email string) (ConsoleActor, error) {
+ var actor ConsoleActor
+ err := s.db.QueryRow(ctx, `SELECT id::text,COALESCE(tenant_id::text,''),email,display_name,role
+ FROM console_users WHERE lower(email)=$1 AND status='active' AND email_verified_at IS NOT NULL`,
+ strings.ToLower(strings.TrimSpace(email))).Scan(&actor.ID, &actor.TenantID, &actor.Email, &actor.DisplayName, &actor.Role)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return ConsoleActor{}, ErrConsoleUnauthorized
+ }
+ return actor, err
+}
+
+func (s *Store) VerifyConsolePassword(ctx context.Context, userID, password string) error {
+ var expectedHash, salt []byte
+ var iterations int
+ if err := s.db.QueryRow(ctx, `SELECT password_hash,password_salt,password_iterations FROM console_users
+ WHERE id=$1 AND status='active'`, userID).Scan(&expectedHash, &salt, &iterations); err != nil {
+ return ErrConsoleUnauthorized
+ }
+ if !security.VerifyPassword(password, expectedHash, salt, iterations) {
+ return ErrConsoleUnauthorized
+ }
+ return nil
+}
+
+func (s *Store) ResolveMFAChallenge(ctx context.Context, rawToken, remoteIP string) (string, string, error) {
+ hash := sha256.Sum256([]byte(strings.TrimSpace(rawToken)))
+ var id, userID, expectedIP string
+ err := s.db.QueryRow(ctx, `SELECT id::text,user_id::text,COALESCE(host(remote_ip),'')
+ FROM console_auth_challenges WHERE token_hash=$1 AND purpose='mfa_login'
+ AND consumed_at IS NULL AND expires_at>now()`, hash[:]).Scan(&id, &userID, &expectedIP)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return "", "", ErrActionTokenInvalid
+ }
+ if err != nil {
+ return "", "", err
+ }
+ if expectedIP != "" && remoteIP != "" && expectedIP != remoteIP {
+ return "", "", ErrActionTokenInvalid
+ }
+ return id, userID, nil
+}
+
+func (s *Store) VerifyTOTPForUser(ctx context.Context, userID, code string) error {
+ tx, err := s.db.Begin(ctx)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback(ctx)
+ var ciphertext []byte
+ var lastStep int64
+ if err := tx.QueryRow(ctx, `SELECT secret_ciphertext,last_used_step FROM console_totp_credentials
+ WHERE user_id=$1 AND confirmed_at IS NOT NULL FOR UPDATE`, userID).Scan(&ciphertext, &lastStep); err != nil {
+ return ErrConsoleUnauthorized
+ }
+ secret, err := s.cipher.Decrypt(ciphertext)
+ if err != nil {
+ return err
+ }
+ step, ok := matchingTOTPStep(secret, strings.TrimSpace(code), time.Now().UTC())
+ if !ok || step <= lastStep {
+ return ErrConsoleUnauthorized
+ }
+ if _, err := tx.Exec(ctx, `UPDATE console_totp_credentials SET last_used_step=$2 WHERE user_id=$1`, userID, step); err != nil {
+ return err
+ }
+ return tx.Commit(ctx)
+}
+
+func (s *Store) BeginMFAChallenge(ctx context.Context, actor ConsoleActor, remoteIP, userAgent string) (AuthChallenge, error) {
+ methods, err := s.MFAMethods(ctx, actor.ID)
+ if err != nil {
+ return AuthChallenge{}, err
+ }
+ if len(methods) == 0 {
+ return AuthChallenge{}, ErrActionTokenInvalid
+ }
+ raw, hash, err := randomCredential("mfa-aigw-")
+ if err != nil {
+ return AuthChallenge{}, err
+ }
+ expires := time.Now().UTC().Add(10 * time.Minute)
+ _, err = s.db.Exec(ctx, `INSERT INTO console_auth_challenges
+ (user_id,token_hash,purpose,allowed_methods,expires_at,remote_ip,user_agent)
+ VALUES ($1,$2,'mfa_login',$3,$4,NULLIF($5,'')::inet,$6)`, actor.ID, hash, methods, expires, remoteIP, userAgent)
+ if err != nil {
+ return AuthChallenge{}, fmt.Errorf("create MFA challenge: %w", err)
+ }
+ return AuthChallenge{Token: raw, Methods: methods, ExpiresAt: expires}, nil
+}
+
+func (s *Store) BeginTOTP(ctx context.Context, actor ConsoleActor) (TOTPEnrollment, error) {
+ key, err := totp.Generate(totp.GenerateOpts{Issuer: "AIGW", AccountName: actor.Email, Period: 30, SecretSize: 20, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA1})
+ if err != nil {
+ return TOTPEnrollment{}, fmt.Errorf("generate TOTP secret: %w", err)
+ }
+ ciphertext, err := s.cipher.Encrypt(key.Secret())
+ if err != nil {
+ return TOTPEnrollment{}, err
+ }
+ result, err := s.db.Exec(ctx, `INSERT INTO console_totp_credentials (user_id,secret_ciphertext,confirmed_at,last_used_step)
+ VALUES ($1,$2,NULL,-1) ON CONFLICT (user_id) DO UPDATE SET secret_ciphertext=EXCLUDED.secret_ciphertext,
+ confirmed_at=NULL,last_used_step=-1,created_at=now()
+ WHERE console_totp_credentials.confirmed_at IS NULL`, actor.ID, ciphertext)
+ if err != nil {
+ return TOTPEnrollment{}, fmt.Errorf("store TOTP enrollment: %w", err)
+ }
+ if result.RowsAffected() == 0 {
+ return TOTPEnrollment{}, errors.New("TOTP is already enabled")
+ }
+ var image bytes.Buffer
+ qr, err := key.Image(256, 256)
+ if err == nil {
+ err = png.Encode(&image, qr)
+ }
+ if err != nil {
+ return TOTPEnrollment{}, fmt.Errorf("render TOTP QR: %w", err)
+ }
+ return TOTPEnrollment{Secret: key.Secret(), URI: key.URL(), QRCode: "data:image/png;base64," + base64.StdEncoding.EncodeToString(image.Bytes())}, nil
+}
+
+func (s *Store) ConfirmTOTP(ctx context.Context, actorID, code string) ([]string, error) {
+ code = strings.TrimSpace(code)
+ tx, err := s.db.Begin(ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer tx.Rollback(ctx)
+ var ciphertext []byte
+ var confirmed *time.Time
+ if err := tx.QueryRow(ctx, `SELECT secret_ciphertext,confirmed_at FROM console_totp_credentials WHERE user_id=$1 FOR UPDATE`, actorID).Scan(&ciphertext, &confirmed); err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, errors.New("TOTP enrollment has not started")
+ }
+ return nil, err
+ }
+ secret, err := s.cipher.Decrypt(ciphertext)
+ if err != nil {
+ return nil, err
+ }
+ step, ok := matchingTOTPStep(secret, code, time.Now().UTC())
+ if !ok {
+ return nil, errors.New("invalid TOTP code")
+ }
+ if confirmed != nil {
+ return nil, errors.New("TOTP is already enabled")
+ }
+ if _, err := tx.Exec(ctx, `UPDATE console_totp_credentials SET confirmed_at=now(),last_used_step=$2 WHERE user_id=$1`, actorID, step); err != nil {
+ return nil, err
+ }
+ if _, err := tx.Exec(ctx, `DELETE FROM console_recovery_codes WHERE user_id=$1`, actorID); err != nil {
+ return nil, err
+ }
+ codes := make([]string, 0, 10)
+ for i := 0; i < 10; i++ {
+ code, hash, err := newRecoveryCode()
+ if err != nil {
+ return nil, err
+ }
+ if _, err := tx.Exec(ctx, `INSERT INTO console_recovery_codes (user_id,code_hash) VALUES ($1,$2)`, actorID, hash); err != nil {
+ return nil, err
+ }
+ codes = append(codes, code)
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return nil, err
+ }
+ return codes, nil
+}
+
+func (s *Store) DisableTOTP(ctx context.Context, actorID string) error {
+ tx, err := s.db.Begin(ctx)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback(ctx)
+ if _, err := tx.Exec(ctx, `DELETE FROM console_totp_credentials WHERE user_id=$1`, actorID); err != nil {
+ return err
+ }
+ if _, err := tx.Exec(ctx, `DELETE FROM console_recovery_codes WHERE user_id=$1`, actorID); err != nil {
+ return err
+ }
+ return tx.Commit(ctx)
+}
+
+func (s *Store) CompleteMFAChallenge(ctx context.Context, input MFACodeInput, remoteIP string) (ConsoleActor, string, error) {
+ code := strings.TrimSpace(input.Code)
+ tokenHash := sha256.Sum256([]byte(strings.TrimSpace(input.ChallengeToken)))
+ tx, err := s.db.Begin(ctx)
+ if err != nil {
+ return ConsoleActor{}, "", err
+ }
+ defer tx.Rollback(ctx)
+ var challengeID, userID, expectedIP string
+ var allowed []string
+ err = tx.QueryRow(ctx, `SELECT c.id::text,c.user_id::text,COALESCE(host(c.remote_ip),''),c.allowed_methods
+ FROM console_auth_challenges c WHERE c.token_hash=$1 AND c.purpose='mfa_login'
+ AND c.consumed_at IS NULL AND c.expires_at>now() FOR UPDATE`, tokenHash[:]).Scan(&challengeID, &userID, &expectedIP, &allowed)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return ConsoleActor{}, "", ErrActionTokenInvalid
+ }
+ if err != nil {
+ return ConsoleActor{}, "", err
+ }
+ if expectedIP != "" && remoteIP != "" && expectedIP != remoteIP {
+ return ConsoleActor{}, "", ErrActionTokenInvalid
+ }
+ var method string
+ if method = "totp"; len(code) != 6 {
+ method = "recovery"
+ }
+ valid := false
+ if method == "totp" {
+ var ciphertext []byte
+ var lastStep int64
+ if err := tx.QueryRow(ctx, `SELECT secret_ciphertext,last_used_step FROM console_totp_credentials WHERE user_id=$1 AND confirmed_at IS NOT NULL FOR UPDATE`, userID).Scan(&ciphertext, &lastStep); err == nil {
+ secret, decryptErr := s.cipher.Decrypt(ciphertext)
+ if decryptErr == nil {
+ if step, ok := matchingTOTPStep(secret, code, time.Now().UTC()); ok && step > lastStep {
+ valid = true
+ if _, err := tx.Exec(ctx, `UPDATE console_totp_credentials SET last_used_step=$2 WHERE user_id=$1`, userID, step); err != nil {
+ return ConsoleActor{}, "", err
+ }
+ }
+ }
+ }
+ }
+ if !valid && method == "recovery" {
+ hash := sha256.Sum256([]byte(normalizeRecoveryCode(code)))
+ result, updateErr := tx.Exec(ctx, `UPDATE console_recovery_codes SET used_at=now()
+ WHERE user_id=$1 AND code_hash=$2 AND used_at IS NULL`, userID, hash[:])
+ valid = updateErr == nil && result.RowsAffected() == 1
+ }
+ if !valid {
+ _, _ = tx.Exec(ctx, `UPDATE console_auth_challenges SET attempts=attempts+1,
+ consumed_at=CASE WHEN attempts+1 >= 5 THEN now() ELSE consumed_at END WHERE id=$1`, challengeID)
+ if err := tx.Commit(ctx); err != nil {
+ return ConsoleActor{}, "", err
+ }
+ return ConsoleActor{}, "", ErrConsoleUnauthorized
+ }
+ if _, err := tx.Exec(ctx, `UPDATE console_auth_challenges SET consumed_at=now() WHERE id=$1`, challengeID); err != nil {
+ return ConsoleActor{}, "", err
+ }
+ var actor ConsoleActor
+ if err := tx.QueryRow(ctx, `SELECT id::text,COALESCE(tenant_id::text,''),email,display_name,role FROM console_users WHERE id=$1 AND status='active'`, userID).
+ Scan(&actor.ID, &actor.TenantID, &actor.Email, &actor.DisplayName, &actor.Role); err != nil {
+ return ConsoleActor{}, "", err
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return ConsoleActor{}, "", err
+ }
+ return actor, method, nil
+}
+
+func matchingTOTPStep(secret, code string, now time.Time) (int64, bool) {
+ if len(strings.TrimSpace(code)) != 6 {
+ return 0, false
+ }
+ current := now.Unix() / 30
+ opts := totp.ValidateOpts{Period: 30, Skew: 0, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA1}
+ for _, step := range []int64{current - 1, current, current + 1} {
+ generated, err := totp.GenerateCodeCustom(secret, time.Unix(step*30, 0), opts)
+ if err == nil && generated == code {
+ return step, true
+ }
+ }
+ return 0, false
+}
+
+func newRecoveryCode() (string, []byte, error) {
+ raw, _, err := randomCredential("")
+ if err != nil {
+ return "", nil, err
+ }
+ value := strings.ToUpper(strings.TrimRight(raw, "="))
+ if len(value) > 10 {
+ value = value[:10]
+ }
+ if len(value) < 10 {
+ return newRecoveryCode()
+ }
+ code := value[:5] + "-" + value[5:]
+ hash := sha256.Sum256([]byte(normalizeRecoveryCode(code)))
+ return code, hash[:], nil
+}
+
+func normalizeRecoveryCode(value string) string {
+ return strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(value), "-", ""))
+}
diff --git a/internal/controlplane/outbox.go b/internal/controlplane/outbox.go
new file mode 100644
index 0000000..b54a064
--- /dev/null
+++ b/internal/controlplane/outbox.go
@@ -0,0 +1,152 @@
+package controlplane
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/url"
+ "strings"
+ "time"
+
+ "aigw/internal/mailer"
+
+ "github.com/jackc/pgx/v5"
+)
+
+const (
+ actionVerifyEmail = "verify_email"
+ actionPasswordReset = "password_reset"
+ actionInvite = "invite"
+)
+
+func (s *Store) issueActionToken(ctx context.Context, tx pgx.Tx, userID, email, displayName, purpose, publicURL, remoteIP string, ttl time.Duration) (string, error) {
+ token, tokenHash, err := randomCredential("act-aigw-")
+ if err != nil {
+ return "", err
+ }
+ if _, err := tx.Exec(ctx, `UPDATE console_action_tokens SET consumed_at=now()
+ WHERE user_id=$1 AND purpose=$2 AND consumed_at IS NULL`, userID, purpose); err != nil {
+ return "", fmt.Errorf("expire previous account action: %w", err)
+ }
+ if _, err := tx.Exec(ctx, `INSERT INTO console_action_tokens
+ (user_id,purpose,token_hash,expires_at,requested_ip) VALUES ($1,$2,$3,$4,NULLIF($5,'')::inet)`,
+ userID, purpose, tokenHash, time.Now().UTC().Add(ttl), remoteIP); err != nil {
+ return "", fmt.Errorf("create account action: %w", err)
+ }
+ link, err := accountActionURL(publicURL, purpose, token)
+ if err != nil {
+ return "", err
+ }
+ subject, body := actionMail(displayName, purpose, link, ttl)
+ ciphertext, err := s.cipher.Encrypt(body)
+ if err != nil {
+ return "", fmt.Errorf("encrypt account email: %w", err)
+ }
+ if _, err := tx.Exec(ctx, `INSERT INTO console_mail_outbox
+ (recipient,template,subject,body_ciphertext) VALUES ($1,$2,$3,$4)`, email, purpose, subject, ciphertext); err != nil {
+ return "", fmt.Errorf("queue account email: %w", err)
+ }
+ return token, nil
+}
+
+func accountActionURL(publicURL, purpose, token string) (string, error) {
+ parsed, err := url.Parse(publicURL)
+ if err != nil || parsed.Host == "" {
+ return "", errors.New("console public URL is invalid")
+ }
+ query := parsed.Query()
+ switch purpose {
+ case actionVerifyEmail:
+ query.Set("action", "verify-email")
+ case actionPasswordReset:
+ query.Set("action", "reset-password")
+ case actionInvite:
+ query.Set("action", "accept-invite")
+ default:
+ return "", errors.New("unsupported account action")
+ }
+ query.Set("token", token)
+ parsed.RawQuery = query.Encode()
+ return parsed.String(), nil
+}
+
+func actionMail(displayName, purpose, link string, ttl time.Duration) (string, string) {
+ name := strings.TrimSpace(displayName)
+ if name == "" {
+ name = "there"
+ }
+ minutes := int(ttl.Minutes())
+ expires := fmt.Sprintf("%d minutes", minutes)
+ if minutes%1440 == 0 {
+ expires = durationLabel(minutes/1440, "day")
+ } else if minutes%60 == 0 {
+ expires = durationLabel(minutes/60, "hour")
+ }
+ switch purpose {
+ case actionVerifyEmail:
+ return "Verify your AIGW email", fmt.Sprintf("Hi %s,\n\nVerify your email to activate your AIGW workspace:\n\n%s\n\nThis link expires in %s and can be used once. If you did not create this account, ignore this email.\n", name, link, expires)
+ case actionPasswordReset:
+ return "Reset your AIGW password", fmt.Sprintf("Hi %s,\n\nReset your AIGW password:\n\n%s\n\nThis link expires in %s and can be used once. If you did not request this, your password has not changed.\n", name, link, expires)
+ default:
+ return "You have been invited to AIGW", fmt.Sprintf("Hi %s,\n\nAccept your AIGW invitation and choose a password:\n\n%s\n\nThis link expires in %s and can be used once.\n", name, link, expires)
+ }
+}
+
+func durationLabel(value int, unit string) string {
+ if value != 1 {
+ unit += "s"
+ }
+ return fmt.Sprintf("%d %s", value, unit)
+}
+
+func (s *Store) ClaimMail(ctx context.Context) (mailer.Message, bool, error) {
+ tx, err := s.db.Begin(ctx)
+ if err != nil {
+ return mailer.Message{}, false, err
+ }
+ defer tx.Rollback(ctx)
+ var message mailer.Message
+ var ciphertext []byte
+ err = tx.QueryRow(ctx, `WITH candidate AS (
+ SELECT id FROM console_mail_outbox
+ WHERE ((status IN ('pending','failed') AND available_at <= now())
+ OR (status='sending' AND claimed_at < now()-interval '5 minutes'))
+ ORDER BY available_at,created_at FOR UPDATE SKIP LOCKED LIMIT 1
+ ) UPDATE console_mail_outbox o SET status='sending',claimed_at=now(),attempts=attempts+1,last_error=''
+ FROM candidate WHERE o.id=candidate.id
+ RETURNING o.id::text,o.recipient,o.subject,o.body_ciphertext`,
+ ).Scan(&message.ID, &message.Recipient, &message.Subject, &ciphertext)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return mailer.Message{}, false, nil
+ }
+ if err != nil {
+ return mailer.Message{}, false, fmt.Errorf("claim mail outbox item: %w", err)
+ }
+ message.Body, err = s.cipher.Decrypt(ciphertext)
+ if err != nil {
+ return mailer.Message{}, false, fmt.Errorf("decrypt mail outbox item: %w", err)
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return mailer.Message{}, false, err
+ }
+ return message, true, nil
+}
+
+func (s *Store) MarkMailSent(ctx context.Context, id string) error {
+ _, err := s.db.Exec(ctx, `UPDATE console_mail_outbox SET status='sent',sent_at=now(),claimed_at=NULL,last_error=''
+ WHERE id=$1 AND status='sending'`, id)
+ return err
+}
+
+func (s *Store) MarkMailFailed(ctx context.Context, id string, deliveryErr error) error {
+ message := "delivery failed"
+ if deliveryErr != nil {
+ message = deliveryErr.Error()
+ }
+ if len(message) > 1000 {
+ message = message[:1000]
+ }
+ _, err := s.db.Exec(ctx, `UPDATE console_mail_outbox SET status='failed',claimed_at=NULL,last_error=$2,
+ available_at=now()+make_interval(secs => LEAST(300, 5 * attempts)) WHERE id=$1 AND status='sending'`, id, message)
+ return err
+}
diff --git a/internal/controlplane/outbox_test.go b/internal/controlplane/outbox_test.go
new file mode 100644
index 0000000..d9a0f2d
--- /dev/null
+++ b/internal/controlplane/outbox_test.go
@@ -0,0 +1,33 @@
+package controlplane
+
+import (
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestAccountActionURLKeepsAdminPathAndEncodesToken(t *testing.T) {
+ link, err := accountActionURL("https://console.example.test/admin/", actionVerifyEmail, "act-aigw-a+b/c")
+ if err != nil {
+ t.Fatal(err)
+ }
+ parsed, err := url.Parse(link)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if parsed.Path != "/admin/" || parsed.Query().Get("action") != "verify-email" || parsed.Query().Get("token") != "act-aigw-a+b/c" {
+ t.Fatalf("unexpected action URL %q", link)
+ }
+}
+
+func TestActionMailUsesSingularDuration(t *testing.T) {
+ _, dayBody := actionMail("Test", actionVerifyEmail, "https://example.test", 24*time.Hour)
+ _, hourBody := actionMail("Test", actionPasswordReset, "https://example.test", time.Hour)
+ if !strings.Contains(dayBody, "1 day") || strings.Contains(dayBody, "1 days") {
+ t.Fatalf("unexpected day label %q", dayBody)
+ }
+ if !strings.Contains(hourBody, "1 hour") || strings.Contains(hourBody, "1 hours") {
+ t.Fatalf("unexpected hour label %q", hourBody)
+ }
+}
diff --git a/internal/controlplane/passkeys.go b/internal/controlplane/passkeys.go
new file mode 100644
index 0000000..f9fea56
--- /dev/null
+++ b/internal/controlplane/passkeys.go
@@ -0,0 +1,294 @@
+package controlplane
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/go-webauthn/webauthn/protocol"
+ "github.com/go-webauthn/webauthn/webauthn"
+ "github.com/jackc/pgx/v5"
+)
+
+var ErrNoPasskeys = errors.New("no passkeys enrolled")
+
+const webAuthnChallengeTTL = 5 * time.Minute
+
+type consoleWebAuthnUser struct {
+ actor ConsoleActor
+ credentials []webauthn.Credential
+}
+
+func (u *consoleWebAuthnUser) WebAuthnID() []byte { return []byte(u.actor.ID) }
+func (u *consoleWebAuthnUser) WebAuthnName() string { return u.actor.Email }
+func (u *consoleWebAuthnUser) WebAuthnDisplayName() string { return u.actor.DisplayName }
+func (u *consoleWebAuthnUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials }
+
+func (s *Store) webAuthnUser(ctx context.Context, userID string) (*consoleWebAuthnUser, error) {
+ var user consoleWebAuthnUser
+ if err := s.db.QueryRow(ctx, `SELECT id::text,COALESCE(tenant_id::text,''),email,display_name,role
+ FROM console_users WHERE id=$1 AND status='active' AND email_verified_at IS NOT NULL`, userID).
+ Scan(&user.actor.ID, &user.actor.TenantID, &user.actor.Email, &user.actor.DisplayName, &user.actor.Role); err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, ErrConsoleUnauthorized
+ }
+ return nil, err
+ }
+ rows, err := s.db.Query(ctx, `SELECT credential_ciphertext FROM console_passkeys WHERE user_id=$1 ORDER BY created_at`, userID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var ciphertext []byte
+ if err := rows.Scan(&ciphertext); err != nil {
+ return nil, err
+ }
+ plaintext, err := s.cipher.Decrypt(ciphertext)
+ if err != nil {
+ return nil, err
+ }
+ var credential webauthn.Credential
+ if err := json.Unmarshal([]byte(plaintext), &credential); err != nil {
+ return nil, fmt.Errorf("decode stored passkey: %w", err)
+ }
+ user.credentials = append(user.credentials, credential)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return &user, nil
+}
+
+func (s *Store) WebAuthnUser(ctx context.Context, userID string) (webauthn.User, error) {
+ return s.webAuthnUser(ctx, userID)
+}
+
+func (s *Store) BeginWebAuthnRegistration(ctx context.Context, userID string, wa *webauthn.WebAuthn) (string, *protocol.CredentialCreation, error) {
+ user, err := s.webAuthnUser(ctx, userID)
+ if err != nil {
+ return "", nil, err
+ }
+ creation, session, err := wa.BeginRegistration(user)
+ if err != nil {
+ return "", nil, err
+ }
+ token, err := s.storeWebAuthnChallenge(ctx, userID, "register", session, "")
+ if err != nil {
+ return "", nil, err
+ }
+ return token, creation, nil
+}
+
+func (s *Store) BeginWebAuthnLogin(ctx context.Context, userID, purpose, authChallengeID string, wa *webauthn.WebAuthn) (string, *protocol.CredentialAssertion, error) {
+ user, err := s.webAuthnUser(ctx, userID)
+ if err != nil {
+ return "", nil, err
+ }
+ if len(user.credentials) == 0 {
+ return "", nil, ErrNoPasskeys
+ }
+ assertion, session, err := wa.BeginLogin(user)
+ if err != nil {
+ return "", nil, err
+ }
+ token, err := s.storeWebAuthnChallenge(ctx, userID, purpose, session, authChallengeID)
+ if err != nil {
+ return "", nil, err
+ }
+ return token, assertion, nil
+}
+
+func (s *Store) storeWebAuthnChallenge(ctx context.Context, userID, purpose string, session *webauthn.SessionData, authChallengeID string) (string, error) {
+ ensureWebAuthnExpiry(session)
+ encoded, err := json.Marshal(session)
+ if err != nil {
+ return "", err
+ }
+ ciphertext, err := s.cipher.Encrypt(string(encoded))
+ if err != nil {
+ return "", err
+ }
+ raw, hash, err := randomCredential("webauthn-aigw-")
+ if err != nil {
+ return "", err
+ }
+ _, err = s.db.Exec(ctx, `INSERT INTO console_webauthn_challenges
+ (user_id,token_hash,purpose,session_ciphertext,auth_challenge_id,expires_at)
+ VALUES ($1,$2,$3,$4,NULLIF($5,'')::uuid,$6)`, userID, hash, purpose, ciphertext, authChallengeID, session.Expires)
+ if err != nil {
+ return "", err
+ }
+ return raw, nil
+}
+
+func ensureWebAuthnExpiry(session *webauthn.SessionData) {
+ if session.Expires.IsZero() {
+ session.Expires = time.Now().UTC().Add(webAuthnChallengeTTL)
+ }
+}
+
+func (s *Store) WebAuthnSession(ctx context.Context, rawToken, purpose string) (string, string, webauthn.SessionData, error) {
+ hash := sha256.Sum256([]byte(strings.TrimSpace(rawToken)))
+ var userID, authChallengeID string
+ var ciphertext []byte
+ err := s.db.QueryRow(ctx, `SELECT user_id::text,COALESCE(auth_challenge_id::text,''),session_ciphertext FROM console_webauthn_challenges
+ WHERE token_hash=$1 AND purpose=$2 AND consumed_at IS NULL AND expires_at>now()`, hash[:], purpose).Scan(&userID, &authChallengeID, &ciphertext)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return "", "", webauthn.SessionData{}, ErrActionTokenInvalid
+ }
+ if err != nil {
+ return "", "", webauthn.SessionData{}, err
+ }
+ plaintext, err := s.cipher.Decrypt(ciphertext)
+ if err != nil {
+ return "", "", webauthn.SessionData{}, err
+ }
+ var session webauthn.SessionData
+ if err := json.Unmarshal([]byte(plaintext), &session); err != nil {
+ return "", "", webauthn.SessionData{}, err
+ }
+ return userID, authChallengeID, session, nil
+}
+
+func (s *Store) FinishWebAuthnRegistration(ctx context.Context, rawToken, userID, name string, credential *webauthn.Credential) (ConsoleActor, error) {
+ if credential == nil || len(credential.ID) == 0 {
+ return ConsoleActor{}, errors.New("passkey credential is empty")
+ }
+ encoded, err := json.Marshal(credential)
+ if err != nil {
+ return ConsoleActor{}, err
+ }
+ ciphertext, err := s.cipher.Encrypt(string(encoded))
+ if err != nil {
+ return ConsoleActor{}, err
+ }
+ hash := sha256.Sum256([]byte(strings.TrimSpace(rawToken)))
+ tx, err := s.db.Begin(ctx)
+ if err != nil {
+ return ConsoleActor{}, err
+ }
+ defer tx.Rollback(ctx)
+ var challengeID string
+ if err := tx.QueryRow(ctx, `SELECT id::text FROM console_webauthn_challenges
+ WHERE token_hash=$1 AND user_id=$2 AND purpose='register' AND consumed_at IS NULL AND expires_at>now() FOR UPDATE`, hash[:], userID).Scan(&challengeID); err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return ConsoleActor{}, ErrActionTokenInvalid
+ }
+ return ConsoleActor{}, err
+ }
+ label := strings.TrimSpace(name)
+ if label == "" {
+ label = "Passkey"
+ }
+ if _, err := tx.Exec(ctx, `INSERT INTO console_passkeys (user_id,credential_id,credential_ciphertext,name)
+ VALUES ($1,$2,$3,$4)`, userID, credential.ID, ciphertext, label); err != nil {
+ return ConsoleActor{}, err
+ }
+ if _, err := tx.Exec(ctx, `UPDATE console_webauthn_challenges SET consumed_at=now() WHERE id=$1`, challengeID); err != nil {
+ return ConsoleActor{}, err
+ }
+ actor, err := actorInTx(ctx, tx, userID)
+ if err != nil {
+ return ConsoleActor{}, err
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return ConsoleActor{}, err
+ }
+ return actor, nil
+}
+
+func (s *Store) FinishWebAuthnLogin(ctx context.Context, rawToken, userID, authChallengeID string, credential *webauthn.Credential, method string) (ConsoleActor, error) {
+ if credential == nil || len(credential.ID) == 0 {
+ return ConsoleActor{}, errors.New("passkey assertion is empty")
+ }
+ encoded, err := json.Marshal(credential)
+ if err != nil {
+ return ConsoleActor{}, err
+ }
+ ciphertext, err := s.cipher.Encrypt(string(encoded))
+ if err != nil {
+ return ConsoleActor{}, err
+ }
+ hash := sha256.Sum256([]byte(strings.TrimSpace(rawToken)))
+ tx, err := s.db.Begin(ctx)
+ if err != nil {
+ return ConsoleActor{}, err
+ }
+ defer tx.Rollback(ctx)
+ var challengeID string
+ if err := tx.QueryRow(ctx, `SELECT id::text FROM console_webauthn_challenges
+ WHERE token_hash=$1 AND user_id=$2 AND purpose=$3 AND consumed_at IS NULL AND expires_at>now() FOR UPDATE`, hash[:], userID, method).Scan(&challengeID); err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return ConsoleActor{}, ErrActionTokenInvalid
+ }
+ return ConsoleActor{}, err
+ }
+ result, err := tx.Exec(ctx, `UPDATE console_passkeys SET credential_ciphertext=$3,last_used_at=now()
+ WHERE user_id=$1 AND credential_id=$2`, userID, credential.ID, ciphertext)
+ if err != nil {
+ return ConsoleActor{}, err
+ }
+ if result.RowsAffected() == 0 {
+ return ConsoleActor{}, errors.New("passkey is not enrolled")
+ }
+ if _, err := tx.Exec(ctx, `UPDATE console_webauthn_challenges SET consumed_at=now() WHERE id=$1`, challengeID); err != nil {
+ return ConsoleActor{}, err
+ }
+ if authChallengeID != "" {
+ result, err := tx.Exec(ctx, `UPDATE console_auth_challenges SET consumed_at=now() WHERE id=$1 AND consumed_at IS NULL AND expires_at>now()`, authChallengeID)
+ if err != nil {
+ return ConsoleActor{}, err
+ }
+ if result.RowsAffected() != 1 {
+ return ConsoleActor{}, ErrActionTokenInvalid
+ }
+ }
+ actor, err := actorInTx(ctx, tx, userID)
+ if err != nil {
+ return ConsoleActor{}, err
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return ConsoleActor{}, err
+ }
+ return actor, nil
+}
+
+func actorInTx(ctx context.Context, tx pgx.Tx, userID string) (ConsoleActor, error) {
+ var actor ConsoleActor
+ err := tx.QueryRow(ctx, `SELECT id::text,COALESCE(tenant_id::text,''),email,display_name,role
+ FROM console_users WHERE id=$1 AND status='active'`, userID).Scan(&actor.ID, &actor.TenantID, &actor.Email, &actor.DisplayName, &actor.Role)
+ return actor, err
+}
+
+func (s *Store) ListPasskeys(ctx context.Context, userID string) ([]Passkey, error) {
+ rows, err := s.db.Query(ctx, `SELECT id::text,name,created_at,last_used_at FROM console_passkeys WHERE user_id=$1 ORDER BY created_at`, userID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ result := make([]Passkey, 0)
+ for rows.Next() {
+ var item Passkey
+ if err := rows.Scan(&item.ID, &item.Name, &item.CreatedAt, &item.LastUsedAt); err != nil {
+ return nil, err
+ }
+ result = append(result, item)
+ }
+ return result, rows.Err()
+}
+
+func (s *Store) DeletePasskey(ctx context.Context, userID, id string) error {
+ result, err := s.db.Exec(ctx, `DELETE FROM console_passkeys WHERE id=$1 AND user_id=$2`, id, userID)
+ if err != nil {
+ return err
+ }
+ if result.RowsAffected() == 0 {
+ return ErrNotFound
+ }
+ return nil
+}
diff --git a/internal/controlplane/passkeys_test.go b/internal/controlplane/passkeys_test.go
new file mode 100644
index 0000000..8162133
--- /dev/null
+++ b/internal/controlplane/passkeys_test.go
@@ -0,0 +1,27 @@
+package controlplane
+
+import (
+ "testing"
+ "time"
+
+ "github.com/go-webauthn/webauthn/webauthn"
+)
+
+func TestEnsureWebAuthnExpirySetsServerSideDeadline(t *testing.T) {
+ before := time.Now().UTC()
+ session := &webauthn.SessionData{}
+ ensureWebAuthnExpiry(session)
+ if session.Expires.Before(before.Add(webAuthnChallengeTTL-time.Second)) ||
+ session.Expires.After(time.Now().UTC().Add(webAuthnChallengeTTL+time.Second)) {
+ t.Fatalf("expiry %s is not approximately %s from now", session.Expires, webAuthnChallengeTTL)
+ }
+}
+
+func TestEnsureWebAuthnExpiryPreservesLibraryDeadline(t *testing.T) {
+ want := time.Now().UTC().Add(time.Minute)
+ session := &webauthn.SessionData{Expires: want}
+ ensureWebAuthnExpiry(session)
+ if !session.Expires.Equal(want) {
+ t.Fatalf("expiry = %s, want %s", session.Expires, want)
+ }
+}
diff --git a/internal/controlplane/schema.sql b/internal/controlplane/schema.sql
index fc7f5e7..8a5a605 100644
--- a/internal/controlplane/schema.sql
+++ b/internal/controlplane/schema.sql
@@ -214,10 +214,20 @@ ALTER TABLE console_users ADD COLUMN IF NOT EXISTS password_hash BYTEA;
ALTER TABLE console_users ADD COLUMN IF NOT EXISTS password_salt BYTEA;
ALTER TABLE console_users ADD COLUMN IF NOT EXISTS password_iterations INTEGER;
ALTER TABLE console_users ADD COLUMN IF NOT EXISTS password_changed_at TIMESTAMPTZ;
+ALTER TABLE console_users ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ;
+ALTER TABLE console_users ADD COLUMN IF NOT EXISTS invited_at TIMESTAMPTZ;
+ALTER TABLE console_users ADD COLUMN IF NOT EXISTS accepted_at TIMESTAMPTZ;
+ALTER TABLE console_users DROP CONSTRAINT IF EXISTS console_users_status_check;
+ALTER TABLE console_users ADD CONSTRAINT console_users_status_check
+ CHECK (status IN ('pending_verification', 'invited', 'active', 'revoked'));
+UPDATE console_users SET email_verified_at = COALESCE(email_verified_at, created_at)
+ WHERE status = 'active' AND password_hash IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS console_users_email_tenant_idx
ON console_users (lower(email), COALESCE(tenant_id, '00000000-0000-0000-0000-000000000000'::uuid));
CREATE UNIQUE INDEX IF NOT EXISTS console_users_login_email_idx
ON console_users (lower(email)) WHERE password_hash IS NOT NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS console_users_global_email_idx
+ ON console_users (lower(email));
CREATE TABLE IF NOT EXISTS console_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -229,8 +239,14 @@ CREATE TABLE IF NOT EXISTS console_sessions (
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
revoked_at TIMESTAMPTZ,
remote_ip INET,
- user_agent TEXT NOT NULL DEFAULT ''
+ user_agent TEXT NOT NULL DEFAULT '',
+ auth_method TEXT NOT NULL DEFAULT 'password',
+ mfa_verified_at TIMESTAMPTZ
);
+ALTER TABLE console_sessions ADD COLUMN IF NOT EXISTS auth_method TEXT NOT NULL DEFAULT 'password';
+ALTER TABLE console_sessions ADD COLUMN IF NOT EXISTS mfa_verified_at TIMESTAMPTZ;
+CREATE INDEX IF NOT EXISTS console_sessions_user_active_idx
+ ON console_sessions (user_id, created_at DESC) WHERE revoked_at IS NULL;
CREATE TABLE IF NOT EXISTS console_login_throttles (
identity_hash BYTEA PRIMARY KEY CHECK (octet_length(identity_hash) = 32),
@@ -240,6 +256,101 @@ CREATE TABLE IF NOT EXISTS console_login_throttles (
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
+CREATE TABLE IF NOT EXISTS console_rate_limits (
+ bucket_hash BYTEA PRIMARY KEY CHECK (octet_length(bucket_hash) = 32),
+ hits INTEGER NOT NULL DEFAULT 0,
+ window_started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS console_action_tokens (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES console_users(id) ON DELETE CASCADE,
+ purpose TEXT NOT NULL CHECK (purpose IN ('verify_email', 'password_reset', 'invite')),
+ token_hash BYTEA NOT NULL UNIQUE CHECK (octet_length(token_hash) = 32),
+ expires_at TIMESTAMPTZ NOT NULL,
+ consumed_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ requested_ip INET
+);
+CREATE INDEX IF NOT EXISTS console_action_tokens_active_idx
+ ON console_action_tokens (user_id, purpose, expires_at DESC) WHERE consumed_at IS NULL;
+
+CREATE TABLE IF NOT EXISTS console_mail_outbox (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ recipient TEXT NOT NULL,
+ template TEXT NOT NULL CHECK (template IN ('verify_email', 'password_reset', 'invite')),
+ subject TEXT NOT NULL,
+ body_ciphertext BYTEA NOT NULL,
+ status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sending', 'sent', 'failed')),
+ attempts INTEGER NOT NULL DEFAULT 0,
+ available_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ claimed_at TIMESTAMPTZ,
+ sent_at TIMESTAMPTZ,
+ last_error TEXT NOT NULL DEFAULT '',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS console_mail_outbox_pending_idx
+ ON console_mail_outbox (available_at, created_at) WHERE status IN ('pending', 'failed');
+
+CREATE TABLE IF NOT EXISTS console_auth_challenges (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES console_users(id) ON DELETE CASCADE,
+ token_hash BYTEA NOT NULL UNIQUE CHECK (octet_length(token_hash) = 32),
+ purpose TEXT NOT NULL CHECK (purpose IN ('mfa_login')),
+ allowed_methods JSONB NOT NULL DEFAULT '[]'::jsonb,
+ expires_at TIMESTAMPTZ NOT NULL,
+ consumed_at TIMESTAMPTZ,
+ attempts INTEGER NOT NULL DEFAULT 0,
+ remote_ip INET,
+ user_agent TEXT NOT NULL DEFAULT '',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS console_auth_challenges_active_idx
+ ON console_auth_challenges (user_id, expires_at DESC) WHERE consumed_at IS NULL;
+
+CREATE TABLE IF NOT EXISTS console_totp_credentials (
+ user_id UUID PRIMARY KEY REFERENCES console_users(id) ON DELETE CASCADE,
+ secret_ciphertext BYTEA NOT NULL,
+ confirmed_at TIMESTAMPTZ,
+ last_used_step BIGINT NOT NULL DEFAULT -1,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS console_recovery_codes (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES console_users(id) ON DELETE CASCADE,
+ code_hash BYTEA NOT NULL CHECK (octet_length(code_hash) = 32),
+ used_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ UNIQUE (user_id, code_hash)
+);
+
+CREATE TABLE IF NOT EXISTS console_passkeys (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES console_users(id) ON DELETE CASCADE,
+ credential_id BYTEA NOT NULL UNIQUE,
+ credential_ciphertext BYTEA NOT NULL,
+ name TEXT NOT NULL DEFAULT 'Passkey',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ last_used_at TIMESTAMPTZ
+);
+CREATE INDEX IF NOT EXISTS console_passkeys_user_idx ON console_passkeys (user_id, created_at);
+
+CREATE TABLE IF NOT EXISTS console_webauthn_challenges (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES console_users(id) ON DELETE CASCADE,
+ token_hash BYTEA NOT NULL UNIQUE CHECK (octet_length(token_hash) = 32),
+ purpose TEXT NOT NULL CHECK (purpose IN ('register', 'login', 'mfa_login')),
+ session_ciphertext BYTEA NOT NULL,
+ auth_challenge_id UUID REFERENCES console_auth_challenges(id) ON DELETE CASCADE,
+ expires_at TIMESTAMPTZ NOT NULL,
+ consumed_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS console_webauthn_challenges_active_idx
+ ON console_webauthn_challenges (user_id, expires_at DESC) WHERE consumed_at IS NULL;
+
CREATE TABLE IF NOT EXISTS project_limits (
project_id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
diff --git a/internal/controlplane/types.go b/internal/controlplane/types.go
index 959a9ee..f81e63b 100644
--- a/internal/controlplane/types.go
+++ b/internal/controlplane/types.go
@@ -150,25 +150,28 @@ type CreateModelInput struct {
}
type ConsoleActor struct {
- ID string `json:"id,omitempty"`
- TenantID string `json:"tenant_id,omitempty"`
- Email string `json:"email"`
- DisplayName string `json:"display_name"`
- Role string `json:"role"`
- Bootstrap bool `json:"bootstrap"`
+ ID string `json:"id,omitempty"`
+ TenantID string `json:"tenant_id,omitempty"`
+ Email string `json:"email"`
+ DisplayName string `json:"display_name"`
+ Role string `json:"role"`
+ Bootstrap bool `json:"bootstrap"`
+ MFAMethods []string `json:"mfa_methods,omitempty"`
}
type ConsoleUser struct {
- ID string `json:"id"`
- TenantID string `json:"tenant_id,omitempty"`
- Email string `json:"email"`
- DisplayName string `json:"display_name"`
- Role string `json:"role"`
- TokenPrefix string `json:"token_prefix,omitempty"`
- HasPassword bool `json:"has_password"`
- Status string `json:"status"`
- LastUsedAt *time.Time `json:"last_used_at,omitempty"`
- CreatedAt time.Time `json:"created_at"`
+ ID string `json:"id"`
+ TenantID string `json:"tenant_id,omitempty"`
+ Email string `json:"email"`
+ DisplayName string `json:"display_name"`
+ Role string `json:"role"`
+ TokenPrefix string `json:"token_prefix,omitempty"`
+ HasPassword bool `json:"has_password"`
+ Status string `json:"status"`
+ EmailVerified bool `json:"email_verified"`
+ MFAMethods []string `json:"mfa_methods"`
+ LastUsedAt *time.Time `json:"last_used_at,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
}
type CreatedConsoleUser struct {
@@ -180,7 +183,7 @@ type CreateConsoleUserInput struct {
Email string `json:"email"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
- Password string `json:"password"`
+ Password string `json:"-"`
}
type RegisterInput struct {
@@ -201,11 +204,70 @@ type PasswordChangeInput struct {
NewPassword string `json:"new_password"`
}
+type PasswordResetInput struct {
+ Token string `json:"token"`
+ NewPassword string `json:"new_password"`
+}
+
+type InviteAcceptInput struct {
+ Token string `json:"token"`
+ DisplayName string `json:"display_name"`
+ Password string `json:"password"`
+}
+
+type MFACodeInput struct {
+ ChallengeToken string `json:"challenge_token"`
+ Code string `json:"code"`
+}
+
type ConsoleSession struct {
- Actor ConsoleActor
- Token string
- CSRFToken string
- ExpiresAt time.Time
+ Actor ConsoleActor
+ Token string
+ CSRFToken string
+ ExpiresAt time.Time
+ ID string
+ AuthMethod string
+}
+
+type DeviceSession struct {
+ ID string `json:"id"`
+ RemoteIP string `json:"remote_ip,omitempty"`
+ UserAgent string `json:"user_agent"`
+ AuthMethod string `json:"auth_method"`
+ MFAVerifiedAt *time.Time `json:"mfa_verified_at,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ LastSeenAt time.Time `json:"last_seen_at"`
+ ExpiresAt time.Time `json:"expires_at"`
+ Current bool `json:"current"`
+}
+
+type AuthChallenge struct {
+ Token string `json:"challenge_token"`
+ Methods []string `json:"methods"`
+ ExpiresAt time.Time `json:"expires_at"`
+}
+
+type TOTPEnrollment struct {
+ Secret string `json:"secret"`
+ URI string `json:"uri"`
+ QRCode string `json:"qr_code"`
+}
+
+type MFAStatus struct {
+ TOTPEnabled bool `json:"totp_enabled"`
+ Passkeys []Passkey `json:"passkeys"`
+}
+
+type Passkey struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ CreatedAt time.Time `json:"created_at"`
+ LastUsedAt *time.Time `json:"last_used_at,omitempty"`
+}
+
+type ActionResult struct {
+ Actor ConsoleActor
+ Generation int64
}
type ProjectLimit struct {
diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go
new file mode 100644
index 0000000..4ec11ac
--- /dev/null
+++ b/internal/mailer/mailer.go
@@ -0,0 +1,170 @@
+package mailer
+
+import (
+ "context"
+ "crypto/tls"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net"
+ "net/smtp"
+ "strings"
+ "time"
+)
+
+type Message struct {
+ ID string
+ Recipient string
+ Subject string
+ Body string
+}
+
+type Queue interface {
+ ClaimMail(context.Context) (Message, bool, error)
+ MarkMailSent(context.Context, string) error
+ MarkMailFailed(context.Context, string, error) error
+}
+
+type SMTPConfig struct {
+ Address string
+ Username string
+ Password string
+ FromName string
+ FromAddress string
+ TLSMode string
+ DialTimeout time.Duration
+}
+
+type Sender struct {
+ config SMTPConfig
+}
+
+func NewSender(config SMTPConfig) (*Sender, error) {
+ if strings.TrimSpace(config.Address) == "" || strings.TrimSpace(config.FromAddress) == "" {
+ return nil, errors.New("SMTP address and from address are required")
+ }
+ if config.DialTimeout <= 0 {
+ config.DialTimeout = 10 * time.Second
+ }
+ return &Sender{config: config}, nil
+}
+
+func (s *Sender) Send(ctx context.Context, message Message) error {
+ host, _, err := net.SplitHostPort(s.config.Address)
+ if err != nil {
+ return fmt.Errorf("parse SMTP address: %w", err)
+ }
+ dialer := net.Dialer{Timeout: s.config.DialTimeout}
+ var conn net.Conn
+ if s.config.TLSMode == "tls" {
+ conn, err = tls.DialWithDialer(&dialer, "tcp", s.config.Address, &tls.Config{MinVersion: tls.VersionTLS12, ServerName: host})
+ } else {
+ conn, err = dialer.DialContext(ctx, "tcp", s.config.Address)
+ }
+ if err != nil {
+ return fmt.Errorf("connect SMTP: %w", err)
+ }
+ defer conn.Close()
+ client, err := smtp.NewClient(conn, host)
+ if err != nil {
+ return fmt.Errorf("open SMTP client: %w", err)
+ }
+ defer client.Close()
+ if s.config.TLSMode == "starttls" {
+ if ok, _ := client.Extension("STARTTLS"); !ok {
+ return errors.New("SMTP server does not support required STARTTLS")
+ }
+ if err := client.StartTLS(&tls.Config{MinVersion: tls.VersionTLS12, ServerName: host}); err != nil {
+ return fmt.Errorf("start SMTP TLS: %w", err)
+ }
+ }
+ if s.config.Username != "" {
+ if ok, _ := client.Extension("AUTH"); !ok {
+ return errors.New("SMTP server does not support authentication")
+ }
+ if err := client.Auth(smtp.PlainAuth("", s.config.Username, s.config.Password, host)); err != nil {
+ return fmt.Errorf("authenticate SMTP: %w", err)
+ }
+ }
+ if err := client.Mail(s.config.FromAddress); err != nil {
+ return fmt.Errorf("set SMTP sender: %w", err)
+ }
+ if err := client.Rcpt(message.Recipient); err != nil {
+ return fmt.Errorf("set SMTP recipient: %w", err)
+ }
+ w, err := client.Data()
+ if err != nil {
+ return fmt.Errorf("start SMTP body: %w", err)
+ }
+ from := s.config.FromAddress
+ if name := cleanHeader(s.config.FromName); name != "" {
+ from = fmt.Sprintf("%s <%s>", name, s.config.FromAddress)
+ }
+ body := strings.ReplaceAll(message.Body, "\r\n", "\n")
+ body = strings.ReplaceAll(body, "\n", "\r\n")
+ _, writeErr := fmt.Fprintf(w, "From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n%s\r\n",
+ from, cleanHeader(message.Recipient), cleanHeader(message.Subject), body)
+ closeErr := w.Close()
+ if writeErr != nil {
+ return fmt.Errorf("write SMTP body: %w", writeErr)
+ }
+ if closeErr != nil {
+ return fmt.Errorf("finish SMTP body: %w", closeErr)
+ }
+ if err := client.Quit(); err != nil {
+ return fmt.Errorf("finish SMTP session: %w", err)
+ }
+ return nil
+}
+
+func cleanHeader(value string) string {
+ value = strings.ReplaceAll(value, "\r", "")
+ return strings.ReplaceAll(value, "\n", "")
+}
+
+type Worker struct {
+ queue Queue
+ sender *Sender
+ logger *slog.Logger
+}
+
+func NewWorker(queue Queue, sender *Sender, logger *slog.Logger) *Worker {
+ if logger == nil {
+ logger = slog.Default()
+ }
+ return &Worker{queue: queue, sender: sender, logger: logger}
+}
+
+func (w *Worker) Run(ctx context.Context) {
+ ticker := time.NewTicker(3 * time.Second)
+ defer ticker.Stop()
+ for {
+ w.drain(ctx)
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ }
+ }
+}
+
+func (w *Worker) drain(ctx context.Context) {
+ for i := 0; i < 20 && ctx.Err() == nil; i++ {
+ message, ok, err := w.queue.ClaimMail(ctx)
+ if err != nil {
+ w.logger.Warn("mail_outbox_claim_failed", "error", err)
+ return
+ }
+ if !ok {
+ return
+ }
+ if err := w.sender.Send(ctx, message); err != nil {
+ _ = w.queue.MarkMailFailed(ctx, message.ID, err)
+ w.logger.Warn("mail_delivery_failed", "message_id", message.ID, "error", err)
+ continue
+ }
+ if err := w.queue.MarkMailSent(ctx, message.ID); err != nil {
+ w.logger.Warn("mail_outbox_complete_failed", "message_id", message.ID, "error", err)
+ }
+ }
+}
diff --git a/scripts/start-debug.sh b/scripts/start-debug.sh
index 698ae68..7a22a89 100755
--- a/scripts/start-debug.sh
+++ b/scripts/start-debug.sh
@@ -22,14 +22,32 @@ if [[ ! -f "$env_file" ]]; then
command -v openssl >/dev/null 2>&1 || fail "openssl is required to generate local credentials"
credential_key="$(openssl rand -base64 32 | tr -d '\n')"
admin_token="aigw-admin-$(openssl rand -hex 24)"
+ postgres_password="$(openssl rand -hex 24)"
umask 077
{
- printf 'AIGW_DATABASE_URL=postgres://aigw:aigw@127.0.0.1:5432/aigw?sslmode=disable\n'
+ printf 'AIGW_SERVER_ADDRESS=:8080\n'
+ printf 'AIGW_POSTGRES_USER=aigw\n'
+ printf 'AIGW_POSTGRES_PASSWORD=%s\n' "$postgres_password"
+ printf 'AIGW_POSTGRES_DB=aigw\n'
+ printf 'AIGW_DATABASE_URL=postgres://aigw:%s@127.0.0.1:5432/aigw?sslmode=disable\n' "$postgres_password"
+ printf 'AIGW_DATABASE_URL_DOCKER=postgres://aigw:%s@postgres:5432/aigw?sslmode=disable\n' "$postgres_password"
printf 'AIGW_REDIS_URL=redis://127.0.0.1:6379/0\n'
+ printf 'AIGW_REDIS_URL_DOCKER=redis://redis:6379/0\n'
printf 'AIGW_CREDENTIAL_KEY=%s\n' "$credential_key"
printf 'AIGW_ADMIN_TOKEN=%s\n' "$admin_token"
+ printf 'AIGW_PUBLIC_URL=http://localhost:8080/admin/\n'
+ printf 'AIGW_WEBAUTHN_RP_ID=localhost\n'
+ printf 'AIGW_WEBAUTHN_ORIGINS=http://localhost:8080\n'
+ printf 'AIGW_SMTP_FROM_ADDRESS=no-reply@aigw.local\n'
+ printf 'AIGW_SMTP_ADDRESS=127.0.0.1:1025\n'
+ printf 'AIGW_SMTP_ADDRESS_DOCKER=mailpit:1025\n'
+ printf 'AIGW_SMTP_USERNAME=\n'
+ printf 'AIGW_SMTP_PASSWORD=\n'
printf 'AIGW_STRIPE_API_KEY=rk_test_replace_me\n'
+ printf 'AIGW_STRIPE_CLI_API_KEY=rk_test_replace_me\n'
printf 'AIGW_STRIPE_WEBHOOK_SECRET=whsec_replace_me\n'
+ printf 'AIGW_STRIPE_SUCCESS_URL=http://localhost:8080/admin/?topup=success\n'
+ printf 'AIGW_STRIPE_CANCEL_URL=http://localhost:8080/admin/?topup=cancel\n'
} >"$env_file"
chmod 600 "$env_file"
log "created $env_file"
@@ -37,6 +55,9 @@ fi
admin_token="$(sed -n 's/^AIGW_ADMIN_TOKEN=//p' "$env_file" | head -n 1)"
[[ -n "$admin_token" ]] || fail "AIGW_ADMIN_TOKEN is missing from $env_file"
+for required_name in AIGW_SERVER_ADDRESS AIGW_POSTGRES_USER AIGW_POSTGRES_PASSWORD AIGW_POSTGRES_DB AIGW_DATABASE_URL_DOCKER AIGW_CREDENTIAL_KEY AIGW_PUBLIC_URL AIGW_WEBAUTHN_RP_ID AIGW_WEBAUTHN_ORIGINS AIGW_SMTP_FROM_ADDRESS AIGW_SMTP_ADDRESS_DOCKER AIGW_STRIPE_API_KEY AIGW_STRIPE_WEBHOOK_SECRET AIGW_STRIPE_SUCCESS_URL AIGW_STRIPE_CANCEL_URL; do
+ grep -q "^${required_name}=." "$env_file" || fail "$required_name is missing from $env_file; compare it with .env.control.example"
+done
cd "$repo_dir"
if [[ "${AIGW_DEBUG_SKIP_BUILD:-0}" == "1" ]]; then
@@ -47,7 +68,7 @@ else
docker build --network="$build_network" -t aigw-debug:local .
fi
-log "starting PostgreSQL, Redis, and gateway"
+log "starting PostgreSQL, Redis, Mailpit, and gateway"
if ! docker compose --env-file "$env_file" up -d --no-build --wait --wait-timeout 120; then
docker compose --env-file "$env_file" ps >&2 || true
gateway_logs="$(docker compose --env-file "$env_file" logs --no-color --tail=120 aigw 2>&1 || true)"
@@ -60,10 +81,10 @@ if ! docker compose --env-file "$env_file" up -d --no-build --wait --wait-timeou
fi
log "services are ready"
-printf '\nAdmin UI: http://127.0.0.1:8080/admin/\n'
+printf '\nAdmin UI: http://localhost:8080/admin/\n'
+printf 'Mail inbox: http://127.0.0.1:8025/\n'
printf 'Health: http://127.0.0.1:8080/readyz\n'
-printf 'Admin token: %s\n' "$admin_token"
-printf 'Environment: %s\n' "$env_file"
+printf 'Secrets: %s (mode 0600)\n' "$env_file"
printf '\nLogs: docker compose --env-file %q logs -f aigw\n' "$env_file"
printf 'Stop: ./scripts/stop-debug.sh\n'
diff --git a/scripts/stop-debug.sh b/scripts/stop-debug.sh
index 8976d2c..43de07d 100755
--- a/scripts/stop-debug.sh
+++ b/scripts/stop-debug.sh
@@ -14,10 +14,18 @@ command -v docker >/dev/null 2>&1 || {
if [[ -f "$env_file" ]]; then
compose=(docker compose --env-file "$env_file")
else
+ export AIGW_SERVER_ADDRESS=:8080
+ export AIGW_POSTGRES_USER=debug-stop-placeholder
+ export AIGW_POSTGRES_PASSWORD=debug-stop-placeholder
+ export AIGW_POSTGRES_DB=debug-stop-placeholder
+ export AIGW_DATABASE_URL_DOCKER=postgres://debug-stop-placeholder:debug-stop-placeholder@postgres:5432/debug-stop-placeholder
+ export AIGW_REDIS_URL_DOCKER=
export AIGW_CREDENTIAL_KEY=debug-stop-placeholder
export AIGW_ADMIN_TOKEN=debug-stop-placeholder
export AIGW_STRIPE_API_KEY=debug-stop-placeholder
export AIGW_STRIPE_WEBHOOK_SECRET=debug-stop-placeholder
+ export AIGW_STRIPE_SUCCESS_URL=http://127.0.0.1:8080/admin/?topup=success
+ export AIGW_STRIPE_CANCEL_URL=http://127.0.0.1:8080/admin/?topup=cancel
compose=(docker compose)
fi