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>
156 lines
4.6 KiB
Go
156 lines
4.6 KiB
Go
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
|
|
}
|