Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.production.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ RUN_MIGRATIONS=true
PORT=8000
DATABASE_MAX_CONNS=20
UPDATE_CHECK_RETENTION_DAYS=90
CLIENT_CHECKIN_RETENTION_DAYS=7
AUDIT_LOG_RETENTION_DAYS=180
AUDIT_METADATA_RETENTION_DAYS=90
# Shared high-entropy bearer token for the backend and delta worker.
WORKER_TOKEN=replace-with-a-long-random-value

Expand Down
14 changes: 1 addition & 13 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,13 +1 @@
## graphify

This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.

When the user types `/graphify`, invoke the `skill` tool with `skill: "graphify"` before doing anything else.

Rules:
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
- Dirty graphify-out/ files are expected after hooks or incremental updates; dirty graph files are not a reason to skip graphify. Only skip graphify if the task is about stale or incorrect graph output, or the user explicitly says not to use it.
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
- Never commit unless explicetly asked too
# Instructions
23 changes: 13 additions & 10 deletions CLIENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ avoids expiry races. Deduplicate refreshes inside the process so concurrent
features share one request. After transient failures, retry with exponential
backoff and random jitter rather than on every foreground event.

```
```http
Authorization: Bearer <activation-jwt>
```

Expand Down Expand Up @@ -123,7 +123,7 @@ If the cached token has only recently expired (within the same seven-day window
optional update check. Prefer it for scheduled background work because the
server authorizes the license and activation only once.

```
```http
Authorization: Bearer <activation-jwt>
```

Expand All @@ -134,6 +134,7 @@ Authorization: Bearer <activation-jwt>
"build": "140",
"platform": "macos",
"arch": "arm64",
"osVersion": "15.0",
"channel": "stable",
"clientId": "<stable rollout id>"
}
Expand All @@ -144,12 +145,14 @@ The response always contains `token`, `validUntil`, `updateChannels`, and
`updateStatus`. `updateStatus` is `not_requested`, `ok`, or `unavailable`; an
`ok` response also includes the normal update-check payload in `update`.

When `version` is present and the license is authorized, Clave also records a
best-effort client check-in for the Version Adoption dashboard. This happens
independently of update resolution, so the check-in still counts when no update
source is configured or the update provider is temporarily unavailable. Build,
platform, architecture, and OS version are optional diagnostics. Check-in
recording never changes the synchronization response or failure behavior.
Every authorized synchronization updates the activation's last-seen time. When
`version` is present, Clave also stores the current version, build, platform,
architecture, and OS version and records a check-in for the Version Adoption
dashboard. The update happens before update resolution, so the check-in still
counts when no update source is configured or the update provider is temporarily
unavailable. Build, platform, architecture, and OS version are optional. If
Clave can't persist this state, synchronization fails rather than returning
success with stale device data.

---

Expand Down Expand Up @@ -282,7 +285,7 @@ resume with `Range`, verify the published SHA-256, then rename the completed
file atomically. S3-backed artifacts redirect to short-lived presigned URLs and
should use the same resume and checksum workflow.

```
```http
Authorization: Bearer <activation-jwt>
```

Expand Down Expand Up @@ -315,7 +318,7 @@ Use `name` as the `channel` value in update checks. If you're not giving users a

Use your license JWT to check if a newer version is available. The server resolves which update backend to use based on the product's configuration.

```
```http
Authorization: Bearer <activation-jwt>
```

Expand Down
349 changes: 349 additions & 0 deletions PRIVACY.md

Large diffs are not rendered by default.

47 changes: 46 additions & 1 deletion SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,10 @@ names is overridden.
| `RUN_MIGRATIONS` | recommended | `false` | Run DB migrations at startup. Enable for exactly one instance per deployment. |
| `PORT` | no | `8000` | API listen port inside the container. Changing it also requires changing the published port. |
| `DATABASE_MAX_CONNS` | no | `20` | PostgreSQL pool size. Must be greater than zero. |
| `UPDATE_CHECK_RETENTION_DAYS` | no | `90` | Days of update-check history retained. `0` disables pruning. |
| `UPDATE_CHECK_RETENTION_DAYS` | no | `90` | Days of update-check history retained; must be 7–365. |
| `CLIENT_CHECKIN_RETENTION_DAYS` | no | `7` | Days of device-linked raw check-ins retained for diagnostics and aggregate retries; must be 1–30. Closed UTC dates are aggregated daily before eligible rows are deleted. |
| `AUDIT_LOG_RETENTION_DAYS` | no | `180` | Days core admin audit events remain identifiable; must be 90–365. |
| `AUDIT_METADATA_RETENTION_DAYS` | no | `90` | Days audit IP addresses and User-Agent strings are retained; must be 30–180 and no greater than audit-log retention. |
| `SELF_SERVICE_RETURN_TOKEN` | no | `false` | Returns self-service tokens in API responses. Debug aid — leave off in production. |
| `DEV` | no | unset | Development mode; relaxes key requirements. **Leave unset in production.** |
| `LOG_LEVEL` | no | `info` | `debug`, `verbose`, or `trace` enable debug logging. |
Expand All @@ -155,6 +158,48 @@ names is overridden.

Truthy values are `true`, `1`, and `yes`.

### Client check-in data lifecycle

An authorized client sync updates device-linked current state on the activation:
`last_seen_at`, version, build, platform, architecture, and OS version. Device
hostname and HMAC-protected HWID remain on the registered device for licensing,
support, and self-service deactivation.

A sync that reports a version also creates a raw device-linked check-in without
IP address or User-Agent data. A daily worker aggregates every closed UTC date, normally
making yesterday's adoption counts available the next day. Each activation
counts once per date under its final reported version. Recomputing a date
replaces its aggregate, so retries cannot double-count devices.

Raw check-ins remain for `CLIENT_CHECKIN_RETENTION_DAYS` as a diagnostic and
retry window, then are deleted only after aggregation succeeds. Long-term
`daily_version_adoption` rows retain organization, product, date, version, and
count, but no activation, license, HWID, hostname, or customer identifier.

### Persisted data retention

A centralized worker runs at startup and daily. Cleanup statements are
idempotent, failures are isolated per dataset, and affected row counts are
logged. OpenTelemetry records cleanup outcomes by dataset. Cleanup never runs
on request paths.

| Data | Purpose and sensitivity | Lifecycle |
| --- | --- | --- |
| Self-service tokens | Email, hashed token, expiry, consumption time, creation IP, and optional User-Agent for passwordless access and abuse investigation. | Delete one day after expiry or consumption. |
| Admin sessions | Opaque token and serialized admin session used for authenticated access. | Absolute lifetime 12 hours, idle timeout 30 minutes, immediate deletion on logout, and pgxstore cleanup of expired rows every five minutes. |
| Organization invites | Invitee email, role, hashed token, inviter, expiry, and acceptance time. | Delete accepted invites after 30 days; delete unaccepted invites 30 days after expiry. |
| Admin MFA codes | Admin reference, HMAC code, attempts, expiry, and use time. | Delete one day after expiry or consumption. |
| Admin audit events | Actor, organization, action, resource, timestamp, IP, and User-Agent for accountability and incident investigation. | Scrub IP and User-Agent after `AUDIT_METADATA_RETENTION_DAYS`; delete the core event after `AUDIT_LOG_RETENTION_DAYS`. |
| Update checks | Organization/product/license references and software environment used for update diagnostics. | Delete after `UPDATE_CHECK_RETENTION_DAYS`. |
| Email queue messages | Recipient and transactional payload, which may include MFA codes, links, or license keys. | RabbitMQ removes successful messages immediately. Per-message expiry is 10 minutes for MFA, 15 minutes for self-service links, and seven days for organization invites and license emails. No completed-delivery table is kept. |
| MCP tokens | Hashed organization credential plus creator and last-use metadata. | Retain until regeneration or organization deletion. |
| Delta jobs | Release/artifact checksums and operational status used for idempotency and diagnostics. | Retain with the referenced release/artifact lifecycle. |

Expired and revoked licenses, their devices, and admin accounts are deliberately
excluded from automated retention. Their identity supports licensing, abuse
prevention, customer support, and multi-organization access. They require a
separate product/legal decision before deletion or de-identification.

Ensure `RUN_MIGRATIONS=true` for exactly one backend instance during a
deployment, then start it:

Expand Down
16 changes: 13 additions & 3 deletions backend/internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"github.com/cheetahbyte/clave/internal/features/update/providers/native"
"github.com/cheetahbyte/clave/internal/features/validation"
"github.com/cheetahbyte/clave/internal/observability"
"github.com/cheetahbyte/clave/internal/retention"
"github.com/cheetahbyte/clave/internal/shared/events"
"github.com/cheetahbyte/clave/internal/shared/helpers"
"github.com/cheetahbyte/clave/internal/shared/middleware"
Expand All @@ -42,6 +43,7 @@ var (
publisher *events.Publisher
updateRecorder *update.UpdateCheckRecorder
checkinRecorder *diagnostics.Recorder
retentionWorker *retention.Worker
)

func Close() {
Expand All @@ -53,6 +55,9 @@ func Close() {
if checkinRecorder != nil {
checkinRecorder.Close(ctx)
}
if retentionWorker != nil {
retentionWorker.Close(ctx)
}
observability.Shutdown(ctx)
if publisher != nil {
publisher.Close()
Expand Down Expand Up @@ -105,6 +110,11 @@ func NewRouter(cfg *config.Config) (http.Handler, error) {
observability.StartDBPoolMetrics(context.Background(), pool, 30*time.Second)

q := db.New(pool)
retentionWorker = retention.NewWorker(q, retention.Policies{
AuditMetadataDays: cfg.AuditMetadataRetentionDays,
AuditLogDays: cfg.AuditLogRetentionDays,
UpdateCheckDays: cfg.UpdateCheckRetentionDays,
})

signer := signing.New(cfg.LicenseJWTPublicKey, cfg.LicenseJWTPrivateKey, cfg.LicenseHMACSecret)

Expand All @@ -117,14 +127,14 @@ func NewRouter(cfg *config.Config) (http.Handler, error) {
)

updateSvc := update.NewService(licenseSvc, signer, updateRepo, updateRegistry, cfg.PublicAppURL, cfg.UpdateArtifactStoragePath)
updateRecorder = update.NewUpdateCheckRecorder(updateRepo, cfg.UpdateCheckRetentionDays, 256)
updateRecorder = update.NewUpdateCheckRecorder(updateRepo, 256)
updateSvc.SetCheckRecorder(updateRecorder)
validationSvc := validation.NewService(q, signer, licenseSvc, updateSvc)
activationSvc := activation.NewService(q, pool, signer, licenseSvc, updateSvc, validationSvc)
updateSvc.SetValidator(validationSvc)
diagnosticsRepo := diagnostics.NewRepository(q)
diagnosticsRepo := diagnostics.NewRepository(q, pool)
diagnosticsSvc := diagnostics.NewService(diagnosticsRepo)
checkinRecorder = diagnostics.NewRecorder(diagnosticsRepo, 90, 256)
checkinRecorder = diagnostics.NewRecorder(diagnosticsRepo, cfg.ClientCheckinRetentionDays)
clientSyncSvc := clientsync.NewService(validationSvc, updateSvc, checkinRecorder)
selfServiceRepo := selfservice.NewRepository(q, pool)
selfserviceSvc := selfservice.NewService(selfServiceRepo, []byte(cfg.SelfServiceTokenPepper), signer, licenseSvc, updateSvc)
Expand Down
95 changes: 66 additions & 29 deletions backend/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"errors"
"fmt"
"log/slog"
"math"
"os"
"strconv"
"strings"
Expand Down Expand Up @@ -49,8 +50,11 @@ type Config struct {

TrustProxyHeaders bool

UpdateArtifactStoragePath string
UpdateCheckRetentionDays int
UpdateArtifactStoragePath string
UpdateCheckRetentionDays int
ClientCheckinRetentionDays int
AuditLogRetentionDays int
AuditMetadataRetentionDays int

MigrationsDir string
OTELEnabled bool
Expand Down Expand Up @@ -82,43 +86,76 @@ func getEnvInt(key string, fallback int) (int, error) {
return value, nil
}

func getEnvIntRange(key string, fallback, min, max int) (int, error) {
value, err := getEnvInt(key, fallback)
if err != nil {
return 0, err
}
if value < min || value > max {
return 0, fmt.Errorf("%s must be between %d and %d", key, min, max)
}
return value, nil
}

func Load() (*Config, error) {
databaseMaxConns, err := getEnvInt("DATABASE_MAX_CONNS", 20)
if err != nil || databaseMaxConns == 0 {
if err != nil || databaseMaxConns == 0 || databaseMaxConns > math.MaxInt32 {
if err == nil {
err = fmt.Errorf("DATABASE_MAX_CONNS must be greater than zero")
if databaseMaxConns == 0 {
err = fmt.Errorf("DATABASE_MAX_CONNS must be greater than zero")
} else {
err = fmt.Errorf("DATABASE_MAX_CONNS must be less than or equal to %d", math.MaxInt32)
}
}
return nil, err
}
retentionDays, err := getEnvInt("UPDATE_CHECK_RETENTION_DAYS", 90)
retentionDays, err := getEnvIntRange("UPDATE_CHECK_RETENTION_DAYS", 90, 7, 365)
if err != nil {
return nil, err
}
checkinRetentionDays, err := getEnvIntRange("CLIENT_CHECKIN_RETENTION_DAYS", 7, 1, 30)
if err != nil {
return nil, err
}
auditLogRetentionDays, err := getEnvIntRange("AUDIT_LOG_RETENTION_DAYS", 180, 90, 365)
if err != nil {
return nil, err
}
auditMetadataRetentionDays, err := getEnvIntRange("AUDIT_METADATA_RETENTION_DAYS", 90, 30, 180)
if err != nil {
return nil, err
}
if auditMetadataRetentionDays > auditLogRetentionDays {
return nil, errors.New("AUDIT_METADATA_RETENTION_DAYS must not exceed AUDIT_LOG_RETENTION_DAYS")
}
cfg := &Config{
DatabaseURL: getEnv("DATABASE_URL", "postgres://clave@localhost:54321/clave?sslmode=disable"),
DatabaseMaxConns: int32(databaseMaxConns),
RunMigrations: truthy(os.Getenv("RUN_MIGRATIONS")),
VerboseLogging: verboseLoggingEnabled(),
Dev: truthy(os.Getenv("DEV")),
MigrationsDir: getEnv("MIGRATIONS_DIR", "./migrations"),
LicenseHMACSecret: os.Getenv("LICENSE_HMAC_SECRET"),
OTELEnabled: truthy(os.Getenv("OTEL_ENABLED")),
OTELServiceName: getEnv("OTEL_SERVICE_NAME", "clave-api"),
OTELExporterEndpoint: os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"),
SelfServiceTokenPepper: os.Getenv("SELF_SERVICE_TOKEN_PEPPER"),
SelfServiceReturnToken: strings.ToLower(os.Getenv("SELF_SERVICE_RETURN_TOKEN")) == "true",
SMTPHost: os.Getenv("SMTP_HOST"),
SMTPPort: getEnv("SMTP_PORT", "587"),
SMTPUser: os.Getenv("SMTP_USER"),
SMTPPass: os.Getenv("SMTP_PASS"),
MailFrom: getEnv("MAIL_FROM", "noreply@clave.app"),
RabbitMQURL: os.Getenv("RABBITMQ_URL"),
WorkerToken: os.Getenv("WORKER_TOKEN"),
PublicAppURL: os.Getenv("PUBLIC_APP_URL"),
Port: getEnv("PORT", "8000"),
TrustProxyHeaders: truthy(os.Getenv("TRUST_PROXY_HEADERS")),
UpdateArtifactStoragePath: getEnv("UPDATE_ARTIFACT_STORAGE_PATH", "./data/update-artifacts"),
UpdateCheckRetentionDays: retentionDays,
DatabaseURL: getEnv("DATABASE_URL", "postgres://clave@localhost:54321/clave?sslmode=disable"),
DatabaseMaxConns: int32(databaseMaxConns),
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
RunMigrations: truthy(os.Getenv("RUN_MIGRATIONS")),
VerboseLogging: verboseLoggingEnabled(),
Dev: truthy(os.Getenv("DEV")),
MigrationsDir: getEnv("MIGRATIONS_DIR", "./migrations"),
LicenseHMACSecret: os.Getenv("LICENSE_HMAC_SECRET"),
OTELEnabled: truthy(os.Getenv("OTEL_ENABLED")),
OTELServiceName: getEnv("OTEL_SERVICE_NAME", "clave-api"),
OTELExporterEndpoint: os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"),
SelfServiceTokenPepper: os.Getenv("SELF_SERVICE_TOKEN_PEPPER"),
SelfServiceReturnToken: strings.ToLower(os.Getenv("SELF_SERVICE_RETURN_TOKEN")) == "true",
SMTPHost: os.Getenv("SMTP_HOST"),
SMTPPort: getEnv("SMTP_PORT", "587"),
SMTPUser: os.Getenv("SMTP_USER"),
SMTPPass: os.Getenv("SMTP_PASS"),
MailFrom: getEnv("MAIL_FROM", "noreply@clave.app"),
RabbitMQURL: os.Getenv("RABBITMQ_URL"),
WorkerToken: os.Getenv("WORKER_TOKEN"),
PublicAppURL: os.Getenv("PUBLIC_APP_URL"),
Port: getEnv("PORT", "8000"),
TrustProxyHeaders: truthy(os.Getenv("TRUST_PROXY_HEADERS")),
UpdateArtifactStoragePath: getEnv("UPDATE_ARTIFACT_STORAGE_PATH", "./data/update-artifacts"),
UpdateCheckRetentionDays: retentionDays,
ClientCheckinRetentionDays: checkinRetentionDays,
AuditLogRetentionDays: auditLogRetentionDays,
AuditMetadataRetentionDays: auditMetadataRetentionDays,
}

cfg.DevSkip2FA = cfg.Dev && !truthy(os.Getenv("DEV_FORCE_2FA"))
Expand Down
13 changes: 13 additions & 0 deletions backend/internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,19 @@ func TestGetEnvInt(t *testing.T) {
}
}

func TestGetEnvIntRange(t *testing.T) {
t.Setenv("CLAVE_TEST_RANGE", "")
if got, err := getEnvIntRange("CLAVE_TEST_RANGE", 90, 7, 365); err != nil || got != 90 {
t.Fatalf("default = %d, %v", got, err)
}
for _, value := range []string{"0", "366", "invalid"} {
t.Setenv("CLAVE_TEST_RANGE", value)
if _, err := getEnvIntRange("CLAVE_TEST_RANGE", 90, 7, 365); err == nil {
t.Fatalf("expected %q to fail", value)
}
}
}

func TestLoadEd25519PrivateKeyFileErrors(t *testing.T) {
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
Expand Down
Loading
Loading