diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 4f9da10b3..361e4fba9 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -18,6 +18,10 @@ or background job (see `docs/security/tenant-isolation-audit.md`):
+## Contributor License Agreement
+
+- [ ] I have signed the CLA () and my commits use an email linked to my GitHub account.
+
## Testing
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2d5de1d75..70c65e2bd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,7 +6,37 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-## [Unreleased]
+## [1.4.0] - 2026-10-20
+
+### Added
+
+- **Combined telemetry byte quota**: new `telemetry.max_bytes_monthly` quota (default unlimited) whose usage is the sum of `logs.ingested.bytes`, `spans.ingested.bytes` and `metrics.ingested.bytes` over the UTC calendar month. Over the limit (plus `quota.grace_percent`) log ingestion on every path, `POST /v1/otlp/traces` and `POST /v1/otlp/metrics` answer `429` (with `upgrade_available` when the upgrade handoff is active); grace and blocked notifications format it in bytes. Spans and metric datapoints are now also metered in bytes, as the JSON size of the customer-provided fields of the accepted records (the server-injected `organizationId`, `projectId` and span `time` copy are excluded; spans dropped by PII masking and datapoints the store rejected are not counted), measured before storage on every engine. Quota definitions in the capability registry can now sum several metering signals; the evaluator, the capability usage API and the digest usage warnings read them all. The per-signal quotas are unchanged. Settable through entitlements, the admin API and `CAPABILITY_DEFAULTS_JSON`; shown on the Usage page as "Telemetry (logs, traces and metrics)"; when it has a limit the Usage page hides the quotas left unlimited (every row still shows otherwise), and the "Bytes ingested" summary card is now labeled "Log bytes ingested".
+- **Terms and Privacy acceptance**: setting `LOGTIDE_TERMS_URL`, `LOGTIDE_PRIVACY_URL` and `LOGTIDE_TERMS_VERSION` (all three or none, a partial set aborts startup; https URLs, http only for localhost; version non-empty, max 64 characters) makes registration require `acceptTerms: true` (`400 { error: "terms_not_accepted" }` otherwise) and stores the accepted version and time on the user (migration 061 adds `users.terms_accepted_at` and `users.terms_version`, no backfill). Users whose stored version differs from the current one (existing users, SSO users, everyone after a version bump) get `user.termsAcceptanceRequired: true` on `GET /api/v1/auth/me`, a blocking modal in the app with Accept and Log out, and `403 { error: "terms_not_accepted" }` on creating organizations, projects and API keys (and inbound receivers) until they call the new `POST /api/v1/auth/accept-terms` with the current version (`409 terms_version_mismatch` for any other); a page left open shows the modal as soon as a request is refused with `terms_not_accepted`. Ingestion with existing API keys is never blocked. The feature is incompatible with auth-free mode: the backend refuses to start when the terms are configured and the stored auth mode is `none`, and the admin settings API answers `400 terms_incompatible_with_auth_free_mode` to a switch to `none`. `GET /api/v1/auth/config` gains `termsUrl`, `privacyUrl` and `termsVersion`, and the registration form shows a required checkbox linking both pages. Unset: nothing changes, the route is not registered and no key is added.
+- **`metrics.max_datapoints_monthly` quota**: new month-to-date quota on ingested metric datapoints (signal `metrics.ingested.datapoints`), default unlimited. Over the limit (plus `quota.grace_percent`) `POST /v1/otlp/metrics` answers `429` with a `partialSuccess.rejectedDataPoints: -1` body, while logs and spans keep flowing. Settable through entitlements, the admin API and `CAPABILITY_DEFAULTS_JSON`; listed on the Usage page as "Metric datapoints (this month)" and counted as a plain number in grace/blocked notifications.
+- **Metric datapoint metering**: accepted OTLP metric datapoints are now recorded as the billed `metrics.ingested.datapoints` metering type (previously metrics were not measured at all). Datapoints that were not stored are not counted. It does not use the `ingestion.` prefix, so it shows in usage data and respects `METERING_ENABLED=false`, like spans.
+- **Optional email verification**: `REQUIRE_EMAIL_VERIFICATION=true` (default false; any value other than true/false aborts startup, and so does `true` without `SMTP_HOST`) makes newly registered local users confirm their email. Registration still auto-logs in and sends a verification link (`/verify-email?token=...`, 32 random bytes, stored only as a sha256 hash, single use, 24h expiry) through the configured SMTP server in the background, so the response never waits on SMTP (a send failure is logged and the user can resend); until the link is used, `POST /api/v1/projects`, `POST /api/v1/projects/:id/api-keys` and `POST /api/v1/projects/:id/receivers` answer `403 { error: "email_not_verified" }` (checked before the `projects.max` limit). New routes, registered only when the flag is on: `POST /api/v1/auth/verify-email` (`400 invalid_or_expired_token` for unknown, reused, expired or another signed-in user's token, which is not consumed) and `POST /api/v1/auth/resend-verification` (always `202`, the lookup and send run after the response so neither the body nor the timing reveals whether an address exists; 5 per hour per IP plus a 60s per-user cooldown). `GET /api/v1/auth/config` gains `emailVerificationRequired: true` and `GET /api/v1/auth/me` gains `user.emailVerified` only when the flag is on. OIDC users count as verified when the IdP asserts `email_verified: true` for the account's current address, and a new OIDC user without that claim is emailed the verification link on first login; admin-created, LDAP and bootstrap users are verified; changing the email address while the flag is on requires verifying again. The frontend shows a "Verify your email" banner with a resend button and a `/verify-email` confirmation page. Migration 058 adds `users.email_verified_at` (backfilled to `created_at`, default `NOW()`, so existing users and users registered while the flag is off are never locked out) and `email_verification_tokens`.
+- **Hourly registration limit**: `REGISTRATION_RATE_LIMIT_PER_HOUR` (positive integer, unset or empty = off, any other value aborts startup) caps `POST /api/v1/auth/register` per client IP over a one-hour window, on top of `AUTH_RATE_LIMIT_REGISTER`. It is keyed by IP only, so a forged `Authorization` header cannot rotate the counter; over the cap the route answers `429` with `Retry-After`.
+- **Organizations per user cap**: `MAX_ORGANIZATIONS_PER_USER` (positive integer, unset or empty = unlimited, any other value aborts startup) limits how many organizations one user may own (organization_members role `owner`; memberships as member or admin never count). `POST /api/v1/organizations` answers `403 { error: "organization_limit_reached", message }` at the cap. It is the only path that creates a user-owned organization (registration, invitations and SSO login do not create one); the internal-logging bootstrap and seed scripts are operator actions and are not capped.
+- **Organization creations per month cap**: `MAX_ORGANIZATION_CREATIONS_PER_USER_PER_MONTH` (positive integer, unset or empty = unlimited, any other value aborts startup) limits how many organizations one user may create per UTC calendar month, so deleting an organization and creating a fresh one can no longer reset the per-organization monthly quotas. Every `POST /api/v1/organizations` appends a row to the new `organization_creation_log` table (migration 060, no foreign key on the organization so the row outlives a hard delete; always written, even with the variable unset); at the cap the endpoint answers `403 { error: "organization_creation_limit_reached", message }`, checked after `MAX_ORGANIZATIONS_PER_USER`. The counts and the insert now run under a per-user advisory lock, which also stops parallel requests from overshooting `MAX_ORGANIZATIONS_PER_USER`.
+- **`projects.max` capability**: new per-org limit on active projects (soft-deleted ones do not count), default unlimited. Enforced on `POST /api/v1/projects` and on `POST /api/v1/projects/:id/restore` (restoring a soft-deleted project re-activates it) under the per-org limit lock, settable through entitlements, the admin API and `CAPABILITY_DEFAULTS_JSON`, and shown as "Projects" on the Usage page.
+- **Quota grace and threshold notifications**: new per-org `quota.grace_percent` limit (default null = 0%, also settable through `CAPABILITY_DEFAULTS_JSON` and the admin entitlements API). A quota now blocks at `limit * (1 + grace/100)`; between the limit and that ceiling it reports `grace`. The capability usage API returns a `state` for quota rows and the Usage page shows "In grace" / "Blocked" badges. The first time a quota enters grace or becomes blocked in a UTC month, owners and admins get an in-app notification and an email (deduplicated per org, capability, state and limit value per month from existing notifications, so raising the limit mid-month re-arms the notice at the new ceiling; the check and insert are atomic across backend replicas through a transaction-scoped Postgres advisory lock, so several replicas send one notice and one email; email failures never break the evaluator).
+- **Signed upgrade handoff**: setting both `LOGTIDE_UPGRADE_URL` and `LOGTIDE_UPGRADE_SIGNING_SECRET` (min 32 characters; https required except localhost; only one set aborts startup) enables `POST /api/v1/organizations/:id/upgrade-handoff` (owner/admin only, body `{ intent: "upgrade" | "manage" }`). It returns a URL carrying a 10 minute HMAC-SHA256 signed token (`base64url(payload).base64url(hmac)`) that proves user, role and org to an external billing portal without exposing any LogTide session credential. `GET /api/v1/auth/config` gains `upgradeEnabled: true`, capability and quota denials (403/429, including the OTLP logs/traces/metrics `429` bodies) gain `upgrade_available: true`, and the usage page shows Upgrade / Manage billing buttons to owners and admins. All keys and the route are absent when unset.
+- **Admin service token**: setting `LOGTIDE_ADMIN_SERVICE_TOKEN` (min 32 characters, shorter aborts startup) lets an external billing service call the org entitlements and retention admin endpoints with a static bearer. The token is compared in constant time and is honored only on those routes (the global auth plugin defers a matching bearer to the route-level check on opted-in routes only); anywhere else it is rejected exactly like an invalid session. Audit entries record `actor: service:admin-service` in metadata. Unset means unchanged behavior.
+- **Env-driven capability defaults**: `CAPABILITY_DEFAULTS_JSON` (`{"": boolean | integer | null}`) overrides the registry defaults at boot, validated against the registry (unknown keys, wrong types or malformed JSON abort startup with a clear error). Entitlement rows still win over defaults. Unset means unchanged behavior.
+
+### Changed
+
+- **Quota evaluator and default quotas**: when any quota default from `CAPABILITY_DEFAULTS_JSON` is finite, `QuotaEvaluator` now evaluates every organization (not only those holding a non-null quota entitlement row), so a default quota actually blocks; with no quota defaults it still queries only orgs with rows.
+- **Compose passthrough for hosting variables**: `docker/docker-compose.yml`, `docker-compose.build.yml` and `docker-compose.simple.yml` pass `CAPABILITY_DEFAULTS_JSON`, `LOGTIDE_ADMIN_SERVICE_TOKEN`, `LOGTIDE_UPGRADE_URL`, `LOGTIDE_UPGRADE_SIGNING_SECRET`, `MAX_ORGANIZATIONS_PER_USER`, `MAX_ORGANIZATION_CREATIONS_PER_USER_PER_MONTH`, `REQUIRE_EMAIL_VERIFICATION` and `REGISTRATION_RATE_LIMIT_PER_HOUR` to both the backend and the worker (empty when unset, which is inert).
+- **Contributor License Agreement documented**: `CONTRIBUTING.md` (new subsection under Pull Requests), the pull request template checklist and the README License section now point to the CLA Assistant agreement (https://cla-assistant.io/logtide-dev/logtide) and explain that commits must use an email linked to the contributor's GitHub account. Contributions to the core stay AGPLv3; the CLA also lets the maintainer use them in commercial editions.
+- **Hosting guide**: new `docs/cloud-seams.md` ("Hosting LogTide for others") documents every hosting environment variable with default, validation and semantics, the signed upgrade handoff token format, the service-token routes, the new capabilities and quota/grace behavior, with a type-checked reference verifier in `docs/examples/verify-handoff.ts`, plus guidance for the internal logging organization (subject to finite defaults like any org, give it explicit unlimited rows) and operator notes (`TRUST_PROXY`, the shared service-token rate limit, `METERING_ENABLED=false`, capability renames, evaluation lag). `docker/.env.example` lists all the new variables.
+- **Contributing guide**: `CONTRIBUTING.md` now documents the architectural rules every pull request must respect (multi-engine storage, tenant isolation, fail-closed PII masking, correctness under the async buffer, queue/dispatcher/capability abstractions, migrations, deployment footprint), and corrects outdated details: branch from and target `develop`, 80% backend coverage, the dev Compose file path and the `reservoir` package in the project structure.
+
+### Fixed
+
+- **Entitlement limits above 2 GiB**: `organization_entitlements.limit_value` was an `INTEGER`, so any limit of 2^31 or more (every byte quota from 2 GiB up, e.g. a 10 GiB `ingestion.max_bytes_monthly`) failed with "integer out of range" and the admin entitlements API answered `400`. Migration 059 widens the column to `BIGINT`; reads coerce the value back to a number and the API caps `limitValue` at `Number.MAX_SAFE_INTEGER`.
+- **Credentials in rate-limit keys**: the global rate limiter keyed requests by the raw `X-API-Key` or bearer token, so with Redis configured every live API key and session token sat in plain text in Redis key names. Keys are now `key:` plus the first 32 hex characters of the credential's SHA-256; counting per credential is unchanged.
+- **Docker image builds**: the backend and frontend Dockerfiles installed an unpinned `pnpm`, so they picked up a newer major that no longer reads the `pnpm.overrides` field of `package.json` and rejected the lockfile with `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH`, breaking every image build (and the E2E job that builds them). Both Dockerfiles now pin `pnpm@10`, matching the version CI uses.
### Security
@@ -21,15 +51,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Upgraded `vitest` and `@vitest/coverage-v8` to 4.1.12** (Dependabot `GHSA-82fw-gwwq-j7x9`, MEDIUM): a path traversal / arbitrary file read through `@vitest/mocker` redirect mocks, fixed only on the 4.x line (`>=4.1.11`). Test tooling only. The major upgrade also drops `tinypool`, clearing two critical prototype-pollution-to-RCE advisories (`GHSA-5gmw-xhrv-c9v3`, `GHSA-85c8-ppgw-ccpr`) that `pnpm audit` reported and Dependabot did not. Migration: mocks used as constructors now use `function` implementations (Vitest 4 no longer allows arrow functions with `new`), the backend config drops the removed `poolOptions` (sequential runs are still enforced by `fileParallelism: false`), backend and reservoir set `coverage.include: ['src/**/*.ts']` so files never loaded by a test still count toward the 80% gate, and `@logtide/shared` now declares the `@types/node` it was implicitly getting through Vitest 3.
- **Bumped `brace-expansion` to 5.0.12, `qs` to 6.16.1, `source-map-js` to 1.2.2, `browserslist` to 4.29.3, `baseline-browser-mapping` to 2.11.29 and `postcss-selector-parser` to 6.1.4** (Dependabot `GHSA-6j4f-fj2g-mc7p`, `GHSA-4mjr-xmp4-gh2g`, `GHSA-x5fp-wj9c-mxmx`, `GHSA-68fv-2mgg-jv7q`, `GHSA-73wf-gq98-2v4g`, `GHSA-c83g-rgw3-j3cx`, `GHSA-w5vr-8v7q-w6rv`, `GHSA-w9m9-85wc-3x92`): denial of service issues in build and test tooling (glob expansion, query string parsing in dev servers, source maps, browser target resolution, CSS selector parsing), none on a request path. Raised or added as root pnpm overrides.
-### Changed
-
-- **Contributing guide**: `CONTRIBUTING.md` now documents the architectural rules every pull request must respect (multi-engine storage, tenant isolation, fail-closed PII masking, correctness under the async buffer, queue/dispatcher/capability abstractions, migrations, deployment footprint), and corrects outdated details: branch from and target `develop`, 80% backend coverage, the dev Compose file path and the `reservoir` package in the project structure.
-
-### Fixed
-
-- **Docker image builds**: the backend and frontend Dockerfiles installed an unpinned `pnpm`, so they picked up a newer major that no longer reads the `pnpm.overrides` field of `package.json` and rejected the lockfile with `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH`, breaking every image build (and the E2E job that builds them). Both Dockerfiles now pin `pnpm@10`, matching the version CI uses.
-
-
## [1.3.2] - 2026-08-22
### Added
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 063f22657..072f9a88d 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -42,6 +42,14 @@ For larger changes (new storage engine methods, schema changes, changes to the i
Keep each pull request focused on one problem: no unrelated refactors, dependency upgrades or formatting churn in the same PR.
+#### Contributor License Agreement
+
+Before your first pull request can be merged you need to sign the Contributor License Agreement (CLA). A bot posts a comment on your first PR with a link; read the agreement text it shows, then sign by commenting as the bot asks. You sign once, for all your future contributions. You can also sign ahead of time at .
+
+The bot recognises you through the email address on your commits, so **commit with an email address that is linked to your GitHub account** (check it under GitHub Settings, Emails). Commits made with an unlinked email cannot be matched to your account and the CLA check will stay pending.
+
+Contributions to the core are licensed under the AGPLv3 like the rest of the code. LogTide is open core, and the CLA also allows the maintainer to use contributions in commercial editions. See the agreement text shown by the bot for the exact terms.
+
## Development Setup
### Prerequisites
diff --git a/README.md b/README.md
index 4b4100eac..ba67072e0 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
- ☁️ Try Cloud (Free Beta) •
+ ☁️ Try Cloud •
Self-Host •
SDKs •
Docs
@@ -16,14 +16,14 @@
-
+
-
+
-> **🌊 LogTide 1.3.2 (public beta):** unified **Logs, Traces & Metrics** with a built-in **SIEM**, multi-engine storage (TimescaleDB / ClickHouse / MongoDB), uptime monitoring, parsing pipelines, and custom dashboards.
+> **🌊 LogTide 1.4.0:** unified **Logs, Traces & Metrics** with a built-in **SIEM**, multi-engine storage (TimescaleDB / ClickHouse / MongoDB), uptime monitoring, parsing pipelines, and custom dashboards.
---
@@ -128,7 +128,7 @@ We host it for you. Perfect for testing. [**Sign up at logtide.dev**](https://lo
---
-## ✨ Core Features (v1.3.2)
+## ✨ Core Features (v1.4.0)
### Monitoring, Pipelines & Dashboards
* 🩺 **Uptime Monitoring & Status Pages:** HTTP/TCP/heartbeat monitors with configurable thresholds, auto-created SIEM incidents on failure, scheduled maintenances, and public Uptime-Kuma-style status pages per project.
@@ -182,6 +182,8 @@ Ready-to-use SDKs with auto-instrumentation and distributed tracing.
Distributed under the **GNU AGPLv3** License. See `LICENSE` for more information.
+Contributions require signing the [Contributor License Agreement](https://cla-assistant.io/logtide-dev/logtide); see `CONTRIBUTING.md`.
+
---
diff --git a/docker/.env.example b/docker/.env.example
index adffb4ee0..97e1980cf 100644
--- a/docker/.env.example
+++ b/docker/.env.example
@@ -102,3 +102,52 @@ DB_USER=logtide
# docker compose -f docker-compose.yml -f docker-compose.traefik.yml up -d --scale backend=3
#
# See docs: https://logtide.dev/docs/deployment#horizontal-scaling
+
+# =============================================================================
+# External billing handoff (optional, for hosted deployments)
+# =============================================================================
+# When BOTH are set, owners/admins see Upgrade / Manage billing buttons on the
+# usage page. Clicking one redirects to LOGTIDE_UPGRADE_URL with a short-lived
+# (10 minute) HMAC-SHA256 signed token (?t=...). The token never contains
+# LogTide session credentials. Setting only one of the two aborts startup.
+# URL must be https (http is allowed only for localhost / 127.0.0.1).
+# LOGTIDE_UPGRADE_URL=https://billing.example.com/portal
+# Min 32 characters. Generate with: openssl rand -base64 48
+# LOGTIDE_UPGRADE_SIGNING_SECRET=
+
+# =============================================================================
+# Hosting seams (optional, for operators hosting LogTide for others)
+# =============================================================================
+# Full reference: docs/cloud-seams.md. Everything below is off when unset.
+# (LOGTIDE_UPGRADE_URL and LOGTIDE_UPGRADE_SIGNING_SECRET are in the section above.)
+
+# JSON object overriding capability defaults for every org (entitlement rows
+# still win). Values: boolean, integer >= 0, or null for unlimited.
+# Invalid JSON, unknown capability or wrong type aborts startup.
+# CAPABILITY_DEFAULTS_JSON={"projects.max": 3, "quota.grace_percent": 10}
+
+# Static bearer (min 32 characters) letting an external billing service call the
+# org entitlements and retention admin endpoints. Generate: openssl rand -base64 48
+# LOGTIDE_ADMIN_SERVICE_TOKEN=
+
+# Max organizations one user may own (positive integer, unset = unlimited).
+# MAX_ORGANIZATIONS_PER_USER=3
+
+# Max organizations one user may create per UTC calendar month, even if they
+# delete them again (positive integer, unset = unlimited).
+# MAX_ORGANIZATION_CREATIONS_PER_USER_PER_MONTH=2
+
+# Make new local users confirm their email before creating projects or API keys.
+# Requires SMTP_HOST, otherwise startup fails.
+# REQUIRE_EMAIL_VERIFICATION=false
+
+# Extra per-IP cap on POST /auth/register per hour (positive integer, unset = off).
+# REGISTRATION_RATE_LIMIT_PER_HOUR=10
+
+# Require Terms of Service and Privacy Policy acceptance at registration (and
+# again whenever LOGTIDE_TERMS_VERSION changes). Set all three or none: a
+# partial set aborts startup. URLs must be https (http only for localhost).
+# Ingestion with existing API keys is never blocked.
+# LOGTIDE_TERMS_URL=https://example.com/terms
+# LOGTIDE_PRIVACY_URL=https://example.com/privacy
+# LOGTIDE_TERMS_VERSION=2026-10-19
diff --git a/docker/docker-compose.build.yml b/docker/docker-compose.build.yml
index d4a157c83..18749be5f 100644
--- a/docker/docker-compose.build.yml
+++ b/docker/docker-compose.build.yml
@@ -106,6 +106,18 @@ services:
INTERNAL_DSN: ${INTERNAL_DSN:-}
DOCKER_CONTAINER: "true"
SERVICE_NAME: logtide-backend
+ # Hosting seams: empty = inert (see docs/cloud-seams.md)
+ CAPABILITY_DEFAULTS_JSON: ${CAPABILITY_DEFAULTS_JSON:-}
+ LOGTIDE_ADMIN_SERVICE_TOKEN: ${LOGTIDE_ADMIN_SERVICE_TOKEN:-}
+ LOGTIDE_UPGRADE_URL: ${LOGTIDE_UPGRADE_URL:-}
+ LOGTIDE_UPGRADE_SIGNING_SECRET: ${LOGTIDE_UPGRADE_SIGNING_SECRET:-}
+ MAX_ORGANIZATIONS_PER_USER: ${MAX_ORGANIZATIONS_PER_USER:-}
+ MAX_ORGANIZATION_CREATIONS_PER_USER_PER_MONTH: ${MAX_ORGANIZATION_CREATIONS_PER_USER_PER_MONTH:-}
+ REQUIRE_EMAIL_VERIFICATION: ${REQUIRE_EMAIL_VERIFICATION:-}
+ REGISTRATION_RATE_LIMIT_PER_HOUR: ${REGISTRATION_RATE_LIMIT_PER_HOUR:-}
+ LOGTIDE_TERMS_URL: ${LOGTIDE_TERMS_URL:-}
+ LOGTIDE_PRIVACY_URL: ${LOGTIDE_PRIVACY_URL:-}
+ LOGTIDE_TERMS_VERSION: ${LOGTIDE_TERMS_VERSION:-}
STORAGE_ENGINE: ${STORAGE_ENGINE:-timescale}
CLICKHOUSE_HOST: ${CLICKHOUSE_HOST:-clickhouse}
CLICKHOUSE_PORT: ${CLICKHOUSE_PORT:-8123}
@@ -162,6 +174,18 @@ services:
INTERNAL_DSN: ${INTERNAL_DSN:-}
DOCKER_CONTAINER: "true"
SERVICE_NAME: logtide-worker
+ # Hosting seams: empty = inert (see docs/cloud-seams.md)
+ CAPABILITY_DEFAULTS_JSON: ${CAPABILITY_DEFAULTS_JSON:-}
+ LOGTIDE_ADMIN_SERVICE_TOKEN: ${LOGTIDE_ADMIN_SERVICE_TOKEN:-}
+ LOGTIDE_UPGRADE_URL: ${LOGTIDE_UPGRADE_URL:-}
+ LOGTIDE_UPGRADE_SIGNING_SECRET: ${LOGTIDE_UPGRADE_SIGNING_SECRET:-}
+ MAX_ORGANIZATIONS_PER_USER: ${MAX_ORGANIZATIONS_PER_USER:-}
+ MAX_ORGANIZATION_CREATIONS_PER_USER_PER_MONTH: ${MAX_ORGANIZATION_CREATIONS_PER_USER_PER_MONTH:-}
+ REQUIRE_EMAIL_VERIFICATION: ${REQUIRE_EMAIL_VERIFICATION:-}
+ REGISTRATION_RATE_LIMIT_PER_HOUR: ${REGISTRATION_RATE_LIMIT_PER_HOUR:-}
+ LOGTIDE_TERMS_URL: ${LOGTIDE_TERMS_URL:-}
+ LOGTIDE_PRIVACY_URL: ${LOGTIDE_PRIVACY_URL:-}
+ LOGTIDE_TERMS_VERSION: ${LOGTIDE_TERMS_VERSION:-}
STORAGE_ENGINE: ${STORAGE_ENGINE:-timescale}
CLICKHOUSE_HOST: ${CLICKHOUSE_HOST:-clickhouse}
CLICKHOUSE_PORT: ${CLICKHOUSE_PORT:-8123}
diff --git a/docker/docker-compose.simple.yml b/docker/docker-compose.simple.yml
index 4bb27c939..b8f255032 100644
--- a/docker/docker-compose.simple.yml
+++ b/docker/docker-compose.simple.yml
@@ -94,6 +94,18 @@ services:
INTERNAL_DSN: ${INTERNAL_DSN:-}
DOCKER_CONTAINER: "true"
SERVICE_NAME: logtide-backend
+ # Hosting seams: empty = inert (see docs/cloud-seams.md)
+ CAPABILITY_DEFAULTS_JSON: ${CAPABILITY_DEFAULTS_JSON:-}
+ LOGTIDE_ADMIN_SERVICE_TOKEN: ${LOGTIDE_ADMIN_SERVICE_TOKEN:-}
+ LOGTIDE_UPGRADE_URL: ${LOGTIDE_UPGRADE_URL:-}
+ LOGTIDE_UPGRADE_SIGNING_SECRET: ${LOGTIDE_UPGRADE_SIGNING_SECRET:-}
+ MAX_ORGANIZATIONS_PER_USER: ${MAX_ORGANIZATIONS_PER_USER:-}
+ MAX_ORGANIZATION_CREATIONS_PER_USER_PER_MONTH: ${MAX_ORGANIZATION_CREATIONS_PER_USER_PER_MONTH:-}
+ REQUIRE_EMAIL_VERIFICATION: ${REQUIRE_EMAIL_VERIFICATION:-}
+ REGISTRATION_RATE_LIMIT_PER_HOUR: ${REGISTRATION_RATE_LIMIT_PER_HOUR:-}
+ LOGTIDE_TERMS_URL: ${LOGTIDE_TERMS_URL:-}
+ LOGTIDE_PRIVACY_URL: ${LOGTIDE_PRIVACY_URL:-}
+ LOGTIDE_TERMS_VERSION: ${LOGTIDE_TERMS_VERSION:-}
STORAGE_ENGINE: timescale
depends_on:
postgres:
@@ -135,6 +147,18 @@ services:
INTERNAL_DSN: ${INTERNAL_DSN:-}
DOCKER_CONTAINER: "true"
SERVICE_NAME: logtide-worker
+ # Hosting seams: empty = inert (see docs/cloud-seams.md)
+ CAPABILITY_DEFAULTS_JSON: ${CAPABILITY_DEFAULTS_JSON:-}
+ LOGTIDE_ADMIN_SERVICE_TOKEN: ${LOGTIDE_ADMIN_SERVICE_TOKEN:-}
+ LOGTIDE_UPGRADE_URL: ${LOGTIDE_UPGRADE_URL:-}
+ LOGTIDE_UPGRADE_SIGNING_SECRET: ${LOGTIDE_UPGRADE_SIGNING_SECRET:-}
+ MAX_ORGANIZATIONS_PER_USER: ${MAX_ORGANIZATIONS_PER_USER:-}
+ MAX_ORGANIZATION_CREATIONS_PER_USER_PER_MONTH: ${MAX_ORGANIZATION_CREATIONS_PER_USER_PER_MONTH:-}
+ REQUIRE_EMAIL_VERIFICATION: ${REQUIRE_EMAIL_VERIFICATION:-}
+ REGISTRATION_RATE_LIMIT_PER_HOUR: ${REGISTRATION_RATE_LIMIT_PER_HOUR:-}
+ LOGTIDE_TERMS_URL: ${LOGTIDE_TERMS_URL:-}
+ LOGTIDE_PRIVACY_URL: ${LOGTIDE_PRIVACY_URL:-}
+ LOGTIDE_TERMS_VERSION: ${LOGTIDE_TERMS_VERSION:-}
STORAGE_ENGINE: timescale
depends_on:
backend:
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index 2995da4dc..4e55fd618 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -121,6 +121,18 @@ services:
INTERNAL_DSN: ${INTERNAL_DSN:-}
DOCKER_CONTAINER: "true"
SERVICE_NAME: logtide-backend
+ # Hosting seams: empty = inert (see docs/cloud-seams.md)
+ CAPABILITY_DEFAULTS_JSON: ${CAPABILITY_DEFAULTS_JSON:-}
+ LOGTIDE_ADMIN_SERVICE_TOKEN: ${LOGTIDE_ADMIN_SERVICE_TOKEN:-}
+ LOGTIDE_UPGRADE_URL: ${LOGTIDE_UPGRADE_URL:-}
+ LOGTIDE_UPGRADE_SIGNING_SECRET: ${LOGTIDE_UPGRADE_SIGNING_SECRET:-}
+ MAX_ORGANIZATIONS_PER_USER: ${MAX_ORGANIZATIONS_PER_USER:-}
+ MAX_ORGANIZATION_CREATIONS_PER_USER_PER_MONTH: ${MAX_ORGANIZATION_CREATIONS_PER_USER_PER_MONTH:-}
+ REQUIRE_EMAIL_VERIFICATION: ${REQUIRE_EMAIL_VERIFICATION:-}
+ REGISTRATION_RATE_LIMIT_PER_HOUR: ${REGISTRATION_RATE_LIMIT_PER_HOUR:-}
+ LOGTIDE_TERMS_URL: ${LOGTIDE_TERMS_URL:-}
+ LOGTIDE_PRIVACY_URL: ${LOGTIDE_PRIVACY_URL:-}
+ LOGTIDE_TERMS_VERSION: ${LOGTIDE_TERMS_VERSION:-}
STORAGE_ENGINE: ${STORAGE_ENGINE:-timescale}
CLICKHOUSE_HOST: ${CLICKHOUSE_HOST:-clickhouse}
CLICKHOUSE_PORT: ${CLICKHOUSE_PORT:-8123}
@@ -175,6 +187,18 @@ services:
INTERNAL_DSN: ${INTERNAL_DSN:-}
DOCKER_CONTAINER: "true"
SERVICE_NAME: logtide-worker
+ # Hosting seams: empty = inert (see docs/cloud-seams.md)
+ CAPABILITY_DEFAULTS_JSON: ${CAPABILITY_DEFAULTS_JSON:-}
+ LOGTIDE_ADMIN_SERVICE_TOKEN: ${LOGTIDE_ADMIN_SERVICE_TOKEN:-}
+ LOGTIDE_UPGRADE_URL: ${LOGTIDE_UPGRADE_URL:-}
+ LOGTIDE_UPGRADE_SIGNING_SECRET: ${LOGTIDE_UPGRADE_SIGNING_SECRET:-}
+ MAX_ORGANIZATIONS_PER_USER: ${MAX_ORGANIZATIONS_PER_USER:-}
+ MAX_ORGANIZATION_CREATIONS_PER_USER_PER_MONTH: ${MAX_ORGANIZATION_CREATIONS_PER_USER_PER_MONTH:-}
+ REQUIRE_EMAIL_VERIFICATION: ${REQUIRE_EMAIL_VERIFICATION:-}
+ REGISTRATION_RATE_LIMIT_PER_HOUR: ${REGISTRATION_RATE_LIMIT_PER_HOUR:-}
+ LOGTIDE_TERMS_URL: ${LOGTIDE_TERMS_URL:-}
+ LOGTIDE_PRIVACY_URL: ${LOGTIDE_PRIVACY_URL:-}
+ LOGTIDE_TERMS_VERSION: ${LOGTIDE_TERMS_VERSION:-}
STORAGE_ENGINE: ${STORAGE_ENGINE:-timescale}
CLICKHOUSE_HOST: ${CLICKHOUSE_HOST:-clickhouse}
CLICKHOUSE_PORT: ${CLICKHOUSE_PORT:-8123}
diff --git a/docs/cloud-seams.md b/docs/cloud-seams.md
new file mode 100644
index 000000000..d3ee040fb
--- /dev/null
+++ b/docs/cloud-seams.md
@@ -0,0 +1,200 @@
+# Hosting LogTide for others
+
+LogTide is self-hosted by default: with none of the variables below set, nothing changes. Every limit is unlimited, nobody has to verify an email, no upgrade buttons are shown, no service token is accepted and none of the optional routes exist.
+
+This page is for operators who run LogTide on behalf of other people (a managed or multi-customer deployment) and need to cap usage, gate sign-ups and connect an external billing portal. Everything is driven by environment variables; an invalid value aborts startup with a clear message instead of being ignored.
+
+## Environment variables
+
+| Variable | Default | Purpose |
+|---|---|---|
+| `CAPABILITY_DEFAULTS_JSON` | unset | Override the default value of any capability for all organizations |
+| `LOGTIDE_ADMIN_SERVICE_TOKEN` | unset | Static bearer for a billing service on the entitlements and retention admin routes |
+| `LOGTIDE_UPGRADE_URL` | unset | External billing portal the Upgrade / Manage billing buttons redirect to |
+| `LOGTIDE_UPGRADE_SIGNING_SECRET` | unset | HMAC secret used to sign the handoff token |
+| `MAX_ORGANIZATIONS_PER_USER` | unset (unlimited) | Maximum organizations one user may own |
+| `MAX_ORGANIZATION_CREATIONS_PER_USER_PER_MONTH` | unset (unlimited) | Maximum organizations one user may create per UTC calendar month |
+| `REQUIRE_EMAIL_VERIFICATION` | `false` | Require new local users to confirm their email |
+| `REGISTRATION_RATE_LIMIT_PER_HOUR` | unset (off) | Extra per-IP cap on registrations over one hour |
+| `LOGTIDE_TERMS_URL` | unset | Terms of Service page users must accept |
+| `LOGTIDE_PRIVACY_URL` | unset | Privacy Policy page users must accept |
+| `LOGTIDE_TERMS_VERSION` | unset | Current terms version; changing it asks every user to accept again |
+
+An empty string is treated the same as unset for all of them. The bundled `docker/docker-compose*.yml` files pass all eleven through to both the `backend` and the `worker` containers (as empty strings when unset), so setting them in your `.env` is enough.
+
+### CAPABILITY_DEFAULTS_JSON
+
+A JSON object mapping capability names to values:
+
+```
+CAPABILITY_DEFAULTS_JSON={"projects.max": 3, "ingestion.max_bytes_monthly": 1073741824, "quota.grace_percent": 10}
+```
+
+- Boolean capabilities take `true` or `false`.
+- Limit and quota capabilities take an integer `>= 0`, or `null` for unlimited.
+- Unknown capability names, wrong types or malformed JSON abort startup. The whole document is validated before anything is applied.
+- Resolution order is unchanged: a per-organization entitlement row always wins over the default. The defaults apply to every organization that has no row for that capability.
+- Quota evaluation: if any quota default is finite, the quota evaluator checks every organization (not only organizations that have entitlement rows), so a default quota actually blocks ingestion.
+
+### LOGTIDE_ADMIN_SERVICE_TOKEN
+
+A static secret (at least 32 characters; shorter aborts startup) that lets an external service call a small set of admin endpoints without a user session:
+
+- `GET /api/v1/admin/organizations/:id/entitlements`
+- `PUT /api/v1/admin/organizations/:id/entitlements`
+- `GET /api/v1/admin/organizations/:id/retention`
+- `PUT /api/v1/admin/organizations/:id/retention`
+
+Send it as `Authorization: Bearer `. Details:
+
+- The token is compared in constant time and is honored only on the four routes above. On every other route it is treated like any invalid session token. Other admin routes, such as `POST /api/v1/admin/retention/execute`, still require a real admin user.
+- Writes through the token are audited with `actor: service:admin-service` in the entry metadata.
+- Unset means the feature is inert.
+
+Entitlement body for `PUT`:
+
+```json
+{ "entitlements": [ { "capability": "projects.max", "limitValue": 5 } ] }
+```
+
+`limitValue` is an integer `>= 0` or `null`; boolean capabilities use `enabled`. Unknown capabilities are rejected with `400`. Limits are stored as `BIGINT`, so byte quotas of 2 GiB and more work; the API accepts values up to `Number.MAX_SAFE_INTEGER` (2^53 - 1, about 8 PiB) so they always read back exactly.
+
+### Upgrade handoff (LOGTIDE_UPGRADE_URL and LOGTIDE_UPGRADE_SIGNING_SECRET)
+
+These two enable a signed redirect to an external billing portal. They must be set together:
+
+- Only one set aborts startup.
+- `LOGTIDE_UPGRADE_URL` must be an absolute `https` URL (`http` is accepted only for `localhost` and `127.0.0.1`).
+- `LOGTIDE_UPGRADE_SIGNING_SECRET` must be at least 32 characters.
+
+When active:
+
+- `POST /api/v1/organizations/:id/upgrade-handoff` exists (it is not registered at all otherwise). Body: `{ "intent": "upgrade" | "manage" }`. Only owners and admins of the organization may call it (`403` otherwise). It returns `{ "url": "..." }`, which is `LOGTIDE_UPGRADE_URL` with the token in the `t` query parameter (an existing query string is preserved).
+- `GET /api/v1/auth/config` includes `upgradeEnabled: true`, and the Usage page shows Upgrade / Manage billing buttons to owners and admins.
+- Capability (`403`) and quota (`429`) denials include `upgrade_available: true` in the body. This covers the OTLP endpoints too: the `429` bodies of `POST /v1/otlp/logs`, `/v1/otlp/traces` and `/v1/otlp/metrics` carry `upgrade_available: true` next to `partialSuccess`.
+
+The token never carries any LogTide session credential. It proves who the user is, their role and the organization.
+
+#### Token format
+
+```
+token = base64url(JSON payload) + "." + base64url(HMAC-SHA256(secret, base64url(JSON payload)))
+```
+
+The HMAC is computed over the base64url string of the payload (the part before the dot), using the signing secret as the key. base64url has no padding.
+
+Payload fields:
+
+| Field | Type | Meaning |
+|---|---|---|
+| `v` | `1` | Token version |
+| `org` | string | Organization id (UUID) |
+| `user` | string | User id (UUID) |
+| `email` | string | User email |
+| `role` | string | The user's role in the organization (`owner` or `admin`) |
+| `intent` | `"upgrade"` or `"manage"` | Which button was clicked |
+| `exp` | number | Expiry, unix seconds. Tokens live 10 minutes |
+| `nonce` | string | Random value, unique per token |
+
+A verifier must: split on the single `.`, recompute the HMAC and compare in constant time, parse the payload, check `v === 1`, check all fields, and reject if `exp` is not in the future. The portal should also remember used `nonce` values until `exp` if it wants single-use semantics. The token is passed in a URL, so treat it as short-lived and do not log it.
+
+A complete reference verifier using only Node's `crypto` is in [`docs/examples/verify-handoff.ts`](examples/verify-handoff.ts). Type-check it with `npx tsc -p docs/examples/tsconfig.json`.
+
+### MAX_ORGANIZATIONS_PER_USER
+
+Positive integer; unset = unlimited; anything else aborts startup. It counts organizations where the user is `owner`; being a member or admin of someone else's organization never counts. At the cap, `POST /api/v1/organizations` answers `403 { "error": "organization_limit_reached", "message": ... }`. That endpoint is the only path that creates a user-owned organization (registration, invitations and SSO login do not). Operator scripts such as seeding are not capped.
+
+### MAX_ORGANIZATION_CREATIONS_PER_USER_PER_MONTH
+
+Positive integer; unset = unlimited; anything else aborts startup. Monthly quotas are per organization and `MAX_ORGANIZATIONS_PER_USER` only counts organizations the user owns right now, so without this cap a user at the limit could delete an organization (a hard delete) and create a fresh one with zero month-to-date usage, resetting the free quota as often as they like. This cap counts creations instead of ownership: every `POST /api/v1/organizations` appends a row to `organization_creation_log` (always, even when the variable is unset, so turning the cap on later already has history), and the row survives the organization being deleted. When the number of rows for the user since the start of the current UTC calendar month (the same boundary the monthly quotas use) reaches the limit, the endpoint answers `403 { "error": "organization_creation_limit_reached", "message": ... }`. `MAX_ORGANIZATIONS_PER_USER` is checked first. The count and the insert run under a per-user lock, so parallel requests cannot overshoot either cap. There is no backfill: only creations made after migration 060 count.
+
+### REQUIRE_EMAIL_VERIFICATION
+
+`true` or `false` (default `false`); any other value aborts startup. **`true` requires `SMTP_HOST`**, otherwise startup fails, because no verification email could be sent.
+
+When `true`:
+
+- Newly registered local users are still signed in immediately and receive an email with a link to `/verify-email?token=...`. The token is 32 random bytes, stored only as a SHA-256 hash, single use, valid 24 hours.
+- Until verified, `POST /api/v1/projects`, `POST /api/v1/projects/:id/api-keys` and `POST /api/v1/projects/:id/receivers` (an inbound receiver mints an ingest credential too) answer `403 { "error": "email_not_verified" }`. This check runs before the `projects.max` limit check.
+- New routes: `POST /api/v1/auth/verify-email` (`400 invalid_or_expired_token` for unknown, reused, expired or someone else's token) and `POST /api/v1/auth/resend-verification` (always `202`, so it does not reveal whether an address exists; limited to 5 per hour per IP and a 60 second per-user cooldown).
+- `GET /api/v1/auth/config` includes `emailVerificationRequired: true`; `GET /api/v1/auth/me` includes `user.emailVerified`. The app shows a "Verify your email" banner.
+- OIDC users count as verified when the identity provider asserts `email_verified: true` for the current address. Admin-created, LDAP and bootstrap users are verified. Changing the email address requires verifying again.
+- Existing users are backfilled as verified, so enabling the flag never locks anyone out.
+
+When `false`, the verification routes are not registered and every user counts as verified.
+
+### REGISTRATION_RATE_LIMIT_PER_HOUR
+
+Positive integer; unset = off; anything else aborts startup. Caps `POST /api/v1/auth/register` per client IP over a one-hour window, on top of `AUTH_RATE_LIMIT_REGISTER`. It is keyed by IP only, so a forged `Authorization` header cannot rotate the counter. Over the cap the route answers `429` with a `Retry-After` header. It needs the rate-limit plugin; if that is not registered a warning is logged and the cap is not applied. Behind a reverse proxy set `TRUST_PROXY=true` so client IPs are resolved correctly, otherwise every request shares one IP (see Operator notes).
+
+### Terms acceptance (LOGTIDE_TERMS_URL, LOGTIDE_PRIVACY_URL and LOGTIDE_TERMS_VERSION)
+
+Active only when all three are set. Setting one or two aborts startup with a message naming the missing ones; so does a URL that is not an absolute https URL (plain http is allowed only for `localhost` and `127.0.0.1`) or a version that is empty, only spaces or longer than 64 characters. A date such as `2026-10-19` works well as the version. Unset or empty: the feature is fully inert (no checkbox, no modal, no new route, no extra keys, columns stay NULL).
+
+When active:
+
+- `GET /api/v1/auth/config` includes `termsUrl`, `privacyUrl` and `termsVersion`. The registration form then shows a required "I agree to the Terms of Service and the Privacy Policy" checkbox (both links open in a new tab) and the submit button stays disabled until it is checked.
+- `POST /api/v1/auth/register` must carry `acceptTerms: true`, otherwise it answers `400 { "error": "terms_not_accepted" }` and no user is created. The accepted version and time are stored with the new user (`users.terms_version`, `users.terms_accepted_at`, migration 061).
+- A user whose stored version differs from `LOGTIDE_TERMS_VERSION` (NULL included) is "terms pending". This covers users created before the feature was turned on (there is no backfill), OIDC/LDAP users who never saw the checkbox, and everyone after a version bump. `GET /api/v1/auth/me` then includes `user.termsAcceptanceRequired: true` and the app shows a blocking modal on the dashboard and onboarding pages with both links, an Accept button and a Log out option.
+- `POST /api/v1/auth/accept-terms` (session required, rate limited like login) with body `{ "version": "" }` records the acceptance. Any other version answers `409 { "error": "terms_version_mismatch" }`, so a tab left open across a version bump cannot accept terms the user did not see. Each acceptance is audited as `user.terms_accepted`.
+- While pending, `POST /api/v1/organizations`, `POST /api/v1/projects`, `POST /api/v1/projects/:id/api-keys` and `POST /api/v1/projects/:id/receivers` answer `403 { "error": "terms_not_accepted" }` (checked before the organization caps and the `projects.max` limit). Everything else keeps working: **ingestion with existing API keys is never blocked**, so bumping the version never breaks a running pipeline.
+
+To publish new terms, update the pages and change `LOGTIDE_TERMS_VERSION`, then restart the backend: every user is asked again on their next visit. A page left open shows the modal as soon as one of its requests is refused with `terms_not_accepted`, without a reload.
+
+**Not compatible with auth-free mode.** In auth-free mode the default user has no session and could never accept, so the backend refuses to start when the terms are configured and the stored auth mode is `none`, and the admin settings API refuses to switch `auth.mode` to `none` while they are configured (`400 { "error": "terms_incompatible_with_auth_free_mode" }`).
+
+## Capabilities added for hosting
+
+All of them default to unlimited and can be set per organization through entitlements, through the admin API, or globally through `CAPABILITY_DEFAULTS_JSON`.
+
+| Capability | Kind | Meaning |
+|---|---|---|
+| `projects.max` | limit | Maximum number of active projects (soft-deleted ones do not count). Enforced on `POST /api/v1/projects` and on restoring a soft-deleted project (`POST /api/v1/projects/:id/restore`); both answer `403` at the limit |
+| `quota.grace_percent` | limit | Overage allowed above every quota of the organization before hard-blocking. `null` or `0` means no grace |
+| `metrics.max_datapoints_monthly` | quota | Month-to-date ingested OTLP metric datapoints. Over the limit `POST /v1/otlp/metrics` answers `429` while logs and spans keep flowing |
+| `telemetry.max_bytes_monthly` | quota | Month-to-date ingested bytes across logs, traces and metrics combined. Over the limit log ingestion (`POST /api/v1/ingest` and every other log path, including `POST /v1/otlp/logs`), `POST /v1/otlp/traces` and `POST /v1/otlp/metrics` all answer `429` |
+
+Metric datapoints are now metered as the `metrics.ingested.datapoints` usage type (only datapoints that were actually stored). Like spans, this follows `METERING_ENABLED`.
+
+### Combined telemetry quota (recommended for hosted plans)
+
+`telemetry.max_bytes_monthly` is the recommended single quota for a hosted plan ("N GB per month of logs, traces and metrics"). Its usage is the sum of three metering types, all measured the same way, as `Buffer.byteLength(JSON.stringify(records))` of the accepted records, counting customer-provided data only:
+
+| Signal | Metering type | Records measured |
+|---|---|---|
+| Logs | `logs.ingested.bytes` | The log batch of the request, after PII-masking rejections (unchanged, also used by `ingestion.max_bytes_monthly`) |
+| Traces | `spans.ingested.bytes` | The spans handed to storage, after PII masking dropped the spans it could not mask, without the server-injected `organizationId`, `projectId` and `time` (a copy of `startTime`) fields |
+| Metrics | `metrics.ingested.bytes` | The datapoints handed to storage, without the server-injected `organizationId` and `projectId` fields |
+
+The excluded fields are left out of a measured copy only; what is stored is unchanged. Nothing is counted when storage accepted no record; if a store reports only part of a batch as stored, the batch size is prorated by the accepted count. Measurement happens before storage and does not depend on the storage engine (TimescaleDB, ClickHouse and MongoDB measure identically). These are billed types (no `ingestion.` prefix): they show in usage data and follow `METERING_ENABLED`.
+
+The per-signal quotas keep working and can be combined with it; an organization is blocked by whichever applies first. A typical hosted setup sets `telemetry.max_bytes_monthly` per plan and keeps `metrics.max_datapoints_monthly` as a high safety cap against cardinality abuse. When `telemetry.max_bytes_monthly` has a limit, the Usage page hides the quotas left unlimited, so only the plan's meters show; otherwise (including a self-hosted instance that caps some other quota) every row shows as before. The "Log bytes ingested" summary card on that page counts log bytes only.
+
+## Quota behavior
+
+Quotas (`ingestion.max_bytes_monthly`, spans, metric datapoints and so on) are consumption caps that reset on the first day of each calendar month, UTC.
+
+- A background evaluator (`QUOTA_EVALUATOR_INTERVAL_MS`, default 60 seconds, disable with `QUOTA_EVALUATOR_ENABLED=false`) recomputes usage and decides the state, so blocking can lag by up to one interval.
+- A quota is `ok` below its limit, in `grace` from the limit up to `limit * (1 + grace_percent / 100)`, and `blocked` from that ceiling on. With no grace the first state past the limit is `blocked`. Ingestion is rejected only when `blocked`; blocked requests answer `429`.
+- The first time a quota enters `grace` or becomes `blocked` within a UTC month, organization owners and admins get an in-app notification and an email (the email needs SMTP; a failed email never breaks the evaluator). Each organization, capability, state and limit value is notified at most once per month: raising the limit mid-month (an upgrade) re-arms the notice at the new ceiling. With several backend replicas the check is atomic (a Postgres advisory lock), so one notice and one email go out, not one per replica.
+- The Usage page shows "In grace" and "Blocked" badges. The capability usage API returns a `state` for quota rows.
+
+## The internal logging organization
+
+Unless `INTERNAL_LOGGING_ENABLED=false` (or an `INTERNAL_API_KEY` is supplied), LogTide creates a `logtide-internal` organization (with a project and an API key) for its own self-monitoring logs. It is an ordinary organization: every finite default from `CAPABILITY_DEFAULTS_JSON` applies to it, so a small default quota can block LogTide's own logs and a `projects.max` default counts its project. Give it explicit unlimited (`null`) entitlement rows for every capability your defaults restrict, for example with `PUT /api/v1/admin/organizations//entitlements`:
+
+```json
+{ "entitlements": [
+ { "capability": "ingestion.max_bytes_monthly", "limitValue": null },
+ { "capability": "ingestion.max_events_monthly", "limitValue": null },
+ { "capability": "storage.max_bytes", "limitValue": null }
+] }
+```
+
+## Operator notes
+
+- **Reverse proxy**: `REGISTRATION_RATE_LIMIT_PER_HOUR` (like every IP-keyed limit) needs `TRUST_PROXY=true` when LogTide runs behind a proxy or load balancer. Without it every request appears to come from the proxy and all visitors share one counter.
+- **Service token rate limit**: the four service-token routes keep the admin rate limit of 100 requests per minute. The limiter key is derived from the credential (a hash, never the raw value), so every call made with the service token shares one counter; send several entitlement changes in one `PUT` instead of one request per capability.
+- **Metering**: quotas are evaluated against metered usage. With `METERING_ENABLED=false` nothing is metered, so quotas see no usage and never block. Static limits such as `projects.max` still apply.
+- **Capability names are part of the contract**: `CAPABILITY_DEFAULTS_JSON` rejects unknown names at startup. If a future release renames a capability, a JSON value that still uses the old name aborts startup; check the release notes before upgrading.
+- **Evaluation lag**: quota state is recomputed every `QUOTA_EVALUATOR_INTERVAL_MS` (default 60 seconds), so ingestion can run past a quota for up to one interval before it is blocked, and a raised limit unblocks it within one interval.
diff --git a/docs/examples/tsconfig.json b/docs/examples/tsconfig.json
new file mode 100644
index 000000000..61b6458ad
--- /dev/null
+++ b/docs/examples/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ES2022",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "types": ["node"],
+ "typeRoots": ["../../packages/backend/node_modules/@types"]
+ },
+ "include": ["*.ts"]
+}
diff --git a/docs/examples/verify-handoff.ts b/docs/examples/verify-handoff.ts
new file mode 100644
index 000000000..5a56f4f0d
--- /dev/null
+++ b/docs/examples/verify-handoff.ts
@@ -0,0 +1,57 @@
+/**
+ * Reference verifier for the LogTide upgrade handoff token.
+ *
+ * Token format: base64url(JSON payload) + "." + base64url(HMAC-SHA256(secret, base64url payload)).
+ * The HMAC is computed over the base64url-encoded payload string, not the decoded JSON.
+ * Returns the payload, or null for any malformed, tampered or expired token.
+ *
+ * Only depends on Node's built-in crypto. See docs/cloud-seams.md.
+ */
+import crypto from 'node:crypto';
+
+export type UpgradeIntent = 'upgrade' | 'manage';
+
+export interface UpgradeHandoffPayload {
+ v: 1;
+ org: string;
+ user: string;
+ email: string;
+ role: string;
+ intent: UpgradeIntent;
+ /** Expiry as unix seconds. */
+ exp: number;
+ nonce: string;
+}
+
+function sign(secret: string, encodedPayload: string): Buffer {
+ return crypto.createHmac('sha256', secret).update(encodedPayload).digest();
+}
+
+export function verifyUpgradeToken(
+ token: string,
+ secret: string,
+ nowMs: number = Date.now()
+): UpgradeHandoffPayload | null {
+ if (typeof token !== 'string') return null;
+ const parts = token.split('.');
+ if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
+ const [encoded, mac] = parts;
+
+ const expected = sign(secret, encoded);
+ const given = Buffer.from(mac, 'base64url');
+ if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) return null;
+
+ let payload: UpgradeHandoffPayload;
+ try {
+ payload = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'));
+ } catch {
+ return null;
+ }
+ if (!payload || typeof payload !== 'object' || payload.v !== 1 || typeof payload.exp !== 'number') return null;
+ for (const k of ['org', 'user', 'email', 'role', 'nonce'] as const) {
+ if (typeof payload[k] !== 'string' || payload[k].length === 0) return null;
+ }
+ if (payload.intent !== 'upgrade' && payload.intent !== 'manage') return null;
+ if (payload.exp <= Math.floor(nowMs / 1000)) return null;
+ return payload;
+}
diff --git a/package.json b/package.json
index a103a7b0f..2de142b09 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "logtide",
- "version": "1.3.2",
+ "version": "1.4.0",
"private": true,
"description": "LogTide - Self-hosted log management platform",
"author": "LogTide Team",
diff --git a/packages/backend/migrations/058_email_verification.sql b/packages/backend/migrations/058_email_verification.sql
new file mode 100644
index 000000000..7d907ddee
--- /dev/null
+++ b/packages/backend/migrations/058_email_verification.sql
@@ -0,0 +1,28 @@
+-- ============================================================================
+-- Migration 058: Optional email verification
+-- Adds users.email_verified_at and a single-use token table. Every existing
+-- user is backfilled as verified (email_verified_at = created_at) so turning on
+-- REQUIRE_EMAIL_VERIFICATION never locks anyone out. The column default is
+-- NOW(): every user-creation path (admin, LDAP, bootstrap, local registration
+-- with the flag off) counts as verified unless the code explicitly inserts
+-- NULL (local registration or an unverified OIDC identity with the flag on).
+-- Tokens are stored only as a sha256 hash.
+-- ============================================================================
+
+ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ NULL;
+
+UPDATE users SET email_verified_at = created_at WHERE email_verified_at IS NULL;
+
+ALTER TABLE users ALTER COLUMN email_verified_at SET DEFAULT NOW();
+
+CREATE TABLE IF NOT EXISTS email_verification_tokens (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ token_hash TEXT NOT NULL UNIQUE,
+ expires_at TIMESTAMPTZ NOT NULL,
+ used_at TIMESTAMPTZ NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_user
+ ON email_verification_tokens (user_id, created_at DESC);
diff --git a/packages/backend/migrations/059_entitlement_limit_bigint.sql b/packages/backend/migrations/059_entitlement_limit_bigint.sql
new file mode 100644
index 000000000..ce8911cf5
--- /dev/null
+++ b/packages/backend/migrations/059_entitlement_limit_bigint.sql
@@ -0,0 +1,10 @@
+-- ============================================================================
+-- Migration 059: widen organization_entitlements.limit_value to BIGINT
+-- INTEGER capped every limit at 2^31 - 1, so any byte quota of 2 GiB or more
+-- (e.g. 10 GiB = 10737418240) failed with "integer out of range". BIGINT holds
+-- every value the API accepts; the application keeps limits within
+-- Number.MAX_SAFE_INTEGER and coerces the int8 string the pg driver returns
+-- back to a number when reading the column.
+-- ============================================================================
+
+ALTER TABLE organization_entitlements ALTER COLUMN limit_value TYPE BIGINT;
diff --git a/packages/backend/migrations/060_organization_creation_log.sql b/packages/backend/migrations/060_organization_creation_log.sql
new file mode 100644
index 000000000..daeea8575
--- /dev/null
+++ b/packages/backend/migrations/060_organization_creation_log.sql
@@ -0,0 +1,20 @@
+-- ============================================================================
+-- Migration 060: Organization creation log
+-- Append-only record of every organization a user creates as owner through
+-- POST /api/v1/organizations. Backs MAX_ORGANIZATION_CREATIONS_PER_USER_PER_MONTH,
+-- which stops a user from resetting per-organization monthly quotas by
+-- deleting an organization and creating a fresh one.
+-- organization_id deliberately has NO foreign key: the organization may be
+-- hard-deleted later and the creation must still count.
+-- No backfill: the cap only counts creations made after this migration.
+-- ============================================================================
+
+CREATE TABLE IF NOT EXISTS organization_creation_log (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ organization_id UUID NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_organization_creation_log_user
+ ON organization_creation_log (user_id, created_at);
diff --git a/packages/backend/migrations/061_terms_acceptance.sql b/packages/backend/migrations/061_terms_acceptance.sql
new file mode 100644
index 000000000..542f369a5
--- /dev/null
+++ b/packages/backend/migrations/061_terms_acceptance.sql
@@ -0,0 +1,11 @@
+-- ============================================================================
+-- Migration 061: Terms and Privacy acceptance
+-- Records which version of the Terms of Service / Privacy Policy a user
+-- accepted and when. Used only when LOGTIDE_TERMS_URL, LOGTIDE_PRIVACY_URL and
+-- LOGTIDE_TERMS_VERSION are all set; otherwise both columns stay NULL.
+-- No backfill on purpose: existing users have NULL and are asked once, the
+-- first time they use the app after the feature is turned on.
+-- ============================================================================
+
+ALTER TABLE users ADD COLUMN IF NOT EXISTS terms_accepted_at TIMESTAMPTZ NULL;
+ALTER TABLE users ADD COLUMN IF NOT EXISTS terms_version TEXT NULL;
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 7ea178c7b..8b5c2699a 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -1,6 +1,6 @@
{
"name": "@logtide/backend",
- "version": "1.3.2",
+ "version": "1.4.0",
"private": true,
"description": "LogTide Backend API",
"type": "module",
diff --git a/packages/backend/src/capabilities/month.ts b/packages/backend/src/capabilities/month.ts
new file mode 100644
index 000000000..0866877ea
--- /dev/null
+++ b/packages/backend/src/capabilities/month.ts
@@ -0,0 +1,4 @@
+/** Start of the UTC calendar month containing `now` (monthly quota window and notice dedup). */
+export function startOfUtcMonth(now: Date): Date {
+ return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1, 0, 0, 0, 0));
+}
diff --git a/packages/backend/src/capabilities/quota-evaluator.ts b/packages/backend/src/capabilities/quota-evaluator.ts
index 99c4d6143..e4560177e 100644
--- a/packages/backend/src/capabilities/quota-evaluator.ts
+++ b/packages/backend/src/capabilities/quota-evaluator.ts
@@ -8,17 +8,15 @@ import {
CAPABILITY_NAMES,
type CapabilityName,
} from './registry.js';
+import { computeQuotaState } from './quota-state.js';
+import { notifyQuotaThreshold } from './quota-notifier.js';
import type { MeteringService } from '../modules/metering/index.js';
+import { readQuotaUsage } from './quota-usage.js';
const QUOTA_CAPABILITIES = CAPABILITY_NAMES.filter(
(n) => CAPABILITIES[n].kind === 'quota'
) as CapabilityName[];
-/** Start of the current UTC calendar month. */
-function startOfUtcMonth(now: Date): Date {
- return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1, 0, 0, 0, 0));
-}
-
/**
* Periodic job: the single place that joins a configured quota limit (#214) to
* measured usage (#212). For each org that has at least one non-null quota limit,
@@ -33,10 +31,21 @@ export class QuotaEvaluator {
constructor(private readonly metering: Pick) {}
/**
- * Orgs to evaluate: those with at least one non-null quota entitlement row.
- * Cost scales with restricted orgs, not total orgs.
+ * Orgs to evaluate: those with at least one non-null quota entitlement row, or
+ * every org when any quota default is finite (organizations has no soft delete).
+ * Without quota defaults, cost scales with restricted orgs, not total orgs.
*/
private async restrictedOrgIds(): Promise {
+ // A finite quota default (CAPABILITY_DEFAULTS_JSON) restricts every org that has
+ // no explicit row, so all orgs must be evaluated. Rows with limit null still
+ // resolve to unlimited inside evaluateOrg via the resolver.
+ const hasFiniteQuotaDefault = QUOTA_CAPABILITIES.some(
+ (n) => (CAPABILITIES[n] as { defaultLimit: number | null }).defaultLimit !== null
+ );
+ if (hasFiniteQuotaDefault) {
+ const all = await db.selectFrom('organizations').select('id').execute();
+ return all.map((r) => r.id);
+ }
const rows = await db
.selectFrom('organization_entitlements')
.select('organization_id')
@@ -49,6 +58,8 @@ export class QuotaEvaluator {
private async evaluateOrg(organizationId: string, now: Date): Promise {
const flags: Partial> = {};
+ // Per-org grace allowance applying to every quota (null = 0%).
+ const gracePercent = (await capabilities.getLimit(organizationId, 'quota.grace_percent')) ?? 0;
for (const cap of QUOTA_CAPABILITIES) {
const def = CAPABILITIES[cap];
@@ -61,23 +72,15 @@ export class QuotaEvaluator {
}
try {
- let usage = 0;
- if (def.window === 'point_in_time') {
- // Gauge semantics: read the LATEST snapshot per project (summed across
- // projects), never the historical sum of snapshot events.
- usage = await this.metering.latestPointInTime(organizationId, def.signal);
- } else {
- const rows = await this.metering.aggregate({
- organizationId,
- from: startOfUtcMonth(now),
- to: now,
- groupBy: 'type',
- type: def.signal,
- });
- usage = rows.reduce((sum, r) => sum + r.quantity, 0);
- }
+ // Summed over every signal of the quota. Gauges (point_in_time) read the
+ // LATEST snapshot per project, never the historical sum of snapshot events.
+ const usage = await readQuotaUsage(this.metering, organizationId, def, now);
- flags[cap] = usage >= limit;
+ const state = computeQuotaState(usage, limit, gracePercent);
+ flags[cap] = state === 'blocked';
+ if (state === 'grace' || state === 'blocked') {
+ await notifyQuotaThreshold({ organizationId, capability: cap, state, usage, limit, gracePercent, now });
+ }
} catch (err) {
// Fail-open: leave this capability's flag unset (under quota).
console.warn(
diff --git a/packages/backend/src/capabilities/quota-notifier.ts b/packages/backend/src/capabilities/quota-notifier.ts
new file mode 100644
index 000000000..412bb5caf
--- /dev/null
+++ b/packages/backend/src/capabilities/quota-notifier.ts
@@ -0,0 +1,142 @@
+import { sql } from 'kysely';
+import { db } from '../database/connection.js';
+import { config } from '../config/index.js';
+import { getMailer } from '../lib/mailer.js';
+import { hub } from '@logtide/core';
+import { isInternalLoggingEnabled } from '../utils/internal-logger.js';
+import { CAPABILITIES, quotaSignals, type CapabilityName } from './registry.js';
+import { startOfUtcMonth } from './month.js';
+
+export type QuotaNotifyState = 'grace' | 'blocked';
+
+function log(level: 'info' | 'warn' | 'error', msg: string, meta?: Record): void {
+ if (isInternalLoggingEnabled()) hub.captureLog(level, msg, meta);
+}
+
+export function formatQuotaValue(cap: CapabilityName, value: number): string {
+ const def = CAPABILITIES[cap];
+ // Bytes when every signal of the quota is a byte measure (combined quotas too).
+ const isBytes =
+ def.kind === 'quota' &&
+ quotaSignals(def).every((s) => s.endsWith('.bytes') || s === 'storage.snapshot');
+ if (isBytes) {
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
+ let v = value;
+ let i = 0;
+ while (v >= 1024 && i < units.length - 1) {
+ v /= 1024;
+ i++;
+ }
+ return `${v.toLocaleString('en-US', { maximumFractionDigits: 1 })} ${units[i]}`;
+ }
+ return value.toLocaleString('en-US');
+}
+
+/**
+ * Sends the in-app notification + email for a quota threshold, at most once per
+ * (org, capability, state, limit, UTC month). The limit is part of the key so an
+ * org whose limit is raised mid-month is warned again at the new ceiling. Dedup
+ * reads the notifications already created (no extra table) and is atomic across
+ * backend replicas: the check and the inserts run in one transaction holding a
+ * transaction-scoped advisory lock on that key; a replica that cannot take the
+ * lock skips (another one is sending). Never throws.
+ */
+export async function notifyQuotaThreshold(params: {
+ organizationId: string;
+ capability: CapabilityName;
+ state: QuotaNotifyState;
+ usage: number;
+ limit: number;
+ gracePercent: number;
+ now: Date;
+}): Promise {
+ const { organizationId, capability, state, usage, limit, gracePercent, now } = params;
+ try {
+ const monthStart = startOfUtcMonth(now);
+ const dedupKey = `quota_threshold:${organizationId}:${capability}:${state}:${limit}:${monthStart.toISOString()}`;
+
+ const sent = await db.transaction().execute(async (trx) => {
+ const lock = await sql<{ acquired: boolean }>`SELECT pg_try_advisory_xact_lock(hashtext(${dedupKey})) AS acquired`.execute(trx);
+ if (!lock.rows[0]?.acquired) return null;
+
+ const existing = await trx
+ .selectFrom('notifications')
+ .select('id')
+ .where('organization_id', '=', organizationId)
+ .where('type', '=', 'system')
+ .where(sql`metadata->>'kind'`, '=', 'quota_threshold')
+ .where(sql`metadata->>'capability'`, '=', capability)
+ .where(sql`metadata->>'state'`, '=', state)
+ .where(sql`metadata->>'limit'`, '=', String(limit))
+ .where('created_at', '>=', monthStart)
+ .limit(1)
+ .executeTakeFirst();
+ if (existing) return null;
+
+ const recipients = await trx
+ .selectFrom('organization_members')
+ .innerJoin('users', 'users.id', 'organization_members.user_id')
+ .select(['users.id as id', 'users.email as email'])
+ .where('organization_members.organization_id', '=', organizationId)
+ .where('organization_members.role', 'in', ['owner', 'admin'])
+ .where('users.disabled', '=', false)
+ .execute();
+ if (recipients.length === 0) return null;
+
+ const org = await trx
+ .selectFrom('organizations')
+ .select('name')
+ .where('id', '=', organizationId)
+ .executeTakeFirst();
+
+ const used = formatQuotaValue(capability, usage);
+ const cap = formatQuotaValue(capability, limit);
+ const title =
+ state === 'blocked'
+ ? `Quota reached: ${capability}`
+ : `Quota exceeded, grace period active: ${capability}`;
+ const message =
+ state === 'blocked'
+ ? `${capability} is now blocked: ${used} used of ${cap}${
+ gracePercent > 0 ? ` (including a ${gracePercent.toLocaleString('en-US')}% grace allowance)` : ''
+ }. New data for this quota is rejected until the quota resets or the limit is raised.`
+ : `${capability} passed its limit: ${used} used of ${cap}. You are within a ${gracePercent.toLocaleString(
+ 'en-US'
+ )}% grace allowance; once it is used up, new data for this quota will be rejected.`;
+
+ const metadata = { kind: 'quota_threshold', capability, state, usage, limit };
+ await trx
+ .insertInto('notifications')
+ .values(
+ recipients.map((r) => ({
+ user_id: r.id,
+ type: 'system' as const,
+ title,
+ message,
+ organization_id: organizationId,
+ metadata,
+ }))
+ )
+ .execute();
+
+ return { recipients, title, message, orgName: org?.name };
+ });
+ if (!sent) return;
+
+ // Email only after the notifications committed, outside the transaction.
+ const mailer = getMailer();
+ if (!mailer) return;
+ try {
+ await mailer.sendMail({
+ from: `"LogTide Notifications" <${config.SMTP_FROM}>`,
+ to: sent.recipients.map((r) => r.email).join(', '),
+ subject: `[${sent.orgName ?? 'LogTide'}] ${sent.title}`,
+ text: sent.message,
+ });
+ } catch (err) {
+ log('error', `[QuotaNotifier] email failed for org ${organizationId}: ${(err as Error).message}`);
+ }
+ } catch (err) {
+ log('error', `[QuotaNotifier] failed for org ${organizationId}: ${(err as Error).message}`);
+ }
+}
diff --git a/packages/backend/src/capabilities/quota-state.ts b/packages/backend/src/capabilities/quota-state.ts
new file mode 100644
index 000000000..653f68eb3
--- /dev/null
+++ b/packages/backend/src/capabilities/quota-state.ts
@@ -0,0 +1,20 @@
+export type QuotaState = 'ok' | 'grace' | 'blocked' | 'unlimited';
+
+/**
+ * Pure quota state. Blocked when usage >= limit * (1 + grace/100); in grace when
+ * limit <= usage < that ceiling. graceNull/0 collapses the band, so the first
+ * state past the limit is 'blocked' (current behaviour).
+ */
+export function computeQuotaState(
+ usage: number,
+ limit: number | null,
+ gracePercent: number | null
+): QuotaState {
+ if (limit === null) return 'unlimited';
+ const grace = gracePercent && gracePercent > 0 ? gracePercent : 0;
+ // Cross-multiplied to avoid float error (100 * 1.1 = 110.00000000000001).
+ const blocked = usage * 100 >= limit * (100 + grace);
+ if (blocked) return 'blocked';
+ if (usage >= limit) return 'grace';
+ return 'ok';
+}
diff --git a/packages/backend/src/capabilities/quota-usage.ts b/packages/backend/src/capabilities/quota-usage.ts
new file mode 100644
index 000000000..a8714e40c
--- /dev/null
+++ b/packages/backend/src/capabilities/quota-usage.ts
@@ -0,0 +1,34 @@
+import type { MeteringService } from '../modules/metering/service.js';
+import { quotaSignals, type QuotaCapabilityDef } from './registry.js';
+import { startOfUtcMonth } from './month.js';
+
+/**
+ * Metered usage of one quota: the SUM over every signal of the quota (one for
+ * single-signal quotas, several for combined quotas like telemetry.max_bytes_monthly).
+ * Point-in-time gauges read the latest snapshot per project; monthly quotas sum
+ * the signal over the current UTC calendar month. Shared by the QuotaEvaluator
+ * (enforcement) and the usage API so both always agree.
+ */
+export async function readQuotaUsage(
+ metering: Pick,
+ organizationId: string,
+ def: QuotaCapabilityDef,
+ now: Date
+): Promise {
+ let usage = 0;
+ for (const signal of quotaSignals(def)) {
+ if (def.window === 'point_in_time') {
+ usage += await metering.latestPointInTime(organizationId, signal);
+ } else {
+ const rows = await metering.aggregate({
+ organizationId,
+ from: startOfUtcMonth(now),
+ to: now,
+ groupBy: 'type',
+ type: signal,
+ });
+ usage += rows.reduce((sum, r) => sum + r.quantity, 0);
+ }
+ }
+ return usage;
+}
diff --git a/packages/backend/src/capabilities/registry.ts b/packages/backend/src/capabilities/registry.ts
index ff671146a..926c1315a 100644
--- a/packages/backend/src/capabilities/registry.ts
+++ b/packages/backend/src/capabilities/registry.ts
@@ -9,8 +9,10 @@ import type { MeteringEventType } from '../modules/metering/types.js';
* - boolean: on/off gate, compared as a flag.
* - limit: static numeric cap compared to a caller-supplied COUNT(*). null = unlimited.
* - quota: consumption cap compared to metered usage (#212). null = unlimited.
- * `signal` is the #212 metering event type to aggregate; `window` is how to read it.
- * `signal`/`window` live ONLY in the registry, never in the DB.
+ * `signal` is the #212 metering event type to aggregate, or `signals` for
+ * several types whose usage is SUMMED (combined quotas); `window` is how
+ * to read it. Readers go through quotaSignals(def), never `def.signal`.
+ * `signal(s)`/`window` live ONLY in the registry, never in the DB.
*/
export type QuotaWindow = 'calendar_month' | 'point_in_time';
@@ -27,14 +29,25 @@ export interface LimitCapabilityDef {
description: string;
}
-export interface QuotaCapabilityDef {
+interface QuotaCapabilityBase {
kind: 'quota';
defaultLimit: number | null;
- signal: MeteringEventType;
window: QuotaWindow;
description: string;
}
+/** A quota reads one metering signal, or the SUM of several (combined quotas). */
+export type QuotaCapabilityDef = QuotaCapabilityBase &
+ (
+ | { signal: MeteringEventType; signals?: never }
+ | { signals: readonly MeteringEventType[]; signal?: never }
+ );
+
+/** The metering signals a quota sums (exactly one for single-signal quotas). */
+export function quotaSignals(def: QuotaCapabilityDef): readonly MeteringEventType[] {
+ return def.signals ?? [def.signal as MeteringEventType];
+}
+
export type CapabilityDef =
| BooleanCapabilityDef
| LimitCapabilityDef
@@ -79,6 +92,11 @@ export const CAPABILITIES = {
defaultLimit: null,
description: 'Maximum number of API keys',
},
+ 'projects.max': {
+ kind: 'limit',
+ defaultLimit: null,
+ description: 'Maximum number of active projects',
+ },
'audit.retention_days': {
kind: 'limit',
defaultLimit: null,
@@ -104,6 +122,11 @@ export const CAPABILITIES = {
defaultLimit: null,
description: 'Maximum digest email recipients per organization',
},
+ 'quota.grace_percent': {
+ kind: 'limit',
+ defaultLimit: null,
+ description: 'Percent of overage allowed above every quota before hard-blocking (null = 0, no grace). Applies to all quotas of the org.',
+ },
// Consumption quotas (OSS-permissive: null = unlimited). signal maps to #212 metering types.
'ingestion.max_bytes_monthly': {
@@ -134,6 +157,22 @@ export const CAPABILITIES = {
window: 'calendar_month',
description: 'Month-to-date ingested spans cap (hard-block span ingestion)',
},
+ 'metrics.max_datapoints_monthly': {
+ kind: 'quota',
+ defaultLimit: null,
+ signal: 'metrics.ingested.datapoints',
+ window: 'calendar_month',
+ description: 'Month-to-date ingested metric datapoints cap (hard-block metrics ingestion)',
+ },
+ // Combined byte quota for hosted plans: usage is the SUM of the three byte
+ // signals, each measured as the JSON size of the accepted records.
+ 'telemetry.max_bytes_monthly': {
+ kind: 'quota',
+ defaultLimit: null,
+ signals: ['logs.ingested.bytes', 'spans.ingested.bytes', 'metrics.ingested.bytes'],
+ window: 'calendar_month',
+ description: 'Monthly ingested bytes across logs, traces and metrics combined',
+ },
} as const satisfies Record;
export type CapabilityName = keyof typeof CAPABILITIES;
@@ -157,3 +196,68 @@ export function isQuotaCapability(name: CapabilityName): boolean {
export function isKnownCapability(name: string): name is CapabilityName {
return Object.prototype.hasOwnProperty.call(CAPABILITIES, name);
}
+
+/**
+ * Hosting seam: CAPABILITY_DEFAULTS_JSON overrides registry defaults at boot.
+ * Contract: a JSON object `{ "": boolean | number | null }`.
+ * - boolean capabilities take a boolean only;
+ * - limit/quota capabilities take an integer >= 0, or null for unlimited.
+ * Validation is driven by the registry at call time (no hardcoded key list), so
+ * capabilities added later work automatically. The document is validated in full
+ * before anything is mutated, and any problem throws (boot must fail loudly).
+ * Resolution order is unchanged: entitlement row > default.
+ */
+const ORIGINAL_DEFAULTS: Record = {};
+for (const name of CAPABILITY_NAMES) {
+ const def = CAPABILITIES[name];
+ ORIGINAL_DEFAULTS[name] = def.kind === 'boolean' ? def.defaultEnabled : def.defaultLimit;
+}
+
+const ENV_NAME = 'CAPABILITY_DEFAULTS_JSON';
+
+export function applyDefaultOverrides(json: string | undefined | null): void {
+ if (json === undefined || json === null || json.trim() === '') return;
+
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(json);
+ } catch (err) {
+ throw new Error(`${ENV_NAME} is not valid JSON: ${(err as Error).message}`);
+ }
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ throw new Error(`${ENV_NAME} must be a JSON object mapping capability names to values`);
+ }
+
+ const entries = Object.entries(parsed as Record);
+ for (const [name, value] of entries) {
+ if (!isKnownCapability(name)) {
+ throw new Error(
+ `${ENV_NAME} contains unknown capability "${name}". Known capabilities: ${CAPABILITY_NAMES.join(', ')}`
+ );
+ }
+ if (CAPABILITIES[name].kind === 'boolean') {
+ if (typeof value !== 'boolean') {
+ throw new Error(`${ENV_NAME}: "${name}" is a boolean capability and must be true or false`);
+ }
+ } else if (value !== null && !(typeof value === 'number' && Number.isInteger(value) && value >= 0)) {
+ throw new Error(
+ `${ENV_NAME}: "${name}" must be an integer >= 0 or null (unlimited)`
+ );
+ }
+ }
+
+ for (const [name, value] of entries) {
+ const def = CAPABILITIES[name as CapabilityName] as unknown as Record;
+ if (def.kind === 'boolean') def.defaultEnabled = value as boolean;
+ else def.defaultLimit = value as number | null;
+ }
+}
+
+/** Restore registry defaults (test helper). */
+export function resetDefaultOverrides(): void {
+ for (const name of CAPABILITY_NAMES) {
+ const def = CAPABILITIES[name] as unknown as Record;
+ if (def.kind === 'boolean') def.defaultEnabled = ORIGINAL_DEFAULTS[name];
+ else def.defaultLimit = ORIGINAL_DEFAULTS[name];
+ }
+}
diff --git a/packages/backend/src/capabilities/resolver.ts b/packages/backend/src/capabilities/resolver.ts
index a644a9fce..2cba93da9 100644
--- a/packages/backend/src/capabilities/resolver.ts
+++ b/packages/backend/src/capabilities/resolver.ts
@@ -72,7 +72,8 @@ export class DbCapabilityResolver implements CapabilityResolver {
if (def.kind === 'boolean') {
values[name] = { kind: 'boolean', enabled: row.enabled ?? def.defaultEnabled };
} else {
- values[name] = { kind: def.kind, limit: row.limit_value ?? null };
+ // limit_value is BIGINT: the pg driver returns int8 as a string (migration 059).
+ values[name] = { kind: def.kind, limit: row.limit_value === null ? null : Number(row.limit_value) };
}
}
} catch (err) {
diff --git a/packages/backend/src/config/index.ts b/packages/backend/src/config/index.ts
index d45e453f0..c4ef925b3 100644
--- a/packages/backend/src/config/index.ts
+++ b/packages/backend/src/config/index.ts
@@ -2,6 +2,7 @@ import dotenv from 'dotenv';
import { fileURLToPath } from 'url';
import path from 'path';
import { z } from 'zod';
+import { applyDefaultOverrides } from '../capabilities/registry.js';
// Load environment variables
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -16,6 +17,66 @@ if (process.env.NODE_ENV === 'test') {
dotenv.config({ path: envPath, debug: false });
}
+/** Boot-time validation of the two env vars. Returns an error message, or null. */
+function validateUpgradeConfig(env: { LOGTIDE_UPGRADE_URL?: string; LOGTIDE_UPGRADE_SIGNING_SECRET?: string }): string | null {
+ const url = env.LOGTIDE_UPGRADE_URL;
+ const secret = env.LOGTIDE_UPGRADE_SIGNING_SECRET;
+ if (!url && !secret) return null;
+ if (!url || !secret) {
+ return 'LOGTIDE_UPGRADE_URL and LOGTIDE_UPGRADE_SIGNING_SECRET must be set together (only one is set)';
+ }
+ let parsed: URL;
+ try {
+ parsed = new URL(url);
+ } catch {
+ return 'LOGTIDE_UPGRADE_URL must be a valid absolute URL';
+ }
+ const localHttp =
+ parsed.protocol === 'http:' && (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1');
+ if (parsed.protocol !== 'https:' && !localHttp) {
+ return 'LOGTIDE_UPGRADE_URL must use https (http is allowed only for localhost or 127.0.0.1)';
+ }
+ if (secret.length < 32) {
+ return 'LOGTIDE_UPGRADE_SIGNING_SECRET must be at least 32 characters';
+ }
+ return null;
+}
+
+type TermsEnv = { LOGTIDE_TERMS_URL?: string; LOGTIDE_PRIVACY_URL?: string; LOGTIDE_TERMS_VERSION?: string };
+const TERMS_ENV_NAMES = ['LOGTIDE_TERMS_URL', 'LOGTIDE_PRIVACY_URL', 'LOGTIDE_TERMS_VERSION'] as const;
+
+/** Boot-time validation of the three terms env vars. Returns [field, message] issues. */
+function validateTermsConfig(env: TermsEnv): Array<[keyof TermsEnv, string]> {
+ const missing = TERMS_ENV_NAMES.filter((name) => env[name] === undefined);
+ if (missing.length === TERMS_ENV_NAMES.length) return [];
+ if (missing.length > 0) {
+ return [[
+ missing[0],
+ `LOGTIDE_TERMS_URL, LOGTIDE_PRIVACY_URL and LOGTIDE_TERMS_VERSION must be set together (missing: ${missing.join(', ')})`,
+ ]];
+ }
+ const issues: Array<[keyof TermsEnv, string]> = [];
+ for (const name of ['LOGTIDE_TERMS_URL', 'LOGTIDE_PRIVACY_URL'] as const) {
+ let parsed: URL;
+ try {
+ parsed = new URL(env[name] as string);
+ } catch {
+ issues.push([name, `${name} must be a valid absolute URL`]);
+ continue;
+ }
+ const localHttp =
+ parsed.protocol === 'http:' && (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1');
+ if (parsed.protocol !== 'https:' && !localHttp) {
+ issues.push([name, `${name} must use https (http is allowed only for localhost or 127.0.0.1)`]);
+ }
+ }
+ const version = env.LOGTIDE_TERMS_VERSION as string;
+ if (version.trim() === '' || version.length > 64) {
+ issues.push(['LOGTIDE_TERMS_VERSION', 'LOGTIDE_TERMS_VERSION must be a non-empty string of at most 64 characters']);
+ }
+ return issues;
+}
+
const configSchema = z.object({
// Server
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
@@ -70,6 +131,77 @@ const configSchema = z.object({
QUOTA_EVALUATOR_ENABLED: z.string().default('true').transform((val) => val === 'true'),
QUOTA_EVALUATOR_INTERVAL_MS: z.string().default('60000').transform(Number),
+ // Hosting seam: JSON object overriding capability defaults, e.g. {"ingestion.max_bytes_monthly": 1048576}.
+ // Unset = registry defaults (unlimited). Invalid value = boot failure. Entitlement rows still win.
+ CAPABILITY_DEFAULTS_JSON: z.string().optional(),
+
+ // Hosting seam: static bearer an external billing service uses on a small set
+ // of admin routes (entitlements, retention). Unset/empty = feature inert.
+ // Shorter than 32 characters = boot failure.
+ LOGTIDE_ADMIN_SERVICE_TOKEN: z.preprocess(
+ (v) => (v === '' ? undefined : v),
+ z.string().min(32, 'LOGTIDE_ADMIN_SERVICE_TOKEN must be at least 32 characters').optional()
+ ),
+
+ // Hosting seam: signed upgrade handoff to an external billing portal. Active
+ // only when BOTH are set; exactly one set, a non-https URL (localhost http is
+ // allowed) or a secret under 32 characters = boot failure.
+ LOGTIDE_UPGRADE_URL: z.preprocess((v) => (v === '' ? undefined : v), z.string().optional()),
+ LOGTIDE_UPGRADE_SIGNING_SECRET: z.preprocess((v) => (v === '' ? undefined : v), z.string().optional()),
+
+ // Hosting seam: mandatory Terms of Service / Privacy Policy acceptance at
+ // registration, and re-acceptance when the version changes. Active only when
+ // ALL three are set; one or two set, a non-https URL (localhost http is
+ // allowed) or an empty / longer than 64 characters version = boot failure.
+ LOGTIDE_TERMS_URL: z.preprocess((v) => (v === '' ? undefined : v), z.string().optional()),
+ LOGTIDE_PRIVACY_URL: z.preprocess((v) => (v === '' ? undefined : v), z.string().optional()),
+ LOGTIDE_TERMS_VERSION: z.preprocess((v) => (v === '' ? undefined : v), z.string().optional()),
+
+ // Hosting seam: maximum organizations a single user may own. Positive integer;
+ // unset or empty = unlimited. Any other value = boot failure.
+ MAX_ORGANIZATIONS_PER_USER: z.preprocess(
+ (v) => (v === '' ? undefined : v),
+ z.coerce
+ .number({ invalid_type_error: 'MAX_ORGANIZATIONS_PER_USER must be a positive integer' })
+ .int('MAX_ORGANIZATIONS_PER_USER must be a positive integer')
+ .positive('MAX_ORGANIZATIONS_PER_USER must be a positive integer')
+ .optional()
+ ),
+
+ // Hosting seam: maximum organizations a single user may create per UTC calendar
+ // month (counted from organization_creation_log, so deleting an organization
+ // does not give the creation back). Positive integer; unset or empty =
+ // unlimited. Any other value = boot failure.
+ MAX_ORGANIZATION_CREATIONS_PER_USER_PER_MONTH: z.preprocess(
+ (v) => (v === '' ? undefined : v),
+ z.coerce
+ .number({ invalid_type_error: 'MAX_ORGANIZATION_CREATIONS_PER_USER_PER_MONTH must be a positive integer' })
+ .int('MAX_ORGANIZATION_CREATIONS_PER_USER_PER_MONTH must be a positive integer')
+ .positive('MAX_ORGANIZATION_CREATIONS_PER_USER_PER_MONTH must be a positive integer')
+ .optional()
+ ),
+
+ // Hosting seam: optional email verification. When true, newly registered
+ // local users must confirm their email before creating projects or API keys.
+ // Unset/false = no verification email, no gate (users count as verified).
+ REQUIRE_EMAIL_VERIFICATION: z.preprocess(
+ (v) => (v === '' ? undefined : v),
+ z.enum(['true', 'false'], { errorMap: () => ({ message: 'REQUIRE_EMAIL_VERIFICATION must be true or false' }) })
+ .default('false')
+ .transform((v) => v === 'true')
+ ),
+
+ // Hosting seam: extra per-IP cap on POST /auth/register over a rolling hour,
+ // on top of AUTH_RATE_LIMIT_REGISTER. Positive integer; unset or empty = off.
+ REGISTRATION_RATE_LIMIT_PER_HOUR: z.preprocess(
+ (v) => (v === '' ? undefined : v),
+ z.coerce
+ .number({ invalid_type_error: 'REGISTRATION_RATE_LIMIT_PER_HOUR must be a positive integer' })
+ .int('REGISTRATION_RATE_LIMIT_PER_HOUR must be a positive integer')
+ .positive('REGISTRATION_RATE_LIMIT_PER_HOUR must be a positive integer')
+ .optional()
+ ),
+
// Storage snapshot job (#212 follow-up). Periodic per-project stored-bytes estimate.
STORAGE_SNAPSHOT_ENABLED: z.string().default('true').transform((val) => val === 'true'),
STORAGE_SNAPSHOT_INTERVAL_MS: z.string().default('86400000').transform(Number),
@@ -98,6 +230,26 @@ const configSchema = z.object({
INITIAL_ADMIN_EMAIL: z.string().email().optional(),
INITIAL_ADMIN_PASSWORD: z.string().min(8).optional(),
INITIAL_ADMIN_NAME: z.string().optional(),
+}).superRefine((env, ctx) => {
+ const message = validateUpgradeConfig(env);
+ if (message) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: [message.startsWith('LOGTIDE_UPGRADE_SIGNING_SECRET') ? 'LOGTIDE_UPGRADE_SIGNING_SECRET' : 'LOGTIDE_UPGRADE_URL'],
+ message,
+ });
+ }
+ for (const [field, termsMessage] of validateTermsConfig(env)) {
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: [field], message: termsMessage });
+ }
+ if (env.REQUIRE_EMAIL_VERIFICATION && !env.SMTP_HOST) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['REQUIRE_EMAIL_VERIFICATION'],
+ message:
+ 'REQUIRE_EMAIL_VERIFICATION=true needs SMTP_HOST: without SMTP no verification email can be sent and new users could never verify',
+ });
+ }
});
export type Config = z.infer;
@@ -147,6 +299,15 @@ function loadConfig(): Config {
throw new Error('Invalid configuration - see error details above');
}
+ try {
+ applyDefaultOverrides(result.data.CAPABILITY_DEFAULTS_JSON);
+ } catch (err) {
+ console.error(`
+Invalid configuration: ${(err as Error).message}
+`);
+ throw err;
+ }
+
return result.data;
}
diff --git a/packages/backend/src/database/tenant-tables.ts b/packages/backend/src/database/tenant-tables.ts
index d91d303f3..2310c8ce5 100644
--- a/packages/backend/src/database/tenant-tables.ts
+++ b/packages/backend/src/database/tenant-tables.ts
@@ -32,6 +32,7 @@ export const GLOBAL_TABLES = new Set([
'users', 'sessions', 'organizations', 'organization_members',
'organization_invitations', 'user_identities', 'oidc_states',
'system_settings', 'auth_providers', 'user_onboarding',
+ 'email_verification_tokens', 'organization_creation_log',
]);
export type TableCategory = 'tenant' | 'child' | 'global' | 'unknown';
diff --git a/packages/backend/src/database/types.ts b/packages/backend/src/database/types.ts
index 54cfdc133..337c6e87f 100644
--- a/packages/backend/src/database/types.ts
+++ b/packages/backend/src/database/types.ts
@@ -83,7 +83,8 @@ export interface OrganizationEntitlementsTable {
organization_id: string;
capability: string;
enabled: boolean | null;
- limit_value: number | null;
+ /** BIGINT (migration 059): selected as a string by the pg driver, written as a number. */
+ limit_value: ColumnType;
updated_at: Generated;
}
@@ -97,6 +98,28 @@ export interface UsersTable {
created_at: Generated;
updated_at: Generated;
last_login: Timestamp | null;
+ // NULL = unverified. Defaults to NOW(); only enforced when REQUIRE_EMAIL_VERIFICATION=true.
+ email_verified_at: ColumnType;
+ // Terms/Privacy acceptance (migration 061). NULL = never accepted. Only read
+ // when the LOGTIDE_TERMS_* hosting seam is configured.
+ terms_accepted_at: ColumnType;
+ terms_version: ColumnType;
+}
+
+export interface EmailVerificationTokensTable {
+ id: Generated;
+ user_id: string;
+ token_hash: string;
+ expires_at: Timestamp;
+ used_at: Timestamp | null;
+ created_at: Generated;
+}
+
+export interface OrganizationCreationLogTable {
+ id: Generated;
+ user_id: string;
+ organization_id: string | null;
+ created_at: Generated;
}
export interface SessionsTable {
@@ -1170,6 +1193,8 @@ export interface Database {
users: UsersTable;
sessions: SessionsTable;
stream_tickets: StreamTicketsTable;
+ email_verification_tokens: EmailVerificationTokensTable;
+ organization_creation_log: OrganizationCreationLogTable;
organizations: OrganizationsTable;
organization_members: OrganizationMembersTable;
organization_invitations: OrganizationInvitationsTable;
diff --git a/packages/backend/src/lib/email-templates.ts b/packages/backend/src/lib/email-templates.ts
index da5e3e90b..9c7bff5c7 100644
--- a/packages/backend/src/lib/email-templates.ts
+++ b/packages/backend/src/lib/email-templates.ts
@@ -739,6 +739,48 @@ Sent by LogTide
return { html, text };
}
+// ============================================================================
+// EMAIL VERIFICATION
+// ============================================================================
+
+export interface EmailVerificationEmailData {
+ name: string;
+ token: string;
+}
+
+export function generateEmailVerificationEmail(data: EmailVerificationEmailData): { html: string; text: string } {
+ const frontendUrl = getFrontendUrl();
+ const verifyUrl = `${frontendUrl}/verify-email?token=${encodeURIComponent(data.token)}`;
+
+ const html = baseTemplate(
+ card(`
+ ${header('Verify your email', { text: 'Account', color: colors.info })}
+ ${divider()}
+ ${subtitle(`Hi ${data.name}, confirm your email address to finish setting up your LogTide account.`)}
+ ${alertBox('This link expires in 24 hours and can be used once.', 'info')}
+ ${cta('Verify email', verifyUrl)}
+ `),
+ { preheader: 'Confirm your email address for LogTide' }
+ );
+
+ const text = `
+VERIFY YOUR EMAIL
+${'='.repeat(50)}
+
+Hi ${data.name}, confirm your email address to finish setting up your LogTide account.
+
+Verify email: ${verifyUrl}
+
+This link expires in 24 hours and can be used once. If you did not create a
+LogTide account, you can ignore this email.
+
+--
+Sent by LogTide
+`.trim();
+
+ return { html, text };
+}
+
// ============================================================================
// MONITOR NOTIFICATION EMAIL
// ============================================================================
diff --git a/packages/backend/src/lib/mailer.ts b/packages/backend/src/lib/mailer.ts
new file mode 100644
index 000000000..9248ea961
--- /dev/null
+++ b/packages/backend/src/lib/mailer.ts
@@ -0,0 +1,24 @@
+import nodemailer, { type Transporter, type TransportOptions } from 'nodemailer';
+import { config } from '../config/index.js';
+
+let transporter: Transporter | null = null;
+
+/**
+ * Lazily created SMTP transporter for API-process emails that are sent
+ * inline (no queue). Returns null when SMTP is not configured.
+ */
+export function getMailer(): Transporter | null {
+ if (!config.SMTP_HOST) return null;
+ if (!transporter) {
+ const opts: Record = {
+ host: config.SMTP_HOST,
+ port: config.SMTP_PORT || 587,
+ secure: config.SMTP_SECURE || false,
+ };
+ if (config.SMTP_USER && config.SMTP_PASS) {
+ opts.auth = { user: config.SMTP_USER, pass: config.SMTP_PASS };
+ }
+ transporter = nodemailer.createTransport(opts as TransportOptions);
+ }
+ return transporter;
+}
diff --git a/packages/backend/src/modules/admin/middleware.ts b/packages/backend/src/modules/admin/middleware.ts
index f1185c55f..c6cfe431e 100644
--- a/packages/backend/src/modules/admin/middleware.ts
+++ b/packages/backend/src/modules/admin/middleware.ts
@@ -6,6 +6,11 @@ import type { FastifyRequest, FastifyReply } from 'fastify';
* Requires that session authentication has already been performed
*/
export async function requireAdmin(request: FastifyRequest, reply: FastifyReply) {
+ // Service principal was already vetted by authenticate on opted-in routes
+ if (request.servicePrincipal) {
+ return;
+ }
+
const user = (request as any).user;
if (!user) {
diff --git a/packages/backend/src/modules/api-keys/routes.ts b/packages/backend/src/modules/api-keys/routes.ts
index 924b33119..75142dfc6 100644
--- a/packages/backend/src/modules/api-keys/routes.ts
+++ b/packages/backend/src/modules/api-keys/routes.ts
@@ -6,6 +6,8 @@ import { apiKeysService } from './service.js';
import { authenticate } from '../auth/middleware.js';
import { projectsService } from '../projects/service.js';
import { auditLogService } from '../audit-log/index.js';
+import { emailVerificationService } from '../users/email-verification.js';
+import { termsAcceptanceService } from '../users/terms.js';
import { assertWithinLimit, withLimitLock } from '../../capabilities/index.js';
import { CapabilityError } from '../../capabilities/errors.js';
@@ -64,6 +66,17 @@ export async function apiKeysRoutes(fastify: FastifyInstance) {
const { projectId } = projectIdSchema.parse(request.params);
const body = createApiKeySchema.parse(request.body);
+ // Email verification gate (no-op unless REQUIRE_EMAIL_VERIFICATION=true):
+ // without an API key nothing can be ingested.
+ if (await emailVerificationService.isBlocked(request.user.id)) {
+ return reply.status(403).send({ error: 'email_not_verified' });
+ }
+ // Terms gate (no-op unless the LOGTIDE_TERMS_* seam is active). Only key
+ // creation is gated: existing keys keep ingesting.
+ if (await termsAcceptanceService.isPending(request.user.id)) {
+ return reply.status(403).send({ error: 'terms_not_accepted' });
+ }
+
// Check if user has access to the project
const project = await projectsService.getProjectById(projectId, request.user.id);
if (!project || project.deletedAt) {
diff --git a/packages/backend/src/modules/audit-log/actions.ts b/packages/backend/src/modules/audit-log/actions.ts
index 72e888396..a3ad104cd 100644
--- a/packages/backend/src/modules/audit-log/actions.ts
+++ b/packages/backend/src/modules/audit-log/actions.ts
@@ -28,6 +28,8 @@ export const AUDIT_ACTIONS = {
'receiver.deleted': 'config_change',
// users and membership
'user.registered': 'user_management',
+ 'user.email_verified': 'user_management',
+ 'user.terms_accepted': 'user_management',
'user.created': 'user_management',
'user.invited': 'user_management',
'user.invite_accepted': 'user_management',
diff --git a/packages/backend/src/modules/auth/authentication-service.ts b/packages/backend/src/modules/auth/authentication-service.ts
index 53c550bea..355a80424 100644
--- a/packages/backend/src/modules/auth/authentication-service.ts
+++ b/packages/backend/src/modules/auth/authentication-service.ts
@@ -10,7 +10,10 @@
*/
import crypto from 'crypto';
+import { sql } from 'kysely';
import { db } from '../../database/connection.js';
+import { isEmailVerificationRequired } from '../users/email-verification-flag.js';
+import { emailVerificationService } from '../users/email-verification.js';
import { CacheManager } from '../../utils/cache.js';
import type { UserProfile, SessionInfo } from '../users/service.js';
import { settingsService } from '../settings/service.js';
@@ -248,6 +251,14 @@ export class AuthenticationService {
const email = result.email!.toLowerCase().trim();
const name = result.name || email.split('@')[0];
+ // An OIDC identity whose IdP asserts email_verified: true confirms the
+ // user's email. Other providers never change the verification state.
+ const oidcEmailVerified = provider.config.type === 'oidc' && result.emailVerified === true;
+ const loginUpdate = {
+ last_login: new Date(),
+ ...(oidcEmailVerified ? { email_verified_at: sql`COALESCE(email_verified_at, NOW())` } : {}),
+ };
+
// First, check if this external identity already exists
const existingIdentity = await db
.selectFrom('user_identities')
@@ -271,10 +282,13 @@ export class AuthenticationService {
throw new AuthError('This account has been disabled', AuthErrorCode.USER_DISABLED);
}
- // Update user's last login
+ // Update user's last login. The IdP's verification only counts when it
+ // vouches for the account's current address (the user may have changed
+ // it locally since the identity was linked).
+ const sameEmail = existingIdentity.email.toLowerCase().trim() === email;
await db
.updateTable('users')
- .set({ last_login: new Date() })
+ .set(sameEmail ? loginUpdate : { last_login: new Date() })
.where('id', '=', existingIdentity.id)
.execute();
@@ -328,7 +342,7 @@ export class AuthenticationService {
// Update user's last login
await db
.updateTable('users')
- .set({ last_login: new Date() })
+ .set(loginUpdate)
.where('id', '=', existingUser.id)
.execute();
@@ -373,6 +387,9 @@ export class AuthenticationService {
console.log(`[Auth] No admin exists yet. Promoting ${email} to admin on first external login.`);
}
+ const startsUnverified =
+ isEmailVerificationRequired() && provider.config.type === 'oidc' && result.emailVerified !== true;
+
// Create new user
const newUser = await db
.insertInto('users')
@@ -382,6 +399,9 @@ export class AuthenticationService {
password_hash: null, // External auth users don't have local passwords
is_admin: shouldBeAdmin,
last_login: new Date(),
+ // Column default (NOW()) marks the user verified. With the gate on, an
+ // OIDC identity without email_verified: true starts unverified.
+ ...(startsUnverified ? { email_verified_at: null } : {}),
})
.returning(['id', 'email', 'name', 'is_admin', 'disabled', 'created_at', 'last_login'])
.executeTakeFirstOrThrow();
@@ -397,6 +417,14 @@ export class AuthenticationService {
})
.execute();
+ // The app banner tells an unverified user a link was sent, so send it.
+ // Fire and forget: SMTP latency or failure never blocks the login.
+ if (startsUnverified) {
+ void emailVerificationService.issueAndSend(newUser).catch((err) => {
+ console.error('[Auth] Failed to send verification email to new OIDC user:', newUser.id, (err as Error).message);
+ });
+ }
+
return {
user: {
id: newUser.id,
diff --git a/packages/backend/src/modules/auth/middleware.ts b/packages/backend/src/modules/auth/middleware.ts
index 7a0a0d737..3bbf8274d 100644
--- a/packages/backend/src/modules/auth/middleware.ts
+++ b/packages/backend/src/modules/auth/middleware.ts
@@ -5,7 +5,9 @@
* Used by all session-based route handlers (organizations, projects, etc.)
*/
+import { timingSafeEqual } from 'node:crypto';
import type { FastifyRequest, FastifyReply } from 'fastify';
+import { config } from '../../config/index.js';
import { usersService, type UserProfile } from '../users/service.js';
import { settingsService } from '../settings/service.js';
import { bootstrapService } from '../bootstrap/service.js';
@@ -14,7 +16,28 @@ import { bootstrapService } from '../bootstrap/service.js';
declare module 'fastify' {
interface FastifyRequest {
user?: UserProfile;
+ /** Set only on routes opted in via `config.allowServiceToken` (admin hosting seam). */
+ servicePrincipal?: ServicePrincipal;
}
+ interface FastifyContextConfig {
+ allowServiceToken?: boolean;
+ }
+}
+
+export interface ServicePrincipal {
+ kind: 'service';
+ name: 'admin-service';
+}
+
+export const SERVICE_ACTOR_LABEL = 'service:admin-service';
+
+export function matchesServiceToken(token: string): boolean {
+ const expected = config.LOGTIDE_ADMIN_SERVICE_TOKEN;
+ if (!expected) return false;
+ const a = Buffer.from(token);
+ const b = Buffer.from(expected);
+ if (a.length !== b.length) return false;
+ return timingSafeEqual(a, b);
}
/**
@@ -29,6 +52,13 @@ export async function authenticate(
const token = request.headers.authorization?.replace('Bearer ', '');
if (token) {
+ // Service token: honored only on routes that explicitly opt in (admin
+ // routes behind requireAdmin). Everywhere else it is just an invalid session.
+ if (request.routeOptions?.config?.allowServiceToken && matchesServiceToken(token)) {
+ request.servicePrincipal = { kind: 'service', name: 'admin-service' };
+ return;
+ }
+
const user = await usersService.validateSession(token);
if (user) {
diff --git a/packages/backend/src/modules/auth/plugin.ts b/packages/backend/src/modules/auth/plugin.ts
index dc0564473..aa71fa470 100644
--- a/packages/backend/src/modules/auth/plugin.ts
+++ b/packages/backend/src/modules/auth/plugin.ts
@@ -6,6 +6,7 @@ import { usersService } from '../users/service.js';
import { settingsService } from '../settings/service.js';
import { bootstrapService } from '../bootstrap/service.js';
import { streamTicketService } from '../streaming/stream-ticket-service.js';
+import { matchesServiceToken } from './middleware.js';
declare module 'fastify' {
interface FastifyRequest {
@@ -178,6 +179,14 @@ const authPlugin: FastifyPluginAsync = async (fastify) => {
if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.substring(7); // Remove 'Bearer ' prefix
+ // Admin service token (hosting seam): leave it to the route-level
+ // `authenticate`, which honors it only on routes that opted in via
+ // `config.allowServiceToken`. Everywhere else it falls through to the
+ // session check below and is rejected as an invalid session.
+ if (request.routeOptions?.config?.allowServiceToken && matchesServiceToken(token)) {
+ return;
+ }
+
const user = await usersService.validateSession(token);
if (!user) {
diff --git a/packages/backend/src/modules/capabilities/admin-routes.ts b/packages/backend/src/modules/capabilities/admin-routes.ts
index aa8b7d06c..06f642ae8 100644
--- a/packages/backend/src/modules/capabilities/admin-routes.ts
+++ b/packages/backend/src/modules/capabilities/admin-routes.ts
@@ -2,7 +2,7 @@ import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { sql } from 'kysely';
import { db } from '../../database/index.js';
-import { authenticate } from '../auth/middleware.js';
+import { authenticate, SERVICE_ACTOR_LABEL } from '../auth/middleware.js';
import { requireAdmin } from '../admin/middleware.js';
import {
capabilities,
@@ -15,7 +15,8 @@ import { auditLogService } from '../audit-log/index.js';
const entitlementInputSchema = z.object({
capability: z.string(),
enabled: z.boolean().optional(),
- limitValue: z.number().int().min(0).nullable().optional(),
+ // limit_value is BIGINT; cap at MAX_SAFE_INTEGER so reads coerce back exactly.
+ limitValue: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).nullable().optional(),
});
const putBodySchema = z.object({
@@ -74,7 +75,7 @@ export async function adminEntitlementsRoutes(fastify: FastifyInstance) {
// GET /api/v1/admin/organizations/:id/entitlements
fastify.get(
'/organizations/:id/entitlements',
- { preHandler: [requireAdmin], config: { rateLimit: rateLimitConfig } },
+ { preHandler: [requireAdmin], config: { allowServiceToken: true, rateLimit: rateLimitConfig } },
async (request, reply) => {
const { id } = request.params as { id: string };
const list = await capabilities.list(id);
@@ -85,7 +86,7 @@ export async function adminEntitlementsRoutes(fastify: FastifyInstance) {
// PUT /api/v1/admin/organizations/:id/entitlements
fastify.put(
'/organizations/:id/entitlements',
- { preHandler: [requireAdmin], config: { rateLimit: rateLimitConfig } },
+ { preHandler: [requireAdmin], config: { allowServiceToken: true, rateLimit: rateLimitConfig } },
async (request, reply) => {
const { id } = request.params as { id: string };
let rows: ReturnType[] = [];
@@ -120,7 +121,7 @@ export async function adminEntitlementsRoutes(fastify: FastifyInstance) {
action: 'org.entitlements_updated',
target: { type: 'organization_entitlement', id },
organizationId: id,
- metadata: { entitlements: rows.map((r) => ({ capability: r.capability, enabled: r.enabled, limitValue: r.limit_value })) },
+ metadata: { ...(request.servicePrincipal ? { actor: SERVICE_ACTOR_LABEL } : {}), entitlements: rows.map((r) => ({ capability: r.capability, enabled: r.enabled, limitValue: r.limit_value })) },
});
return reply.send({ message: 'Entitlements updated', updated: rows.length });
diff --git a/packages/backend/src/modules/ingestion/service.ts b/packages/backend/src/modules/ingestion/service.ts
index b0dc425dd..cf395b43f 100644
--- a/packages/backend/src/modules/ingestion/service.ts
+++ b/packages/backend/src/modules/ingestion/service.ts
@@ -153,6 +153,7 @@ export class IngestionService {
await assertWithinUsageQuota('ingestion.max_bytes_monthly');
await assertWithinUsageQuota('ingestion.max_events_monthly');
await assertWithinUsageQuota('storage.max_bytes');
+ await assertWithinUsageQuota('telemetry.max_bytes_monthly');
}
// Convert logs to reservoir LogRecord format
diff --git a/packages/backend/src/modules/metering/capability-usage.ts b/packages/backend/src/modules/metering/capability-usage.ts
index 056b4b0ac..8cb1d171c 100644
--- a/packages/backend/src/modules/metering/capability-usage.ts
+++ b/packages/backend/src/modules/metering/capability-usage.ts
@@ -4,12 +4,15 @@ import {
CAPABILITY_NAMES,
type CapabilityName,
} from '../../capabilities/registry.js';
+import { computeQuotaState, type QuotaState } from '../../capabilities/quota-state.js';
import { meteringService } from './service.js';
import { alertsService } from '../alerts/service.js';
import { apiKeysService } from '../api-keys/service.js';
import { customDashboardsService } from '../custom-dashboards/service.js';
+import { projectsService } from '../projects/service.js';
import { notificationChannelsService } from '../notification-channels/service.js';
import { SigmaService } from '../sigma/service.js';
+import { readQuotaUsage } from '../../capabilities/quota-usage.js';
const sigmaService = new SigmaService();
@@ -21,6 +24,8 @@ export interface CapabilityUsage {
/** Configured cap. null = unlimited (OSS default); render as "no cap", not 0%. */
limit: number | null;
description: string;
+ /** Quota state (quota kind only): ok | grace | blocked | unlimited. */
+ state?: QuotaState;
}
// Current-count resolver per limit capability. audit.retention_days is a config
@@ -32,17 +37,13 @@ const LIMIT_COUNTERS: Partial<
'alerts.max_rules': (o) => alertsService.countAlertRules(o),
'notifications.max_channels': (o) => notificationChannelsService.countChannels(o),
'apikeys.max': (o) => apiKeysService.countKeysForOrg(o),
+ 'projects.max': (o) => projectsService.countActiveForOrg(o),
'sigma.max_active_rules': (o) => sigmaService.countActiveRules(o),
'dashboards.max_custom': (o) => customDashboardsService.countForOrg(o),
};
-/** Start of the current UTC calendar month (matches QuotaEvaluator semantics). */
-function startOfUtcMonth(now: Date): Date {
- return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1, 0, 0, 0, 0));
-}
-
-// Mirrors QuotaEvaluator: point-in-time gauges read the latest snapshot, monthly
-// quotas sum the metering signal over the current calendar month.
+// Same reader as the QuotaEvaluator: point-in-time gauges read the latest
+// snapshot, monthly quotas sum their signal(s) over the current calendar month.
async function quotaUsage(
organizationId: string,
cap: CapabilityName,
@@ -50,19 +51,7 @@ async function quotaUsage(
): Promise {
const def = CAPABILITIES[cap];
if (def.kind !== 'quota') return 0;
-
- if (def.window === 'point_in_time') {
- return meteringService.latestPointInTime(organizationId, def.signal);
- }
-
- const rows = await meteringService.aggregate({
- organizationId,
- from: startOfUtcMonth(now),
- to: now,
- groupBy: 'type',
- type: def.signal,
- });
- return rows.reduce((sum, r) => sum + r.quantity, 0);
+ return readQuotaUsage(meteringService, organizationId, def, now);
}
/**
@@ -101,12 +90,14 @@ export async function getCapabilityUsage(
Promise.all([
quotaUsage(organizationId, cap, now),
capabilities.getLimit(organizationId, cap),
+ capabilities.getLimit(organizationId, 'quota.grace_percent'),
]).then(
- ([current, limit]): CapabilityUsage => ({
+ ([current, limit, grace]): CapabilityUsage => ({
capability: cap,
kind: 'quota',
current,
limit,
+ state: computeQuotaState(current, limit, grace),
description: def.description,
})
),
diff --git a/packages/backend/src/modules/metering/index.ts b/packages/backend/src/modules/metering/index.ts
index 4fb8df051..26ee12519 100644
--- a/packages/backend/src/modules/metering/index.ts
+++ b/packages/backend/src/modules/metering/index.ts
@@ -36,14 +36,50 @@ export function recordLogIngestion(params: {
});
}
+/** Server-injected top-level fields never billed: tenant ids on every record. */
+const INJECTED_FIELDS = ['organizationId', 'projectId'] as const;
+/** Spans also carry `time`, a server-side copy of `startTime`. */
+const INJECTED_SPAN_FIELDS = [...INJECTED_FIELDS, 'time'] as const;
+
+/**
+ * Bytes of the accepted records, measured like logs.ingested.bytes:
+ * Buffer.byteLength(JSON.stringify(...)), but over a projection of each record
+ * WITHOUT the server-injected top-level fields in `omit`, so only
+ * customer-provided data is billed (logs are measured on the input, which has
+ * none of them). Nested keys are untouched and the stored records are never
+ * mutated. `records` is the batch handed to storage; when the store accepted
+ * only part of it (`accepted` < length, e.g. a Mongo partial insert) the size
+ * is prorated, since which records failed is not reported. Returns 0 when
+ * nothing was accepted.
+ */
+function acceptedBytes(
+ records: readonly unknown[],
+ accepted: number,
+ omit: readonly string[]
+): number {
+ if (accepted <= 0 || records.length === 0) return 0;
+ const projected = records.map((r) => {
+ if (r === null || typeof r !== 'object' || Array.isArray(r)) return r;
+ const copy: Record = { ...(r as Record) };
+ for (const key of omit) delete copy[key];
+ return copy;
+ });
+ const bytes = Buffer.byteLength(JSON.stringify(projected));
+ if (accepted >= records.length) return bytes;
+ return Math.round((bytes * accepted) / records.length);
+}
+
/**
* Recording site helper for span ingestion (#212 follow-up). Fire-and-forget.
- * Count only; span bytes are not metered.
+ * Records the accepted span count and, when `spans` (the batch as stored) is
+ * given, the JSON byte size of its customer-provided fields under
+ * spans.ingested.bytes (telemetry quota).
*/
export function recordSpanIngestion(params: {
spanCount: number;
organizationId: string;
projectId: string;
+ spans?: readonly unknown[];
}): void {
if (params.spanCount <= 0) return;
metering.record({
@@ -52,6 +88,46 @@ export function recordSpanIngestion(params: {
organizationId: params.organizationId,
projectId: params.projectId,
});
+ const bytes = params.spans ? acceptedBytes(params.spans, params.spanCount, INJECTED_SPAN_FIELDS) : 0;
+ if (bytes > 0) {
+ metering.record({
+ type: 'spans.ingested.bytes',
+ quantity: bytes,
+ organizationId: params.organizationId,
+ projectId: params.projectId,
+ });
+ }
+}
+
+/**
+ * Recording site helper for metric ingestion. Fire-and-forget.
+ * Count of accepted datapoints and, when `records` (the batch as stored) is
+ * given, the JSON byte size of its customer-provided fields under
+ * metrics.ingested.bytes (telemetry quota).
+ * Billed, so not under the ingestion.* prefix.
+ */
+export function recordMetricIngestion(params: {
+ datapointCount: number;
+ organizationId: string;
+ projectId: string;
+ records?: readonly unknown[];
+}): void {
+ if (params.datapointCount <= 0) return;
+ metering.record({
+ type: 'metrics.ingested.datapoints',
+ quantity: params.datapointCount,
+ organizationId: params.organizationId,
+ projectId: params.projectId,
+ });
+ const bytes = params.records ? acceptedBytes(params.records, params.datapointCount, INJECTED_FIELDS) : 0;
+ if (bytes > 0) {
+ metering.record({
+ type: 'metrics.ingested.bytes',
+ quantity: bytes,
+ organizationId: params.organizationId,
+ projectId: params.projectId,
+ });
+ }
}
export { MeteringRecorder } from './recorder.js';
diff --git a/packages/backend/src/modules/metering/types.ts b/packages/backend/src/modules/metering/types.ts
index c5a24f765..2ee1d0398 100644
--- a/packages/backend/src/modules/metering/types.ts
+++ b/packages/backend/src/modules/metering/types.ts
@@ -2,12 +2,17 @@
* Resource usage metering event types (#212).
* The full set is declared up front so deferred recording sites
* (spans, metric cardinality, storage snapshots) need no type change.
- * Only `logs.ingested.*` are wired in this plan.
+ * Wired recording sites: `logs.ingested.*`, `spans.ingested`, `spans.ingested.bytes`,
+ * `metrics.ingested.datapoints` and `metrics.ingested.bytes` (billed, so no
+ * `ingestion.` prefix).
*/
export type MeteringEventType =
| 'logs.ingested.bytes'
| 'logs.ingested.events'
| 'spans.ingested'
+ | 'spans.ingested.bytes'
+ | 'metrics.ingested.datapoints'
+ | 'metrics.ingested.bytes'
| 'metrics.cardinality'
| 'storage.snapshot'
// Ingestion health counters (WS1): not billed, surfaced in admin stats.
diff --git a/packages/backend/src/modules/metrics/service.ts b/packages/backend/src/modules/metrics/service.ts
index 8986a8268..45be5efc5 100644
--- a/packages/backend/src/modules/metrics/service.ts
+++ b/packages/backend/src/modules/metrics/service.ts
@@ -1,6 +1,6 @@
import { reservoir } from '../../database/reservoir.js';
import { projectsService } from '../projects/service.js';
-import { metering } from '../metering/index.js';
+import { metering, recordMetricIngestion } from '../metering/index.js';
import { createSkewTracker } from '../ingestion/skew.js';
import type {
MetricRecord,
@@ -44,6 +44,13 @@ export class MetricsService {
});
}
+ // Metering: accepted data points only (reservoir result), billed like
+ // spans. Deliberately not under the ingestion.* prefix, which is excluded
+ // from usage breakdowns and bypasses METERING_ENABLED=false.
+ if (organizationId) {
+ recordMetricIngestion({ datapointCount: result.ingested, organizationId, projectId, records: enriched });
+ }
+
// Mark the project as having metrics (debounced, fire-and-forget)
projectsService.markHasData(projectId, 'metrics').catch(() => {});
diff --git a/packages/backend/src/modules/organizations/routes.ts b/packages/backend/src/modules/organizations/routes.ts
index 5a851ce74..4baddf7a7 100644
--- a/packages/backend/src/modules/organizations/routes.ts
+++ b/packages/backend/src/modules/organizations/routes.ts
@@ -4,6 +4,11 @@ import { OrganizationsService } from './service.js';
import { authenticate } from '../auth/middleware.js';
import type { OrgRole } from '@logtide/shared';
import { auditLogService } from '../audit-log/index.js';
+import { config } from '../../config/index.js';
+import { withLimitLock } from '../../capabilities/limit-lock.js';
+import { startOfUtcMonth } from '../../capabilities/month.js';
+import { isUpgradeHandoffActive, signUpgradeToken, buildUpgradeUrl } from './upgrade-handoff.js';
+import { termsAcceptanceService } from '../users/terms.js';
const organizationsService = new OrganizationsService();
@@ -35,10 +40,41 @@ const updateMemberRoleSchema = z.object({
role: z.enum(['admin', 'member']),
});
+const upgradeHandoffBodySchema = z.object({
+ intent: z.enum(['upgrade', 'manage']),
+});
+
export async function organizationsRoutes(fastify: FastifyInstance) {
// All routes require authentication
fastify.addHook('onRequest', authenticate);
+ // Signed handoff to the external billing portal. Registered ONLY when the
+ // feature is configured (boot-time), so inactive = default Fastify 404.
+ // Owner/admin of the org only. Uses the normal user session.
+ if (isUpgradeHandoffActive()) fastify.post('/:id/upgrade-handoff', async (request: any, reply) => {
+ try {
+ const { id } = organizationIdSchema.parse(request.params);
+ const { intent } = upgradeHandoffBodySchema.parse(request.body);
+
+ const role = await organizationsService.getMemberRole(id, request.user.id);
+ if (role !== 'owner' && role !== 'admin') {
+ return reply.status(403).send({ error: 'Only organization owners and admins can manage billing' });
+ }
+
+ const cfg = config as { LOGTIDE_UPGRADE_URL?: string; LOGTIDE_UPGRADE_SIGNING_SECRET?: string };
+ const token = signUpgradeToken(
+ { org: id, user: request.user.id, email: request.user.email, role, intent },
+ cfg.LOGTIDE_UPGRADE_SIGNING_SECRET!
+ );
+ return reply.send({ url: buildUpgradeUrl(cfg.LOGTIDE_UPGRADE_URL!, token) });
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return reply.status(400).send({ error: 'Invalid request' });
+ }
+ throw error;
+ }
+ });
+
// Get all organizations for current user
fastify.get('/', async (request: any, reply) => {
const organizations = await organizationsService.getUserOrganizations(request.user.id);
@@ -275,12 +311,57 @@ export async function organizationsRoutes(fastify: FastifyInstance) {
try {
const body = createOrganizationSchema.parse(request.body);
- const organization = await organizationsService.createOrganization({
- userId: request.user.id,
- name: body.name,
- description: body.description,
+ // Terms gate (no-op unless the LOGTIDE_TERMS_* seam is active), checked
+ // before the per-user organization caps.
+ if (await termsAcceptanceService.isPending(request.user.id)) {
+ return reply.status(403).send({ error: 'terms_not_accepted' });
+ }
+
+ // One lock per user: the cap counts and the insert must not interleave
+ // across parallel POSTs, otherwise both caps can be overshot.
+ const outcome = await withLimitLock(`user:${request.user.id}`, 'organizations.create', async () => {
+ const maxOwned = config.MAX_ORGANIZATIONS_PER_USER;
+ if (maxOwned !== undefined) {
+ const owned = await organizationsService.countOwnedOrganizations(request.user.id);
+ if (owned >= maxOwned) {
+ return {
+ denied: {
+ error: 'organization_limit_reached',
+ message: `You can own at most ${maxOwned.toLocaleString('en-US')} organization${maxOwned === 1 ? '' : 's'}`,
+ },
+ };
+ }
+ }
+
+ const maxCreations = config.MAX_ORGANIZATION_CREATIONS_PER_USER_PER_MONTH;
+ if (maxCreations !== undefined) {
+ const createdThisMonth = await organizationsService.countCreationsSince(
+ request.user.id,
+ startOfUtcMonth(new Date()),
+ );
+ if (createdThisMonth >= maxCreations) {
+ return {
+ denied: {
+ error: 'organization_creation_limit_reached',
+ message: `You can create at most ${maxCreations.toLocaleString('en-US')} organization${maxCreations === 1 ? '' : 's'} per month`,
+ },
+ };
+ }
+ }
+
+ const created = await organizationsService.createOrganization({
+ userId: request.user.id,
+ name: body.name,
+ description: body.description,
+ });
+ return { organization: created };
});
+ if ('denied' in outcome) {
+ return reply.status(403).send(outcome.denied);
+ }
+ const organization = outcome.organization;
+
await auditLogService.record({
action: 'org.created',
target: { type: 'organization', id: organization.id },
diff --git a/packages/backend/src/modules/organizations/service.ts b/packages/backend/src/modules/organizations/service.ts
index c250e37ef..f2af034e1 100644
--- a/packages/backend/src/modules/organizations/service.ts
+++ b/packages/backend/src/modules/organizations/service.ts
@@ -53,6 +53,28 @@ export class OrganizationsService {
}
}
+ /** Count organizations a user owns (organization_members role 'owner'). */
+ async countOwnedOrganizations(userId: string): Promise {
+ const row = await db
+ .selectFrom('organization_members')
+ .select((eb) => eb.fn.countAll().as('count'))
+ .where('user_id', '=', userId)
+ .where('role', '=', 'owner')
+ .executeTakeFirst();
+ return Number(row?.count ?? 0);
+ }
+
+ /** Count organizations the user created since `since` (survives org deletion). */
+ async countCreationsSince(userId: string, since: Date): Promise {
+ const row = await db
+ .selectFrom('organization_creation_log')
+ .select((eb) => eb.fn.countAll().as('count'))
+ .where('user_id', '=', userId)
+ .where('created_at', '>=', since)
+ .executeTakeFirst();
+ return Number(row?.count ?? 0);
+ }
+
/**
* Create a new organization
*/
@@ -63,27 +85,37 @@ export class OrganizationsService {
// Find unique slug
const uniqueSlug = await this.findUniqueSlug(baseSlug);
- // Create organization
- const org = await db
- .insertInto('organizations')
- .values({
- name: input.name,
- slug: uniqueSlug,
- description: input.description || null,
- owner_id: input.userId,
- })
- .returning(['id', 'name', 'slug', 'description', 'owner_id', 'retention_days', 'created_at', 'updated_at'])
- .executeTakeFirstOrThrow();
-
- // Add user as owner member
- await db
- .insertInto('organization_members')
- .values({
- organization_id: org.id,
- user_id: input.userId,
- role: 'owner',
- })
- .execute();
+ // Org, owner membership and the creation log row commit together
+ const org = await db.transaction().execute(async (trx) => {
+ const created = await trx
+ .insertInto('organizations')
+ .values({
+ name: input.name,
+ slug: uniqueSlug,
+ description: input.description || null,
+ owner_id: input.userId,
+ })
+ .returning(['id', 'name', 'slug', 'description', 'owner_id', 'retention_days', 'created_at', 'updated_at'])
+ .executeTakeFirstOrThrow();
+
+ // Add user as owner member
+ await trx
+ .insertInto('organization_members')
+ .values({
+ organization_id: created.id,
+ user_id: input.userId,
+ role: 'owner',
+ })
+ .execute();
+
+ // Append-only bookkeeping for MAX_ORGANIZATION_CREATIONS_PER_USER_PER_MONTH
+ await trx
+ .insertInto('organization_creation_log')
+ .values({ user_id: input.userId, organization_id: created.id })
+ .execute();
+
+ return created;
+ });
return {
id: org.id,
@@ -408,6 +440,20 @@ export class OrganizationsService {
}));
}
+ /**
+ * Role of a user in an organization, or null when not a member
+ */
+ async getMemberRole(organizationId: string, userId: string): Promise {
+ const member = await db
+ .selectFrom('organization_members')
+ .select(['role'])
+ .where('organization_id', '=', organizationId)
+ .where('user_id', '=', userId)
+ .executeTakeFirst();
+
+ return (member?.role as OrgRole | undefined) ?? null;
+ }
+
/**
* Check if user is owner or admin
*/
diff --git a/packages/backend/src/modules/organizations/upgrade-handoff.ts b/packages/backend/src/modules/organizations/upgrade-handoff.ts
new file mode 100644
index 000000000..8b434685b
--- /dev/null
+++ b/packages/backend/src/modules/organizations/upgrade-handoff.ts
@@ -0,0 +1,107 @@
+import crypto from 'crypto';
+import { config } from '../../config/index.js';
+import { CapabilityError, QuotaExceededError } from '../../capabilities/errors.js';
+
+export type UpgradeIntent = 'upgrade' | 'manage';
+
+export interface UpgradeHandoffPayload {
+ v: 1;
+ org: string;
+ user: string;
+ email: string;
+ role: string;
+ intent: UpgradeIntent;
+ /** Expiry as unix seconds. */
+ exp: number;
+ nonce: string;
+}
+
+export const UPGRADE_TOKEN_TTL_SECONDS = 10 * 60;
+
+type UpgradeConfig = { LOGTIDE_UPGRADE_URL?: string; LOGTIDE_UPGRADE_SIGNING_SECRET?: string };
+
+/**
+ * The handoff is active only when BOTH the portal URL and the signing secret
+ * are configured. Read at call time so the answer always reflects config.
+ */
+export function isUpgradeHandoffActive(): boolean {
+ const c = config as UpgradeConfig;
+ return Boolean(c.LOGTIDE_UPGRADE_URL && c.LOGTIDE_UPGRADE_SIGNING_SECRET);
+}
+
+/**
+ * Body fragment for a capability (403) or quota (429) rejection: `{ upgrade_available: true }`
+ * while the handoff is active, `{}` otherwise (and for every other error), so OSS
+ * responses stay byte-for-byte unchanged.
+ */
+export function upgradeHintFor(error: unknown): { upgrade_available?: true } {
+ if ((error instanceof CapabilityError || error instanceof QuotaExceededError) && isUpgradeHandoffActive()) {
+ return { upgrade_available: true };
+ }
+ return {};
+}
+
+function b64url(input: Buffer | string): string {
+ return Buffer.from(input).toString('base64url');
+}
+
+function sign(secret: string, encodedPayload: string): Buffer {
+ return crypto.createHmac('sha256', secret).update(encodedPayload).digest();
+}
+
+/** Token format: base64url(JSON payload) + "." + base64url(HMAC-SHA256(secret, base64url payload)). */
+export function signUpgradeToken(
+ input: Omit,
+ secret: string,
+ nowMs: number = Date.now()
+): string {
+ const payload: UpgradeHandoffPayload = {
+ v: 1,
+ ...input,
+ exp: Math.floor(nowMs / 1000) + UPGRADE_TOKEN_TTL_SECONDS,
+ nonce: b64url(crypto.randomBytes(16)),
+ };
+ const encoded = b64url(JSON.stringify(payload));
+ return `${encoded}.${b64url(sign(secret, encoded))}`;
+}
+
+/**
+ * Reference verifier: this is the algorithm the external billing service must
+ * implement. Returns the payload, or null for any malformed, tampered or
+ * expired token.
+ */
+export function verifyUpgradeToken(
+ token: string,
+ secret: string,
+ nowMs: number = Date.now()
+): UpgradeHandoffPayload | null {
+ if (typeof token !== 'string') return null;
+ const parts = token.split('.');
+ if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
+ const [encoded, mac] = parts;
+
+ const expected = sign(secret, encoded);
+ const given = Buffer.from(mac, 'base64url');
+ if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) return null;
+
+ let payload: UpgradeHandoffPayload;
+ try {
+ payload = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'));
+ } catch {
+ return null;
+ }
+ if (!payload || typeof payload !== 'object' || payload.v !== 1 || typeof payload.exp !== 'number') return null;
+ for (const k of ['org', 'user', 'email', 'role', 'nonce'] as const) {
+ if (typeof payload[k] !== 'string' || payload[k].length === 0) return null;
+ }
+ if (payload.intent !== 'upgrade' && payload.intent !== 'manage') return null;
+ if (payload.exp <= Math.floor(nowMs / 1000)) return null;
+ return payload;
+}
+
+/** Builds the redirect URL; an existing query string on the base URL is preserved. */
+export function buildUpgradeUrl(baseUrl: string, token: string): string {
+ const url = new URL(baseUrl);
+ url.searchParams.set('t', token);
+ return url.toString();
+}
diff --git a/packages/backend/src/modules/otlp/metric-routes.ts b/packages/backend/src/modules/otlp/metric-routes.ts
index 88fff42cb..a60aa988c 100644
--- a/packages/backend/src/modules/otlp/metric-routes.ts
+++ b/packages/backend/src/modules/otlp/metric-routes.ts
@@ -16,6 +16,9 @@ import { detectContentType, isGzipCompressed, decompressGzip } from './parser.js
import { metricsService } from '../metrics/service.js';
import { config } from '../../config/index.js';
import { db } from '../../database/index.js';
+import { context } from '@logtide/shared/context';
+import { assertWithinUsageQuota } from '../../capabilities/index.js';
+import { upgradeHintFor } from '../organizations/upgrade-handoff.js';
const collectStreamToBuffer = (stream: NodeJS.ReadableStream): Promise =>
new Promise((resolve, reject) => {
@@ -97,6 +100,20 @@ const otlpMetricRoutes: FastifyPluginAsync = async (fastify) => {
},
},
},
+ 429: {
+ type: 'object',
+ properties: {
+ partialSuccess: {
+ type: 'object',
+ properties: {
+ rejectedDataPoints: { type: 'number' },
+ errorMessage: { type: 'string' },
+ },
+ },
+ // Hosting seam: present only while the upgrade handoff is active.
+ upgrade_available: { type: 'boolean' },
+ },
+ },
400: {
type: 'object',
properties: {
@@ -181,7 +198,16 @@ const otlpMetricRoutes: FastifyPluginAsync = async (fastify) => {
};
}
- await metricsService.ingestMetrics(records, projectId, project.organization_id);
+ // Capability: hard-block metrics ingestion when over the datapoint quota
+ // (metrics only; logs and spans have their own quotas) or over the
+ // combined telemetry byte quota (shared by logs, traces and metrics).
+ await context.runAsSystem('otlp:metric-ingest', async () => {
+ await context.with({ organizationId: project.organization_id }, async () => {
+ await assertWithinUsageQuota('metrics.max_datapoints_monthly');
+ await assertWithinUsageQuota('telemetry.max_bytes_monthly');
+ await metricsService.ingestMetrics(records, projectId, project.organization_id);
+ });
+ });
return {
partialSuccess: {
@@ -191,6 +217,17 @@ const otlpMetricRoutes: FastifyPluginAsync = async (fastify) => {
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ // Over-quota metric batches return 429 (retry later / upgrade), not 400.
+ if (error && (error as any).statusCode === 429) {
+ return reply.code(429).send({
+ partialSuccess: {
+ rejectedDataPoints: -1,
+ errorMessage,
+ },
+ ...upgradeHintFor(error),
+ });
+ }
+
console.error('[OTLP Metrics] Ingestion error:', errorMessage);
return reply.code(400).send({
diff --git a/packages/backend/src/modules/otlp/routes.ts b/packages/backend/src/modules/otlp/routes.ts
index 2bfacc6c7..97df2dc59 100644
--- a/packages/backend/src/modules/otlp/routes.ts
+++ b/packages/backend/src/modules/otlp/routes.ts
@@ -17,6 +17,7 @@ import { ingestionService } from '../ingestion/service.js';
import { config } from '../../config/index.js';
import { db } from '../../database/index.js';
import { context } from '@logtide/shared/context';
+import { upgradeHintFor } from '../organizations/upgrade-handoff.js';
/**
* Helper to collect chunks from a stream into a buffer.
@@ -144,6 +145,8 @@ const otlpRoutes: FastifyPluginAsync = async (fastify) => {
errorMessage: { type: 'string' },
},
},
+ // Hosting seam: present only while the upgrade handoff is active.
+ upgrade_available: { type: 'boolean' },
},
},
},
@@ -261,6 +264,7 @@ const otlpRoutes: FastifyPluginAsync = async (fastify) => {
rejectedLogRecords: -1,
errorMessage,
},
+ ...upgradeHintFor(error),
});
}
diff --git a/packages/backend/src/modules/otlp/trace-routes.ts b/packages/backend/src/modules/otlp/trace-routes.ts
index ffb05b183..d5040ec3f 100644
--- a/packages/backend/src/modules/otlp/trace-routes.ts
+++ b/packages/backend/src/modules/otlp/trace-routes.ts
@@ -18,6 +18,7 @@ import { config } from '../../config/index.js';
import { db } from '../../database/index.js';
import { context } from '@logtide/shared/context';
import { assertWithinUsageQuota } from '../../capabilities/index.js';
+import { upgradeHintFor } from '../organizations/upgrade-handoff.js';
/**
* Helper to collect chunks from a stream into a buffer.
@@ -140,6 +141,8 @@ const otlpTraceRoutes: FastifyPluginAsync = async (fastify) => {
errorMessage: { type: 'string' },
},
},
+ // Hosting seam: present only while the upgrade handoff is active.
+ upgrade_available: { type: 'boolean' },
},
},
},
@@ -219,11 +222,13 @@ const otlpTraceRoutes: FastifyPluginAsync = async (fastify) => {
};
}
- // Capability: hard-block span ingestion when over the tracing quota (#214).
+ // Capability: hard-block span ingestion when over the tracing quota (#214)
+ // or the combined telemetry byte quota.
// Establish the org context so assertWithinUsageQuota can read it, then ingest.
await context.runAsSystem('otlp:trace-ingest', async () => {
await context.with({ organizationId: project.organization_id }, async () => {
await assertWithinUsageQuota('tracing.max_spans_monthly');
+ await assertWithinUsageQuota('telemetry.max_bytes_monthly');
await tracesService.ingestSpans(spans, traces, projectId, project.organization_id);
});
});
@@ -244,6 +249,7 @@ const otlpTraceRoutes: FastifyPluginAsync = async (fastify) => {
rejectedSpans: -1,
errorMessage,
},
+ ...upgradeHintFor(error),
});
}
diff --git a/packages/backend/src/modules/projects/routes.ts b/packages/backend/src/modules/projects/routes.ts
index de235571c..e9c1972c6 100644
--- a/packages/backend/src/modules/projects/routes.ts
+++ b/packages/backend/src/modules/projects/routes.ts
@@ -5,7 +5,11 @@ import { authenticate } from '../auth/middleware.js';
import { auditLogService } from '../audit-log/index.js';
import { reservoir } from '../../database/reservoir.js';
import { db } from '../../database/connection.js';
+import { context } from '@logtide/shared/context';
+import { assertWithinLimit, withLimitLock } from '../../capabilities/index.js';
import { CacheManager, CACHE_TTL } from '../../utils/cache.js';
+import { emailVerificationService } from '../users/email-verification.js';
+import { termsAcceptanceService } from '../users/terms.js';
const createProjectSchema = z.object({
organizationId: z.string().uuid('Invalid organization ID'),
@@ -193,7 +197,17 @@ export async function projectsRoutes(fastify: FastifyInstance) {
return reply.status(409).send({ error: 'Project is not deleted' });
}
- const restored = await projectsService.restoreProject(id, request.user.id);
+ // Restoring re-activates a project, so it counts against projects.max
+ // exactly like a create (same lock, same system-context count).
+ const restored = await withLimitLock(project.organizationId, 'projects.max', async () => {
+ await context.runAsSystem('projects:restore-limit-check', async () => {
+ await context.with({ organizationId: project.organizationId }, async () => {
+ const count = await projectsService.countActiveForOrg(project.organizationId);
+ await assertWithinLimit('projects.max', count);
+ });
+ });
+ return projectsService.restoreProject(id, request.user.id);
+ });
if (!restored) {
return reply.status(404).send({ error: 'Project not found' });
}
@@ -254,11 +268,32 @@ export async function projectsRoutes(fastify: FastifyInstance) {
try {
const body = createProjectSchema.parse(request.body);
- const project = await projectsService.createProject({
- organizationId: body.organizationId,
- userId: request.user.id,
- name: body.name,
- description: body.description,
+ // Email verification gate (no-op unless REQUIRE_EMAIL_VERIFICATION=true),
+ // checked before the projects.max limit.
+ if (await emailVerificationService.isBlocked(request.user.id)) {
+ return reply.status(403).send({ error: 'email_not_verified' });
+ }
+ // Terms gate (no-op unless the LOGTIDE_TERMS_* seam is active).
+ if (await termsAcceptanceService.isPending(request.user.id)) {
+ return reply.status(403).send({ error: 'terms_not_accepted' });
+ }
+
+ const project = await withLimitLock(body.organizationId, 'projects.max', async () => {
+ // Membership first so non-members get the access error, not a limit error.
+ await projectsService.checkOrganizationAccess(body.organizationId, request.user.id);
+ await context.runAsSystem('projects:create-limit-check', async () => {
+ await context.with({ organizationId: body.organizationId }, async () => {
+ const count = await projectsService.countActiveForOrg(body.organizationId);
+ await assertWithinLimit('projects.max', count);
+ });
+ });
+
+ return projectsService.createProject({
+ organizationId: body.organizationId,
+ userId: request.user.id,
+ name: body.name,
+ description: body.description,
+ });
});
await auditLogService.record({
diff --git a/packages/backend/src/modules/projects/service.ts b/packages/backend/src/modules/projects/service.ts
index 026ed426c..162bc7ffd 100644
--- a/packages/backend/src/modules/projects/service.ts
+++ b/packages/backend/src/modules/projects/service.ts
@@ -104,7 +104,7 @@ export class ProjectsService {
/**
* Check if user has access to organization
*/
- private async checkOrganizationAccess(organizationId: string, userId: string): Promise {
+ async checkOrganizationAccess(organizationId: string, userId: string): Promise {
const member = await db
.selectFrom('organization_members')
.select('id')
@@ -117,6 +117,17 @@ export class ProjectsService {
}
}
+ /** Count active (not soft-deleted) projects of an organization (capability limit input). */
+ async countActiveForOrg(organizationId: string): Promise {
+ const row = await db
+ .selectFrom('projects')
+ .select((eb) => eb.fn.countAll().as('count'))
+ .where('organization_id', '=', organizationId)
+ .where('deleted_at', 'is', null)
+ .executeTakeFirst();
+ return Number(row?.count ?? 0);
+ }
+
/**
* Create a new project
*/
diff --git a/packages/backend/src/modules/receivers/routes.ts b/packages/backend/src/modules/receivers/routes.ts
index a23c7bf70..d85fc3c26 100644
--- a/packages/backend/src/modules/receivers/routes.ts
+++ b/packages/backend/src/modules/receivers/routes.ts
@@ -8,6 +8,8 @@ import { projectsService } from '../projects/service.js';
import { auditLogService } from '../audit-log/index.js';
import { assertWithinLimit, withLimitLock } from '../../capabilities/index.js';
import { CapabilityError } from '../../capabilities/errors.js';
+import { emailVerificationService } from '../users/email-verification.js';
+import { termsAcceptanceService } from '../users/terms.js';
const createReceiverSchema = z
.object({
@@ -73,6 +75,15 @@ export async function receiversRoutes(fastify: FastifyInstance) {
const { projectId } = projectIdSchema.parse(request.params);
const body = createReceiverSchema.parse(request.body);
+ // A receiver mints an ingest credential, so it carries the same gates as
+ // API key creation (both no-ops unless their hosting seam is on).
+ if (await emailVerificationService.isBlocked(request.user.id)) {
+ return reply.status(403).send({ error: 'email_not_verified' });
+ }
+ if (await termsAcceptanceService.isPending(request.user.id)) {
+ return reply.status(403).send({ error: 'terms_not_accepted' });
+ }
+
const project = await projectsService.getProjectById(projectId, request.user.id);
if (!project || project.deletedAt) {
return reply.status(404).send({ error: 'Project not found or access denied' });
diff --git a/packages/backend/src/modules/retention/routes.ts b/packages/backend/src/modules/retention/routes.ts
index 2dd8065ed..9d9789ff8 100644
--- a/packages/backend/src/modules/retention/routes.ts
+++ b/packages/backend/src/modules/retention/routes.ts
@@ -3,6 +3,7 @@ import { z } from 'zod';
import { retentionService } from './service.js';
import { authenticate } from '../auth/middleware.js';
import { requireAdmin } from '../admin/middleware.js';
+import { SERVICE_ACTOR_LABEL } from '../auth/middleware.js';
import { auditLogService } from '../audit-log/index.js';
import { db } from '../../database/index.js';
@@ -36,6 +37,7 @@ export async function retentionRoutes(fastify: FastifyInstance) {
{
preHandler: [requireAdmin],
config: {
+ allowServiceToken: true,
rateLimit: rateLimitConfig,
},
},
@@ -63,6 +65,7 @@ export async function retentionRoutes(fastify: FastifyInstance) {
organizationId: id,
target: { type: 'organization', id },
metadata: {
+ ...(request.servicePrincipal ? { actor: SERVICE_ACTOR_LABEL } : {}),
...(body.retentionDays !== undefined ? { retentionDays: body.retentionDays } : {}),
...(body.auditRetentionDays !== undefined ? { auditRetentionDays: body.auditRetentionDays } : {}),
},
@@ -92,6 +95,7 @@ export async function retentionRoutes(fastify: FastifyInstance) {
{
preHandler: [requireAdmin],
config: {
+ allowServiceToken: true,
rateLimit: rateLimitConfig,
},
},
diff --git a/packages/backend/src/modules/settings/routes.ts b/packages/backend/src/modules/settings/routes.ts
index 1b2b56c54..d47e2f2ea 100644
--- a/packages/backend/src/modules/settings/routes.ts
+++ b/packages/backend/src/modules/settings/routes.ts
@@ -9,6 +9,9 @@ import { z } from 'zod';
import { settingsService, SETTING_KEYS } from './service.js';
import { usersService } from '../users/service.js';
import { bootstrapService } from '../bootstrap/service.js';
+import { isUpgradeHandoffActive } from '../organizations/upgrade-handoff.js';
+import { isEmailVerificationRequired } from '../users/email-verification-flag.js';
+import { getActiveTerms, TERMS_AUTH_FREE_ERROR, TERMS_AUTH_FREE_MESSAGE } from '../users/terms.js';
import type { UserProfile } from '../users/service.js';
import { auditLogService } from '../audit-log/service.js';
@@ -118,6 +121,9 @@ export async function settingsRoutes(fastify: FastifyInstance) {
if (!validKeys.includes(key as any)) {
return reply.status(400).send({ error: `Invalid setting key: ${key}` });
}
+ if (key === SETTING_KEYS.AUTH_MODE && body.value === 'none' && getActiveTerms()) {
+ return reply.status(400).send({ error: TERMS_AUTH_FREE_ERROR, message: TERMS_AUTH_FREE_MESSAGE });
+ }
await settingsService.set(key as any, body.value as any, userId);
@@ -159,6 +165,10 @@ export async function settingsRoutes(fastify: FastifyInstance) {
const body = updateSettingsSchema.parse(request.body);
const userId = (request as any).user?.id;
+ if (body['auth.mode'] === 'none' && getActiveTerms()) {
+ return reply.status(400).send({ error: TERMS_AUTH_FREE_ERROR, message: TERMS_AUTH_FREE_MESSAGE });
+ }
+
await settingsService.setMany(body, userId);
const user = (request as any).user;
@@ -243,6 +253,10 @@ export async function publicSettingsRoutes(fastify: FastifyInstance) {
authMode,
signupEnabled,
requiresLogin: authMode !== 'none',
+ ...(isUpgradeHandoffActive() ? { upgradeEnabled: true } : {}),
+ ...(isEmailVerificationRequired() ? { emailVerificationRequired: true } : {}),
+ // Terms seam: termsUrl, privacyUrl, termsVersion only when active.
+ ...(getActiveTerms() ?? {}),
});
} catch (error) {
console.error('Error fetching auth config:', error);
@@ -251,6 +265,8 @@ export async function publicSettingsRoutes(fastify: FastifyInstance) {
authMode: 'standard',
signupEnabled: true,
requiresLogin: true,
+ ...(isEmailVerificationRequired() ? { emailVerificationRequired: true } : {}),
+ ...(getActiveTerms() ?? {}),
});
}
});
diff --git a/packages/backend/src/modules/traces/service.ts b/packages/backend/src/modules/traces/service.ts
index 3cacec524..fa68d1dce 100644
--- a/packages/backend/src/modules/traces/service.ts
+++ b/packages/backend/src/modules/traces/service.ts
@@ -178,10 +178,10 @@ export class TracesService {
});
}
- // Metering: record ingested span count (fire-and-forget; activates
- // the tracing.max_spans_monthly quota in the capability system).
+ // Metering: record ingested span count and bytes of the spans as stored
+ // (fire-and-forget; feeds tracing.max_spans_monthly and telemetry.max_bytes_monthly).
if (organizationId) {
- recordSpanIngestion({ spanCount: result.ingested, organizationId, projectId });
+ recordSpanIngestion({ spanCount: result.ingested, organizationId, projectId, spans: spansToStore });
}
for (const [, trace] of traces) {
diff --git a/packages/backend/src/modules/users/email-verification-flag.ts b/packages/backend/src/modules/users/email-verification-flag.ts
new file mode 100644
index 000000000..1a820e367
--- /dev/null
+++ b/packages/backend/src/modules/users/email-verification-flag.ts
@@ -0,0 +1,10 @@
+import { config } from '../../config/index.js';
+
+/**
+ * True only when REQUIRE_EMAIL_VERIFICATION=true. Reads the config field
+ * directly (instead of a helper exported by the config module) so modules
+ * that mock the config object keep the gate off by default.
+ */
+export function isEmailVerificationRequired(): boolean {
+ return config.REQUIRE_EMAIL_VERIFICATION === true;
+}
diff --git a/packages/backend/src/modules/users/email-verification.ts b/packages/backend/src/modules/users/email-verification.ts
new file mode 100644
index 000000000..00f2aeafe
--- /dev/null
+++ b/packages/backend/src/modules/users/email-verification.ts
@@ -0,0 +1,159 @@
+import crypto from 'crypto';
+import { sql } from 'kysely';
+import { hub } from '@logtide/core';
+import { db } from '../../database/connection.js';
+import { config } from '../../config/index.js';
+import { isEmailVerificationRequired } from './email-verification-flag.js';
+import { getMailer } from '../../lib/mailer.js';
+import { generateEmailVerificationEmail } from '../../lib/email-templates.js';
+import { isInternalLoggingEnabled } from '../../utils/internal-logger.js';
+
+export const EMAIL_VERIFICATION_TTL_MS = 24 * 60 * 60 * 1000;
+/** A new verification email is not sent if one was issued this recently. */
+export const RESEND_COOLDOWN_MS = 60 * 1000;
+
+export function hashVerificationToken(token: string): string {
+ return crypto.createHash('sha256').update(token).digest('hex');
+}
+
+export type VerifyResult =
+ | { ok: true; userId: string }
+ | { ok: false; reason: 'invalid' };
+
+function log(level: 'info' | 'warn' | 'error', msg: string, meta?: Record): void {
+ if (isInternalLoggingEnabled()) hub.captureLog(level, msg, meta);
+}
+
+export class EmailVerificationService {
+ /** True when the gate is on and the user has not confirmed their email. */
+ async isBlocked(userId: string): Promise {
+ if (!isEmailVerificationRequired()) return false;
+ return !(await this.isVerified(userId));
+ }
+
+ async isVerified(userId: string): Promise {
+ const row = await db
+ .selectFrom('users')
+ .select('email_verified_at')
+ .where('id', '=', userId)
+ .executeTakeFirst();
+ return !!row?.email_verified_at;
+ }
+
+ /**
+ * Issue a fresh single-use token (previous unused tokens for the user are
+ * discarded) and email the link. Returns the raw token. Never throws on
+ * SMTP failure: the user can always ask for a resend.
+ */
+ async issueAndSend(user: { id: string; email: string; name: string }): Promise {
+ const token = crypto.randomBytes(32).toString('base64url');
+ const tokenHash = hashVerificationToken(token);
+
+ await db.transaction().execute(async (trx) => {
+ await trx
+ .deleteFrom('email_verification_tokens')
+ .where('user_id', '=', user.id)
+ .where('used_at', 'is', null)
+ .execute();
+ await trx
+ .insertInto('email_verification_tokens')
+ .values({
+ user_id: user.id,
+ token_hash: tokenHash,
+ expires_at: new Date(Date.now() + EMAIL_VERIFICATION_TTL_MS),
+ })
+ .execute();
+ });
+
+ const mailer = getMailer();
+ if (!mailer) {
+ log('warn', '[EmailVerification] SMTP not configured, verification email not sent', { userId: user.id });
+ return token;
+ }
+ try {
+ const { html, text } = generateEmailVerificationEmail({ name: user.name, token });
+ await mailer.sendMail({
+ from: `"LogTide" <${config.SMTP_FROM}>`,
+ to: user.email,
+ subject: 'Verify your email for LogTide',
+ text,
+ html,
+ });
+ } catch (err) {
+ log('error', '[EmailVerification] failed to send verification email', {
+ userId: user.id,
+ error: err instanceof Error ? err.message : String(err),
+ });
+ }
+ return token;
+ }
+
+ /**
+ * Consume a token. Single use is enforced atomically by the conditional
+ * UPDATE (used_at IS NULL AND not expired). When `expectedUserId` is given
+ * (the caller is signed in) a token belonging to someone else is rejected
+ * WITHOUT being consumed.
+ */
+ async verify(token: string, expectedUserId?: string): Promise {
+ const tokenHash = hashVerificationToken(token);
+
+ return db.transaction().execute(async (trx) => {
+ let query = trx
+ .updateTable('email_verification_tokens')
+ .set({ used_at: sql`NOW()` })
+ .where('token_hash', '=', tokenHash)
+ .where('used_at', 'is', null)
+ .where('expires_at', '>', sql`NOW()`);
+ if (expectedUserId) {
+ query = query.where('user_id', '=', expectedUserId);
+ }
+ const consumed = await query.returning('user_id').executeTakeFirst();
+ if (!consumed) return { ok: false as const, reason: 'invalid' as const };
+
+ await trx
+ .updateTable('users')
+ .set({ email_verified_at: sql`NOW()` })
+ .where('id', '=', consumed.user_id)
+ .where('email_verified_at', 'is', null)
+ .execute();
+
+ return { ok: true as const, userId: consumed.user_id };
+ });
+ }
+
+ /**
+ * Resend for an unverified user, subject to a short cooldown. Silently does
+ * nothing for unknown, disabled or already verified users so callers can
+ * always answer the same way.
+ */
+ async resendForUser(userId: string): Promise {
+ const user = await db
+ .selectFrom('users')
+ .select(['id', 'email', 'name', 'disabled', 'email_verified_at'])
+ .where('id', '=', userId)
+ .executeTakeFirst();
+ if (!user || user.disabled || user.email_verified_at) return;
+
+ const recent = await db
+ .selectFrom('email_verification_tokens')
+ .select('id')
+ .where('user_id', '=', user.id)
+ .where('created_at', '>', new Date(Date.now() - RESEND_COOLDOWN_MS))
+ .executeTakeFirst();
+ if (recent) return;
+
+ await this.issueAndSend(user);
+ }
+
+ async resendForEmail(email: string): Promise {
+ const user = await db
+ .selectFrom('users')
+ .select('id')
+ .where('email', '=', email.toLowerCase().trim())
+ .executeTakeFirst();
+ if (user) await this.resendForUser(user.id);
+ }
+
+}
+
+export const emailVerificationService = new EmailVerificationService();
diff --git a/packages/backend/src/modules/users/routes.ts b/packages/backend/src/modules/users/routes.ts
index a5173ae23..b177e5555 100644
--- a/packages/backend/src/modules/users/routes.ts
+++ b/packages/backend/src/modules/users/routes.ts
@@ -6,6 +6,9 @@ import { AuthError, AuthErrorCode } from '../auth/providers/types.js';
import { usersService } from './service.js';
import { authenticationService } from '../auth/authentication-service.js';
import { config } from '../../config/index.js';
+import { isEmailVerificationRequired } from './email-verification-flag.js';
+import { emailVerificationService } from './email-verification.js';
+import { getActiveTerms, termsAcceptanceService } from './terms.js';
import { settingsService } from '../settings/service.js';
import { bootstrapService } from '../bootstrap/service.js';
import { auditLogService } from '../audit-log/index.js';
@@ -14,6 +17,14 @@ const registerSchema = z.object({
email: z.string().email(),
password: z.string().min(8, 'Password must be at least 8 characters'),
name: z.string().min(1, 'Name is required'),
+ // Must be exactly true only when the terms seam is active. Typed unknown so
+ // an inactive seam never rejects a body it used to accept (unknown keys were
+ // stripped before this field existed).
+ acceptTerms: z.unknown().optional(),
+});
+
+const acceptTermsSchema = z.object({
+ version: z.string().max(64),
});
const loginSchema = z.object({
@@ -28,13 +39,64 @@ const updateUserSchema = z.object({
newPassword: z.string().min(8, 'Password must be at least 8 characters').optional(),
});
+const verifyEmailSchema = z.object({
+ token: z.string().min(1).max(200),
+});
+
+const resendVerificationSchema = z.object({
+ email: z.string().email().optional(),
+});
+
+const ONE_HOUR_MS = 60 * 60 * 1000;
+
+function logVerificationError(message: string, userId: string | null, error: unknown): void {
+ if (isInternalLoggingEnabled()) {
+ hub.captureLog('error', message, {
+ userId,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+}
+
const deleteUserSchema = z.object({
password: z.string().min(1, 'Password is required'),
});
export async function usersRoutes(fastify: FastifyInstance) {
+ // Optional hourly per-IP cap on registrations, layered on top of the
+ // AUTH_RATE_LIMIT_REGISTER route limit. Keyed by IP only (the global key
+ // generator would let a forged Authorization header rotate the key) and
+ // prefixed so it never shares a counter with the other limiters.
+ const registrationHourlyLimit = config.REGISTRATION_RATE_LIMIT_PER_HOUR;
+ const hourlyLimiter =
+ registrationHourlyLimit && fastify.hasDecorator('createRateLimit')
+ ? (fastify as any).createRateLimit({
+ max: registrationHourlyLimit,
+ timeWindow: ONE_HOUR_MS,
+ keyGenerator: (request: { ip: string }) => `registration-hourly:${request.ip}`,
+ })
+ : null;
+ if (registrationHourlyLimit && !hourlyLimiter) {
+ console.warn('[RateLimit] REGISTRATION_RATE_LIMIT_PER_HOUR is set but the rate-limit plugin is not registered');
+ }
+
// Register new user
fastify.post('/register', {
+ ...(hourlyLimiter
+ ? {
+ onRequest: async (request: any, reply: any) => {
+ const result = await hourlyLimiter(request);
+ if (!result.isAllowed && result.isExceeded) {
+ reply.header('retry-after', result.ttlInSeconds);
+ return reply.status(429).send({
+ statusCode: 429,
+ error: 'Too Many Requests',
+ message: 'Too many registrations from this address, please try again later',
+ });
+ }
+ },
+ }
+ : {}),
config: {
rateLimit: {
max: config.AUTH_RATE_LIMIT_REGISTER, // Configurable via AUTH_RATE_LIMIT_REGISTER env var
@@ -54,7 +116,30 @@ export async function usersRoutes(fastify: FastifyInstance) {
const body = registerSchema.parse(request.body);
- const user = await usersService.createUser(body);
+ // Terms seam: when active, the Terms and Privacy Policy must be
+ // accepted explicitly; acceptance is stored with the new user.
+ const terms = getActiveTerms();
+ if (terms && body.acceptTerms !== true) {
+ return reply.status(400).send({ error: 'terms_not_accepted' });
+ }
+
+ const user = await usersService.createUser({
+ email: body.email,
+ password: body.password,
+ name: body.name,
+ ...(terms ? { termsVersion: terms.termsVersion } : {}),
+ });
+
+ // Gate on: email the verification link, fire and forget so the response
+ // never depends on SMTP latency. A send failure never fails the
+ // registration (the user can request a resend from the app).
+ if (isEmailVerificationRequired()) {
+ void emailVerificationService
+ .issueAndSend(user)
+ .catch((verificationError) =>
+ logVerificationError('[Auth] Failed to issue email verification token', user.id, verificationError)
+ );
+ }
// Automatically log in the new user through the provider registry.
// This also creates the user_identities link for the local provider.
@@ -93,6 +178,7 @@ export async function usersRoutes(fastify: FastifyInstance) {
email: user.email,
name: user.name,
is_admin: user.is_admin,
+ ...(isEmailVerificationRequired() ? { emailVerified: false } : {}),
},
...(session ? {
session: {
@@ -269,6 +355,13 @@ export async function usersRoutes(fastify: FastifyInstance) {
});
}
+ // Read fresh from the DB (the session cache would lag behind a verify).
+ const emailVerified = isEmailVerificationRequired()
+ ? await emailVerificationService.isVerified(user.id)
+ : undefined;
+ // Terms seam: key present (true) only while the current version is not accepted.
+ const termsPending = await termsAcceptanceService.isPending(user.id);
+
return reply.send({
user: {
id: user.id,
@@ -277,6 +370,8 @@ export async function usersRoutes(fastify: FastifyInstance) {
is_admin: user.is_admin,
createdAt: user.createdAt,
lastLogin: user.lastLogin,
+ ...(emailVerified !== undefined ? { emailVerified } : {}),
+ ...(termsPending ? { termsAcceptanceRequired: true } : {}),
},
});
});
@@ -311,6 +406,16 @@ export async function usersRoutes(fastify: FastifyInstance) {
const updatedUser = await usersService.updateUser(currentUser.id, body);
+ // Gate on: a changed address must be confirmed again (updateUser already
+ // cleared email_verified_at in the same UPDATE that changed the email).
+ if (isEmailVerificationRequired() && updatedUser.email !== currentUser.email) {
+ try {
+ await emailVerificationService.issueAndSend(updatedUser);
+ } catch (verificationError) {
+ logVerificationError('[Auth] Failed to issue email verification token', updatedUser.id, verificationError);
+ }
+ }
+
await auditLogService.record({
action: 'user.profile_updated',
organizationId: null,
@@ -406,4 +511,124 @@ export async function usersRoutes(fastify: FastifyInstance) {
throw error;
}
});
+ // Terms acceptance route exists only when the terms seam is active. It covers
+ // users who never saw the registration checkbox (pre-existing, SSO) and
+ // re-acceptance after LOGTIDE_TERMS_VERSION changes.
+ if (getActiveTerms()) {
+ fastify.post('/accept-terms', {
+ config: {
+ rateLimit: {
+ max: config.AUTH_RATE_LIMIT_LOGIN,
+ timeWindow: config.AUTH_RATE_LIMIT_WINDOW,
+ },
+ },
+ handler: async (request, reply) => {
+ const token = request.headers.authorization?.replace('Bearer ', '');
+ if (!token) {
+ return reply.status(401).send({ error: 'No token provided' });
+ }
+ const user = await usersService.validateSession(token);
+ if (!user) {
+ return reply.status(401).send({ error: 'Invalid or expired session' });
+ }
+
+ // Read at call time: a version bump must be accepted explicitly, an
+ // acceptance of a stale version is refused.
+ const terms = getActiveTerms();
+ const parsed = acceptTermsSchema.safeParse(request.body ?? {});
+ if (!terms || !parsed.success || parsed.data.version !== terms.termsVersion) {
+ return reply.status(409).send({ error: 'terms_version_mismatch' });
+ }
+
+ await termsAcceptanceService.accept(user.id, terms.termsVersion);
+
+ await auditLogService.record({
+ action: 'user.terms_accepted',
+ target: { type: 'user', id: user.id },
+ organizationId: null,
+ actor: { type: 'user', id: user.id, label: user.email },
+ metadata: { version: terms.termsVersion },
+ });
+
+ return reply.send({ accepted: true, termsVersion: terms.termsVersion });
+ },
+ });
+ }
+
+ // Email verification routes exist only when the gate is on.
+ if (isEmailVerificationRequired()) {
+ // Consume a verification token. The token is the credential; when the
+ // caller is also signed in, a token belonging to another user is rejected
+ // without being consumed.
+ fastify.post('/verify-email', {
+ config: {
+ rateLimit: {
+ max: config.AUTH_RATE_LIMIT_LOGIN,
+ timeWindow: config.AUTH_RATE_LIMIT_WINDOW,
+ keyGenerator: (request: { ip: string }) => request.ip,
+ },
+ },
+ handler: async (request, reply) => {
+ const parsed = verifyEmailSchema.safeParse(request.body);
+ if (!parsed.success) {
+ return reply.status(400).send({ error: 'invalid_or_expired_token' });
+ }
+
+ const bearer = request.headers.authorization?.replace('Bearer ', '');
+ const sessionUser = bearer ? await usersService.validateSession(bearer) : null;
+
+ const result = await emailVerificationService.verify(parsed.data.token, sessionUser?.id);
+ if (!result.ok) {
+ return reply.status(400).send({ error: 'invalid_or_expired_token' });
+ }
+
+ await auditLogService.record({
+ action: 'user.email_verified',
+ target: { type: 'user', id: result.userId },
+ organizationId: null,
+ actor: { type: 'user', id: result.userId, label: null },
+ });
+
+ return reply.send({ verified: true });
+ },
+ });
+
+ // Resend the verification email. Signed-in callers resend for themselves;
+ // otherwise an email in the body is looked up. The answer is always 202 so
+ // it never reveals whether an address is registered or already verified.
+ fastify.post('/resend-verification', {
+ config: {
+ rateLimit: {
+ max: 5,
+ timeWindow: ONE_HOUR_MS,
+ keyGenerator: (request: { ip: string }) => request.ip,
+ },
+ },
+ handler: async (request, reply) => {
+ const bearer = request.headers.authorization?.replace('Bearer ', '');
+ const sessionUser = bearer ? await usersService.validateSession(bearer) : null;
+
+ // Fire and forget: the lookup, token write and SMTP send all run after
+ // the response, so its timing never reveals whether the address exists.
+ let work: Promise | null = null;
+ if (sessionUser) {
+ work = emailVerificationService.resendForUser(sessionUser.id);
+ } else {
+ const parsed = resendVerificationSchema.safeParse(request.body ?? {});
+ if (parsed.success && parsed.data.email) {
+ work = emailVerificationService.resendForEmail(parsed.data.email);
+ }
+ }
+ if (work) {
+ void work.catch((error) =>
+ logVerificationError('[Auth] Resend verification failed', sessionUser?.id ?? null, error)
+ );
+ }
+
+ return reply.status(202).send({
+ message: 'If the account exists and is not verified yet, a new verification email has been sent.',
+ });
+ },
+ });
+ }
}
diff --git a/packages/backend/src/modules/users/service.ts b/packages/backend/src/modules/users/service.ts
index 2c5819f11..d72267075 100644
--- a/packages/backend/src/modules/users/service.ts
+++ b/packages/backend/src/modules/users/service.ts
@@ -3,6 +3,7 @@ import crypto from 'crypto';
import { sql } from 'kysely';
import { db } from '../../database/connection.js';
import { CacheManager, CACHE_TTL } from '../../utils/cache.js';
+import { isEmailVerificationRequired } from './email-verification-flag.js';
const SALT_ROUNDS = 10;
@@ -15,6 +16,8 @@ export interface CreateUserInput {
email: string;
password: string;
name: string;
+ /** Terms version accepted at registration (terms seam active only). */
+ termsVersion?: string;
}
export interface SessionInfo {
@@ -127,6 +130,11 @@ export class UsersService {
password_hash: passwordHash,
name: input.name,
is_admin: shouldBeAdmin,
+ // Column default (NOW()) marks the user verified; only an enabled gate
+ // starts a local registration as unverified.
+ ...(isEmailVerificationRequired() ? { email_verified_at: null } : {}),
+ // Terms seam: acceptance is recorded in the same insert.
+ ...(input.termsVersion ? { terms_accepted_at: sql`NOW()`, terms_version: input.termsVersion } : {}),
})
.returning(['id', 'email', 'name', 'is_admin', 'disabled', 'created_at', 'last_login'])
.executeTakeFirstOrThrow();
@@ -333,6 +341,10 @@ export class UsersService {
const updateData: any = {};
if (input.name) updateData.name = input.name;
if (normalizedEmail) updateData.email = normalizedEmail;
+ // Gate on: a new address starts unverified, written atomically with the email.
+ if (normalizedEmail && normalizedEmail !== user.email && isEmailVerificationRequired()) {
+ updateData.email_verified_at = null;
+ }
if (input.newPassword) {
updateData.password_hash = await this.hashPassword(input.newPassword);
}
diff --git a/packages/backend/src/modules/users/terms.ts b/packages/backend/src/modules/users/terms.ts
new file mode 100644
index 000000000..4b0bd0adf
--- /dev/null
+++ b/packages/backend/src/modules/users/terms.ts
@@ -0,0 +1,70 @@
+import { sql } from 'kysely';
+import { db } from '../../database/connection.js';
+import { config } from '../../config/index.js';
+
+export interface ActiveTerms {
+ termsUrl: string;
+ privacyUrl: string;
+ termsVersion: string;
+}
+
+type TermsConfig = { LOGTIDE_TERMS_URL?: string; LOGTIDE_PRIVACY_URL?: string; LOGTIDE_TERMS_VERSION?: string };
+
+/**
+ * The configured terms when the seam is active (all three env vars set), null
+ * otherwise. Reads the config fields at call time (not a helper exported by the
+ * config module) so suites that mock the config object keep the feature off.
+ */
+export function getActiveTerms(): ActiveTerms | null {
+ const c = config as TermsConfig;
+ if (!c.LOGTIDE_TERMS_URL || !c.LOGTIDE_PRIVACY_URL || !c.LOGTIDE_TERMS_VERSION) return null;
+ return { termsUrl: c.LOGTIDE_TERMS_URL, privacyUrl: c.LOGTIDE_PRIVACY_URL, termsVersion: c.LOGTIDE_TERMS_VERSION };
+}
+
+export class TermsAcceptanceService {
+ /**
+ * True when the seam is active and the user has not accepted the current
+ * version (NULL included). Zero queries when inactive. Read fresh from the
+ * DB: the session cache would lag behind an accept.
+ */
+ async isPending(userId: string): Promise {
+ const terms = getActiveTerms();
+ if (!terms) return false;
+ const row = await db
+ .selectFrom('users')
+ .select('terms_version')
+ .where('id', '=', userId)
+ .executeTakeFirst();
+ return row?.terms_version !== terms.termsVersion;
+ }
+
+ /** Record acceptance of `version` for the user. */
+ async accept(userId: string, version: string): Promise {
+ await db
+ .updateTable('users')
+ .set({ terms_accepted_at: sql`NOW()`, terms_version: version })
+ .where('id', '=', userId)
+ .execute();
+ }
+}
+
+export const termsAcceptanceService = new TermsAcceptanceService();
+
+/** Error code for an auth-free mode request while the terms seam is active. */
+export const TERMS_AUTH_FREE_ERROR = 'terms_incompatible_with_auth_free_mode';
+export const TERMS_AUTH_FREE_MESSAGE =
+ 'Terms acceptance (LOGTIDE_TERMS_URL, LOGTIDE_PRIVACY_URL, LOGTIDE_TERMS_VERSION) cannot be combined with ' +
+ 'auth-free mode: the default user has no session and could never accept the terms. Switch the auth mode ' +
+ 'back to standard or unset the LOGTIDE_TERMS_* variables.';
+
+/**
+ * Refuses to start when the terms seam is active and the stored auth mode is
+ * 'none'. No query when the seam is inactive.
+ */
+export async function assertTermsCompatibleWithAuthMode(): Promise {
+ if (!getActiveTerms()) return;
+ const { settingsService } = await import('../settings/service.js');
+ if ((await settingsService.getAuthMode()) === 'none') {
+ throw new Error(TERMS_AUTH_FREE_MESSAGE);
+ }
+}
diff --git a/packages/backend/src/server.ts b/packages/backend/src/server.ts
index ccc9f346f..953f84bb5 100644
--- a/packages/backend/src/server.ts
+++ b/packages/backend/src/server.ts
@@ -14,6 +14,7 @@ import { detectionPacksRoutes } from './modules/detection-packs/index.js';
import { usersRoutes } from './modules/users/routes.js';
import { projectsRoutes } from './modules/projects/routes.js';
import { organizationsRoutes } from './modules/organizations/routes.js';
+import { upgradeHintFor } from './modules/organizations/upgrade-handoff.js';
import { invitationsRoutes } from './modules/invitations/routes.js';
import { notificationsRoutes } from './modules/notifications/routes.js';
import { apiKeysRoutes } from './modules/api-keys/routes.js';
@@ -48,6 +49,7 @@ import { sessionsRoutes } from './modules/sessions/routes.js';
import { sourcemapsRoutes } from './modules/sourcemaps/index.js';
import { auditLogRoutes, auditLogService } from './modules/audit-log/index.js';
import { bootstrapService } from './modules/bootstrap/index.js';
+import { assertTermsCompatibleWithAuthMode } from './modules/users/terms.js';
import { runDataAvailabilityBackfill } from './modules/projects/data-availability-backfill.js';
import { notificationChannelsRoutes } from './modules/notification-channels/index.js';
import { digestsRoutes } from './modules/digests/routes.js';
@@ -62,6 +64,7 @@ import { enrichmentService } from './modules/siem/enrichment-service.js';
import { validateStorageConfig } from './database/storage-config.js';
import { shutdownReservoir } from './database/reservoir.js';
import { readFileSync } from 'fs';
+import { createHash } from 'node:crypto';
import { fileURLToPath } from 'url';
import path from 'path';
@@ -73,7 +76,22 @@ const HOST = config.HOST;
const quotaEvaluator = new QuotaEvaluator(meteringService);
+/**
+ * Rate-limit key: per credential when one is sent, else per client IP. The
+ * credential is hashed so raw API keys and bearer tokens never land in the
+ * rate-limit store (Redis keys are visible to anyone with Redis access).
+ */
+export function rateLimitKeyGenerator(request: { headers: Record; ip: string }): string {
+ const credential = request.headers['x-api-key'] || request.headers['authorization']?.replace('Bearer ', '');
+ if (!credential) return request.ip;
+ return `key:${createHash('sha256').update(String(credential)).digest('hex').slice(0, 32)}`;
+}
+
export async function build(opts = {}) {
+ // Terms seam + auth-free mode can never work together (the default user
+ // could never accept): refuse to build the app. No query when inactive.
+ await assertTermsCompatibleWithAuthMode();
+
const fastify = Fastify({
logger: true,
bodyLimit: 10 * 1024 * 1024,
@@ -112,6 +130,7 @@ export async function build(opts = {}) {
if (typeof (error as any).code === 'string') {
body.code = (error as any).code;
}
+ Object.assign(body, upgradeHintFor(error));
reply.code(statusCode).send(body);
return;
}
@@ -141,11 +160,6 @@ export async function build(opts = {}) {
crossOriginEmbedderPolicy: false,
});
- const rateLimitKeyGenerator = (request: any) => {
- const apiKey = request.headers['x-api-key'] || request.headers['authorization']?.replace('Bearer ', '');
- return apiKey ? `key:${apiKey}` : request.ip;
- };
-
const redisConn = getConnection();
if (isRedisConfigured() && redisConn) {
await fastify.register(rateLimit, {
@@ -248,6 +262,9 @@ async function start() {
quotaEvaluator.start();
storageSnapshotJob.start();
+ // Fail before the auth-free default setup runs (build() checks again).
+ await assertTermsCompatibleWithAuthMode();
+
const authMode = await settingsService.getAuthMode();
if (authMode === 'none') {
console.log('[Auth] Auth-free mode detected, ensuring default setup...');
diff --git a/packages/backend/src/tests/capabilities/bigint-limits.test.ts b/packages/backend/src/tests/capabilities/bigint-limits.test.ts
new file mode 100644
index 000000000..63158084b
--- /dev/null
+++ b/packages/backend/src/tests/capabilities/bigint-limits.test.ts
@@ -0,0 +1,102 @@
+import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
+import type { FastifyInstance } from 'fastify';
+import { db } from '../../database/index.js';
+import { reservoirReady } from '../../database/reservoir.js';
+import { build } from '../../server.js';
+import { capabilities, quotaFlagCache } from '../../capabilities/index.js';
+import { QuotaEvaluator } from '../../capabilities/quota-evaluator.js';
+import { createTestSession } from '../helpers/auth.js';
+import { createTestApiKey, createTestContext, createTestUser } from '../helpers/factories.js';
+
+// 10 GiB: well above 2^31 - 1, the old INTEGER ceiling of limit_value.
+const TEN_GIB = 10 * 1024 * 1024 * 1024;
+
+describe('entitlement limits above 2 GiB (bigint limit_value)', () => {
+ let app: FastifyInstance;
+ let adminHeaders: Record;
+
+ beforeAll(async () => {
+ await reservoirReady;
+ app = await build({ logger: false });
+ await app.ready();
+ });
+
+ afterAll(async () => {
+ await app.close();
+ });
+
+ beforeEach(async () => {
+ await db.deleteFrom('organization_entitlements').execute();
+ quotaFlagCache.clear();
+ const admin = await createTestUser({ email: `admin-${Date.now()}-${Math.random()}@example.com` });
+ await db.updateTable('users').set({ is_admin: true }).where('id', '=', admin.id).execute();
+ const session = await createTestSession(admin.id);
+ adminHeaders = { authorization: `Bearer ${session.token}` };
+ });
+
+ async function putLimit(orgId: string, limitValue: number) {
+ return app.inject({
+ method: 'PUT',
+ url: `/api/v1/admin/organizations/${orgId}/entitlements`,
+ headers: adminHeaders,
+ payload: { entitlements: [{ capability: 'ingestion.max_bytes_monthly', limitValue }] },
+ });
+ }
+
+ it('PUT accepts a 10 GiB limit and GET returns it as a number', async () => {
+ const ctx = await createTestContext();
+ const orgId = ctx.organization.id;
+
+ const put = await putLimit(orgId, TEN_GIB);
+ expect(put.statusCode).toBe(200);
+
+ const get = await app.inject({
+ method: 'GET',
+ url: `/api/v1/admin/organizations/${orgId}/entitlements`,
+ headers: adminHeaders,
+ });
+ expect(get.statusCode).toBe(200);
+ const value = get.json().entitlements['ingestion.max_bytes_monthly'];
+ expect(value).toEqual({ kind: 'quota', limit: TEN_GIB });
+ expect(typeof value.limit).toBe('number');
+
+ capabilities.invalidate(orgId);
+ const limit = await capabilities.getLimit(orgId, 'ingestion.max_bytes_monthly');
+ expect(limit).toBe(TEN_GIB);
+ expect(typeof limit).toBe('number');
+ });
+
+ it('enforces a 10 GiB limit: under passes, over is 429 through the real app', async () => {
+ const ctx = await createTestContext();
+ const orgId = ctx.organization.id;
+ const key = await createTestApiKey({ projectId: ctx.project.id });
+ expect((await putLimit(orgId, TEN_GIB)).statusCode).toBe(200);
+
+ const ingest = () =>
+ app.inject({
+ method: 'POST',
+ url: '/api/v1/ingest',
+ headers: { 'x-api-key': key.plainKey, 'content-type': 'application/json' },
+ payload: { logs: [{ time: new Date().toISOString(), service: 'api', level: 'info', message: 'x' }] },
+ });
+
+ const stub = (bytes: number) => ({
+ async aggregate(params: { type?: string }) {
+ return params.type === 'logs.ingested.bytes' ? [{ type: params.type, project_id: null, quantity: bytes }] : [];
+ },
+ async latestPointInTime() {
+ return 0;
+ },
+ });
+
+ // 9 GiB used of 10 GiB: under quota (an int4 or string limit would misbehave here).
+ await new QuotaEvaluator(stub(9 * 1024 * 1024 * 1024) as any).runOnce();
+ expect(quotaFlagCache.isOverQuota(orgId, 'ingestion.max_bytes_monthly')).toBe(false);
+ expect((await ingest()).statusCode).toBe(200);
+
+ // 10 GiB + 1 used: blocked.
+ await new QuotaEvaluator(stub(TEN_GIB + 1) as any).runOnce();
+ expect(quotaFlagCache.isOverQuota(orgId, 'ingestion.max_bytes_monthly')).toBe(true);
+ expect((await ingest()).statusCode).toBe(429);
+ });
+});
diff --git a/packages/backend/src/tests/capabilities/defaults-override.test.ts b/packages/backend/src/tests/capabilities/defaults-override.test.ts
new file mode 100644
index 000000000..eef803546
--- /dev/null
+++ b/packages/backend/src/tests/capabilities/defaults-override.test.ts
@@ -0,0 +1,125 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { db } from '../../database/index.js';
+import {
+ CAPABILITIES,
+ applyDefaultOverrides,
+ resetDefaultOverrides,
+} from '../../capabilities/registry.js';
+import { QuotaEvaluator } from '../../capabilities/quota-evaluator.js';
+import { quotaFlagCache } from '../../capabilities/quota-cache.js';
+import { capabilities } from '../../capabilities/facade.js';
+import type { UsageRow } from '../../modules/metering/index.js';
+import { createTestContext } from '../helpers/factories.js';
+
+const MIB = 1024 * 1024;
+
+function meteringStub(bytesByOrg: Record) {
+ return {
+ async aggregate(params: { organizationId: string; type?: string }): Promise {
+ if (params.type !== 'logs.ingested.bytes') return [];
+ const q = bytesByOrg[params.organizationId];
+ return q === undefined ? [] : [{ type: params.type, project_id: null, quantity: q }];
+ },
+ async latestPointInTime(): Promise {
+ return 0;
+ },
+ };
+}
+
+describe('CAPABILITY_DEFAULTS_JSON overrides', () => {
+ beforeEach(async () => {
+ resetDefaultOverrides();
+ await db.deleteFrom('organization_entitlements').execute();
+ quotaFlagCache.clear();
+ });
+ afterEach(() => {
+ resetDefaultOverrides();
+ });
+
+ it('(a) absent env leaves registry defaults untouched', () => {
+ applyDefaultOverrides(undefined);
+ applyDefaultOverrides('');
+ expect(CAPABILITIES['ingestion.max_bytes_monthly'].defaultLimit).toBeNull();
+ expect(CAPABILITIES['auth.sso'].defaultEnabled).toBe(true);
+ });
+
+ it('applies boolean, number and null overrides', () => {
+ applyDefaultOverrides(
+ JSON.stringify({
+ 'ingestion.max_bytes_monthly': MIB,
+ 'alerts.max_rules': 3,
+ 'auth.sso': false,
+ 'apikeys.max': null,
+ })
+ );
+ expect(CAPABILITIES['ingestion.max_bytes_monthly'].defaultLimit).toBe(MIB);
+ expect(CAPABILITIES['alerts.max_rules'].defaultLimit).toBe(3);
+ expect(CAPABILITIES['auth.sso'].defaultEnabled).toBe(false);
+ expect(CAPABILITIES['apikeys.max'].defaultLimit).toBeNull();
+ });
+
+ it('(d) unknown key fails with an explicit message', () => {
+ expect(() => applyDefaultOverrides('{"nope.nothing": 1}')).toThrow(/CAPABILITY_DEFAULTS_JSON.*nope\.nothing/);
+ });
+
+ it('rejects malformed JSON, non-objects and wrong value types', () => {
+ expect(() => applyDefaultOverrides('{bad')).toThrow(/CAPABILITY_DEFAULTS_JSON/);
+ expect(() => applyDefaultOverrides('[1]')).toThrow(/CAPABILITY_DEFAULTS_JSON/);
+ expect(() => applyDefaultOverrides('{"alerts.max_rules": true}')).toThrow(/alerts\.max_rules/);
+ expect(() => applyDefaultOverrides('{"alerts.max_rules": -1}')).toThrow(/alerts\.max_rules/);
+ expect(() => applyDefaultOverrides('{"alerts.max_rules": 1.5}')).toThrow(/alerts\.max_rules/);
+ expect(() => applyDefaultOverrides('{"auth.sso": 5}')).toThrow(/auth\.sso/);
+ expect(() => applyDefaultOverrides('{"auth.sso": null}')).toThrow(/auth\.sso/);
+ });
+
+ it('a rejected document applies nothing (atomic)', () => {
+ expect(() => applyDefaultOverrides('{"alerts.max_rules": 3, "bogus": 1}')).toThrow();
+ expect(CAPABILITIES['alerts.max_rules'].defaultLimit).toBeNull();
+ });
+
+ it('(b) org with no entitlement rows is evaluated and blocked by a default quota', async () => {
+ const { organization } = await createTestContext();
+ applyDefaultOverrides(JSON.stringify({ 'ingestion.max_bytes_monthly': MIB }));
+ capabilities.invalidate(organization.id);
+
+ const ev = new QuotaEvaluator(meteringStub({ [organization.id]: 2 * MIB }));
+ await ev.runOnce();
+ expect(quotaFlagCache.isOverQuota(organization.id, 'ingestion.max_bytes_monthly')).toBe(true);
+ });
+
+ it('(c) explicit null row means unlimited despite a finite default', async () => {
+ const { organization } = await createTestContext();
+ applyDefaultOverrides(JSON.stringify({ 'ingestion.max_bytes_monthly': MIB }));
+ await db
+ .insertInto('organization_entitlements')
+ .values({
+ organization_id: organization.id,
+ capability: 'ingestion.max_bytes_monthly',
+ enabled: null,
+ limit_value: null,
+ } as never)
+ .execute();
+ capabilities.invalidate(organization.id);
+
+ expect(await capabilities.getLimit(organization.id, 'ingestion.max_bytes_monthly')).toBeNull();
+ const ev = new QuotaEvaluator(meteringStub({ [organization.id]: 5 * MIB }));
+ await ev.runOnce();
+ expect(quotaFlagCache.isOverQuota(organization.id, 'ingestion.max_bytes_monthly')).toBe(false);
+ });
+
+ it('(e) without quota defaults only orgs with rows are queried', async () => {
+ const { organization } = await createTestContext();
+ capabilities.invalidate(organization.id);
+ const calls: string[] = [];
+ const stub = meteringStub({ [organization.id]: 5 * MIB });
+ const spy = {
+ aggregate: async (p: { organizationId: string; type?: string }) => {
+ calls.push(p.organizationId);
+ return stub.aggregate(p);
+ },
+ latestPointInTime: stub.latestPointInTime,
+ };
+ await new QuotaEvaluator(spy).runOnce();
+ expect(calls).not.toContain(organization.id);
+ });
+});
diff --git a/packages/backend/src/tests/capabilities/migration.test.ts b/packages/backend/src/tests/capabilities/migration.test.ts
index 0ad1995c1..225923a87 100644
--- a/packages/backend/src/tests/capabilities/migration.test.ts
+++ b/packages/backend/src/tests/capabilities/migration.test.ts
@@ -42,7 +42,8 @@ describe('organization_entitlements table', () => {
.where('capability', '=', 'alerts.max_rules')
.executeTakeFirst();
- expect(row?.limit_value).toBe(5);
+ // BIGINT since migration 059: the pg driver returns int8 as a string.
+ expect(row?.limit_value).toBe('5');
expect(row?.enabled).toBeNull();
});
diff --git a/packages/backend/src/tests/capabilities/month.test.ts b/packages/backend/src/tests/capabilities/month.test.ts
new file mode 100644
index 000000000..9335cee44
--- /dev/null
+++ b/packages/backend/src/tests/capabilities/month.test.ts
@@ -0,0 +1,11 @@
+import { describe, it, expect } from 'vitest';
+import { startOfUtcMonth } from '../../capabilities/month.js';
+
+describe('startOfUtcMonth', () => {
+ it('returns midnight UTC on the first of the month', () => {
+ expect(startOfUtcMonth(new Date('2026-10-19T23:59:59.999Z')).toISOString()).toBe('2026-10-01T00:00:00.000Z');
+ expect(startOfUtcMonth(new Date('2026-01-01T00:00:00.000Z')).toISOString()).toBe('2026-01-01T00:00:00.000Z');
+ // 2026-03-01T00:30 in UTC+1 is still February in UTC
+ expect(startOfUtcMonth(new Date('2026-02-28T23:30:00.000Z')).toISOString()).toBe('2026-02-01T00:00:00.000Z');
+ });
+});
diff --git a/packages/backend/src/tests/capabilities/quota-grace.test.ts b/packages/backend/src/tests/capabilities/quota-grace.test.ts
new file mode 100644
index 000000000..cf26169e2
--- /dev/null
+++ b/packages/backend/src/tests/capabilities/quota-grace.test.ts
@@ -0,0 +1,182 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+import { db } from '../../database/index.js';
+import { QuotaEvaluator } from '../../capabilities/quota-evaluator.js';
+import { quotaFlagCache } from '../../capabilities/quota-cache.js';
+import { capabilities } from '../../capabilities/facade.js';
+import { computeQuotaState } from '../../capabilities/quota-state.js';
+import { formatQuotaValue, notifyQuotaThreshold } from '../../capabilities/quota-notifier.js';
+import { createTestContext } from '../helpers/factories.js';
+
+const CAP = 'ingestion.max_events_monthly';
+
+function stubFor(orgId: string, getUsage: () => number) {
+ return {
+ async aggregate() {
+ return [{ type: 'logs.ingested.events', project_id: null, quantity: getUsage() }];
+ },
+ async latestPointInTime() {
+ return 0;
+ },
+ } as any;
+}
+
+async function setLimit(orgId: string, capability: string, limit: number) {
+ await db
+ .insertInto('organization_entitlements')
+ .values({ organization_id: orgId, capability, enabled: null, limit_value: limit })
+ .execute();
+ capabilities.invalidate(orgId);
+}
+
+async function quotaNotifications(orgId: string, state?: string) {
+ const rows = await db
+ .selectFrom('notifications')
+ .selectAll()
+ .where('organization_id', '=', orgId)
+ .execute();
+ return rows.filter(
+ (r) => (r.metadata as any)?.kind === 'quota_threshold' && (!state || (r.metadata as any).state === state)
+ );
+}
+
+describe('computeQuotaState', () => {
+ it('collapses to blocked at the limit without grace', () => {
+ expect(computeQuotaState(100, 100, null)).toBe('blocked');
+ expect(computeQuotaState(99, 100, null)).toBe('ok');
+ expect(computeQuotaState(5, null, 10)).toBe('unlimited');
+ });
+ it('has a grace band with grace percent', () => {
+ expect(computeQuotaState(100, 100, 10)).toBe('grace');
+ expect(computeQuotaState(105, 100, 10)).toBe('grace');
+ expect(computeQuotaState(110, 100, 10)).toBe('blocked');
+ });
+});
+
+describe('formatQuotaValue', () => {
+ it('formats counts and bytes in en-US', () => {
+ expect(formatQuotaValue('ingestion.max_events_monthly', 1234567)).toBe('1,234,567');
+ expect(formatQuotaValue('ingestion.max_bytes_monthly', 1536 * 1024)).toBe('1.5 MB');
+ });
+
+ it('formats metric datapoints as a plain count, not bytes', () => {
+ expect(formatQuotaValue('metrics.max_datapoints_monthly', 2048)).toBe('2,048');
+ expect(formatQuotaValue('metrics.max_datapoints_monthly', 5_000_000)).toBe('5,000,000');
+ });
+});
+
+describe('QuotaEvaluator grace and notifications', () => {
+ let orgId: string;
+
+ beforeEach(async () => {
+ await db.deleteFrom('organization_entitlements').execute();
+ await fetch('http://localhost:8025/api/v1/messages', { method: 'DELETE' }).catch(() => {});
+ quotaFlagCache.clear();
+ const ctx = await createTestContext();
+ orgId = ctx.organization.id;
+ capabilities.invalidate(orgId);
+ });
+
+ it('105/100 with 10% grace is not blocked, 110 is blocked', async () => {
+ await setLimit(orgId, CAP, 100);
+ await setLimit(orgId, 'quota.grace_percent', 10);
+ let usage = 105;
+ const ev = new QuotaEvaluator(stubFor(orgId, () => usage));
+ await ev.runOnce();
+ expect(quotaFlagCache.isOverQuota(orgId, CAP)).toBe(false);
+ usage = 110;
+ await ev.runOnce();
+ expect(quotaFlagCache.isOverQuota(orgId, CAP)).toBe(true);
+ });
+
+ it('without grace the behaviour is unchanged (blocked at the limit)', async () => {
+ await setLimit(orgId, CAP, 100);
+ const ev = new QuotaEvaluator(stubFor(orgId, () => 100));
+ await ev.runOnce();
+ expect(quotaFlagCache.isOverQuota(orgId, CAP)).toBe(true);
+ });
+
+ it('notifies once per state per month across ticks (in-app and email)', async () => {
+ await setLimit(orgId, CAP, 100);
+ await setLimit(orgId, 'quota.grace_percent', 10);
+ let usage = 105;
+ const ev = new QuotaEvaluator(stubFor(orgId, () => usage));
+
+ await ev.runOnce();
+ await ev.runOnce();
+ expect(await quotaNotifications(orgId, 'grace')).toHaveLength(1);
+ expect(await quotaNotifications(orgId, 'blocked')).toHaveLength(0);
+
+ usage = 120;
+ await ev.runOnce();
+ await ev.runOnce();
+ expect(await quotaNotifications(orgId, 'grace')).toHaveLength(1);
+ expect(await quotaNotifications(orgId, 'blocked')).toHaveLength(1);
+
+ const res = await fetch('http://localhost:8025/api/v2/messages');
+ const body: any = await res.json();
+ const mine = body.items.filter((m: any) =>
+ String(m.Content.Headers.Subject?.[0] ?? '').includes(CAP)
+ );
+ // one email per state, no matter how many ticks
+ expect(mine).toHaveLength(2);
+ });
+
+ it('a notification from last month does not suppress this month', async () => {
+ await setLimit(orgId, CAP, 100);
+ const ev = new QuotaEvaluator(stubFor(orgId, () => 150));
+ await ev.runOnce();
+ await db
+ .updateTable('notifications')
+ .set({ created_at: new Date(Date.UTC(2020, 0, 15)) })
+ .where('organization_id', '=', orgId)
+ .execute();
+ await ev.runOnce();
+ expect(await quotaNotifications(orgId, 'blocked')).toHaveLength(2);
+ });
+ it('a raised limit in the same month notifies again at the new ceiling', async () => {
+ await setLimit(orgId, CAP, 100);
+ let usage = 150;
+ const ev = new QuotaEvaluator(stubFor(orgId, () => usage));
+ await ev.runOnce();
+ expect(await quotaNotifications(orgId, 'blocked')).toHaveLength(1);
+
+ // Upgrade mid-month: limit 100 -> 200 (upsert like the admin route does).
+ await db
+ .updateTable('organization_entitlements')
+ .set({ limit_value: 200 })
+ .where('organization_id', '=', orgId)
+ .where('capability', '=', CAP)
+ .execute();
+ capabilities.invalidate(orgId);
+
+ usage = 180; // under the new ceiling: no new notification
+ await ev.runOnce();
+ expect(await quotaNotifications(orgId, 'blocked')).toHaveLength(1);
+
+ usage = 250; // crosses the new ceiling: notified again, once
+ await ev.runOnce();
+ await ev.runOnce();
+ const blocked = await quotaNotifications(orgId, 'blocked');
+ expect(blocked).toHaveLength(2);
+ expect(blocked.map((r) => (r.metadata as any).limit).sort()).toEqual([100, 200]);
+ });
+
+ it('concurrent evaluations (several replicas) send one notification and one email', async () => {
+ const params = {
+ organizationId: orgId,
+ capability: CAP as typeof CAP,
+ state: 'blocked' as const,
+ usage: 150,
+ limit: 100,
+ gracePercent: 0,
+ now: new Date(),
+ };
+ await Promise.all(Array.from({ length: 8 }, () => notifyQuotaThreshold(params)));
+
+ expect(await quotaNotifications(orgId, 'blocked')).toHaveLength(1);
+ const res = await fetch('http://localhost:8025/api/v2/messages');
+ const body: any = await res.json();
+ const mine = body.items.filter((m: any) => String(m.Content.Headers.Subject?.[0] ?? '').includes(CAP));
+ expect(mine).toHaveLength(1);
+ });
+});
diff --git a/packages/backend/src/tests/capabilities/registry.test.ts b/packages/backend/src/tests/capabilities/registry.test.ts
index b4fb7ca86..f4c016261 100644
--- a/packages/backend/src/tests/capabilities/registry.test.ts
+++ b/packages/backend/src/tests/capabilities/registry.test.ts
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
import {
CAPABILITIES,
CAPABILITY_NAMES,
+ quotaSignals,
type CapabilityName,
} from '../../capabilities/registry.js';
@@ -34,7 +35,9 @@ describe('capability registry', () => {
for (const name of CAPABILITY_NAMES) {
const def = CAPABILITIES[name];
if (def.kind === 'quota') {
- expect(typeof def.signal).toBe('string');
+ const signals = quotaSignals(def);
+ expect(signals.length).toBeGreaterThan(0);
+ for (const sig of signals) expect(typeof sig).toBe('string');
expect(['calendar_month', 'point_in_time']).toContain(def.window);
}
}
@@ -53,6 +56,8 @@ describe('capability registry', () => {
expect(CAPABILITIES['ingestion.max_events_monthly'].kind).toBe('quota');
expect(CAPABILITIES['storage.max_bytes'].kind).toBe('quota');
expect(CAPABILITIES['tracing.max_spans_monthly'].kind).toBe('quota');
+ expect(CAPABILITIES['metrics.max_datapoints_monthly'].kind).toBe('quota');
+ expect(CAPABILITIES['telemetry.max_bytes_monthly'].kind).toBe('quota');
});
it('defaults every quota to null (unlimited) so OSS never blocks', () => {
@@ -61,6 +66,8 @@ describe('capability registry', () => {
'ingestion.max_events_monthly',
'storage.max_bytes',
'tracing.max_spans_monthly',
+ 'metrics.max_datapoints_monthly',
+ 'telemetry.max_bytes_monthly',
];
for (const q of quotas) {
const def = CAPABILITIES[q];
@@ -82,5 +89,7 @@ describe('capability registry', () => {
expect(storageQuota.window).toBe('point_in_time');
}
if (spanQuota.kind === 'quota') expect(spanQuota.signal).toBe('spans.ingested');
+ const metricQuota = CAPABILITIES['metrics.max_datapoints_monthly'];
+ if (metricQuota.kind === 'quota') expect(metricQuota.signal).toBe('metrics.ingested.datapoints');
});
});
diff --git a/packages/backend/src/tests/capabilities/telemetry-quota.test.ts b/packages/backend/src/tests/capabilities/telemetry-quota.test.ts
new file mode 100644
index 000000000..6b09596a7
--- /dev/null
+++ b/packages/backend/src/tests/capabilities/telemetry-quota.test.ts
@@ -0,0 +1,380 @@
+import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
+import type { FastifyInstance } from 'fastify';
+import { db } from '../../database/index.js';
+import { reservoir, reservoirReady } from '../../database/reservoir.js';
+import { config } from '../../config/index.js';
+import { build } from '../../server.js';
+import { capabilities, quotaFlagCache } from '../../capabilities/index.js';
+import {
+ CAPABILITIES,
+ applyDefaultOverrides,
+ quotaSignals,
+ resetDefaultOverrides,
+} from '../../capabilities/registry.js';
+import { QuotaEvaluator } from '../../capabilities/quota-evaluator.js';
+import { formatQuotaValue } from '../../capabilities/quota-notifier.js';
+import { meteringRecorder, meteringService } from '../../modules/metering/index.js';
+import { getCapabilityUsage } from '../../modules/metering/capability-usage.js';
+import { piiMaskingService } from '../../modules/pii-masking/service.js';
+import type { MeteringEventType } from '../../modules/metering/types.js';
+import { createTestSession } from '../helpers/auth.js';
+import { createTestApiKey, createTestContext, createTestUser } from '../helpers/factories.js';
+
+const CAP = 'telemetry.max_bytes_monthly';
+const cfg = config as { LOGTIDE_UPGRADE_URL?: string; LOGTIDE_UPGRADE_SIGNING_SECRET?: string };
+const nowNs = () => String(Date.now() * 1_000_000);
+
+// Bytes cover customer-provided data only: the server-injected tenant ids and the
+// span 'time' copy of startTime are excluded from the measured projection.
+const customerSpanBytes = (stored: any[]) =>
+ Buffer.byteLength(JSON.stringify(stored.map(({ organizationId, projectId, time, ...rest }) => rest)));
+const customerMetricBytes = (stored: any[]) =>
+ Buffer.byteLength(JSON.stringify(stored.map(({ organizationId, projectId, ...rest }) => rest)));
+
+function tracesBody(spanIds: string[]) {
+ return {
+ resourceSpans: [
+ {
+ resource: { attributes: [{ key: 'service.name', value: { stringValue: 'svc' } }] },
+ scopeSpans: [
+ {
+ spans: spanIds.map((id) => ({
+ traceId: '0000000000000000000000000000' + id.padStart(4, '0'),
+ spanId: id.padStart(16, '0'),
+ name: 'op-' + id,
+ kind: 1,
+ startTimeUnixNano: nowNs(),
+ endTimeUnixNano: nowNs(),
+ })),
+ },
+ ],
+ },
+ ],
+ };
+}
+
+function metricsBody(names: string[]) {
+ return {
+ resourceMetrics: [
+ {
+ resource: { attributes: [{ key: 'service.name', value: { stringValue: 'svc' } }] },
+ scopeMetrics: [
+ {
+ metrics: names.map((name) => ({
+ name,
+ gauge: { dataPoints: [{ timeUnixNano: nowNs(), asDouble: 1 }] },
+ })),
+ },
+ ],
+ },
+ ],
+ };
+}
+
+function otlpLogsBody() {
+ return {
+ resourceLogs: [
+ {
+ resource: { attributes: [{ key: 'service.name', value: { stringValue: 'svc' } }] },
+ scopeLogs: [{ logRecords: [{ timeUnixNano: nowNs(), severityNumber: 9, body: { stringValue: 'x' } }] }],
+ },
+ ],
+ };
+}
+
+async function meteredSum(orgId: string, type: MeteringEventType): Promise<{ rows: number; total: number }> {
+ await meteringRecorder.flush();
+ const rows = await db
+ .selectFrom('metering_events')
+ .select('quantity')
+ .where('organization_id', '=', orgId)
+ .where('type', '=', type)
+ .execute();
+ return { rows: rows.length, total: rows.reduce((a, r) => a + Number(r.quantity), 0) };
+}
+
+async function setLimit(orgId: string, capability: string, limit: number | null) {
+ await db
+ .insertInto('organization_entitlements')
+ .values({ organization_id: orgId, capability, enabled: null, limit_value: limit })
+ .execute();
+ capabilities.invalidate(orgId);
+}
+
+describe('telemetry.max_bytes_monthly registry entry', () => {
+ afterEach(() => resetDefaultOverrides());
+
+ it('is a null-default monthly quota over logs, spans and metrics bytes', () => {
+ const def = CAPABILITIES[CAP];
+ expect(def.kind).toBe('quota');
+ expect(def.defaultLimit).toBeNull();
+ expect(def.window).toBe('calendar_month');
+ expect(def.description).toBe('Monthly ingested bytes across logs, traces and metrics combined');
+ expect([...quotaSignals(def)].sort()).toEqual(
+ ['logs.ingested.bytes', 'metrics.ingested.bytes', 'spans.ingested.bytes'].sort()
+ );
+ });
+
+ it('single-signal quotas still resolve to their one signal', () => {
+ expect(quotaSignals(CAPABILITIES['ingestion.max_bytes_monthly'])).toEqual(['logs.ingested.bytes']);
+ expect(quotaSignals(CAPABILITIES['storage.max_bytes'])).toEqual(['storage.snapshot']);
+ });
+
+ it('CAPABILITY_DEFAULTS_JSON accepts the key', () => {
+ applyDefaultOverrides(JSON.stringify({ [CAP]: 10 * 1024 ** 3 }));
+ expect(CAPABILITIES[CAP].defaultLimit).toBe(10 * 1024 ** 3);
+ });
+
+ it('formats notifications in bytes', () => {
+ expect(formatQuotaValue(CAP, 1536 * 1024)).toBe('1.5 MB');
+ expect(formatQuotaValue(CAP, 10 * 1024 ** 3)).toBe('10 GB');
+ });
+});
+
+describe('telemetry quota evaluation (sum of three signals)', () => {
+ let orgId: string;
+ let projectId: string;
+
+ beforeEach(async () => {
+ await db.deleteFrom('organization_entitlements').execute();
+ await fetch('http://localhost:8025/api/v1/messages', { method: 'DELETE' }).catch(() => {});
+ quotaFlagCache.clear();
+ const ctx = await createTestContext();
+ orgId = ctx.organization.id;
+ projectId = ctx.project.id;
+ capabilities.invalidate(orgId);
+
+ const rec = (type: MeteringEventType, quantity: number) =>
+ meteringRecorder.record({ type, quantity, organizationId: orgId, projectId });
+ rec('logs.ingested.bytes', 100);
+ rec('spans.ingested.bytes', 200);
+ rec('metrics.ingested.bytes', 300);
+ // Not part of the combined quota.
+ rec('logs.ingested.events', 100_000);
+ rec('spans.ingested', 100_000);
+ rec('metrics.ingested.datapoints', 100_000);
+ await meteringRecorder.flush();
+ });
+
+ it('blocks at the summed usage and not below it', async () => {
+ const ev = new QuotaEvaluator(meteringService);
+ await setLimit(orgId, CAP, 601);
+ await ev.runOnce();
+ expect(quotaFlagCache.isOverQuota(orgId, CAP)).toBe(false);
+
+ await db.deleteFrom('organization_entitlements').where('organization_id', '=', orgId).execute();
+ await setLimit(orgId, CAP, 600);
+ await ev.runOnce();
+ expect(quotaFlagCache.isOverQuota(orgId, CAP)).toBe(true);
+ });
+
+ it('null limit is unlimited', async () => {
+ await setLimit(orgId, CAP, null);
+ await new QuotaEvaluator(meteringService).runOnce();
+ expect(quotaFlagCache.isOverQuota(orgId, CAP)).toBe(false);
+ });
+
+ it('grace applies and the notification formats bytes', async () => {
+ await setLimit(orgId, CAP, 590);
+ await setLimit(orgId, 'quota.grace_percent', 10);
+ await new QuotaEvaluator(meteringService).runOnce();
+ expect(quotaFlagCache.isOverQuota(orgId, CAP)).toBe(false);
+
+ const rows = await db
+ .selectFrom('notifications')
+ .selectAll()
+ .where('organization_id', '=', orgId)
+ .execute();
+ const mine = rows.filter((r) => (r.metadata as any)?.capability === CAP);
+ expect(mine.length).toBeGreaterThan(0);
+ expect((mine[0].metadata as any).state).toBe('grace');
+ expect((mine[0].metadata as any).usage).toBe(600);
+ expect(mine[0].message).toContain('600 B used of 590 B');
+ });
+
+ it('the usage API reports the combined total', async () => {
+ await setLimit(orgId, CAP, 1000);
+ const usage = await getCapabilityUsage(orgId);
+ const row = usage.find((u) => u.capability === CAP);
+ expect(row).toMatchObject({ kind: 'quota', current: 600, limit: 1000, state: 'ok' });
+ // The existing single-signal quota is untouched.
+ const bytes = usage.find((u) => u.capability === 'ingestion.max_bytes_monthly');
+ expect(bytes?.current).toBe(100);
+ });
+});
+
+describe('telemetry quota on the real app', () => {
+ let app: FastifyInstance;
+ let orgId: string;
+ let apiKey: string;
+
+ beforeAll(async () => {
+ await reservoirReady;
+ app = await build({ logger: false });
+ await app.ready();
+ });
+
+ afterAll(async () => {
+ await app.close();
+ });
+
+ beforeEach(async () => {
+ await db.deleteFrom('organization_entitlements').execute();
+ quotaFlagCache.clear();
+ const ctx = await createTestContext();
+ orgId = ctx.organization.id;
+ apiKey = (await createTestApiKey({ projectId: ctx.project.id })).plainKey;
+ capabilities.invalidate(orgId);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ delete cfg.LOGTIDE_UPGRADE_URL;
+ delete cfg.LOGTIDE_UPGRADE_SIGNING_SECRET;
+ });
+
+ const post = (url: string, payload: unknown) =>
+ app.inject({
+ method: 'POST',
+ url,
+ headers: { 'x-api-key': apiKey, 'content-type': 'application/json' },
+ payload: JSON.stringify(payload),
+ });
+
+ const restLogs = () =>
+ post('/api/v1/ingest', {
+ logs: [{ time: new Date().toISOString(), service: 'api', level: 'info', message: 'x' }],
+ });
+
+ describe('metering bytes', () => {
+ it('records spans.ingested.bytes as the JSON size of the customer-provided span fields', async () => {
+ const spy = vi.spyOn(reservoir, 'ingestSpans');
+ const res = await post('/v1/otlp/traces', tracesBody(['1', '2']));
+ expect(res.statusCode).toBe(200);
+ expect(spy).toHaveBeenCalledTimes(1);
+ const stored = spy.mock.calls[0][0];
+ expect(stored).toHaveLength(2);
+ expect(stored[0]).toHaveProperty('organizationId', orgId);
+ expect(stored[0]).toHaveProperty('time');
+ const m = await meteredSum(orgId, 'spans.ingested.bytes');
+ expect(m.total).toBeLessThan(Buffer.byteLength(JSON.stringify(stored)));
+ expect(m.rows).toBe(1);
+ expect(m.total).toBe(customerSpanBytes(stored));
+ });
+
+ it('does not count spans dropped by PII masking', async () => {
+ vi.spyOn(piiMaskingService, 'maskSpanBatch').mockResolvedValueOnce([0]);
+ const spy = vi.spyOn(reservoir, 'ingestSpans');
+ const res = await post('/v1/otlp/traces', tracesBody(['3', '4']));
+ expect(res.statusCode).toBe(200);
+ const stored = spy.mock.calls[0][0];
+ expect(stored).toHaveLength(1);
+ const m = await meteredSum(orgId, 'spans.ingested.bytes');
+ expect(m.total).toBe(customerSpanBytes(stored));
+ });
+
+ it('records metrics.ingested.bytes as the JSON size of the customer-provided datapoint fields', async () => {
+ const spy = vi.spyOn(reservoir, 'ingestMetrics');
+ const res = await post('/v1/otlp/metrics', metricsBody(['a', 'b']));
+ expect(res.statusCode).toBe(200);
+ const stored = spy.mock.calls[0][0];
+ expect(stored).toHaveLength(2);
+ expect(stored[0]).toHaveProperty('organizationId', orgId);
+ const m = await meteredSum(orgId, 'metrics.ingested.bytes');
+ expect(m.total).toBeLessThan(Buffer.byteLength(JSON.stringify(stored)));
+ expect(m.rows).toBe(1);
+ expect(m.total).toBe(customerMetricBytes(stored));
+ });
+
+ it('does not count datapoints the store rejected', async () => {
+ vi.spyOn(reservoir, 'ingestMetrics').mockResolvedValueOnce({ ingested: 0, failed: 1, durationMs: 0 });
+ await post('/v1/otlp/metrics', metricsBody(['c']));
+ expect((await meteredSum(orgId, 'metrics.ingested.bytes')).rows).toBe(0);
+ });
+ });
+
+ describe('enforcement', () => {
+ it('over the combined limit: 429 on REST logs, OTLP logs, traces and metrics', async () => {
+ await setLimit(orgId, CAP, 1);
+ quotaFlagCache.setOrgFlags(orgId, { [CAP]: true });
+
+ const rest = await restLogs();
+ expect(rest.statusCode).toBe(429);
+
+ const otlpLogs = await post('/v1/otlp/logs', otlpLogsBody());
+ expect(otlpLogs.statusCode).toBe(429);
+
+ const traces = await post('/v1/otlp/traces', tracesBody(['5']));
+ expect(traces.statusCode).toBe(429);
+ expect(traces.json().partialSuccess.rejectedSpans).toBe(-1);
+
+ const metrics = await post('/v1/otlp/metrics', metricsBody(['d']));
+ expect(metrics.statusCode).toBe(429);
+ expect(metrics.json().partialSuccess.rejectedDataPoints).toBe(-1);
+
+ // Nothing was stored or metered.
+ expect((await meteredSum(orgId, 'spans.ingested.bytes')).rows).toBe(0);
+ expect((await meteredSum(orgId, 'metrics.ingested.bytes')).rows).toBe(0);
+ expect((await meteredSum(orgId, 'logs.ingested.bytes')).rows).toBe(0);
+ });
+
+ it('429 carries upgrade_available when the handoff is active', async () => {
+ cfg.LOGTIDE_UPGRADE_URL = 'https://billing.example.com/portal';
+ cfg.LOGTIDE_UPGRADE_SIGNING_SECRET = 'u'.repeat(40);
+ await setLimit(orgId, CAP, 1);
+ quotaFlagCache.setOrgFlags(orgId, { [CAP]: true });
+
+ for (const res of [
+ await restLogs(),
+ await post('/v1/otlp/traces', tracesBody(['6'])),
+ await post('/v1/otlp/metrics', metricsBody(['e'])),
+ ]) {
+ expect(res.statusCode).toBe(429);
+ expect(res.json().upgrade_available).toBe(true);
+ }
+ });
+
+ it('under the limit: everything is accepted', async () => {
+ await setLimit(orgId, CAP, 10 * 1024 ** 3);
+ quotaFlagCache.setOrgFlags(orgId, { [CAP]: false });
+ expect((await restLogs()).statusCode).toBeLessThan(300);
+ expect((await post('/v1/otlp/logs', otlpLogsBody())).statusCode).toBe(200);
+ expect((await post('/v1/otlp/traces', tracesBody(['7']))).statusCode).toBe(200);
+ expect((await post('/v1/otlp/metrics', metricsBody(['f']))).statusCode).toBe(200);
+ });
+
+ it('null limit is unlimited even with a stale flag', async () => {
+ quotaFlagCache.setOrgFlags(orgId, { [CAP]: true });
+ expect((await restLogs()).statusCode).toBeLessThan(300);
+ expect((await post('/v1/otlp/traces', tracesBody(['8']))).statusCode).toBe(200);
+ expect((await post('/v1/otlp/metrics', metricsBody(['g']))).statusCode).toBe(200);
+ });
+
+ it('end to end: real metering + evaluator blocks the next batch', async () => {
+ await setLimit(orgId, CAP, 1);
+ expect((await post('/v1/otlp/metrics', metricsBody(['h']))).statusCode).toBe(200);
+ await meteringRecorder.flush();
+ await new QuotaEvaluator(meteringService).runOnce();
+ expect(quotaFlagCache.isOverQuota(orgId, CAP)).toBe(true);
+ expect((await post('/v1/otlp/traces', tracesBody(['9']))).statusCode).toBe(429);
+ expect((await restLogs()).statusCode).toBe(429);
+ });
+ });
+
+ describe('admin entitlements PUT', () => {
+ it('accepts the new key (registry-driven)', async () => {
+ const admin = await createTestUser({ email: `admin-${Date.now()}-${Math.random()}@example.com` });
+ await db.updateTable('users').set({ is_admin: true }).where('id', '=', admin.id).execute();
+ const session = await createTestSession(admin.id);
+ const tenGiB = 10 * 1024 ** 3;
+ const put = await app.inject({
+ method: 'PUT',
+ url: `/api/v1/admin/organizations/${orgId}/entitlements`,
+ headers: { authorization: `Bearer ${session.token}` },
+ payload: { entitlements: [{ capability: CAP, limitValue: tenGiB }] },
+ });
+ expect(put.statusCode).toBe(200);
+ expect(await capabilities.getLimit(orgId, CAP)).toBe(tenGiB);
+ });
+ });
+});
diff --git a/packages/backend/src/tests/cloud-seams/env-unset.test.ts b/packages/backend/src/tests/cloud-seams/env-unset.test.ts
new file mode 100644
index 000000000..3bdba31fe
--- /dev/null
+++ b/packages/backend/src/tests/cloud-seams/env-unset.test.ts
@@ -0,0 +1,311 @@
+/**
+ * Self-hosted invariance: with every hosting-seam env var unset, the REAL app
+ * (the same builder server.ts uses, full plugin stack) behaves exactly like a
+ * build without the seams. Any failure here means a seam leaked into OSS.
+ */
+import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
+import type { FastifyInstance } from 'fastify';
+import crypto from 'crypto';
+import { db } from '../../database/index.js';
+import { reservoirReady } from '../../database/reservoir.js';
+import { config } from '../../config/index.js';
+import { build } from '../../server.js';
+import { CAPABILITIES, CAPABILITY_NAMES } from '../../capabilities/registry.js';
+import { capabilities, quotaFlagCache } from '../../capabilities/index.js';
+import { QuotaEvaluator } from '../../capabilities/quota-evaluator.js';
+import { settingsService } from '../../modules/settings/index.js';
+import { SETTING_KEYS } from '../../modules/settings/service.js';
+import { createTestSession } from '../helpers/auth.js';
+import { createTestApiKey, createTestContext } from '../helpers/factories.js';
+
+const SEAM_ENV = [
+ 'CAPABILITY_DEFAULTS_JSON',
+ 'LOGTIDE_ADMIN_SERVICE_TOKEN',
+ 'LOGTIDE_UPGRADE_URL',
+ 'LOGTIDE_UPGRADE_SIGNING_SECRET',
+ 'MAX_ORGANIZATIONS_PER_USER',
+ 'MAX_ORGANIZATION_CREATIONS_PER_USER_PER_MONTH',
+ 'REQUIRE_EMAIL_VERIFICATION',
+ 'REGISTRATION_RATE_LIMIT_PER_HOUR',
+ 'LOGTIDE_TERMS_URL',
+ 'LOGTIDE_PRIVACY_URL',
+ 'LOGTIDE_TERMS_VERSION',
+] as const;
+
+const cfg = config as unknown as Record;
+
+describe('hosting seams with all env unset', () => {
+ let app: FastifyInstance;
+
+ beforeAll(async () => {
+ await reservoirReady;
+ app = await build({ logger: false });
+ await app.ready();
+ });
+
+ afterAll(async () => {
+ await app.close();
+ });
+
+ beforeEach(async () => {
+ await db.deleteFrom('organization_entitlements').execute();
+ quotaFlagCache.clear();
+ });
+
+ it('precondition: no seam env var is set', () => {
+ for (const name of SEAM_ENV) {
+ expect(process.env[name] ?? '', name).toBe('');
+ }
+ expect(cfg.CAPABILITY_DEFAULTS_JSON ?? undefined).toBeUndefined();
+ expect(cfg.LOGTIDE_ADMIN_SERVICE_TOKEN).toBeUndefined();
+ expect(cfg.LOGTIDE_UPGRADE_URL).toBeUndefined();
+ expect(cfg.LOGTIDE_UPGRADE_SIGNING_SECRET).toBeUndefined();
+ expect(cfg.MAX_ORGANIZATIONS_PER_USER).toBeUndefined();
+ expect(cfg.MAX_ORGANIZATION_CREATIONS_PER_USER_PER_MONTH).toBeUndefined();
+ expect(cfg.REQUIRE_EMAIL_VERIFICATION).toBe(false);
+ expect(cfg.REGISTRATION_RATE_LIMIT_PER_HOUR).toBeUndefined();
+ expect(cfg.LOGTIDE_TERMS_URL).toBeUndefined();
+ expect(cfg.LOGTIDE_PRIVACY_URL).toBeUndefined();
+ expect(cfg.LOGTIDE_TERMS_VERSION).toBeUndefined();
+ });
+
+ it('registry defaults are unlimited / enabled', async () => {
+ for (const name of CAPABILITY_NAMES) {
+ const def = CAPABILITIES[name];
+ if (def.kind === 'boolean') expect(def.defaultEnabled, name).toBe(true);
+ else expect(def.defaultLimit, name).toBeNull();
+ }
+ const ctx = await createTestContext();
+ const list = await capabilities.list(ctx.organization.id);
+ for (const name of CAPABILITY_NAMES) {
+ const v = list[name];
+ if (v.kind === 'boolean') expect(v.enabled, name).toBe(true);
+ else expect(v.limit, name).toBeNull();
+ }
+ });
+
+ it('evaluator queries only orgs with non-null quota rows', async () => {
+ const restricted = await createTestContext();
+ const nullRow = await createTestContext();
+ const noRows = await createTestContext();
+ await db
+ .insertInto('organization_entitlements')
+ .values([
+ { organization_id: restricted.organization.id, capability: 'ingestion.max_events_monthly', enabled: null, limit_value: 100 },
+ { organization_id: nullRow.organization.id, capability: 'ingestion.max_events_monthly', enabled: null, limit_value: null },
+ ])
+ .execute();
+ capabilities.invalidate(restricted.organization.id);
+ capabilities.invalidate(nullRow.organization.id);
+
+ const queried = new Set();
+ const stub = {
+ async aggregate(params: { organizationId: string }) {
+ queried.add(params.organizationId);
+ return [];
+ },
+ async latestPointInTime(organizationId: string) {
+ queried.add(organizationId);
+ return 0;
+ },
+ };
+ await new QuotaEvaluator(stub as any).runOnce();
+
+ // Deferred Task 1 item: orgs WITH a non-null quota row are still evaluated.
+ expect(queried.has(restricted.organization.id)).toBe(true);
+ expect(queried.has(nullRow.organization.id)).toBe(false);
+ expect(queried.has(noRows.organization.id)).toBe(false);
+ });
+
+ it('refuses any bearer on the service-token admin routes (401)', async () => {
+ const ctx = await createTestContext();
+ const token = crypto.randomBytes(32).toString('hex');
+ const headers = { authorization: `Bearer ${token}` };
+ const id = ctx.organization.id;
+ const calls = [
+ app.inject({ method: 'GET', url: `/api/v1/admin/organizations/${id}/entitlements`, headers }),
+ app.inject({
+ method: 'PUT',
+ url: `/api/v1/admin/organizations/${id}/entitlements`,
+ headers,
+ payload: { entitlements: [{ capability: 'projects.max', limitValue: 1 }] },
+ }),
+ app.inject({ method: 'GET', url: `/api/v1/admin/organizations/${id}/retention`, headers }),
+ app.inject({
+ method: 'PUT',
+ url: `/api/v1/admin/organizations/${id}/retention`,
+ headers,
+ payload: { retentionDays: 30 },
+ }),
+ ];
+ for (const res of await Promise.all(calls)) {
+ expect(res.statusCode).toBe(401);
+ }
+ });
+
+ it('upgrade-handoff route does not exist (404)', async () => {
+ const ctx = await createTestContext();
+ const session = await createTestSession(ctx.user.id);
+ const res = await app.inject({
+ method: 'POST',
+ url: `/api/v1/organizations/${ctx.organization.id}/upgrade-handoff`,
+ headers: { authorization: `Bearer ${session.token}` },
+ payload: { intent: 'upgrade' },
+ });
+ expect(res.statusCode).toBe(404);
+ });
+
+ it('/auth/config has no seam keys', async () => {
+ const res = await app.inject({ method: 'GET', url: '/api/v1/auth/config' });
+ expect(res.statusCode).toBe(200);
+ const body = res.json();
+ expect(body).not.toHaveProperty('upgradeEnabled');
+ expect(body).not.toHaveProperty('emailVerificationRequired');
+ expect(body).not.toHaveProperty('termsUrl');
+ expect(body).not.toHaveProperty('privacyUrl');
+ expect(body).not.toHaveProperty('termsVersion');
+ });
+
+ it('terms acceptance is inert: no /me key, no accept-terms route', async () => {
+ const ctx = await createTestContext();
+ const session = await createTestSession(ctx.user.id);
+ const headers = { authorization: `Bearer ${session.token}` };
+ const me = await app.inject({ method: 'GET', url: '/api/v1/auth/me', headers });
+ expect(me.statusCode).toBe(200);
+ expect(me.json().user).not.toHaveProperty('termsAcceptanceRequired');
+ const accept = await app.inject({
+ method: 'POST',
+ url: '/api/v1/auth/accept-terms',
+ headers,
+ payload: { version: 'anything' },
+ });
+ expect(accept.statusCode).toBe(404);
+ });
+
+ it('registration, auto-login, project and API key creation work without verification', async () => {
+ const signupWasEnabled = await settingsService.isSignupEnabled();
+ if (!signupWasEnabled) await settingsService.set(SETTING_KEYS.AUTH_SIGNUP_ENABLED, true);
+ try {
+ const email = `seams-${crypto.randomBytes(4).toString('hex')}@example.com`;
+ const reg = await app.inject({
+ method: 'POST',
+ url: '/api/v1/auth/register',
+ payload: { email, password: 'password123', name: 'Seams' },
+ });
+ expect(reg.statusCode).toBe(201);
+ const regBody = reg.json();
+ expect(regBody.user).not.toHaveProperty('emailVerified');
+ expect(regBody.session?.token).toBeTruthy();
+ const auth = { authorization: `Bearer ${regBody.session.token}` };
+
+ const user = await db
+ .selectFrom('users')
+ .select('email_verified_at')
+ .where('id', '=', regBody.user.id)
+ .executeTakeFirstOrThrow();
+ expect(user.email_verified_at).not.toBeNull();
+
+ // No verification email was ever issued.
+ const tokens = await db
+ .selectFrom('email_verification_tokens')
+ .select('id')
+ .where('user_id', '=', regBody.user.id)
+ .execute();
+ expect(tokens).toHaveLength(0);
+
+ // No terms acceptance was recorded or required.
+ const terms = await db
+ .selectFrom('users')
+ .select(['terms_accepted_at', 'terms_version'])
+ .where('id', '=', regBody.user.id)
+ .executeTakeFirstOrThrow();
+ expect(terms.terms_accepted_at).toBeNull();
+ expect(terms.terms_version).toBeNull();
+
+ const org = await app.inject({
+ method: 'POST',
+ url: '/api/v1/organizations',
+ headers: auth,
+ payload: { name: `Seams Org ${Date.now()}` },
+ });
+ expect(org.statusCode).toBe(201);
+ const orgId = org.json().organization.id;
+
+ const project = await app.inject({
+ method: 'POST',
+ url: '/api/v1/projects',
+ headers: auth,
+ payload: { organizationId: orgId, name: 'Seams Project' },
+ });
+ expect(project.statusCode).toBe(201);
+ const projectId = project.json().project.id;
+
+ const key = await app.inject({
+ method: 'POST',
+ url: `/api/v1/projects/${projectId}/api-keys`,
+ headers: auth,
+ payload: { name: 'Seams Key' },
+ });
+ expect(key.statusCode).toBe(201);
+ } finally {
+ if (!signupWasEnabled) await settingsService.set(SETTING_KEYS.AUTH_SIGNUP_ENABLED, false);
+ }
+ });
+
+ it('403 limit and 429 quota bodies carry no upgrade_available', async () => {
+ const ctx = await createTestContext();
+ const orgId = ctx.organization.id;
+ const session = await createTestSession(ctx.user.id);
+
+ // 403: projects.max reached (createTestContext made 1 project).
+ await db
+ .insertInto('organization_entitlements')
+ .values({ organization_id: orgId, capability: 'projects.max', enabled: null, limit_value: 1 })
+ .execute();
+ capabilities.invalidate(orgId);
+ const blocked = await app.inject({
+ method: 'POST',
+ url: '/api/v1/projects',
+ headers: { authorization: `Bearer ${session.token}` },
+ payload: { organizationId: orgId, name: 'Over' },
+ });
+ expect(blocked.statusCode).toBe(403);
+ expect(blocked.json()).not.toHaveProperty('upgrade_available');
+
+ // 429: flagged over an ingestion quota.
+ await db
+ .insertInto('organization_entitlements')
+ .values({ organization_id: orgId, capability: 'ingestion.max_events_monthly', enabled: null, limit_value: 1 })
+ .execute();
+ capabilities.invalidate(orgId);
+ quotaFlagCache.setOrgFlags(orgId, { 'ingestion.max_events_monthly': true });
+ const key = await createTestApiKey({ projectId: ctx.project.id });
+ const over = await app.inject({
+ method: 'POST',
+ url: '/api/v1/ingest',
+ headers: { 'x-api-key': key.plainKey, 'content-type': 'application/json' },
+ payload: { logs: [{ time: new Date().toISOString(), service: 'api', level: 'info', message: 'x' }] },
+ });
+ expect(over.statusCode).toBe(429);
+ expect(over.json()).not.toHaveProperty('upgrade_available');
+ });
+});
+
+describe('boot with an invalid CAPABILITY_DEFAULTS_JSON', () => {
+ it('fails loading the config (deferred Task 1 item)', async () => {
+ const before = process.env.CAPABILITY_DEFAULTS_JSON;
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ try {
+ for (const bad of ['{not json', '[]', '{"no.such.capability": 1}', '{"projects.max": "ten"}']) {
+ vi.resetModules();
+ process.env.CAPABILITY_DEFAULTS_JSON = bad;
+ await expect(import('../../config/index.js'), bad).rejects.toThrow(/CAPABILITY_DEFAULTS_JSON/);
+ }
+ } finally {
+ if (before === undefined) delete process.env.CAPABILITY_DEFAULTS_JSON;
+ else process.env.CAPABILITY_DEFAULTS_JSON = before;
+ errorSpy.mockRestore();
+ vi.resetModules();
+ }
+ });
+});
diff --git a/packages/backend/src/tests/database/tenant-tables.test.ts b/packages/backend/src/tests/database/tenant-tables.test.ts
index 658053481..b7e1cf4ca 100644
--- a/packages/backend/src/tests/database/tenant-tables.test.ts
+++ b/packages/backend/src/tests/database/tenant-tables.test.ts
@@ -18,7 +18,8 @@ const ALL_TABLES = [
'organization_default_channels', 'pii_masking_rules', 'organization_pii_salts',
'audit_log', 'metrics_hourly_stats', 'metrics_daily_stats', 'metrics',
'metric_exemplars', 'custom_dashboards', 'log_pipelines', 'digest_configs',
- 'digest_recipients', 'receivers', 'receiver_events',
+ 'digest_recipients', 'receivers', 'receiver_events', 'email_verification_tokens',
+ 'organization_creation_log',
];
describe('tenant-tables manifest', () => {
diff --git a/packages/backend/src/tests/lib/email-templates.test.ts b/packages/backend/src/tests/lib/email-templates.test.ts
index 66a4255db..3f8c8200c 100644
--- a/packages/backend/src/tests/lib/email-templates.test.ts
+++ b/packages/backend/src/tests/lib/email-templates.test.ts
@@ -18,6 +18,7 @@ import {
generateIncidentEmail,
generateSigmaDetectionEmail,
generateInvitationEmail,
+ generateEmailVerificationEmail,
generateDigestEmail,
type DigestEmailData,
} from '../../lib/email-templates.js';
@@ -1314,3 +1315,18 @@ describe('Email Templates - Digest', () => {
expect(result.html).toContain('Digest for Acme Corp');
});
});
+
+describe('generateEmailVerificationEmail', () => {
+ it('links to the frontend verify page with the url-encoded token', () => {
+ const result = generateEmailVerificationEmail({ name: 'Ada', token: 'abc_DEF-123' });
+ expect(result.html).toContain('/verify-email?token=abc_DEF-123');
+ expect(result.text).toContain('/verify-email?token=abc_DEF-123');
+ expect(result.text).toContain('24 hours');
+ });
+
+ it('escapes the user name in the html body', () => {
+ const result = generateEmailVerificationEmail({ name: '', token: 't' });
+ expect(result.html).not.toContain('');
+ expect(result.html).toContain('<script>');
+ });
+});
diff --git a/packages/backend/src/tests/modules/admin/service-token.test.ts b/packages/backend/src/tests/modules/admin/service-token.test.ts
new file mode 100644
index 000000000..3e4f915d7
--- /dev/null
+++ b/packages/backend/src/tests/modules/admin/service-token.test.ts
@@ -0,0 +1,155 @@
+import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
+import type { FastifyInstance } from 'fastify';
+import { db } from '../../../database/index.js';
+import { config, configSchema } from '../../../config/index.js';
+import { build } from '../../../server.js';
+import { createTestOrganization } from '../../helpers/factories.js';
+
+const TOKEN = 's'.repeat(40);
+const cfg = config as { LOGTIDE_ADMIN_SERVICE_TOKEN?: string };
+
+describe('LOGTIDE_ADMIN_SERVICE_TOKEN config', () => {
+ const base = {
+ DATABASE_URL: 'postgres://u:p@localhost:5432/db',
+ API_KEY_SECRET: 'x'.repeat(32),
+ };
+
+ it('is inert when unset or empty', () => {
+ expect(configSchema.parse(base).LOGTIDE_ADMIN_SERVICE_TOKEN).toBeUndefined();
+ expect(configSchema.parse({ ...base, LOGTIDE_ADMIN_SERVICE_TOKEN: '' }).LOGTIDE_ADMIN_SERVICE_TOKEN).toBeUndefined();
+ });
+
+ it('rejects a 31 character token', () => {
+ const res = configSchema.safeParse({ ...base, LOGTIDE_ADMIN_SERVICE_TOKEN: 'a'.repeat(31) });
+ expect(res.success).toBe(false);
+ });
+
+ it('accepts a 32 character token', () => {
+ expect(configSchema.parse({ ...base, LOGTIDE_ADMIN_SERVICE_TOKEN: 'a'.repeat(32) }).LOGTIDE_ADMIN_SERVICE_TOKEN).toBe('a'.repeat(32));
+ });
+});
+
+describe('admin service token', () => {
+ let app: FastifyInstance;
+ let orgId: string;
+
+ beforeAll(async () => {
+ // The REAL app (full plugin stack incl. the global auth plugin), not a
+ // bare Fastify instance: the global plugin is what used to reject the token.
+ app = await build({ logger: false });
+ await app.ready();
+ });
+
+ afterAll(async () => {
+ delete cfg.LOGTIDE_ADMIN_SERVICE_TOKEN;
+ await app.close();
+ });
+
+ afterEach(() => {
+ delete cfg.LOGTIDE_ADMIN_SERVICE_TOKEN;
+ });
+
+ async function newOrg() {
+ const org = await createTestOrganization();
+ orgId = org.id;
+ return org.id;
+ }
+
+ const auth = (t: string) => ({ authorization: `Bearer ${t}` });
+
+ it('PUT entitlements returns 200 and writes a service audit row', async () => {
+ cfg.LOGTIDE_ADMIN_SERVICE_TOKEN = TOKEN;
+ const id = await newOrg();
+ const res = await app.inject({
+ method: 'PUT',
+ url: `/api/v1/admin/organizations/${id}/entitlements`,
+ headers: auth(TOKEN),
+ payload: { entitlements: [{ capability: 'ingestion.max_bytes_monthly', limitValue: 1048576 }] },
+ });
+ expect(res.statusCode).toBe(200);
+ const row = await db
+ .selectFrom('audit_log')
+ .selectAll()
+ .where('action', '=', 'org.entitlements_updated')
+ .where('organization_id', '=', id)
+ .executeTakeFirst();
+ expect(row).toBeTruthy();
+ expect((row!.metadata as any).actor).toBe('service:admin-service');
+ expect(row!.actor_type).toBe('system');
+ });
+
+ it('PUT retention returns 200 and writes a service audit row', async () => {
+ cfg.LOGTIDE_ADMIN_SERVICE_TOKEN = TOKEN;
+ const id = await newOrg();
+ const res = await app.inject({
+ method: 'PUT',
+ url: `/api/v1/admin/organizations/${id}/retention`,
+ headers: auth(TOKEN),
+ payload: { retentionDays: 30 },
+ });
+ expect(res.statusCode).toBe(200);
+ const row = await db
+ .selectFrom('audit_log')
+ .selectAll()
+ .where('action', '=', 'org.retention_updated')
+ .where('organization_id', '=', id)
+ .executeTakeFirst();
+ expect((row!.metadata as any).actor).toBe('service:admin-service');
+ expect(row!.actor_type).toBe('system');
+ });
+
+ it('GET retention accepts the token', async () => {
+ cfg.LOGTIDE_ADMIN_SERVICE_TOKEN = TOKEN;
+ const id = await newOrg();
+ const res = await app.inject({
+ method: 'GET',
+ url: `/api/v1/admin/organizations/${id}/retention`,
+ headers: auth(TOKEN),
+ });
+ expect(res.statusCode).toBe(200);
+ });
+
+ it('non-admin routes with the token are 401', async () => {
+ cfg.LOGTIDE_ADMIN_SERVICE_TOKEN = TOKEN;
+ const id = await newOrg();
+ // session-auth route (route-level authenticate)
+ const projects = await app.inject({ method: 'GET', url: '/api/v1/projects', headers: auth(TOKEN) });
+ expect(projects.statusCode).toBe(401);
+ // routes guarded only by the global auth plugin
+ const usage = await app.inject({ method: 'GET', url: `/api/v1/usage?organizationId=${id}`, headers: auth(TOKEN) });
+ expect(usage.statusCode).toBe(401);
+ const logs = await app.inject({ method: 'GET', url: '/api/v1/logs', headers: auth(TOKEN) });
+ expect(logs.statusCode).toBe(401);
+ });
+
+ it('admin route not opted in to the service token fails cleanly (401, no 500)', async () => {
+ cfg.LOGTIDE_ADMIN_SERVICE_TOKEN = TOKEN;
+ const res = await app.inject({ method: 'GET', url: '/api/v1/admin/users', headers: auth(TOKEN) });
+ expect(res.statusCode).toBe(401);
+ });
+
+ it('wrong token is 401', async () => {
+ cfg.LOGTIDE_ADMIN_SERVICE_TOKEN = TOKEN;
+ const id = await newOrg();
+ for (const t of ['w'.repeat(40), 'short', TOKEN + 'x']) {
+ const res = await app.inject({
+ method: 'PUT',
+ url: `/api/v1/admin/organizations/${id}/retention`,
+ headers: auth(t),
+ payload: { retentionDays: 30 },
+ });
+ expect(res.statusCode).toBe(401);
+ }
+ });
+
+ it('env unset: any token is 401', async () => {
+ const id = await newOrg();
+ const res = await app.inject({
+ method: 'PUT',
+ url: `/api/v1/admin/organizations/${id}/retention`,
+ headers: auth(TOKEN),
+ payload: { retentionDays: 30 },
+ });
+ expect(res.statusCode).toBe(401);
+ });
+});
diff --git a/packages/backend/src/tests/modules/capabilities/ingestion-quota.test.ts b/packages/backend/src/tests/modules/capabilities/ingestion-quota.test.ts
index b0d040511..798f43ea6 100644
--- a/packages/backend/src/tests/modules/capabilities/ingestion-quota.test.ts
+++ b/packages/backend/src/tests/modules/capabilities/ingestion-quota.test.ts
@@ -10,6 +10,8 @@ import type { LogInput } from '@logtide/shared';
import { createTestContext } from '../../helpers/factories.js';
import Fastify from 'fastify';
import otlpTraceRoutes from '../../../modules/otlp/trace-routes.js';
+import otlpMetricRoutes from '../../../modules/otlp/metric-routes.js';
+import { meteringRecorder } from '../../../modules/metering/index.js';
import otlpRoutes from '../../../modules/otlp/routes.js';
function asOrg(orgId: string, fn: () => Promise | T): Promise {
@@ -244,3 +246,152 @@ describe('otlp log ingestion returns 429 on quota', () => {
expect(body.partialSuccess.rejectedLogRecords).toBe(0);
});
});
+
+describe('metric ingestion hard-block on datapoint quota', () => {
+ let app: any;
+ let orgId: string;
+ let projectId: string;
+
+ beforeAll(async () => {
+ await reservoirReady;
+ });
+
+ beforeEach(async () => {
+ await db.deleteFrom('organization_entitlements').execute();
+ await db.deleteFrom('metering_events').execute();
+ quotaFlagCache.clear();
+ const ctx = await createTestContext();
+ orgId = ctx.organization.id;
+ projectId = ctx.project.id;
+ capabilities.invalidate(orgId);
+
+ app = Fastify();
+ app.addHook('onRequest', async (request: any) => {
+ request.projectId = projectId;
+ });
+ await app.register(otlpMetricRoutes);
+ await app.ready();
+ });
+
+ function point(name: string) {
+ return {
+ name,
+ gauge: { dataPoints: [{ timeUnixNano: String(Date.now() * 1_000_000), asDouble: 1 }] },
+ };
+ }
+
+ function otlpMetricsBody(metrics: unknown[]) {
+ return {
+ resourceMetrics: [
+ {
+ resource: { attributes: [{ key: 'service.name', value: { stringValue: 'svc' } }] },
+ scopeMetrics: [{ metrics }],
+ },
+ ],
+ };
+ }
+
+ const post = (payload: unknown) =>
+ app.inject({
+ method: 'POST',
+ url: '/v1/otlp/metrics',
+ headers: { 'content-type': 'application/json' },
+ payload: JSON.stringify(payload),
+ });
+
+ async function meteredDatapoints(): Promise {
+ await meteringRecorder.flush();
+ const rows = await db
+ .selectFrom('metering_events')
+ .select('quantity')
+ .where('organization_id', '=', orgId)
+ .where('type', '=', 'metrics.ingested.datapoints')
+ .execute();
+ return rows.reduce((a, r) => a + Number(r.quantity), 0);
+ }
+
+ it('returns 429 on metrics when flagged over the quota, and stores nothing', async () => {
+ await db
+ .insertInto('organization_entitlements')
+ .values({ organization_id: orgId, capability: 'metrics.max_datapoints_monthly', enabled: null, limit_value: 1 })
+ .execute();
+ capabilities.invalidate(orgId);
+ quotaFlagCache.setOrgFlags(orgId, { 'metrics.max_datapoints_monthly': true });
+
+ const res = await post(otlpMetricsBody([point('a')]));
+ expect(res.statusCode).toBe(429);
+ expect(JSON.parse(res.payload).partialSuccess.rejectedDataPoints).toBe(-1);
+ expect(await meteredDatapoints()).toBe(0);
+ });
+
+ it('does not block logs or spans when only the metrics quota is exceeded', async () => {
+ quotaFlagCache.setOrgFlags(orgId, { 'metrics.max_datapoints_monthly': true });
+
+ const logs = await asOrg(orgId, () =>
+ ingestionService.ingestLogs(
+ [{ time: new Date(), service: 'api', level: 'info', message: 'a' } as LogInput],
+ projectId
+ )
+ );
+ expect(logs.received).toBe(1);
+
+ const spanApp = Fastify();
+ spanApp.addHook('onRequest', async (request: any) => {
+ request.projectId = projectId;
+ });
+ await spanApp.register(otlpTraceRoutes);
+ await spanApp.ready();
+ const now = Date.now() * 1_000_000;
+ const res = await spanApp.inject({
+ method: 'POST',
+ url: '/v1/otlp/traces',
+ headers: { 'content-type': 'application/json' },
+ payload: JSON.stringify({
+ resourceSpans: [
+ {
+ resource: { attributes: [{ key: 'service.name', value: { stringValue: 'svc' } }] },
+ scopeSpans: [
+ {
+ spans: [
+ {
+ traceId: '00000000000000000000000000000002',
+ spanId: '0000000000000002',
+ name: 'op',
+ kind: 1,
+ startTimeUnixNano: String(now),
+ endTimeUnixNano: String(now + 1_000_000),
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ }),
+ });
+ expect(res.statusCode).toBe(200);
+ });
+
+ it('is unlimited by default and meters the exact datapoint count', async () => {
+ const res = await post(otlpMetricsBody([point('a'), point('b'), point('c')]));
+ expect(res.statusCode).toBe(200);
+ expect(await meteredDatapoints()).toBe(3);
+ });
+
+ it('does not count datapoints of a rejected request (invalid payload)', async () => {
+ const res = await app.inject({
+ method: 'POST',
+ url: '/v1/otlp/metrics',
+ headers: { 'content-type': 'application/json' },
+ payload: '{not json',
+ });
+ expect(res.statusCode).toBe(400);
+ expect(await meteredDatapoints()).toBe(0);
+ });
+
+ it('does not count datapoints dropped by transform validation', async () => {
+ // A metric with no data kind and a point without a value produce no records.
+ const res = await post(otlpMetricsBody([{ name: 'empty' }]));
+ expect(res.statusCode).toBe(200);
+ expect(await meteredDatapoints()).toBe(0);
+ });
+});
diff --git a/packages/backend/src/tests/modules/metering/capability-usage.test.ts b/packages/backend/src/tests/modules/metering/capability-usage.test.ts
index de05e7852..733fa52d1 100644
--- a/packages/backend/src/tests/modules/metering/capability-usage.test.ts
+++ b/packages/backend/src/tests/modules/metering/capability-usage.test.ts
@@ -29,9 +29,12 @@ describe('getCapabilityUsage', () => {
'dashboards.max_custom',
'ingestion.max_bytes_monthly',
'ingestion.max_events_monthly',
+ 'metrics.max_datapoints_monthly',
'notifications.max_channels',
+ 'projects.max',
'sigma.max_active_rules',
'storage.max_bytes',
+ 'telemetry.max_bytes_monthly',
'tracing.max_spans_monthly',
].sort()
);
diff --git a/packages/backend/src/tests/modules/metering/record-spans.test.ts b/packages/backend/src/tests/modules/metering/record-spans.test.ts
index cd32b215a..a73ad2e6b 100644
--- a/packages/backend/src/tests/modules/metering/record-spans.test.ts
+++ b/packages/backend/src/tests/modules/metering/record-spans.test.ts
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { db } from '../../../database/index.js';
-import { recordSpanIngestion, meteringRecorder } from '../../../modules/metering/index.js';
+import { recordSpanIngestion, recordMetricIngestion, meteringRecorder } from '../../../modules/metering/index.js';
+import { getUsageBreakdown } from '../../../modules/metering/index.js';
import { createTestContext } from '../../helpers/factories.js';
describe('recordSpanIngestion', () => {
@@ -44,3 +45,121 @@ describe('recordSpanIngestion', () => {
expect(rows).toHaveLength(0);
});
});
+
+describe('recordMetricIngestion', () => {
+ let orgId: string;
+ let projectId: string;
+
+ beforeEach(async () => {
+ await db.deleteFrom('metering_events').execute();
+ const ctx = await createTestContext();
+ orgId = ctx.organization.id;
+ projectId = ctx.project.id;
+ });
+
+ const rows = () =>
+ db
+ .selectFrom('metering_events')
+ .selectAll()
+ .where('organization_id', '=', orgId)
+ .where('type', '=', 'metrics.ingested.datapoints')
+ .execute();
+
+ it('records the datapoint count and surfaces it in the usage breakdown', async () => {
+ recordMetricIngestion({ datapointCount: 9, organizationId: orgId, projectId });
+ await meteringRecorder.flush();
+
+ const r = await rows();
+ expect(r).toHaveLength(1);
+ expect(Number(r[0].quantity)).toBe(9);
+
+ const b = await getUsageBreakdown({
+ organizationId: orgId,
+ from: new Date(Date.now() - 60 * 60 * 1000),
+ to: new Date(Date.now() + 5 * 60 * 1000),
+ });
+ const byType = Object.fromEntries(b.byType.map((t) => [t.type, t.quantity]));
+ expect(byType['metrics.ingested.datapoints']).toBe(9);
+ });
+
+ it('records nothing for a zero-datapoint batch', async () => {
+ recordMetricIngestion({ datapointCount: 0, organizationId: orgId, projectId });
+ await meteringRecorder.flush();
+ expect(await rows()).toHaveLength(0);
+ });
+});
+
+describe('span and metric byte metering (telemetry quota)', () => {
+ let orgId: string;
+ let projectId: string;
+
+ beforeEach(async () => {
+ await db.deleteFrom('metering_events').execute();
+ const ctx = await createTestContext();
+ orgId = ctx.organization.id;
+ projectId = ctx.project.id;
+ });
+
+ const sumOf = async (type: string) => {
+ await meteringRecorder.flush();
+ const r = await db
+ .selectFrom('metering_events')
+ .select('quantity')
+ .where('organization_id', '=', orgId)
+ .where('type', '=', type as any)
+ .execute();
+ return { rows: r.length, total: r.reduce((a, x) => a + Number(x.quantity), 0) };
+ };
+
+ const injected = { organizationId: 'org-x', projectId: 'proj-x' };
+ const spans = [
+ { ...injected, time: 't0', startTime: 't0', spanId: 'a', attributes: { k: 'v', time: 'kept' } },
+ { ...injected, time: 't1', startTime: 't1', spanId: 'b', attributes: { k: 'w' } },
+ ];
+ const points = [{ ...injected, metricName: 'm', value: 1 }, { ...injected, metricName: 'n', value: 2 }];
+ // Customer-provided projection: no tenant ids, no span 'time' copy (nested keys untouched).
+ const spanBytes = Buffer.byteLength(JSON.stringify([
+ { startTime: 't0', spanId: 'a', attributes: { k: 'v', time: 'kept' } },
+ { startTime: 't1', spanId: 'b', attributes: { k: 'w' } },
+ ]));
+ const pointBytes = Buffer.byteLength(JSON.stringify([{ metricName: 'm', value: 1 }, { metricName: 'n', value: 2 }]));
+
+ it('records the JSON byte size of the customer-provided span fields', async () => {
+ recordSpanIngestion({ spanCount: 2, organizationId: orgId, projectId, spans });
+ expect(await sumOf('spans.ingested.bytes')).toEqual({ rows: 1, total: spanBytes });
+ });
+
+ it('records the JSON byte size of the customer-provided datapoint fields', async () => {
+ const before = JSON.stringify(points);
+ recordMetricIngestion({ datapointCount: 2, organizationId: orgId, projectId, records: points });
+ expect(await sumOf('metrics.ingested.bytes')).toEqual({ rows: 1, total: pointBytes });
+ expect(JSON.stringify(points)).toBe(before); // stored records are not mutated
+ });
+
+ it('prorates a partially accepted batch', async () => {
+ recordSpanIngestion({ spanCount: 1, organizationId: orgId, projectId, spans });
+ expect((await sumOf('spans.ingested.bytes')).total).toBe(Math.round(spanBytes / 2));
+ });
+
+ it('records no bytes when nothing was accepted or no records were given', async () => {
+ recordSpanIngestion({ spanCount: 0, organizationId: orgId, projectId, spans });
+ recordMetricIngestion({ datapointCount: 0, organizationId: orgId, projectId, records: points });
+ recordSpanIngestion({ spanCount: 3, organizationId: orgId, projectId });
+ expect((await sumOf('spans.ingested.bytes')).rows).toBe(0);
+ expect((await sumOf('metrics.ingested.bytes')).rows).toBe(0);
+ });
+
+ it('surfaces the byte types in the usage breakdown', async () => {
+ recordSpanIngestion({ spanCount: 2, organizationId: orgId, projectId, spans });
+ recordMetricIngestion({ datapointCount: 2, organizationId: orgId, projectId, records: points });
+ await meteringRecorder.flush();
+ const b = await getUsageBreakdown({
+ organizationId: orgId,
+ from: new Date(Date.now() - 60 * 60 * 1000),
+ to: new Date(Date.now() + 5 * 60 * 1000),
+ });
+ const types = b.byType.map((t) => t.type);
+ expect(types).toContain('spans.ingested.bytes');
+ expect(types).toContain('metrics.ingested.bytes');
+ });
+});
diff --git a/packages/backend/src/tests/modules/metrics/service.test.ts b/packages/backend/src/tests/modules/metrics/service.test.ts
index 6ded75457..f1644c00e 100644
--- a/packages/backend/src/tests/modules/metrics/service.test.ts
+++ b/packages/backend/src/tests/modules/metrics/service.test.ts
@@ -21,11 +21,13 @@ vi.mock('../../../database/reservoir.js', () => ({
}));
const mockMeteringRecord = vi.fn();
+const mockRecordMetric = vi.fn();
vi.mock('../../../modules/metering/index.js', () => ({
metering: {
record: (...args: unknown[]) => mockMeteringRecord(...args),
},
+ recordMetricIngestion: (...args: unknown[]) => mockRecordMetric(...args),
}));
import { MetricsService } from '../../../modules/metrics/service.js';
@@ -109,6 +111,34 @@ describe('MetricsService', () => {
expect(result).toBe(5);
});
+ describe('datapoint metering', () => {
+ const rec = (n: number) => Array.from({ length: n }, (_, i) => ({
+ time: new Date(), metricName: 'm' + i, metricType: 'gauge' as const, value: 1,
+ serviceName: 's', organizationId: '', projectId: '',
+ }));
+
+ it('records the accepted datapoint count under metrics.ingested.datapoints', async () => {
+ mockIngestMetrics.mockResolvedValueOnce({ ingested: 3 });
+ await service.ingestMetrics(rec(3), 'proj-1', 'org-1');
+ expect(mockRecordMetric).toHaveBeenCalledWith({
+ datapointCount: 3, organizationId: 'org-1', projectId: 'proj-1', records: expect.any(Array),
+ });
+ });
+
+ it('counts only accepted datapoints, not the submitted batch', async () => {
+ mockIngestMetrics.mockResolvedValueOnce({ ingested: 2, failed: 3 });
+ await service.ingestMetrics(rec(5), 'proj-1', 'org-1');
+ expect(mockRecordMetric.mock.calls[0][0].datapointCount).toBe(2);
+ });
+
+ it('records nothing when no datapoint was accepted', async () => {
+ mockIngestMetrics.mockResolvedValueOnce({ ingested: 0, failed: 2 });
+ await service.ingestMetrics(rec(2), 'proj-1', 'org-1');
+ // The service passes the accepted count (0); the helper's <= 0 guard drops it.
+ expect(mockRecordMetric.mock.calls[0][0].datapointCount).toBe(0);
+ });
+ });
+
describe('clock skew detection (span-metric-skew)', () => {
it('counts a metric with a past-skewed time', async () => {
mockIngestMetrics.mockResolvedValueOnce({ ingested: 1 });
diff --git a/packages/backend/src/tests/modules/organizations/org-creation-cap.test.ts b/packages/backend/src/tests/modules/organizations/org-creation-cap.test.ts
new file mode 100644
index 000000000..87e1ca807
--- /dev/null
+++ b/packages/backend/src/tests/modules/organizations/org-creation-cap.test.ts
@@ -0,0 +1,141 @@
+import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
+import type { FastifyInstance } from 'fastify';
+import { db } from '../../../database/index.js';
+import { reservoirReady } from '../../../database/reservoir.js';
+import { config, configSchema } from '../../../config/index.js';
+import { build } from '../../../server.js';
+import { startOfUtcMonth } from '../../../capabilities/month.js';
+import { createTestUser } from '../../helpers/factories.js';
+import { createTestSession } from '../../helpers/auth.js';
+
+const cfg = config as { MAX_ORGANIZATION_CREATIONS_PER_USER_PER_MONTH?: number; MAX_ORGANIZATIONS_PER_USER?: number };
+const base = { DATABASE_URL: 'postgres://u:p@localhost:5432/db', API_KEY_SECRET: 'x'.repeat(32) };
+const KEY = 'MAX_ORGANIZATION_CREATIONS_PER_USER_PER_MONTH';
+
+describe('MAX_ORGANIZATION_CREATIONS_PER_USER_PER_MONTH env validation', () => {
+ const parse = (extra: Record) => configSchema.safeParse({ ...base, ...extra });
+
+ it('is unlimited when unset or empty', () => {
+ const a = parse({});
+ expect(a.success && a.data[KEY]).toBeUndefined();
+ const b = parse({ [KEY]: '' });
+ expect(b.success && b.data[KEY]).toBeUndefined();
+ });
+
+ it('accepts a positive integer', () => {
+ const r = parse({ [KEY]: '2' });
+ expect(r.success && r.data[KEY]).toBe(2);
+ });
+
+ it.each(['0', '-1', '1.5', 'abc'])('boot fails on %s', (v) => {
+ expect(parse({ [KEY]: v }).success).toBe(false);
+ });
+});
+
+describe('POST /api/v1/organizations monthly creation cap', () => {
+ let app: FastifyInstance;
+ let userId: string;
+ let token: string;
+
+ beforeAll(async () => {
+ await reservoirReady;
+ app = await build({ logger: false });
+ await app.ready();
+ });
+
+ afterAll(async () => {
+ await app.close();
+ });
+
+ beforeEach(async () => {
+ await db.deleteFrom('organization_creation_log').execute();
+ await db.deleteFrom('sessions').execute();
+ await db.deleteFrom('organization_members').execute();
+ await db.deleteFrom('projects').execute();
+ await db.deleteFrom('organizations').execute();
+ await db.deleteFrom('users').execute();
+ const user = await createTestUser({ email: `cc-${Date.now()}@example.com` });
+ userId = user.id;
+ token = (await createTestSession(userId)).token;
+ });
+
+ afterEach(() => {
+ delete cfg.MAX_ORGANIZATION_CREATIONS_PER_USER_PER_MONTH;
+ delete cfg.MAX_ORGANIZATIONS_PER_USER;
+ });
+
+ const create = (name: string, tok = token) =>
+ app.inject({
+ method: 'POST',
+ url: '/api/v1/organizations',
+ headers: { Authorization: `Bearer ${tok}` },
+ payload: { name },
+ });
+
+ const logRows = (uid: string) =>
+ db.selectFrom('organization_creation_log').selectAll().where('user_id', '=', uid).execute();
+
+ it('is unlimited when unset and still writes a log row per creation', async () => {
+ for (let i = 0; i < 4; i++) expect((await create(`Org ${i}`)).statusCode).toBe(201);
+ const rows = await logRows(userId);
+ expect(rows).toHaveLength(4);
+ const orgs = await db.selectFrom('organizations').select('id').execute();
+ expect(new Set(rows.map((r) => r.organization_id))).toEqual(new Set(orgs.map((o) => o.id)));
+ });
+
+ it('blocks the third creation in a month even after deleting the first two orgs', async () => {
+ cfg.MAX_ORGANIZATION_CREATIONS_PER_USER_PER_MONTH = 2;
+ const a = await create('A');
+ const b = await create('B');
+ expect(a.statusCode).toBe(201);
+ expect(b.statusCode).toBe(201);
+ await db.deleteFrom('organizations').execute();
+
+ const res = await create('C');
+ expect(res.statusCode).toBe(403);
+ const body = JSON.parse(res.body);
+ expect(body.error).toBe('organization_creation_limit_reached');
+ expect(typeof body.message).toBe('string');
+ expect(await db.selectFrom('organizations').select('id').execute()).toHaveLength(0);
+ expect(await logRows(userId)).toHaveLength(2);
+ });
+
+ it('does not count creations from a previous month', async () => {
+ cfg.MAX_ORGANIZATION_CREATIONS_PER_USER_PER_MONTH = 1;
+ const prev = new Date(startOfUtcMonth(new Date()).getTime() - 3_600_000);
+ await db.insertInto('organization_creation_log').values({ user_id: userId, organization_id: null, created_at: prev }).execute();
+ expect((await create('Fresh')).statusCode).toBe(201);
+ expect((await create('Again')).statusCode).toBe(403);
+ });
+
+ it('does not count other users creations', async () => {
+ cfg.MAX_ORGANIZATION_CREATIONS_PER_USER_PER_MONTH = 1;
+ const other = await createTestUser({ email: `cc-other-${Date.now()}@example.com` });
+ await db.insertInto('organization_creation_log').values({ user_id: other.id, organization_id: null }).execute();
+ expect((await create('Mine')).statusCode).toBe(201);
+ });
+
+ it('checks MAX_ORGANIZATIONS_PER_USER first', async () => {
+ cfg.MAX_ORGANIZATIONS_PER_USER = 1;
+ cfg.MAX_ORGANIZATION_CREATIONS_PER_USER_PER_MONTH = 1;
+ expect((await create('One')).statusCode).toBe(201);
+ const res = await create('Two');
+ expect(res.statusCode).toBe(403);
+ expect(JSON.parse(res.body).error).toBe('organization_limit_reached');
+ });
+
+ it('exactly one of parallel POSTs at the boundary succeeds', async () => {
+ cfg.MAX_ORGANIZATION_CREATIONS_PER_USER_PER_MONTH = 1;
+ const results = await Promise.all(Array.from({ length: 6 }, (_, i) => create(`Race ${i}`)));
+ const codes = results.map((r) => r.statusCode).sort();
+ expect(codes.filter((c) => c === 201)).toHaveLength(1);
+ expect(codes.filter((c) => c === 403)).toHaveLength(5);
+ expect(await logRows(userId)).toHaveLength(1);
+ });
+
+ it('parallel POSTs cannot overshoot MAX_ORGANIZATIONS_PER_USER', async () => {
+ cfg.MAX_ORGANIZATIONS_PER_USER = 2;
+ const results = await Promise.all(Array.from({ length: 6 }, (_, i) => create(`Own ${i}`)));
+ expect(results.filter((r) => r.statusCode === 201)).toHaveLength(2);
+ });
+});
diff --git a/packages/backend/src/tests/modules/organizations/org-limit.test.ts b/packages/backend/src/tests/modules/organizations/org-limit.test.ts
new file mode 100644
index 000000000..1bf0aae90
--- /dev/null
+++ b/packages/backend/src/tests/modules/organizations/org-limit.test.ts
@@ -0,0 +1,132 @@
+import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
+import crypto from 'crypto';
+import Fastify, { FastifyInstance } from 'fastify';
+import { db } from '../../../database/index.js';
+import { config, configSchema } from '../../../config/index.js';
+import { organizationsRoutes } from '../../../modules/organizations/routes.js';
+import { createTestUser } from '../../helpers/factories.js';
+
+const cfg = config as { MAX_ORGANIZATIONS_PER_USER?: number };
+const base = {
+ DATABASE_URL: 'postgres://u:p@localhost:5432/db',
+ API_KEY_SECRET: 'x'.repeat(32),
+};
+
+async function session(userId: string) {
+ const token = crypto.randomBytes(32).toString('hex');
+ await db
+ .insertInto('sessions')
+ .values({ user_id: userId, token, expires_at: new Date(Date.now() + 86_400_000) })
+ .execute();
+ return token;
+}
+
+async function ownOrg(userId: string, n: number) {
+ const org = await db
+ .insertInto('organizations')
+ .values({ name: `Org ${n}`, slug: `lim-${n}-${crypto.randomBytes(3).toString('hex')}`, owner_id: userId })
+ .returningAll()
+ .executeTakeFirstOrThrow();
+ await db.insertInto('organization_members').values({ organization_id: org.id, user_id: userId, role: 'owner' }).execute();
+ return org;
+}
+
+describe('MAX_ORGANIZATIONS_PER_USER env validation', () => {
+ const parse = (extra: Record) => configSchema.safeParse({ ...base, ...extra });
+
+ it('is unlimited when unset or empty', () => {
+ const a = parse({});
+ expect(a.success && a.data.MAX_ORGANIZATIONS_PER_USER).toBeUndefined();
+ const b = parse({ MAX_ORGANIZATIONS_PER_USER: '' });
+ expect(b.success && b.data.MAX_ORGANIZATIONS_PER_USER).toBeUndefined();
+ });
+
+ it('accepts a positive integer', () => {
+ const r = parse({ MAX_ORGANIZATIONS_PER_USER: '3' });
+ expect(r.success && r.data.MAX_ORGANIZATIONS_PER_USER).toBe(3);
+ });
+
+ it.each(['0', '-1', '1.5', 'abc'])('rejects %s', (v) => {
+ expect(parse({ MAX_ORGANIZATIONS_PER_USER: v }).success).toBe(false);
+ });
+});
+
+describe('POST /api/v1/organizations per-user cap', () => {
+ let app: FastifyInstance;
+ let userId: string;
+ let token: string;
+
+ beforeAll(async () => {
+ app = Fastify();
+ await app.register(organizationsRoutes, { prefix: '/api/v1/organizations' });
+ await app.ready();
+ });
+
+ afterAll(async () => {
+ await app.close();
+ });
+
+ beforeEach(async () => {
+ await db.deleteFrom('sessions').execute();
+ await db.deleteFrom('organization_members').execute();
+ await db.deleteFrom('projects').execute();
+ await db.deleteFrom('organizations').execute();
+ await db.deleteFrom('users').execute();
+ const user = await createTestUser({ email: `cap-${Date.now()}@example.com` });
+ userId = user.id;
+ token = await session(userId);
+ });
+
+ afterEach(() => {
+ delete cfg.MAX_ORGANIZATIONS_PER_USER;
+ });
+
+ const create = (name: string) =>
+ app.inject({
+ method: 'POST',
+ url: '/api/v1/organizations',
+ headers: { Authorization: `Bearer ${token}` },
+ payload: { name },
+ });
+
+ it('returns 403 organization_limit_reached when the user owns the cap', async () => {
+ cfg.MAX_ORGANIZATIONS_PER_USER = 2;
+ await ownOrg(userId, 1);
+ await ownOrg(userId, 2);
+
+ const res = await create('Third org');
+ expect(res.statusCode).toBe(403);
+ expect(JSON.parse(res.body).error).toBe('organization_limit_reached');
+ const count = await db.selectFrom('organizations').select((eb) => eb.fn.countAll().as('c')).executeTakeFirstOrThrow();
+ expect(Number(count.c)).toBe(2);
+ });
+
+ it('creates the organization when under the cap', async () => {
+ cfg.MAX_ORGANIZATIONS_PER_USER = 2;
+ await ownOrg(userId, 1);
+
+ const res = await create('Second org');
+ expect(res.statusCode).toBe(201);
+ });
+
+ it('creates the organization when the env is unset', async () => {
+ await ownOrg(userId, 1);
+ await ownOrg(userId, 2);
+ await ownOrg(userId, 3);
+
+ const res = await create('Fourth org');
+ expect(res.statusCode).toBe(201);
+ });
+
+ it('does not count organizations where the user is only a member or admin', async () => {
+ cfg.MAX_ORGANIZATIONS_PER_USER = 1;
+ const other = await createTestUser({ email: `other-${Date.now()}@example.com` });
+ const o1 = await ownOrg(other.id, 1);
+ await db.insertInto('organization_members').values({ organization_id: o1.id, user_id: userId, role: 'member' }).execute();
+ const o2 = await ownOrg(other.id, 2);
+ await db.insertInto('organization_members').values({ organization_id: o2.id, user_id: userId, role: 'admin' }).execute();
+
+ const res = await create('My first org');
+ expect(res.statusCode).toBe(201);
+ });
+});
diff --git a/packages/backend/src/tests/modules/organizations/upgrade-handoff.test.ts b/packages/backend/src/tests/modules/organizations/upgrade-handoff.test.ts
new file mode 100644
index 000000000..4afc70c22
--- /dev/null
+++ b/packages/backend/src/tests/modules/organizations/upgrade-handoff.test.ts
@@ -0,0 +1,270 @@
+import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
+import crypto from 'crypto';
+import type { FastifyInstance } from 'fastify';
+import { build } from '../../../server.js';
+import { db } from '../../../database/index.js';
+import { config, configSchema } from '../../../config/index.js';
+import { CapabilityError, QuotaExceededError } from '../../../capabilities/errors.js';
+import {
+ signUpgradeToken,
+ verifyUpgradeToken,
+ buildUpgradeUrl,
+ isUpgradeHandoffActive,
+} from '../../../modules/organizations/upgrade-handoff.js';
+import { createTestUser, createTestOrganization } from '../../helpers/factories.js';
+
+const SECRET = 'u'.repeat(40);
+const PORTAL = 'https://billing.example.com/portal';
+const cfg = config as { LOGTIDE_UPGRADE_URL?: string; LOGTIDE_UPGRADE_SIGNING_SECRET?: string };
+
+const base = {
+ DATABASE_URL: 'postgres://u:p@localhost:5432/db',
+ API_KEY_SECRET: 'x'.repeat(32),
+};
+
+async function session(userId: string) {
+ const token = crypto.randomBytes(32).toString('hex');
+ await db
+ .insertInto('sessions')
+ .values({ user_id: userId, token, expires_at: new Date(Date.now() + 86_400_000) })
+ .execute();
+ return token;
+}
+
+function activate() {
+ cfg.LOGTIDE_UPGRADE_URL = PORTAL;
+ cfg.LOGTIDE_UPGRADE_SIGNING_SECRET = SECRET;
+}
+function deactivate() {
+ delete cfg.LOGTIDE_UPGRADE_URL;
+ delete cfg.LOGTIDE_UPGRADE_SIGNING_SECRET;
+}
+
+describe('upgrade handoff env validation', () => {
+ const parse = (extra: Record) => configSchema.safeParse({ ...base, ...extra });
+
+ it('is inert when both are unset or empty', () => {
+ expect(parse({}).success).toBe(true);
+ expect(parse({ LOGTIDE_UPGRADE_URL: '', LOGTIDE_UPGRADE_SIGNING_SECRET: '' }).success).toBe(true);
+ });
+
+ it('rejects exactly one of the two', () => {
+ const a = parse({ LOGTIDE_UPGRADE_URL: PORTAL });
+ expect(a.success).toBe(false);
+ if (!a.success) expect(JSON.stringify(a.error.issues)).toContain('must be set together');
+ expect(parse({ LOGTIDE_UPGRADE_SIGNING_SECRET: SECRET }).success).toBe(false);
+ });
+
+ it('rejects an invalid or non-https URL, allows local http', () => {
+ expect(parse({ LOGTIDE_UPGRADE_URL: 'not a url', LOGTIDE_UPGRADE_SIGNING_SECRET: SECRET }).success).toBe(false);
+ expect(parse({ LOGTIDE_UPGRADE_URL: 'http://billing.example.com', LOGTIDE_UPGRADE_SIGNING_SECRET: SECRET }).success).toBe(false);
+ expect(parse({ LOGTIDE_UPGRADE_URL: 'http://localhost:4000/x', LOGTIDE_UPGRADE_SIGNING_SECRET: SECRET }).success).toBe(true);
+ expect(parse({ LOGTIDE_UPGRADE_URL: 'http://127.0.0.1:4000/x', LOGTIDE_UPGRADE_SIGNING_SECRET: SECRET }).success).toBe(true);
+ expect(parse({ LOGTIDE_UPGRADE_URL: PORTAL, LOGTIDE_UPGRADE_SIGNING_SECRET: SECRET }).success).toBe(true);
+ });
+
+ it('rejects a secret shorter than 32 characters', () => {
+ expect(parse({ LOGTIDE_UPGRADE_URL: PORTAL, LOGTIDE_UPGRADE_SIGNING_SECRET: 's'.repeat(31) }).success).toBe(false);
+ expect(parse({ LOGTIDE_UPGRADE_URL: PORTAL, LOGTIDE_UPGRADE_SIGNING_SECRET: 's'.repeat(32) }).success).toBe(true);
+ });
+});
+
+describe('upgrade token signer and reference verifier', () => {
+ const input = { org: 'o1', user: 'u1', email: 'a@b.c', role: 'owner', intent: 'upgrade' as const };
+
+ it('round-trips', () => {
+ const token = signUpgradeToken(input, SECRET);
+ const payload = verifyUpgradeToken(token, SECRET);
+ expect(payload).toMatchObject({ v: 1, ...input });
+ expect(payload!.nonce.length).toBeGreaterThanOrEqual(21);
+ const now = Math.floor(Date.now() / 1000);
+ expect(payload!.exp).toBeGreaterThan(now + 590);
+ expect(payload!.exp).toBeLessThanOrEqual(now + 601);
+ });
+
+ it('uses a fresh nonce per token', () => {
+ const a = verifyUpgradeToken(signUpgradeToken(input, SECRET), SECRET)!;
+ const b = verifyUpgradeToken(signUpgradeToken(input, SECRET), SECRET)!;
+ expect(a.nonce).not.toBe(b.nonce);
+ });
+
+ it('rejects a tampered payload', () => {
+ const token = signUpgradeToken(input, SECRET);
+ const [enc, mac] = token.split('.');
+ const forged = { ...JSON.parse(Buffer.from(enc, 'base64url').toString()), role: 'member', org: 'other' };
+ const tampered = `${Buffer.from(JSON.stringify(forged)).toString('base64url')}.${mac}`;
+ const truncated = `${enc}.${mac.slice(0, -2)}AA`;
+ expect(verifyUpgradeToken(tampered, SECRET)).toBeNull();
+ expect(verifyUpgradeToken(token, 'w'.repeat(40))).toBeNull();
+ expect(verifyUpgradeToken(truncated, SECRET)).toBeNull();
+ expect(verifyUpgradeToken('garbage', SECRET)).toBeNull();
+ expect(verifyUpgradeToken('', SECRET)).toBeNull();
+ });
+
+ it('rejects a correctly signed payload with a wrong field type', () => {
+ const sealed = (p: object) => {
+ const enc = Buffer.from(JSON.stringify(p)).toString('base64url');
+ const mac = crypto.createHmac('sha256', SECRET).update(enc).digest('base64url');
+ return `${enc}.${mac}`;
+ };
+ const good = { v: 1, ...input, exp: Math.floor(Date.now() / 1000) + 300, nonce: 'n'.repeat(22) };
+ expect(verifyUpgradeToken(sealed(good), SECRET)).not.toBeNull();
+ expect(verifyUpgradeToken(sealed({ ...good, org: 5 }), SECRET)).toBeNull();
+ expect(verifyUpgradeToken(sealed({ ...good, email: undefined }), SECRET)).toBeNull();
+ expect(verifyUpgradeToken(sealed({ ...good, nonce: '' }), SECRET)).toBeNull();
+ expect(verifyUpgradeToken(sealed({ ...good, intent: 'admin' }), SECRET)).toBeNull();
+ });
+
+ it('rejects an expired token', () => {
+ const t0 = Date.now() - 11 * 60 * 1000;
+ const token = signUpgradeToken(input, SECRET, t0);
+ expect(verifyUpgradeToken(token, SECRET)).toBeNull();
+ expect(verifyUpgradeToken(token, SECRET, t0 + 9 * 60 * 1000)).not.toBeNull();
+ });
+
+ it('builds the url preserving an existing query string', () => {
+ const url = new URL(buildUpgradeUrl('https://billing.example.com/p?plan=pro', 'TOK'));
+ expect(url.searchParams.get('plan')).toBe('pro');
+ expect(url.searchParams.get('t')).toBe('TOK');
+ });
+});
+
+describe('upgrade handoff integration', () => {
+ let app: FastifyInstance;
+ let appOff: FastifyInstance;
+
+ beforeAll(async () => {
+ // The handoff route is registered at boot only when configured.
+ deactivate();
+ appOff = await build({ logger: false });
+ await appOff.ready();
+ activate();
+ app = await build({ logger: false });
+ deactivate();
+ app.get('/__cap', async () => {
+ throw new CapabilityError('FEATURE_DISABLED', 'nope', 'alerts.max_rules');
+ });
+ app.get('/__quota', async () => {
+ throw new QuotaExceededError('QUOTA_EXCEEDED', 'over', 'ingestion.max_events_monthly');
+ });
+ await app.ready();
+ });
+
+ afterAll(async () => {
+ deactivate();
+ await app.close();
+ await appOff.close();
+ });
+
+ afterEach(() => deactivate());
+
+ async function fixture() {
+ const owner = await createTestUser();
+ const org = await createTestOrganization({ ownerId: owner.id });
+ const member = await createTestUser();
+ const admin = await createTestUser();
+ await db
+ .insertInto('organization_members')
+ .values([
+ { organization_id: org.id, user_id: member.id, role: 'member' },
+ { organization_id: org.id, user_id: admin.id, role: 'admin' },
+ ])
+ .execute();
+ return { owner, member, admin, org };
+ }
+
+ const post = (orgId: string, token: string | null, body: unknown) =>
+ app.inject({
+ method: 'POST',
+ url: `/api/v1/organizations/${orgId}/upgrade-handoff`,
+ headers: token ? { authorization: `Bearer ${token}` } : {},
+ payload: body as any,
+ });
+
+ it('is not active by default', () => {
+ expect(isUpgradeHandoffActive()).toBe(false);
+ });
+
+ it('route does not exist when not configured (default 404, even unauthenticated)', async () => {
+ const f = await fixture();
+ const url = `/api/v1/organizations/${f.org.id}/upgrade-handoff`;
+ const authed = await appOff.inject({
+ method: 'POST',
+ url,
+ headers: { authorization: `Bearer ${await session(f.owner.id)}` },
+ payload: { intent: 'upgrade' },
+ });
+ expect(authed.statusCode).toBe(404);
+ const anon = await appOff.inject({ method: 'POST', url, payload: { intent: 'upgrade' } });
+ expect(anon.statusCode).toBe(404);
+ });
+
+ it('is inactive when only one var is set', () => {
+ cfg.LOGTIDE_UPGRADE_URL = PORTAL;
+ expect(isUpgradeHandoffActive()).toBe(false);
+ });
+
+ it('owner and admin get a signed url, member gets 403', async () => {
+ activate();
+ const f = await fixture();
+
+ const ownerRes = await post(f.org.id, await session(f.owner.id), { intent: 'upgrade' });
+ expect(ownerRes.statusCode).toBe(200);
+ const url = new URL(ownerRes.json().url);
+ expect(url.origin + url.pathname).toBe(PORTAL);
+ const payload = verifyUpgradeToken(url.searchParams.get('t')!, SECRET)!;
+ expect(payload).toMatchObject({ org: f.org.id, user: f.owner.id, email: f.owner.email, role: 'owner', intent: 'upgrade' });
+
+ const adminRes = await post(f.org.id, await session(f.admin.id), { intent: 'manage' });
+ expect(adminRes.statusCode).toBe(200);
+ const adminPayload = verifyUpgradeToken(new URL(adminRes.json().url).searchParams.get('t')!, SECRET)!;
+ expect(adminPayload).toMatchObject({ role: 'admin', intent: 'manage' });
+
+ const memberRes = await post(f.org.id, await session(f.member.id), { intent: 'upgrade' });
+ expect(memberRes.statusCode).toBe(403);
+ });
+
+ it('never leaks the session token into the handoff url', async () => {
+ activate();
+ const f = await fixture();
+ const sess = await session(f.owner.id);
+ const res = await post(f.org.id, sess, { intent: 'upgrade' });
+ expect(res.body).not.toContain(sess);
+ const enc = new URL(res.json().url).searchParams.get('t')!.split('.')[0];
+ expect(Buffer.from(enc, 'base64url').toString()).not.toContain(sess);
+ });
+
+ it('403 for a non-member, 401 unauthenticated, 400 bad intent', async () => {
+ activate();
+ const f = await fixture();
+ const outsider = await createTestUser();
+ expect((await post(f.org.id, await session(outsider.id), { intent: 'upgrade' })).statusCode).toBe(403);
+ expect((await post(f.org.id, null, { intent: 'upgrade' })).statusCode).toBe(401);
+ expect((await post(f.org.id, await session(f.owner.id), { intent: 'nope' })).statusCode).toBe(400);
+ });
+
+ it('auth config exposes upgradeEnabled only when active', async () => {
+ const off = (await app.inject({ method: 'GET', url: '/api/v1/auth/config' })).json();
+ expect('upgradeEnabled' in off).toBe(false);
+ activate();
+ const on = (await app.inject({ method: 'GET', url: '/api/v1/auth/config' })).json();
+ expect(on.upgradeEnabled).toBe(true);
+ });
+
+ it('capability and quota errors carry upgrade_available only when active', async () => {
+ const u = await createTestUser();
+ const headers = { authorization: `Bearer ${await session(u.id)}` };
+ for (const url of ['/__cap', '/__quota']) {
+ const off = await app.inject({ method: 'GET', url, headers });
+ expect([403, 429]).toContain(off.statusCode);
+ expect('upgrade_available' in off.json()).toBe(false);
+ }
+ activate();
+ const cap = await app.inject({ method: 'GET', url: '/__cap', headers });
+ expect(cap.statusCode).toBe(403);
+ expect(cap.json().upgrade_available).toBe(true);
+ const quota = await app.inject({ method: 'GET', url: '/__quota', headers });
+ expect(quota.statusCode).toBe(429);
+ expect(quota.json().upgrade_available).toBe(true);
+ });
+});
diff --git a/packages/backend/src/tests/modules/otlp/otlp-upgrade-hint.test.ts b/packages/backend/src/tests/modules/otlp/otlp-upgrade-hint.test.ts
new file mode 100644
index 000000000..a35f56d69
--- /dev/null
+++ b/packages/backend/src/tests/modules/otlp/otlp-upgrade-hint.test.ts
@@ -0,0 +1,131 @@
+import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
+import type { FastifyInstance } from 'fastify';
+import { db } from '../../../database/index.js';
+import { reservoirReady } from '../../../database/reservoir.js';
+import { config } from '../../../config/index.js';
+import { build } from '../../../server.js';
+import { capabilities, quotaFlagCache } from '../../../capabilities/index.js';
+import { createTestApiKey, createTestContext } from '../../helpers/factories.js';
+
+const cfg = config as { LOGTIDE_UPGRADE_URL?: string; LOGTIDE_UPGRADE_SIGNING_SECRET?: string };
+const nowNs = () => String(Date.now() * 1_000_000);
+
+const bodies: Record = {
+ logs: {
+ url: '/v1/otlp/logs',
+ cap: 'ingestion.max_events_monthly',
+ rejectedKey: 'rejectedLogRecords',
+ payload: {
+ resourceLogs: [
+ {
+ resource: { attributes: [{ key: 'service.name', value: { stringValue: 'svc' } }] },
+ scopeLogs: [{ logRecords: [{ timeUnixNano: nowNs(), severityNumber: 9, body: { stringValue: 'x' } }] }],
+ },
+ ],
+ },
+ },
+ traces: {
+ url: '/v1/otlp/traces',
+ cap: 'tracing.max_spans_monthly',
+ rejectedKey: 'rejectedSpans',
+ payload: {
+ resourceSpans: [
+ {
+ resource: { attributes: [{ key: 'service.name', value: { stringValue: 'svc' } }] },
+ scopeSpans: [
+ {
+ spans: [
+ {
+ traceId: '00000000000000000000000000000001',
+ spanId: '0000000000000001',
+ name: 'op',
+ kind: 1,
+ startTimeUnixNano: nowNs(),
+ endTimeUnixNano: nowNs(),
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ },
+ metrics: {
+ url: '/v1/otlp/metrics',
+ cap: 'metrics.max_datapoints_monthly',
+ rejectedKey: 'rejectedDataPoints',
+ payload: {
+ resourceMetrics: [
+ {
+ resource: { attributes: [{ key: 'service.name', value: { stringValue: 'svc' } }] },
+ scopeMetrics: [{ metrics: [{ name: 'm', gauge: { dataPoints: [{ timeUnixNano: nowNs(), asDouble: 1 }] } }] }],
+ },
+ ],
+ },
+ },
+};
+
+describe('OTLP 429 bodies and the upgrade hint', () => {
+ let app: FastifyInstance;
+ let orgId: string;
+ let apiKey: string;
+
+ beforeAll(async () => {
+ await reservoirReady;
+ app = await build({ logger: false });
+ await app.ready();
+ });
+
+ afterAll(async () => {
+ await app.close();
+ });
+
+ beforeEach(async () => {
+ await db.deleteFrom('organization_entitlements').execute();
+ quotaFlagCache.clear();
+ const ctx = await createTestContext();
+ orgId = ctx.organization.id;
+ apiKey = (await createTestApiKey({ projectId: ctx.project.id })).plainKey;
+ for (const { cap } of Object.values(bodies)) {
+ await db
+ .insertInto('organization_entitlements')
+ .values({ organization_id: orgId, capability: cap, enabled: null, limit_value: 1 })
+ .execute();
+ }
+ capabilities.invalidate(orgId);
+ quotaFlagCache.setOrgFlags(orgId, Object.fromEntries(Object.values(bodies).map((b) => [b.cap, true])));
+ });
+
+ afterEach(() => {
+ delete cfg.LOGTIDE_UPGRADE_URL;
+ delete cfg.LOGTIDE_UPGRADE_SIGNING_SECRET;
+ });
+
+ const post = (b: (typeof bodies)[string]) =>
+ app.inject({
+ method: 'POST',
+ url: b.url,
+ headers: { 'x-api-key': apiKey, 'content-type': 'application/json' },
+ payload: JSON.stringify(b.payload),
+ });
+
+ for (const [signal, b] of Object.entries(bodies)) {
+ it(`${signal}: no upgrade_available when the handoff is inactive`, async () => {
+ const res = await post(b);
+ expect(res.statusCode).toBe(429);
+ const body = res.json();
+ expect(body.partialSuccess[b.rejectedKey]).toBe(-1);
+ expect(body).not.toHaveProperty('upgrade_available');
+ });
+
+ it(`${signal}: upgrade_available: true when the handoff is active`, async () => {
+ cfg.LOGTIDE_UPGRADE_URL = 'https://billing.example.com/portal';
+ cfg.LOGTIDE_UPGRADE_SIGNING_SECRET = 'u'.repeat(40);
+ const res = await post(b);
+ expect(res.statusCode).toBe(429);
+ const body = res.json();
+ expect(body.partialSuccess[b.rejectedKey]).toBe(-1);
+ expect(body.upgrade_available).toBe(true);
+ });
+ }
+});
diff --git a/packages/backend/src/tests/modules/projects/projects-max.test.ts b/packages/backend/src/tests/modules/projects/projects-max.test.ts
new file mode 100644
index 000000000..80201f301
--- /dev/null
+++ b/packages/backend/src/tests/modules/projects/projects-max.test.ts
@@ -0,0 +1,159 @@
+import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
+import Fastify, { FastifyInstance } from 'fastify';
+import crypto from 'crypto';
+import { db } from '../../../database/index.js';
+import { projectsRoutes } from '../../../modules/projects/routes.js';
+import { contextPlugin } from '../../../context/index.js';
+import { capabilities } from '../../../capabilities/index.js';
+import { createTestContext } from '../../helpers/factories.js';
+
+async function createTestSession(userId: string) {
+ const token = crypto.randomBytes(32).toString('hex');
+ await db
+ .insertInto('sessions')
+ .values({ user_id: userId, token, expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000) })
+ .execute();
+ return token;
+}
+
+async function insertProject(orgId: string, userId: string, name: string, deleted = false) {
+ return db
+ .insertInto('projects')
+ .values({
+ name,
+ slug: `${name.toLowerCase().replace(/\s+/g, '-')}-${crypto.randomBytes(3).toString('hex')}`,
+ organization_id: orgId,
+ user_id: userId,
+ deleted_at: deleted ? new Date() : null,
+ })
+ .returningAll()
+ .executeTakeFirstOrThrow();
+}
+
+async function setLimit(orgId: string, limit: number) {
+ await db
+ .insertInto('organization_entitlements')
+ .values({ organization_id: orgId, capability: 'projects.max', enabled: null, limit_value: limit })
+ .execute();
+ capabilities.invalidate(orgId);
+}
+
+describe('projects.max enforcement', () => {
+ let app: FastifyInstance;
+ let orgId: string;
+ let userId: string;
+ let token: string;
+
+ beforeAll(async () => {
+ app = Fastify();
+ await app.register(contextPlugin);
+ await app.register(projectsRoutes, { prefix: '/api/v1/projects' });
+ await app.ready();
+ });
+
+ afterAll(async () => {
+ await app.close();
+ });
+
+ beforeEach(async () => {
+ await db.deleteFrom('organization_entitlements').execute();
+ await db.deleteFrom('api_keys').execute();
+ await db.deleteFrom('sessions').execute();
+ await db.deleteFrom('organization_members').execute();
+ await db.deleteFrom('projects').execute();
+ await db.deleteFrom('organizations').execute();
+ await db.deleteFrom('users').execute();
+
+ const ctx = await createTestContext();
+ orgId = ctx.organization.id;
+ userId = ctx.user.id;
+ token = await createTestSession(userId);
+ capabilities.invalidate(orgId);
+ });
+
+ const create = (organizationId: string, name: string, bearer = token) =>
+ app.inject({
+ method: 'POST',
+ url: '/api/v1/projects',
+ headers: { Authorization: `Bearer ${bearer}` },
+ payload: { organizationId, name },
+ });
+
+ // createTestContext already created 1 active project.
+ it('blocks creation when the limit is reached', async () => {
+ await setLimit(orgId, 1);
+
+ const res = await create(orgId, 'Over Limit');
+ expect(res.statusCode).toBe(403);
+ expect(JSON.parse(res.body).code).toBe('capability.projects.max.limit_reached');
+ });
+
+ it('allows creation when under the limit', async () => {
+ await setLimit(orgId, 2);
+
+ const res = await create(orgId, 'Under Limit');
+ expect(res.statusCode).toBe(201);
+ });
+
+ it('allows creation when no limit is configured (unlimited default)', async () => {
+ const res = await create(orgId, 'Unlimited');
+ expect(res.statusCode).toBe(201);
+ });
+
+ it('ignores soft-deleted projects and other organizations', async () => {
+ await setLimit(orgId, 2);
+ await insertProject(orgId, userId, 'Gone', true);
+
+ // Another org with several projects must not count against this one.
+ const ctxB = await createTestContext();
+ await insertProject(ctxB.organization.id, ctxB.user.id, 'B Extra 1');
+ await insertProject(ctxB.organization.id, ctxB.user.id, 'B Extra 2');
+
+ const res = await create(orgId, 'Still Fits');
+ expect(res.statusCode).toBe(201);
+
+ // Org A being at its limit does not block org B.
+ const tokenB = await createTestSession(ctxB.user.id);
+ capabilities.invalidate(ctxB.organization.id);
+ const resB = await create(ctxB.organization.id, 'B New', tokenB);
+ expect(resB.statusCode).toBe(201);
+
+ const blocked = await create(orgId, 'Now Full');
+ expect(blocked.statusCode).toBe(403);
+ });
+ const restore = (projectId: string, bearer = token) =>
+ app.inject({
+ method: 'POST',
+ url: `/api/v1/projects/${projectId}/restore`,
+ headers: { Authorization: `Bearer ${bearer}` },
+ });
+
+ it('blocks restoring a soft-deleted project when the limit is reached', async () => {
+ // 1 active project from createTestContext + 1 soft-deleted.
+ const gone = await insertProject(orgId, userId, 'Gone Restore', true);
+ await setLimit(orgId, 1);
+
+ const res = await restore(gone.id);
+ expect(res.statusCode).toBe(403);
+ expect(JSON.parse(res.body).code).toBe('capability.projects.max.limit_reached');
+
+ const row = await db.selectFrom('projects').select('deleted_at').where('id', '=', gone.id).executeTakeFirstOrThrow();
+ expect(row.deleted_at).not.toBeNull();
+ });
+
+ it('allows restoring a soft-deleted project when under the limit', async () => {
+ const gone = await insertProject(orgId, userId, 'Gone Restore Ok', true);
+ await setLimit(orgId, 2);
+
+ const res = await restore(gone.id);
+ expect(res.statusCode).toBe(200);
+ const row = await db.selectFrom('projects').select('deleted_at').where('id', '=', gone.id).executeTakeFirstOrThrow();
+ expect(row.deleted_at).toBeNull();
+ });
+
+ it('allows restoring when no limit is configured', async () => {
+ const gone = await insertProject(orgId, userId, 'Gone Restore Unlimited', true);
+ const res = await restore(gone.id);
+ expect(res.statusCode).toBe(200);
+ });
+});
diff --git a/packages/backend/src/tests/modules/receivers/receiver-gates.test.ts b/packages/backend/src/tests/modules/receivers/receiver-gates.test.ts
new file mode 100644
index 000000000..e4523b19f
--- /dev/null
+++ b/packages/backend/src/tests/modules/receivers/receiver-gates.test.ts
@@ -0,0 +1,106 @@
+/**
+ * Receiver creation mints an ingest credential, so it carries the same gates
+ * as API key creation: email verification and terms acceptance. Real app.
+ */
+import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
+import type { FastifyInstance } from 'fastify';
+import { build } from '../../../server.js';
+import { db } from '../../../database/index.js';
+import { reservoirReady } from '../../../database/reservoir.js';
+import { config } from '../../../config/index.js';
+import { createTestContext } from '../../helpers/factories.js';
+import { createTestSession } from '../../helpers/auth.js';
+
+const cfg = config as unknown as {
+ REQUIRE_EMAIL_VERIFICATION: boolean;
+ LOGTIDE_TERMS_URL?: string;
+ LOGTIDE_PRIVACY_URL?: string;
+ LOGTIDE_TERMS_VERSION?: string;
+};
+
+function activateTerms() {
+ cfg.LOGTIDE_TERMS_URL = 'https://logtide.example.com/terms';
+ cfg.LOGTIDE_PRIVACY_URL = 'https://logtide.example.com/privacy';
+ cfg.LOGTIDE_TERMS_VERSION = 'v1';
+}
+function reset() {
+ delete cfg.LOGTIDE_TERMS_URL;
+ delete cfg.LOGTIDE_PRIVACY_URL;
+ delete cfg.LOGTIDE_TERMS_VERSION;
+ cfg.REQUIRE_EMAIL_VERIFICATION = false;
+}
+
+describe('receiver creation gates (real app)', () => {
+ let app: FastifyInstance;
+
+ beforeAll(async () => {
+ reset();
+ await reservoirReady;
+ app = await build({ logger: false });
+ await app.ready();
+ });
+
+ afterAll(async () => {
+ await app.close();
+ });
+
+ afterEach(() => {
+ reset();
+ });
+
+ async function create(headers: Record, projectId: string) {
+ return app.inject({
+ method: 'POST',
+ url: `/api/v1/projects/${projectId}/receivers`,
+ headers,
+ payload: { name: 'ci', adapterType: 'github' },
+ });
+ }
+
+ async function setup() {
+ const ctx = await createTestContext();
+ const session = await createTestSession(ctx.user.id);
+ return { ctx, headers: { authorization: `Bearer ${session.token}` } };
+ }
+
+ async function receiverCount(projectId: string) {
+ const rows = await db.selectFrom('receivers').select('id').where('project_id', '=', projectId).execute();
+ return rows.length;
+ }
+
+ it('works with every seam inactive (NULL terms, unverified email)', async () => {
+ const { ctx, headers } = await setup();
+ await db.updateTable('users').set({ email_verified_at: null }).where('id', '=', ctx.user.id).execute();
+ const res = await create(headers, ctx.project.id);
+ expect(res.statusCode).toBe(201);
+ });
+
+ it('403 email_not_verified for an unverified user when verification is required', async () => {
+ const { ctx, headers } = await setup();
+ await db.updateTable('users').set({ email_verified_at: null }).where('id', '=', ctx.user.id).execute();
+ cfg.REQUIRE_EMAIL_VERIFICATION = true;
+ const res = await create(headers, ctx.project.id);
+ expect(res.statusCode).toBe(403);
+ expect(res.json()).toEqual({ error: 'email_not_verified' });
+ expect(await receiverCount(ctx.project.id)).toBe(0);
+
+ await db.updateTable('users').set({ email_verified_at: new Date() }).where('id', '=', ctx.user.id).execute();
+ expect((await create(headers, ctx.project.id)).statusCode).toBe(201);
+ });
+
+ it('403 terms_not_accepted while terms are pending, 201 after accepting', async () => {
+ const { ctx, headers } = await setup();
+ activateTerms();
+ const res = await create(headers, ctx.project.id);
+ expect(res.statusCode).toBe(403);
+ expect(res.json()).toEqual({ error: 'terms_not_accepted' });
+ expect(await receiverCount(ctx.project.id)).toBe(0);
+
+ await db
+ .updateTable('users')
+ .set({ terms_version: 'v1', terms_accepted_at: new Date() })
+ .where('id', '=', ctx.user.id)
+ .execute();
+ expect((await create(headers, ctx.project.id)).statusCode).toBe(201);
+ });
+});
diff --git a/packages/backend/src/tests/modules/users/email-verification.test.ts b/packages/backend/src/tests/modules/users/email-verification.test.ts
new file mode 100644
index 000000000..9978f0fcb
--- /dev/null
+++ b/packages/backend/src/tests/modules/users/email-verification.test.ts
@@ -0,0 +1,553 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import crypto from 'crypto';
+import Fastify, { type FastifyInstance } from 'fastify';
+import rateLimit from '@fastify/rate-limit';
+import { db } from '../../../database/index.js';
+import { config, configSchema } from '../../../config/index.js';
+import { usersRoutes } from '../../../modules/users/routes.js';
+import { publicSettingsRoutes } from '../../../modules/settings/routes.js';
+import { projectsRoutes } from '../../../modules/projects/routes.js';
+import { apiKeysRoutes } from '../../../modules/api-keys/routes.js';
+import { authenticationService } from '../../../modules/auth/authentication-service.js';
+import { settingsService } from '../../../modules/settings/service.js';
+import {
+ emailVerificationService,
+ hashVerificationToken,
+ RESEND_COOLDOWN_MS,
+} from '../../../modules/users/email-verification.js';
+import { createTestUser, createTestOrganization, createTestProject } from '../../helpers/factories.js';
+import { createTestSession } from '../../helpers/auth.js';
+
+beforeEach(() => {
+ vi.spyOn(settingsService, 'isSignupEnabled').mockResolvedValue(true);
+ vi.spyOn(settingsService, 'getAuthMode').mockResolvedValue('standard');
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+const cfg = config as { REQUIRE_EMAIL_VERIFICATION: boolean; REGISTRATION_RATE_LIMIT_PER_HOUR?: number };
+const base = {
+ DATABASE_URL: 'postgres://u:p@localhost:5432/db',
+ API_KEY_SECRET: 'x'.repeat(32),
+};
+
+async function buildApp(opts: { withRateLimit?: boolean } = {}): Promise {
+ const app = Fastify();
+ if (opts.withRateLimit) {
+ await app.register(rateLimit, { max: 10000, timeWindow: 60000 });
+ }
+ await app.register(usersRoutes, { prefix: '/api/v1/auth' });
+ await app.register(publicSettingsRoutes, { prefix: '/api/v1/auth' });
+ await app.register(projectsRoutes, { prefix: '/api/v1/projects' });
+ await app.register(apiKeysRoutes, { prefix: '/api/v1/projects' });
+ await app.ready();
+ return app;
+}
+
+function uniqueEmail(tag: string): string {
+ return `${tag}-${crypto.randomBytes(4).toString('hex')}@example.com`;
+}
+
+async function register(app: FastifyInstance, email: string, remoteAddress = '127.0.0.1', headers = {}) {
+ return app.inject({
+ method: 'POST',
+ url: '/api/v1/auth/register',
+ remoteAddress,
+ headers,
+ payload: { email, password: 'password123', name: 'Verify Me' },
+ });
+}
+
+async function tokenFromMailhog(email: string): Promise {
+ for (let i = 0; i < 20; i++) {
+ const res = await fetch(`http://localhost:8025/api/v2/search?kind=to&query=${encodeURIComponent(email)}`);
+ const data = (await res.json()) as { items: Array<{ Content: { Body: string } }> };
+ if (data.items.length > 0) {
+ const body = data.items[0].Content.Body.replace(/=\r?\n/g, '').replace(/=3D/g, '=');
+ const m = body.match(/verify-email\?token=([A-Za-z0-9_-]{43})/);
+ return m ? m[1] : null;
+ }
+ await new Promise((r) => setTimeout(r, 100));
+ }
+ return null;
+}
+
+async function eventually(fn: () => Promise, ok: (v: T) => boolean): Promise {
+ let last = await fn();
+ for (let i = 0; i < 50 && !ok(last); i++) {
+ await new Promise((r) => setTimeout(r, 100));
+ last = await fn();
+ }
+ return last;
+}
+
+async function seedToken(userId: string, opts: { expired?: boolean } = {}): Promise {
+ const token = crypto.randomBytes(32).toString('base64url');
+ await db
+ .insertInto('email_verification_tokens')
+ .values({
+ user_id: userId,
+ token_hash: hashVerificationToken(token),
+ expires_at: new Date(Date.now() + (opts.expired ? -60_000 : 3_600_000)),
+ })
+ .execute();
+ return token;
+}
+
+async function unverifiedUser(tag: string) {
+ const user = await createTestUser({ email: uniqueEmail(tag) });
+ await db.updateTable('users').set({ email_verified_at: null }).where('id', '=', user.id).execute();
+ return user;
+}
+
+async function cleanDb() {
+ await db.deleteFrom('api_keys').execute();
+ await db.deleteFrom('projects').execute();
+ await db.deleteFrom('organization_members').execute();
+ await db.deleteFrom('organizations').execute();
+ await db.deleteFrom('sessions').execute();
+ await db.deleteFrom('email_verification_tokens').execute();
+ await db.deleteFrom('users').execute();
+}
+
+describe('email verification env validation', () => {
+ const parse = (extra: Record) => configSchema.safeParse({ ...base, ...extra });
+
+ it('REQUIRE_EMAIL_VERIFICATION defaults to false', () => {
+ const a = parse({});
+ expect(a.success && a.data.REQUIRE_EMAIL_VERIFICATION).toBe(false);
+ const b = parse({ REQUIRE_EMAIL_VERIFICATION: '' });
+ expect(b.success && b.data.REQUIRE_EMAIL_VERIFICATION).toBe(false);
+ });
+
+ it('REQUIRE_EMAIL_VERIFICATION accepts true/false and rejects anything else', () => {
+ const on = parse({ REQUIRE_EMAIL_VERIFICATION: 'true', SMTP_HOST: 'smtp.example.com' });
+ expect(on.success && on.data.REQUIRE_EMAIL_VERIFICATION).toBe(true);
+ expect(parse({ REQUIRE_EMAIL_VERIFICATION: 'yes', SMTP_HOST: 'smtp.example.com' }).success).toBe(false);
+ });
+
+ it('REQUIRE_EMAIL_VERIFICATION=true without SMTP_HOST fails boot with a clear message', () => {
+ const r = parse({ REQUIRE_EMAIL_VERIFICATION: 'true' });
+ expect(r.success).toBe(false);
+ if (!r.success) {
+ const errors = r.error.flatten().fieldErrors.REQUIRE_EMAIL_VERIFICATION ?? [];
+ expect(errors.join(' ')).toMatch(/needs SMTP_HOST/);
+ }
+ // Flag off never needs SMTP
+ expect(parse({ REQUIRE_EMAIL_VERIFICATION: 'false' }).success).toBe(true);
+ });
+
+ it('REGISTRATION_RATE_LIMIT_PER_HOUR is unset by default and must be a positive integer', () => {
+ const a = parse({});
+ expect(a.success && a.data.REGISTRATION_RATE_LIMIT_PER_HOUR).toBeUndefined();
+ const b = parse({ REGISTRATION_RATE_LIMIT_PER_HOUR: '' });
+ expect(b.success && b.data.REGISTRATION_RATE_LIMIT_PER_HOUR).toBeUndefined();
+ const c = parse({ REGISTRATION_RATE_LIMIT_PER_HOUR: '5' });
+ expect(c.success && c.data.REGISTRATION_RATE_LIMIT_PER_HOUR).toBe(5);
+ for (const v of ['0', '-1', '1.5', 'abc']) {
+ expect(parse({ REGISTRATION_RATE_LIMIT_PER_HOUR: v }).success).toBe(false);
+ }
+ });
+});
+
+describe('email verification gate OFF (default)', () => {
+ let app: FastifyInstance;
+
+ beforeEach(async () => {
+ cfg.REQUIRE_EMAIL_VERIFICATION = false;
+ await cleanDb();
+ app = await buildApp();
+ });
+
+ afterEach(async () => {
+ await app.close();
+ });
+
+ it('registers, auto-logs in and marks the user verified without a token or email', async () => {
+ const email = uniqueEmail('off');
+ const res = await register(app, email);
+ expect(res.statusCode).toBe(201);
+ const body = res.json();
+ expect(body.session.token).toBeDefined();
+ expect('emailVerified' in body.user).toBe(false);
+
+ const row = await db.selectFrom('users').select('email_verified_at').where('email', '=', email).executeTakeFirstOrThrow();
+ expect(row.email_verified_at).not.toBeNull();
+ const tokens = await db.selectFrom('email_verification_tokens').selectAll().execute();
+ expect(tokens).toHaveLength(0);
+ expect(await tokenFromMailhogOnce(email)).toBe(0);
+
+ const me = (await app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: { authorization: `Bearer ${body.session.token}` } })).json();
+ expect('emailVerified' in me.user).toBe(false);
+ });
+
+ it('does not expose the verification routes or the config key', async () => {
+ expect((await app.inject({ method: 'POST', url: '/api/v1/auth/verify-email', payload: { token: 'x' } })).statusCode).toBe(404);
+ expect((await app.inject({ method: 'POST', url: '/api/v1/auth/resend-verification', payload: {} })).statusCode).toBe(404);
+ const conf = (await app.inject({ method: 'GET', url: '/api/v1/auth/config' })).json();
+ expect('emailVerificationRequired' in conf).toBe(false);
+ });
+
+ it('never blocks project or API key creation, even for a null email_verified_at', async () => {
+ const user = await unverifiedUser('off-null');
+ const org = await createTestOrganization({ ownerId: user.id });
+ const session = await createTestSession(user.id);
+ const headers = { authorization: `Bearer ${session.token}` };
+ const proj = await app.inject({ method: 'POST', url: '/api/v1/projects', headers, payload: { organizationId: org.id, name: 'P' } });
+ expect(proj.statusCode).toBe(201);
+ const key = await app.inject({
+ method: 'POST',
+ url: `/api/v1/projects/${proj.json().project.id}/api-keys`,
+ headers,
+ payload: { name: 'k' },
+ });
+ expect(key.statusCode).toBe(201);
+ });
+});
+
+async function tokenFromMailhogOnce(email: string): Promise {
+ const res = await fetch(`http://localhost:8025/api/v2/search?kind=to&query=${encodeURIComponent(email)}`);
+ const data = (await res.json()) as { total: number };
+ return data.total;
+}
+
+describe('email verification gate ON', () => {
+ let app: FastifyInstance;
+
+ beforeEach(async () => {
+ cfg.REQUIRE_EMAIL_VERIFICATION = true;
+ await cleanDb();
+ app = await buildApp();
+ });
+
+ afterEach(async () => {
+ await app.close();
+ cfg.REQUIRE_EMAIL_VERIFICATION = false;
+ });
+
+ it('auth config exposes emailVerificationRequired', async () => {
+ const conf = (await app.inject({ method: 'GET', url: '/api/v1/auth/config' })).json();
+ expect(conf.emailVerificationRequired).toBe(true);
+ });
+
+ it('full flow: register sends a mail, gates projects and API keys, verify unlocks them', async () => {
+ const email = uniqueEmail('on');
+ const res = await register(app, email);
+ expect(res.statusCode).toBe(201);
+ const body = res.json();
+ expect(body.session.token).toBeDefined(); // login is still allowed
+ expect(body.user.emailVerified).toBe(false);
+ const headers = { authorization: `Bearer ${body.session.token}` };
+
+ const row = await db.selectFrom('users').select(['id', 'email_verified_at']).where('email', '=', email).executeTakeFirstOrThrow();
+ expect(row.email_verified_at).toBeNull();
+
+ // Email delivered to mailhog, token stored only as a sha256 hash
+ const token = await tokenFromMailhog(email);
+ expect(token).not.toBeNull();
+ const stored = await db.selectFrom('email_verification_tokens').selectAll().where('user_id', '=', row.id).execute();
+ expect(stored).toHaveLength(1);
+ expect(stored[0].token_hash).toBe(hashVerificationToken(token!));
+ expect(stored[0].token_hash).not.toContain(token!);
+ const ttl = new Date(stored[0].expires_at).getTime() - Date.now();
+ expect(ttl).toBeGreaterThan(23.9 * 3600_000);
+ expect(ttl).toBeLessThanOrEqual(24 * 3600_000);
+
+ const me = (await app.inject({ method: 'GET', url: '/api/v1/auth/me', headers })).json();
+ expect(me.user.emailVerified).toBe(false);
+
+ const org = await createTestOrganization({ ownerId: row.id });
+ const blockedProject = await app.inject({ method: 'POST', url: '/api/v1/projects', headers, payload: { organizationId: org.id, name: 'P' } });
+ expect(blockedProject.statusCode).toBe(403);
+ expect(blockedProject.json()).toEqual({ error: 'email_not_verified' });
+
+ // A project created out of band (e.g. by another org admin) still cannot get keys
+ const existing = await createTestProject({ organizationId: org.id, userId: row.id, name: 'Existing' });
+ const blockedKey = await app.inject({ method: 'POST', url: `/api/v1/projects/${existing.id}/api-keys`, headers, payload: { name: 'k' } });
+ expect(blockedKey.statusCode).toBe(403);
+ expect(blockedKey.json()).toEqual({ error: 'email_not_verified' });
+
+ const verify = await app.inject({ method: 'POST', url: '/api/v1/auth/verify-email', payload: { token } });
+ expect(verify.statusCode).toBe(200);
+ expect(verify.json()).toEqual({ verified: true });
+
+ const meAfter = (await app.inject({ method: 'GET', url: '/api/v1/auth/me', headers })).json();
+ expect(meAfter.user.emailVerified).toBe(true);
+
+ const key = await app.inject({ method: 'POST', url: `/api/v1/projects/${existing.id}/api-keys`, headers, payload: { name: 'k' } });
+ expect(key.statusCode).toBe(201);
+ const proj = await app.inject({ method: 'POST', url: '/api/v1/projects', headers, payload: { organizationId: org.id, name: 'P2' } });
+ expect(proj.statusCode).toBe(201);
+
+ // Single use
+ const reuse = await app.inject({ method: 'POST', url: '/api/v1/auth/verify-email', payload: { token } });
+ expect(reuse.statusCode).toBe(400);
+ expect(reuse.json()).toEqual({ error: 'invalid_or_expired_token' });
+ });
+
+ it('register does not wait for the verification email', async () => {
+ let release!: () => void;
+ const pending = new Promise((resolve) => {
+ release = () => resolve('t');
+ });
+ const spy = vi.spyOn(emailVerificationService, 'issueAndSend').mockReturnValue(pending);
+ const started = Date.now();
+ const res = await Promise.race([
+ register(app, uniqueEmail('slow-smtp')),
+ new Promise((r) => setTimeout(() => r(null), 3000)),
+ ]);
+ release();
+ expect(res).not.toBeNull();
+ expect(res!.statusCode).toBe(201);
+ expect(Date.now() - started).toBeLessThan(3000);
+ expect(spy).toHaveBeenCalledTimes(1);
+ });
+
+ it('register still answers 201 when sending the verification email fails', async () => {
+ vi.spyOn(emailVerificationService, 'issueAndSend').mockRejectedValue(new Error('smtp down'));
+ const res = await register(app, uniqueEmail('smtp-fail'));
+ expect(res.statusCode).toBe(201);
+ expect(res.json().session.token).toBeDefined();
+ });
+
+ it('rejects an expired token with 400', async () => {
+ const user = await unverifiedUser('expired');
+ const token = await seedToken(user.id, { expired: true });
+ const res = await app.inject({ method: 'POST', url: '/api/v1/auth/verify-email', payload: { token } });
+ expect(res.statusCode).toBe(400);
+ expect(await emailVerificationService.isVerified(user.id)).toBe(false);
+ });
+
+ it('rejects unknown and malformed tokens with 400', async () => {
+ expect((await app.inject({ method: 'POST', url: '/api/v1/auth/verify-email', payload: { token: 'nope' } })).statusCode).toBe(400);
+ expect((await app.inject({ method: 'POST', url: '/api/v1/auth/verify-email', payload: {} })).statusCode).toBe(400);
+ });
+
+ it("rejects another user's token when signed in, without consuming it", async () => {
+ const alice = await unverifiedUser('alice');
+ const bob = await unverifiedUser('bob');
+ const bobToken = await seedToken(bob.id);
+ const aliceSession = await createTestSession(alice.id);
+
+ const cross = await app.inject({
+ method: 'POST',
+ url: '/api/v1/auth/verify-email',
+ headers: { authorization: `Bearer ${aliceSession.token}` },
+ payload: { token: bobToken },
+ });
+ expect(cross.statusCode).toBe(400);
+ expect(await emailVerificationService.isVerified(alice.id)).toBe(false);
+ expect(await emailVerificationService.isVerified(bob.id)).toBe(false);
+
+ // Bob can still use his own link
+ const own = await app.inject({ method: 'POST', url: '/api/v1/auth/verify-email', payload: { token: bobToken } });
+ expect(own.statusCode).toBe(200);
+ expect(await emailVerificationService.isVerified(bob.id)).toBe(true);
+ expect(await emailVerificationService.isVerified(alice.id)).toBe(false);
+ });
+
+ it('does not block pre-existing (backfilled / default verified) users', async () => {
+ const user = await createTestUser({ email: uniqueEmail('legacy') });
+ const org = await createTestOrganization({ ownerId: user.id });
+ const session = await createTestSession(user.id);
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/v1/projects',
+ headers: { authorization: `Bearer ${session.token}` },
+ payload: { organizationId: org.id, name: 'Legacy' },
+ });
+ expect(res.statusCode).toBe(201);
+ });
+
+ it('resend always answers 202 and only mails unverified accounts', async () => {
+ const unknown = await app.inject({ method: 'POST', url: '/api/v1/auth/resend-verification', payload: { email: uniqueEmail('ghost') } });
+ expect(unknown.statusCode).toBe(202);
+
+ const verified = await createTestUser({ email: uniqueEmail('verified') });
+ const v = await app.inject({ method: 'POST', url: '/api/v1/auth/resend-verification', payload: { email: verified.email } });
+ expect(v.statusCode).toBe(202);
+ expect(v.json()).toEqual(unknown.json());
+ expect(await db.selectFrom('email_verification_tokens').selectAll().where('user_id', '=', verified.id).execute()).toHaveLength(0);
+
+ const pending = await unverifiedUser('pending');
+ const p = await app.inject({ method: 'POST', url: '/api/v1/auth/resend-verification', payload: { email: pending.email } });
+ expect(p.statusCode).toBe(202);
+ expect(p.json()).toEqual(unknown.json());
+ expect(await tokenFromMailhog(pending.email)).not.toBeNull();
+ // The verified account still got nothing once the background work settled
+ expect(await db.selectFrom('email_verification_tokens').selectAll().where('user_id', '=', verified.id).execute()).toHaveLength(0);
+ });
+
+ it('resend for the signed-in user replaces the previous token after the cooldown', async () => {
+ const user = await unverifiedUser('resend');
+ const session = await createTestSession(user.id);
+ const headers = { authorization: `Bearer ${session.token}` };
+
+ const tokensOf = () => db.selectFrom('email_verification_tokens').selectAll().where('user_id', '=', user.id).execute();
+
+ expect((await app.inject({ method: 'POST', url: '/api/v1/auth/resend-verification', headers })).statusCode).toBe(202);
+ const first = await eventually(tokensOf, (t) => t.length === 1);
+ expect(first).toHaveLength(1);
+
+ // Inside the cooldown: no new token
+ expect((await app.inject({ method: 'POST', url: '/api/v1/auth/resend-verification', headers })).statusCode).toBe(202);
+ await new Promise((r) => setTimeout(r, 500)); // let the background resend settle
+ const same = await tokensOf();
+ expect(same.map((t) => t.id)).toEqual(first.map((t) => t.id));
+
+ // Age the token past the cooldown: the old token is replaced
+ await db
+ .updateTable('email_verification_tokens')
+ .set({ created_at: new Date(Date.now() - RESEND_COOLDOWN_MS - 1000) })
+ .where('user_id', '=', user.id)
+ .execute();
+ expect((await app.inject({ method: 'POST', url: '/api/v1/auth/resend-verification', headers })).statusCode).toBe(202);
+ const after = await eventually(tokensOf, (t) => t.length === 1 && t[0].id !== first[0].id);
+ expect(after).toHaveLength(1);
+ expect(after[0].id).not.toBe(first[0].id);
+ });
+
+ it('changing the email address requires verifying again', async () => {
+ const user = await createTestUser({ email: uniqueEmail('change') });
+ const session = await createTestSession(user.id);
+ const headers = { authorization: `Bearer ${session.token}` };
+ const newEmail = uniqueEmail('changed');
+ const res = await app.inject({ method: 'PUT', url: '/api/v1/auth/me', headers, payload: { email: newEmail } });
+ expect(res.statusCode).toBe(200);
+ expect(await emailVerificationService.isVerified(user.id)).toBe(false);
+ expect(await tokenFromMailhog(newEmail)).not.toBeNull();
+ });
+});
+
+describe('OIDC email verification', () => {
+ let providerId: string;
+ const provider = () => ({ config: { id: providerId, type: 'oidc', name: 'OIDC', slug: 'verify-oidc', enabled: true, config: {} } });
+ const result = (email: string, emailVerified: boolean) => ({
+ success: true,
+ providerUserId: `sub-${crypto.randomBytes(4).toString('hex')}`,
+ email,
+ emailVerified,
+ name: 'Oidc User',
+ });
+ const findOrCreate = (p: unknown, r: unknown) =>
+ (authenticationService as any).findOrCreateUser(p, r) as Promise<{ user: { id: string } }>;
+
+ beforeEach(async () => {
+ await cleanDb();
+ await db.deleteFrom('auth_providers').where('slug', '=', 'verify-oidc').execute();
+ providerId = crypto.randomUUID();
+ await db
+ .insertInto('auth_providers')
+ .values({ id: providerId, type: 'oidc', name: 'OIDC', slug: 'verify-oidc', enabled: true, config: {} })
+ .execute();
+ });
+
+ afterEach(async () => {
+ cfg.REQUIRE_EMAIL_VERIFICATION = false;
+ await db.deleteFrom('user_identities').where('provider_id', '=', providerId).execute();
+ await db.deleteFrom('auth_providers').where('id', '=', providerId).execute();
+ });
+
+ it('gate on: new user is verified only when the IdP asserts email_verified: true', async () => {
+ cfg.REQUIRE_EMAIL_VERIFICATION = true;
+ const yes = await findOrCreate(provider(), result(uniqueEmail('oidc-yes'), true));
+ expect(await emailVerificationService.isVerified(yes.user.id)).toBe(true);
+ const no = await findOrCreate(provider(), result(uniqueEmail('oidc-no'), false));
+ expect(await emailVerificationService.isVerified(no.user.id)).toBe(false);
+ });
+
+ it('gate on: a new unverified OIDC user is sent the verification email', async () => {
+ cfg.REQUIRE_EMAIL_VERIFICATION = true;
+ const email = uniqueEmail('oidc-mail');
+ const no = await findOrCreate(provider(), result(email, false));
+ expect(await tokenFromMailhog(email)).not.toBeNull();
+ const tokens = await eventually(
+ () => db.selectFrom('email_verification_tokens').select('id').where('user_id', '=', no.user.id).execute(),
+ (rows) => rows.length === 1
+ );
+ expect(tokens).toHaveLength(1);
+
+ // An IdP-verified user gets no mail and no token.
+ const yes = await findOrCreate(provider(), result(uniqueEmail('oidc-nomail'), true));
+ await new Promise((r) => setTimeout(r, 300));
+ const none = await db.selectFrom('email_verification_tokens').select('id').where('user_id', '=', yes.user.id).execute();
+ expect(none).toHaveLength(0);
+ });
+
+ it('a later login with email_verified: true verifies an unverified OIDC user', async () => {
+ cfg.REQUIRE_EMAIL_VERIFICATION = true;
+ const r = result(uniqueEmail('oidc-later'), false);
+ const first = await findOrCreate(provider(), r);
+ expect(await emailVerificationService.isVerified(first.user.id)).toBe(false);
+ await findOrCreate(provider(), { ...r, emailVerified: true });
+ expect(await emailVerificationService.isVerified(first.user.id)).toBe(true);
+ });
+
+ it('an existing identity login only verifies when the asserted email matches the account email', async () => {
+ cfg.REQUIRE_EMAIL_VERIFICATION = true;
+ const original = uniqueEmail('oidc-orig');
+ const r = result(original, false);
+ const first = await findOrCreate(provider(), r);
+ expect(await emailVerificationService.isVerified(first.user.id)).toBe(false);
+
+ // The user changed the address locally; the IdP still vouches for the old one
+ const changed = uniqueEmail('oidc-changed');
+ await db.updateTable('users').set({ email: changed }).where('id', '=', first.user.id).execute();
+ await findOrCreate(provider(), { ...r, emailVerified: true });
+ expect(await emailVerificationService.isVerified(first.user.id)).toBe(false);
+
+ // Same address (case-insensitive): verified
+ await findOrCreate(provider(), { ...r, email: changed.toUpperCase(), emailVerified: true });
+ expect(await emailVerificationService.isVerified(first.user.id)).toBe(true);
+ });
+
+ it('gate off: OIDC users are verified regardless of the claim', async () => {
+ const no = await findOrCreate(provider(), result(uniqueEmail('oidc-off'), false));
+ expect(await emailVerificationService.isVerified(no.user.id)).toBe(true);
+ });
+
+ it('local logins never flip the verification state', async () => {
+ cfg.REQUIRE_EMAIL_VERIFICATION = true;
+ const user = await unverifiedUser('local-login');
+ await authenticationService.authenticateWithProvider('local', { email: user.email, password: 'password123' });
+ expect(await emailVerificationService.isVerified(user.id)).toBe(false);
+ });
+});
+
+describe('REGISTRATION_RATE_LIMIT_PER_HOUR', () => {
+ let app: FastifyInstance;
+
+ afterEach(async () => {
+ await app.close();
+ delete cfg.REGISTRATION_RATE_LIMIT_PER_HOUR;
+ });
+
+ beforeEach(async () => {
+ cfg.REQUIRE_EMAIL_VERIFICATION = false;
+ await cleanDb();
+ });
+
+ it('caps registrations per IP per hour, keyed by IP only', async () => {
+ cfg.REGISTRATION_RATE_LIMIT_PER_HOUR = 2;
+ app = await buildApp({ withRateLimit: true });
+ const ip = `10.9.${crypto.randomInt(0, 255)}.${crypto.randomInt(1, 255)}`;
+ expect((await register(app, uniqueEmail('rl1'), ip)).statusCode).toBe(201);
+ // A forged Authorization header must not rotate the key
+ expect((await register(app, uniqueEmail('rl2'), ip, { authorization: 'Bearer forged-1' })).statusCode).toBe(201);
+ const third = await register(app, uniqueEmail('rl3'), ip, { authorization: 'Bearer forged-2' });
+ expect(third.statusCode).toBe(429);
+ expect(third.headers['retry-after']).toBeDefined();
+ // Another address is unaffected
+ expect((await register(app, uniqueEmail('rl4'), '10.8.0.1')).statusCode).toBe(201);
+ });
+
+ it('unset = only the existing limits apply', async () => {
+ app = await buildApp({ withRateLimit: true });
+ const ip = '10.7.0.1';
+ for (let i = 0; i < 4; i++) {
+ expect((await register(app, uniqueEmail(`nolimit${i}`), ip)).statusCode).toBe(201);
+ }
+ });
+});
diff --git a/packages/backend/src/tests/modules/users/terms-acceptance.test.ts b/packages/backend/src/tests/modules/users/terms-acceptance.test.ts
new file mode 100644
index 000000000..9d63b5a18
--- /dev/null
+++ b/packages/backend/src/tests/modules/users/terms-acceptance.test.ts
@@ -0,0 +1,549 @@
+/**
+ * Terms and Privacy acceptance (hosting seam). Active only when
+ * LOGTIDE_TERMS_URL, LOGTIDE_PRIVACY_URL and LOGTIDE_TERMS_VERSION are all set.
+ * Every HTTP test runs against the REAL app builder from server.ts.
+ */
+import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
+import crypto from 'crypto';
+import type { FastifyInstance } from 'fastify';
+import { build } from '../../../server.js';
+import { db } from '../../../database/index.js';
+import { reservoirReady } from '../../../database/reservoir.js';
+import { config, configSchema } from '../../../config/index.js';
+import { settingsService } from '../../../modules/settings/index.js';
+import { SETTING_KEYS } from '../../../modules/settings/service.js';
+import { createTestContext, createTestUser } from '../../helpers/factories.js';
+import { createTestSession } from '../../helpers/auth.js';
+
+const TERMS_URL = 'https://logtide.example.com/terms';
+const PRIVACY_URL = 'https://logtide.example.com/privacy';
+const VERSION = '2026-10-19';
+
+const cfg = config as unknown as {
+ LOGTIDE_TERMS_URL?: string;
+ LOGTIDE_PRIVACY_URL?: string;
+ LOGTIDE_TERMS_VERSION?: string;
+};
+
+function activate(version = VERSION) {
+ cfg.LOGTIDE_TERMS_URL = TERMS_URL;
+ cfg.LOGTIDE_PRIVACY_URL = PRIVACY_URL;
+ cfg.LOGTIDE_TERMS_VERSION = version;
+}
+function deactivate() {
+ delete cfg.LOGTIDE_TERMS_URL;
+ delete cfg.LOGTIDE_PRIVACY_URL;
+ delete cfg.LOGTIDE_TERMS_VERSION;
+}
+
+const base = {
+ DATABASE_URL: 'postgres://u:p@localhost:5432/db',
+ API_KEY_SECRET: 'x'.repeat(32),
+};
+
+let ipCounter = 1;
+function nextIp(): string {
+ ipCounter += 1;
+ return `10.77.${Math.floor(ipCounter / 250)}.${(ipCounter % 250) + 1}`;
+}
+
+function uniqueEmail(tag: string): string {
+ return `${tag}-${crypto.randomBytes(4).toString('hex')}@example.com`;
+}
+
+async function register(app: FastifyInstance, extra: Record = {}) {
+ return app.inject({
+ method: 'POST',
+ url: '/api/v1/auth/register',
+ remoteAddress: nextIp(),
+ payload: { email: uniqueEmail('terms'), password: 'password123', name: 'Terms User', ...extra },
+ });
+}
+
+async function termsColumns(userId: string) {
+ return db
+ .selectFrom('users')
+ .select(['terms_accepted_at', 'terms_version'])
+ .where('id', '=', userId)
+ .executeTakeFirstOrThrow();
+}
+
+async function withSignup(fn: () => Promise): Promise {
+ const was = await settingsService.isSignupEnabled();
+ if (!was) await settingsService.set(SETTING_KEYS.AUTH_SIGNUP_ENABLED, true);
+ try {
+ return await fn();
+ } finally {
+ if (!was) await settingsService.set(SETTING_KEYS.AUTH_SIGNUP_ENABLED, false);
+ }
+}
+
+describe('terms acceptance env validation', () => {
+ const parse = (extra: Record) => configSchema.safeParse({ ...base, ...extra });
+ const all = { LOGTIDE_TERMS_URL: TERMS_URL, LOGTIDE_PRIVACY_URL: PRIVACY_URL, LOGTIDE_TERMS_VERSION: VERSION };
+
+ it('is inert when all three are unset or empty', () => {
+ const a = parse({});
+ expect(a.success).toBe(true);
+ if (a.success) {
+ expect(a.data.LOGTIDE_TERMS_URL).toBeUndefined();
+ expect(a.data.LOGTIDE_PRIVACY_URL).toBeUndefined();
+ expect(a.data.LOGTIDE_TERMS_VERSION).toBeUndefined();
+ }
+ expect(parse({ LOGTIDE_TERMS_URL: '', LOGTIDE_PRIVACY_URL: '', LOGTIDE_TERMS_VERSION: '' }).success).toBe(true);
+ });
+
+ it('accepts all three set', () => {
+ expect(parse(all).success).toBe(true);
+ });
+
+ it('rejects a partial set with a clear message', () => {
+ const partials: Array> = [
+ { LOGTIDE_TERMS_URL: TERMS_URL },
+ { LOGTIDE_PRIVACY_URL: PRIVACY_URL },
+ { LOGTIDE_TERMS_VERSION: VERSION },
+ { LOGTIDE_TERMS_URL: TERMS_URL, LOGTIDE_PRIVACY_URL: PRIVACY_URL },
+ { LOGTIDE_TERMS_URL: TERMS_URL, LOGTIDE_TERMS_VERSION: VERSION },
+ { LOGTIDE_PRIVACY_URL: PRIVACY_URL, LOGTIDE_TERMS_VERSION: VERSION },
+ ];
+ for (const p of partials) {
+ const r = parse(p);
+ expect(r.success, JSON.stringify(p)).toBe(false);
+ if (!r.success) expect(JSON.stringify(r.error.issues)).toContain('must be set together');
+ }
+ });
+
+ it('rejects non-https URLs except localhost http', () => {
+ expect(parse({ ...all, LOGTIDE_TERMS_URL: 'not a url' }).success).toBe(false);
+ expect(parse({ ...all, LOGTIDE_TERMS_URL: 'http://logtide.example.com/terms' }).success).toBe(false);
+ expect(parse({ ...all, LOGTIDE_PRIVACY_URL: 'http://logtide.example.com/privacy' }).success).toBe(false);
+ expect(parse({ ...all, LOGTIDE_PRIVACY_URL: 'ftp://logtide.example.com/privacy' }).success).toBe(false);
+ expect(parse({ ...all, LOGTIDE_TERMS_URL: 'http://localhost:4321/terms' }).success).toBe(true);
+ expect(parse({ ...all, LOGTIDE_PRIVACY_URL: 'http://127.0.0.1:4321/privacy' }).success).toBe(true);
+ });
+
+ it('rejects a version longer than 64 characters or made of spaces', () => {
+ expect(parse({ ...all, LOGTIDE_TERMS_VERSION: 'v'.repeat(65) }).success).toBe(false);
+ expect(parse({ ...all, LOGTIDE_TERMS_VERSION: 'v'.repeat(64) }).success).toBe(true);
+ expect(parse({ ...all, LOGTIDE_TERMS_VERSION: ' ' }).success).toBe(false);
+ });
+
+ it('a partial env fails the boot', async () => {
+ const before = process.env.LOGTIDE_TERMS_URL;
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ try {
+ vi.resetModules();
+ process.env.LOGTIDE_TERMS_URL = TERMS_URL;
+ await expect(import('../../../config/index.js')).rejects.toThrow(/Invalid configuration/);
+ const printed = errorSpy.mock.calls.map((c) => String(c[0])).join('\n');
+ expect(printed).toContain('LOGTIDE_TERMS_URL');
+ expect(printed).toContain('must be set together');
+ } finally {
+ if (before === undefined) delete process.env.LOGTIDE_TERMS_URL;
+ else process.env.LOGTIDE_TERMS_URL = before;
+ errorSpy.mockRestore();
+ vi.resetModules();
+ }
+ });
+});
+
+describe('terms acceptance inactive (real app)', () => {
+ let app: FastifyInstance;
+
+ beforeAll(async () => {
+ deactivate();
+ await reservoirReady;
+ app = await build({ logger: false });
+ await app.ready();
+ });
+
+ afterAll(async () => {
+ await app.close();
+ });
+
+ it('registration without acceptTerms works and leaves the columns NULL', async () => {
+ await withSignup(async () => {
+ const res = await register(app);
+ expect(res.statusCode).toBe(201);
+ const cols = await termsColumns(res.json().user.id);
+ expect(cols.terms_accepted_at).toBeNull();
+ expect(cols.terms_version).toBeNull();
+ });
+ });
+
+ it('acceptTerms is ignored (any value) and the columns stay NULL', async () => {
+ await withSignup(async () => {
+ for (const acceptTerms of [true, false, 'yes']) {
+ const res = await register(app, { acceptTerms });
+ expect(res.statusCode, String(acceptTerms)).toBe(201);
+ const cols = await termsColumns(res.json().user.id);
+ expect(cols.terms_accepted_at).toBeNull();
+ expect(cols.terms_version).toBeNull();
+ }
+ });
+ });
+
+ it('/auth/config and /me carry no terms keys, accept-terms does not exist', async () => {
+ const conf = (await app.inject({ method: 'GET', url: '/api/v1/auth/config' })).json();
+ expect(conf).not.toHaveProperty('termsUrl');
+ expect(conf).not.toHaveProperty('privacyUrl');
+ expect(conf).not.toHaveProperty('termsVersion');
+
+ const user = await createTestUser({ email: uniqueEmail('inactive') });
+ const session = await createTestSession(user.id);
+ const me = await app.inject({
+ method: 'GET',
+ url: '/api/v1/auth/me',
+ headers: { authorization: `Bearer ${session.token}` },
+ });
+ expect(me.statusCode).toBe(200);
+ expect(me.json().user).not.toHaveProperty('termsAcceptanceRequired');
+
+ const accept = await app.inject({
+ method: 'POST',
+ url: '/api/v1/auth/accept-terms',
+ headers: { authorization: `Bearer ${session.token}` },
+ payload: { version: VERSION },
+ });
+ expect(accept.statusCode).toBe(404);
+ });
+
+ it('a user with NULL terms can create organizations, projects and API keys', async () => {
+ const ctx = await createTestContext();
+ const session = await createTestSession(ctx.user.id);
+ const headers = { authorization: `Bearer ${session.token}` };
+ const org = await app.inject({
+ method: 'POST',
+ url: '/api/v1/organizations',
+ headers,
+ payload: { name: `Inactive Org ${Date.now()}` },
+ });
+ expect(org.statusCode).toBe(201);
+ const project = await app.inject({
+ method: 'POST',
+ url: '/api/v1/projects',
+ headers,
+ payload: { organizationId: ctx.organization.id, name: 'Inactive Project' },
+ });
+ expect(project.statusCode).toBe(201);
+ const key = await app.inject({
+ method: 'POST',
+ url: `/api/v1/projects/${ctx.project.id}/api-keys`,
+ headers,
+ payload: { name: 'Inactive Key' },
+ });
+ expect(key.statusCode).toBe(201);
+ });
+});
+
+describe('terms acceptance active (real app)', () => {
+ let app: FastifyInstance;
+
+ beforeAll(async () => {
+ activate();
+ await reservoirReady;
+ app = await build({ logger: false });
+ await app.ready();
+ });
+
+ afterAll(async () => {
+ await app.close();
+ deactivate();
+ });
+
+ beforeEach(() => {
+ activate();
+ });
+
+ afterEach(() => {
+ activate();
+ });
+
+ async function pendingUser() {
+ const ctx = await createTestContext();
+ const session = await createTestSession(ctx.user.id);
+ return { ctx, headers: { authorization: `Bearer ${session.token}` } };
+ }
+
+ async function me(headers: Record) {
+ const res = await app.inject({ method: 'GET', url: '/api/v1/auth/me', headers });
+ expect(res.statusCode).toBe(200);
+ return res.json().user;
+ }
+
+ it('/auth/config exposes the terms keys', async () => {
+ const res = await app.inject({ method: 'GET', url: '/api/v1/auth/config' });
+ expect(res.statusCode).toBe(200);
+ expect(res.json()).toMatchObject({ termsUrl: TERMS_URL, privacyUrl: PRIVACY_URL, termsVersion: VERSION });
+ });
+
+ it('registration without acceptTerms (or with false) is 400 terms_not_accepted', async () => {
+ await withSignup(async () => {
+ const email = uniqueEmail('noaccept');
+ const missing = await app.inject({
+ method: 'POST',
+ url: '/api/v1/auth/register',
+ remoteAddress: nextIp(),
+ payload: { email, password: 'password123', name: 'No Accept' },
+ });
+ expect(missing.statusCode).toBe(400);
+ expect(missing.json()).toEqual({ error: 'terms_not_accepted' });
+
+ const falsy = await register(app, { email, acceptTerms: false });
+ expect(falsy.statusCode).toBe(400);
+ expect(falsy.json()).toEqual({ error: 'terms_not_accepted' });
+
+ const stringy = await register(app, { email, acceptTerms: 'true' });
+ expect(stringy.statusCode).toBe(400);
+
+ // No user row was created.
+ const row = await db.selectFrom('users').select('id').where('email', '=', email).executeTakeFirst();
+ expect(row).toBeUndefined();
+ });
+ });
+
+ it('registration with acceptTerms: true is 201 and stores the version and time', async () => {
+ await withSignup(async () => {
+ const before = Date.now();
+ const res = await register(app, { acceptTerms: true });
+ expect(res.statusCode).toBe(201);
+ const body = res.json();
+ const cols = await termsColumns(body.user.id);
+ expect(cols.terms_version).toBe(VERSION);
+ expect(cols.terms_accepted_at).not.toBeNull();
+ expect(new Date(cols.terms_accepted_at as Date).getTime()).toBeGreaterThan(before - 60_000);
+
+ // Freshly accepted: not pending, can create an organization.
+ const headers = { authorization: `Bearer ${body.session.token}` };
+ expect(await me(headers)).not.toHaveProperty('termsAcceptanceRequired');
+ const org = await app.inject({
+ method: 'POST',
+ url: '/api/v1/organizations',
+ headers,
+ payload: { name: `Accepted Org ${Date.now()}` },
+ });
+ expect(org.statusCode).toBe(201);
+ });
+ });
+
+ it('an existing user with NULL terms is pending and blocked from creating resources', async () => {
+ const { ctx, headers } = await pendingUser();
+ expect((await me(headers)).termsAcceptanceRequired).toBe(true);
+
+ const org = await app.inject({
+ method: 'POST',
+ url: '/api/v1/organizations',
+ headers,
+ payload: { name: `Pending Org ${Date.now()}` },
+ });
+ expect(org.statusCode).toBe(403);
+ expect(org.json()).toEqual({ error: 'terms_not_accepted' });
+
+ const project = await app.inject({
+ method: 'POST',
+ url: '/api/v1/projects',
+ headers,
+ payload: { organizationId: ctx.organization.id, name: 'Pending Project' },
+ });
+ expect(project.statusCode).toBe(403);
+ expect(project.json()).toEqual({ error: 'terms_not_accepted' });
+
+ const key = await app.inject({
+ method: 'POST',
+ url: `/api/v1/projects/${ctx.project.id}/api-keys`,
+ headers,
+ payload: { name: 'Pending Key' },
+ });
+ expect(key.statusCode).toBe(403);
+ expect(key.json()).toEqual({ error: 'terms_not_accepted' });
+
+ // Nothing was created.
+ const projects = await db
+ .selectFrom('projects')
+ .select('id')
+ .where('organization_id', '=', ctx.organization.id)
+ .execute();
+ expect(projects).toHaveLength(1);
+ });
+
+ it('ingestion with an existing API key is never blocked while pending', async () => {
+ const { ctx, headers } = await pendingUser();
+ expect((await me(headers)).termsAcceptanceRequired).toBe(true);
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/v1/ingest',
+ headers: { 'x-api-key': ctx.apiKey.plainKey, 'content-type': 'application/json' },
+ payload: { logs: [{ time: new Date().toISOString(), service: 'api', level: 'info', message: 'still flowing' }] },
+ });
+ expect(res.statusCode).toBeGreaterThanOrEqual(200);
+ expect(res.statusCode).toBeLessThan(300);
+ });
+
+ it('accept-terms requires a session', async () => {
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/v1/auth/accept-terms',
+ payload: { version: VERSION },
+ });
+ expect(res.statusCode).toBe(401);
+ });
+
+ it('accept-terms with a wrong or missing version is 409 and changes nothing', async () => {
+ const { ctx, headers } = await pendingUser();
+ for (const payload of [{ version: '2020-01-01' }, {}]) {
+ const res = await app.inject({ method: 'POST', url: '/api/v1/auth/accept-terms', headers, payload });
+ expect(res.statusCode).toBe(409);
+ expect(res.json()).toEqual({ error: 'terms_version_mismatch' });
+ }
+ const cols = await termsColumns(ctx.user.id);
+ expect(cols.terms_version).toBeNull();
+ expect(cols.terms_accepted_at).toBeNull();
+ expect((await me(headers)).termsAcceptanceRequired).toBe(true);
+ });
+
+ it('accept-terms with the current version clears the pending state', async () => {
+ const { ctx, headers } = await pendingUser();
+ const res = await app.inject({
+ method: 'POST',
+ url: '/api/v1/auth/accept-terms',
+ headers,
+ payload: { version: VERSION },
+ });
+ expect(res.statusCode).toBe(200);
+ expect(res.json()).toMatchObject({ accepted: true, termsVersion: VERSION });
+
+ const cols = await termsColumns(ctx.user.id);
+ expect(cols.terms_version).toBe(VERSION);
+ expect(cols.terms_accepted_at).not.toBeNull();
+ expect(await me(headers)).not.toHaveProperty('termsAcceptanceRequired');
+
+ const project = await app.inject({
+ method: 'POST',
+ url: '/api/v1/projects',
+ headers,
+ payload: { organizationId: ctx.organization.id, name: 'After Accept' },
+ });
+ expect(project.statusCode).toBe(201);
+ });
+
+ it('a version bump makes accepted users pending again', async () => {
+ const { headers } = await pendingUser();
+ const ok = await app.inject({
+ method: 'POST',
+ url: '/api/v1/auth/accept-terms',
+ headers,
+ payload: { version: VERSION },
+ });
+ expect(ok.statusCode).toBe(200);
+ expect(await me(headers)).not.toHaveProperty('termsAcceptanceRequired');
+
+ activate('2026-12-01');
+ expect((await me(headers)).termsAcceptanceRequired).toBe(true);
+ const conf = (await app.inject({ method: 'GET', url: '/api/v1/auth/config' })).json();
+ expect(conf.termsVersion).toBe('2026-12-01');
+
+ const org = await app.inject({
+ method: 'POST',
+ url: '/api/v1/organizations',
+ headers,
+ payload: { name: `Bumped Org ${Date.now()}` },
+ });
+ expect(org.statusCode).toBe(403);
+ expect(org.json()).toEqual({ error: 'terms_not_accepted' });
+
+ const stale = await app.inject({
+ method: 'POST',
+ url: '/api/v1/auth/accept-terms',
+ headers,
+ payload: { version: VERSION },
+ });
+ expect(stale.statusCode).toBe(409);
+
+ const fresh = await app.inject({
+ method: 'POST',
+ url: '/api/v1/auth/accept-terms',
+ headers,
+ payload: { version: '2026-12-01' },
+ });
+ expect(fresh.statusCode).toBe(200);
+ expect(await me(headers)).not.toHaveProperty('termsAcceptanceRequired');
+ });
+});
+
+describe('terms acceptance and auth-free mode', () => {
+ let app: FastifyInstance | null = null;
+
+ afterEach(async () => {
+ if (app) await app.close();
+ app = null;
+ await settingsService.set(SETTING_KEYS.AUTH_MODE, 'standard');
+ deactivate();
+ });
+
+ it('the real app refuses to build when terms are active and the stored auth mode is none', async () => {
+ await settingsService.set(SETTING_KEYS.AUTH_MODE, 'none');
+ activate();
+ await expect(build({ logger: false })).rejects.toThrow(/auth-free mode/);
+ });
+
+ it('auth-free mode without terms still builds', async () => {
+ await settingsService.set(SETTING_KEYS.AUTH_MODE, 'none');
+ deactivate();
+ app = await build({ logger: false });
+ await app.ready();
+ });
+
+ it('admin settings reject switching auth.mode to none while terms are active', async () => {
+ activate();
+ app = await build({ logger: false });
+ await app.ready();
+ const admin = await createTestUser({ email: uniqueEmail('admin') });
+ await db.updateTable('users').set({ is_admin: true }).where('id', '=', admin.id).execute();
+ const session = await createTestSession(admin.id);
+ const headers = { authorization: `Bearer ${session.token}` };
+
+ const put = await app.inject({
+ method: 'PUT',
+ url: '/api/v1/admin/settings/auth.mode',
+ headers,
+ payload: { value: 'none' },
+ });
+ expect(put.statusCode).toBe(400);
+ expect(put.json().error).toBe('terms_incompatible_with_auth_free_mode');
+
+ const patch = await app.inject({
+ method: 'PATCH',
+ url: '/api/v1/admin/settings',
+ headers,
+ payload: { 'auth.mode': 'none' },
+ });
+ expect(patch.statusCode).toBe(400);
+ expect(patch.json().error).toBe('terms_incompatible_with_auth_free_mode');
+ expect(await settingsService.getAuthMode()).toBe('standard');
+
+ // Other values and other keys are unaffected.
+ const ok = await app.inject({
+ method: 'PATCH',
+ url: '/api/v1/admin/settings',
+ headers,
+ payload: { 'auth.mode': 'standard', 'auth.signup_enabled': true },
+ });
+ expect(ok.statusCode).toBe(200);
+ });
+
+ it('admin settings allow auth.mode none when terms are inactive', async () => {
+ deactivate();
+ app = await build({ logger: false });
+ await app.ready();
+ const admin = await createTestUser({ email: uniqueEmail('admin') });
+ await db.updateTable('users').set({ is_admin: true }).where('id', '=', admin.id).execute();
+ const session = await createTestSession(admin.id);
+ const put = await app.inject({
+ method: 'PUT',
+ url: '/api/v1/admin/settings/auth.mode',
+ headers: { authorization: `Bearer ${session.token}` },
+ payload: { value: 'none' },
+ });
+ expect(put.statusCode).toBe(200);
+ });
+});
diff --git a/packages/backend/src/tests/server/rate-limit-key.test.ts b/packages/backend/src/tests/server/rate-limit-key.test.ts
new file mode 100644
index 000000000..305ee0edc
--- /dev/null
+++ b/packages/backend/src/tests/server/rate-limit-key.test.ts
@@ -0,0 +1,34 @@
+import { describe, it, expect } from 'vitest';
+import { createHash } from 'node:crypto';
+import { rateLimitKeyGenerator } from '../../server.js';
+
+const sha = (v: string) => createHash('sha256').update(v).digest('hex').slice(0, 32);
+
+describe('rate limit key generator', () => {
+ const secret = 'lp_' + 'k'.repeat(40);
+ const bearer = 's'.repeat(64);
+
+ it('never embeds the raw API key', () => {
+ const key = rateLimitKeyGenerator({ headers: { 'x-api-key': secret }, ip: '1.2.3.4' });
+ expect(key).not.toContain(secret);
+ expect(key).toBe(`key:${sha(secret)}`);
+ });
+
+ it('never embeds the raw bearer token', () => {
+ const key = rateLimitKeyGenerator({ headers: { authorization: `Bearer ${bearer}` }, ip: '1.2.3.4' });
+ expect(key).not.toContain(bearer);
+ expect(key).toBe(`key:${sha(bearer)}`);
+ });
+
+ it('is stable per credential and distinct across credentials', () => {
+ const a = rateLimitKeyGenerator({ headers: { 'x-api-key': secret }, ip: '1.1.1.1' });
+ const b = rateLimitKeyGenerator({ headers: { 'x-api-key': secret }, ip: '2.2.2.2' });
+ const c = rateLimitKeyGenerator({ headers: { 'x-api-key': secret + 'x' }, ip: '1.1.1.1' });
+ expect(a).toBe(b);
+ expect(a).not.toBe(c);
+ });
+
+ it('falls back to the client ip without credentials', () => {
+ expect(rateLimitKeyGenerator({ headers: {}, ip: '9.9.9.9' })).toBe('9.9.9.9');
+ });
+});
diff --git a/packages/backend/src/utils/internal-logger.ts b/packages/backend/src/utils/internal-logger.ts
index 1c8bf356b..b60194758 100644
--- a/packages/backend/src/utils/internal-logger.ts
+++ b/packages/backend/src/utils/internal-logger.ts
@@ -58,7 +58,7 @@ export async function initializeInternalLogging(): Promise {
dsn,
service: process.env.SERVICE_NAME || 'logtide-backend',
environment: process.env.NODE_ENV || 'development',
- release: process.env.npm_package_version || '1.3.2',
+ release: process.env.npm_package_version || '1.4.0',
batchSize: 5, // Smaller batch for internal logs to see them faster
flushInterval: 5000,
maxBufferSize: 1000,
diff --git a/packages/frontend/package.json b/packages/frontend/package.json
index 790722d2f..48a30194a 100644
--- a/packages/frontend/package.json
+++ b/packages/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "@logtide/frontend",
- "version": "1.3.2",
+ "version": "1.4.0",
"private": true,
"description": "LogTide Frontend Dashboard",
"type": "module",
diff --git a/packages/frontend/src/hooks.client.ts b/packages/frontend/src/hooks.client.ts
index 11f93b985..ac65a8c30 100644
--- a/packages/frontend/src/hooks.client.ts
+++ b/packages/frontend/src/hooks.client.ts
@@ -9,7 +9,7 @@ if (dsn) {
dsn,
service: 'logtide-frontend-client',
environment: env.PUBLIC_NODE_ENV || 'production',
- release: env.PUBLIC_APP_VERSION || '1.3.2',
+ release: env.PUBLIC_APP_VERSION || '1.4.0',
debug: env.PUBLIC_NODE_ENV === 'development',
browser: {
// Core Web Vitals (LCP, INP, CLS, TTFB)
diff --git a/packages/frontend/src/hooks.server.ts b/packages/frontend/src/hooks.server.ts
index b509a787b..39834ef04 100644
--- a/packages/frontend/src/hooks.server.ts
+++ b/packages/frontend/src/hooks.server.ts
@@ -82,7 +82,7 @@ export const handle = dsn
dsn,
service: 'logtide-frontend',
environment: privateEnv?.NODE_ENV || 'production',
- release: process.env.npm_package_version || '1.3.2', }) as unknown as Handle,
+ release: process.env.npm_package_version || '1.4.0', }) as unknown as Handle,
requestLogHandle,
configHandle
)
diff --git a/packages/frontend/src/lib/api/api-keys.ts b/packages/frontend/src/lib/api/api-keys.ts
index 028e37903..32cbe9d86 100644
--- a/packages/frontend/src/lib/api/api-keys.ts
+++ b/packages/frontend/src/lib/api/api-keys.ts
@@ -1,4 +1,5 @@
import { getApiBaseUrl } from '$lib/config';
+import { apiErrorMessage } from '$lib/utils/email-verification';
import { getAuthToken } from '$lib/utils/auth';
export type ApiKeyType = 'write' | 'full';
@@ -72,7 +73,7 @@ export class ApiKeysAPI {
if (!response.ok) {
const error = await response.json();
- throw new Error(error.error || 'Failed to create API key');
+ throw new Error(apiErrorMessage(error.error, 'Failed to create API key'));
}
return response.json();
diff --git a/packages/frontend/src/lib/api/auth.ts b/packages/frontend/src/lib/api/auth.ts
index 48c353871..e22dd828e 100644
--- a/packages/frontend/src/lib/api/auth.ts
+++ b/packages/frontend/src/lib/api/auth.ts
@@ -4,6 +4,8 @@ export interface RegisterInput {
email: string;
password: string;
name: string;
+ /** Sent only when the backend advertises terms (auth config `termsUrl`). */
+ acceptTerms?: boolean;
}
export interface LoginInput {
@@ -57,6 +59,14 @@ export interface UserIdentity {
export interface AuthConfig {
authMode: 'standard' | 'none';
signupEnabled: boolean;
+ /** Present (true) only when the host configured the external upgrade handoff. */
+ upgradeEnabled?: boolean;
+ /** Present (true) only when REQUIRE_EMAIL_VERIFICATION is on. */
+ emailVerificationRequired?: boolean;
+ /** Present only when the host requires Terms and Privacy acceptance. */
+ termsUrl?: string;
+ privacyUrl?: string;
+ termsVersion?: string;
}
export class AuthAPI {
@@ -248,6 +258,62 @@ export class AuthAPI {
}
}
+ /** Consume an email verification token. Sends the session when signed in. */
+ async verifyEmail(token: string, sessionToken?: string | null): Promise {
+ const response = await fetch(`${getApiBaseUrl()}/auth/verify-email`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(sessionToken ? { Authorization: `Bearer ${sessionToken}` } : {}),
+ },
+ body: JSON.stringify({ token }),
+ });
+
+ if (!response.ok) {
+ throw new Error('This verification link is invalid, expired or was already used.');
+ }
+ }
+
+ /** Ask for a new verification email for the signed-in user. Always succeeds unless the request fails. */
+ async resendVerification(sessionToken: string | null): Promise {
+ const response = await fetch(`${getApiBaseUrl()}/auth/resend-verification`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(sessionToken ? { Authorization: `Bearer ${sessionToken}` } : {}),
+ },
+ body: JSON.stringify({}),
+ });
+
+ if (!response.ok) {
+ throw new Error(
+ response.status === 429
+ ? 'Too many requests. Please wait a while before asking for another email.'
+ : 'Could not send the verification email'
+ );
+ }
+ }
+
+ /** Accept the current Terms and Privacy Policy version for the signed-in user. */
+ async acceptTerms(sessionToken: string | null, version: string): Promise {
+ const response = await fetch(`${getApiBaseUrl()}/auth/accept-terms`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(sessionToken ? { Authorization: `Bearer ${sessionToken}` } : {}),
+ },
+ body: JSON.stringify({ version }),
+ });
+
+ if (!response.ok) {
+ throw new Error(
+ response.status === 409
+ ? 'The terms were updated in the meantime. Reload the page and review them again.'
+ : 'Could not record your acceptance. Please try again.'
+ );
+ }
+ }
+
async getAuthConfig(): Promise {
const response = await fetch(`${getApiBaseUrl()}/auth/config`);
diff --git a/packages/frontend/src/lib/api/fetch-interceptor.test.ts b/packages/frontend/src/lib/api/fetch-interceptor.test.ts
new file mode 100644
index 000000000..fa9189717
--- /dev/null
+++ b/packages/frontend/src/lib/api/fetch-interceptor.test.ts
@@ -0,0 +1,43 @@
+import { describe, it, expect, vi, beforeAll } from 'vitest';
+import { get } from 'svelte/store';
+import { termsRecheck } from '$lib/stores/terms-recheck';
+
+const responses: Response[] = [];
+const originalFetch = vi.fn(async () => responses.shift() as Response);
+
+function json(status: number, body: unknown): Response {
+ return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
+}
+
+async function settle() {
+ for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0));
+}
+
+describe('fetch interceptor terms re-check', () => {
+ beforeAll(async () => {
+ window.fetch = originalFetch as unknown as typeof window.fetch;
+ const { installAuthFetchInterceptor } = await import('./fetch-interceptor');
+ installAuthFetchInterceptor();
+ });
+
+ it('asks for a terms re-check on a 403 terms_not_accepted and leaves the body readable', async () => {
+ const before = get(termsRecheck);
+ responses.push(json(403, { error: 'terms_not_accepted' }));
+ const res = await window.fetch('http://localhost/api/v1/projects', { method: 'POST' });
+ expect(await res.json()).toEqual({ error: 'terms_not_accepted' });
+ await settle();
+ expect(get(termsRecheck)).toBe(before + 1);
+ });
+
+ it('ignores other 403s and successful responses', async () => {
+ const before = get(termsRecheck);
+ responses.push(json(403, { error: 'email_not_verified' }));
+ await window.fetch('http://localhost/api/v1/projects', { method: 'POST' });
+ responses.push(json(200, { ok: true }));
+ await window.fetch('http://localhost/api/v1/projects');
+ responses.push(new Response('not json', { status: 403 }));
+ await window.fetch('http://localhost/api/v1/projects', { method: 'POST' });
+ await settle();
+ expect(get(termsRecheck)).toBe(before);
+ });
+});
diff --git a/packages/frontend/src/lib/api/fetch-interceptor.ts b/packages/frontend/src/lib/api/fetch-interceptor.ts
index 6dc0e0ed4..1b7bf3a76 100644
--- a/packages/frontend/src/lib/api/fetch-interceptor.ts
+++ b/packages/frontend/src/lib/api/fetch-interceptor.ts
@@ -1,6 +1,7 @@
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth';
import { getAuthToken } from '$lib/utils/auth';
+import { requestTermsRecheck } from '$lib/stores/terms-recheck';
/**
* Global 401 handler.
@@ -92,6 +93,18 @@ export function installAuthFetchInterceptor(): void {
) {
handleUnauthorized();
}
+ // Terms seam: a 403 terms_not_accepted means the terms changed (or were
+ // never accepted) while the app was open; ask the modal to re-check.
+ // Read from a clone, off the caller's path, so the body stays readable.
+ if (response.status === 403 && isApiRequest(urlOf(input))) {
+ response
+ .clone()
+ .json()
+ .then((body: { error?: unknown } | null) => {
+ if (body?.error === 'terms_not_accepted') requestTermsRecheck();
+ })
+ .catch(() => {});
+ }
} catch {
// Never let interceptor logic break a fetch call.
}
diff --git a/packages/frontend/src/lib/api/organizations.ts b/packages/frontend/src/lib/api/organizations.ts
index 4ceff725a..0fb13fb08 100644
--- a/packages/frontend/src/lib/api/organizations.ts
+++ b/packages/frontend/src/lib/api/organizations.ts
@@ -1,6 +1,7 @@
import type { Organization, OrganizationWithRole, OrganizationMemberWithUser, OrgRole } from '@logtide/shared';
import { getApiBaseUrl } from '$lib/config';
import { getAuthToken } from '$lib/utils/auth';
+import { apiErrorMessage } from '$lib/utils/email-verification';
export type { OrganizationMemberWithUser } from '@logtide/shared';
@@ -35,7 +36,7 @@ export class OrganizationsAPI {
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Request failed' }));
- throw new Error(error.error || `HTTP ${response.status}`);
+ throw new Error(apiErrorMessage(error.error, `HTTP ${response.status}`));
}
if (response.status === 204) {
@@ -108,6 +109,13 @@ export class OrganizationsAPI {
});
}
+ async createUpgradeHandoff(id: string, intent: 'upgrade' | 'manage'): Promise<{ url: string }> {
+ return this.request<{ url: string }>(`/organizations/${id}/upgrade-handoff`, {
+ method: 'POST',
+ body: JSON.stringify({ intent }),
+ });
+ }
+
async deleteOrganization(id: string): Promise {
await this.request(`/organizations/${id}`, {
method: 'DELETE',
diff --git a/packages/frontend/src/lib/api/projects.ts b/packages/frontend/src/lib/api/projects.ts
index 16a422af6..4b0b6da7e 100644
--- a/packages/frontend/src/lib/api/projects.ts
+++ b/packages/frontend/src/lib/api/projects.ts
@@ -1,4 +1,5 @@
import type { Project } from '@logtide/shared';
+import { apiErrorMessage } from '$lib/utils/email-verification';
import { getApiBaseUrl } from '$lib/config';
import { getAuthToken } from '$lib/utils/auth';
@@ -124,7 +125,7 @@ export class ProjectsAPI {
if (!response.ok) {
const error = await response.json();
- throw new Error(error.error || 'Failed to create project');
+ throw new Error(apiErrorMessage(error.error, 'Failed to create project'));
}
return response.json();
diff --git a/packages/frontend/src/lib/api/usage.ts b/packages/frontend/src/lib/api/usage.ts
index 9611b9ee4..96e182cc3 100644
--- a/packages/frontend/src/lib/api/usage.ts
+++ b/packages/frontend/src/lib/api/usage.ts
@@ -129,6 +129,8 @@ export interface CapabilityUsage {
/** Configured cap. null = unlimited (OSS default). */
limit: number | null;
description: string;
+ /** Quota kind only. */
+ state?: 'ok' | 'grace' | 'blocked' | 'unlimited';
}
export interface CapabilityUsageResponse {
diff --git a/packages/frontend/src/lib/components/AppLayout.svelte b/packages/frontend/src/lib/components/AppLayout.svelte
index 8fbf43b02..47e591526 100644
--- a/packages/frontend/src/lib/components/AppLayout.svelte
+++ b/packages/frontend/src/lib/components/AppLayout.svelte
@@ -50,6 +50,8 @@
import Activity from "@lucide/svelte/icons/activity";
import { formatTimeAgo } from "$lib/utils/datetime";
import Footer from "$lib/components/Footer.svelte";
+ import EmailVerificationBanner from "$lib/components/EmailVerificationBanner.svelte";
+ import TermsAcceptanceModal from "$lib/components/TermsAcceptanceModal.svelte";
import OnboardingChecklist from "$lib/components/OnboardingChecklist.svelte";
import FeatureBadge from "$lib/components/FeatureBadge.svelte";
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
@@ -826,6 +828,8 @@
+
+
{@render children?.()}
diff --git a/packages/frontend/src/lib/components/EmailVerificationBanner.svelte b/packages/frontend/src/lib/components/EmailVerificationBanner.svelte
new file mode 100644
index 000000000..b5eb42b82
--- /dev/null
+++ b/packages/frontend/src/lib/components/EmailVerificationBanner.svelte
@@ -0,0 +1,60 @@
+
+
+{#if visible}
+
+
+ Verify your email
+
+ Confirm your address to create projects and API keys. We sent you a link.
+
+ {#if message}
+ {message}
+ {/if}
+
+
+ To keep using LogTide, please review and accept the
+ Terms of Service
+ and the
+ Privacy Policy.
+ Your existing data and ingestion keep working in the meantime.
+
+ {#if error}
+
{error}
+ {/if}
+
+
+
+
+
+
+{/if}
diff --git a/packages/frontend/src/lib/components/TermsAcceptanceModal.test.ts b/packages/frontend/src/lib/components/TermsAcceptanceModal.test.ts
new file mode 100644
index 000000000..3e7958a1a
--- /dev/null
+++ b/packages/frontend/src/lib/components/TermsAcceptanceModal.test.ts
@@ -0,0 +1,131 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, waitFor, fireEvent } from '@testing-library/svelte';
+
+const { getAuthConfig, getMe, acceptTerms, logout, goto } = vi.hoisted(() => ({
+ getAuthConfig: vi.fn(),
+ getMe: vi.fn(),
+ acceptTerms: vi.fn(),
+ logout: vi.fn(),
+ goto: vi.fn(),
+}));
+
+vi.mock('$lib/api/auth', () => ({
+ authAPI: { getAuthConfig, getMe, acceptTerms, logout },
+}));
+vi.mock('$app/navigation', () => ({ goto }));
+
+import TermsAcceptanceModal from './TermsAcceptanceModal.svelte';
+import { authStore } from '$lib/stores/auth';
+import { requestTermsRecheck } from '$lib/stores/terms-recheck';
+import { get } from 'svelte/store';
+
+const TERMS = {
+ authMode: 'standard',
+ signupEnabled: true,
+ termsUrl: 'https://example.com/terms',
+ privacyUrl: 'https://example.com/privacy',
+ termsVersion: '2026-10-19',
+};
+
+describe('TermsAcceptanceModal', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ for (const fn of [getAuthConfig, getMe, acceptTerms, logout, goto]) fn.mockReset();
+ authStore.setAuth({ id: 'u1', email: 'a@b.c', name: 'A' }, 'session-1');
+ });
+
+ it('blocks a pending user with both links opening in a new tab', async () => {
+ getAuthConfig.mockResolvedValue(TERMS);
+ getMe.mockResolvedValue({ user: { id: 'u1', termsAcceptanceRequired: true } });
+ render(TermsAcceptanceModal);
+ const dialog = await screen.findByRole('dialog');
+ expect(dialog.getAttribute('aria-modal')).toBe('true');
+ const terms = screen.getByRole('link', { name: 'Terms of Service' });
+ const privacy = screen.getByRole('link', { name: 'Privacy Policy' });
+ expect(terms.getAttribute('href')).toBe(TERMS.termsUrl);
+ expect(privacy.getAttribute('href')).toBe(TERMS.privacyUrl);
+ expect(terms.getAttribute('target')).toBe('_blank');
+ expect(privacy.getAttribute('target')).toBe('_blank');
+ expect(terms.getAttribute('rel')).toContain('noopener');
+ expect(getMe).toHaveBeenCalledWith('session-1');
+ });
+
+ it('renders nothing and skips /me when terms are not configured', async () => {
+ getAuthConfig.mockResolvedValue({ authMode: 'standard', signupEnabled: true });
+ const { container } = render(TermsAcceptanceModal);
+ await waitFor(() => expect(getAuthConfig).toHaveBeenCalled());
+ await Promise.resolve();
+ expect(getMe).not.toHaveBeenCalled();
+ expect(container.textContent?.trim()).toBe('');
+ });
+
+ it('renders nothing when the user already accepted', async () => {
+ getAuthConfig.mockResolvedValue(TERMS);
+ getMe.mockResolvedValue({ user: { id: 'u1' } });
+ const { container } = render(TermsAcceptanceModal);
+ await waitFor(() => expect(getMe).toHaveBeenCalled());
+ await Promise.resolve();
+ expect(container.textContent?.trim()).toBe('');
+ });
+
+ it('accept sends the current version, reloads the user and closes', async () => {
+ getAuthConfig.mockResolvedValue(TERMS);
+ getMe
+ .mockResolvedValueOnce({ user: { id: 'u1', termsAcceptanceRequired: true } })
+ .mockResolvedValueOnce({ user: { id: 'u1' } });
+ acceptTerms.mockResolvedValue(undefined);
+ render(TermsAcceptanceModal);
+ await fireEvent.click(await screen.findByRole('button', { name: 'Accept' }));
+ expect(acceptTerms).toHaveBeenCalledWith('session-1', '2026-10-19');
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
+ expect(getMe).toHaveBeenCalledTimes(2);
+ });
+
+ it('keeps the modal open and shows the error when accept fails', async () => {
+ getAuthConfig.mockResolvedValue(TERMS);
+ getMe.mockResolvedValue({ user: { id: 'u1', termsAcceptanceRequired: true } });
+ acceptTerms.mockRejectedValue(new Error('The terms were updated in the meantime.'));
+ render(TermsAcceptanceModal);
+ await fireEvent.click(await screen.findByRole('button', { name: 'Accept' }));
+ await waitFor(() => expect(screen.getByText('The terms were updated in the meantime.')).toBeTruthy());
+ expect(screen.getByRole('dialog')).toBeTruthy();
+ });
+
+ it('log out revokes the session, clears auth and goes to /login', async () => {
+ getAuthConfig.mockResolvedValue(TERMS);
+ getMe.mockResolvedValue({ user: { id: 'u1', termsAcceptanceRequired: true } });
+ logout.mockResolvedValue(undefined);
+ render(TermsAcceptanceModal);
+ await fireEvent.click(await screen.findByRole('button', { name: 'Log out' }));
+ await waitFor(() => expect(goto).toHaveBeenCalledWith('/login'));
+ expect(logout).toHaveBeenCalledWith('session-1');
+ expect(get(authStore).token).toBeNull();
+ });
+});
+
+describe('TermsAcceptanceModal re-check', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ for (const fn of [getAuthConfig, getMe, acceptTerms, logout, goto]) fn.mockReset();
+ authStore.setAuth({ id: 'u1', email: 'a@b.c', name: 'A' }, 'session-1');
+ });
+
+ it('appears without a reload when a request reports terms_not_accepted', async () => {
+ getAuthConfig.mockResolvedValue(TERMS);
+ getMe.mockResolvedValueOnce({ user: { id: 'u1' } });
+ render(TermsAcceptanceModal);
+ await waitFor(() => expect(getMe).toHaveBeenCalledTimes(1));
+ expect(screen.queryByRole('dialog')).toBeNull();
+
+ // The terms version was bumped while the page was open.
+ getAuthConfig.mockResolvedValue({ ...TERMS, termsVersion: '2026-12-01' });
+ getMe.mockResolvedValueOnce({ user: { id: 'u1', termsAcceptanceRequired: true } });
+ requestTermsRecheck();
+ expect(await screen.findByRole('dialog')).toBeTruthy();
+
+ acceptTerms.mockResolvedValue(undefined);
+ getMe.mockResolvedValueOnce({ user: { id: 'u1' } });
+ await fireEvent.click(screen.getByRole('button', { name: 'Accept' }));
+ expect(acceptTerms).toHaveBeenCalledWith('session-1', '2026-12-01');
+ });
+});
diff --git a/packages/frontend/src/lib/stores/terms-recheck.ts b/packages/frontend/src/lib/stores/terms-recheck.ts
new file mode 100644
index 000000000..7b31ecab3
--- /dev/null
+++ b/packages/frontend/src/lib/stores/terms-recheck.ts
@@ -0,0 +1,12 @@
+import { writable } from 'svelte/store';
+
+/**
+ * Bumped whenever an API call is refused with `terms_not_accepted` (e.g. the
+ * terms version changed while the page was open). TermsAcceptanceModal listens
+ * and re-runs its pending check, so the modal appears without a reload.
+ */
+export const termsRecheck = writable(0);
+
+export function requestTermsRecheck(): void {
+ termsRecheck.update((n) => n + 1);
+}
diff --git a/packages/frontend/src/lib/utils/email-verification.test.ts b/packages/frontend/src/lib/utils/email-verification.test.ts
new file mode 100644
index 000000000..53c66d2b7
--- /dev/null
+++ b/packages/frontend/src/lib/utils/email-verification.test.ts
@@ -0,0 +1,38 @@
+import { describe, it, expect } from 'vitest';
+import {
+ shouldShowVerifyBanner,
+ apiErrorMessage,
+ EMAIL_NOT_VERIFIED_MESSAGE,
+ TERMS_NOT_ACCEPTED_MESSAGE,
+} from './email-verification';
+
+describe('shouldShowVerifyBanner', () => {
+ it('shows only when required and the user is explicitly unverified', () => {
+ expect(shouldShowVerifyBanner(true, false)).toBe(true);
+ });
+
+ it('hides when the gate is off or not advertised', () => {
+ expect(shouldShowVerifyBanner(undefined, false)).toBe(false);
+ expect(shouldShowVerifyBanner(false, false)).toBe(false);
+ });
+
+ it('hides for verified users or when the server did not report the state', () => {
+ expect(shouldShowVerifyBanner(true, true)).toBe(false);
+ expect(shouldShowVerifyBanner(true, undefined)).toBe(false);
+ });
+});
+
+describe('apiErrorMessage', () => {
+ it('maps email_not_verified to a readable message', () => {
+ expect(apiErrorMessage('email_not_verified', 'x')).toBe(EMAIL_NOT_VERIFIED_MESSAGE);
+ });
+
+ it('maps terms_not_accepted to a readable message', () => {
+ expect(apiErrorMessage('terms_not_accepted', 'x')).toBe(TERMS_NOT_ACCEPTED_MESSAGE);
+ });
+
+ it('passes other errors through and falls back when empty', () => {
+ expect(apiErrorMessage('Project already exists', 'x')).toBe('Project already exists');
+ expect(apiErrorMessage(undefined, 'Failed')).toBe('Failed');
+ });
+});
diff --git a/packages/frontend/src/lib/utils/email-verification.ts b/packages/frontend/src/lib/utils/email-verification.ts
new file mode 100644
index 000000000..16836aa33
--- /dev/null
+++ b/packages/frontend/src/lib/utils/email-verification.ts
@@ -0,0 +1,31 @@
+/** Error code the backend returns when the email verification gate blocks an action. */
+export const EMAIL_NOT_VERIFIED = 'email_not_verified';
+
+export const EMAIL_NOT_VERIFIED_MESSAGE =
+ 'Verify your email address first. Check your inbox for the verification link.';
+
+/**
+ * The "Verify your email" banner is shown only when the backend advertises the
+ * gate (auth config `emailVerificationRequired`) AND reports the current user
+ * as unverified. A missing `emailVerified` means the server did not say, so
+ * nothing is shown.
+ */
+export function shouldShowVerifyBanner(
+ emailVerificationRequired: boolean | undefined,
+ emailVerified: boolean | undefined
+): boolean {
+ return emailVerificationRequired === true && emailVerified === false;
+}
+
+/** Error code the backend returns while the current Terms version is not accepted. */
+export const TERMS_NOT_ACCEPTED = 'terms_not_accepted';
+
+export const TERMS_NOT_ACCEPTED_MESSAGE =
+ 'Accept the Terms of Service and the Privacy Policy first.';
+
+/** Turns a backend error code into the message shown to the user. */
+export function apiErrorMessage(error: string | undefined, fallback: string): string {
+ if (error === EMAIL_NOT_VERIFIED) return EMAIL_NOT_VERIFIED_MESSAGE;
+ if (error === TERMS_NOT_ACCEPTED) return TERMS_NOT_ACCEPTED_MESSAGE;
+ return error || fallback;
+}
diff --git a/packages/frontend/src/lib/utils/upgrade-cta.test.ts b/packages/frontend/src/lib/utils/upgrade-cta.test.ts
new file mode 100644
index 000000000..313433661
--- /dev/null
+++ b/packages/frontend/src/lib/utils/upgrade-cta.test.ts
@@ -0,0 +1,30 @@
+import { describe, it, expect } from 'vitest';
+import { canShowUpgradeCta, anyCapabilityAtLimit } from './upgrade-cta';
+
+describe('canShowUpgradeCta', () => {
+ it('requires the feature flag and an owner or admin role', () => {
+ expect(canShowUpgradeCta(true, 'owner')).toBe(true);
+ expect(canShowUpgradeCta(true, 'admin')).toBe(true);
+ expect(canShowUpgradeCta(true, 'member')).toBe(false);
+ expect(canShowUpgradeCta(true, undefined)).toBe(false);
+ expect(canShowUpgradeCta(true, null)).toBe(false);
+ });
+
+ it('is hidden when the feature is off or absent', () => {
+ expect(canShowUpgradeCta(undefined, 'owner')).toBe(false);
+ expect(canShowUpgradeCta(false, 'admin')).toBe(false);
+ });
+});
+
+describe('anyCapabilityAtLimit', () => {
+ it('flags capabilities at or over their limit', () => {
+ expect(anyCapabilityAtLimit([{ current: 5, limit: 5 }])).toBe(true);
+ expect(anyCapabilityAtLimit([{ current: 6, limit: 5 }])).toBe(true);
+ expect(anyCapabilityAtLimit([{ current: 0, limit: 0 }])).toBe(true);
+ });
+
+ it('ignores unlimited and under-limit capabilities', () => {
+ expect(anyCapabilityAtLimit([{ current: 99, limit: null }, { current: 1, limit: 5 }])).toBe(false);
+ expect(anyCapabilityAtLimit([])).toBe(false);
+ });
+});
diff --git a/packages/frontend/src/lib/utils/upgrade-cta.ts b/packages/frontend/src/lib/utils/upgrade-cta.ts
new file mode 100644
index 000000000..d8227fd7b
--- /dev/null
+++ b/packages/frontend/src/lib/utils/upgrade-cta.ts
@@ -0,0 +1,20 @@
+import type { OrgRole } from '@logtide/shared';
+
+/**
+ * The Upgrade / Manage billing buttons are shown only when the backend
+ * advertises the handoff (auth config `upgradeEnabled`) AND the viewer is an
+ * owner or admin of the current organization.
+ */
+export function canShowUpgradeCta(
+ upgradeEnabled: boolean | undefined,
+ role: OrgRole | undefined | null
+): boolean {
+ return upgradeEnabled === true && (role === 'owner' || role === 'admin');
+}
+
+/** True when any capability with a configured limit is at or over it. */
+export function anyCapabilityAtLimit(
+ caps: Array<{ current: number; limit: number | null }>
+): boolean {
+ return caps.some((c) => c.limit !== null && c.current >= c.limit);
+}
diff --git a/packages/frontend/src/lib/utils/usage-capabilities.test.ts b/packages/frontend/src/lib/utils/usage-capabilities.test.ts
new file mode 100644
index 000000000..687bc2c32
--- /dev/null
+++ b/packages/frontend/src/lib/utils/usage-capabilities.test.ts
@@ -0,0 +1,48 @@
+import { describe, it, expect } from 'vitest';
+import type { CapabilityUsage } from '$lib/api/usage';
+import { visibleCapabilities } from './usage-capabilities';
+
+const row = (capability: string, kind: 'limit' | 'quota', limit: number | null): CapabilityUsage =>
+ ({ capability, kind, current: 0, limit, description: '' }) as CapabilityUsage;
+
+const names = (caps: CapabilityUsage[]) => visibleCapabilities(caps).map((c) => c.capability);
+
+describe('visibleCapabilities', () => {
+ it('keeps every row when no quota is capped (self-hosted default), limited first', () => {
+ expect(
+ names([
+ row('ingestion.max_bytes_monthly', 'quota', null),
+ row('alerts.max_rules', 'limit', 5),
+ row('telemetry.max_bytes_monthly', 'quota', null),
+ ])
+ ).toEqual(['alerts.max_rules', 'ingestion.max_bytes_monthly', 'telemetry.max_bytes_monthly']);
+ });
+
+ it('keeps every row when only a per-signal quota is capped (self-hoster with one quota)', () => {
+ expect(
+ names([
+ row('ingestion.max_bytes_monthly', 'quota', null),
+ row('storage.max_bytes', 'quota', 1024),
+ row('tracing.max_spans_monthly', 'quota', null),
+ row('telemetry.max_bytes_monthly', 'quota', null),
+ ])
+ ).toEqual([
+ 'storage.max_bytes',
+ 'ingestion.max_bytes_monthly',
+ 'tracing.max_spans_monthly',
+ 'telemetry.max_bytes_monthly',
+ ]);
+ });
+
+ it('hides null quotas when the combined telemetry quota is capped (hosted plan)', () => {
+ expect(
+ names([
+ row('apikeys.max', 'limit', null),
+ row('ingestion.max_bytes_monthly', 'quota', null),
+ row('tracing.max_spans_monthly', 'quota', null),
+ row('metrics.max_datapoints_monthly', 'quota', 1_000_000_000),
+ row('telemetry.max_bytes_monthly', 'quota', 10 * 1024 ** 3),
+ ])
+ ).toEqual(['metrics.max_datapoints_monthly', 'telemetry.max_bytes_monthly', 'apikeys.max']);
+ });
+});
diff --git a/packages/frontend/src/lib/utils/usage-capabilities.ts b/packages/frontend/src/lib/utils/usage-capabilities.ts
new file mode 100644
index 000000000..b7edf7670
--- /dev/null
+++ b/packages/frontend/src/lib/utils/usage-capabilities.ts
@@ -0,0 +1,22 @@
+import type { CapabilityUsage } from '$lib/api/usage';
+
+/** The combined quota of hosted plans. */
+const TELEMETRY_QUOTA = 'telemetry.max_bytes_monthly';
+
+/**
+ * Rows of the Usage page capability card, in display order.
+ *
+ * Capabilities with a configured limit come first (the actionable ones),
+ * unlimited ones after; stable within each group by registry order.
+ *
+ * Only when the combined telemetry.max_bytes_monthly quota has a limit (the
+ * hosted plan case) are the quotas left at null hidden: listing every
+ * per-signal meter as "Unlimited" next to the plan quota is noise. In every
+ * other case (self-hosted default, or a self-hoster who capped one quota)
+ * every row stays, exactly as before.
+ */
+export function visibleCapabilities(caps: CapabilityUsage[]): CapabilityUsage[] {
+ const telemetryCapped = caps.some((c) => c.capability === TELEMETRY_QUOTA && c.limit !== null);
+ const rows = telemetryCapped ? caps.filter((c) => !(c.kind === 'quota' && c.limit === null)) : caps;
+ return [...rows].sort((a, b) => (a.limit === null ? 1 : 0) - (b.limit === null ? 1 : 0));
+}
diff --git a/packages/frontend/src/routes/dashboard/settings/usage/+page.svelte b/packages/frontend/src/routes/dashboard/settings/usage/+page.svelte
index fc5f181bd..60be74543 100644
--- a/packages/frontend/src/routes/dashboard/settings/usage/+page.svelte
+++ b/packages/frontend/src/routes/dashboard/settings/usage/+page.svelte
@@ -21,10 +21,15 @@
import { Button } from '$lib/components/ui/button';
import TimeRangeButtons from '$lib/components/TimeRangeButtons.svelte';
import Spinner from '$lib/components/Spinner.svelte';
+ import QuotaStateBadge from '$lib/components/QuotaStateBadge.svelte';
+ import { authAPI } from '$lib/api/auth';
+ import { organizationsAPI } from '$lib/api/organizations';
+ import { canShowUpgradeCta, anyCapabilityAtLimit } from '$lib/utils/upgrade-cta';
import type { OrganizationWithRole } from '@logtide/shared';
import BarChart3 from '@lucide/svelte/icons/bar-chart-3';
import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
import Gauge from '@lucide/svelte/icons/gauge';
+ import { visibleCapabilities } from '$lib/utils/usage-capabilities';
// TODO(#214): entitlements/limits editor goes here once feature #214 backend is shipped
@@ -52,6 +57,35 @@
let lastLoadedOrgId = $state(null);
let lastLoadedDays = $state(null);
+ let upgradeEnabled = $state(false);
+ let handoffBusy = $state<'upgrade' | 'manage' | null>(null);
+ let handoffError = $state('');
+
+ onMount(async () => {
+ try {
+ const cfg = await authAPI.getAuthConfig();
+ upgradeEnabled = cfg.upgradeEnabled === true;
+ } catch {
+ upgradeEnabled = false;
+ }
+ });
+
+ let showUpgradeCta = $derived(canShowUpgradeCta(upgradeEnabled, currentOrg?.role));
+ let atLimit = $derived(anyCapabilityAtLimit(capabilities));
+
+ async function startHandoff(intent: 'upgrade' | 'manage') {
+ if (!currentOrg || handoffBusy) return;
+ handoffBusy = intent;
+ handoffError = '';
+ try {
+ const { url } = await organizationsAPI.createUpgradeHandoff(currentOrg.id, intent);
+ window.location.assign(url);
+ } catch (e) {
+ handoffError = e instanceof Error ? e.message : 'Could not open billing';
+ handoffBusy = null;
+ }
+ }
+
const unsubOrg = organizationStore.subscribe((state) => {
currentOrg = state.currentOrganization;
});
@@ -157,12 +191,15 @@
'alerts.max_rules': { label: 'Alert rules', unit: 'count' },
'notifications.max_channels': { label: 'Notification channels', unit: 'count' },
'apikeys.max': { label: 'API keys', unit: 'count' },
+ 'projects.max': { label: 'Projects', unit: 'count' },
'sigma.max_active_rules': { label: 'Active Sigma rules', unit: 'count' },
'dashboards.max_custom': { label: 'Custom dashboards', unit: 'count' },
'ingestion.max_events_monthly': { label: 'Events (this month)', unit: 'count' },
'ingestion.max_bytes_monthly': { label: 'Ingested bytes (this month)', unit: 'bytes' },
'storage.max_bytes': { label: 'Storage used', unit: 'bytes' },
'tracing.max_spans_monthly': { label: 'Spans (this month)', unit: 'count' },
+ 'metrics.max_datapoints_monthly': { label: 'Metric datapoints (this month)', unit: 'count' },
+ 'telemetry.max_bytes_monthly': { label: 'Telemetry (logs, traces and metrics)', unit: 'bytes' },
};
function capLabel(c: CapabilityUsage): string {
@@ -189,15 +226,9 @@
return 'bg-green-500';
}
- // Capabilities with a configured limit come first (the actionable ones),
- // unlimited ones after; stable within each group by registry order.
- let sortedCapabilities = $derived(
- [...capabilities].sort((a, b) => {
- const al = a.limit === null ? 1 : 0;
- const bl = b.limit === null ? 1 : 0;
- return al - bl;
- })
- );
+ // Limited first, unlimited after; null quotas hidden when the combined
+ // telemetry quota is capped (see visibleCapabilities).
+ let sortedCapabilities = $derived(visibleCapabilities(capabilities));
let hasAnyLimit = $derived(capabilities.some((c) => c.limit !== null));
@@ -214,12 +245,36 @@
value={String(selectedDays)}
onChange={(v) => { selectedDays = parseInt(v, 10) as RangeDays; }}
/>
-
+