package store import ( "context" "sort" "sync" "time" "github.com/google/uuid" ) // Memory — in-process Store used when DATABASE_URL is empty. Convenient for // local dev when you don't want to bring up Postgres + run migrations. // Pre-seeded with the acme tenant. type Memory struct { mu sync.RWMutex tenants map[string]*Tenant // id → tenant bySlug map[string]string // slug → id products map[string]map[string]*TenantProduct // tenant_id → product → row apiKeys map[string]*apiKeyWithHash // id → key byPrefix map[string]string // prefix → id audit []*AuditEvent auditID int64 } type apiKeyWithHash struct { APIKey Hash string } // NewMemory returns a fresh in-memory store with the seed acme tenant. func NewMemory() *Memory { m := &Memory{ tenants: make(map[string]*Tenant), bySlug: make(map[string]string), products: make(map[string]map[string]*TenantProduct), apiKeys: make(map[string]*apiKeyWithHash), byPrefix: make(map[string]string), } now := time.Now().UTC() seed := &Tenant{ ID: "00000000-0000-0000-0000-000000000001", Slug: "acme", Name: "Acme Inc.", Status: "active", Kind: "customer", Plan: "professional", CreatedAt: now, UpdatedAt: now, } m.tenants[seed.ID] = seed m.bySlug[seed.Slug] = seed.ID m.products[seed.ID] = map[string]*TenantProduct{ "certifai": {TenantID: seed.ID, Product: "certifai", Enabled: true, Config: map[string]interface{}{}, CreatedAt: now, UpdatedAt: now}, "compliance": {TenantID: seed.ID, Product: "compliance", Enabled: true, Config: map[string]interface{}{}, CreatedAt: now, UpdatedAt: now}, } return m } func (m *Memory) Close() {} func (m *Memory) Ping(_ context.Context) error { return nil } // ─── tenants ────────────────────────────────────────────────────────────── func (m *Memory) CreateTenant(_ context.Context, in TenantCreate) (*Tenant, error) { m.mu.Lock() defer m.mu.Unlock() if _, taken := m.bySlug[in.Slug]; taken { return nil, ErrConflict } now := time.Now().UTC() kind := firstNonEmpty(in.Kind, "customer") status := "trial" var trialEnds *time.Time if kind == "demo" { status = "demo" } else { end := now.Add(14 * 24 * time.Hour) trialEnds = &end } t := &Tenant{ ID: uuid.NewString(), Slug: in.Slug, Name: in.Name, Status: status, Kind: kind, Plan: firstNonEmpty(in.Plan, "starter"), SalesOwner: in.SalesOwner, TrialEndsAt: trialEnds, CreatedAt: now, UpdatedAt: now, } m.tenants[t.ID] = t m.bySlug[t.Slug] = t.ID cp := *t return &cp, nil } func (m *Memory) GetTenant(_ context.Context, id string) (*Tenant, error) { m.mu.RLock() defer m.mu.RUnlock() t, ok := m.tenants[id] if !ok { return nil, ErrNotFound } cp := *t return &cp, nil } func (m *Memory) GetTenantBySlug(_ context.Context, slug string) (*Tenant, error) { m.mu.RLock() defer m.mu.RUnlock() id, ok := m.bySlug[slug] if !ok { return nil, ErrNotFound } t := m.tenants[id] cp := *t return &cp, nil } func (m *Memory) UpdateTenant(_ context.Context, id string, in TenantUpdate) (*Tenant, error) { m.mu.Lock() defer m.mu.Unlock() t, ok := m.tenants[id] if !ok { return nil, ErrNotFound } if in.Status != nil { t.Status = *in.Status } if in.Plan != nil { t.Plan = *in.Plan } if in.ErpCustomerID != nil { t.ErpCustomerID = *in.ErpCustomerID } if in.StripeCustID != nil { t.StripeCustID = *in.StripeCustID } if in.TrialEndsAt != nil { t.TrialEndsAt = in.TrialEndsAt } if in.ContractStart != nil { t.ContractStart = in.ContractStart } if in.ContractEnd != nil { t.ContractEnd = in.ContractEnd } if in.SalesOwner != nil { t.SalesOwner = *in.SalesOwner } t.UpdatedAt = time.Now().UTC() cp := *t return &cp, nil } // ─── entitlements ───────────────────────────────────────────────────────── func (m *Memory) UpsertTenantProduct(_ context.Context, tp TenantProduct) (*TenantProduct, error) { m.mu.Lock() defer m.mu.Unlock() if _, ok := m.tenants[tp.TenantID]; !ok { return nil, ErrNotFound } if _, ok := m.products[tp.TenantID]; !ok { m.products[tp.TenantID] = map[string]*TenantProduct{} } now := time.Now().UTC() stored, exists := m.products[tp.TenantID][tp.Product] if !exists { stored = &TenantProduct{ TenantID: tp.TenantID, Product: tp.Product, CreatedAt: now, } m.products[tp.TenantID][tp.Product] = stored } stored.Enabled = tp.Enabled stored.Config = tp.Config stored.ExpiresAt = tp.ExpiresAt stored.UpdatedAt = now cp := *stored return &cp, nil } func (m *Memory) ListTenantProducts(_ context.Context, tenantID string) ([]TenantProduct, error) { m.mu.RLock() defer m.mu.RUnlock() if _, ok := m.tenants[tenantID]; !ok { return nil, ErrNotFound } out := make([]TenantProduct, 0, len(m.products[tenantID])) for _, p := range m.products[tenantID] { out = append(out, *p) } sort.Slice(out, func(i, j int) bool { return out[i].Product < out[j].Product }) return out, nil } // ─── api keys ───────────────────────────────────────────────────────────── func (m *Memory) CreateAPIKey(_ context.Context, in APIKeyCreate) (*APIKey, error) { m.mu.Lock() defer m.mu.Unlock() if _, ok := m.tenants[in.TenantID]; !ok { return nil, ErrNotFound } now := time.Now().UTC() k := &apiKeyWithHash{ APIKey: APIKey{ ID: uuid.NewString(), TenantID: in.TenantID, Product: in.Product, Name: in.Name, Scopes: append([]string{}, in.Scopes...), Prefix: in.Prefix, CreatedBy: in.CreatedBy, CreatedAt: now, }, Hash: in.Hash, } m.apiKeys[k.ID] = k m.byPrefix[k.Prefix] = k.ID cp := k.APIKey return &cp, nil } func (m *Memory) FindAPIKeyByPrefix(_ context.Context, prefix string) (*APIKey, string, error) { m.mu.RLock() defer m.mu.RUnlock() id, ok := m.byPrefix[prefix] if !ok { return nil, "", ErrNotFound } k := m.apiKeys[id] if k.RevokedAt != nil { return nil, "", ErrNotFound } cp := k.APIKey return &cp, k.Hash, nil } func (m *Memory) TouchAPIKeyUsed(_ context.Context, id string) error { m.mu.Lock() defer m.mu.Unlock() k, ok := m.apiKeys[id] if !ok { return ErrNotFound } now := time.Now().UTC() k.LastUsedAt = &now return nil } func (m *Memory) RevokeAPIKey(_ context.Context, id string) error { m.mu.Lock() defer m.mu.Unlock() k, ok := m.apiKeys[id] if !ok { return ErrNotFound } now := time.Now().UTC() k.RevokedAt = &now return nil } func (m *Memory) ListAPIKeys(_ context.Context, tenantID string) ([]APIKey, error) { m.mu.RLock() defer m.mu.RUnlock() if _, ok := m.tenants[tenantID]; !ok { return nil, ErrNotFound } out := []APIKey{} for _, k := range m.apiKeys { if k.TenantID == tenantID { out = append(out, k.APIKey) } } sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) }) return out, nil } // ─── audit ──────────────────────────────────────────────────────────────── func (m *Memory) AppendAudit(_ context.Context, ev AuditEvent) (*AuditEvent, error) { m.mu.Lock() defer m.mu.Unlock() m.auditID++ ev.ID = m.auditID ev.CreatedAt = time.Now().UTC() cp := ev m.audit = append(m.audit, &cp) out := ev return &out, nil } func (m *Memory) ListAudit(_ context.Context, f AuditFilter) ([]AuditEvent, int64, error) { m.mu.RLock() defer m.mu.RUnlock() limit := f.Limit if limit <= 0 || limit > 500 { limit = 100 } matches := []AuditEvent{} for _, ev := range m.audit { if ev.ID <= f.Cursor { continue } if f.TenantID != "" && ev.TenantID != f.TenantID { continue } if f.Product != "" && ev.Product != f.Product { continue } if f.ActorID != "" && ev.ActorID != f.ActorID { continue } if f.Action != "" && ev.Action != f.Action { continue } if f.Since != nil && ev.CreatedAt.Before(*f.Since) { continue } if f.Until != nil && ev.CreatedAt.After(*f.Until) { continue } matches = append(matches, *ev) if len(matches) >= limit { break } } var nextCursor int64 if len(matches) == limit && len(matches) > 0 { nextCursor = matches[len(matches)-1].ID } return matches, nextCursor, nil } func firstNonEmpty(values ...string) string { for _, v := range values { if v != "" { return v } } return "" }