Compare commits

...

3 Commits

Author SHA1 Message Date
jordan
0f25bd8dbe feat: hook in notify service for per-project email delivery
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Add NotifyProvisioner (port + adapter) using real notify admin API
- Create notify account + send key + host grant per project
- Inject NOTIFY_API_KEY/HOST/FROM into component deployments
- Store NOTIFY_URL, NOTIFY_ADMIN_KEY, RESEND_API_KEY in credential store
- Add setup-notify.sh for one-time host/provider/domain setup
- Add NOTIFY_ADMIN_KEY constant to domain/credential.go
- Wire provisioner in main.go with connection test guard
- Add .claude/guides/services/notify.md and CLAUDE.md entry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 00:30:32 -07:00
jordan
bc77504b35 fix: add 'use client' directive to MediaLibrary and MediaUploader components
These components use useState/useRef hooks but lacked the Next.js 'use client'
directive, causing the Next.js app build to fail with Server Component errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 00:32:24 -07:00
jordan
592b2d5ec0 fix: clarify database types across docs and fix video storage persistence
Two distinct fixes:

1. Database terminology: Make it crystal clear that generated projects use
   CockroachDB in production and PostgreSQL for local dev, while the rdev
   platform itself uses PostgreSQL. Updated 15 files across skeleton agents,
   component templates, cookbook trees, and platform docs.

2. Video storage: VideoHandler was ignoring vid.Data bytes (already downloaded
   by the Gemini adapter with auth) and re-downloading from the provider URL
   with a plain GET — which fails because Gemini URLs require API key auth.
   Now uses vid.Data first, falls back to downloadURL only for public URLs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 23:13:21 -07:00
28 changed files with 704 additions and 23 deletions

View File

@ -14,6 +14,16 @@ Run Claude Code instances in isolated Kubernetes pods with REST API control. Ena
When discussing code: "add to **platform**" = edit rdev; "add to **skeleton**" = edit project templates.
### Database Rule
| Context | Database | Details |
|---------|----------|---------|
| **rdev platform** | PostgreSQL | API keys, audit logs, work queue, credentials (`internal/adapter/postgres/`) |
| **Generated projects (production)** | CockroachDB | Provisioned per-project by rdev (`internal/adapter/cockroach/`) |
| **Generated projects (local dev)** | PostgreSQL | Via docker-compose, wire-compatible with CockroachDB |
Both use `lib/pq` driver. The `type: postgres` component API provisions **CockroachDB** in production — the name is a legacy artifact. Skeleton SQL must be compatible with both PostgreSQL and CockroachDB.
## Find Your Guide
| If you need to... | Read this |
@ -57,6 +67,7 @@ When discussing code: "add to **platform**" = edit rdev; "add to **skeleton**" =
| **Traefik v3 ingress & middleware** | [ops/traefik-v3.md](.claude/guides/ops/traefik-v3.md) |
| **Zot container registry** | [ops/zot-registry.md](.claude/guides/ops/zot-registry.md) |
| **cert-manager / TLS certificates** | [ops/cert-manager.md](.claude/guides/ops/cert-manager.md) |
| **Notify / email delivery** | [services/notify.md](.claude/guides/services/notify.md) |
| **Structured logging** | `internal/logging/` - field constants, context propagation, redaction |
## Critical Rules

View File

@ -87,6 +87,12 @@ type InfraConfig struct {
GCSProjectID string // e.g., "threesix-prod"
GCSCredentialsPath string // Path to service account JSON (empty = ADC)
GCSLocation string // Bucket location (default: "US")
// Notify provisioner (for project email delivery)
NotifyURL string // e.g., "https://notify.orchard9.ai"
NotifyAdminKey string // notify_admin_... admin API key
NotifyHost string // shared host (e.g., "threesix.ai")
NotifyFrom string // from-address (e.g., "noreply@threesix.ai")
}
func loadConfig() Config {
@ -147,6 +153,8 @@ func loadInfraConfig(ctx context.Context, store port.CredentialStore, cfg Config
domain.CredKeyWoodpeckerAPIToken,
domain.CredKeyWoodpeckerWebhookSecret,
domain.CredKeyRegistryURL,
domain.CredKeyNotifyURL,
domain.CredKeyNotifyAdminKey,
})
if err != nil {
logger.Warn("failed to load credentials from store, using env vars", "error", err)
@ -189,6 +197,12 @@ func loadInfraConfig(ctx context.Context, store port.CredentialStore, cfg Config
GCSProjectID: os.Getenv("GCS_PROJECT_ID"),
GCSCredentialsPath: os.Getenv("GCS_CREDENTIALS_PATH"),
GCSLocation: envutil.GetEnv("GCS_LOCATION", "US"),
// Notify provisioner (credential store with env fallback)
NotifyURL: getOrFallback(domain.CredKeyNotifyURL, os.Getenv("NOTIFY_URL")),
NotifyAdminKey: getOrFallback(domain.CredKeyNotifyAdminKey, os.Getenv("NOTIFY_ADMIN_KEY")),
NotifyHost: envutil.GetEnv("NOTIFY_HOST", "threesix.ai"),
NotifyFrom: envutil.GetEnv("NOTIFY_FROM", "noreply@threesix.ai"),
}
// Log which credentials were loaded from store vs env

View File

@ -18,6 +18,7 @@ import (
"github.com/orchard9/rdev/internal/adapter/gitea"
"github.com/orchard9/rdev/internal/adapter/kubernetes"
"github.com/orchard9/rdev/internal/adapter/memory"
notifyadapter "github.com/orchard9/rdev/internal/adapter/notify"
"github.com/orchard9/rdev/internal/adapter/postgres"
redisadapter "github.com/orchard9/rdev/internal/adapter/redis"
sdlcadapter "github.com/orchard9/rdev/internal/adapter/sdlc"
@ -240,6 +241,23 @@ func main() {
}
defer closeProvisioner(storageProvisioner, "gcs", logger)
// Initialize notify provisioner (optional - for project email delivery)
var notifyProvisioner port.NotifyProvisioner
if infraCfg.NotifyURL != "" && infraCfg.NotifyAdminKey != "" {
np := notifyadapter.NewProvisioner(notifyadapter.Config{
BaseURL: infraCfg.NotifyURL,
AdminKey: infraCfg.NotifyAdminKey,
Host: infraCfg.NotifyHost,
From: infraCfg.NotifyFrom,
}, logger)
if err := np.TestConnection(context.Background()); err != nil {
logger.Warn("notify provisioner connection test failed, disabling", "error", err)
} else {
notifyProvisioner = np
logger.Info("notify provisioner initialized", "url", infraCfg.NotifyURL, "host", infraCfg.NotifyHost)
}
}
// Initialize registry client (for monitoring and image cleanup on project teardown)
var registryClient *zot.Client
if infraCfg.RegistryURL != "" {
@ -482,6 +500,9 @@ func main() {
if citadelClient != nil {
projectInfraService = projectInfraService.WithCitadelClient(citadelClient)
}
if notifyProvisioner != nil {
projectInfraService = projectInfraService.WithNotifyProvisioner(notifyProvisioner)
}
// Create domain service adapter for infrastructure handler
domainServiceAdapter := handlers.NewDomainServiceAdapter(projectInfraService)

View File

@ -22,7 +22,7 @@ steps:
- domain: .data.domain
add-db:
description: Add Postgres
description: Add CockroachDB
depends_on: [create-project]
action: api
method: POST

View File

@ -19,7 +19,7 @@ steps:
name: "{{ .vars.project_name }}"
description: "Foundary Studio: Task management with Kanban board"
template: "skeleton"
prompt: "Set up the monorepo workspace. Ensure the root README describes a task management studio with Kanban board, REST API, and Postgres persistence."
prompt: "Set up the monorepo workspace. Ensure the root README describes a task management studio with Kanban board, REST API, and CockroachDB persistence."
auto_commit: true
auto_push: true
outputs:
@ -59,7 +59,7 @@ steps:
poll_interval: 5
add-components:
description: "Add React frontend, API service, and Postgres database"
description: "Add React frontend, API service, and CockroachDB database"
depends_on: [wait-setup-hooks]
action: api
method: POST
@ -101,7 +101,7 @@ steps:
method: POST
endpoint: "/projects/{{ .outputs.create-project.project_id }}/architect/start"
body:
prompt: "I want to build a task management studio. The product needs: 1) Core data models for Task, Project, Label, and Assignment entities with full CRUD stored in Postgres via studio-db, exposed as REST endpoints on studio-api. 2) A React frontend in studio-ui with a Kanban board (drag-and-drop columns: To Do, In Progress, Done), task creation/edit modals, and filtering by label and assignee. Propose the architecture and identify the two MVP features we should build."
prompt: "I want to build a task management studio. The product needs: 1) Core data models for Task, Project, Label, and Assignment entities with full CRUD stored in CockroachDB via studio-db, exposed as REST endpoints on studio-api. 2) A React frontend in studio-ui with a Kanban board (drag-and-drop columns: To Do, In Progress, Done), task creation/edit modals, and filtering by label and assignee. Propose the architecture and identify the two MVP features we should build."
outputs:
- conversation_id: .data.id
@ -143,7 +143,7 @@ steps:
- Assignment: id, task_id, label_id (many-to-many join)
2. Database: Create SQL migrations for all tables with foreign keys and indexes.
Use the studio-db Postgres connection (DATABASE_URL env var).
Use the studio-db CockroachDB connection (DATABASE_URL env var).
3. Repository layer: Implement CRUD operations for each entity using sqlx.

View File

@ -21,7 +21,7 @@ steps:
- domain: .data.domain
add-db:
description: Add PostgreSQL for user storage
description: Add CockroachDB for user storage
depends_on: [create-project]
action: api
method: POST

View File

@ -292,7 +292,7 @@ type MockStorage struct {
- Deletes service account and keys
**Orphan Prevention:**
- Project deletion hook cleans up all infra (postgres, redis, gcs)
- Project deletion hook cleans up all infra (CockroachDB, Redis, GCS)
- If cleanup fails, logs warning but continues (manual cleanup required)
### Cost Management
@ -398,5 +398,5 @@ type MockStorage struct {
- **GCS Client Docs:** https://cloud.google.com/go/docs/reference/cloud.google.com/go/storage/latest
- **IAM Best Practices:** https://cloud.google.com/iam/docs/best-practices
- **Signed URLs:** https://cloud.google.com/storage/docs/access-control/signed-urls
- **rdev Postgres Provisioner:** `internal/adapter/postgres/provisioner.go`
- **rdev CockroachDB Provisioner:** `internal/adapter/cockroach/provisioner.go`
- **rdev Redis Provisioner:** `internal/adapter/redis/provisioner.go`

View File

@ -0,0 +1,165 @@
// Package notify provides a notify service admin client for rdev.
// It manages accounts and send keys on behalf of projects.
package notify
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// adminClient calls the notify admin API to manage accounts and keys.
type adminClient struct {
baseURL string
adminKey string
httpClient *http.Client
}
func newAdminClient(baseURL, adminKey string) *adminClient {
return &adminClient{
baseURL: baseURL,
adminKey: adminKey,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// accountResponse is the shape returned by POST /admin/accounts.
type accountResponse struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
}
// apiKeyResponse is the shape returned by POST /admin/api-keys (full key only on creation).
type apiKeyResponse struct {
ID int `json:"id"`
Key string `json:"key"` // plaintext — only present on creation
KeyPrefix string `json:"key_prefix"` // e.g. "notify_send"
AccountID string `json:"account_id"`
KeyType string `json:"key_type"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
}
// listAccountsResponse is the shape returned by GET /admin/accounts.
type listAccountsResponse struct {
Items []accountResponse `json:"items"`
}
// createAccount creates a new notify account with the given name.
func (c *adminClient) createAccount(ctx context.Context, name string) (*accountResponse, error) {
payload := map[string]string{"name": name}
respBody, err := c.doRequest(ctx, http.MethodPost, "/admin/accounts", payload)
if err != nil {
return nil, fmt.Errorf("create account: %w", err)
}
var acct accountResponse
if err := json.Unmarshal(respBody, &acct); err != nil {
return nil, fmt.Errorf("unmarshal account response: %w", err)
}
return &acct, nil
}
// createSendKey creates a send API key for the given account.
// The plaintext key is only present in the response at creation time.
func (c *adminClient) createSendKey(ctx context.Context, accountID, name string) (*apiKeyResponse, error) {
payload := map[string]string{
"account_id": accountID,
"key_type": "send",
"name": name,
}
respBody, err := c.doRequest(ctx, http.MethodPost, "/admin/api-keys", payload)
if err != nil {
return nil, fmt.Errorf("create send key: %w", err)
}
var key apiKeyResponse
if err := json.Unmarshal(respBody, &key); err != nil {
return nil, fmt.Errorf("unmarshal key response: %w", err)
}
return &key, nil
}
// grantHostAccess grants the given account access to send from the specified host slug.
func (c *adminClient) grantHostAccess(ctx context.Context, hostSlug, accountID string) error {
payload := map[string]string{"account_id": accountID}
_, err := c.doRequest(ctx, http.MethodPost, "/admin/hosts/"+hostSlug+"/accounts", payload)
if err != nil {
return fmt.Errorf("grant host access: %w", err)
}
return nil
}
// deleteAccount removes the notify account and all its keys.
func (c *adminClient) deleteAccount(ctx context.Context, accountID string) error {
_, err := c.doRequest(ctx, http.MethodDelete, "/admin/accounts/"+accountID, nil)
if err != nil {
return fmt.Errorf("delete account: %w", err)
}
return nil
}
// listAccounts returns all accounts in the notify service.
func (c *adminClient) listAccounts(ctx context.Context) ([]accountResponse, error) {
respBody, err := c.doRequest(ctx, http.MethodGet, "/admin/accounts", nil)
if err != nil {
return nil, fmt.Errorf("list accounts: %w", err)
}
var resp listAccountsResponse
if err := json.Unmarshal(respBody, &resp); err != nil {
return nil, fmt.Errorf("unmarshal accounts list: %w", err)
}
return resp.Items, nil
}
// doRequest executes an HTTP request against the notify admin API.
func (c *adminClient) doRequest(ctx context.Context, method, path string, bodyData any) ([]byte, error) {
var reqBody io.Reader
if bodyData != nil {
jsonBody, err := json.Marshal(bodyData)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
reqBody = bytes.NewReader(jsonBody)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reqBody)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.adminKey)
if bodyData != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("http do: %w", err)
}
defer func() { _ = resp.Body.Close() }()
// 204 No Content — success with no body (e.g., grant host access, delete)
if resp.StatusCode == http.StatusNoContent {
return nil, nil
}
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response body: %w", err)
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return respBody, nil
}
return nil, fmt.Errorf("notify admin API error (HTTP %d): %s", resp.StatusCode, string(respBody))
}

View File

@ -0,0 +1,155 @@
package notify
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/orchard9/rdev/internal/domain"
)
// Provisioner implements port.NotifyProvisioner using the notify admin API.
// Each project gets an isolated notify account and send key scoped to the
// shared sending host (e.g., "threesix.ai").
type Provisioner struct {
client *adminClient
host string // shared sending host slug (e.g., "threesix.ai")
from string // from-address (e.g., "noreply@threesix.ai")
logger *slog.Logger
}
// Config holds configuration for the notify provisioner.
type Config struct {
BaseURL string // Required: notify service URL (e.g., "https://notify.orchard9.ai")
AdminKey string // Required: admin API key (notify_admin_...)
Host string // Shared host slug for all projects (e.g., "threesix.ai")
From string // Default from-address (e.g., "noreply@threesix.ai")
}
// NewProvisioner creates a new notify provisioner.
func NewProvisioner(cfg Config, logger *slog.Logger) *Provisioner {
host := cfg.Host
if host == "" {
host = "threesix.ai"
}
from := cfg.From
if from == "" {
from = "noreply@threesix.ai"
}
return &Provisioner{
client: newAdminClient(cfg.BaseURL, cfg.AdminKey),
host: host,
from: from,
logger: logger,
}
}
// CreateProjectNotify provisions a notify account and send key for the project.
// Steps:
// 1. Create account named "project-{projectID}"
// 2. Create send API key via POST /admin/api-keys
// 3. Grant account access to the shared host
func (p *Provisioner) CreateProjectNotify(ctx context.Context, projectID string) (*domain.NotifyCredentials, error) {
accountName := "project-" + projectID
// 1. Create account
acct, err := p.client.createAccount(ctx, accountName)
if err != nil {
return nil, fmt.Errorf("notify: create account for project %s: %w", projectID, err)
}
// 2. Create send key (plaintext key only returned here)
key, err := p.client.createSendKey(ctx, acct.ID, accountName+"-send")
if err != nil {
// Best-effort cleanup
if delErr := p.client.deleteAccount(ctx, acct.ID); delErr != nil {
p.logger.Warn("failed to clean up notify account after key creation failure",
"account_id", acct.ID,
"project_id", projectID,
"error", delErr,
)
}
return nil, fmt.Errorf("notify: create send key for project %s: %w", projectID, err)
}
// 3. Grant host access
if err := p.client.grantHostAccess(ctx, p.host, acct.ID); err != nil {
p.logger.Warn("failed to grant notify host access",
"host", p.host,
"account_id", acct.ID,
"project_id", projectID,
"error", err,
)
}
return &domain.NotifyCredentials{
ProjectID: projectID,
AccountID: acct.ID,
APIKey: key.Key,
Host: p.host,
From: p.from,
CreatedAt: time.Now(),
}, nil
}
// DeleteProjectNotify removes the notify account for the project.
func (p *Provisioner) DeleteProjectNotify(ctx context.Context, projectID string) error {
acct, err := p.findAccountByProject(ctx, projectID)
if err != nil {
return fmt.Errorf("notify: find account for project %s: %w", projectID, err)
}
if acct == nil {
return nil // Already deleted or never provisioned
}
if err := p.client.deleteAccount(ctx, acct.ID); err != nil {
return fmt.Errorf("notify: delete account %s for project %s: %w", acct.ID, projectID, err)
}
return nil
}
// GetProjectNotify returns notify credentials for the project, or nil if not provisioned.
// Note: APIKey cannot be retrieved after creation — returns empty string.
func (p *Provisioner) GetProjectNotify(ctx context.Context, projectID string) (*domain.NotifyCredentials, error) {
acct, err := p.findAccountByProject(ctx, projectID)
if err != nil {
return nil, fmt.Errorf("notify: find account for project %s: %w", projectID, err)
}
if acct == nil {
return nil, nil
}
return &domain.NotifyCredentials{
ProjectID: projectID,
AccountID: acct.ID,
Host: p.host,
From: p.from,
CreatedAt: acct.CreatedAt,
}, nil
}
// TestConnection verifies the notify admin API is reachable.
func (p *Provisioner) TestConnection(ctx context.Context) error {
_, err := p.client.listAccounts(ctx)
if err != nil {
return fmt.Errorf("notify admin API unreachable: %w", err)
}
return nil
}
// findAccountByProject looks up the account named "project-{projectID}".
func (p *Provisioner) findAccountByProject(ctx context.Context, projectID string) (*accountResponse, error) {
accounts, err := p.client.listAccounts(ctx)
if err != nil {
return nil, err
}
targetName := "project-" + projectID
for i := range accounts {
if accounts[i].Name == targetName {
return &accounts[i], nil
}
}
return nil, nil
}

View File

@ -18,4 +18,6 @@ AUTH_ENABLED=false
JWT_SECRET=dev-secret-change-in-production
# Database (if needed)
# Local dev: PostgreSQL via docker-compose. Production: CockroachDB (platform-provisioned).
# The postgres:// scheme works for both — CockroachDB is wire-compatible.
DATABASE_URL=postgres://dev:dev@localhost:5432/{{PROJECT_NAME}}?sslmode=disable

View File

@ -10,6 +10,8 @@ LOG_LEVEL=debug
LOG_FORMAT=text
# Database (required for job queue)
# Local dev: PostgreSQL via docker-compose. Production: CockroachDB (platform-provisioned).
# The postgres:// scheme works for both — CockroachDB is wire-compatible.
DATABASE_URL=postgres://dev:dev@localhost:5432/{{PROJECT_NAME}}?sslmode=disable
# Worker

View File

@ -1,6 +1,6 @@
---
name: database-architect
description: Database schema design and query optimization for {{PROJECT_NAME}} - PostgreSQL, migrations, indexing
description: Database schema design and query optimization for {{PROJECT_NAME}} - CockroachDB (production), PostgreSQL (local dev), migrations, indexing
color: yellow
---
@ -10,11 +10,16 @@ You design database schemas and optimize queries for {{PROJECT_NAME}}. Every ser
## Stack
- **Primary:** PostgreSQL
- **Driver:** sqlx (no GORM)
- **Production:** CockroachDB (distributed SQL, provisioned by the platform)
- **Local dev:** PostgreSQL via docker-compose (wire-compatible with CockroachDB)
- **Driver:** sqlx with lib/pq (no GORM) — works with both PostgreSQL and CockroachDB
- **Migrations:** Per-service in `services/{name}/migrations/`
- **Naming:** snake_case for tables and columns
> **Important:** Write SQL that is compatible with both PostgreSQL and CockroachDB.
> Avoid PostgreSQL-specific features not supported by CockroachDB (e.g., advisory locks, listen/notify, full-text search with tsvector).
> Use `UUID` primary keys (CockroachDB handles these efficiently with no hotspotting).
## Schema Conventions
### Tables

View File

@ -1,6 +1,6 @@
---
name: queue-specialist
description: Async job processing patterns for {{PROJECT_NAME}} - PostgreSQL queues, producer/consumer, retry logic, idempotency
description: Async job processing patterns for {{PROJECT_NAME}} - SQL queues (CockroachDB/PostgreSQL), producer/consumer, retry logic, idempotency
color: purple
---

View File

@ -11,7 +11,7 @@ You design and implement background workers for {{PROJECT_NAME}}. Workers are re
## Worker Types
### Queue Consumer
Processes jobs from a queue (PostgreSQL SKIP LOCKED, Redis, etc.):
Processes jobs from a queue (CockroachDB/PostgreSQL SKIP LOCKED, Redis, etc.):
```go
func (w *Worker) Run(ctx context.Context) error {
for {

View File

@ -29,7 +29,7 @@ You are a librarian who transforms ephemeral conversation knowledge into permane
| `architecture/` | System design facts | "How the work queue flows" |
| `debugging/` | How to diagnose issues | "How to debug pod execution" |
| `conventions/` | Naming, style, standards | "Error type naming convention" |
| `integrations/` | External system knowledge | "How we talk to PostgreSQL" |
| `integrations/` | External system knowledge | "How we talk to CockroachDB" |
## Storage Structure

View File

@ -38,6 +38,7 @@
- **OpenAPI first:** Document endpoints in `spec.go` using `openapi.*` helpers. Mount with `application.EnableDocs(spec)`.
- **CSS variables:** All UI components use CSS custom properties (`var(--background)`, `var(--accent)`, etc.). Never hardcode colors.
- **Monorepo imports:** Go packages from `{{GO_MODULE}}/pkg/*`, TypeScript from `@{{PROJECT_NAME}}/*`.
- **Database:** Production uses **CockroachDB** (provisioned by the platform). Local dev uses **PostgreSQL** via docker-compose. Both are wire-compatible via `lib/pq`. Write SQL compatible with both — avoid PostgreSQL-only features (advisory locks, listen/notify, tsvector).
- **NO WEBSOCKETS. EVER.** All real-time communication uses HTTP2 + SSE. User → server is HTTP2 POST. Server → user is SSE. This includes chat, notifications, progress, everything.
- **Event flow:** `POST → Service (enqueue) → Queue → Worker (generate) → Redis pub/sub → Service SSE subscriber → User`. Service is thin, worker does AI work.
- **Channel naming:** `user:<id>` = events for a specific user. `channel:<id>` = events for a topic/room/resource. Document all channels in `./docs/channels.md`.

View File

@ -1,5 +1,8 @@
version: '3.8'
# Local development uses PostgreSQL for convenience.
# Production uses CockroachDB (provisioned by the platform).
# Both are wire-compatible — code using lib/pq works with either.
services:
postgres:
image: postgres:16

View File

@ -1,3 +1,5 @@
'use client';
import { useState } from 'react';
import { Trash2, Image, Video, ExternalLink } from 'lucide-react';

View File

@ -1,3 +1,5 @@
'use client';
import { useCallback, useState, useRef } from 'react';
import { Upload, Loader2, Check } from 'lucide-react';

View File

@ -1,4 +1,8 @@
// Package database provides a standardized PostgreSQL/CockroachDB connection pool.
// Package database provides a standardized database connection pool.
//
// Production uses CockroachDB (provisioned by the platform).
// Local development uses PostgreSQL via docker-compose.
// Both are wire-compatible and use the lib/pq driver ("postgres").
//
// This package wraps sqlx to provide:
// - Connection pool management with sensible defaults
@ -31,7 +35,7 @@ import (
"time"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq" // PostgreSQL/CockroachDB driver
_ "github.com/lib/pq" // PostgreSQL-compatible driver (works with both PostgreSQL and CockroachDB)
)
// Pool wraps a sqlx.DB with additional lifecycle management.
@ -64,7 +68,7 @@ type Options struct {
}
// Connect establishes a connection pool to the database.
// The URL should be a PostgreSQL connection string:
// The URL should be a PostgreSQL-compatible connection string (works with CockroachDB):
//
// postgres://user:pass@host:port/dbname?sslmode=disable
func Connect(ctx context.Context, url string, opts Options) (*Pool, error) {

View File

@ -179,13 +179,25 @@ func VideoHandler(mg *mediagen.Manager, store storage.Store, pub realtime.EventP
for i, vid := range resp.Videos {
videoURL := vid.URL
// Persist to storage: download from provider URL, then upload to GCS.
if store != nil && vid.URL != "" {
// Persist to storage if available.
// Prefer vid.Data (already downloaded by provider adapter) over re-downloading from URL.
// Provider URLs (e.g., Gemini API) often require authentication and fail with plain GET.
if store != nil {
storagePath := fmt.Sprintf("media/%s/videos/%s_%d.mp4", userID, job.ID, i)
videoData, downloadErr := downloadURL(ctx, vid.URL)
if downloadErr != nil {
logger.Warn("failed to download video from provider", "error", downloadErr, "job_id", job.ID)
} else {
var videoData []byte
if len(vid.Data) > 0 {
videoData = vid.Data
} else if vid.URL != "" {
downloaded, downloadErr := downloadURL(ctx, vid.URL)
if downloadErr != nil {
logger.Warn("failed to download video from provider", "error", downloadErr, "job_id", job.ID)
} else {
videoData = downloaded
}
}
if len(videoData) > 0 {
persistedURL, uploadErr := store.Upload(ctx, storagePath, videoData, "video/mp4")
if uploadErr != nil {
logger.Warn("failed to persist video to storage", "error", uploadErr, "job_id", job.ID)

View File

@ -38,6 +38,7 @@ const (
CredentialCategoryWorker = "worker"
CredentialCategoryStorage = "storage"
CredentialCategoryAI = "ai"
CredentialCategoryNotify = "notify"
)
// Known credential keys.
@ -65,4 +66,11 @@ const (
// AI Providers
CredKeyLaozhangAPIKey = "LAOZHANG_API_KEY"
CredKeyGeminiAPIKey = "GEMINI_API_KEY"
// Notify service (email delivery)
CredKeyNotifyURL = "NOTIFY_URL"
CredKeyNotifyAdminKey = "NOTIFY_ADMIN_KEY"
CredKeyNotifyAPIKey = "NOTIFY_API_KEY"
CredKeyNotifyHost = "NOTIFY_HOST"
CredKeyNotifyFrom = "NOTIFY_FROM"
)

25
internal/domain/notify.go Normal file
View File

@ -0,0 +1,25 @@
// Package domain contains core business entities.
package domain
import "time"
// NotifyCredentials holds per-project email delivery credentials.
type NotifyCredentials struct {
// ProjectID is the rdev project this credential set belongs to.
ProjectID string
// AccountID is the notify service account UUID (used for deletion).
AccountID string
// APIKey is the notify send key (notify_send_...) for sending emails.
APIKey string
// Host is the shared sending host (e.g., "threesix.ai").
Host string
// From is the from-address for outgoing email (e.g., "noreply@threesix.ai").
From string
// CreatedAt is when the credentials were provisioned.
CreatedAt time.Time
}

View File

@ -0,0 +1,23 @@
package port
import (
"context"
"github.com/orchard9/rdev/internal/domain"
)
// NotifyProvisioner manages per-project email delivery accounts on the notify service.
type NotifyProvisioner interface {
// CreateProjectNotify creates a notify account and send key for a project.
// Grants the account access to the shared host and returns credentials.
CreateProjectNotify(ctx context.Context, projectID string) (*domain.NotifyCredentials, error)
// DeleteProjectNotify removes the notify account for a project.
DeleteProjectNotify(ctx context.Context, projectID string) error
// GetProjectNotify returns notify credentials for a project, or nil if not provisioned.
GetProjectNotify(ctx context.Context, projectID string) (*domain.NotifyCredentials, error)
// TestConnection verifies the admin API key and notify service are reachable.
TestConnection(ctx context.Context) error
}

View File

@ -35,6 +35,7 @@ type ProjectInfraService struct {
dbProvisioner port.DatabaseProvisioner
cacheProvisioner port.CacheProvisioner
storageProvisioner port.StorageProvisioner
notifyProvisioner port.NotifyProvisioner
registryProvider port.RegistryProvider
citadelClient port.CitadelClient
@ -109,6 +110,12 @@ func (s *ProjectInfraService) WithStorageProvisioner(sp port.StorageProvisioner)
return s
}
// WithNotifyProvisioner sets the notify provisioner for project email delivery.
func (s *ProjectInfraService) WithNotifyProvisioner(np port.NotifyProvisioner) *ProjectInfraService {
s.notifyProvisioner = np
return s
}
// WithRegistryProvider sets the container registry provider for image cleanup.
func (s *ProjectInfraService) WithRegistryProvider(rp port.RegistryProvider) *ProjectInfraService {
s.registryProvider = rp

View File

@ -467,6 +467,46 @@ func (s *ProjectInfraService) provisionResources(ctx context.Context, result *Cr
}
}
// Provision notify email delivery (idempotent)
if s.notifyProvisioner != nil {
existing, _ := s.notifyProvisioner.GetProjectNotify(ctx, projectID)
if existing != nil {
log.Info("notify already provisioned, skipping", logging.FieldProjectID, projectID)
} else {
notifyCreds, err := s.notifyProvisioner.CreateProjectNotify(ctx, projectID)
if err != nil {
log.Error("failed to provision notify", logging.FieldProjectID, projectID, logging.FieldError, err)
result.NextSteps = append(result.NextSteps, "Notify provisioning failed - contact admin")
} else if s.credentialStore != nil {
var storeErr error
if err := s.storeCredential(ctx, projectID, domain.CredentialCategoryNotify, domain.CredKeyNotifyAPIKey, notifyCreds.APIKey); err != nil {
storeErr = err
log.Error("failed to store NOTIFY_API_KEY", logging.FieldProjectID, projectID, logging.FieldError, err)
}
if err := s.storeCredential(ctx, projectID, domain.CredentialCategoryNotify, domain.CredKeyNotifyHost, notifyCreds.Host); err != nil {
storeErr = err
log.Error("failed to store NOTIFY_HOST", logging.FieldProjectID, projectID, logging.FieldError, err)
}
if err := s.storeCredential(ctx, projectID, domain.CredentialCategoryNotify, domain.CredKeyNotifyFrom, notifyCreds.From); err != nil {
storeErr = err
log.Error("failed to store NOTIFY_FROM", logging.FieldProjectID, projectID, logging.FieldError, err)
}
if storeErr != nil {
log.Warn("rolling back notify due to credential storage failure", logging.FieldProjectID, projectID)
if rollbackErr := s.notifyProvisioner.DeleteProjectNotify(ctx, projectID); rollbackErr != nil {
log.Error("failed to rollback notify account", logging.FieldProjectID, projectID, logging.FieldError, rollbackErr)
result.NextSteps = append(result.NextSteps, "Notify created but credentials not stored - manual cleanup required")
} else {
result.NextSteps = append(result.NextSteps, "Notify provisioning rolled back due to credential storage failure")
}
} else {
log.Info("notify provisioned", logging.FieldProjectID, projectID, "host", notifyCreds.Host)
}
}
}
}
// Provision storage (idempotent)
if s.storageProvisioner != nil {
existing, _ := s.storageProvisioner.GetProjectBucket(ctx, projectID)
@ -844,6 +884,13 @@ func (s *ProjectInfraService) DeleteProject(ctx context.Context, projectID strin
}
}
// 5. Delete provisioned notify account
if s.notifyProvisioner != nil {
if err := s.notifyProvisioner.DeleteProjectNotify(ctx, projectID); err != nil {
log.Warn("failed to delete project notify account", logging.FieldError, err)
}
}
// 5b. Delete Citadel log environment
s.deleteCitadelEnvironment(ctx, projectID)

View File

@ -70,6 +70,7 @@ get_category() {
WOODPECKER_URL|WOODPECKER_API_TOKEN|WOODPECKER_WEBHOOK_SECRET) echo "woodpecker" ;;
REGISTRY_URL) echo "registry" ;;
LAOZHANG_API_KEY|GEMINI_API_KEY) echo "ai" ;;
NOTIFY_URL|NOTIFY_ADMIN_KEY) echo "notify" ;;
*) echo "other" ;;
esac
}
@ -87,6 +88,8 @@ get_description() {
REGISTRY_URL) echo "Container registry URL" ;;
LAOZHANG_API_KEY) echo "LaoZhang API key for text/image generation (also proxies Grok)" ;;
GEMINI_API_KEY) echo "Google Gemini API key for text/image generation" ;;
NOTIFY_URL) echo "Notify service base URL for email delivery" ;;
NOTIFY_ADMIN_KEY) echo "Notify admin API key for provisioning per-project accounts" ;;
*) echo "$1 credential" ;;
esac
}

169
scripts/setup-notify.sh Executable file
View File

@ -0,0 +1,169 @@
#!/usr/bin/env bash
# setup-notify.sh - One-time host and provider setup for the notify service.
#
# Creates the threesix.ai host, adds Resend as provider, registers noreply@threesix.ai,
# and adds Resend DNS records to Cloudflare for domain verification.
#
# Idempotent: safe to run multiple times.
#
# Usage:
# ./scripts/setup-notify.sh
# NOTIFY_URL=... NOTIFY_ADMIN_KEY=... RESEND_API_KEY=... ./scripts/setup-notify.sh
set -euo pipefail
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
log_step() { echo -e "${BLUE}[STEP]${NC} $1"; }
# ─── Load secrets ────────────────────────────────────────────────────────────
SECRETS_FILE="${SECRETS_FILE:-.secrets}"
if [[ -f "$SECRETS_FILE" ]]; then
while IFS='=' read -r key val || [[ -n "$key" ]]; do
[[ -z "$key" || "$key" == \#* ]] && continue
export "$key"="${val}"
done < "$SECRETS_FILE"
fi
NOTIFY_URL="${NOTIFY_URL:-}"
NOTIFY_ADMIN_KEY="${NOTIFY_ADMIN_KEY:-}"
RESEND_API_KEY="${RESEND_API_KEY:-}"
CF_TOKEN="${CLOUDFLARE_API_TOKEN:-}"
CF_ZONE="${CLOUDFLARE_ZONE_ID:-}"
if [[ -z "$NOTIFY_URL" ]]; then log_error "NOTIFY_URL required"; exit 1; fi
if [[ -z "$NOTIFY_ADMIN_KEY" ]]; then log_error "NOTIFY_ADMIN_KEY required"; exit 1; fi
if [[ -z "$RESEND_API_KEY" ]]; then log_error "RESEND_API_KEY required"; exit 1; fi
HOST=threesix.ai
FROM=noreply@threesix.ai
log_info "Notify URL: $NOTIFY_URL"
log_info "Host: $HOST"
log_info "From: $FROM"
# ─── Helpers ─────────────────────────────────────────────────────────────────
notify() {
local method="$1" path="$2" body="${3:-}"
local args=(-s -X "$method" "$NOTIFY_URL$path"
-H "Authorization: Bearer $NOTIFY_ADMIN_KEY"
-H "Content-Type: application/json")
[[ -n "$body" ]] && args+=(-d "$body")
curl "${args[@]}"
}
resend_api() {
local method="$1" path="$2" body="${3:-}"
local args=(-s -X "$method" "https://api.resend.com$path"
-H "Authorization: Bearer $RESEND_API_KEY"
-H "Content-Type: application/json")
[[ -n "$body" ]] && args+=(-d "$body")
curl "${args[@]}"
}
cf_dns() {
local method="$1" path="$2" body="${3:-}"
local args=(-s -X "$method" "https://api.cloudflare.com/client/v4/zones/$CF_ZONE$path"
-H "Authorization: Bearer $CF_TOKEN"
-H "Content-Type: application/json")
[[ -n "$body" ]] && args+=(-d "$body")
curl "${args[@]}"
}
# ─── Step 1: Create host ──────────────────────────────────────────────────────
log_step "1. Setting up notify host: $HOST"
existing=$(notify GET "/admin/hosts" | python3 -c "import sys,json; items=json.load(sys.stdin).get('items',[]); print(next((x['host'] for x in items if x['host']=='$HOST'),''))" 2>/dev/null || true)
if [[ "$existing" == "$HOST" ]]; then
log_info " Host already exists — skipping"
else
notify POST "/admin/hosts" "{\"host\":\"$HOST\",\"strategy\":\"failover\"}" | python3 -m json.tool
log_info " Host created"
fi
# ─── Step 2: Add Resend provider ─────────────────────────────────────────────
log_step "2. Adding Resend provider"
providers=$(notify GET "/admin/hosts/$HOST/providers" | python3 -c "import sys,json; items=json.load(sys.stdin); print(next((str(x['id']) for x in items if x['provider']=='resend'),''))" 2>/dev/null || true)
if [[ -n "$providers" ]]; then
log_info " Resend provider already configured (id: $providers) — skipping"
else
notify POST "/admin/hosts/$HOST/providers" \
"{\"provider\":\"resend\",\"config\":{\"api_key\":\"$RESEND_API_KEY\"},\"priority\":1,\"retry_attempts\":3,\"retry_backoff_ms\":1000}" | python3 -m json.tool
log_info " Resend provider added"
fi
# ─── Step 3: Register from-address ───────────────────────────────────────────
log_step "3. Registering from-address: $FROM"
addrs=$(notify GET "/admin/hosts/$HOST/from-addresses" | python3 -c "import sys,json; items=json.load(sys.stdin).get('items',[]); print(next((x['email'] for x in items if x['email']=='$FROM'),''))" 2>/dev/null || true)
if [[ "$addrs" == "$FROM" ]]; then
log_info " From-address already registered — skipping"
else
notify POST "/admin/hosts/$HOST/from-addresses" \
"{\"email\":\"$FROM\",\"display_name\":\"threesix.ai\"}" | python3 -m json.tool
log_info " From-address registered"
fi
# ─── Step 4: Resend domain + Cloudflare DNS ───────────────────────────────────
log_step "4. Setting up Resend domain for $HOST"
existing_domain=$(resend_api GET "/domains" | python3 -c "import sys,json; data=json.load(sys.stdin); print(next((x['id'] for x in data.get('data',[]) if x['name']=='$HOST'),''))" 2>/dev/null || true)
if [[ -n "$existing_domain" ]]; then
log_info " Resend domain already exists (id: $existing_domain)"
DOMAIN_ID="$existing_domain"
DOMAIN_RECORDS=$(resend_api GET "/domains/$DOMAIN_ID" | python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin).get('records',[])))")
else
log_info " Creating Resend domain..."
domain_resp=$(resend_api POST "/domains" "{\"name\":\"$HOST\",\"region\":\"us-east-1\"}")
DOMAIN_ID=$(echo "$domain_resp" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
DOMAIN_RECORDS=$(echo "$domain_resp" | python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin).get('records',[])))")
log_info " Domain created (id: $DOMAIN_ID)"
fi
# Add DNS records if Cloudflare is configured
if [[ -n "$CF_TOKEN" && -n "$CF_ZONE" ]]; then
log_step "5. Adding Resend DNS records to Cloudflare"
echo "$DOMAIN_RECORDS" | python3 -c "
import sys, json
records = json.load(sys.stdin)
for r in records:
print(r['type'], r['name'], r.get('value',''), r.get('priority',''))
" | while read -r rtype rname rvalue rpriority; do
# Check if record already exists
existing_rec=$(cf_dns GET "/dns_records?type=$rtype&name=$rname.$HOST" | python3 -c "import sys,json; result=json.load(sys.stdin).get('result',[]); print(result[0]['id'] if result else '')" 2>/dev/null || true)
if [[ -n "$existing_rec" ]]; then
log_info " $rtype $rname already exists — skipping"
else
if [[ "$rtype" == "MX" ]]; then
cf_dns POST "/dns_records" "{\"type\":\"MX\",\"name\":\"$rname\",\"content\":\"$rvalue\",\"priority\":$rpriority,\"ttl\":1}" > /dev/null
else
cf_dns POST "/dns_records" "{\"type\":\"$rtype\",\"name\":\"$rname\",\"content\":\"$rvalue\",\"ttl\":1,\"proxied\":false}" > /dev/null
fi
log_info " Added $rtype $rname"
fi
done
# Trigger verification
log_step "6. Triggering Resend domain verification"
resend_api POST "/domains/$DOMAIN_ID/verify" > /dev/null
log_info " Verification triggered (DNS propagation takes ~60s)"
else
log_warn " CLOUDFLARE_API_TOKEN or CLOUDFLARE_ZONE_ID not set — add DNS records manually:"
echo "$DOMAIN_RECORDS" | python3 -m json.tool
fi
echo ""
log_info "Setup complete."
log_info "Check Resend domain status: curl -s https://api.resend.com/domains/$DOMAIN_ID -H 'Authorization: Bearer \$RESEND_API_KEY' | python3 -m json.tool"