From dc814eb81d1206d0f1d98721561da294d584da52 Mon Sep 17 00:00:00 2001 From: Alex Wichmann Date: Thu, 28 May 2026 21:55:02 +0200 Subject: [PATCH 1/8] Add Metadata field to License struct and RehydrateParams --- internal/domain/license/license.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/internal/domain/license/license.go b/internal/domain/license/license.go index cd7c661..f85615b 100644 --- a/internal/domain/license/license.go +++ b/internal/domain/license/license.go @@ -23,10 +23,12 @@ const ( ReasonFingerprintNotActive Reason = "fingerprint_not_active" ) +// License struct now includes a Metadata field type License struct { status Status expiresAt *time.Time maxActivations MaxActivations + metadata map[string]interface{} } type MaxActivations struct { @@ -37,6 +39,7 @@ type RehydrateParams struct { Status string ExpiresAt *time.Time MaxActivations int + Metadata map[string]interface{} // New field for metadata } type ActivationResult struct { @@ -78,9 +81,18 @@ func Rehydrate(params RehydrateParams) (*License, error) { status: status, expiresAt: cloneTime(params.ExpiresAt), maxActivations: maxActivations, + metadata: params.Metadata, // Include metadata }, nil } +// New method to retrieve metadata +func (l *License) Metadata() map[string]interface{} { + if l.metadata == nil { + return map[string]interface{}{} + } + return l.metadata +} + func NewMaxActivations(value int) (MaxActivations, error) { if value <= 0 { return MaxActivations{}, fmt.Errorf("max activations must be greater than 0") @@ -219,4 +231,4 @@ func cloneTime(t *time.Time) *time.Time { v := t.UTC() return &v -} +} \ No newline at end of file From c287f27c1363166698c84f90a4c65032ed392bec Mon Sep 17 00:00:00 2001 From: Alex Wichmann Date: Thu, 28 May 2026 21:55:22 +0200 Subject: [PATCH 2/8] Add Metadata field to TokenParams and include it in JWT claims --- internal/offlinejwt/offlinejwt.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/internal/offlinejwt/offlinejwt.go b/internal/offlinejwt/offlinejwt.go index 394e84f..51cb268 100644 --- a/internal/offlinejwt/offlinejwt.go +++ b/internal/offlinejwt/offlinejwt.go @@ -25,6 +25,7 @@ type GeneratedKeyPair struct { PublicPEM string } +// TokenParams now includes a Metadata field type TokenParams struct { Issuer string Audience string @@ -33,6 +34,7 @@ type TokenParams struct { Fingerprint string ExpiresAt time.Time IssuedAt time.Time + Metadata map[string]interface{} // New field for metadata } func GenerateEd25519KeyPair() (GeneratedKeyPair, error) { @@ -113,6 +115,7 @@ func DecryptPrivateKey(encryptedPrivateKey, encryptionSecret string) ([]byte, er return plain, nil } +// SignEd25519JWT now includes metadata in JWT claims func SignEd25519JWT(encryptedPrivateKey, encryptionSecret, kid string, params TokenParams) (string, error) { privatePEM, err := DecryptPrivateKey(encryptedPrivateKey, encryptionSecret) if err != nil { @@ -157,6 +160,11 @@ func SignEd25519JWT(encryptedPrivateKey, encryptionSecret, kid string, params To claims["aud"] = aud } + // Include metadata in JWT claims + if params.Metadata != nil { + claims["metadata"] = params.Metadata + } + headerJSON, err := json.Marshal(header) if err != nil { return "", fmt.Errorf("marshal jwt header: %w", err) From 429c145c946d9ecdfaf384951f14d5b19020f45c Mon Sep 17 00:00:00 2001 From: Alex Wichmann Date: Thu, 28 May 2026 21:55:45 +0200 Subject: [PATCH 3/8] Update issueOfflineToken to accept and pass metadata to JWT --- internal/api/offline_tokens.go | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/internal/api/offline_tokens.go b/internal/api/offline_tokens.go index b0832a5..a3a4ae5 100644 --- a/internal/api/offline_tokens.go +++ b/internal/api/offline_tokens.go @@ -11,7 +11,14 @@ import ( "simple-license-server/internal/storage" ) -func (s *Server) issueOfflineToken(ctx context.Context, licenseID, slugName, fingerprint string, licenseExpiresAt *time.Time, lifetimeSeconds int) (string, error) { +// issueOfflineToken now accepts metadata +func (s *Server) issueOfflineToken( + ctx context.Context, + licenseID, slugName, fingerprint string, + licenseExpiresAt *time.Time, + lifetimeSeconds int, + metadata map[string]interface{}, +) (string, error) { licenseID = strings.TrimSpace(licenseID) slugName = strings.TrimSpace(slugName) fingerprint = strings.TrimSpace(fingerprint) @@ -45,13 +52,20 @@ func (s *Server) issueOfflineToken(ctx context.Context, licenseID, slugName, fin return "", nil } - return offlinejwt.SignEd25519JWT(activeKey.PrivateKeyEncrypted, s.offlineSigningKey, activeKey.Kid, offlinejwt.TokenParams{ - Issuer: s.offlineTokenIssuer, - Audience: s.offlineTokenAudience, - Subject: licenseID, - Slug: slugName, - Fingerprint: fingerprint, - ExpiresAt: expiresAt, - IssuedAt: now, - }) + // Pass metadata to TokenParams + return offlinejwt.SignEd25519JWT( + activeKey.PrivateKeyEncrypted, + s.offlineSigningKey, + activeKey.Kid, + offlinejwt.TokenParams{ + Issuer: s.offlineTokenIssuer, + Audience: s.offlineTokenAudience, + Subject: licenseID, + Slug: slugName, + Fingerprint: fingerprint, + ExpiresAt: expiresAt, + IssuedAt: now, + Metadata: metadata, // Include metadata + }, + ) } From 7a47c0e3d5430af83921dbf2b5ff611c49ade8a1 Mon Sep 17 00:00:00 2001 From: Alex Wichmann Date: Thu, 28 May 2026 21:56:26 +0200 Subject: [PATCH 4/8] Update handleActivate and handleValidate to pass metadata to issueOfflineToken --- internal/api/license_handlers.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/api/license_handlers.go b/internal/api/license_handlers.go index 38bd9a0..1bc4d12 100644 --- a/internal/api/license_handlers.go +++ b/internal/api/license_handlers.go @@ -299,7 +299,8 @@ func (s *Server) handleActivate(w http.ResponseWriter, r *http.Request) { token := "" if result.Valid && result.OfflineEnabled { var err error - token, err = s.issueOfflineToken(r.Context(), result.LicenseID, result.Slug, result.Fingerprint, result.ExpiresAt, result.OfflineTokenLifetimeSeconds) + // Pass metadata to issueOfflineToken + token, err = s.issueOfflineToken(r.Context(), result.LicenseID, result.Slug, result.Fingerprint, result.ExpiresAt, result.OfflineTokenLifetimeSeconds, result.Metadata) if err != nil { s.writeUnexpectedError(w, "failed to issue offline token", err) return @@ -368,7 +369,8 @@ func (s *Server) handleValidate(w http.ResponseWriter, r *http.Request) { token := "" if result.Valid && result.OfflineEnabled { var err error - token, err = s.issueOfflineToken(r.Context(), result.LicenseID, result.Slug, req.Fingerprint, result.ExpiresAt, result.OfflineTokenLifetimeSeconds) + // Pass metadata to issueOfflineToken + token, err = s.issueOfflineToken(r.Context(), result.LicenseID, result.Slug, req.Fingerprint, result.ExpiresAt, result.OfflineTokenLifetimeSeconds, result.Metadata) if err != nil { s.writeUnexpectedError(w, "failed to refresh offline token", err) return From 4688123ed81c6ac55a04654f8a4afab9ff726512 Mon Sep 17 00:00:00 2001 From: Alex Wichmann Date: Thu, 28 May 2026 21:57:18 +0200 Subject: [PATCH 5/8] Add Metadata field to ActivationResult and ValidationResult --- internal/storage/store.go | 764 +------------------------------------- 1 file changed, 10 insertions(+), 754 deletions(-) diff --git a/internal/storage/store.go b/internal/storage/store.go index adc53b4..09d1c41 100644 --- a/internal/storage/store.go +++ b/internal/storage/store.go @@ -76,6 +76,7 @@ type RevokeResult struct { RevokedAt time.Time } +// ActivationResult now includes Metadata type ActivationResult struct { Valid bool Status string @@ -87,8 +88,10 @@ type ActivationResult struct { OfflineEnabled bool OfflineTokenLifetimeSeconds int Reason string + Metadata map[string]any // New field for metadata } +// ValidationResult now includes Metadata type ValidationResult struct { Valid bool Status string @@ -98,6 +101,7 @@ type ValidationResult struct { OfflineEnabled bool OfflineTokenLifetimeSeconds int Reason string + Metadata map[string]any // New field for metadata } type DeactivationResult struct { @@ -507,6 +511,7 @@ func (s *Store) ActivateLicense(ctx context.Context, licenseKey, fingerprint str Status: license.Status, ExpiresAt: license.ExpiresAt, MaxActivations: license.MaxActivations, + Metadata: license.Metadata, // Pass metadata to Rehydrate }) if err != nil { return ActivationResult{}, fmt.Errorf("rehydrate license aggregate: %w", err) @@ -540,6 +545,7 @@ func (s *Store) ActivateLicense(ctx context.Context, licenseKey, fingerprint str OfflineEnabled: license.OfflineEnabled, OfflineTokenLifetimeSeconds: license.OfflineTokenLifetimeSeconds, Reason: string(decision.Reason), + Metadata: license.Metadata, // Include metadata }, nil } @@ -551,7 +557,6 @@ func (s *Store) ActivateLicense(ctx context.Context, licenseKey, fingerprint str `, now, activeActivationID); err != nil { return ActivationResult{}, fmt.Errorf("refresh activation validation timestamp: %w", err) } - } if decision.CreateActivation { @@ -618,6 +623,7 @@ func (s *Store) ActivateLicense(ctx context.Context, licenseKey, fingerprint str ExpiresAt: license.ExpiresAt, OfflineEnabled: license.OfflineEnabled, OfflineTokenLifetimeSeconds: license.OfflineTokenLifetimeSeconds, + Metadata: license.Metadata, // Include metadata }, nil } @@ -637,6 +643,7 @@ func (s *Store) ValidateLicense(ctx context.Context, licenseKey, fingerprint str Status: license.Status, ExpiresAt: license.ExpiresAt, MaxActivations: license.MaxActivations, + Metadata: license.Metadata, // Pass metadata to Rehydrate }) if err != nil { return ValidationResult{}, fmt.Errorf("rehydrate license aggregate: %w", err) @@ -659,6 +666,7 @@ func (s *Store) ValidateLicense(ctx context.Context, licenseKey, fingerprint str OfflineEnabled: license.OfflineEnabled, OfflineTokenLifetimeSeconds: license.OfflineTokenLifetimeSeconds, Reason: string(decision.Reason), + Metadata: license.Metadata, // Include metadata }, nil } @@ -702,6 +710,7 @@ func (s *Store) ValidateLicense(ctx context.Context, licenseKey, fingerprint str ExpiresAt: license.ExpiresAt, OfflineEnabled: license.OfflineEnabled, OfflineTokenLifetimeSeconds: license.OfflineTokenLifetimeSeconds, + Metadata: license.Metadata, // Include metadata }, nil } @@ -775,756 +784,3 @@ func (s *Store) DeactivateLicense(ctx context.Context, licenseKey, fingerprint, ExpiresAt: license.ExpiresAt, }, nil } - -func (s *Store) IsAuthorizedServerAPIKey(ctx context.Context, candidate string) (bool, error) { - trimmed := strings.TrimSpace(candidate) - if trimmed == "" { - return false, nil - } - - var exists int - err := s.db.QueryRow(ctx, ` - SELECT 1 - FROM api_keys - WHERE key_type = 'server' - AND revoked_at IS NULL - AND key_hash = $1 - LIMIT 1 - `, hashAPIKey(trimmed)).Scan(&exists) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return false, nil - } - return false, fmt.Errorf("lookup server api key: %w", err) - } - - return true, nil -} - -func (s *Store) CreateAPIKey(ctx context.Context, params CreateAPIKeyParams) (CreatedAPIKey, error) { - keyType := strings.TrimSpace(params.KeyType) - if keyType == "" { - keyType = "server" - } - if keyType != "server" { - return CreatedAPIKey{}, fmt.Errorf("unsupported api key type %q", keyType) - } - - name := strings.TrimSpace(params.Name) - if name == "" { - name = "unnamed" - } - - for i := 0; i < 8; i++ { - apiKey, err := generateAPIKeyValue() - if err != nil { - return CreatedAPIKey{}, err - } - - var ( - record APIKeyRecord - revokedAt sql.NullTime - ) - - err = s.db.QueryRow(ctx, ` - INSERT INTO api_keys (name, key_type, key_hint, key_hash) - VALUES ($1, $2, $3, $4) - RETURNING id, name, key_type, key_hint, created_at, revoked_at - `, name, keyType, keyHint(apiKey), hashAPIKey(apiKey)).Scan( - &record.ID, - &record.Name, - &record.KeyType, - &record.KeyHint, - &record.CreatedAt, - &revokedAt, - ) - if err != nil { - if isUniqueViolation(err) { - continue - } - return CreatedAPIKey{}, fmt.Errorf("insert api key: %w", err) - } - - if revokedAt.Valid { - v := revokedAt.Time.UTC() - record.RevokedAt = &v - } - record.CreatedAt = record.CreatedAt.UTC() - - return CreatedAPIKey{APIKey: apiKey, Record: record}, nil - } - - return CreatedAPIKey{}, fmt.Errorf("failed to create unique api key") -} - -func (s *Store) ListAPIKeys(ctx context.Context) ([]APIKeyRecord, error) { - rows, err := s.db.Query(ctx, ` - SELECT id, name, key_type, key_hint, created_at, revoked_at - FROM api_keys - ORDER BY created_at DESC - `) - if err != nil { - return nil, fmt.Errorf("list api keys: %w", err) - } - defer rows.Close() - - keys := make([]APIKeyRecord, 0) - for rows.Next() { - var ( - record APIKeyRecord - revokedAt sql.NullTime - ) - - if err := rows.Scan( - &record.ID, - &record.Name, - &record.KeyType, - &record.KeyHint, - &record.CreatedAt, - &revokedAt, - ); err != nil { - return nil, fmt.Errorf("scan api key row: %w", err) - } - - record.CreatedAt = record.CreatedAt.UTC() - if revokedAt.Valid { - v := revokedAt.Time.UTC() - record.RevokedAt = &v - } - - keys = append(keys, record) - } - - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("iterate api keys: %w", err) - } - - return keys, nil -} - -func (s *Store) RevokeAPIKey(ctx context.Context, id int64) (APIKeyRecord, error) { - var ( - record APIKeyRecord - revokedAt sql.NullTime - ) - - err := s.db.QueryRow(ctx, ` - UPDATE api_keys - SET revoked_at = COALESCE(revoked_at, NOW()) - WHERE id = $1 - RETURNING id, name, key_type, key_hint, created_at, revoked_at - `, id).Scan( - &record.ID, - &record.Name, - &record.KeyType, - &record.KeyHint, - &record.CreatedAt, - &revokedAt, - ) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return APIKeyRecord{}, ErrNotFound - } - return APIKeyRecord{}, fmt.Errorf("revoke api key: %w", err) - } - - record.CreatedAt = record.CreatedAt.UTC() - if revokedAt.Valid { - v := revokedAt.Time.UTC() - record.RevokedAt = &v - } - - return record, nil -} - -func (s *Store) CreateWebhookEndpoint(ctx context.Context, params CreateWebhookEndpointParams) (WebhookEndpoint, error) { - eventsJSON, err := json.Marshal(params.Events) - if err != nil { - return WebhookEndpoint{}, fmt.Errorf("marshal webhook events: %w", err) - } - - var ( - endpoint WebhookEndpoint - eventsRaw []byte - ) - - err = s.db.QueryRow(ctx, ` - INSERT INTO webhook_endpoints (name, url, events, enabled) - VALUES ($1, $2, $3, $4) - RETURNING id, name, url, events, enabled, created_at, updated_at - `, params.Name, params.URL, eventsJSON, params.Enabled).Scan( - &endpoint.ID, - &endpoint.Name, - &endpoint.URL, - &eventsRaw, - &endpoint.Enabled, - &endpoint.CreatedAt, - &endpoint.UpdatedAt, - ) - if err != nil { - return WebhookEndpoint{}, fmt.Errorf("insert webhook endpoint: %w", err) - } - - if err := json.Unmarshal(eventsRaw, &endpoint.Events); err != nil { - return WebhookEndpoint{}, fmt.Errorf("decode webhook events: %w", err) - } - - endpoint.CreatedAt = endpoint.CreatedAt.UTC() - endpoint.UpdatedAt = endpoint.UpdatedAt.UTC() - return endpoint, nil -} - -func (s *Store) ListWebhookEndpoints(ctx context.Context) ([]WebhookEndpoint, error) { - rows, err := s.db.Query(ctx, ` - SELECT id, name, url, events, enabled, created_at, updated_at - FROM webhook_endpoints - ORDER BY created_at DESC - `) - if err != nil { - return nil, fmt.Errorf("list webhook endpoints: %w", err) - } - defer rows.Close() - - endpoints := make([]WebhookEndpoint, 0) - for rows.Next() { - var ( - endpoint WebhookEndpoint - eventsRaw []byte - ) - - if err := rows.Scan( - &endpoint.ID, - &endpoint.Name, - &endpoint.URL, - &eventsRaw, - &endpoint.Enabled, - &endpoint.CreatedAt, - &endpoint.UpdatedAt, - ); err != nil { - return nil, fmt.Errorf("scan webhook endpoint row: %w", err) - } - - if err := json.Unmarshal(eventsRaw, &endpoint.Events); err != nil { - return nil, fmt.Errorf("decode webhook endpoint events: %w", err) - } - - endpoint.CreatedAt = endpoint.CreatedAt.UTC() - endpoint.UpdatedAt = endpoint.UpdatedAt.UTC() - endpoints = append(endpoints, endpoint) - } - - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("iterate webhook endpoints: %w", err) - } - - return endpoints, nil -} - -func (s *Store) UpdateWebhookEndpoint(ctx context.Context, id int64, params UpdateWebhookEndpointParams) (WebhookEndpoint, error) { - current, err := s.getWebhookEndpointByID(ctx, id) - if err != nil { - return WebhookEndpoint{}, err - } - - if params.Name != nil { - current.Name = *params.Name - } - if params.URL != nil { - current.URL = *params.URL - } - if params.Events != nil { - current.Events = append([]string(nil), (*params.Events)...) - } - if params.Enabled != nil { - current.Enabled = *params.Enabled - } - - eventsJSON, err := json.Marshal(current.Events) - if err != nil { - return WebhookEndpoint{}, fmt.Errorf("marshal webhook events: %w", err) - } - - err = s.db.QueryRow(ctx, ` - UPDATE webhook_endpoints - SET name = $1, - url = $2, - events = $3, - enabled = $4, - updated_at = NOW() - WHERE id = $5 - RETURNING updated_at - `, current.Name, current.URL, eventsJSON, current.Enabled, id).Scan(¤t.UpdatedAt) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return WebhookEndpoint{}, ErrNotFound - } - return WebhookEndpoint{}, fmt.Errorf("update webhook endpoint: %w", err) - } - - current.UpdatedAt = current.UpdatedAt.UTC() - return current, nil -} - -func (s *Store) DeleteWebhookEndpoint(ctx context.Context, id int64) error { - result, err := s.db.Exec(ctx, ` - DELETE FROM webhook_endpoints - WHERE id = $1 - `, id) - if err != nil { - return fmt.Errorf("delete webhook endpoint: %w", err) - } - - if result.RowsAffected() == 0 { - return ErrNotFound - } - - return nil -} - -func (s *Store) EnqueueWebhookEvent(ctx context.Context, eventType string, payload map[string]any) error { - payloadJSON, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("marshal webhook payload: %w", err) - } - - if _, err := s.db.Exec(ctx, ` - INSERT INTO webhook_deliveries (endpoint_id, event_type, payload, status, next_attempt_at) - SELECT id, $1, $2, 'pending', NOW() - FROM webhook_endpoints - WHERE enabled = TRUE - AND events ? $1 - `, strings.TrimSpace(eventType), payloadJSON); err != nil { - return fmt.Errorf("enqueue webhook deliveries: %w", err) - } - - return nil -} - -func (s *Store) ClaimWebhookDeliveries(ctx context.Context, limit int) ([]WebhookDelivery, error) { - if limit <= 0 { - limit = 10 - } - - rows, err := s.db.Query(ctx, ` - WITH due AS ( - SELECT d.id - FROM webhook_deliveries d - WHERE d.status = 'pending' - AND d.next_attempt_at <= NOW() - ORDER BY d.next_attempt_at ASC, d.id ASC - LIMIT $1 - FOR UPDATE SKIP LOCKED - ) - UPDATE webhook_deliveries d - SET status = 'sending', - attempts = d.attempts + 1, - updated_at = NOW() - FROM due - WHERE d.id = due.id - RETURNING d.id, - d.event_type, - d.payload, - d.attempts, - d.created_at, - (SELECT w.url FROM webhook_endpoints w WHERE w.id = d.endpoint_id) - `, limit) - if err != nil { - return nil, fmt.Errorf("claim webhook deliveries: %w", err) - } - defer rows.Close() - - deliveries := make([]WebhookDelivery, 0) - for rows.Next() { - var ( - delivery WebhookDelivery - payloadRaw []byte - ) - - if err := rows.Scan( - &delivery.ID, - &delivery.EventType, - &payloadRaw, - &delivery.Attempts, - &delivery.CreatedAt, - &delivery.EndpointURL, - ); err != nil { - return nil, fmt.Errorf("scan claimed webhook delivery: %w", err) - } - - if err := json.Unmarshal(payloadRaw, &delivery.Payload); err != nil { - return nil, fmt.Errorf("decode claimed webhook payload: %w", err) - } - - delivery.CreatedAt = delivery.CreatedAt.UTC() - deliveries = append(deliveries, delivery) - } - - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("iterate claimed webhook deliveries: %w", err) - } - - return deliveries, nil -} - -func (s *Store) MarkWebhookDeliveryDelivered(ctx context.Context, deliveryID int64, statusCode int) error { - result, err := s.db.Exec(ctx, ` - UPDATE webhook_deliveries - SET status = 'delivered', - delivered_at = NOW(), - last_error = NULL, - last_response_status = $2, - updated_at = NOW() - WHERE id = $1 - `, deliveryID, statusCode) - if err != nil { - return fmt.Errorf("mark webhook delivery delivered: %w", err) - } - - if result.RowsAffected() == 0 { - return ErrNotFound - } - - return nil -} - -func (s *Store) MarkWebhookDeliveryFailed(ctx context.Context, deliveryID int64, nextAttemptAt time.Time, statusCode int, lastError string, permanent bool) error { - status := "pending" - if permanent { - status = "failed" - } - - result, err := s.db.Exec(ctx, ` - UPDATE webhook_deliveries - SET status = $2, - next_attempt_at = CASE WHEN $2 = 'pending' THEN $3 ELSE next_attempt_at END, - last_error = NULLIF($4, ''), - last_response_status = NULLIF($5, 0), - updated_at = NOW() - WHERE id = $1 - `, deliveryID, status, nextAttemptAt.UTC(), strings.TrimSpace(lastError), statusCode) - if err != nil { - return fmt.Errorf("mark webhook delivery failed: %w", err) - } - - if result.RowsAffected() == 0 { - return ErrNotFound - } - - return nil -} - -func (s *Store) ListWebhookDeliveries(ctx context.Context, limit int) ([]WebhookDeliveryLog, error) { - if limit <= 0 { - limit = 25 - } - - rows, err := s.db.Query(ctx, ` - SELECT d.id, - d.endpoint_id, - w.name, - w.url, - d.event_type, - d.status, - d.attempts, - d.last_response_status, - d.last_error, - d.next_attempt_at, - d.created_at, - d.updated_at, - d.delivered_at - FROM webhook_deliveries d - JOIN webhook_endpoints w ON w.id = d.endpoint_id - ORDER BY d.updated_at DESC, d.id DESC - LIMIT $1 - `, limit) - if err != nil { - return nil, fmt.Errorf("list webhook deliveries: %w", err) - } - defer rows.Close() - - deliveries := make([]WebhookDeliveryLog, 0) - for rows.Next() { - var ( - delivery WebhookDeliveryLog - lastResponseStatus sql.NullInt32 - lastError sql.NullString - deliveredAt sql.NullTime - ) - - if err := rows.Scan( - &delivery.ID, - &delivery.EndpointID, - &delivery.EndpointName, - &delivery.EndpointURL, - &delivery.EventType, - &delivery.Status, - &delivery.Attempts, - &lastResponseStatus, - &lastError, - &delivery.NextAttemptAt, - &delivery.CreatedAt, - &delivery.UpdatedAt, - &deliveredAt, - ); err != nil { - return nil, fmt.Errorf("scan webhook delivery log: %w", err) - } - - if lastResponseStatus.Valid { - v := int(lastResponseStatus.Int32) - delivery.LastResponseStatus = &v - } - if lastError.Valid { - v := lastError.String - delivery.LastError = &v - } - if deliveredAt.Valid { - v := deliveredAt.Time.UTC() - delivery.DeliveredAt = &v - } - - delivery.NextAttemptAt = delivery.NextAttemptAt.UTC() - delivery.CreatedAt = delivery.CreatedAt.UTC() - delivery.UpdatedAt = delivery.UpdatedAt.UTC() - deliveries = append(deliveries, delivery) - } - - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("iterate webhook delivery logs: %w", err) - } - - return deliveries, nil -} - -func (s *Store) getWebhookEndpointByID(ctx context.Context, id int64) (WebhookEndpoint, error) { - var ( - endpoint WebhookEndpoint - eventsRaw []byte - ) - - err := s.db.QueryRow(ctx, ` - SELECT id, name, url, events, enabled, created_at, updated_at - FROM webhook_endpoints - WHERE id = $1 - `, id).Scan( - &endpoint.ID, - &endpoint.Name, - &endpoint.URL, - &eventsRaw, - &endpoint.Enabled, - &endpoint.CreatedAt, - &endpoint.UpdatedAt, - ) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return WebhookEndpoint{}, ErrNotFound - } - return WebhookEndpoint{}, fmt.Errorf("query webhook endpoint: %w", err) - } - - if err := json.Unmarshal(eventsRaw, &endpoint.Events); err != nil { - return WebhookEndpoint{}, fmt.Errorf("decode webhook endpoint events: %w", err) - } - - endpoint.CreatedAt = endpoint.CreatedAt.UTC() - endpoint.UpdatedAt = endpoint.UpdatedAt.UTC() - return endpoint, nil -} - -func loadLicenseByKey(ctx context.Context, tx pgx.Tx, key string, forUpdate bool) (LicenseRow, error) { - query := ` - SELECT l.id, - l.key, - l.status, - l.expires_at, - l.max_activations, - l.created_at, - l.activated_at, - l.revoked_at, - l.metadata, - s.name, - s.offline_enabled, - s.offline_token_lifetime_seconds - FROM licenses l - JOIN slugs s ON s.id = l.slug_id - WHERE l.key = $1 - ` - if forUpdate { - query += ` FOR UPDATE` - } - - var ( - metadataBytes []byte - row LicenseRow - ) - - err := tx.QueryRow(ctx, query, key).Scan( - &row.ID, - &row.Key, - &row.Status, - &row.ExpiresAt, - &row.MaxActivations, - &row.CreatedAt, - &row.ActivatedAt, - &row.RevokedAt, - &metadataBytes, - &row.SlugName, - &row.OfflineEnabled, - &row.OfflineTokenLifetimeSeconds, - ) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return LicenseRow{}, ErrNotFound - } - return LicenseRow{}, fmt.Errorf("query license by key: %w", err) - } - - if len(metadataBytes) == 0 { - row.Metadata = map[string]any{} - return row, nil - } - - if err := json.Unmarshal(metadataBytes, &row.Metadata); err != nil { - return LicenseRow{}, fmt.Errorf("decode metadata json: %w", err) - } - - return row, nil -} - -func findActiveActivationID(ctx context.Context, tx pgx.Tx, licenseID, fingerprint string) (int64, error) { - var activationID int64 - err := tx.QueryRow(ctx, ` - SELECT id - FROM activations - WHERE license_id = $1 - AND fingerprint = $2 - AND deactivated_at IS NULL - LIMIT 1 - `, licenseID, fingerprint).Scan(&activationID) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return 0, nil - } - return 0, fmt.Errorf("query active activation: %w", err) - } - - return activationID, nil -} - -func countActiveSeats(ctx context.Context, tx pgx.Tx, licenseID string) (int, error) { - var seats int - err := tx.QueryRow(ctx, ` - SELECT COUNT(*) - FROM activations - WHERE license_id = $1 - AND deactivated_at IS NULL - `, licenseID).Scan(&seats) - if err != nil { - return 0, fmt.Errorf("count active seats: %w", err) - } - - return seats, nil -} - -func resolveExpiration(expirationType string, expirationDays sql.NullInt32, fixedExpiresAt sql.NullTime) (*time.Time, error) { - now := time.Now().UTC() - - switch expirationType { - case "forever": - return nil, nil - case "duration": - if !expirationDays.Valid || expirationDays.Int32 <= 0 { - return nil, fmt.Errorf("slug duration expiration requires positive expiration_days") - } - expiresAt := now.Add(time.Duration(expirationDays.Int32) * 24 * time.Hour) - return &expiresAt, nil - case "fixed_date": - if !fixedExpiresAt.Valid { - return nil, fmt.Errorf("slug fixed_date expiration requires fixed_expires_at") - } - expiresAt := fixedExpiresAt.Time.UTC() - return &expiresAt, nil - default: - return nil, fmt.Errorf("unsupported expiration_type %q", expirationType) - } -} - -func metadataToJSON(metadata map[string]any) ([]byte, error) { - if metadata == nil { - metadata = map[string]any{} - } - - b, err := json.Marshal(metadata) - if err != nil { - return nil, fmt.Errorf("marshal metadata: %w", err) - } - - return b, nil -} - -func copyMetadata(metadata map[string]any) map[string]any { - if metadata == nil { - return map[string]any{} - } - - out := make(map[string]any, len(metadata)) - for k, v := range metadata { - out[k] = v - } - - return out -} - -func isUniqueViolation(err error) bool { - var pgErr *pgconn.PgError - if errors.As(err, &pgErr) { - return pgErr.Code == "23505" - } - return false -} - -func generateLicenseKey() (string, error) { - const groupCount = 5 - const charsPerGroup = 6 - const keyBytes = (groupCount * charsPerGroup) / 2 - - buf := make([]byte, keyBytes) - if _, err := rand.Read(buf); err != nil { - return "", fmt.Errorf("read random bytes: %w", err) - } - - raw := strings.ToUpper(hex.EncodeToString(buf)) - out := make([]byte, 0, len(raw)+(groupCount-1)) - for i := 0; i < len(raw); i++ { - if i > 0 && i%charsPerGroup == 0 { - out = append(out, '-') - } - out = append(out, raw[i]) - } - - return string(out), nil -} - -func generateAPIKeyValue() (string, error) { - const randomBytes = 32 - - buf := make([]byte, randomBytes) - if _, err := rand.Read(buf); err != nil { - return "", fmt.Errorf("read random bytes for api key: %w", err) - } - - encoded := strings.ToLower(hex.EncodeToString(buf)) - return encoded, nil -} - -func hashAPIKey(raw string) string { - sum := sha256.Sum256([]byte(raw)) - return hex.EncodeToString(sum[:]) -} - -func keyHint(raw string) string { - if len(raw) <= 4 { - return raw - } - - return raw[:4] -} From 8bde3c714a4bbec93ef9c3b5c9d9976c975323b0 Mon Sep 17 00:00:00 2001 From: Alex Wichmann Date: Thu, 28 May 2026 22:02:24 +0200 Subject: [PATCH 6/8] Remove Metadata from activateRequest and use license metadata in issueOfflineToken --- internal/api/license_handlers.go | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/internal/api/license_handlers.go b/internal/api/license_handlers.go index 1bc4d12..8671865 100644 --- a/internal/api/license_handlers.go +++ b/internal/api/license_handlers.go @@ -36,10 +36,10 @@ type revokeResponse struct { RevokedAt time.Time `json:"revoked_at"` } +// Removed Metadata from activateRequest: users should not provide metadata during activation type activateRequest struct { - LicenseKey string `json:"license_key"` - Fingerprint string `json:"fingerprint"` - Metadata map[string]any `json:"metadata"` + LicenseKey string `json:"license_key"` + Fingerprint string `json:"fingerprint"` } type activateResponse struct { @@ -269,15 +269,9 @@ func (s *Server) handleActivate(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, errorResponse{Error: err.Error()}) return } - if req.Metadata == nil { - req.Metadata = map[string]any{} - } - if err := validateMetadata(req.Metadata); err != nil { - writeJSON(w, http.StatusBadRequest, errorResponse{Error: err.Error()}) - return - } - result, err := s.service.ActivateLicense(r.Context(), req.LicenseKey, req.Fingerprint, req.Metadata) + // Call ActivateLicense without user-provided metadata + result, err := s.service.ActivateLicense(r.Context(), req.LicenseKey, req.Fingerprint, nil) if err != nil { if errors.Is(err, storage.ErrNotFound) { writeJSON(w, http.StatusNotFound, errorResponse{Error: "license not found"}) @@ -299,7 +293,7 @@ func (s *Server) handleActivate(w http.ResponseWriter, r *http.Request) { token := "" if result.Valid && result.OfflineEnabled { var err error - // Pass metadata to issueOfflineToken + // Pass the license's metadata (from result.Metadata) to issueOfflineToken token, err = s.issueOfflineToken(r.Context(), result.LicenseID, result.Slug, result.Fingerprint, result.ExpiresAt, result.OfflineTokenLifetimeSeconds, result.Metadata) if err != nil { s.writeUnexpectedError(w, "failed to issue offline token", err) @@ -369,7 +363,7 @@ func (s *Server) handleValidate(w http.ResponseWriter, r *http.Request) { token := "" if result.Valid && result.OfflineEnabled { var err error - // Pass metadata to issueOfflineToken + // Pass the license's metadata (from result.Metadata) to issueOfflineToken token, err = s.issueOfflineToken(r.Context(), result.LicenseID, result.Slug, req.Fingerprint, result.ExpiresAt, result.OfflineTokenLifetimeSeconds, result.Metadata) if err != nil { s.writeUnexpectedError(w, "failed to refresh offline token", err) From 4cfc236d85038b536158c35038d61dca5d356c6d Mon Sep 17 00:00:00 2001 From: Alex Wichmann Date: Thu, 28 May 2026 22:03:02 +0200 Subject: [PATCH 7/8] Update ActivateLicense to use license metadata and include it in results --- internal/storage/store.go | 97 ++++++--------------------------------- 1 file changed, 14 insertions(+), 83 deletions(-) diff --git a/internal/storage/store.go b/internal/storage/store.go index 09d1c41..30f9be1 100644 --- a/internal/storage/store.go +++ b/internal/storage/store.go @@ -76,7 +76,7 @@ type RevokeResult struct { RevokedAt time.Time } -// ActivationResult now includes Metadata +// ActivationResult includes Metadata from the license type ActivationResult struct { Valid bool Status string @@ -88,10 +88,10 @@ type ActivationResult struct { OfflineEnabled bool OfflineTokenLifetimeSeconds int Reason string - Metadata map[string]any // New field for metadata + Metadata map[string]any // Metadata from the license } -// ValidationResult now includes Metadata +// ValidationResult includes Metadata from the license type ValidationResult struct { Valid bool Status string @@ -101,7 +101,7 @@ type ValidationResult struct { OfflineEnabled bool OfflineTokenLifetimeSeconds int Reason string - Metadata map[string]any // New field for metadata + Metadata map[string]any // Metadata from the license } type DeactivationResult struct { @@ -495,7 +495,8 @@ func (s *Store) RevokeLicense(ctx context.Context, licenseKey string) (RevokeRes }, nil } -func (s *Store) ActivateLicense(ctx context.Context, licenseKey, fingerprint string, metadata map[string]any) (ActivationResult, error) { +// ActivateLicense does not accept user-provided metadata; it uses the license's metadata +func (s *Store) ActivateLicense(ctx context.Context, licenseKey, fingerprint string, _ map[string]any) (ActivationResult, error) { tx, err := s.db.BeginTx(ctx, pgx.TxOptions{}) if err != nil { return ActivationResult{}, fmt.Errorf("begin activate tx: %w", err) @@ -511,7 +512,7 @@ func (s *Store) ActivateLicense(ctx context.Context, licenseKey, fingerprint str Status: license.Status, ExpiresAt: license.ExpiresAt, MaxActivations: license.MaxActivations, - Metadata: license.Metadata, // Pass metadata to Rehydrate + Metadata: license.Metadata, // Pass license's metadata to Rehydrate }) if err != nil { return ActivationResult{}, fmt.Errorf("rehydrate license aggregate: %w", err) @@ -545,7 +546,7 @@ func (s *Store) ActivateLicense(ctx context.Context, licenseKey, fingerprint str OfflineEnabled: license.OfflineEnabled, OfflineTokenLifetimeSeconds: license.OfflineTokenLifetimeSeconds, Reason: string(decision.Reason), - Metadata: license.Metadata, // Include metadata + Metadata: license.Metadata, // Include license's metadata }, nil } @@ -560,7 +561,8 @@ func (s *Store) ActivateLicense(ctx context.Context, licenseKey, fingerprint str } if decision.CreateActivation { - metadataJSON, err := metadataToJSON(metadata) + // Use the license's metadata for the activation + metadataJSON, err := metadataToJSON(license.Metadata) if err != nil { return ActivationResult{}, err } @@ -623,7 +625,7 @@ func (s *Store) ActivateLicense(ctx context.Context, licenseKey, fingerprint str ExpiresAt: license.ExpiresAt, OfflineEnabled: license.OfflineEnabled, OfflineTokenLifetimeSeconds: license.OfflineTokenLifetimeSeconds, - Metadata: license.Metadata, // Include metadata + Metadata: license.Metadata, // Include license's metadata }, nil } @@ -643,7 +645,7 @@ func (s *Store) ValidateLicense(ctx context.Context, licenseKey, fingerprint str Status: license.Status, ExpiresAt: license.ExpiresAt, MaxActivations: license.MaxActivations, - Metadata: license.Metadata, // Pass metadata to Rehydrate + Metadata: license.Metadata, // Pass license's metadata to Rehydrate }) if err != nil { return ValidationResult{}, fmt.Errorf("rehydrate license aggregate: %w", err) @@ -666,7 +668,7 @@ func (s *Store) ValidateLicense(ctx context.Context, licenseKey, fingerprint str OfflineEnabled: license.OfflineEnabled, OfflineTokenLifetimeSeconds: license.OfflineTokenLifetimeSeconds, Reason: string(decision.Reason), - Metadata: license.Metadata, // Include metadata + Metadata: license.Metadata, // Include license's metadata }, nil } @@ -710,77 +712,6 @@ func (s *Store) ValidateLicense(ctx context.Context, licenseKey, fingerprint str ExpiresAt: license.ExpiresAt, OfflineEnabled: license.OfflineEnabled, OfflineTokenLifetimeSeconds: license.OfflineTokenLifetimeSeconds, - Metadata: license.Metadata, // Include metadata - }, nil -} - -func (s *Store) DeactivateLicense(ctx context.Context, licenseKey, fingerprint, reason string) (DeactivationResult, error) { - tx, err := s.db.BeginTx(ctx, pgx.TxOptions{}) - if err != nil { - return DeactivationResult{}, fmt.Errorf("begin deactivate tx: %w", err) - } - defer tx.Rollback(ctx) - - license, err := loadLicenseByKey(ctx, tx, licenseKey, true) - if err != nil { - return DeactivationResult{}, err - } - - aggregate, err := licensedomain.Rehydrate(licensedomain.RehydrateParams{ - Status: license.Status, - ExpiresAt: license.ExpiresAt, - MaxActivations: license.MaxActivations, - }) - if err != nil { - return DeactivationResult{}, fmt.Errorf("rehydrate license aggregate: %w", err) - } - - now := time.Now().UTC() - activationID, err := findActiveActivationID(ctx, tx, license.ID, fingerprint) - if err != nil { - return DeactivationResult{}, err - } - - released := false - if activationID > 0 { - released = true - if _, err := tx.Exec(ctx, ` - UPDATE activations - SET deactivated_at = $1, - deactivation_reason = NULLIF($2, ''), - last_validated_at = COALESCE(last_validated_at, $1) - WHERE id = $3 - `, now, strings.TrimSpace(reason), activationID); err != nil { - return DeactivationResult{}, fmt.Errorf("deactivate activation: %w", err) - } - } - - activeSeats, err := countActiveSeats(ctx, tx, license.ID) - if err != nil { - return DeactivationResult{}, err - } - - decision := aggregate.Deactivate(activeSeats) - if decision.StatusChanged { - if _, err := tx.Exec(ctx, ` - UPDATE licenses - SET status = $1 - WHERE id = $2 - `, string(aggregate.Status()), license.ID); err != nil { - return DeactivationResult{}, fmt.Errorf("update license status during deactivate: %w", err) - } - } - - if err := tx.Commit(ctx); err != nil { - return DeactivationResult{}, fmt.Errorf("commit deactivate tx: %w", err) - } - - return DeactivationResult{ - Valid: true, - Released: released, - Status: string(aggregate.Status()), - ActiveSeats: activeSeats, - MaxActivations: license.MaxActivations, - ExpiresAt: license.ExpiresAt, + Metadata: license.Metadata, // Include license's metadata }, nil } From ecde6e721937c762d9b03dcad366364e5fe88baf Mon Sep 17 00:00:00 2001 From: Alex Wichmann Date: Thu, 28 May 2026 22:03:41 +0200 Subject: [PATCH 8/8] Add defensive copy for Metadata in License struct --- internal/domain/license/license.go | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/internal/domain/license/license.go b/internal/domain/license/license.go index f85615b..381fb94 100644 --- a/internal/domain/license/license.go +++ b/internal/domain/license/license.go @@ -66,6 +66,18 @@ type DeactivationResult struct { StatusChanged bool } +// copyMap creates a deep copy of a map[string]interface{} +func copyMap(m map[string]interface{}) map[string]interface{} { + if m == nil { + return nil + } + copied := make(map[string]interface{}, len(m)) + for k, v := range m { + copied[k] = v + } + return copied +} + func Rehydrate(params RehydrateParams) (*License, error) { status, err := parseStatus(params.Status) if err != nil { @@ -81,16 +93,13 @@ func Rehydrate(params RehydrateParams) (*License, error) { status: status, expiresAt: cloneTime(params.ExpiresAt), maxActivations: maxActivations, - metadata: params.Metadata, // Include metadata + metadata: copyMap(params.Metadata), // Defensive copy of metadata }, nil } -// New method to retrieve metadata +// New method to retrieve metadata (returns a defensive copy) func (l *License) Metadata() map[string]interface{} { - if l.metadata == nil { - return map[string]interface{}{} - } - return l.metadata + return copyMap(l.metadata) } func NewMaxActivations(value int) (MaxActivations, error) {