Compare commits

..

No commits in common. "0f25bd8dbe73790d241d37e474b5cd8da2518756" and "a8c8a0a14d89951535b4e1ba2707c1bd77eeedd6" have entirely different histories.

28 changed files with 23 additions and 704 deletions

View File

@ -14,16 +14,6 @@ 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 |
@ -67,7 +57,6 @@ Both use `lib/pq` driver. The `type: postgres` component API provisions **Cockro
| **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,12 +87,6 @@ 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 {
@ -153,8 +147,6 @@ 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)
@ -197,12 +189,6 @@ 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,7 +18,6 @@ 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"
@ -241,23 +240,6 @@ 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 != "" {
@ -500,9 +482,6 @@ 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 CockroachDB
description: Add Postgres
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 CockroachDB persistence."
prompt: "Set up the monorepo workspace. Ensure the root README describes a task management studio with Kanban board, REST API, and Postgres 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 CockroachDB database"
description: "Add React frontend, API service, and Postgres 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 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."
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."
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 CockroachDB connection (DATABASE_URL env var).
Use the studio-db Postgres 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 CockroachDB for user storage
description: Add PostgreSQL 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 (CockroachDB, Redis, GCS)
- Project deletion hook cleans up all infra (postgres, 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 CockroachDB Provisioner:** `internal/adapter/cockroach/provisioner.go`
- **rdev Postgres Provisioner:** `internal/adapter/postgres/provisioner.go`
- **rdev Redis Provisioner:** `internal/adapter/redis/provisioner.go`

View File

@ -1,165 +0,0 @@
// 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

@ -1,155 +0,0 @@
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,6 +18,4 @@ 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,8 +10,6 @@ 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}} - CockroachDB (production), PostgreSQL (local dev), migrations, indexing
description: Database schema design and query optimization for {{PROJECT_NAME}} - PostgreSQL, migrations, indexing
color: yellow
---
@ -10,16 +10,11 @@ You design database schemas and optimize queries for {{PROJECT_NAME}}. Every ser
## Stack
- **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
- **Primary:** PostgreSQL
- **Driver:** sqlx (no GORM)
- **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}} - SQL queues (CockroachDB/PostgreSQL), producer/consumer, retry logic, idempotency
description: Async job processing patterns for {{PROJECT_NAME}} - PostgreSQL queues, 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 (CockroachDB/PostgreSQL SKIP LOCKED, Redis, etc.):
Processes jobs from a queue (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 CockroachDB" |
| `integrations/` | External system knowledge | "How we talk to PostgreSQL" |
## Storage Structure

View File

@ -38,7 +38,6 @@
- **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,8 +1,5 @@
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,5 +1,3 @@
'use client';
import { useState } from 'react';
import { Trash2, Image, Video, ExternalLink } from 'lucide-react';

View File

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

View File

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

View File

@ -179,25 +179,13 @@ func VideoHandler(mg *mediagen.Manager, store storage.Store, pub realtime.EventP
for i, vid := range resp.Videos {
videoURL := 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 {
// Persist to storage: download from provider URL, then upload to GCS.
if store != nil && vid.URL != "" {
storagePath := fmt.Sprintf("media/%s/videos/%s_%d.mp4", userID, job.ID, i)
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 {
videoData, downloadErr := downloadURL(ctx, vid.URL)
if downloadErr != nil {
logger.Warn("failed to download video from provider", "error", downloadErr, "job_id", job.ID)
} else {
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,7 +38,6 @@ const (
CredentialCategoryWorker = "worker"
CredentialCategoryStorage = "storage"
CredentialCategoryAI = "ai"
CredentialCategoryNotify = "notify"
)
// Known credential keys.
@ -66,11 +65,4 @@ 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"
)

View File

@ -1,25 +0,0 @@
// 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

@ -1,23 +0,0 @@
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,7 +35,6 @@ type ProjectInfraService struct {
dbProvisioner port.DatabaseProvisioner
cacheProvisioner port.CacheProvisioner
storageProvisioner port.StorageProvisioner
notifyProvisioner port.NotifyProvisioner
registryProvider port.RegistryProvider
citadelClient port.CitadelClient
@ -110,12 +109,6 @@ 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,46 +467,6 @@ 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)
@ -884,13 +844,6 @@ 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,7 +70,6 @@ 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
}
@ -88,8 +87,6 @@ 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
}

View File

@ -1,169 +0,0 @@
#!/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"