From 0293145f178d56bded22c3f3296874d35267d84b Mon Sep 17 00:00:00 2001 From: Polliog Date: Sun, 18 Oct 2026 21:29:36 +0200 Subject: [PATCH 01/54] add env driven capability defaults --- CHANGELOG.md | 8 ++ .../src/capabilities/quota-evaluator.ts | 15 ++- packages/backend/src/capabilities/registry.ts | 65 +++++++++ packages/backend/src/config/index.ts | 14 ++ .../capabilities/defaults-override.test.ts | 125 ++++++++++++++++++ 5 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 packages/backend/src/tests/capabilities/defaults-override.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a30c6e3b..6e2e080cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **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. + +### Fixed + +- **Quota evaluator ignored default quotas**: `QuotaEvaluator` only evaluated orgs holding a non-null quota entitlement row, so a quota coming from defaults never blocked anyone. When any quota default is finite it now evaluates every organization; with no quota defaults it still queries only orgs with rows. + ### 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. diff --git a/packages/backend/src/capabilities/quota-evaluator.ts b/packages/backend/src/capabilities/quota-evaluator.ts index 99c4d6143..adc6ed8bb 100644 --- a/packages/backend/src/capabilities/quota-evaluator.ts +++ b/packages/backend/src/capabilities/quota-evaluator.ts @@ -33,10 +33,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') diff --git a/packages/backend/src/capabilities/registry.ts b/packages/backend/src/capabilities/registry.ts index ff671146a..e6ce62be4 100644 --- a/packages/backend/src/capabilities/registry.ts +++ b/packages/backend/src/capabilities/registry.ts @@ -157,3 +157,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/config/index.ts b/packages/backend/src/config/index.ts index d45e453f0..1e3bea23e 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)); @@ -70,6 +71,10 @@ 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(), + // 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), @@ -147,6 +152,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/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); + }); +}); From fe9ccf71e67131198b2e7f0828c93c7b406504cf Mon Sep 17 00:00:00 2001 From: Polliog Date: Sun, 18 Oct 2026 21:41:08 +0200 Subject: [PATCH 02/54] accept service token on admin routes --- CHANGELOG.md | 1 + packages/backend/src/config/index.ts | 8 + .../backend/src/modules/admin/middleware.ts | 5 + .../backend/src/modules/auth/middleware.ts | 30 ++++ .../src/modules/capabilities/admin-routes.ts | 8 +- .../backend/src/modules/retention/routes.ts | 3 + .../tests/modules/admin/service-token.test.ts | 140 ++++++++++++++++++ 7 files changed, 191 insertions(+), 4 deletions(-) create mode 100644 packages/backend/src/tests/modules/admin/service-token.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e2e080cd..a23393b79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Admin service token**: setting `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; 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. ### Fixed diff --git a/packages/backend/src/config/index.ts b/packages/backend/src/config/index.ts index 1e3bea23e..76d527238 100644 --- a/packages/backend/src/config/index.ts +++ b/packages/backend/src/config/index.ts @@ -75,6 +75,14 @@ const configSchema = z.object({ // 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. + ADMIN_SERVICE_TOKEN: z.preprocess( + (v) => (v === '' ? undefined : v), + z.string().min(32, 'ADMIN_SERVICE_TOKEN must be at least 32 characters').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), 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/auth/middleware.ts b/packages/backend/src/modules/auth/middleware.ts index 7a0a0d737..9b6ff7259 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'; + +function matchesServiceToken(token: string): boolean { + const expected = config.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/capabilities/admin-routes.ts b/packages/backend/src/modules/capabilities/admin-routes.ts index aa8b7d06c..35f77a9b5 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, @@ -74,7 +74,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 +85,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 +120,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/retention/routes.ts b/packages/backend/src/modules/retention/routes.ts index 2dd8065ed..887e1820f 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 } : {}), }, 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..36fd0c8a8 --- /dev/null +++ b/packages/backend/src/tests/modules/admin/service-token.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import { db } from '../../../database/index.js'; +import { config, configSchema } from '../../../config/index.js'; +import { authenticate } from '../../../modules/auth/middleware.js'; +import { retentionRoutes } from '../../../modules/retention/index.js'; +import { adminEntitlementsRoutes } from '../../../modules/capabilities/index.js'; +import { adminRoutes } from '../../../modules/admin/routes.js'; +import { createTestOrganization } from '../../helpers/factories.js'; + +const TOKEN = 's'.repeat(40); +const cfg = config as { ADMIN_SERVICE_TOKEN?: string }; + +describe('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).ADMIN_SERVICE_TOKEN).toBeUndefined(); + expect(configSchema.parse({ ...base, ADMIN_SERVICE_TOKEN: '' }).ADMIN_SERVICE_TOKEN).toBeUndefined(); + }); + + it('rejects a 31 character token', () => { + const res = configSchema.safeParse({ ...base, ADMIN_SERVICE_TOKEN: 'a'.repeat(31) }); + expect(res.success).toBe(false); + }); + + it('accepts a 32 character token', () => { + expect(configSchema.parse({ ...base, ADMIN_SERVICE_TOKEN: 'a'.repeat(32) }).ADMIN_SERVICE_TOKEN).toBe('a'.repeat(32)); + }); +}); + +describe('admin service token', () => { + let app: FastifyInstance; + let orgId: string; + + beforeAll(async () => { + app = Fastify(); + await app.register(retentionRoutes, { prefix: '/api/v1/admin' }); + await app.register(adminEntitlementsRoutes, { prefix: '/api/v1/admin' }); + await app.register(adminRoutes, { prefix: '/api/v1/admin' }); + app.get('/plain', { onRequest: authenticate }, async (request) => ({ user: request.user?.id ?? null })); + await app.ready(); + }); + + afterAll(async () => { + delete cfg.ADMIN_SERVICE_TOKEN; + await app.close(); + }); + + afterEach(() => { + delete cfg.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.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'); + }); + + it('PUT retention returns 200 and writes a service audit row', async () => { + cfg.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'); + }); + + it('non-admin route with the token is 401', async () => { + cfg.ADMIN_SERVICE_TOKEN = TOKEN; + const res = await app.inject({ method: 'GET', url: '/plain', headers: auth(TOKEN) }); + expect(res.statusCode).toBe(401); + }); + + it('admin route not opted in to the service token fails cleanly (401, no 500)', async () => { + cfg.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.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); + }); +}); From 860c0de4587ec35289e9ada056685c13ef518ad9 Mon Sep 17 00:00:00 2001 From: Polliog Date: Sun, 18 Oct 2026 21:45:11 +0200 Subject: [PATCH 03/54] rename service token env and tighten tests --- CHANGELOG.md | 2 +- packages/backend/src/config/index.ts | 4 +- .../backend/src/modules/auth/middleware.ts | 2 +- .../backend/src/modules/retention/routes.ts | 1 + .../tests/modules/admin/service-token.test.ts | 41 +++++++++++++------ 5 files changed, 33 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a23393b79..ae335e3ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **Admin service token**: setting `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; anywhere else it is rejected exactly like an invalid session. Audit entries record `actor: service:admin-service` in metadata. Unset means unchanged behavior. +- **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; 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. ### Fixed diff --git a/packages/backend/src/config/index.ts b/packages/backend/src/config/index.ts index 76d527238..dc6e12991 100644 --- a/packages/backend/src/config/index.ts +++ b/packages/backend/src/config/index.ts @@ -78,9 +78,9 @@ const configSchema = z.object({ // 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. - ADMIN_SERVICE_TOKEN: z.preprocess( + LOGTIDE_ADMIN_SERVICE_TOKEN: z.preprocess( (v) => (v === '' ? undefined : v), - z.string().min(32, 'ADMIN_SERVICE_TOKEN must be at least 32 characters').optional() + z.string().min(32, 'LOGTIDE_ADMIN_SERVICE_TOKEN must be at least 32 characters').optional() ), // Storage snapshot job (#212 follow-up). Periodic per-project stored-bytes estimate. diff --git a/packages/backend/src/modules/auth/middleware.ts b/packages/backend/src/modules/auth/middleware.ts index 9b6ff7259..c6714a8e5 100644 --- a/packages/backend/src/modules/auth/middleware.ts +++ b/packages/backend/src/modules/auth/middleware.ts @@ -32,7 +32,7 @@ export interface ServicePrincipal { export const SERVICE_ACTOR_LABEL = 'service:admin-service'; function matchesServiceToken(token: string): boolean { - const expected = config.ADMIN_SERVICE_TOKEN; + const expected = config.LOGTIDE_ADMIN_SERVICE_TOKEN; if (!expected) return false; const a = Buffer.from(token); const b = Buffer.from(expected); diff --git a/packages/backend/src/modules/retention/routes.ts b/packages/backend/src/modules/retention/routes.ts index 887e1820f..9d9789ff8 100644 --- a/packages/backend/src/modules/retention/routes.ts +++ b/packages/backend/src/modules/retention/routes.ts @@ -95,6 +95,7 @@ export async function retentionRoutes(fastify: FastifyInstance) { { preHandler: [requireAdmin], config: { + allowServiceToken: true, rateLimit: rateLimitConfig, }, }, diff --git a/packages/backend/src/tests/modules/admin/service-token.test.ts b/packages/backend/src/tests/modules/admin/service-token.test.ts index 36fd0c8a8..956670ee1 100644 --- a/packages/backend/src/tests/modules/admin/service-token.test.ts +++ b/packages/backend/src/tests/modules/admin/service-token.test.ts @@ -6,29 +6,30 @@ import { authenticate } from '../../../modules/auth/middleware.js'; import { retentionRoutes } from '../../../modules/retention/index.js'; import { adminEntitlementsRoutes } from '../../../modules/capabilities/index.js'; import { adminRoutes } from '../../../modules/admin/routes.js'; +import { contextPlugin } from '../../../context/index.js'; import { createTestOrganization } from '../../helpers/factories.js'; const TOKEN = 's'.repeat(40); -const cfg = config as { ADMIN_SERVICE_TOKEN?: string }; +const cfg = config as { LOGTIDE_ADMIN_SERVICE_TOKEN?: string }; -describe('ADMIN_SERVICE_TOKEN config', () => { +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).ADMIN_SERVICE_TOKEN).toBeUndefined(); - expect(configSchema.parse({ ...base, ADMIN_SERVICE_TOKEN: '' }).ADMIN_SERVICE_TOKEN).toBeUndefined(); + 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, ADMIN_SERVICE_TOKEN: 'a'.repeat(31) }); + 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, ADMIN_SERVICE_TOKEN: 'a'.repeat(32) }).ADMIN_SERVICE_TOKEN).toBe('a'.repeat(32)); + expect(configSchema.parse({ ...base, LOGTIDE_ADMIN_SERVICE_TOKEN: 'a'.repeat(32) }).LOGTIDE_ADMIN_SERVICE_TOKEN).toBe('a'.repeat(32)); }); }); @@ -38,6 +39,7 @@ describe('admin service token', () => { beforeAll(async () => { app = Fastify(); + await app.register(contextPlugin); await app.register(retentionRoutes, { prefix: '/api/v1/admin' }); await app.register(adminEntitlementsRoutes, { prefix: '/api/v1/admin' }); await app.register(adminRoutes, { prefix: '/api/v1/admin' }); @@ -46,12 +48,12 @@ describe('admin service token', () => { }); afterAll(async () => { - delete cfg.ADMIN_SERVICE_TOKEN; + delete cfg.LOGTIDE_ADMIN_SERVICE_TOKEN; await app.close(); }); afterEach(() => { - delete cfg.ADMIN_SERVICE_TOKEN; + delete cfg.LOGTIDE_ADMIN_SERVICE_TOKEN; }); async function newOrg() { @@ -63,7 +65,7 @@ describe('admin service token', () => { const auth = (t: string) => ({ authorization: `Bearer ${t}` }); it('PUT entitlements returns 200 and writes a service audit row', async () => { - cfg.ADMIN_SERVICE_TOKEN = TOKEN; + cfg.LOGTIDE_ADMIN_SERVICE_TOKEN = TOKEN; const id = await newOrg(); const res = await app.inject({ method: 'PUT', @@ -80,10 +82,11 @@ describe('admin service token', () => { .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.ADMIN_SERVICE_TOKEN = TOKEN; + cfg.LOGTIDE_ADMIN_SERVICE_TOKEN = TOKEN; const id = await newOrg(); const res = await app.inject({ method: 'PUT', @@ -99,22 +102,34 @@ describe('admin service token', () => { .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 route with the token is 401', async () => { - cfg.ADMIN_SERVICE_TOKEN = TOKEN; + cfg.LOGTIDE_ADMIN_SERVICE_TOKEN = TOKEN; const res = await app.inject({ method: 'GET', url: '/plain', headers: auth(TOKEN) }); expect(res.statusCode).toBe(401); }); it('admin route not opted in to the service token fails cleanly (401, no 500)', async () => { - cfg.ADMIN_SERVICE_TOKEN = TOKEN; + 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.ADMIN_SERVICE_TOKEN = TOKEN; + 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({ From 851e482894f4334171b3ad8418b50edb76f312cf Mon Sep 17 00:00:00 2001 From: Polliog Date: Sun, 18 Oct 2026 21:52:24 +0200 Subject: [PATCH 04/54] add signed upgrade handoff for external billing --- CHANGELOG.md | 1 + docker/.env.example | 12 + packages/backend/src/config/index.ts | 40 +++ .../src/modules/organizations/routes.ts | 35 +++ .../src/modules/organizations/service.ts | 14 + .../modules/organizations/upgrade-handoff.ts | 90 ++++++ .../backend/src/modules/settings/routes.ts | 2 + packages/backend/src/server.ts | 8 + .../organizations/upgrade-handoff.test.ts | 260 ++++++++++++++++++ 9 files changed, 462 insertions(+) create mode 100644 packages/backend/src/modules/organizations/upgrade-handoff.ts create mode 100644 packages/backend/src/tests/modules/organizations/upgrade-handoff.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ae335e3ff..1ef9952f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **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) 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; 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. diff --git a/docker/.env.example b/docker/.env.example index adffb4ee0..f391dfb1c 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -102,3 +102,15 @@ 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= diff --git a/packages/backend/src/config/index.ts b/packages/backend/src/config/index.ts index dc6e12991..2f0baf802 100644 --- a/packages/backend/src/config/index.ts +++ b/packages/backend/src/config/index.ts @@ -17,6 +17,31 @@ 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; +} + const configSchema = z.object({ // Server NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), @@ -83,6 +108,12 @@ const configSchema = z.object({ 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()), + // 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), @@ -111,6 +142,15 @@ 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, + }); + } }); export type Config = z.infer; diff --git a/packages/backend/src/modules/organizations/routes.ts b/packages/backend/src/modules/organizations/routes.ts index 5a851ce74..b10c5a03e 100644 --- a/packages/backend/src/modules/organizations/routes.ts +++ b/packages/backend/src/modules/organizations/routes.ts @@ -4,6 +4,8 @@ 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 { isUpgradeHandoffActive, signUpgradeToken, buildUpgradeUrl } from './upgrade-handoff.js'; const organizationsService = new OrganizationsService(); @@ -35,10 +37,43 @@ 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. 404 unless the feature is + // configured; owner/admin of the org only. Uses the normal user session. + fastify.post('/:id/upgrade-handoff', async (request: any, reply) => { + if (!isUpgradeHandoffActive()) { + return reply.status(404).send({ error: 'Not found' }); + } + 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); diff --git a/packages/backend/src/modules/organizations/service.ts b/packages/backend/src/modules/organizations/service.ts index c250e37ef..dddebebf0 100644 --- a/packages/backend/src/modules/organizations/service.ts +++ b/packages/backend/src/modules/organizations/service.ts @@ -408,6 +408,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..410de715f --- /dev/null +++ b/packages/backend/src/modules/organizations/upgrade-handoff.ts @@ -0,0 +1,90 @@ +import crypto from 'crypto'; +import { config } from '../../config/index.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); +} + +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 || payload.v !== 1 || typeof payload.exp !== 'number') 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/settings/routes.ts b/packages/backend/src/modules/settings/routes.ts index 1b2b56c54..b22a88497 100644 --- a/packages/backend/src/modules/settings/routes.ts +++ b/packages/backend/src/modules/settings/routes.ts @@ -9,6 +9,7 @@ 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 type { UserProfile } from '../users/service.js'; import { auditLogService } from '../audit-log/service.js'; @@ -243,6 +244,7 @@ export async function publicSettingsRoutes(fastify: FastifyInstance) { authMode, signupEnabled, requiresLogin: authMode !== 'none', + ...(isUpgradeHandoffActive() ? { upgradeEnabled: true } : {}), }); } catch (error) { console.error('Error fetching auth config:', error); diff --git a/packages/backend/src/server.ts b/packages/backend/src/server.ts index ccc9f346f..579e18335 100644 --- a/packages/backend/src/server.ts +++ b/packages/backend/src/server.ts @@ -14,6 +14,8 @@ 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 { isUpgradeHandoffActive } from './modules/organizations/upgrade-handoff.js'; +import { CapabilityError, QuotaExceededError } from './capabilities/errors.js'; import { invitationsRoutes } from './modules/invitations/routes.js'; import { notificationsRoutes } from './modules/notifications/routes.js'; import { apiKeysRoutes } from './modules/api-keys/routes.js'; @@ -112,6 +114,12 @@ export async function build(opts = {}) { if (typeof (error as any).code === 'string') { body.code = (error as any).code; } + if ( + (error instanceof CapabilityError || error instanceof QuotaExceededError) && + isUpgradeHandoffActive() + ) { + body.upgrade_available = true; + } reply.code(statusCode).send(body); return; } 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..3f42751ca --- /dev/null +++ b/packages/backend/src/tests/modules/organizations/upgrade-handoff.test.ts @@ -0,0 +1,260 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; +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 and writes evidence', () => { + 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(); + + const dir = 'G:\\progetti\\logtide\\logtide\\.omo\\evidence'; + try { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'w1-t3-tamper.txt'), + [ + 'valid token verifies: ' + (verifyUpgradeToken(token, SECRET) !== null), + 'tampered payload (role member, org other) verifies: ' + (verifyUpgradeToken(tampered, SECRET) !== null), + 'wrong secret verifies: ' + (verifyUpgradeToken(token, 'w'.repeat(40)) !== null), + 'altered mac verifies: ' + (verifyUpgradeToken(truncated, SECRET) !== null), + 'garbage verifies: ' + (verifyUpgradeToken('garbage', SECRET) !== null), + '', + ].join('\n') + ); + } catch { + /* evidence is best effort */ + } + }); + + 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; + + beforeAll(async () => { + app = await build({ logger: false }); + 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(); + }); + + 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('404 when not configured, even for the owner', async () => { + const f = await fixture(); + const res = await post(f.org.id, await session(f.owner.id), { intent: 'upgrade' }); + expect(res.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); + }); +}); From 4d527626869687eb6ebb85731b54d4f4b403012f Mon Sep 17 00:00:00 2001 From: Polliog Date: Sun, 18 Oct 2026 21:52:24 +0200 Subject: [PATCH 05/54] add upgrade and manage billing buttons --- packages/frontend/src/lib/api/auth.ts | 2 + .../frontend/src/lib/api/organizations.ts | 7 ++ .../src/lib/utils/upgrade-cta.test.ts | 30 +++++++++ .../frontend/src/lib/utils/upgrade-cta.ts | 20 ++++++ .../dashboard/settings/usage/+page.svelte | 64 +++++++++++++++++-- 5 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 packages/frontend/src/lib/utils/upgrade-cta.test.ts create mode 100644 packages/frontend/src/lib/utils/upgrade-cta.ts diff --git a/packages/frontend/src/lib/api/auth.ts b/packages/frontend/src/lib/api/auth.ts index 48c353871..0bdd2a595 100644 --- a/packages/frontend/src/lib/api/auth.ts +++ b/packages/frontend/src/lib/api/auth.ts @@ -57,6 +57,8 @@ export interface UserIdentity { export interface AuthConfig { authMode: 'standard' | 'none'; signupEnabled: boolean; + /** Present (true) only when the host configured the external upgrade handoff. */ + upgradeEnabled?: boolean; } export class AuthAPI { diff --git a/packages/frontend/src/lib/api/organizations.ts b/packages/frontend/src/lib/api/organizations.ts index 4ceff725a..de7386bbb 100644 --- a/packages/frontend/src/lib/api/organizations.ts +++ b/packages/frontend/src/lib/api/organizations.ts @@ -108,6 +108,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/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/routes/dashboard/settings/usage/+page.svelte b/packages/frontend/src/routes/dashboard/settings/usage/+page.svelte index fc5f181bd..4f0b55b8a 100644 --- a/packages/frontend/src/routes/dashboard/settings/usage/+page.svelte +++ b/packages/frontend/src/routes/dashboard/settings/usage/+page.svelte @@ -21,6 +21,9 @@ import { Button } from '$lib/components/ui/button'; import TimeRangeButtons from '$lib/components/TimeRangeButtons.svelte'; import Spinner from '$lib/components/Spinner.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'; @@ -52,6 +55,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; }); @@ -214,12 +246,36 @@ value={String(selectedDays)} onChange={(v) => { selectedDays = parseInt(v, 10) as RangeDays; }} /> - +
+ {#if showUpgradeCta} + + + {/if} + +
+ {#if handoffError} +

{handoffError}

+ {/if} + {#if error}

{error}

{/if} From 43a12daf9b56fcf80aa0612bac800caea4fbcee6 Mon Sep 17 00:00:00 2001 From: Polliog Date: Sun, 18 Oct 2026 21:58:05 +0200 Subject: [PATCH 06/54] register handoff route only when enabled --- .../src/modules/organizations/routes.ts | 10 ++-- .../modules/organizations/upgrade-handoff.ts | 6 +- .../organizations/upgrade-handoff.test.ts | 56 +++++++++++-------- 3 files changed, 42 insertions(+), 30 deletions(-) diff --git a/packages/backend/src/modules/organizations/routes.ts b/packages/backend/src/modules/organizations/routes.ts index b10c5a03e..1edfb5109 100644 --- a/packages/backend/src/modules/organizations/routes.ts +++ b/packages/backend/src/modules/organizations/routes.ts @@ -45,12 +45,10 @@ export async function organizationsRoutes(fastify: FastifyInstance) { // All routes require authentication fastify.addHook('onRequest', authenticate); - // Signed handoff to the external billing portal. 404 unless the feature is - // configured; owner/admin of the org only. Uses the normal user session. - fastify.post('/:id/upgrade-handoff', async (request: any, reply) => { - if (!isUpgradeHandoffActive()) { - return reply.status(404).send({ error: 'Not found' }); - } + // 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); diff --git a/packages/backend/src/modules/organizations/upgrade-handoff.ts b/packages/backend/src/modules/organizations/upgrade-handoff.ts index 410de715f..f90811f4e 100644 --- a/packages/backend/src/modules/organizations/upgrade-handoff.ts +++ b/packages/backend/src/modules/organizations/upgrade-handoff.ts @@ -77,7 +77,11 @@ export function verifyUpgradeToken( } catch { return null; } - if (!payload || payload.v !== 1 || typeof payload.exp !== 'number') 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/packages/backend/src/tests/modules/organizations/upgrade-handoff.test.ts b/packages/backend/src/tests/modules/organizations/upgrade-handoff.test.ts index 3f42751ca..4afc70c22 100644 --- a/packages/backend/src/tests/modules/organizations/upgrade-handoff.test.ts +++ b/packages/backend/src/tests/modules/organizations/upgrade-handoff.test.ts @@ -1,7 +1,5 @@ import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; import crypto from 'crypto'; -import fs from 'fs'; -import path from 'path'; import type { FastifyInstance } from 'fastify'; import { build } from '../../../server.js'; import { db } from '../../../database/index.js'; @@ -90,7 +88,7 @@ describe('upgrade token signer and reference verifier', () => { expect(a.nonce).not.toBe(b.nonce); }); - it('rejects a tampered payload and writes evidence', () => { + 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' }; @@ -101,24 +99,20 @@ describe('upgrade token signer and reference verifier', () => { expect(verifyUpgradeToken(truncated, SECRET)).toBeNull(); expect(verifyUpgradeToken('garbage', SECRET)).toBeNull(); expect(verifyUpgradeToken('', SECRET)).toBeNull(); + }); - const dir = 'G:\\progetti\\logtide\\logtide\\.omo\\evidence'; - try { - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync( - path.join(dir, 'w1-t3-tamper.txt'), - [ - 'valid token verifies: ' + (verifyUpgradeToken(token, SECRET) !== null), - 'tampered payload (role member, org other) verifies: ' + (verifyUpgradeToken(tampered, SECRET) !== null), - 'wrong secret verifies: ' + (verifyUpgradeToken(token, 'w'.repeat(40)) !== null), - 'altered mac verifies: ' + (verifyUpgradeToken(truncated, SECRET) !== null), - 'garbage verifies: ' + (verifyUpgradeToken('garbage', SECRET) !== null), - '', - ].join('\n') - ); - } catch { - /* evidence is best effort */ - } + 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', () => { @@ -137,9 +131,16 @@ describe('upgrade token signer and reference verifier', () => { 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'); }); @@ -152,6 +153,7 @@ describe('upgrade handoff integration', () => { afterAll(async () => { deactivate(); await app.close(); + await appOff.close(); }); afterEach(() => deactivate()); @@ -183,10 +185,18 @@ describe('upgrade handoff integration', () => { expect(isUpgradeHandoffActive()).toBe(false); }); - it('404 when not configured, even for the owner', async () => { + it('route does not exist when not configured (default 404, even unauthenticated)', async () => { const f = await fixture(); - const res = await post(f.org.id, await session(f.owner.id), { intent: 'upgrade' }); - expect(res.statusCode).toBe(404); + 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', () => { From 50cf264f01e6ac81f619935ed27adb5bdde3258a Mon Sep 17 00:00:00 2001 From: Polliog Date: Sun, 18 Oct 2026 22:03:33 +0200 Subject: [PATCH 07/54] add quota grace and threshold notifications --- CHANGELOG.md | 1 + .../src/capabilities/quota-evaluator.ts | 10 +- .../src/capabilities/quota-notifier.ts | 139 ++++++++++++++++++ .../backend/src/capabilities/quota-state.ts | 20 +++ packages/backend/src/capabilities/registry.ts | 5 + .../src/modules/metering/capability-usage.ts | 7 +- .../tests/capabilities/quota-grace.test.ts | 131 +++++++++++++++++ packages/frontend/src/lib/api/usage.ts | 2 + .../src/lib/components/QuotaStateBadge.svelte | 11 ++ .../lib/components/QuotaStateBadge.test.ts | 21 +++ .../dashboard/settings/usage/+page.svelte | 3 +- 11 files changed, 347 insertions(+), 3 deletions(-) create mode 100644 packages/backend/src/capabilities/quota-notifier.ts create mode 100644 packages/backend/src/capabilities/quota-state.ts create mode 100644 packages/backend/src/tests/capabilities/quota-grace.test.ts create mode 100644 packages/frontend/src/lib/components/QuotaStateBadge.svelte create mode 100644 packages/frontend/src/lib/components/QuotaStateBadge.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ef9952f0..c1735bd3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **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 and state per month from existing notifications; 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) 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; 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. diff --git a/packages/backend/src/capabilities/quota-evaluator.ts b/packages/backend/src/capabilities/quota-evaluator.ts index adc6ed8bb..4429ff152 100644 --- a/packages/backend/src/capabilities/quota-evaluator.ts +++ b/packages/backend/src/capabilities/quota-evaluator.ts @@ -8,6 +8,8 @@ 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'; const QUOTA_CAPABILITIES = CAPABILITY_NAMES.filter( @@ -60,6 +62,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]; @@ -88,7 +92,11 @@ export class QuotaEvaluator { usage = rows.reduce((sum, r) => sum + r.quantity, 0); } - 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..3f7f64ee5 --- /dev/null +++ b/packages/backend/src/capabilities/quota-notifier.ts @@ -0,0 +1,139 @@ +import nodemailer from 'nodemailer'; +import { sql } from 'kysely'; +import { db } from '../database/connection.js'; +import { config } from '../config/index.js'; +import { notificationsService } from '../modules/notifications/service.js'; +import { hub } from '@logtide/core'; +import { isInternalLoggingEnabled } from '../utils/internal-logger.js'; +import { CAPABILITIES, type CapabilityName } from './registry.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] as { signal?: string }; + if (def.signal && (def.signal.endsWith('.bytes') || def.signal === 'storage.snapshot')) { + 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'); +} + +function startOfUtcMonth(now: Date): Date { + return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); +} + +let transporter: nodemailer.Transporter | null = null; +function getTransporter(): nodemailer.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 nodemailer.TransportOptions); + } + return transporter; +} + +/** + * Sends the in-app notification + email for a quota threshold, at most once per + * (org, capability, state, UTC month). Dedup reads the notifications already + * created; no extra table. 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 existing = await db + .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('created_at', '>=', startOfUtcMonth(now)) + .limit(1) + .executeTakeFirst(); + if (existing) return; + + const recipients = await db + .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; + + const org = await db + .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 }; + for (const r of recipients) { + await notificationsService.createNotification({ + userId: r.id, + type: 'system', + title, + message, + organizationId, + metadata, + }); + } + + const mailer = getTransporter(); + if (!mailer) return; + try { + await mailer.sendMail({ + from: `"LogTide Notifications" <${config.SMTP_FROM}>`, + to: recipients.map((r) => r.email).join(', '), + subject: `[${org?.name ?? 'LogTide'}] ${title}`, + text: 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/registry.ts b/packages/backend/src/capabilities/registry.ts index e6ce62be4..f3e540dc5 100644 --- a/packages/backend/src/capabilities/registry.ts +++ b/packages/backend/src/capabilities/registry.ts @@ -104,6 +104,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': { diff --git a/packages/backend/src/modules/metering/capability-usage.ts b/packages/backend/src/modules/metering/capability-usage.ts index 056b4b0ac..8a9706303 100644 --- a/packages/backend/src/modules/metering/capability-usage.ts +++ b/packages/backend/src/modules/metering/capability-usage.ts @@ -4,6 +4,7 @@ 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'; @@ -21,6 +22,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 @@ -101,12 +104,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/tests/capabilities/quota-grace.test.ts b/packages/backend/src/tests/capabilities/quota-grace.test.ts new file mode 100644 index 000000000..3a7a481da --- /dev/null +++ b/packages/backend/src/tests/capabilities/quota-grace.test.ts @@ -0,0 +1,131 @@ +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 } 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'); + }); +}); + +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); + }); +}); 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/QuotaStateBadge.svelte b/packages/frontend/src/lib/components/QuotaStateBadge.svelte new file mode 100644 index 000000000..54d57453e --- /dev/null +++ b/packages/frontend/src/lib/components/QuotaStateBadge.svelte @@ -0,0 +1,11 @@ + + +{#if state === 'grace'} + In grace +{:else if state === 'blocked'} + Blocked +{/if} diff --git a/packages/frontend/src/lib/components/QuotaStateBadge.test.ts b/packages/frontend/src/lib/components/QuotaStateBadge.test.ts new file mode 100644 index 000000000..6e1d1e1e2 --- /dev/null +++ b/packages/frontend/src/lib/components/QuotaStateBadge.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import QuotaStateBadge from './QuotaStateBadge.svelte'; + +describe('QuotaStateBadge', () => { + it('shows In grace', () => { + render(QuotaStateBadge, { state: 'grace' }); + expect(screen.getByText('In grace')).toBeTruthy(); + }); + it('shows Blocked', () => { + render(QuotaStateBadge, { state: 'blocked' }); + expect(screen.getByText('Blocked')).toBeTruthy(); + }); + it('renders nothing for ok, unlimited and undefined', () => { + for (const s of ['ok', 'unlimited', undefined] as const) { + const { container, unmount } = render(QuotaStateBadge, { state: s }); + expect(container.textContent?.trim()).toBe(''); + unmount(); + } + }); +}); diff --git a/packages/frontend/src/routes/dashboard/settings/usage/+page.svelte b/packages/frontend/src/routes/dashboard/settings/usage/+page.svelte index 4f0b55b8a..442760b5b 100644 --- a/packages/frontend/src/routes/dashboard/settings/usage/+page.svelte +++ b/packages/frontend/src/routes/dashboard/settings/usage/+page.svelte @@ -21,6 +21,7 @@ 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'; @@ -366,7 +367,7 @@ {@const pct = capPercent(cap)}
- {capLabel(cap)} + {capLabel(cap)} {capFormat(cap, cap.current)} / {cap.limit === null ? '∞' : capFormat(cap, cap.limit)} {#if pct !== null} From 323c30b8b2e27517efa1ffd2c1a563a330158993 Mon Sep 17 00:00:00 2001 From: Polliog Date: Sun, 18 Oct 2026 22:11:09 +0200 Subject: [PATCH 08/54] cap organizations per user via env --- CHANGELOG.md | 1 + packages/backend/src/config/index.ts | 11 ++ .../src/modules/organizations/routes.ts | 11 ++ .../src/modules/organizations/service.ts | 11 ++ .../modules/organizations/org-limit.test.ts | 132 ++++++++++++++++++ 5 files changed, 166 insertions(+) create mode 100644 packages/backend/src/tests/modules/organizations/org-limit.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c1735bd3d..0408bdfe6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **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. - **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 and state per month from existing notifications; 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) 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; anywhere else it is rejected exactly like an invalid session. Audit entries record `actor: service:admin-service` in metadata. Unset means unchanged behavior. diff --git a/packages/backend/src/config/index.ts b/packages/backend/src/config/index.ts index 2f0baf802..5d4cab3de 100644 --- a/packages/backend/src/config/index.ts +++ b/packages/backend/src/config/index.ts @@ -114,6 +114,17 @@ const configSchema = z.object({ 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: 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() + ), + // 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), diff --git a/packages/backend/src/modules/organizations/routes.ts b/packages/backend/src/modules/organizations/routes.ts index 1edfb5109..989793103 100644 --- a/packages/backend/src/modules/organizations/routes.ts +++ b/packages/backend/src/modules/organizations/routes.ts @@ -308,6 +308,17 @@ export async function organizationsRoutes(fastify: FastifyInstance) { try { const body = createOrganizationSchema.parse(request.body); + const maxOwned = config.MAX_ORGANIZATIONS_PER_USER; + if (maxOwned !== undefined) { + const owned = await organizationsService.countOwnedOrganizations(request.user.id); + if (owned >= maxOwned) { + return reply.status(403).send({ + error: 'organization_limit_reached', + message: `You can own at most ${maxOwned.toLocaleString('en-US')} organization${maxOwned === 1 ? '' : 's'}`, + }); + } + } + const organization = await organizationsService.createOrganization({ userId: request.user.id, name: body.name, diff --git a/packages/backend/src/modules/organizations/service.ts b/packages/backend/src/modules/organizations/service.ts index dddebebf0..377e92bfc 100644 --- a/packages/backend/src/modules/organizations/service.ts +++ b/packages/backend/src/modules/organizations/service.ts @@ -53,6 +53,17 @@ 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); + } + /** * Create a new organization */ 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); + }); +}); From a885d31cbc03704fbd9dbd030874f7a4f5b89188 Mon Sep 17 00:00:00 2001 From: Polliog Date: Sun, 18 Oct 2026 22:11:09 +0200 Subject: [PATCH 09/54] add projects.max capability with enforcement --- CHANGELOG.md | 1 + packages/backend/src/capabilities/registry.ts | 5 + .../src/modules/metering/capability-usage.ts | 2 + .../backend/src/modules/projects/routes.ts | 23 +++- .../backend/src/modules/projects/service.ts | 13 +- .../modules/metering/capability-usage.test.ts | 1 + .../modules/projects/projects-max.test.ts | 124 ++++++++++++++++++ .../dashboard/settings/usage/+page.svelte | 1 + 8 files changed, 164 insertions(+), 6 deletions(-) create mode 100644 packages/backend/src/tests/modules/projects/projects-max.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0408bdfe6..60420c3b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **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. +- **`projects.max` capability**: new per-org limit on active projects (soft-deleted ones do not count), default unlimited. Enforced on `POST /api/v1/projects` 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 and state per month from existing notifications; 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) 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; anywhere else it is rejected exactly like an invalid session. Audit entries record `actor: service:admin-service` in metadata. Unset means unchanged behavior. diff --git a/packages/backend/src/capabilities/registry.ts b/packages/backend/src/capabilities/registry.ts index f3e540dc5..53848029a 100644 --- a/packages/backend/src/capabilities/registry.ts +++ b/packages/backend/src/capabilities/registry.ts @@ -79,6 +79,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, diff --git a/packages/backend/src/modules/metering/capability-usage.ts b/packages/backend/src/modules/metering/capability-usage.ts index 8a9706303..40d2508c5 100644 --- a/packages/backend/src/modules/metering/capability-usage.ts +++ b/packages/backend/src/modules/metering/capability-usage.ts @@ -9,6 +9,7 @@ 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'; @@ -35,6 +36,7 @@ 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), }; diff --git a/packages/backend/src/modules/projects/routes.ts b/packages/backend/src/modules/projects/routes.ts index de235571c..29005812f 100644 --- a/packages/backend/src/modules/projects/routes.ts +++ b/packages/backend/src/modules/projects/routes.ts @@ -5,6 +5,8 @@ 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'; const createProjectSchema = z.object({ @@ -254,11 +256,22 @@ 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, + 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/tests/modules/metering/capability-usage.test.ts b/packages/backend/src/tests/modules/metering/capability-usage.test.ts index de05e7852..e707deff6 100644 --- a/packages/backend/src/tests/modules/metering/capability-usage.test.ts +++ b/packages/backend/src/tests/modules/metering/capability-usage.test.ts @@ -30,6 +30,7 @@ describe('getCapabilityUsage', () => { 'ingestion.max_bytes_monthly', 'ingestion.max_events_monthly', 'notifications.max_channels', + 'projects.max', 'sigma.max_active_rules', 'storage.max_bytes', 'tracing.max_spans_monthly', 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..b37a89f6a --- /dev/null +++ b/packages/backend/src/tests/modules/projects/projects-max.test.ts @@ -0,0 +1,124 @@ +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); + }); +}); diff --git a/packages/frontend/src/routes/dashboard/settings/usage/+page.svelte b/packages/frontend/src/routes/dashboard/settings/usage/+page.svelte index 442760b5b..ffcd6ae35 100644 --- a/packages/frontend/src/routes/dashboard/settings/usage/+page.svelte +++ b/packages/frontend/src/routes/dashboard/settings/usage/+page.svelte @@ -190,6 +190,7 @@ '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' }, From de12373ff7a1d35892d66ca89c831511b833b9d4 Mon Sep 17 00:00:00 2001 From: Polliog Date: Sun, 18 Oct 2026 22:38:02 +0200 Subject: [PATCH 10/54] add email verification tables --- .../migrations/058_email_verification.sql | 28 +++++++++++++++++++ .../backend/src/database/tenant-tables.ts | 1 + packages/backend/src/database/types.ts | 12 ++++++++ .../src/tests/database/tenant-tables.test.ts | 2 +- 4 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 packages/backend/migrations/058_email_verification.sql 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/src/database/tenant-tables.ts b/packages/backend/src/database/tenant-tables.ts index d91d303f3..f82747909 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', ]); 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..a7bb72e58 100644 --- a/packages/backend/src/database/types.ts +++ b/packages/backend/src/database/types.ts @@ -97,6 +97,17 @@ 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; +} + +export interface EmailVerificationTokensTable { + id: Generated; + user_id: string; + token_hash: string; + expires_at: Timestamp; + used_at: Timestamp | null; + created_at: Generated; } export interface SessionsTable { @@ -1170,6 +1181,7 @@ export interface Database { users: UsersTable; sessions: SessionsTable; stream_tickets: StreamTicketsTable; + email_verification_tokens: EmailVerificationTokensTable; organizations: OrganizationsTable; organization_members: OrganizationMembersTable; organization_invitations: OrganizationInvitationsTable; diff --git a/packages/backend/src/tests/database/tenant-tables.test.ts b/packages/backend/src/tests/database/tenant-tables.test.ts index 658053481..98501a42f 100644 --- a/packages/backend/src/tests/database/tenant-tables.test.ts +++ b/packages/backend/src/tests/database/tenant-tables.test.ts @@ -18,7 +18,7 @@ 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', ]; describe('tenant-tables manifest', () => { From 1f4a7db7645224c7c9ccc666ce39e57bcf21401b Mon Sep 17 00:00:00 2001 From: Polliog Date: Sun, 18 Oct 2026 22:38:02 +0200 Subject: [PATCH 11/54] require verified email when enabled --- CHANGELOG.md | 2 + packages/backend/src/config/index.ts | 21 + packages/backend/src/lib/email-templates.ts | 42 ++ packages/backend/src/lib/mailer.ts | 24 + .../backend/src/modules/api-keys/routes.ts | 7 + .../backend/src/modules/audit-log/actions.ts | 1 + .../modules/auth/authentication-service.ts | 19 +- .../backend/src/modules/projects/routes.ts | 7 + .../backend/src/modules/settings/routes.ts | 3 + .../modules/users/email-verification-flag.ts | 10 + .../src/modules/users/email-verification.ts | 163 ++++++ packages/backend/src/modules/users/routes.ts | 152 ++++++ packages/backend/src/modules/users/service.ts | 4 + .../src/tests/lib/email-templates.test.ts | 16 + .../modules/users/email-verification.test.ts | 467 ++++++++++++++++++ 15 files changed, 936 insertions(+), 2 deletions(-) create mode 100644 packages/backend/src/lib/mailer.ts create mode 100644 packages/backend/src/modules/users/email-verification-flag.ts create mode 100644 packages/backend/src/modules/users/email-verification.ts create mode 100644 packages/backend/src/tests/modules/users/email-verification.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 60420c3b4..040d953b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Optional email verification**: `REQUIRE_EMAIL_VERIFICATION=true` (default false; any value other than true/false aborts startup) 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; until the link is used, `POST /api/v1/projects` and `POST /api/v1/projects/:id/api-keys` 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`, never 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`; 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. - **`projects.max` capability**: new per-org limit on active projects (soft-deleted ones do not count), default unlimited. Enforced on `POST /api/v1/projects` 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 and state per month from existing notifications; email failures never break the evaluator). diff --git a/packages/backend/src/config/index.ts b/packages/backend/src/config/index.ts index 5d4cab3de..aab5a5747 100644 --- a/packages/backend/src/config/index.ts +++ b/packages/backend/src/config/index.ts @@ -125,6 +125,27 @@ const configSchema = z.object({ .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), 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..82b502199 --- /dev/null +++ b/packages/backend/src/lib/mailer.ts @@ -0,0 +1,24 @@ +import nodemailer from 'nodemailer'; +import { config } from '../config/index.js'; + +let transporter: nodemailer.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(): nodemailer.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 nodemailer.TransportOptions); + } + return transporter; +} diff --git a/packages/backend/src/modules/api-keys/routes.ts b/packages/backend/src/modules/api-keys/routes.ts index 924b33119..489c2b1ff 100644 --- a/packages/backend/src/modules/api-keys/routes.ts +++ b/packages/backend/src/modules/api-keys/routes.ts @@ -6,6 +6,7 @@ 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 { assertWithinLimit, withLimitLock } from '../../capabilities/index.js'; import { CapabilityError } from '../../capabilities/errors.js'; @@ -64,6 +65,12 @@ 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' }); + } + // 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..f933365a9 100644 --- a/packages/backend/src/modules/audit-log/actions.ts +++ b/packages/backend/src/modules/audit-log/actions.ts @@ -28,6 +28,7 @@ export const AUDIT_ACTIONS = { 'receiver.deleted': 'config_change', // users and membership 'user.registered': 'user_management', + 'user.email_verified': '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..ccd50893b 100644 --- a/packages/backend/src/modules/auth/authentication-service.ts +++ b/packages/backend/src/modules/auth/authentication-service.ts @@ -10,7 +10,9 @@ */ import crypto from 'crypto'; +import { sql } from 'kysely'; import { db } from '../../database/connection.js'; +import { isEmailVerificationRequired } from '../users/email-verification-flag.js'; import { CacheManager } from '../../utils/cache.js'; import type { UserProfile, SessionInfo } from '../users/service.js'; import { settingsService } from '../settings/service.js'; @@ -248,6 +250,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') @@ -274,7 +284,7 @@ export class AuthenticationService { // Update user's last login await db .updateTable('users') - .set({ last_login: new Date() }) + .set(loginUpdate) .where('id', '=', existingIdentity.id) .execute(); @@ -328,7 +338,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(); @@ -382,6 +392,11 @@ 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. + ...(isEmailVerificationRequired() && provider.config.type === 'oidc' && result.emailVerified !== true + ? { email_verified_at: null } + : {}), }) .returning(['id', 'email', 'name', 'is_admin', 'disabled', 'created_at', 'last_login']) .executeTakeFirstOrThrow(); diff --git a/packages/backend/src/modules/projects/routes.ts b/packages/backend/src/modules/projects/routes.ts index 29005812f..9bd875e2d 100644 --- a/packages/backend/src/modules/projects/routes.ts +++ b/packages/backend/src/modules/projects/routes.ts @@ -8,6 +8,7 @@ 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'; const createProjectSchema = z.object({ organizationId: z.string().uuid('Invalid organization ID'), @@ -256,6 +257,12 @@ export async function projectsRoutes(fastify: FastifyInstance) { try { const body = createProjectSchema.parse(request.body); + // 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' }); + } + 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); diff --git a/packages/backend/src/modules/settings/routes.ts b/packages/backend/src/modules/settings/routes.ts index b22a88497..37e9adad1 100644 --- a/packages/backend/src/modules/settings/routes.ts +++ b/packages/backend/src/modules/settings/routes.ts @@ -10,6 +10,7 @@ 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 type { UserProfile } from '../users/service.js'; import { auditLogService } from '../audit-log/service.js'; @@ -245,6 +246,7 @@ export async function publicSettingsRoutes(fastify: FastifyInstance) { signupEnabled, requiresLogin: authMode !== 'none', ...(isUpgradeHandoffActive() ? { upgradeEnabled: true } : {}), + ...(isEmailVerificationRequired() ? { emailVerificationRequired: true } : {}), }); } catch (error) { console.error('Error fetching auth config:', error); @@ -253,6 +255,7 @@ export async function publicSettingsRoutes(fastify: FastifyInstance) { authMode: 'standard', signupEnabled: true, requiresLogin: true, + ...(isEmailVerificationRequired() ? { emailVerificationRequired: true } : {}), }); } }); 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..3824269b3 --- /dev/null +++ b/packages/backend/src/modules/users/email-verification.ts @@ -0,0 +1,163 @@ +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); + } + + /** Mark a user unverified (used when the email address changes while the gate is on). */ + async markUnverified(userId: string): Promise { + await db.updateTable('users').set({ email_verified_at: null }).where('id', '=', userId).execute(); + } +} + +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..ef7168951 100644 --- a/packages/backend/src/modules/users/routes.ts +++ b/packages/backend/src/modules/users/routes.ts @@ -6,6 +6,8 @@ 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 { settingsService } from '../settings/service.js'; import { bootstrapService } from '../bootstrap/service.js'; import { auditLogService } from '../audit-log/index.js'; @@ -28,13 +30,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 @@ -56,6 +109,16 @@ export async function usersRoutes(fastify: FastifyInstance) { const user = await usersService.createUser(body); + // Gate on: email the verification link. A send failure never fails the + // registration (the user can request a resend from the app). + if (isEmailVerificationRequired()) { + try { + await 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. // If this fails for a transient reason the user row is already committed, @@ -93,6 +156,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 +333,11 @@ 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; + return reply.send({ user: { id: user.id, @@ -277,6 +346,7 @@ export async function usersRoutes(fastify: FastifyInstance) { is_admin: user.is_admin, createdAt: user.createdAt, lastLogin: user.lastLogin, + ...(emailVerified !== undefined ? { emailVerified } : {}), }, }); }); @@ -311,6 +381,16 @@ export async function usersRoutes(fastify: FastifyInstance) { const updatedUser = await usersService.updateUser(currentUser.id, body); + // Gate on: a changed address must be confirmed again. + if (isEmailVerificationRequired() && updatedUser.email !== currentUser.email) { + await emailVerificationService.markUnverified(updatedUser.id); + 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 +486,76 @@ export async function usersRoutes(fastify: FastifyInstance) { throw error; } }); + // 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; + + try { + if (sessionUser) { + await emailVerificationService.resendForUser(sessionUser.id); + } else { + const parsed = resendVerificationSchema.safeParse(request.body ?? {}); + if (parsed.success && parsed.data.email) { + await emailVerificationService.resendForEmail(parsed.data.email); + } + } + } 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..8825ebcab 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; @@ -127,6 +128,9 @@ 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 } : {}), }) .returning(['id', 'email', 'name', 'is_admin', 'disabled', 'created_at', 'last_login']) .executeTakeFirstOrThrow(); 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/users/email-verification.test.ts b/packages/backend/src/tests/modules/users/email-verification.test.ts new file mode 100644 index 000000000..963ab4e4e --- /dev/null +++ b/packages/backend/src/tests/modules/users/email-verification.test.ts @@ -0,0 +1,467 @@ +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 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' }); + expect(on.success && on.data.REQUIRE_EMAIL_VERIFICATION).toBe(true); + expect(parse({ REQUIRE_EMAIL_VERIFICATION: 'yes' }).success).toBe(false); + }); + + 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('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(); + }); + + 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}` }; + + expect((await app.inject({ method: 'POST', url: '/api/v1/auth/resend-verification', headers })).statusCode).toBe(202); + const first = await db.selectFrom('email_verification_tokens').selectAll().where('user_id', '=', user.id).execute(); + 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); + const same = await db.selectFrom('email_verification_tokens').selectAll().where('user_id', '=', user.id).execute(); + 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 db.selectFrom('email_verification_tokens').selectAll().where('user_id', '=', user.id).execute(); + 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('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('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); + } + }); +}); From 862f882a210229fbfda06223e69089edb470df83 Mon Sep 17 00:00:00 2001 From: Polliog Date: Sun, 18 Oct 2026 22:38:03 +0200 Subject: [PATCH 12/54] verify email banner and page --- packages/frontend/src/lib/api/api-keys.ts | 3 +- packages/frontend/src/lib/api/auth.ts | 38 +++++++++ packages/frontend/src/lib/api/projects.ts | 3 +- .../src/lib/components/AppLayout.svelte | 2 + .../components/EmailVerificationBanner.svelte | 60 +++++++++++++ .../EmailVerificationBanner.test.ts | 71 ++++++++++++++++ .../src/lib/utils/email-verification.test.ts | 33 +++++++ .../src/lib/utils/email-verification.ts | 24 ++++++ .../src/routes/onboarding/+page.svelte | 2 + .../src/routes/verify-email/+page.svelte | 85 +++++++++++++++++++ .../routes/verify-email/verify-email.test.ts | 55 ++++++++++++ packages/frontend/src/test-stubs/app-state.ts | 5 ++ packages/frontend/vitest.config.ts | 1 + 13 files changed, 380 insertions(+), 2 deletions(-) create mode 100644 packages/frontend/src/lib/components/EmailVerificationBanner.svelte create mode 100644 packages/frontend/src/lib/components/EmailVerificationBanner.test.ts create mode 100644 packages/frontend/src/lib/utils/email-verification.test.ts create mode 100644 packages/frontend/src/lib/utils/email-verification.ts create mode 100644 packages/frontend/src/routes/verify-email/+page.svelte create mode 100644 packages/frontend/src/routes/verify-email/verify-email.test.ts create mode 100644 packages/frontend/src/test-stubs/app-state.ts 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 0bdd2a595..20cb06898 100644 --- a/packages/frontend/src/lib/api/auth.ts +++ b/packages/frontend/src/lib/api/auth.ts @@ -59,6 +59,8 @@ export interface AuthConfig { 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; } export class AuthAPI { @@ -250,6 +252,42 @@ 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' + ); + } + } + async getAuthConfig(): Promise { const response = await fetch(`${getApiBaseUrl()}/auth/config`); 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/components/AppLayout.svelte b/packages/frontend/src/lib/components/AppLayout.svelte index 8fbf43b02..02efdb4a4 100644 --- a/packages/frontend/src/lib/components/AppLayout.svelte +++ b/packages/frontend/src/lib/components/AppLayout.svelte @@ -50,6 +50,7 @@ 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 OnboardingChecklist from "$lib/components/OnboardingChecklist.svelte"; import FeatureBadge from "$lib/components/FeatureBadge.svelte"; import ThemeToggle from "$lib/components/ThemeToggle.svelte"; @@ -826,6 +827,7 @@
+ {@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} + +
+{/if} diff --git a/packages/frontend/src/lib/components/EmailVerificationBanner.test.ts b/packages/frontend/src/lib/components/EmailVerificationBanner.test.ts new file mode 100644 index 000000000..74d7633fc --- /dev/null +++ b/packages/frontend/src/lib/components/EmailVerificationBanner.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, fireEvent } from '@testing-library/svelte'; + +const { getAuthConfig, getMe, resendVerification } = vi.hoisted(() => ({ + getAuthConfig: vi.fn(), + getMe: vi.fn(), + resendVerification: vi.fn(), +})); + +vi.mock('$lib/api/auth', () => ({ + authAPI: { getAuthConfig, getMe, resendVerification }, +})); + +import EmailVerificationBanner from './EmailVerificationBanner.svelte'; +import { authStore } from '$lib/stores/auth'; + +describe('EmailVerificationBanner', () => { + beforeEach(() => { + localStorage.clear(); + getAuthConfig.mockReset(); + getMe.mockReset(); + resendVerification.mockReset(); + authStore.setAuth({ id: 'u1', email: 'a@b.c', name: 'A' }, 'session-1'); + }); + + it('shows the banner for an unverified user when the gate is on', async () => { + getAuthConfig.mockResolvedValue({ authMode: 'standard', signupEnabled: true, emailVerificationRequired: true }); + getMe.mockResolvedValue({ user: { id: 'u1', emailVerified: false } }); + render(EmailVerificationBanner); + await waitFor(() => expect(screen.getByText('Verify your email')).toBeTruthy()); + expect(getMe).toHaveBeenCalledWith('session-1'); + }); + + it('renders nothing and skips /me when the gate is off', async () => { + getAuthConfig.mockResolvedValue({ authMode: 'standard', signupEnabled: true }); + const { container } = render(EmailVerificationBanner); + await waitFor(() => expect(getAuthConfig).toHaveBeenCalled()); + await Promise.resolve(); + expect(getMe).not.toHaveBeenCalled(); + expect(container.textContent?.trim()).toBe(''); + }); + + it('renders nothing for a verified user', async () => { + getAuthConfig.mockResolvedValue({ authMode: 'standard', signupEnabled: true, emailVerificationRequired: true }); + getMe.mockResolvedValue({ user: { id: 'u1', emailVerified: true } }); + const { container } = render(EmailVerificationBanner); + await waitFor(() => expect(getMe).toHaveBeenCalled()); + await Promise.resolve(); + expect(container.textContent?.trim()).toBe(''); + }); + + it('resend button asks for a new email and confirms', async () => { + getAuthConfig.mockResolvedValue({ authMode: 'standard', signupEnabled: true, emailVerificationRequired: true }); + getMe.mockResolvedValue({ user: { id: 'u1', emailVerified: false } }); + resendVerification.mockResolvedValue(undefined); + render(EmailVerificationBanner); + const button = await screen.findByRole('button', { name: 'Resend email' }); + await fireEvent.click(button); + expect(resendVerification).toHaveBeenCalledWith('session-1'); + await waitFor(() => expect(screen.getByText('Verification email sent. Check your inbox.')).toBeTruthy()); + }); + + it('shows the error when resend fails', async () => { + getAuthConfig.mockResolvedValue({ authMode: 'standard', signupEnabled: true, emailVerificationRequired: true }); + getMe.mockResolvedValue({ user: { id: 'u1', emailVerified: false } }); + resendVerification.mockRejectedValue(new Error('Too many requests. Please wait a while before asking for another email.')); + render(EmailVerificationBanner); + await fireEvent.click(await screen.findByRole('button', { name: 'Resend email' })); + await waitFor(() => expect(screen.getByText(/Too many requests/)).toBeTruthy()); + }); +}); 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..1f25486ac --- /dev/null +++ b/packages/frontend/src/lib/utils/email-verification.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { + shouldShowVerifyBanner, + apiErrorMessage, + EMAIL_NOT_VERIFIED_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('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..b102dc7da --- /dev/null +++ b/packages/frontend/src/lib/utils/email-verification.ts @@ -0,0 +1,24 @@ +/** 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; +} + +/** 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; + return error || fallback; +} diff --git a/packages/frontend/src/routes/onboarding/+page.svelte b/packages/frontend/src/routes/onboarding/+page.svelte index d14a635ab..b12b1c5fb 100644 --- a/packages/frontend/src/routes/onboarding/+page.svelte +++ b/packages/frontend/src/routes/onboarding/+page.svelte @@ -15,6 +15,7 @@ FeatureTourStep } from '$lib/components/onboarding'; import Footer from '$lib/components/Footer.svelte'; + import EmailVerificationBanner from '$lib/components/EmailVerificationBanner.svelte'; let currentStep = $state('welcome'); let userName = $state(''); @@ -82,6 +83,7 @@ {#if isAuthenticated && !checkingOrgs} + {#if currentStep === 'welcome'} diff --git a/packages/frontend/src/routes/verify-email/+page.svelte b/packages/frontend/src/routes/verify-email/+page.svelte new file mode 100644 index 000000000..af3a14104 --- /dev/null +++ b/packages/frontend/src/routes/verify-email/+page.svelte @@ -0,0 +1,85 @@ + + + + Verify email - LogTide + + +
+ + +
+ {#if status === 'working'} + + {:else if status === 'done'} + + {:else} + + {/if} +
+ + {#if status === 'working'} + Verifying your email... + {:else if status === 'done'} + Email verified + {:else} + Verification failed + {/if} + + + {#if status === 'working'} + One moment while we confirm your address. + {:else if status === 'done'} + Thanks, your email address is confirmed. You can now create projects and API keys. + {:else} + {errorMessage} + {/if} + +
+ + {#if status === 'done'} + + {:else if status === 'error'} + {#if signedIn} +

Use "Resend email" in the banner on your dashboard to get a new link.

+ + {:else} +

Sign in to request a new verification link.

+ + {/if} + {/if} +
+
+
diff --git a/packages/frontend/src/routes/verify-email/verify-email.test.ts b/packages/frontend/src/routes/verify-email/verify-email.test.ts new file mode 100644 index 000000000..37a6c5dff --- /dev/null +++ b/packages/frontend/src/routes/verify-email/verify-email.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/svelte'; + +const { verifyEmail } = vi.hoisted(() => ({ verifyEmail: vi.fn() })); + +vi.mock('$lib/api/auth', () => ({ + authAPI: { verifyEmail }, +})); + +import VerifyEmailPage from './+page.svelte'; +// Same module the alias resolves $app/state to, typed loosely for tests. +import { page } from '../../test-stubs/app-state'; +import { authStore } from '$lib/stores/auth'; + +describe('/verify-email page', () => { + beforeEach(() => { + localStorage.clear(); + verifyEmail.mockReset(); + authStore.clearAuth(); + }); + + it('consumes the token from the url and confirms', async () => { + page.url = new URL('http://localhost/verify-email?token=abc123'); + verifyEmail.mockResolvedValue(undefined); + render(VerifyEmailPage); + await waitFor(() => expect(screen.getByText('Email verified')).toBeTruthy()); + expect(verifyEmail).toHaveBeenCalledWith('abc123', null); + expect(screen.getByRole('link', { name: 'Sign in' })).toBeTruthy(); + }); + + it('sends the session when signed in and links to the dashboard', async () => { + authStore.setAuth({ id: 'u1', email: 'a@b.c', name: 'A' }, 'session-1'); + page.url = new URL('http://localhost/verify-email?token=abc123'); + verifyEmail.mockResolvedValue(undefined); + render(VerifyEmailPage); + await waitFor(() => expect(screen.getByText('Email verified')).toBeTruthy()); + expect(verifyEmail).toHaveBeenCalledWith('abc123', 'session-1'); + expect(screen.getByRole('link', { name: 'Go to dashboard' })).toBeTruthy(); + }); + + it('shows the failure for an invalid or used token', async () => { + page.url = new URL('http://localhost/verify-email?token=used'); + verifyEmail.mockRejectedValue(new Error('This verification link is invalid, expired or was already used.')); + render(VerifyEmailPage); + await waitFor(() => expect(screen.getByText('Verification failed')).toBeTruthy()); + expect(screen.getByText('This verification link is invalid, expired or was already used.')).toBeTruthy(); + }); + + it('reports a missing token without calling the api', async () => { + page.url = new URL('http://localhost/verify-email'); + render(VerifyEmailPage); + await waitFor(() => expect(screen.getByText('This verification link is missing its token.')).toBeTruthy()); + expect(verifyEmail).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/frontend/src/test-stubs/app-state.ts b/packages/frontend/src/test-stubs/app-state.ts new file mode 100644 index 000000000..eb8097565 --- /dev/null +++ b/packages/frontend/src/test-stubs/app-state.ts @@ -0,0 +1,5 @@ +// Minimal stand-in for SvelteKit's $app/state under plain vitest. Tests set +// page.url to drive components that read query parameters. +export const page: { url: URL } = { + url: new URL('http://localhost/'), +}; diff --git a/packages/frontend/vitest.config.ts b/packages/frontend/vitest.config.ts index 0f53d991e..c800390c7 100644 --- a/packages/frontend/vitest.config.ts +++ b/packages/frontend/vitest.config.ts @@ -28,6 +28,7 @@ export default defineConfig({ // plain vitest; point it at a stub so store/component tests can import it. '$app/environment': path.resolve(__dirname, 'src/test-stubs/app-environment.ts'), '$app/navigation': path.resolve(__dirname, 'src/test-stubs/app-navigation.ts'), + '$app/state': path.resolve(__dirname, 'src/test-stubs/app-state.ts'), }, }, }); From 1ad88c141e3db537100170390f1b741b25cef648 Mon Sep 17 00:00:00 2001 From: Polliog Date: Sun, 18 Oct 2026 22:45:54 +0200 Subject: [PATCH 13/54] require smtp for email verification and share mailer --- CHANGELOG.md | 2 +- .../src/capabilities/quota-notifier.ts | 21 +------- packages/backend/src/config/index.ts | 8 +++ .../modules/auth/authentication-service.ts | 7 ++- .../src/modules/users/email-verification.ts | 4 -- packages/backend/src/modules/users/routes.ts | 28 +++++----- packages/backend/src/modules/users/service.ts | 4 ++ .../modules/users/email-verification.test.ts | 53 +++++++++++++++++-- 8 files changed, 84 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 040d953b7..9bbdc42f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **Optional email verification**: `REQUIRE_EMAIL_VERIFICATION=true` (default false; any value other than true/false aborts startup) 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; until the link is used, `POST /api/v1/projects` and `POST /api/v1/projects/:id/api-keys` 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`, never 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`; 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`. +- **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; until the link is used, `POST /api/v1/projects` and `POST /api/v1/projects/:id/api-keys` 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; 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. - **`projects.max` capability**: new per-org limit on active projects (soft-deleted ones do not count), default unlimited. Enforced on `POST /api/v1/projects` under the per-org limit lock, settable through entitlements, the admin API and `CAPABILITY_DEFAULTS_JSON`, and shown as "Projects" on the Usage page. diff --git a/packages/backend/src/capabilities/quota-notifier.ts b/packages/backend/src/capabilities/quota-notifier.ts index 3f7f64ee5..0351aec0f 100644 --- a/packages/backend/src/capabilities/quota-notifier.ts +++ b/packages/backend/src/capabilities/quota-notifier.ts @@ -1,7 +1,7 @@ -import nodemailer from 'nodemailer'; import { sql } from 'kysely'; import { db } from '../database/connection.js'; import { config } from '../config/index.js'; +import { getMailer } from '../lib/mailer.js'; import { notificationsService } from '../modules/notifications/service.js'; import { hub } from '@logtide/core'; import { isInternalLoggingEnabled } from '../utils/internal-logger.js'; @@ -32,23 +32,6 @@ function startOfUtcMonth(now: Date): Date { return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); } -let transporter: nodemailer.Transporter | null = null; -function getTransporter(): nodemailer.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 nodemailer.TransportOptions); - } - return transporter; -} - /** * Sends the in-app notification + email for a quota threshold, at most once per * (org, capability, state, UTC month). Dedup reads the notifications already @@ -121,7 +104,7 @@ export async function notifyQuotaThreshold(params: { }); } - const mailer = getTransporter(); + const mailer = getMailer(); if (!mailer) return; try { await mailer.sendMail({ diff --git a/packages/backend/src/config/index.ts b/packages/backend/src/config/index.ts index aab5a5747..0c9abd259 100644 --- a/packages/backend/src/config/index.ts +++ b/packages/backend/src/config/index.ts @@ -183,6 +183,14 @@ const configSchema = z.object({ message, }); } + 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; diff --git a/packages/backend/src/modules/auth/authentication-service.ts b/packages/backend/src/modules/auth/authentication-service.ts index ccd50893b..18241e382 100644 --- a/packages/backend/src/modules/auth/authentication-service.ts +++ b/packages/backend/src/modules/auth/authentication-service.ts @@ -281,10 +281,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(loginUpdate) + .set(sameEmail ? loginUpdate : { last_login: new Date() }) .where('id', '=', existingIdentity.id) .execute(); diff --git a/packages/backend/src/modules/users/email-verification.ts b/packages/backend/src/modules/users/email-verification.ts index 3824269b3..00f2aeafe 100644 --- a/packages/backend/src/modules/users/email-verification.ts +++ b/packages/backend/src/modules/users/email-verification.ts @@ -154,10 +154,6 @@ export class EmailVerificationService { if (user) await this.resendForUser(user.id); } - /** Mark a user unverified (used when the email address changes while the gate is on). */ - async markUnverified(userId: string): Promise { - await db.updateTable('users').set({ email_verified_at: null }).where('id', '=', userId).execute(); - } } export const emailVerificationService = new EmailVerificationService(); diff --git a/packages/backend/src/modules/users/routes.ts b/packages/backend/src/modules/users/routes.ts index ef7168951..5688096db 100644 --- a/packages/backend/src/modules/users/routes.ts +++ b/packages/backend/src/modules/users/routes.ts @@ -381,9 +381,9 @@ export async function usersRoutes(fastify: FastifyInstance) { const updatedUser = await usersService.updateUser(currentUser.id, body); - // Gate on: a changed address must be confirmed again. + // 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) { - await emailVerificationService.markUnverified(updatedUser.id); try { await emailVerificationService.issueAndSend(updatedUser); } catch (verificationError) { @@ -539,17 +539,21 @@ export async function usersRoutes(fastify: FastifyInstance) { const bearer = request.headers.authorization?.replace('Bearer ', ''); const sessionUser = bearer ? await usersService.validateSession(bearer) : null; - try { - if (sessionUser) { - await emailVerificationService.resendForUser(sessionUser.id); - } else { - const parsed = resendVerificationSchema.safeParse(request.body ?? {}); - if (parsed.success && parsed.data.email) { - await emailVerificationService.resendForEmail(parsed.data.email); - } + // 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); } - } catch (error) { - logVerificationError('[Auth] Resend verification failed', sessionUser?.id ?? null, error); + } + if (work) { + void work.catch((error) => + logVerificationError('[Auth] Resend verification failed', sessionUser?.id ?? null, error) + ); } return reply.status(202).send({ diff --git a/packages/backend/src/modules/users/service.ts b/packages/backend/src/modules/users/service.ts index 8825ebcab..61e00c120 100644 --- a/packages/backend/src/modules/users/service.ts +++ b/packages/backend/src/modules/users/service.ts @@ -337,6 +337,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/tests/modules/users/email-verification.test.ts b/packages/backend/src/tests/modules/users/email-verification.test.ts index 963ab4e4e..eb83249a0 100644 --- a/packages/backend/src/tests/modules/users/email-verification.test.ts +++ b/packages/backend/src/tests/modules/users/email-verification.test.ts @@ -74,6 +74,15 @@ async function tokenFromMailhog(email: string): Promise { 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 @@ -114,9 +123,20 @@ describe('email verification env validation', () => { }); it('REQUIRE_EMAIL_VERIFICATION accepts true/false and rejects anything else', () => { - const on = parse({ REQUIRE_EMAIL_VERIFICATION: 'true' }); + 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' }).success).toBe(false); + 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', () => { @@ -331,6 +351,8 @@ describe('email verification gate ON', () => { 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 () => { @@ -338,13 +360,16 @@ describe('email verification gate ON', () => { 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 db.selectFrom('email_verification_tokens').selectAll().where('user_id', '=', user.id).execute(); + 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); - const same = await db.selectFrom('email_verification_tokens').selectAll().where('user_id', '=', user.id).execute(); + 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 @@ -354,7 +379,7 @@ describe('email verification gate ON', () => { .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 db.selectFrom('email_verification_tokens').selectAll().where('user_id', '=', user.id).execute(); + 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); }); @@ -417,6 +442,24 @@ describe('OIDC email verification', () => { 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); From 643ac2d64ddb462aa4f18878138876df68263383 Mon Sep 17 00:00:00 2001 From: Polliog Date: Sun, 18 Oct 2026 22:48:35 +0200 Subject: [PATCH 14/54] meter ingested metric datapoints --- CHANGELOG.md | 1 + .../backend/src/modules/metrics/service.ts | 12 ++++++++ .../src/tests/modules/metrics/service.test.ts | 29 +++++++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bbdc42f8..b9f0f25f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **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; until the link is used, `POST /api/v1/projects` and `POST /api/v1/projects/:id/api-keys` 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; 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. diff --git a/packages/backend/src/modules/metrics/service.ts b/packages/backend/src/modules/metrics/service.ts index 8986a8268..b7b47553f 100644 --- a/packages/backend/src/modules/metrics/service.ts +++ b/packages/backend/src/modules/metrics/service.ts @@ -44,6 +44,18 @@ 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 && result.ingested > 0) { + metering.record({ + type: 'metrics.ingested.datapoints', + quantity: result.ingested, + organizationId, + projectId, + }); + } + // Mark the project as having metrics (debounced, fire-and-forget) projectsService.markHasData(projectId, 'metrics').catch(() => {}); diff --git a/packages/backend/src/tests/modules/metrics/service.test.ts b/packages/backend/src/tests/modules/metrics/service.test.ts index 6ded75457..83f2459ae 100644 --- a/packages/backend/src/tests/modules/metrics/service.test.ts +++ b/packages/backend/src/tests/modules/metrics/service.test.ts @@ -109,6 +109,35 @@ 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'); + const call = mockMeteringRecord.mock.calls.find(([e]) => e.type === 'metrics.ingested.datapoints'); + expect(call![0]).toEqual({ + type: 'metrics.ingested.datapoints', quantity: 3, organizationId: 'org-1', projectId: 'proj-1', + }); + }); + + 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'); + const call = mockMeteringRecord.mock.calls.find(([e]) => e.type === 'metrics.ingested.datapoints'); + expect(call![0].quantity).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'); + expect(mockMeteringRecord.mock.calls.some(([e]) => e.type === 'metrics.ingested.datapoints')).toBe(false); + }); + }); + describe('clock skew detection (span-metric-skew)', () => { it('counts a metric with a past-skewed time', async () => { mockIngestMetrics.mockResolvedValueOnce({ ingested: 1 }); From dd625776e51c30a640e4c77b71a84431504d698d Mon Sep 17 00:00:00 2001 From: Polliog Date: Sun, 18 Oct 2026 23:22:46 +0200 Subject: [PATCH 15/54] add metrics datapoint quota --- CHANGELOG.md | 1 + packages/backend/src/capabilities/registry.ts | 7 + .../backend/src/modules/otlp/metric-routes.ts | 33 +++- .../src/tests/capabilities/registry.test.ts | 4 + .../capabilities/ingestion-quota.test.ts | 151 ++++++++++++++++++ .../modules/metering/capability-usage.test.ts | 1 + .../dashboard/settings/usage/+page.svelte | 1 + 7 files changed, 197 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9f0f25f4..a06dea903 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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; until the link is used, `POST /api/v1/projects` and `POST /api/v1/projects/:id/api-keys` 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; 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`. diff --git a/packages/backend/src/capabilities/registry.ts b/packages/backend/src/capabilities/registry.ts index 53848029a..feb29467d 100644 --- a/packages/backend/src/capabilities/registry.ts +++ b/packages/backend/src/capabilities/registry.ts @@ -144,6 +144,13 @@ 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)', + }, } as const satisfies Record; export type CapabilityName = keyof typeof CAPABILITIES; diff --git a/packages/backend/src/modules/otlp/metric-routes.ts b/packages/backend/src/modules/otlp/metric-routes.ts index 88fff42cb..a4edf9683 100644 --- a/packages/backend/src/modules/otlp/metric-routes.ts +++ b/packages/backend/src/modules/otlp/metric-routes.ts @@ -16,6 +16,8 @@ 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'; const collectStreamToBuffer = (stream: NodeJS.ReadableStream): Promise => new Promise((resolve, reject) => { @@ -97,6 +99,18 @@ const otlpMetricRoutes: FastifyPluginAsync = async (fastify) => { }, }, }, + 429: { + type: 'object', + properties: { + partialSuccess: { + type: 'object', + properties: { + rejectedDataPoints: { type: 'number' }, + errorMessage: { type: 'string' }, + }, + }, + }, + }, 400: { type: 'object', properties: { @@ -181,7 +195,14 @@ const otlpMetricRoutes: FastifyPluginAsync = async (fastify) => { }; } - await metricsService.ingestMetrics(records, projectId, project.organization_id); + // Capability: hard-block metrics ingestion when over the datapoint quota. + // Blocks metrics only; logs and spans have their own quotas. + await context.runAsSystem('otlp:metric-ingest', async () => { + await context.with({ organizationId: project.organization_id }, async () => { + await assertWithinUsageQuota('metrics.max_datapoints_monthly'); + await metricsService.ingestMetrics(records, projectId, project.organization_id); + }); + }); return { partialSuccess: { @@ -191,6 +212,16 @@ 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, + }, + }); + } + console.error('[OTLP Metrics] Ingestion error:', errorMessage); return reply.code(400).send({ diff --git a/packages/backend/src/tests/capabilities/registry.test.ts b/packages/backend/src/tests/capabilities/registry.test.ts index b4fb7ca86..7acb5ed3f 100644 --- a/packages/backend/src/tests/capabilities/registry.test.ts +++ b/packages/backend/src/tests/capabilities/registry.test.ts @@ -53,6 +53,7 @@ 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'); }); it('defaults every quota to null (unlimited) so OSS never blocks', () => { @@ -61,6 +62,7 @@ describe('capability registry', () => { 'ingestion.max_events_monthly', 'storage.max_bytes', 'tracing.max_spans_monthly', + 'metrics.max_datapoints_monthly', ]; for (const q of quotas) { const def = CAPABILITIES[q]; @@ -82,5 +84,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/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 e707deff6..8b3ba3c19 100644 --- a/packages/backend/src/tests/modules/metering/capability-usage.test.ts +++ b/packages/backend/src/tests/modules/metering/capability-usage.test.ts @@ -29,6 +29,7 @@ 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', diff --git a/packages/frontend/src/routes/dashboard/settings/usage/+page.svelte b/packages/frontend/src/routes/dashboard/settings/usage/+page.svelte index ffcd6ae35..56864ec86 100644 --- a/packages/frontend/src/routes/dashboard/settings/usage/+page.svelte +++ b/packages/frontend/src/routes/dashboard/settings/usage/+page.svelte @@ -197,6 +197,7 @@ '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' }, }; function capLabel(c: CapabilityUsage): string { From 1c38a74df1a0311d8c38b16088b8729460e4a09e Mon Sep 17 00:00:00 2001 From: Polliog Date: Sun, 18 Oct 2026 23:28:22 +0200 Subject: [PATCH 16/54] type metric datapoints and add record helper --- .../backend/src/modules/metering/index.ts | 18 ++++++++ .../backend/src/modules/metering/types.ts | 4 +- .../backend/src/modules/metrics/service.ts | 11 ++--- .../tests/capabilities/quota-grace.test.ts | 5 ++ .../modules/metering/record-spans.test.ts | 46 ++++++++++++++++++- .../src/tests/modules/metrics/service.test.ts | 13 +++--- 6 files changed, 81 insertions(+), 16 deletions(-) diff --git a/packages/backend/src/modules/metering/index.ts b/packages/backend/src/modules/metering/index.ts index 4fb8df051..a565884e5 100644 --- a/packages/backend/src/modules/metering/index.ts +++ b/packages/backend/src/modules/metering/index.ts @@ -54,6 +54,24 @@ export function recordSpanIngestion(params: { }); } +/** + * Recording site helper for metric ingestion. Fire-and-forget. + * Count of accepted datapoints; billed, so not under the ingestion.* prefix. + */ +export function recordMetricIngestion(params: { + datapointCount: number; + organizationId: string; + projectId: string; +}): void { + if (params.datapointCount <= 0) return; + metering.record({ + type: 'metrics.ingested.datapoints', + quantity: params.datapointCount, + organizationId: params.organizationId, + projectId: params.projectId, + }); +} + export { MeteringRecorder } from './recorder.js'; export { meteringService, MeteringService } from './service.js'; export { getUsageBreakdown } from './breakdown.js'; diff --git a/packages/backend/src/modules/metering/types.ts b/packages/backend/src/modules/metering/types.ts index c5a24f765..18660afdd 100644 --- a/packages/backend/src/modules/metering/types.ts +++ b/packages/backend/src/modules/metering/types.ts @@ -2,12 +2,14 @@ * 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` and + * `metrics.ingested.datapoints` (billed, so no `ingestion.` prefix). */ export type MeteringEventType = | 'logs.ingested.bytes' | 'logs.ingested.events' | 'spans.ingested' + | 'metrics.ingested.datapoints' | '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 b7b47553f..098ee849d 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, @@ -47,13 +47,8 @@ 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 && result.ingested > 0) { - metering.record({ - type: 'metrics.ingested.datapoints', - quantity: result.ingested, - organizationId, - projectId, - }); + if (organizationId) { + recordMetricIngestion({ datapointCount: result.ingested, organizationId, projectId }); } // Mark the project as having metrics (debounced, fire-and-forget) diff --git a/packages/backend/src/tests/capabilities/quota-grace.test.ts b/packages/backend/src/tests/capabilities/quota-grace.test.ts index 3a7a481da..966cadf07 100644 --- a/packages/backend/src/tests/capabilities/quota-grace.test.ts +++ b/packages/backend/src/tests/capabilities/quota-grace.test.ts @@ -57,6 +57,11 @@ describe('formatQuotaValue', () => { 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', () => { 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..0566ea3d9 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,46 @@ 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); + }); +}); diff --git a/packages/backend/src/tests/modules/metrics/service.test.ts b/packages/backend/src/tests/modules/metrics/service.test.ts index 83f2459ae..75bdc5130 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'; @@ -118,23 +120,22 @@ describe('MetricsService', () => { it('records the accepted datapoint count under metrics.ingested.datapoints', async () => { mockIngestMetrics.mockResolvedValueOnce({ ingested: 3 }); await service.ingestMetrics(rec(3), 'proj-1', 'org-1'); - const call = mockMeteringRecord.mock.calls.find(([e]) => e.type === 'metrics.ingested.datapoints'); - expect(call![0]).toEqual({ - type: 'metrics.ingested.datapoints', quantity: 3, organizationId: 'org-1', projectId: 'proj-1', + expect(mockRecordMetric).toHaveBeenCalledWith({ + datapointCount: 3, organizationId: 'org-1', projectId: 'proj-1', }); }); 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'); - const call = mockMeteringRecord.mock.calls.find(([e]) => e.type === 'metrics.ingested.datapoints'); - expect(call![0].quantity).toBe(2); + 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'); - expect(mockMeteringRecord.mock.calls.some(([e]) => e.type === 'metrics.ingested.datapoints')).toBe(false); + // The service passes the accepted count (0); the helper's <= 0 guard drops it. + expect(mockRecordMetric.mock.calls[0][0].datapointCount).toBe(0); }); }); From 477989e2c3ef7842a562a0960297866bde69cd8f Mon Sep 17 00:00:00 2001 From: Polliog Date: Sun, 18 Oct 2026 23:31:31 +0200 Subject: [PATCH 17/54] document contributor license agreement --- .github/pull_request_template.md | 4 ++++ CONTRIBUTING.md | 8 ++++++++ README.md | 2 ++ 3 files changed, 14 insertions(+) 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/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..f8032fa03 100644 --- a/README.md +++ b/README.md @@ -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`. + ---
From 8dae26fb67bcc03f8d0921570c6651f98ef5ebbf Mon Sep 17 00:00:00 2001 From: Polliog Date: Sun, 18 Oct 2026 23:31:31 +0200 Subject: [PATCH 18/54] document hosting seams --- CHANGELOG.md | 2 + docker/.env.example | 25 ++++++ docs/cloud-seams.md | 141 ++++++++++++++++++++++++++++++++ docs/examples/tsconfig.json | 14 ++++ docs/examples/verify-handoff.ts | 57 +++++++++++++ 5 files changed, 239 insertions(+) create mode 100644 docs/cloud-seams.md create mode 100644 docs/examples/tsconfig.json create mode 100644 docs/examples/verify-handoff.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a06dea903..c4abdf594 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **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`. `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. diff --git a/docker/.env.example b/docker/.env.example index f391dfb1c..890c12f91 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -114,3 +114,28 @@ DB_USER=logtide # 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 + +# 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 diff --git a/docs/cloud-seams.md b/docs/cloud-seams.md new file mode 100644 index 000000000..a89e5f9fa --- /dev/null +++ b/docs/cloud-seams.md @@ -0,0 +1,141 @@ +# 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 | +| `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 | + +An empty string is treated the same as unset for all of them. + +### 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`. + +### 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. + +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. + +### 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` and `POST /api/v1/projects/:id/api-keys` 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 make sure client IPs are resolved correctly, otherwise every request shares one IP. + +## Capabilities added for hosting + +All three 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`, answers `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 | + +Metric datapoints are now metered as the `metrics.ingested.datapoints` usage type (only datapoints that were actually stored). Like spans, this follows `METERING_ENABLED`. + +## 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 and state is notified at most once per month. +- The Usage page shows "In grace" and "Blocked" badges. The capability usage API returns a `state` for quota rows. 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; +} From a8984665557911d7ccc12af3690c66e193948102 Mon Sep 17 00:00:00 2001 From: Polliog Date: Mon, 19 Oct 2026 00:06:17 +0200 Subject: [PATCH 19/54] let service token pass the auth plugin --- .../backend/src/modules/auth/middleware.ts | 2 +- packages/backend/src/modules/auth/plugin.ts | 9 ++++++ .../tests/modules/admin/service-token.test.ts | 30 +++++++++---------- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/packages/backend/src/modules/auth/middleware.ts b/packages/backend/src/modules/auth/middleware.ts index c6714a8e5..3bbf8274d 100644 --- a/packages/backend/src/modules/auth/middleware.ts +++ b/packages/backend/src/modules/auth/middleware.ts @@ -31,7 +31,7 @@ export interface ServicePrincipal { export const SERVICE_ACTOR_LABEL = 'service:admin-service'; -function matchesServiceToken(token: string): boolean { +export function matchesServiceToken(token: string): boolean { const expected = config.LOGTIDE_ADMIN_SERVICE_TOKEN; if (!expected) return false; const a = Buffer.from(token); 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/tests/modules/admin/service-token.test.ts b/packages/backend/src/tests/modules/admin/service-token.test.ts index 956670ee1..3e4f915d7 100644 --- a/packages/backend/src/tests/modules/admin/service-token.test.ts +++ b/packages/backend/src/tests/modules/admin/service-token.test.ts @@ -1,12 +1,8 @@ import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; -import Fastify, { type FastifyInstance } from 'fastify'; +import type { FastifyInstance } from 'fastify'; import { db } from '../../../database/index.js'; import { config, configSchema } from '../../../config/index.js'; -import { authenticate } from '../../../modules/auth/middleware.js'; -import { retentionRoutes } from '../../../modules/retention/index.js'; -import { adminEntitlementsRoutes } from '../../../modules/capabilities/index.js'; -import { adminRoutes } from '../../../modules/admin/routes.js'; -import { contextPlugin } from '../../../context/index.js'; +import { build } from '../../../server.js'; import { createTestOrganization } from '../../helpers/factories.js'; const TOKEN = 's'.repeat(40); @@ -38,12 +34,9 @@ describe('admin service token', () => { let orgId: string; beforeAll(async () => { - app = Fastify(); - await app.register(contextPlugin); - await app.register(retentionRoutes, { prefix: '/api/v1/admin' }); - await app.register(adminEntitlementsRoutes, { prefix: '/api/v1/admin' }); - await app.register(adminRoutes, { prefix: '/api/v1/admin' }); - app.get('/plain', { onRequest: authenticate }, async (request) => ({ user: request.user?.id ?? null })); + // 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(); }); @@ -116,10 +109,17 @@ describe('admin service token', () => { expect(res.statusCode).toBe(200); }); - it('non-admin route with the token is 401', async () => { + it('non-admin routes with the token are 401', async () => { cfg.LOGTIDE_ADMIN_SERVICE_TOKEN = TOKEN; - const res = await app.inject({ method: 'GET', url: '/plain', headers: auth(TOKEN) }); - expect(res.statusCode).toBe(401); + 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 () => { From 06102e3902a2d961d9c9604473bf2b4b7c7997da Mon Sep 17 00:00:00 2001 From: Polliog Date: Mon, 19 Oct 2026 00:10:08 +0200 Subject: [PATCH 20/54] widen entitlement limits to bigint --- .../059_entitlement_limit_bigint.sql | 10 ++ packages/backend/src/capabilities/resolver.ts | 3 +- packages/backend/src/database/types.ts | 3 +- .../src/modules/capabilities/admin-routes.ts | 3 +- .../tests/capabilities/bigint-limits.test.ts | 102 ++++++++++++++++++ .../src/tests/capabilities/migration.test.ts | 3 +- 6 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 packages/backend/migrations/059_entitlement_limit_bigint.sql create mode 100644 packages/backend/src/tests/capabilities/bigint-limits.test.ts 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/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/database/types.ts b/packages/backend/src/database/types.ts index a7bb72e58..1142f830f 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; } diff --git a/packages/backend/src/modules/capabilities/admin-routes.ts b/packages/backend/src/modules/capabilities/admin-routes.ts index 35f77a9b5..06f642ae8 100644 --- a/packages/backend/src/modules/capabilities/admin-routes.ts +++ b/packages/backend/src/modules/capabilities/admin-routes.ts @@ -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({ 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/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(); }); From 2e983923546717734aaf9d83a3abd8cf08348d88 Mon Sep 17 00:00:00 2001 From: Polliog Date: Mon, 19 Oct 2026 00:10:57 +0200 Subject: [PATCH 21/54] pass hosting seam env vars to compose services --- docker/docker-compose.build.yml | 16 ++++++++++++++++ docker/docker-compose.simple.yml | 16 ++++++++++++++++ docker/docker-compose.yml | 16 ++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/docker/docker-compose.build.yml b/docker/docker-compose.build.yml index d4a157c83..adf5a91e0 100644 --- a/docker/docker-compose.build.yml +++ b/docker/docker-compose.build.yml @@ -106,6 +106,14 @@ 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:-} + REQUIRE_EMAIL_VERIFICATION: ${REQUIRE_EMAIL_VERIFICATION:-} + REGISTRATION_RATE_LIMIT_PER_HOUR: ${REGISTRATION_RATE_LIMIT_PER_HOUR:-} STORAGE_ENGINE: ${STORAGE_ENGINE:-timescale} CLICKHOUSE_HOST: ${CLICKHOUSE_HOST:-clickhouse} CLICKHOUSE_PORT: ${CLICKHOUSE_PORT:-8123} @@ -162,6 +170,14 @@ 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:-} + REQUIRE_EMAIL_VERIFICATION: ${REQUIRE_EMAIL_VERIFICATION:-} + REGISTRATION_RATE_LIMIT_PER_HOUR: ${REGISTRATION_RATE_LIMIT_PER_HOUR:-} 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..b9694da53 100644 --- a/docker/docker-compose.simple.yml +++ b/docker/docker-compose.simple.yml @@ -94,6 +94,14 @@ 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:-} + REQUIRE_EMAIL_VERIFICATION: ${REQUIRE_EMAIL_VERIFICATION:-} + REGISTRATION_RATE_LIMIT_PER_HOUR: ${REGISTRATION_RATE_LIMIT_PER_HOUR:-} STORAGE_ENGINE: timescale depends_on: postgres: @@ -135,6 +143,14 @@ 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:-} + REQUIRE_EMAIL_VERIFICATION: ${REQUIRE_EMAIL_VERIFICATION:-} + REGISTRATION_RATE_LIMIT_PER_HOUR: ${REGISTRATION_RATE_LIMIT_PER_HOUR:-} STORAGE_ENGINE: timescale depends_on: backend: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 2995da4dc..515dceb58 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -121,6 +121,14 @@ 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:-} + REQUIRE_EMAIL_VERIFICATION: ${REQUIRE_EMAIL_VERIFICATION:-} + REGISTRATION_RATE_LIMIT_PER_HOUR: ${REGISTRATION_RATE_LIMIT_PER_HOUR:-} STORAGE_ENGINE: ${STORAGE_ENGINE:-timescale} CLICKHOUSE_HOST: ${CLICKHOUSE_HOST:-clickhouse} CLICKHOUSE_PORT: ${CLICKHOUSE_PORT:-8123} @@ -175,6 +183,14 @@ 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:-} + REQUIRE_EMAIL_VERIFICATION: ${REQUIRE_EMAIL_VERIFICATION:-} + REGISTRATION_RATE_LIMIT_PER_HOUR: ${REGISTRATION_RATE_LIMIT_PER_HOUR:-} STORAGE_ENGINE: ${STORAGE_ENGINE:-timescale} CLICKHOUSE_HOST: ${CLICKHOUSE_HOST:-clickhouse} CLICKHOUSE_PORT: ${CLICKHOUSE_PORT:-8123} From c461bfcfb5d1ecfdaab8a0bc4a36eb1db481bb4c Mon Sep 17 00:00:00 2001 From: Polliog Date: Mon, 19 Oct 2026 00:12:24 +0200 Subject: [PATCH 22/54] enforce projects.max on project restore --- .../backend/src/modules/projects/routes.ts | 12 ++++++- .../modules/projects/projects-max.test.ts | 35 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/modules/projects/routes.ts b/packages/backend/src/modules/projects/routes.ts index 9bd875e2d..cab804e08 100644 --- a/packages/backend/src/modules/projects/routes.ts +++ b/packages/backend/src/modules/projects/routes.ts @@ -196,7 +196,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' }); } diff --git a/packages/backend/src/tests/modules/projects/projects-max.test.ts b/packages/backend/src/tests/modules/projects/projects-max.test.ts index b37a89f6a..80201f301 100644 --- a/packages/backend/src/tests/modules/projects/projects-max.test.ts +++ b/packages/backend/src/tests/modules/projects/projects-max.test.ts @@ -121,4 +121,39 @@ describe('projects.max enforcement', () => { 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); + }); }); From 0a9b19e97b225ce4ecac4b66eb2a0a089fe7c388 Mon Sep 17 00:00:00 2001 From: Polliog Date: Mon, 19 Oct 2026 00:14:51 +0200 Subject: [PATCH 23/54] dedup quota notices per limit, atomically --- .../src/capabilities/quota-notifier.ts | 133 ++++++++++-------- .../tests/capabilities/quota-grace.test.ts | 48 ++++++- 2 files changed, 123 insertions(+), 58 deletions(-) diff --git a/packages/backend/src/capabilities/quota-notifier.ts b/packages/backend/src/capabilities/quota-notifier.ts index 0351aec0f..b0739ecdf 100644 --- a/packages/backend/src/capabilities/quota-notifier.ts +++ b/packages/backend/src/capabilities/quota-notifier.ts @@ -2,7 +2,6 @@ import { sql } from 'kysely'; import { db } from '../database/connection.js'; import { config } from '../config/index.js'; import { getMailer } from '../lib/mailer.js'; -import { notificationsService } from '../modules/notifications/service.js'; import { hub } from '@logtide/core'; import { isInternalLoggingEnabled } from '../utils/internal-logger.js'; import { CAPABILITIES, type CapabilityName } from './registry.js'; @@ -34,8 +33,12 @@ function startOfUtcMonth(now: Date): Date { /** * Sends the in-app notification + email for a quota threshold, at most once per - * (org, capability, state, UTC month). Dedup reads the notifications already - * created; no extra table. Never throws. + * (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; @@ -48,70 +51,86 @@ export async function notifyQuotaThreshold(params: { }): Promise { const { organizationId, capability, state, usage, limit, gracePercent, now } = params; try { - const existing = await db - .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('created_at', '>=', startOfUtcMonth(now)) - .limit(1) - .executeTakeFirst(); - if (existing) return; + const monthStart = startOfUtcMonth(now); + const dedupKey = `quota_threshold:${organizationId}:${capability}:${state}:${limit}:${monthStart.toISOString()}`; - const recipients = await db - .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; + 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 org = await db - .selectFrom('organizations') - .select('name') - .where('id', '=', organizationId) - .executeTakeFirst(); + 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 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 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 metadata = { kind: 'quota_threshold', capability, state, usage, limit }; - for (const r of recipients) { - await notificationsService.createNotification({ - userId: r.id, - type: 'system', - title, - message, - organizationId, - metadata, - }); - } + 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: recipients.map((r) => r.email).join(', '), - subject: `[${org?.name ?? 'LogTide'}] ${title}`, - text: message, + 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}`); diff --git a/packages/backend/src/tests/capabilities/quota-grace.test.ts b/packages/backend/src/tests/capabilities/quota-grace.test.ts index 966cadf07..cf26169e2 100644 --- a/packages/backend/src/tests/capabilities/quota-grace.test.ts +++ b/packages/backend/src/tests/capabilities/quota-grace.test.ts @@ -4,7 +4,7 @@ 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 } from '../../capabilities/quota-notifier.js'; +import { formatQuotaValue, notifyQuotaThreshold } from '../../capabilities/quota-notifier.js'; import { createTestContext } from '../helpers/factories.js'; const CAP = 'ingestion.max_events_monthly'; @@ -133,4 +133,50 @@ describe('QuotaEvaluator grace and notifications', () => { 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); + }); }); From 44e0ca6d16fedbd495b343bf207e4f6325d24b65 Mon Sep 17 00:00:00 2001 From: Polliog Date: Mon, 19 Oct 2026 00:15:55 +0200 Subject: [PATCH 24/54] hash credentials in rate limit keys --- packages/backend/src/server.ts | 17 +++++++--- .../src/tests/server/rate-limit-key.test.ts | 34 +++++++++++++++++++ 2 files changed, 46 insertions(+), 5 deletions(-) create mode 100644 packages/backend/src/tests/server/rate-limit-key.test.ts diff --git a/packages/backend/src/server.ts b/packages/backend/src/server.ts index 579e18335..c49c246db 100644 --- a/packages/backend/src/server.ts +++ b/packages/backend/src/server.ts @@ -64,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'; @@ -75,6 +76,17 @@ 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 = {}) { const fastify = Fastify({ logger: true, @@ -149,11 +161,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, { 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'); + }); +}); From de635caf132d2804b377ac9cc8307077bb616193 Mon Sep 17 00:00:00 2001 From: Polliog Date: Mon, 19 Oct 2026 00:17:40 +0200 Subject: [PATCH 25/54] add env-unset regression suite for hosting seams --- .../src/tests/cloud-seams/env-unset.test.ts | 275 ++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 packages/backend/src/tests/cloud-seams/env-unset.test.ts 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..aaf4d0c61 --- /dev/null +++ b/packages/backend/src/tests/cloud-seams/env-unset.test.ts @@ -0,0 +1,275 @@ +/** + * 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', + 'REQUIRE_EMAIL_VERIFICATION', + 'REGISTRATION_RATE_LIMIT_PER_HOUR', +] 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.REQUIRE_EMAIL_VERIFICATION).toBe(false); + expect(cfg.REGISTRATION_RATE_LIMIT_PER_HOUR).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'); + }); + + 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); + + 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(); + } + }); +}); From 92b1caeb87a0eea4a71b63ac23c94a56571e079b Mon Sep 17 00:00:00 2001 From: Polliog Date: Mon, 19 Oct 2026 00:20:59 +0200 Subject: [PATCH 26/54] send register verification email in background --- packages/backend/src/modules/users/routes.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/modules/users/routes.ts b/packages/backend/src/modules/users/routes.ts index 5688096db..fdbd80fc0 100644 --- a/packages/backend/src/modules/users/routes.ts +++ b/packages/backend/src/modules/users/routes.ts @@ -109,14 +109,15 @@ export async function usersRoutes(fastify: FastifyInstance) { const user = await usersService.createUser(body); - // Gate on: email the verification link. A send failure never fails the + // 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()) { - try { - await emailVerificationService.issueAndSend(user); - } catch (verificationError) { - logVerificationError('[Auth] Failed to issue email verification token', user.id, verificationError); - } + 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. From c4b8e7f8d06a9208e48e66dbbfc15b6534ea611c Mon Sep 17 00:00:00 2001 From: Polliog Date: Mon, 19 Oct 2026 00:21:00 +0200 Subject: [PATCH 27/54] email new unverified oidc users their link --- .../modules/auth/authentication-service.ts | 16 +++++-- .../modules/users/email-verification.test.ts | 43 +++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/modules/auth/authentication-service.ts b/packages/backend/src/modules/auth/authentication-service.ts index 18241e382..355a80424 100644 --- a/packages/backend/src/modules/auth/authentication-service.ts +++ b/packages/backend/src/modules/auth/authentication-service.ts @@ -13,6 +13,7 @@ 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'; @@ -386,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') @@ -397,9 +401,7 @@ export class AuthenticationService { last_login: new Date(), // Column default (NOW()) marks the user verified. With the gate on, an // OIDC identity without email_verified: true starts unverified. - ...(isEmailVerificationRequired() && provider.config.type === 'oidc' && result.emailVerified !== true - ? { email_verified_at: null } - : {}), + ...(startsUnverified ? { email_verified_at: null } : {}), }) .returning(['id', 'email', 'name', 'is_admin', 'disabled', 'created_at', 'last_login']) .executeTakeFirstOrThrow(); @@ -415,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/tests/modules/users/email-verification.test.ts b/packages/backend/src/tests/modules/users/email-verification.test.ts index eb83249a0..9978f0fcb 100644 --- a/packages/backend/src/tests/modules/users/email-verification.test.ts +++ b/packages/backend/src/tests/modules/users/email-verification.test.ts @@ -287,6 +287,31 @@ describe('email verification gate ON', () => { 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 }); @@ -433,6 +458,24 @@ describe('OIDC email verification', () => { 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); From 6dbeaabf2c43eff1ce697629fe0c06272466ee31 Mon Sep 17 00:00:00 2001 From: Polliog Date: Mon, 19 Oct 2026 00:23:57 +0200 Subject: [PATCH 28/54] add upgrade hint to otlp 429 bodies --- .../modules/organizations/upgrade-handoff.ts | 13 ++ .../backend/src/modules/otlp/metric-routes.ts | 4 + packages/backend/src/modules/otlp/routes.ts | 4 + .../backend/src/modules/otlp/trace-routes.ts | 4 + packages/backend/src/server.ts | 10 +- .../modules/otlp/otlp-upgrade-hint.test.ts | 131 ++++++++++++++++++ 6 files changed, 158 insertions(+), 8 deletions(-) create mode 100644 packages/backend/src/tests/modules/otlp/otlp-upgrade-hint.test.ts diff --git a/packages/backend/src/modules/organizations/upgrade-handoff.ts b/packages/backend/src/modules/organizations/upgrade-handoff.ts index f90811f4e..8b434685b 100644 --- a/packages/backend/src/modules/organizations/upgrade-handoff.ts +++ b/packages/backend/src/modules/organizations/upgrade-handoff.ts @@ -1,5 +1,6 @@ import crypto from 'crypto'; import { config } from '../../config/index.js'; +import { CapabilityError, QuotaExceededError } from '../../capabilities/errors.js'; export type UpgradeIntent = 'upgrade' | 'manage'; @@ -28,6 +29,18 @@ export function isUpgradeHandoffActive(): boolean { 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'); } diff --git a/packages/backend/src/modules/otlp/metric-routes.ts b/packages/backend/src/modules/otlp/metric-routes.ts index a4edf9683..8d9d3ac15 100644 --- a/packages/backend/src/modules/otlp/metric-routes.ts +++ b/packages/backend/src/modules/otlp/metric-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'; const collectStreamToBuffer = (stream: NodeJS.ReadableStream): Promise => new Promise((resolve, reject) => { @@ -109,6 +110,8 @@ const otlpMetricRoutes: FastifyPluginAsync = async (fastify) => { errorMessage: { type: 'string' }, }, }, + // Hosting seam: present only while the upgrade handoff is active. + upgrade_available: { type: 'boolean' }, }, }, 400: { @@ -219,6 +222,7 @@ const otlpMetricRoutes: FastifyPluginAsync = async (fastify) => { rejectedDataPoints: -1, errorMessage, }, + ...upgradeHintFor(error), }); } 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..5af0e74b2 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' }, }, }, }, @@ -244,6 +247,7 @@ const otlpTraceRoutes: FastifyPluginAsync = async (fastify) => { rejectedSpans: -1, errorMessage, }, + ...upgradeHintFor(error), }); } diff --git a/packages/backend/src/server.ts b/packages/backend/src/server.ts index c49c246db..058e22c98 100644 --- a/packages/backend/src/server.ts +++ b/packages/backend/src/server.ts @@ -14,8 +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 { isUpgradeHandoffActive } from './modules/organizations/upgrade-handoff.js'; -import { CapabilityError, QuotaExceededError } from './capabilities/errors.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'; @@ -126,12 +125,7 @@ export async function build(opts = {}) { if (typeof (error as any).code === 'string') { body.code = (error as any).code; } - if ( - (error instanceof CapabilityError || error instanceof QuotaExceededError) && - isUpgradeHandoffActive() - ) { - body.upgrade_available = true; - } + Object.assign(body, upgradeHintFor(error)); reply.code(statusCode).send(body); return; } 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); + }); + } +}); From 985b17976361209f4b20d4733fb2385df08d1406 Mon Sep 17 00:00:00 2001 From: Polliog Date: Mon, 19 Oct 2026 00:26:43 +0200 Subject: [PATCH 29/54] share the utc month start helper --- packages/backend/src/capabilities/month.ts | 4 ++++ packages/backend/src/capabilities/quota-evaluator.ts | 6 +----- packages/backend/src/capabilities/quota-notifier.ts | 5 +---- .../backend/src/modules/metering/capability-usage.ts | 6 +----- packages/backend/src/tests/capabilities/month.test.ts | 11 +++++++++++ 5 files changed, 18 insertions(+), 14 deletions(-) create mode 100644 packages/backend/src/capabilities/month.ts create mode 100644 packages/backend/src/tests/capabilities/month.test.ts 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 4429ff152..8ec04b450 100644 --- a/packages/backend/src/capabilities/quota-evaluator.ts +++ b/packages/backend/src/capabilities/quota-evaluator.ts @@ -11,16 +11,12 @@ import { import { computeQuotaState } from './quota-state.js'; import { notifyQuotaThreshold } from './quota-notifier.js'; import type { MeteringService } from '../modules/metering/index.js'; +import { startOfUtcMonth } from './month.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, diff --git a/packages/backend/src/capabilities/quota-notifier.ts b/packages/backend/src/capabilities/quota-notifier.ts index b0739ecdf..87e533d09 100644 --- a/packages/backend/src/capabilities/quota-notifier.ts +++ b/packages/backend/src/capabilities/quota-notifier.ts @@ -5,6 +5,7 @@ import { getMailer } from '../lib/mailer.js'; import { hub } from '@logtide/core'; import { isInternalLoggingEnabled } from '../utils/internal-logger.js'; import { CAPABILITIES, type CapabilityName } from './registry.js'; +import { startOfUtcMonth } from './month.js'; export type QuotaNotifyState = 'grace' | 'blocked'; @@ -27,10 +28,6 @@ export function formatQuotaValue(cap: CapabilityName, value: number): string { return value.toLocaleString('en-US'); } -function startOfUtcMonth(now: Date): Date { - return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); -} - /** * 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 diff --git a/packages/backend/src/modules/metering/capability-usage.ts b/packages/backend/src/modules/metering/capability-usage.ts index 40d2508c5..e3ec45070 100644 --- a/packages/backend/src/modules/metering/capability-usage.ts +++ b/packages/backend/src/modules/metering/capability-usage.ts @@ -12,6 +12,7 @@ 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 { startOfUtcMonth } from '../../capabilities/month.js'; const sigmaService = new SigmaService(); @@ -41,11 +42,6 @@ const LIMIT_COUNTERS: Partial< '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. async function quotaUsage( 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'); + }); +}); From 3d6fe5ef154d5955be5260251b1a1d425d5b6329 Mon Sep 17 00:00:00 2001 From: Polliog Date: Mon, 19 Oct 2026 00:27:23 +0200 Subject: [PATCH 30/54] document internal org, otlp hint and operator notes --- docs/cloud-seams.md | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/docs/cloud-seams.md b/docs/cloud-seams.md index a89e5f9fa..016f65159 100644 --- a/docs/cloud-seams.md +++ b/docs/cloud-seams.md @@ -16,7 +16,7 @@ This page is for operators who run LogTide on behalf of other people (a managed | `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 | -An empty string is treated the same as unset for all of them. +An empty string is treated the same as unset for all of them. The bundled `docker/docker-compose*.yml` files pass all seven 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 @@ -53,7 +53,7 @@ Entitlement body for `PUT`: { "entitlements": [ { "capability": "projects.max", "limitValue": 5 } ] } ``` -`limitValue` is an integer `>= 0` or `null`; boolean capabilities use `enabled`. Unknown capabilities are rejected with `400`. +`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) @@ -67,7 +67,7 @@ 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. +- 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. @@ -117,7 +117,7 @@ When `false`, the verification routes are not registered and every user counts a ### 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 make sure client IPs are resolved correctly, otherwise every request shares one IP. +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). ## Capabilities added for hosting @@ -125,7 +125,7 @@ All three default to unlimited and can be set per organization through entitleme | Capability | Kind | Meaning | |---|---|---| -| `projects.max` | limit | Maximum number of active projects (soft-deleted ones do not count). Enforced on `POST /api/v1/projects`, answers `403` at the limit | +| `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 | @@ -137,5 +137,25 @@ Quotas (`ingestion.max_bytes_monthly`, spans, metric datapoints and so on) are c - 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 and state is notified at most once per month. +- 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`, 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. From 0b8b62294fdf27d774fef9a037aaf768e589b31a Mon Sep 17 00:00:00 2001 From: Polliog Date: Mon, 19 Oct 2026 00:28:07 +0200 Subject: [PATCH 31/54] update changelog for the final fix wave --- CHANGELOG.md | 17 ++++++++++------- docs/cloud-seams.md | 2 +- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4abdf594..6bd4e6303 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,23 +12,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`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; until the link is used, `POST /api/v1/projects` and `POST /api/v1/projects/:id/api-keys` 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; 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`. +- **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` and `POST /api/v1/projects/:id/api-keys` 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. -- **`projects.max` capability**: new per-org limit on active projects (soft-deleted ones do not count), default unlimited. Enforced on `POST /api/v1/projects` 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 and state per month from existing notifications; 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) 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; anywhere else it is rejected exactly like an invalid session. Audit entries record `actor: service:admin-service` in metadata. Unset means unchanged behavior. +- **`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. ### Fixed -- **Quota evaluator ignored default quotas**: `QuotaEvaluator` only evaluated orgs holding a non-null quota entitlement row, so a quota coming from defaults never blocked anyone. When any quota default is finite it now evaluates every organization; with no quota defaults it still queries only orgs with rows. +- **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. ### 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`, `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`. `docker/.env.example` lists all the new variables. +- **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. diff --git a/docs/cloud-seams.md b/docs/cloud-seams.md index 016f65159..5bc8e5036 100644 --- a/docs/cloud-seams.md +++ b/docs/cloud-seams.md @@ -142,7 +142,7 @@ Quotas (`ingestion.max_bytes_monthly`, spans, metric datapoints and so on) are c ## The internal logging organization -Unless `INTERNAL_LOGGING_ENABLED=false`, 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`: +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": [ From 6ba54e219736d8519b9f3e0f0e4ea6d87908ad2e Mon Sep 17 00:00:00 2001 From: Polliog Date: Mon, 19 Oct 2026 02:10:49 +0200 Subject: [PATCH 32/54] log organization creations per user --- .../060_organization_creation_log.sql | 20 ++++++ .../backend/src/database/tenant-tables.ts | 2 +- packages/backend/src/database/types.ts | 8 +++ .../src/modules/organizations/service.ts | 63 ++++++++++++------- .../src/tests/database/tenant-tables.test.ts | 1 + 5 files changed, 72 insertions(+), 22 deletions(-) create mode 100644 packages/backend/migrations/060_organization_creation_log.sql 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/src/database/tenant-tables.ts b/packages/backend/src/database/tenant-tables.ts index f82747909..2310c8ce5 100644 --- a/packages/backend/src/database/tenant-tables.ts +++ b/packages/backend/src/database/tenant-tables.ts @@ -32,7 +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', + '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 1142f830f..de6f8cdef 100644 --- a/packages/backend/src/database/types.ts +++ b/packages/backend/src/database/types.ts @@ -111,6 +111,13 @@ export interface EmailVerificationTokensTable { created_at: Generated; } +export interface OrganizationCreationLogTable { + id: Generated; + user_id: string; + organization_id: string | null; + created_at: Generated; +} + export interface SessionsTable { id: Generated; user_id: string; @@ -1183,6 +1190,7 @@ export interface Database { 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/modules/organizations/service.ts b/packages/backend/src/modules/organizations/service.ts index 377e92bfc..f2af034e1 100644 --- a/packages/backend/src/modules/organizations/service.ts +++ b/packages/backend/src/modules/organizations/service.ts @@ -64,6 +64,17 @@ export class OrganizationsService { 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 */ @@ -74,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, diff --git a/packages/backend/src/tests/database/tenant-tables.test.ts b/packages/backend/src/tests/database/tenant-tables.test.ts index 98501a42f..b7e1cf4ca 100644 --- a/packages/backend/src/tests/database/tenant-tables.test.ts +++ b/packages/backend/src/tests/database/tenant-tables.test.ts @@ -19,6 +19,7 @@ const ALL_TABLES = [ 'audit_log', 'metrics_hourly_stats', 'metrics_daily_stats', 'metrics', 'metric_exemplars', 'custom_dashboards', 'log_pipelines', 'digest_configs', 'digest_recipients', 'receivers', 'receiver_events', 'email_verification_tokens', + 'organization_creation_log', ]; describe('tenant-tables manifest', () => { From 59209302368437598bf06918ca6bb2f4ab4b2f49 Mon Sep 17 00:00:00 2001 From: Polliog Date: Mon, 19 Oct 2026 02:12:27 +0200 Subject: [PATCH 33/54] cap organization creations per month --- CHANGELOG.md | 3 +- docker/.env.example | 4 + docker/docker-compose.build.yml | 2 + docker/docker-compose.simple.yml | 2 + docker/docker-compose.yml | 2 + docs/cloud-seams.md | 7 +- packages/backend/src/config/index.ts | 13 ++ .../src/modules/organizations/routes.ts | 56 +++++-- .../src/tests/cloud-seams/env-unset.test.ts | 2 + .../organizations/org-creation-cap.test.ts | 141 ++++++++++++++++++ 10 files changed, 217 insertions(+), 15 deletions(-) create mode 100644 packages/backend/src/tests/modules/organizations/org-creation-cap.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bd4e6303..33428f450 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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` and `POST /api/v1/projects/:id/api-keys` 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. @@ -29,7 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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`, `REQUIRE_EMAIL_VERIFICATION` and `REGISTRATION_RATE_LIMIT_PER_HOUR` to both the backend and the worker (empty when unset, which is inert). +- **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. diff --git a/docker/.env.example b/docker/.env.example index 890c12f91..494bed9da 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -133,6 +133,10 @@ DB_USER=logtide # 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 diff --git a/docker/docker-compose.build.yml b/docker/docker-compose.build.yml index adf5a91e0..1053e31ac 100644 --- a/docker/docker-compose.build.yml +++ b/docker/docker-compose.build.yml @@ -112,6 +112,7 @@ services: 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:-} STORAGE_ENGINE: ${STORAGE_ENGINE:-timescale} @@ -176,6 +177,7 @@ services: 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:-} STORAGE_ENGINE: ${STORAGE_ENGINE:-timescale} diff --git a/docker/docker-compose.simple.yml b/docker/docker-compose.simple.yml index b9694da53..95d2d9bff 100644 --- a/docker/docker-compose.simple.yml +++ b/docker/docker-compose.simple.yml @@ -100,6 +100,7 @@ services: 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:-} STORAGE_ENGINE: timescale @@ -149,6 +150,7 @@ services: 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:-} STORAGE_ENGINE: timescale diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 515dceb58..e2e640929 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -127,6 +127,7 @@ services: 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:-} STORAGE_ENGINE: ${STORAGE_ENGINE:-timescale} @@ -189,6 +190,7 @@ services: 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:-} STORAGE_ENGINE: ${STORAGE_ENGINE:-timescale} diff --git a/docs/cloud-seams.md b/docs/cloud-seams.md index 5bc8e5036..27a2cbdc8 100644 --- a/docs/cloud-seams.md +++ b/docs/cloud-seams.md @@ -13,10 +13,11 @@ This page is for operators who run LogTide on behalf of other people (a managed | `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 | -An empty string is treated the same as unset for all of them. The bundled `docker/docker-compose*.yml` files pass all seven through to both the `backend` and the `worker` containers (as empty strings when unset), so setting them in your `.env` is enough. +An empty string is treated the same as unset for all of them. The bundled `docker/docker-compose*.yml` files pass all eight 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 @@ -100,6 +101,10 @@ A complete reference verifier using only Node's `crypto` is in [`docs/examples/v 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. diff --git a/packages/backend/src/config/index.ts b/packages/backend/src/config/index.ts index 0c9abd259..a4e1b848c 100644 --- a/packages/backend/src/config/index.ts +++ b/packages/backend/src/config/index.ts @@ -125,6 +125,19 @@ const configSchema = z.object({ .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). diff --git a/packages/backend/src/modules/organizations/routes.ts b/packages/backend/src/modules/organizations/routes.ts index 989793103..c69324ec1 100644 --- a/packages/backend/src/modules/organizations/routes.ts +++ b/packages/backend/src/modules/organizations/routes.ts @@ -5,6 +5,8 @@ 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'; const organizationsService = new OrganizationsService(); @@ -308,23 +310,51 @@ export async function organizationsRoutes(fastify: FastifyInstance) { try { const body = createOrganizationSchema.parse(request.body); - const maxOwned = config.MAX_ORGANIZATIONS_PER_USER; - if (maxOwned !== undefined) { - const owned = await organizationsService.countOwnedOrganizations(request.user.id); - if (owned >= maxOwned) { - return reply.status(403).send({ - error: 'organization_limit_reached', - message: `You can own at most ${maxOwned.toLocaleString('en-US')} organization${maxOwned === 1 ? '' : 's'}`, - }); + // 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 organization = await organizationsService.createOrganization({ - userId: request.user.id, - name: body.name, - description: body.description, + 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/tests/cloud-seams/env-unset.test.ts b/packages/backend/src/tests/cloud-seams/env-unset.test.ts index aaf4d0c61..3dbd00db8 100644 --- a/packages/backend/src/tests/cloud-seams/env-unset.test.ts +++ b/packages/backend/src/tests/cloud-seams/env-unset.test.ts @@ -24,6 +24,7 @@ const SEAM_ENV = [ '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', ] as const; @@ -57,6 +58,7 @@ describe('hosting seams with all env unset', () => { 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(); }); 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); + }); +}); From 4129c2ff7180addb9480ed2b92dcc0387d0cf722 Mon Sep 17 00:00:00 2001 From: Polliog Date: Mon, 19 Oct 2026 02:26:37 +0200 Subject: [PATCH 34/54] add terms acceptance columns --- packages/backend/migrations/061_terms_acceptance.sql | 11 +++++++++++ packages/backend/src/database/types.ts | 4 ++++ 2 files changed, 15 insertions(+) create mode 100644 packages/backend/migrations/061_terms_acceptance.sql 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/src/database/types.ts b/packages/backend/src/database/types.ts index de6f8cdef..337c6e87f 100644 --- a/packages/backend/src/database/types.ts +++ b/packages/backend/src/database/types.ts @@ -100,6 +100,10 @@ export interface UsersTable { 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 { From 933b3558b50ed4559ac4cdf2ecddf9f267adb013 Mon Sep 17 00:00:00 2001 From: Polliog Date: Mon, 19 Oct 2026 02:33:17 +0200 Subject: [PATCH 35/54] require terms acceptance when configured --- packages/backend/src/config/index.ts | 46 ++++++++++++ .../backend/src/modules/audit-log/actions.ts | 1 + .../backend/src/modules/settings/routes.ts | 4 ++ packages/backend/src/modules/users/routes.ts | 70 ++++++++++++++++++- packages/backend/src/modules/users/service.ts | 4 ++ packages/backend/src/modules/users/terms.ts | 51 ++++++++++++++ 6 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 packages/backend/src/modules/users/terms.ts diff --git a/packages/backend/src/config/index.ts b/packages/backend/src/config/index.ts index a4e1b848c..c4ef925b3 100644 --- a/packages/backend/src/config/index.ts +++ b/packages/backend/src/config/index.ts @@ -42,6 +42,41 @@ function validateUpgradeConfig(env: { LOGTIDE_UPGRADE_URL?: string; LOGTIDE_UPGR 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'), @@ -114,6 +149,14 @@ const configSchema = z.object({ 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( @@ -196,6 +239,9 @@ const configSchema = z.object({ 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, diff --git a/packages/backend/src/modules/audit-log/actions.ts b/packages/backend/src/modules/audit-log/actions.ts index f933365a9..a3ad104cd 100644 --- a/packages/backend/src/modules/audit-log/actions.ts +++ b/packages/backend/src/modules/audit-log/actions.ts @@ -29,6 +29,7 @@ export const AUDIT_ACTIONS = { // 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/settings/routes.ts b/packages/backend/src/modules/settings/routes.ts index 37e9adad1..a047df58a 100644 --- a/packages/backend/src/modules/settings/routes.ts +++ b/packages/backend/src/modules/settings/routes.ts @@ -11,6 +11,7 @@ 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 } from '../users/terms.js'; import type { UserProfile } from '../users/service.js'; import { auditLogService } from '../audit-log/service.js'; @@ -247,6 +248,8 @@ export async function publicSettingsRoutes(fastify: FastifyInstance) { 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); @@ -256,6 +259,7 @@ export async function publicSettingsRoutes(fastify: FastifyInstance) { signupEnabled: true, requiresLogin: true, ...(isEmailVerificationRequired() ? { emailVerificationRequired: true } : {}), + ...(getActiveTerms() ?? {}), }); } }); diff --git a/packages/backend/src/modules/users/routes.ts b/packages/backend/src/modules/users/routes.ts index fdbd80fc0..b177e5555 100644 --- a/packages/backend/src/modules/users/routes.ts +++ b/packages/backend/src/modules/users/routes.ts @@ -8,6 +8,7 @@ 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'; @@ -16,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({ @@ -107,7 +116,19 @@ 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 @@ -338,6 +359,8 @@ export async function usersRoutes(fastify: FastifyInstance) { 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: { @@ -348,6 +371,7 @@ export async function usersRoutes(fastify: FastifyInstance) { createdAt: user.createdAt, lastLogin: user.lastLogin, ...(emailVerified !== undefined ? { emailVerified } : {}), + ...(termsPending ? { termsAcceptanceRequired: true } : {}), }, }); }); @@ -487,6 +511,50 @@ 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 diff --git a/packages/backend/src/modules/users/service.ts b/packages/backend/src/modules/users/service.ts index 61e00c120..d72267075 100644 --- a/packages/backend/src/modules/users/service.ts +++ b/packages/backend/src/modules/users/service.ts @@ -16,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 { @@ -131,6 +133,8 @@ export class UsersService { // 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(); diff --git a/packages/backend/src/modules/users/terms.ts b/packages/backend/src/modules/users/terms.ts new file mode 100644 index 000000000..54fd2d6aa --- /dev/null +++ b/packages/backend/src/modules/users/terms.ts @@ -0,0 +1,51 @@ +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(); From 903b03d26cf89645e697a43dea3d6bb50797ce93 Mon Sep 17 00:00:00 2001 From: Polliog Date: Mon, 19 Oct 2026 02:33:17 +0200 Subject: [PATCH 36/54] gate resource creation on pending terms --- .../backend/src/modules/api-keys/routes.ts | 6 + .../src/modules/organizations/routes.ts | 7 + .../backend/src/modules/projects/routes.ts | 5 + .../src/tests/cloud-seams/env-unset.test.ts | 34 ++ .../modules/users/terms-acceptance.test.ts | 471 ++++++++++++++++++ 5 files changed, 523 insertions(+) create mode 100644 packages/backend/src/tests/modules/users/terms-acceptance.test.ts diff --git a/packages/backend/src/modules/api-keys/routes.ts b/packages/backend/src/modules/api-keys/routes.ts index 489c2b1ff..75142dfc6 100644 --- a/packages/backend/src/modules/api-keys/routes.ts +++ b/packages/backend/src/modules/api-keys/routes.ts @@ -7,6 +7,7 @@ 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'; @@ -70,6 +71,11 @@ export async function apiKeysRoutes(fastify: FastifyInstance) { 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); diff --git a/packages/backend/src/modules/organizations/routes.ts b/packages/backend/src/modules/organizations/routes.ts index c69324ec1..4baddf7a7 100644 --- a/packages/backend/src/modules/organizations/routes.ts +++ b/packages/backend/src/modules/organizations/routes.ts @@ -8,6 +8,7 @@ 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(); @@ -310,6 +311,12 @@ export async function organizationsRoutes(fastify: FastifyInstance) { try { const body = createOrganizationSchema.parse(request.body); + // 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 () => { diff --git a/packages/backend/src/modules/projects/routes.ts b/packages/backend/src/modules/projects/routes.ts index cab804e08..e9c1972c6 100644 --- a/packages/backend/src/modules/projects/routes.ts +++ b/packages/backend/src/modules/projects/routes.ts @@ -9,6 +9,7 @@ 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'), @@ -272,6 +273,10 @@ export async function projectsRoutes(fastify: FastifyInstance) { 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. diff --git a/packages/backend/src/tests/cloud-seams/env-unset.test.ts b/packages/backend/src/tests/cloud-seams/env-unset.test.ts index 3dbd00db8..3bdba31fe 100644 --- a/packages/backend/src/tests/cloud-seams/env-unset.test.ts +++ b/packages/backend/src/tests/cloud-seams/env-unset.test.ts @@ -27,6 +27,9 @@ const SEAM_ENV = [ '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; @@ -61,6 +64,9 @@ describe('hosting seams with all env unset', () => { 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 () => { @@ -155,6 +161,25 @@ describe('hosting seams with all env unset', () => { 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 () => { @@ -188,6 +213,15 @@ describe('hosting seams with all env unset', () => { .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', 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..74cda1d93 --- /dev/null +++ b/packages/backend/src/tests/modules/users/terms-acceptance.test.ts @@ -0,0 +1,471 @@ +/** + * 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'); + }); +}); From c3c8a19437bd82eb0927419ff8aa9b55cbb9ceec Mon Sep 17 00:00:00 2001 From: Polliog Date: Mon, 19 Oct 2026 10:25:07 +0200 Subject: [PATCH 37/54] add terms acceptance to frontend auth api --- packages/frontend/src/lib/api/auth.ts | 26 +++++++++++++++++++ .../frontend/src/lib/api/organizations.ts | 3 ++- .../src/lib/utils/email-verification.test.ts | 5 ++++ .../src/lib/utils/email-verification.ts | 7 +++++ 4 files changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/frontend/src/lib/api/auth.ts b/packages/frontend/src/lib/api/auth.ts index 20cb06898..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 { @@ -61,6 +63,10 @@ export interface AuthConfig { 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 { @@ -288,6 +294,26 @@ export class AuthAPI { } } + /** 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/organizations.ts b/packages/frontend/src/lib/api/organizations.ts index de7386bbb..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) { diff --git a/packages/frontend/src/lib/utils/email-verification.test.ts b/packages/frontend/src/lib/utils/email-verification.test.ts index 1f25486ac..53c66d2b7 100644 --- a/packages/frontend/src/lib/utils/email-verification.test.ts +++ b/packages/frontend/src/lib/utils/email-verification.test.ts @@ -3,6 +3,7 @@ import { shouldShowVerifyBanner, apiErrorMessage, EMAIL_NOT_VERIFIED_MESSAGE, + TERMS_NOT_ACCEPTED_MESSAGE, } from './email-verification'; describe('shouldShowVerifyBanner', () => { @@ -26,6 +27,10 @@ describe('apiErrorMessage', () => { 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 index b102dc7da..16836aa33 100644 --- a/packages/frontend/src/lib/utils/email-verification.ts +++ b/packages/frontend/src/lib/utils/email-verification.ts @@ -17,8 +17,15 @@ export function shouldShowVerifyBanner( 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; } From 1d5a3d9f9b7cfdf5a89a582df4299a3ec371708b Mon Sep 17 00:00:00 2001 From: Polliog Date: Mon, 19 Oct 2026 10:25:07 +0200 Subject: [PATCH 38/54] terms checkbox and acceptance modal --- .../src/lib/components/AppLayout.svelte | 2 + .../components/TermsAcceptanceModal.svelte | 95 ++++++++++++++++ .../components/TermsAcceptanceModal.test.ts | 103 ++++++++++++++++++ .../src/routes/onboarding/+page.svelte | 2 + .../frontend/src/routes/register/+page.svelte | 37 ++++++- .../routes/register/register-terms.test.ts | 95 ++++++++++++++++ 6 files changed, 332 insertions(+), 2 deletions(-) create mode 100644 packages/frontend/src/lib/components/TermsAcceptanceModal.svelte create mode 100644 packages/frontend/src/lib/components/TermsAcceptanceModal.test.ts create mode 100644 packages/frontend/src/routes/register/register-terms.test.ts diff --git a/packages/frontend/src/lib/components/AppLayout.svelte b/packages/frontend/src/lib/components/AppLayout.svelte index 02efdb4a4..47e591526 100644 --- a/packages/frontend/src/lib/components/AppLayout.svelte +++ b/packages/frontend/src/lib/components/AppLayout.svelte @@ -51,6 +51,7 @@ 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"; @@ -828,6 +829,7 @@
+ {@render children?.()}
diff --git a/packages/frontend/src/lib/components/TermsAcceptanceModal.svelte b/packages/frontend/src/lib/components/TermsAcceptanceModal.svelte new file mode 100644 index 000000000..2f7e81401 --- /dev/null +++ b/packages/frontend/src/lib/components/TermsAcceptanceModal.svelte @@ -0,0 +1,95 @@ + + +{#if visible} +
+ +
+{/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..c0335f7a5 --- /dev/null +++ b/packages/frontend/src/lib/components/TermsAcceptanceModal.test.ts @@ -0,0 +1,103 @@ +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 { 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(); + }); +}); diff --git a/packages/frontend/src/routes/onboarding/+page.svelte b/packages/frontend/src/routes/onboarding/+page.svelte index b12b1c5fb..087478f8e 100644 --- a/packages/frontend/src/routes/onboarding/+page.svelte +++ b/packages/frontend/src/routes/onboarding/+page.svelte @@ -16,6 +16,7 @@ } from '$lib/components/onboarding'; import Footer from '$lib/components/Footer.svelte'; import EmailVerificationBanner from '$lib/components/EmailVerificationBanner.svelte'; + import TermsAcceptanceModal from '$lib/components/TermsAcceptanceModal.svelte'; let currentStep = $state('welcome'); let userName = $state(''); @@ -84,6 +85,7 @@ {#if isAuthenticated && !checkingOrgs} + {#if currentStep === 'welcome'} diff --git a/packages/frontend/src/routes/register/+page.svelte b/packages/frontend/src/routes/register/+page.svelte index 66d18ad6d..45b321898 100644 --- a/packages/frontend/src/routes/register/+page.svelte +++ b/packages/frontend/src/routes/register/+page.svelte @@ -42,6 +42,11 @@ let organizationsAPI = $derived(new OrganizationsAPI(() => token)); let checkingAuthConfig = $state(true); let signupDisabled = $state(false); + // Terms seam: set only when the backend advertises termsUrl/privacyUrl. + let termsUrl = $state(null); + let privacyUrl = $state(null); + let acceptTerms = $state(false); + let termsRequired = $derived(termsUrl !== null && privacyUrl !== null); // Check auth config on mount onMount(async () => { @@ -56,6 +61,10 @@ if (!config.signupEnabled) { signupDisabled = true; } + if (config.termsUrl && config.privacyUrl) { + termsUrl = config.termsUrl; + privacyUrl = config.privacyUrl; + } } catch (e) { console.error('Failed to get auth config:', e); } @@ -119,7 +128,12 @@ error = ''; try { - const response = await authAPI.register({ name, email, password }); + const response = await authAPI.register({ + name, + email, + password, + ...(termsRequired ? { acceptTerms } : {}), + }); if (!response.session) { // 201 without a session: the account exists but auto-login failed // transiently, so send the user to the login page instead of crashing. @@ -331,7 +345,26 @@ {/if}
-

-> **🌊 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. 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/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/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/components/Footer.svelte b/packages/frontend/src/lib/components/Footer.svelte index 97561fc5b..b382e9b2f 100644 --- a/packages/frontend/src/lib/components/Footer.svelte +++ b/packages/frontend/src/lib/components/Footer.svelte @@ -1,7 +1,7 @@ diff --git a/packages/reservoir/package.json b/packages/reservoir/package.json index 6842b9c13..313513bb5 100644 --- a/packages/reservoir/package.json +++ b/packages/reservoir/package.json @@ -1,6 +1,6 @@ { "name": "@logtide/reservoir", - "version": "1.3.2", + "version": "1.4.0", "description": "Pluggable storage abstraction for Logtide log management", "type": "module", "main": "./dist/index.js", diff --git a/packages/shared/package.json b/packages/shared/package.json index e92217f1b..5f6dca8bf 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@logtide/shared", - "version": "1.3.2", + "version": "1.4.0", "private": true, "description": "Shared types, schemas and utilities for LogTide", "type": "module",