From 576e3797563e32ff3aaf3f255b0be12bf3495b27 Mon Sep 17 00:00:00 2001 From: Alex Wichmann Date: Sun, 31 May 2026 00:05:57 +0200 Subject: [PATCH] Update API and storage to support license attributes This commit introduces support for storing and handling license attributes in the backend and frontend. In the backend, the `licenseManagementResponse` struct in `internal/api/management_licenses.go` is updated to include an `Attributes` map. Correspondingly, `marshalGenerateResponseBody` in `internal/storage/store.go` is updated to include `Attributes` in the response payload. The database schema in `internal/storage/schema.sql` is updated to add an `attributes` JSONB column to the licenses table. In the API layer, `issueOfflineToken` in `internal/api/offline_tokens.go` is updated to accept and use an `attributes` map. License handlers in `internal/api/license_handlers.go` are updated to handle and validate these attributes, including a new validation function `validateAttributes`. In the frontend, `LicensesView.vue` is updated to include an `attributes` reactive property in the form state. Similarly, `SlugsView.vue` updates the form state for attributes. The `slugAPI` in `ui/src/stores/api.ts` is updated to include an `attributes` field in the data --- internal/api/license_handlers.go | 8 +- internal/api/management_licenses.go | 58 +++++++------- internal/api/management_slugs.go | 70 ++++++++++------- internal/api/offline_tokens.go | 3 +- internal/api/server.go | 22 ++++-- internal/api/server_test.go | 21 +++++ internal/offlinejwt/offlinejwt.go | 15 ++++ internal/storage/generate_license_test.go | 33 ++++++-- internal/storage/license_management.go | 16 +++- internal/storage/schema.sql | 8 ++ internal/storage/slug_management.go | 22 +++++- internal/storage/store.go | 46 ++++++++--- internal/version/version.go | 2 +- ui/src/stores/api.ts | 2 + ui/src/views/LicensesView.vue | 95 ++++++++++++++++++++++- ui/src/views/SlugsView.vue | 24 ++++++ 16 files changed, 357 insertions(+), 88 deletions(-) diff --git a/internal/api/license_handlers.go b/internal/api/license_handlers.go index 38bd9a0..9b27ccf 100644 --- a/internal/api/license_handlers.go +++ b/internal/api/license_handlers.go @@ -21,6 +21,7 @@ type generateResponse struct { Slug string `json:"slug"` Status string `json:"status"` Metadata map[string]any `json:"metadata"` + Attributes map[string]any `json:"attributes"` ExpiresAt *time.Time `json:"expires_at"` CreatedAt time.Time `json:"created_at"` } @@ -158,6 +159,7 @@ func (s *Server) handleGenerate(w http.ResponseWriter, r *http.Request) { "slug": generated.Slug, "status": generated.Status, "metadata": generated.Metadata, + "attributes": generated.Attributes, "expires_at": generated.ExpiresAt, "created_at": generated.CreatedAt, }) @@ -182,6 +184,7 @@ func (s *Server) handleGenerate(w http.ResponseWriter, r *http.Request) { Slug: generated.Slug, Status: generated.Status, Metadata: generated.Metadata, + Attributes: generated.Attributes, ExpiresAt: generated.ExpiresAt, CreatedAt: generated.CreatedAt, } @@ -197,6 +200,7 @@ func (s *Server) handleGenerate(w http.ResponseWriter, r *http.Request) { "slug": generated.Slug, "status": generated.Status, "metadata": generated.Metadata, + "attributes": generated.Attributes, "expires_at": generated.ExpiresAt, "created_at": generated.CreatedAt, }) @@ -299,7 +303,7 @@ 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) + token, err = s.issueOfflineToken(r.Context(), result.LicenseID, result.Slug, result.Fingerprint, result.Attributes, result.ExpiresAt, result.OfflineTokenLifetimeSeconds) if err != nil { s.writeUnexpectedError(w, "failed to issue offline token", err) return @@ -368,7 +372,7 @@ 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) + token, err = s.issueOfflineToken(r.Context(), result.LicenseID, result.Slug, req.Fingerprint, result.Attributes, result.ExpiresAt, result.OfflineTokenLifetimeSeconds) if err != nil { s.writeUnexpectedError(w, "failed to refresh offline token", err) return diff --git a/internal/api/management_licenses.go b/internal/api/management_licenses.go index 6c0d6fd..b0a586c 100644 --- a/internal/api/management_licenses.go +++ b/internal/api/management_licenses.go @@ -10,20 +10,21 @@ import ( ) type licenseManagementResponse struct { - ID string `json:"id"` - LicenseKey string `json:"license_key"` - Slug string `json:"slug"` - Status string `json:"status"` - Metadata map[string]any `json:"metadata"` - MaxActivations int `json:"max_activations"` - ActiveSeats int `json:"active_seats"` - OfflineEnabled bool `json:"offline_enabled"` - OfflineTokenLifetimeHours int `json:"offline_token_lifetime_hours"` - ExpiresAt *time.Time `json:"expires_at"` - CreatedAt time.Time `json:"created_at"` - ActivatedAt *time.Time `json:"activated_at"` - LastValidatedAt *time.Time `json:"last_validated_at"` - RevokedAt *time.Time `json:"revoked_at"` + ID string `json:"id"` + LicenseKey string `json:"license_key"` + Slug string `json:"slug"` + Status string `json:"status"` + Metadata map[string]any `json:"metadata"` + Attributes map[string]any `json:"attributes"` + MaxActivations int `json:"max_activations"` + ActiveSeats int `json:"active_seats"` + OfflineEnabled bool `json:"offline_enabled"` + OfflineTokenLifetimeHours int `json:"offline_token_lifetime_hours"` + ExpiresAt *time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` + ActivatedAt *time.Time `json:"activated_at"` + LastValidatedAt *time.Time `json:"last_validated_at"` + RevokedAt *time.Time `json:"revoked_at"` } type licensePaginationResponse struct { @@ -109,20 +110,21 @@ func (s *Server) handleListLicenses(w http.ResponseWriter, r *http.Request) { func mapLicenseManagementResponse(record storage.LicenseRow) licenseManagementResponse { return licenseManagementResponse{ - ID: record.ID, - LicenseKey: record.Key, - Slug: record.SlugName, - Status: displayLicenseStatus(record), - Metadata: record.Metadata, - MaxActivations: record.MaxActivations, - ActiveSeats: record.ActiveSeats, - OfflineEnabled: record.OfflineEnabled, - OfflineTokenLifetimeHours: offlineTokenLifetimeHoursFromSeconds(record.OfflineTokenLifetimeSeconds), - ExpiresAt: record.ExpiresAt, - CreatedAt: record.CreatedAt, - ActivatedAt: record.ActivatedAt, - LastValidatedAt: record.LastValidatedAt, - RevokedAt: record.RevokedAt, + ID: record.ID, + LicenseKey: record.Key, + Slug: record.SlugName, + Status: displayLicenseStatus(record), + Metadata: record.Metadata, + Attributes: record.Attributes, + MaxActivations: record.MaxActivations, + ActiveSeats: record.ActiveSeats, + OfflineEnabled: record.OfflineEnabled, + OfflineTokenLifetimeHours: offlineTokenLifetimeHoursFromSeconds(record.OfflineTokenLifetimeSeconds), + ExpiresAt: record.ExpiresAt, + CreatedAt: record.CreatedAt, + ActivatedAt: record.ActivatedAt, + LastValidatedAt: record.LastValidatedAt, + RevokedAt: record.RevokedAt, } } diff --git a/internal/api/management_slugs.go b/internal/api/management_slugs.go index 26fdb47..dd80ebf 100644 --- a/internal/api/management_slugs.go +++ b/internal/api/management_slugs.go @@ -13,18 +13,19 @@ import ( ) type slugResponse struct { - ID int64 `json:"id"` - Name string `json:"name"` - MaxActivations int `json:"max_activations"` - ExpirationType string `json:"expiration_type"` - ExpirationDays *int `json:"expiration_days"` - FixedExpiresAt *time.Time `json:"fixed_expires_at"` - OfflineEnabled bool `json:"offline_enabled"` - OfflineTokenLifetimeHours int `json:"offline_token_lifetime_hours"` - IsDefault bool `json:"is_default"` - DeletedAt *time.Time `json:"deleted_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID int64 `json:"id"` + Name string `json:"name"` + MaxActivations int `json:"max_activations"` + ExpirationType string `json:"expiration_type"` + ExpirationDays *int `json:"expiration_days"` + FixedExpiresAt *time.Time `json:"fixed_expires_at"` + OfflineEnabled bool `json:"offline_enabled"` + OfflineTokenLifetimeHours int `json:"offline_token_lifetime_hours"` + Attributes map[string]any `json:"attributes"` + IsDefault bool `json:"is_default"` + DeletedAt *time.Time `json:"deleted_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type listSlugsResponse struct { @@ -32,23 +33,25 @@ type listSlugsResponse struct { } type createSlugRequest struct { - Name string `json:"name"` - MaxActivations int `json:"max_activations"` - ExpirationType string `json:"expiration_type"` - ExpirationDays *int `json:"expiration_days"` - FixedExpiresAt *string `json:"fixed_expires_at"` - OfflineEnabled bool `json:"offline_enabled"` - OfflineTokenLifetimeHours *int `json:"offline_token_lifetime_hours"` + Name string `json:"name"` + MaxActivations int `json:"max_activations"` + ExpirationType string `json:"expiration_type"` + ExpirationDays *int `json:"expiration_days"` + FixedExpiresAt *string `json:"fixed_expires_at"` + OfflineEnabled bool `json:"offline_enabled"` + OfflineTokenLifetimeHours *int `json:"offline_token_lifetime_hours"` + Attributes map[string]any `json:"attributes"` } type updateSlugRequest struct { - Name *string `json:"name"` - MaxActivations *int `json:"max_activations"` - ExpirationType *string `json:"expiration_type"` - ExpirationDays *int `json:"expiration_days"` - FixedExpiresAt *string `json:"fixed_expires_at"` - OfflineEnabled *bool `json:"offline_enabled"` - OfflineTokenLifetimeHours *int `json:"offline_token_lifetime_hours"` + Name *string `json:"name"` + MaxActivations *int `json:"max_activations"` + ExpirationType *string `json:"expiration_type"` + ExpirationDays *int `json:"expiration_days"` + FixedExpiresAt *string `json:"fixed_expires_at"` + OfflineEnabled *bool `json:"offline_enabled"` + OfflineTokenLifetimeHours *int `json:"offline_token_lifetime_hours"` + Attributes map[string]any `json:"attributes"` } func (s *Server) handleListSlugs(w http.ResponseWriter, r *http.Request) { @@ -125,6 +128,10 @@ func (s *Server) handleCreateSlug(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, errorResponse{Error: err.Error()}) return } + if err := validateAttributes(req.Attributes); err != nil { + writeJSON(w, http.StatusBadRequest, errorResponse{Error: err.Error()}) + return + } record, err := s.service.CreateSlug(r.Context(), storage.CreateSlugParams{ Name: req.Name, @@ -134,6 +141,7 @@ func (s *Server) handleCreateSlug(w http.ResponseWriter, r *http.Request) { FixedExpiresAt: policy.FixedExpiresAt(), OfflineEnabled: req.OfflineEnabled, OfflineTokenLifetimeSeconds: offlineTokenLifetimeHours * 3600, + Attributes: req.Attributes, }) if err != nil { if errors.Is(err, storage.ErrConflict) { @@ -170,10 +178,16 @@ func (s *Server) handleUpdateSlug(w http.ResponseWriter, r *http.Request) { return } - if req.Name == nil && req.MaxActivations == nil && req.ExpirationType == nil && req.ExpirationDays == nil && req.FixedExpiresAt == nil && req.OfflineEnabled == nil && req.OfflineTokenLifetimeHours == nil { + if req.Name == nil && req.MaxActivations == nil && req.ExpirationType == nil && req.ExpirationDays == nil && req.FixedExpiresAt == nil && req.OfflineEnabled == nil && req.OfflineTokenLifetimeHours == nil && req.Attributes == nil { writeJSON(w, http.StatusBadRequest, errorResponse{Error: "at least one field must be provided"}) return } + if req.Attributes != nil { + if err := validateAttributes(req.Attributes); err != nil { + writeJSON(w, http.StatusBadRequest, errorResponse{Error: err.Error()}) + return + } + } resolvedName := current.Name if req.Name != nil { @@ -245,6 +259,7 @@ func (s *Server) handleUpdateSlug(w http.ResponseWriter, r *http.Request) { FixedExpiresAt: &fixedParam, OfflineEnabled: req.OfflineEnabled, OfflineTokenLifetimeSeconds: offlineTokenLifetimeSeconds, + Attributes: req.Attributes, }) if err != nil { if errors.Is(err, storage.ErrNotFound) { @@ -296,6 +311,7 @@ func mapSlugResponse(record storage.SlugRecord) slugResponse { FixedExpiresAt: record.FixedExpiresAt, OfflineEnabled: record.OfflineEnabled, OfflineTokenLifetimeHours: offlineTokenLifetimeHoursFromSeconds(record.OfflineTokenLifetimeSeconds), + Attributes: record.Attributes, IsDefault: record.IsDefault, DeletedAt: record.DeletedAt, CreatedAt: record.CreatedAt, diff --git a/internal/api/offline_tokens.go b/internal/api/offline_tokens.go index b0832a5..7ef0ad3 100644 --- a/internal/api/offline_tokens.go +++ b/internal/api/offline_tokens.go @@ -11,7 +11,7 @@ import ( "simple-license-server/internal/storage" ) -func (s *Server) issueOfflineToken(ctx context.Context, licenseID, slugName, fingerprint string, licenseExpiresAt *time.Time, lifetimeSeconds int) (string, error) { +func (s *Server) issueOfflineToken(ctx context.Context, licenseID, slugName, fingerprint string, attributes map[string]any, licenseExpiresAt *time.Time, lifetimeSeconds int) (string, error) { licenseID = strings.TrimSpace(licenseID) slugName = strings.TrimSpace(slugName) fingerprint = strings.TrimSpace(fingerprint) @@ -51,6 +51,7 @@ func (s *Server) issueOfflineToken(ctx context.Context, licenseID, slugName, fin Subject: licenseID, Slug: slugName, Fingerprint: fingerprint, + Attributes: attributes, ExpiresAt: expiresAt, IssuedAt: now, }) diff --git a/internal/api/server.go b/internal/api/server.go index 9a7570a..a7cdd13 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -664,27 +664,35 @@ func validateSlugName(value string) error { } func validateMetadata(metadata map[string]any) error { - if metadata == nil { + return validateJSONMap(metadata, "metadata") +} + +func validateAttributes(attributes map[string]any) error { + return validateJSONMap(attributes, "attributes") +} + +func validateJSONMap(values map[string]any, label string) error { + if values == nil { return nil } - if len(metadata) > maxMetadataEntries { - return fmt.Errorf("metadata exceeds max entries of %d", maxMetadataEntries) + if len(values) > maxMetadataEntries { + return fmt.Errorf("%s exceeds max entries of %d", label, maxMetadataEntries) } nodes := 0 - for key, value := range metadata { + for key, value := range values { trimmedKey := strings.TrimSpace(key) if trimmedKey == "" { - return fmt.Errorf("metadata keys must be non-empty") + return fmt.Errorf("%s keys must be non-empty", label) } if len(trimmedKey) > maxSlugLength { - return fmt.Errorf("metadata key %q exceeds max length of %d", trimmedKey, maxSlugLength) + return fmt.Errorf("%s key %q exceeds max length of %d", label, trimmedKey, maxSlugLength) } if err := validateMetadataValue(value, 1, &nodes); err != nil { - return fmt.Errorf("metadata value for key %q invalid: %w", trimmedKey, err) + return fmt.Errorf("%s value for key %q invalid: %w", label, trimmedKey, err) } } diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 0233708..74a8941 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -3,6 +3,7 @@ package api import ( "bytes" "context" + "encoding/base64" "encoding/json" "io" "log/slog" @@ -399,6 +400,7 @@ func TestValidateRefreshesOfflineTokenWhenSlugAllowsOffline(t *testing.T) { Status: "active", LicenseID: "8c50f4f7-761d-4bf1-8e09-27a5f7ea0a12", Slug: "default", + Attributes: map[string]any{"features": []any{"analytics"}, "tier": "pro"}, OfflineEnabled: true, OfflineTokenLifetimeSeconds: 86400, }, nil @@ -439,6 +441,25 @@ func TestValidateRefreshesOfflineTokenWhenSlugAllowsOffline(t *testing.T) { if strings.Count(response.Token, ".") != 2 { t.Fatalf("expected JWT-shaped token, got %q", response.Token) } + + parts := strings.Split(response.Token, ".") + claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + t.Fatalf("decode jwt claims: %v", err) + } + + var claims map[string]any + if err := json.Unmarshal(claimsJSON, &claims); err != nil { + t.Fatalf("unmarshal jwt claims: %v", err) + } + + attributes, ok := claims["attributes"].(map[string]any) + if !ok { + t.Fatalf("expected attributes object claim, got %T", claims["attributes"]) + } + if attributes["tier"] != "pro" { + t.Fatalf("expected tier=pro in attributes, got %v", attributes) + } } func TestManagementEndpointRequiresManagementKey(t *testing.T) { diff --git a/internal/offlinejwt/offlinejwt.go b/internal/offlinejwt/offlinejwt.go index 394e84f..210a3c2 100644 --- a/internal/offlinejwt/offlinejwt.go +++ b/internal/offlinejwt/offlinejwt.go @@ -31,6 +31,7 @@ type TokenParams struct { Subject string Slug string Fingerprint string + Attributes map[string]any ExpiresAt time.Time IssuedAt time.Time } @@ -148,6 +149,7 @@ func SignEd25519JWT(encryptedPrivateKey, encryptionSecret, kid string, params To "sub": strings.TrimSpace(params.Subject), "slug": strings.TrimSpace(params.Slug), "fingerprint": strings.TrimSpace(params.Fingerprint), + "attributes": copyMap(params.Attributes), "iat": now.Unix(), "nbf": now.Unix(), "exp": expiresAt.Unix(), @@ -171,6 +173,19 @@ func SignEd25519JWT(encryptedPrivateKey, encryptionSecret, kid string, params To return signingInput + "." + base64.RawURLEncoding.EncodeToString(signature), nil } +func copyMap(values map[string]any) map[string]any { + if values == nil { + return map[string]any{} + } + + out := make(map[string]any, len(values)) + for k, v := range values { + out[k] = v + } + + return out +} + func encryptionAEAD(secret string) (cipher.AEAD, error) { trimmed := strings.TrimSpace(secret) if len(trimmed) < 32 { diff --git a/internal/storage/generate_license_test.go b/internal/storage/generate_license_test.go index d36a437..a777fcd 100644 --- a/internal/storage/generate_license_test.go +++ b/internal/storage/generate_license_test.go @@ -37,6 +37,7 @@ func (r stubRow) Scan(dest ...any) error { func TestGenerateLicenseWithQuerierSnapshotsDurationSlugPolicy(t *testing.T) { createdAt := time.Date(2026, 4, 21, 12, 0, 0, 0, time.UTC) metadata := map[string]any{"email": "user@example.com"} + slugAttributes := map[string]any{"features": []any{"analytics", "export"}, "tier": "pro"} callCount := 0 q := stubQueryable{queryRowFn: func(_ context.Context, _ string, args ...any) pgx.Row { @@ -49,12 +50,15 @@ func TestGenerateLicenseWithQuerierSnapshotsDurationSlugPolicy(t *testing.T) { *dest[2].(*sql.NullInt32) = sql.NullInt32{Int32: 30, Valid: true} *dest[3].(*sql.NullTime) = sql.NullTime{} *dest[4].(*int) = 7 + *dest[5].(*bool) = true + *dest[6].(*int) = 86400 + *dest[7].(*map[string]any) = slugAttributes return nil }} case 2: return stubRow{scanFn: func(dest ...any) error { - if len(args) != 5 { - t.Fatalf("expected 5 insert args, got %d", len(args)) + if len(args) != 6 { + t.Fatalf("expected 6 insert args, got %d", len(args)) } key, ok := args[0].(string) @@ -85,9 +89,22 @@ func TestGenerateLicenseWithQuerierSnapshotsDurationSlugPolicy(t *testing.T) { t.Fatalf("unexpected metadata %v", metadataDecoded) } - expiresAt, ok := args[3].(*time.Time) + attributesJSON, ok := args[3].([]byte) + if !ok { + t.Fatalf("expected attributes []byte, got %T", args[3]) + } + + var attributesDecoded map[string]any + if err := json.Unmarshal(attributesJSON, &attributesDecoded); err != nil { + t.Fatalf("unmarshal attributes: %v", err) + } + if attributesDecoded["tier"] != "pro" { + t.Fatalf("unexpected attributes %v", attributesDecoded) + } + + expiresAt, ok := args[4].(*time.Time) if !ok || expiresAt == nil { - t.Fatalf("expected expires_at *time.Time, got %T (%v)", args[3], args[3]) + t.Fatalf("expected expires_at *time.Time, got %T (%v)", args[4], args[4]) } remaining := time.Until(*expiresAt) @@ -95,9 +112,9 @@ func TestGenerateLicenseWithQuerierSnapshotsDurationSlugPolicy(t *testing.T) { t.Fatalf("expected duration-based expiration around 30 days, got %s", remaining) } - maxActivations, ok := args[4].(int) + maxActivations, ok := args[5].(int) if !ok || maxActivations != 7 { - t.Fatalf("expected max_activations 7, got %v (%T)", args[4], args[4]) + t.Fatalf("expected max_activations 7, got %v (%T)", args[5], args[5]) } *dest[0].(*time.Time) = createdAt @@ -122,6 +139,10 @@ func TestGenerateLicenseWithQuerierSnapshotsDurationSlugPolicy(t *testing.T) { t.Fatalf("expected inactive status, got %q", generated.Status) } + if generated.Attributes["tier"] != "pro" { + t.Fatalf("expected snapshotted attributes, got %v", generated.Attributes) + } + if generated.ExpiresAt == nil { t.Fatalf("expected generated license expiration") } diff --git a/internal/storage/license_management.go b/internal/storage/license_management.go index 880d52d..1204a3e 100644 --- a/internal/storage/license_management.go +++ b/internal/storage/license_management.go @@ -52,6 +52,7 @@ func (s *Store) ListLicenses(ctx context.Context, params LicenseListParams) (Lic ) AS last_validated_at, l.revoked_at, l.metadata, + l.attributes, s.name, s.offline_enabled, s.offline_token_lifetime_seconds, @@ -154,6 +155,7 @@ func scanLicenseListRow(row licenseListScanner) (LicenseRow, error) { lastValidatedAt sql.NullTime revokedAt sql.NullTime metadataJSON []byte + attributesJSON []byte ) err := row.Scan( @@ -166,6 +168,7 @@ func scanLicenseListRow(row licenseListScanner) (LicenseRow, error) { &lastValidatedAt, &revokedAt, &metadataJSON, + &attributesJSON, &license.SlugName, &license.OfflineEnabled, &license.OfflineTokenLifetimeSeconds, @@ -196,11 +199,18 @@ func scanLicenseListRow(row licenseListScanner) (LicenseRow, error) { license.CreatedAt = license.CreatedAt.UTC() if len(metadataJSON) == 0 { license.Metadata = map[string]any{} - return license, nil + } else { + if err := json.Unmarshal(metadataJSON, &license.Metadata); err != nil { + return LicenseRow{}, fmt.Errorf("decode license metadata: %w", err) + } } - if err := json.Unmarshal(metadataJSON, &license.Metadata); err != nil { - return LicenseRow{}, fmt.Errorf("decode license metadata: %w", err) + if len(attributesJSON) == 0 { + license.Attributes = map[string]any{} + } else { + if err := json.Unmarshal(attributesJSON, &license.Attributes); err != nil { + return LicenseRow{}, fmt.Errorf("decode license attributes: %w", err) + } } return license, nil diff --git a/internal/storage/schema.sql b/internal/storage/schema.sql index f0908a6..12fdf01 100644 --- a/internal/storage/schema.sql +++ b/internal/storage/schema.sql @@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS slugs ( fixed_expires_at TIMESTAMPTZ, offline_enabled BOOLEAN NOT NULL DEFAULT FALSE, offline_token_lifetime_seconds INTEGER NOT NULL DEFAULT 86400, + attributes JSONB NOT NULL DEFAULT '{}'::jsonb, is_default BOOLEAN NOT NULL DEFAULT FALSE, deleted_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), @@ -37,6 +38,9 @@ END $$; ALTER TABLE slugs ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; +ALTER TABLE slugs + ADD COLUMN IF NOT EXISTS attributes JSONB NOT NULL DEFAULT '{}'::jsonb; + ALTER TABLE slugs ADD COLUMN IF NOT EXISTS offline_token_lifetime_seconds INTEGER NOT NULL DEFAULT 86400; @@ -75,6 +79,7 @@ CREATE TABLE IF NOT EXISTS licenses ( slug_id BIGINT NOT NULL REFERENCES slugs(id), status TEXT NOT NULL CHECK (status IN ('inactive', 'active', 'revoked')), metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + attributes JSONB NOT NULL DEFAULT '{}'::jsonb, expires_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), activated_at TIMESTAMPTZ, @@ -84,6 +89,9 @@ CREATE TABLE IF NOT EXISTS licenses ( ALTER TABLE licenses ADD COLUMN IF NOT EXISTS max_activations INTEGER; +ALTER TABLE licenses + ADD COLUMN IF NOT EXISTS attributes JSONB NOT NULL DEFAULT '{}'::jsonb; + UPDATE licenses l SET max_activations = s.max_activations FROM slugs s diff --git a/internal/storage/slug_management.go b/internal/storage/slug_management.go index ca63e2e..64abd18 100644 --- a/internal/storage/slug_management.go +++ b/internal/storage/slug_management.go @@ -31,6 +31,7 @@ type SlugRecord struct { DeletedAt *time.Time CreatedAt time.Time UpdatedAt time.Time + Attributes map[string]any } type CreateSlugParams struct { @@ -41,6 +42,7 @@ type CreateSlugParams struct { FixedExpiresAt *time.Time OfflineEnabled bool OfflineTokenLifetimeSeconds int + Attributes map[string]any } type UpdateSlugParams struct { @@ -51,6 +53,7 @@ type UpdateSlugParams struct { FixedExpiresAt **time.Time OfflineEnabled *bool OfflineTokenLifetimeSeconds *int + Attributes map[string]any } func (s *Store) ListSlugs(ctx context.Context, includeArchived bool) ([]SlugRecord, error) { @@ -63,6 +66,7 @@ func (s *Store) ListSlugs(ctx context.Context, includeArchived bool) ([]SlugReco fixed_expires_at, offline_enabled, offline_token_lifetime_seconds, + attributes, is_default, deleted_at, created_at, @@ -108,6 +112,7 @@ func (s *Store) GetSlugByName(ctx context.Context, name string) (SlugRecord, err fixed_expires_at, offline_enabled, offline_token_lifetime_seconds, + attributes, is_default, deleted_at, created_at, @@ -155,8 +160,8 @@ func (s *Store) CreateSlug(ctx context.Context, params CreateSlugParams) (SlugRe } row := s.db.QueryRow(ctx, ` - INSERT INTO slugs (name, max_activations, expiration_type, expiration_days, fixed_expires_at, offline_enabled, offline_token_lifetime_seconds, is_default) - VALUES ($1, $2, $3, $4, $5, $6, $7, FALSE) + INSERT INTO slugs (name, max_activations, expiration_type, expiration_days, fixed_expires_at, offline_enabled, offline_token_lifetime_seconds, is_default, attributes) + VALUES ($1, $2, $3, $4, $5, $6, $7, FALSE, $8) RETURNING id, name, max_activations, @@ -165,6 +170,7 @@ func (s *Store) CreateSlug(ctx context.Context, params CreateSlugParams) (SlugRe fixed_expires_at, offline_enabled, offline_token_lifetime_seconds, + attributes, is_default, deleted_at, created_at, @@ -177,6 +183,7 @@ func (s *Store) CreateSlug(ctx context.Context, params CreateSlugParams) (SlugRe fixedExpiresAt, params.OfflineEnabled, offlineTokenLifetimeSeconds, + copyMetadata(params.Attributes), ) record, err := scanSlugRecord(row) @@ -224,6 +231,11 @@ func (s *Store) UpdateSlugByName(ctx context.Context, currentName string, params return SlugRecord{}, fmt.Errorf("offline_token_lifetime_seconds must be greater than 0") } + attributes := current.Attributes + if params.Attributes != nil { + attributes = params.Attributes + } + var expirationDays sql.NullInt32 if params.ExpirationDays != nil { expirationDays = sql.NullInt32{Int32: int32(*params.ExpirationDays), Valid: true} @@ -281,8 +293,9 @@ func (s *Store) UpdateSlugByName(ctx context.Context, currentName string, params fixed_expires_at = $5, offline_enabled = $6, offline_token_lifetime_seconds = $7, + attributes = $8, updated_at = NOW() - WHERE id = $8 + WHERE id = $9 RETURNING id, name, max_activations, @@ -291,6 +304,7 @@ func (s *Store) UpdateSlugByName(ctx context.Context, currentName string, params fixed_expires_at, offline_enabled, offline_token_lifetime_seconds, + attributes, is_default, deleted_at, created_at, @@ -303,6 +317,7 @@ func (s *Store) UpdateSlugByName(ctx context.Context, currentName string, params fixedExpiresAt, offlineEnabled, offlineTokenLifetimeSeconds, + copyMetadata(attributes), current.ID, ) @@ -405,6 +420,7 @@ func scanSlugRecord(row slugScanner) (SlugRecord, error) { &fixedExpiresAt, &record.OfflineEnabled, &record.OfflineTokenLifetimeSeconds, + &record.Attributes, &record.IsDefault, &deletedAt, &record.CreatedAt, diff --git a/internal/storage/store.go b/internal/storage/store.go index adc53b4..e32ab11 100644 --- a/internal/storage/store.go +++ b/internal/storage/store.go @@ -44,6 +44,7 @@ type GeneratedLicense struct { Slug string Status string Metadata map[string]any + Attributes map[string]any ExpiresAt *time.Time CreatedAt time.Time } @@ -82,6 +83,7 @@ type ActivationResult struct { LicenseID string LicenseKey string Slug string + Attributes map[string]any Fingerprint string ExpiresAt *time.Time OfflineEnabled bool @@ -94,6 +96,7 @@ type ValidationResult struct { Status string LicenseID string Slug string + Attributes map[string]any ExpiresAt *time.Time OfflineEnabled bool OfflineTokenLifetimeSeconds int @@ -187,6 +190,7 @@ type LicenseRow struct { LastValidatedAt *time.Time RevokedAt *time.Time Metadata map[string]any + Attributes map[string]any SlugName string OfflineEnabled bool OfflineTokenLifetimeSeconds int @@ -202,6 +206,7 @@ type slugOptions struct { MaxActivations int OfflineEnabled bool OfflineTokenLifetimeSeconds int + Attributes map[string]any } func New(ctx context.Context, databaseURL string) (*Store, error) { @@ -338,7 +343,7 @@ func generateLicenseWithQuerier(ctx context.Context, q queryable, slugName strin func loadSlugOptionsByName(ctx context.Context, q queryable, slugName string) (slugOptions, error) { var options slugOptions err := q.QueryRow(ctx, ` - SELECT id, expiration_type, expiration_days, fixed_expires_at, max_activations, offline_enabled, offline_token_lifetime_seconds + SELECT id, expiration_type, expiration_days, fixed_expires_at, max_activations, offline_enabled, offline_token_lifetime_seconds, attributes FROM slugs WHERE name = $1 AND deleted_at IS NULL @@ -350,6 +355,7 @@ func loadSlugOptionsByName(ctx context.Context, q queryable, slugName string) (s &options.MaxActivations, &options.OfflineEnabled, &options.OfflineTokenLifetimeSeconds, + &options.Attributes, ) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -388,6 +394,11 @@ func createLicenseFromSlugOptions(ctx context.Context, q queryable, slugName str return GeneratedLicense{}, err } + attributesJSON, err := metadataToJSON(options.Attributes) + if err != nil { + return GeneratedLicense{}, fmt.Errorf("marshal attributes: %w", err) + } + var createdAt time.Time licenseKey := "" for i := 0; i < 8; i++ { @@ -397,10 +408,10 @@ func createLicenseFromSlugOptions(ctx context.Context, q queryable, slugName str } err = q.QueryRow(ctx, ` - INSERT INTO licenses (key, slug_id, status, metadata, expires_at, max_activations) - VALUES ($1, $2, 'inactive', $3, $4, $5) + INSERT INTO licenses (key, slug_id, status, metadata, attributes, expires_at, max_activations) + VALUES ($1, $2, 'inactive', $3, $4, $5, $6) RETURNING created_at - `, licenseKey, options.SlugID, metadataJSON, expiresAt, options.MaxActivations).Scan(&createdAt) + `, licenseKey, options.SlugID, metadataJSON, attributesJSON, expiresAt, options.MaxActivations).Scan(&createdAt) if err == nil { break } @@ -419,6 +430,7 @@ func createLicenseFromSlugOptions(ctx context.Context, q queryable, slugName str Slug: slugName, Status: "inactive", Metadata: copyMetadata(metadata), + Attributes: copyMetadata(options.Attributes), ExpiresAt: expiresAt, CreatedAt: createdAt.UTC(), }, nil @@ -449,6 +461,7 @@ func marshalGenerateResponseBody(generated GeneratedLicense) ([]byte, error) { Slug string `json:"slug"` Status string `json:"status"` Metadata map[string]any `json:"metadata"` + Attributes map[string]any `json:"attributes"` ExpiresAt *time.Time `json:"expires_at"` CreatedAt time.Time `json:"created_at"` }{ @@ -456,6 +469,7 @@ func marshalGenerateResponseBody(generated GeneratedLicense) ([]byte, error) { Slug: generated.Slug, Status: generated.Status, Metadata: generated.Metadata, + Attributes: generated.Attributes, ExpiresAt: generated.ExpiresAt, CreatedAt: generated.CreatedAt, } @@ -535,6 +549,7 @@ func (s *Store) ActivateLicense(ctx context.Context, licenseKey, fingerprint str LicenseID: license.ID, LicenseKey: license.Key, Slug: license.SlugName, + Attributes: copyMetadata(license.Attributes), Fingerprint: fingerprint, ExpiresAt: license.ExpiresAt, OfflineEnabled: license.OfflineEnabled, @@ -614,6 +629,7 @@ func (s *Store) ActivateLicense(ctx context.Context, licenseKey, fingerprint str LicenseID: license.ID, LicenseKey: license.Key, Slug: license.SlugName, + Attributes: copyMetadata(license.Attributes), Fingerprint: fingerprint, ExpiresAt: license.ExpiresAt, OfflineEnabled: license.OfflineEnabled, @@ -655,6 +671,7 @@ func (s *Store) ValidateLicense(ctx context.Context, licenseKey, fingerprint str Status: string(decision.Status), LicenseID: license.ID, Slug: license.SlugName, + Attributes: copyMetadata(license.Attributes), ExpiresAt: license.ExpiresAt, OfflineEnabled: license.OfflineEnabled, OfflineTokenLifetimeSeconds: license.OfflineTokenLifetimeSeconds, @@ -699,6 +716,7 @@ func (s *Store) ValidateLicense(ctx context.Context, licenseKey, fingerprint str Status: string(aggregate.Status()), LicenseID: license.ID, Slug: license.SlugName, + Attributes: copyMetadata(license.Attributes), ExpiresAt: license.ExpiresAt, OfflineEnabled: license.OfflineEnabled, OfflineTokenLifetimeSeconds: license.OfflineTokenLifetimeSeconds, @@ -1341,6 +1359,7 @@ func loadLicenseByKey(ctx context.Context, tx pgx.Tx, key string, forUpdate bool l.activated_at, l.revoked_at, l.metadata, + l.attributes, s.name, s.offline_enabled, s.offline_token_lifetime_seconds @@ -1353,8 +1372,9 @@ func loadLicenseByKey(ctx context.Context, tx pgx.Tx, key string, forUpdate bool } var ( - metadataBytes []byte - row LicenseRow + metadataBytes []byte + attributesBytes []byte + row LicenseRow ) err := tx.QueryRow(ctx, query, key).Scan( @@ -1367,6 +1387,7 @@ func loadLicenseByKey(ctx context.Context, tx pgx.Tx, key string, forUpdate bool &row.ActivatedAt, &row.RevokedAt, &metadataBytes, + &attributesBytes, &row.SlugName, &row.OfflineEnabled, &row.OfflineTokenLifetimeSeconds, @@ -1380,11 +1401,18 @@ func loadLicenseByKey(ctx context.Context, tx pgx.Tx, key string, forUpdate bool if len(metadataBytes) == 0 { row.Metadata = map[string]any{} - return row, nil + } else { + if err := json.Unmarshal(metadataBytes, &row.Metadata); err != nil { + return LicenseRow{}, fmt.Errorf("decode metadata json: %w", err) + } } - if err := json.Unmarshal(metadataBytes, &row.Metadata); err != nil { - return LicenseRow{}, fmt.Errorf("decode metadata json: %w", err) + if len(attributesBytes) == 0 { + row.Attributes = map[string]any{} + } else { + if err := json.Unmarshal(attributesBytes, &row.Attributes); err != nil { + return LicenseRow{}, fmt.Errorf("decode attributes json: %w", err) + } } return row, nil diff --git a/internal/version/version.go b/internal/version/version.go index 702b212..28b2b04 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -1,3 +1,3 @@ package version -const Current = "0.2.0" +const Current = "0.4.0" diff --git a/ui/src/stores/api.ts b/ui/src/stores/api.ts index 5063149..ae95018 100644 --- a/ui/src/stores/api.ts +++ b/ui/src/stores/api.ts @@ -40,6 +40,7 @@ export const slugAPI = { fixed_expires_at?: string offline_enabled?: boolean offline_token_lifetime_hours?: number + attributes?: Record }) => managementApi.post('/slugs', data), update: ( name: string, @@ -51,6 +52,7 @@ export const slugAPI = { fixed_expires_at: string offline_enabled: boolean offline_token_lifetime_hours: number + attributes: Record }>, ) => managementApi.patch(`/slugs/${encodeURIComponent(name)}`, data), delete: (name: string) => managementApi.delete(`/slugs/${encodeURIComponent(name)}`), diff --git a/ui/src/views/LicensesView.vue b/ui/src/views/LicensesView.vue index 748d954..49ae0c4 100644 --- a/ui/src/views/LicensesView.vue +++ b/ui/src/views/LicensesView.vue @@ -1,6 +1,6 @@