diff --git a/.env.example b/.env.example index 9b0050ff..87fab8e9 100644 --- a/.env.example +++ b/.env.example @@ -336,3 +336,9 @@ LOGS=./logs # Do NOT rely on FRONTEND_API_URL in Compose. Instead, keep VITE_API_URL=/api and # let the frontend Nginx proxy /api to the backend. Only if you rebuild the frontend # should you change VITE_API_URL at build time. + +# Optional product usage (#1110): disabled until explicit in-app consent. +# USAGE_COLLECTOR_URL=https://usage.picpeak.app +# Backend signing-key encryption (32+ characters); defaults to JWT_SECRET. +# Keep this value stable until participation has been deleted. +# USAGE_ENCRYPTION_KEY= diff --git a/backend/.env.example b/backend/.env.example index 7f2fa965..3ed79921 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -119,4 +119,10 @@ ARCHIVE_PATH=/app/storage/events/archived # UMAMI_WEBSITE_ID=b4d3c2a1-5678-90ab-cdef-1234567890ab # Logging -LOG_LEVEL=info \ No newline at end of file +LOG_LEVEL=info + +# Optional product usage (#1110): disabled until explicit in-app consent. +# USAGE_COLLECTOR_URL=https://usage.picpeak.app +# Encryption material for the backend-only signing key (32+ characters). +# Defaults to JWT_SECRET; keep it stable until participation has been deleted. +# USAGE_ENCRYPTION_KEY= diff --git a/backend/__tests__/integration/productUsagePg.test.js b/backend/__tests__/integration/productUsagePg.test.js new file mode 100644 index 00000000..08306b90 --- /dev/null +++ b/backend/__tests__/integration/productUsagePg.test.js @@ -0,0 +1,279 @@ +/** + * PostgreSQL checks for product usage (#1110). + * + * Gated: runs only when PICPEAK_PG_TEST_URL points at a throwaway database, e.g. + * PICPEAK_PG_TEST_URL="postgres://picpeak:picpeak_secure_pass_2024@127.0.0.1:7102/picpeak_usage_pg_test" \ + * npx jest __tests__/integration/productUsagePg.test.js + * + * What SQLite cannot answer: + * - `cancel_seq` and `sequence` are bigint, and node-postgres returns bigint + * as a STRING. The withdrawal guard compares that value, so a `'1' !== 1` + * slip would let an activation complete after an opt-out — and SQLite, + * which hands back a number, would never show it. + * - booleans are real booleans here, not 0/1, which is what every + * `configured` signal in a report is built from. + * - markUsed takes SELECT ... FOR UPDATE on this engine only. + */ +const knex = require('knex'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { generateIdentity, makePacket } = require('../../src/usage/protocol.cjs'); + +const PG_URL = process.env.PICPEAK_PG_TEST_URL; +const maybe = PG_URL ? describe : describe.skip; + +maybe('product usage on Postgres', () => { + let db; + let UsageService; + + beforeAll(async () => { + // Its own schema, not `public`. CI hands every gated suite the same + // PICPEAK_PG_TEST_URL and runs jest with parallel workers, and both + // picpeakRestorePg and externalRelpathFoldPg drop and recreate `events` + // and `app_settings` there. Sharing that would have made all three + // intermittently destroy each other's fixtures. The service queries + // unqualified table names, so a searchPath keeps it entirely in here. + const bootstrap = knex({ + client: 'pg', connection: PG_URL, pool: { min: 0, max: 2 } + }); + await bootstrap.raw('DROP SCHEMA IF EXISTS usage_pg_test CASCADE'); + await bootstrap.raw('CREATE SCHEMA usage_pg_test'); + await bootstrap.destroy(); + + db = knex({ + client: 'pg', + connection: PG_URL, + searchPath: ['usage_pg_test'], + pool: { min: 0, max: 10 } + }); + // The real migrations, on the real engine. + await require('../../migrations/core/201_product_usage').up(db); + await require('../../migrations/core/202_product_usage_cancel_requested').up(db); + await require('../../migrations/core/203_product_usage_cancel_seq').up(db); + await require('../../migrations/core/204_product_usage_privacy_receipts').up(db); + await require('../../migrations/core/205_product_usage_consent_version').up(db); + await require('../../migrations/core/206_product_usage_delivery_backoff').up(db); + + await db.schema.createTable('app_settings', (t) => { + t.string('setting_key').primary(); t.text('setting_value'); t.string('setting_type'); + }); + await db.schema.createTable('feature_flags', (t) => { + t.string('key').primary(); t.boolean('value'); + }); + await db.schema.createTable('events', (t) => { + t.increments('id'); t.text('color_theme'); t.string('external_path'); t.integer('css_template_id'); + }); + await db.schema.createTable('css_templates', (t) => { + t.increments('id'); t.boolean('is_enabled'); t.text('css_content'); + }); + for (const table of ['email_configs', 'mail_accounts']) { + await db.schema.createTable(table, (t) => { t.increments('id'); t.string('smtp_host'); }); + } + await db.schema.createTable('whatsapp_configs', (t) => { + t.increments('id'); t.boolean('enabled'); t.string('phone_number_id'); t.string('access_token'); + }); + + ({ UsageService } = require('../../src/usage/UsageService')); + }, 120000); + + afterAll(async () => { + if (db) { + await db.raw('DROP SCHEMA IF EXISTS usage_pg_test CASCADE'); + await db.destroy(); + } + fs.rmSync(bindingDir, { recursive: true, force: true }); + }); + + beforeEach(async () => { + await db('product_usage_markers').delete(); + await db('product_usage_state').delete(); + await db('product_usage_state').insert({ id: 1 }); + await db('events').delete(); + await db('css_templates').delete(); + await db('feature_flags').delete(); + await db('app_settings').delete(); + }); + + // The instance-binding file defaults to STORAGE_PATH, which is '/storage' + // in a bare test process. Point it at a temp dir so the real binding code + // runs rather than being stubbed out. + const bindingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-usage-pg-')); + + const service = (over = {}) => + new UsageService(db, { + secret: 'p'.repeat(48), + endpoint: 'http://127.0.0.1:9/', + bindingPath: path.join(bindingDir, 'usage-instance.key'), + fetch: async () => { throw new Error('collector unreachable in tests'); }, + ...over, + }); + + it('creates the columns with the types the code expects', async () => { + const cols = await db('product_usage_state').columnInfo(); + expect(cols.cancel_seq).toBeDefined(); + expect(cols.cancel_requested).toBeUndefined(); // dropped by 203 + expect(cols.sequence).toBeDefined(); + expect(cols.privacy_receipts).toBeDefined(); + expect(cols.consent_version).toBeDefined(); + // next_attempt_at is a bigint like sequence and cancel_seq, so pg hands it + // back as a STRING — the tick() gate compares it against a number. + expect(cols.attempts).toBeDefined(); + expect(cols.next_attempt_at).toBeDefined(); + }); + + it('reruns the backoff migration safely', async () => { + const migration = require('../../migrations/core/206_product_usage_delivery_backoff'); + await migration.up(db); + await migration.up(db); + const row = await db('product_usage_state').where({ id: 1 }).first(); + expect(Number(row.attempts)).toBe(0); + expect(Number(row.next_attempt_at)).toBe(0); + }); + + it('honours the retry gate even though pg returns next_attempt_at as a string', async () => { + let clock = 5_000_000; + let calls = 0; + const identity = generateIdentity(); + const client = service({ + now: () => clock, + fetch: async () => { calls += 1; throw new Error('collector unreachable'); }, + }); + await db('product_usage_state').where({ id: 1 }).update({ + status: 'active', + consent_version: 'usage-consent.v2', + installation_id: identity.installation_id, + public_key: identity.public_key, + private_key_encrypted: client.encrypt(identity.private_key), + sequence: 1, + attempts: 0, + next_attempt_at: 0, + pending_packet: JSON.stringify(makePacket(identity, 'session', 2, {}, 'usage.v2')), + }); + + await client.tick(); + expect(calls).toBe(1); + const paced = await db('product_usage_state').where({ id: 1 }).first(); + // A '5000120000' > 5000000 string comparison would be a different answer. + expect(typeof paced.next_attempt_at).toBe('string'); + await client.tick(); + expect(calls).toBe(1); + + clock = Number(paced.next_attempt_at) + 1; + await client.tick(); + expect(calls).toBe(2); + + await db('product_usage_state').where({ id: 1 }).update({ + status: 'disabled', pending_packet: null, attempts: 0, next_attempt_at: 0, + }); + }); + + it('reruns the receipt migration safely and scrubs legacy plaintext sessions', async () => { + const migration = require('../../migrations/core/204_product_usage_privacy_receipts'); + await db('product_usage_state').where({ id: 1 }).update({ + last_receipt: JSON.stringify({ status: 'accepted', session_token: 'synthetic-old-token' }) + }); + await migration.up(db); + await migration.up(db); + const row = await db('product_usage_state').where({ id: 1 }).first(); + expect(JSON.parse(row.last_receipt)).toEqual({ status: 'accepted' }); + }); + + it('migration preserves v1 consent and v2 snapshot works with PostgreSQL booleans and optional modules', async () => { + const migration = require('../../migrations/core/205_product_usage_consent_version'); + await migration.up(db); await migration.up(db); + const svc = service(); + await db('product_usage_state').where({ id: 1 }).update({ status: 'active' }); + await svc.markUsed(['video_uploads']); + expect(await db('product_usage_markers').pluck('feature')).toEqual([]); + expect((await svc.status()).schema_version).toBe('usage.v1'); + await db('product_usage_state').where({ id: 1 }).update({ consent_version: 'usage-consent.v2' }); + await db('feature_flags').insert({ key: 'quotes', value: true }); + await db('app_settings').insert({ setting_key: 'general_allowed_file_types', setting_value: '"dng,mp4"' }); + await svc.markUsed(['video_uploads', 'gallery_downloads']); + const report = await svc.snapshot(); + expect(Object.keys(report.features)).toHaveLength(73); + expect(report.features.video_uploads).toEqual({ configured: true, used: true }); + expect(report.features.camera_raw_uploads).toEqual({ configured: true, used: false }); + expect(report.features.gallery_downloads).toEqual({ configured: false }); + expect(report.features.crm.configured).toBe(true); + expect(report.features.api_integration.configured).toBe(false); + }); + + it('reads bigint cancel_seq correctly even though pg returns it as a string', async () => { + await db('product_usage_state').where({ id: 1 }).update({ cancel_seq: 5 }); + const row = await db('product_usage_state').where({ id: 1 }).first(); + // The thing SQLite hides: this is a string here. + expect(typeof row.cancel_seq).toBe('string'); + expect(Number(row.cancel_seq)).toBe(5); + }); + + it('honours a withdrawal that lands while an activation is starting', async () => { + const svc = service(); + const realBinding = svc.binding.bind(svc); + svc.binding = async (create = false) => { + // The withdrawal lands inside the window where the row still reads + // `disabled`, with the real binding write still happening. + if (create) await svc.disable(); + return realBinding(create); + }; + await svc.enable('usage-consent.v1'); + + const row = await db('product_usage_state').where({ id: 1 }).first(); + expect(row.status).toBe('disabled'); + expect(row.installation_id).toBeNull(); + }); + + it('activates when no withdrawal arrives', async () => { + await service().enable('usage-consent.v1'); + const row = await db('product_usage_state').where({ id: 1 }).first(); + expect(row.status).toBe('activation_pending'); + expect(row.installation_id).not.toBeNull(); + }); + + it('records markers only while active, using SELECT ... FOR UPDATE', async () => { + const svc = service(); + await svc.markUsed(['crm']); + expect(await db('product_usage_markers').count('* as c').first()).toMatchObject({ c: '0' }); + + await db('product_usage_state').where({ id: 1 }).update({ status: 'active' }); + await svc.markUsed(['crm', 'newsletters']); + const rows = await db('product_usage_markers').pluck('feature'); + expect(rows.sort()).toEqual(['crm', 'newsletters']); + + // onConflict().ignore() must not throw on a repeat. + await svc.markUsed(['crm']); + expect((await db('product_usage_markers').pluck('feature')).length).toBe(2); + }); + + it('builds a report from real booleans, not 0/1', async () => { + await db('feature_flags').insert([ + { key: 'clients', value: true }, + { key: 'newsletters', value: false }, + ]); + await db('product_usage_state').where({ id: 1 }).update({ status: 'active' }); + await service().markUsed(['crm']); + + const report = await service().snapshot(); + expect(report.features.crm.configured).toBe(true); + expect(report.features.crm.used).toBe(true); + expect(report.features.newsletters.configured).toBe(false); + }); + + it('resolves preset layouts and template CSS on this engine too', async () => { + const [tpl] = await db('css_templates').insert({ is_enabled: true, css_content: '.a{}' }).returning('id'); + const templateId = typeof tpl === 'object' ? tpl.id : tpl; + await db('events').insert([ + { color_theme: 'modernMasonry' }, + { color_theme: null, css_template_id: templateId }, + ]); + await db('app_settings').insert({ + setting_key: 'theme_config', + setting_value: JSON.stringify({ galleryLayout: 'carousel' }), + }); + + const report = await service().snapshot(); + expect(report.gallery_layouts.sort()).toEqual(['carousel', 'masonry']); + expect(report.features.custom_css.configured).toBe(true); + }); +}); diff --git a/backend/__tests__/routes/adminUsage.test.js b/backend/__tests__/routes/adminUsage.test.js new file mode 100644 index 00000000..6d515052 --- /dev/null +++ b/backend/__tests__/routes/adminUsage.test.js @@ -0,0 +1,320 @@ +const request = require('supertest'); +const express = require('express'); +const jwt = require('jsonwebtoken'); +const mockDb = require('knex')({ + client: 'sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true +}); +jest.mock('../../src/database/db', () => ({ + get db() { + return mockDb; + } +})); +jest.mock('../../src/utils/tokenRevocation', () => ({ + isTokenRevoked: jest.fn().mockResolvedValue(false) +})); +jest.mock('../../src/utils/sessionCutoff', () => ({ + isTokenBeforeCutoff: jest.fn().mockResolvedValue(false) +})); +jest.mock('../../src/utils/logger', () => ({ + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + info: jest.fn() +})); +jest.mock('../../src/services/productUsageService', () => + Object.fromEntries( + [ + 'tick', + 'status', + 'dismiss', + 'enable', + 'disable', + 'abandon', + 'preview', + 'export', + 'preferences', + 'command', + 'markUsed' + ].map((key) => [key, jest.fn().mockResolvedValue({ status: 'disabled' })]) + ) +); +const service = require('../../src/services/productUsageService'); +const { productUsage } = require('../../src/middleware/productUsage'); +const { productUsageApi } = require('../../src/middleware/productUsage'); +const SECRET = 'usage-auth-test-secret-not-a-live-credential'; +const token = (type, id = 1) => + jwt.sign({ type, id }, SECRET, { + issuer: 'picpeak-auth', + algorithm: 'HS256' + }); +let app; +beforeAll(async () => { + process.env.JWT_SECRET = SECRET; + await mockDb.schema.createTable('roles', (t) => { + t.increments('id'); + t.string('name'); + }); + await mockDb.schema.createTable('admin_users', (t) => { + t.increments('id'); + t.string('username'); + t.string('email'); + t.integer('role_id'); + t.boolean('is_active'); + t.timestamp('password_changed_at'); + }); + await mockDb.schema.createTable('permissions', (t) => { + t.increments('id'); + t.string('name'); + }); + await mockDb.schema.createTable('role_permissions', (t) => { + t.integer('role_id'); + t.integer('permission_id'); + }); + await mockDb('roles').insert([ + { id: 1, name: 'super_admin' }, + { id: 2, name: 'viewer' } + ]); + await mockDb('admin_users').insert([ + { + id: 1, + username: 'owner', + email: 'owner@example.test', + role_id: 1, + is_active: 1 + }, + { + id: 2, + username: 'viewer', + email: 'viewer@example.test', + role_id: 2, + is_active: 1 + } + ]); + await mockDb('permissions').insert({ id: 1, name: 'settings.edit' }); + await mockDb('role_permissions').insert({ role_id: 1, permission_id: 1 }); + app = express(); + app.use(express.json()); + app.use('/api/admin/usage', require('../../src/routes/adminUsage')); + app.use((err, _req, res, _next) => + res.status(err.statusCode || 500).json({ code: err.code }) + ); +}); +afterAll(() => mockDb.destroy()); +beforeEach(() => jest.clearAllMocks()); + +test('scoped API use records only its fixed v2 capability and never triggers a report', () => { + const simulate = (admin, apiToken, statusCode) => { + const res = new (require('events').EventEmitter)(); res.statusCode = statusCode; + productUsageApi({ admin, apiToken, body: { user: 'PRIVATE@example.test' } }, res, () => {}); + res.emit('finish'); + }; + simulate(null, { id: 99 }, 200); + simulate({ id: 42 }, null, 200); + simulate({ id: 42 }, { id: 99 }, 403); + expect(service.markUsed).not.toHaveBeenCalled(); + simulate({ id: 42 }, { id: 99 }, 200); + expect(service.markUsed).toHaveBeenCalledWith(['api_integration'], { legacyFeatures: [] }); + expect(service.tick).not.toHaveBeenCalled(); + expect(JSON.stringify(service.markUsed.mock.calls)).not.toMatch(/PRIVATE|42|99/); +}); + +const ROUTES = [ + ['get', '/'], + ['post', '/activity'], + ['post', '/enable'], + ['post', '/consent'], + ['post', '/disable'], + ['post', '/abandon'], + ['post', '/retry'], + ['post', '/dismiss'], + ['get', '/preview'], + ['get', '/export'], + ['put', '/feedback-preferences'], + ['post', '/feedback'], + ['post', '/vote'], + ['post', '/portal-session'] +]; +test.each(ROUTES)( + '%s %s rejects unauthenticated and gallery tokens', + async (method, route) => { + await request(app)[method](`/api/admin/usage${route}`).send({}).expect(401); + await request(app)[method](`/api/admin/usage${route}`) + .set('Authorization', `Bearer ${token('gallery')}`) + .send({}) + .expect(403); + expect(service.tick).not.toHaveBeenCalled(); + expect(service.enable).not.toHaveBeenCalled(); + } +); +test.each(ROUTES.filter(([, route]) => route !== '/activity'))( + '%s %s requires settings.edit', + async (method, route) => { + await request(app)[method](`/api/admin/usage${route}`) + .set('Authorization', `Bearer ${token('admin', 2)}`) + .send({}) + .expect(403); + } +); +test('an authenticated admin can trigger cadence without seeing identity or packet data', async () => { + const response = await request(app) + .post('/api/admin/usage/activity') + .set('Authorization', `Bearer ${token('admin', 2)}`) + .expect(200); + expect(response.body).toEqual({ ok: true }); + expect(service.tick).toHaveBeenCalledTimes(1); +}); +test('owner sees no-store status and supplies consent to the service', async () => { + await request(app) + .get('/api/admin/usage') + .set('Authorization', `Bearer ${token('admin')}`) + .expect('Cache-Control', 'no-store') + .expect(200); + await request(app) + .post('/api/admin/usage/enable') + .set('Authorization', `Bearer ${token('admin')}`) + .send({ consent_version: 'usage-consent.v1' }) + .expect(200); + expect(service.enable).toHaveBeenCalledWith('usage-consent.v1'); +}); +test('public/gallery paths and failed/unauthenticated admin operations never set feature markers', async () => { + const { EventEmitter } = require('events'); + const simulate = (path, admin, statusCode) => { + const res = new EventEmitter(); + res.locals = {}; + res.statusCode = statusCode; + productUsage({ path, method: 'POST', admin }, res, () => {}); + res.emit('finish'); + }; + simulate('/gallery/example', null, 200); + simulate('/customers', null, 200); + simulate('/quotes', { id: 1 }, 403); + expect(service.markUsed).not.toHaveBeenCalled(); + simulate('/customers/42/hour-entries', { id: 1 }, 200); + // The second argument tells markUsed whether this operation writes to the + // configured backup destination; a CRM route never does. + expect(service.markUsed).toHaveBeenCalledWith( + expect.arrayContaining(['crm', 'crm_hours']), + expect.objectContaining({ destinationBackup: false }) + ); + expect(JSON.stringify(service.markUsed.mock.calls)).not.toContain('42'); +}); + +test('consent upgrade accepts exactly the explicit v2 choice, never extra fields', async () => { + for (const data of [{}, { consent_version: 'usage-consent.v1' }, { consent_version: 'usage-consent.v2', user: 'PRIVATE' }]) + await request(app).post('/api/admin/usage/consent').set('Authorization', `Bearer ${token('admin')}`).send(data).expect(400); + expect(service.command).not.toHaveBeenCalled(); + await request(app).post('/api/admin/usage/consent').set('Authorization', `Bearer ${token('admin')}`) + .send({ consent_version: 'usage-consent.v2' }).expect(200); + expect(service.command).toHaveBeenCalledWith('consent', { consent_version: 'usage-consent.v2' }); +}); + +test('only a backup that writes to the configured destination flags S3', () => { + // /database-backup/* and /backup/picpeak/export produce a local file, so + // they must not imply S3 use just because S3 is the configured destination. + const seen = []; + const simulate = (pathname) => { + service.markUsed.mockClear(); + const res = new (require('events').EventEmitter)(); + res.locals = {}; + res.statusCode = 200; + productUsage({ path: pathname, method: pathname.endsWith('/export') ? 'GET' : 'POST', admin: { id: 1 } }, res, () => {}); + res.emit('finish'); + seen.push([pathname, service.markUsed.mock.calls[0]?.[1]?.destinationBackup]); + }; + simulate('/backup/run'); + simulate('/database-backup/backup'); + simulate('/backup/picpeak/export'); + expect(seen).toEqual([ + ['/backup/run', true], + ['/database-backup/backup', false], + ['/backup/picpeak/export', false], + ]); +}); + +// The route allowlist and the packet schema have to agree. The allowlist used +// to let `name`, `allow_public` and `allow_marketing` be omitted while the +// schema requires all three, so an API caller got a bare INVALID_PACKET from +// deep inside signing instead of being told which field was missing. +const VALID_FEEDBACK = { + kind: 'feedback', + title: 'Title', + body: 'Body', + name: '', + allow_public: false, + allow_marketing: false +}; +test.each([ + ['no body at all', {}], + ['missing name', { ...VALID_FEEDBACK, name: undefined }], + ['missing allow_public', { ...VALID_FEEDBACK, allow_public: undefined }], + ['missing allow_marketing', { ...VALID_FEEDBACK, allow_marketing: undefined }], + ['a boolean sent as a string', { ...VALID_FEEDBACK, allow_public: 'true' }], + ['a title of only whitespace', { ...VALID_FEEDBACK, title: ' ' }], + ['an unknown field', { ...VALID_FEEDBACK, ownerId: 7 }] +])('feedback rejects %s before anything is signed', async (_label, data) => { + const response = await request(app) + .post('/api/admin/usage/feedback') + .set('Authorization', `Bearer ${token('admin')}`) + .send(JSON.parse(JSON.stringify(data))) + .expect(400); + // Named, not a bare protocol failure the caller cannot act on. + expect(response.body.code).toBe('VALIDATION_ERROR'); + expect(service.command).not.toHaveBeenCalled(); +}); +test('feedback accepts the complete payload and mints the id server-side', async () => { + await request(app) + .post('/api/admin/usage/feedback') + .set('Authorization', `Bearer ${token('admin')}`) + .send({ ...VALID_FEEDBACK, name: 'QA' }) + .expect(200); + expect(service.command).toHaveBeenCalledWith( + 'feedback', + expect.objectContaining({ name: 'QA', feedback_id: expect.any(String) }) + ); +}); + +// Runs last on purpose: the limiter's budget is per-process and shared with +// every test above that reaches an outbound route, so consuming it here cannot +// starve them. The assertion is deliberately about the property — some request +// is refused and the service stops being called — rather than an exact count, +// which would depend on how much budget earlier tests used. +test('the outbound routes are throttled so an admin session cannot flood the collector', async () => { + const codes = []; + for (let i = 0; i < 45; i += 1) { + const response = await request(app) + .post('/api/admin/usage/feedback') + .set('Authorization', `Bearer ${token('admin')}`) + .send({ ...VALID_FEEDBACK, title: `flood ${i}` }); + codes.push(response.status); + if (response.status === 429) { + expect(response.body.code).toBe('USAGE_RATE_LIMITED'); + break; + } + } + expect(codes).toContain(429); + expect(service.command.mock.calls.length).toBeLessThan(codes.length); + + // The same budget covers the other two routes that relay to the collector. + await request(app) + .post('/api/admin/usage/vote') + .set('Authorization', `Bearer ${token('admin')}`) + .send({ feedback_id: '11111111-1111-4111-8111-111111111111', voted: true }) + .expect(429); + await request(app) + .post('/api/admin/usage/portal-session') + .set('Authorization', `Bearer ${token('admin')}`) + .expect(429); + + // Reading status and withdrawing must never be throttled: those are how an + // operator sees what is happening and how they get out. + await request(app) + .get('/api/admin/usage') + .set('Authorization', `Bearer ${token('admin')}`) + .expect(200); + await request(app) + .post('/api/admin/usage/disable') + .set('Authorization', `Bearer ${token('admin')}`) + .expect(200); +}); diff --git a/backend/__tests__/services/usageCoverageInventory.test.js b/backend/__tests__/services/usageCoverageInventory.test.js new file mode 100644 index 00000000..c365eed2 --- /dev/null +++ b/backend/__tests__/services/usageCoverageInventory.test.js @@ -0,0 +1,111 @@ +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const catalog = require('../../src/usage/features.v2.json'); +const inventory = require('../../../docs/usage-coverage.v2.json'); +const protocol = require('../../src/usage/schema.cjs'); +const { RULES_V2, capabilityKeys } = require('../../src/usage/capabilityRules'); +const { acceptedUpload, capabilityEvidence } = require('../../src/usage/capabilityEvidence'); + +test('every route family and literal route declaration has an explicit privacy decision', () => { + const root = path.resolve(__dirname, '../../src/routes'); + const actual = {}; + function walk(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name === '__tests__') continue; + const file = path.join(dir, entry.name); + if (entry.isDirectory()) walk(file); + else if (entry.name.endsWith('.js')) { + const source = fs.readFileSync(file, 'utf8'); + actual[path.relative(root, file)] = [...source.matchAll(/router\.(get|post|put|patch|delete)\(\s*(['"])([^'"]+)\2/g)] + .map((m) => `${m[1].toUpperCase()} ${m[3]}`); + } + } + } + walk(root); + expect(Object.keys(inventory.route_families).sort()).toEqual(Object.keys(actual).sort()); + for (const [file, decision] of Object.entries(inventory.route_families)) { + expect(decision.reason.length).toBeGreaterThan(30); + expect(decision.route_signatures).toEqual(actual[file]); + for (const signal of decision.signals) expect(catalog.features[signal]).toBeDefined(); + } +}); + +test('all flags and catalog capabilities have a documented decision', () => { + const source = fs.readFileSync(path.resolve(__dirname, '../../src/routes/adminFeatureFlags.js'), 'utf8'); + const array = source.match(/const KNOWN_FLAGS = \[([\s\S]*?)\];/)[1].replace(/\/\/[^\n]*/g, ''); + const flags = [...array.matchAll(/'([^']+)'/g)].map((m) => m[1]); + expect(Object.keys(inventory.feature_flags).sort()).toEqual(flags.sort()); + for (const key of protocol.FEATURE_KEYS) + expect(Object.values(inventory.route_families).some((family) => family.signals.includes(key))).toBe(true); + expect(inventory.configuration_only.sort()).toEqual(protocol.FEATURE_KEYS.filter((key) => !protocol.observesUse(key)).sort()); +}); + +test('all current settings tabs have an explicit scope decision', () => { + const source = fs.readFileSync(path.resolve(__dirname, '../../../frontend/src/pages/admin/SettingsPage.tsx'), 'utf8'); + const union = source.match(/type TabType =([\s\S]*?);/)[1].replace(/\/\/[^\n]*/g, ''); + const tabs = [...union.matchAll(/'([^']+)'/g)].map((m) => m[1]); + expect(Object.keys(inventory.settings_tabs).sort()).toEqual(tabs.sort()); + for (const entry of Object.values(inventory.settings_tabs)) { + expect(entry.reason.length).toBeGreaterThan(20); + for (const key of entry.signals) expect(catalog.features[key]).toBeDefined(); + } +}); + +test('v1 wire validation is immutable; catalog, UI and translated descriptions agree', () => { + expect(crypto.createHash('sha256').update(JSON.stringify(protocol.envelopeSchemas['usage.v1'].properties)).digest('hex')) + .toBe('cc8d0a865d21e36d2b24d23ca6aa8dd8d48000cb17aef83996786f70755bc922'); + expect(protocol.FEATURE_KEYS).toHaveLength(73); + expect(protocol.LEGACY_FEATURE_KEYS).toHaveLength(19); + expect(inventory.configuration_only).toHaveLength(17); + const frontend = path.resolve(__dirname, '../../../frontend'); + expect(JSON.parse(fs.readFileSync(path.join(frontend, 'src/features/settings/usageFeatures.v2.json')))).toEqual(catalog); + for (const lang of ['en', 'de']) { + const translated = JSON.parse(fs.readFileSync(path.join(frontend, `src/i18n/locales/${lang}.json`))).productUsage.catalog; + for (const [key, value] of Object.entries(catalog.features)) { + expect(translated[key]).toEqual({ name: value.name[lang], configured: value.configured[lang], ...(value.used ? { used: value.used[lang] } : {}) }); + } + } +}); + +test('every used field has either a fixed route rule or explicit trusted success evidence', () => { + const explicit = ['custom_css', 'oauth', 'smtp', 'email_webhook', 'whatsapp', 'incoming_mail', + 'video_uploads', 'camera_raw_uploads', 's3_storage', 's3_photo_storage', 's3_backups', 'api_integration']; + const covered = new Set([...explicit, ...RULES_V2.flatMap(([, , keys]) => keys)]); + expect(protocol.FEATURE_KEYS.filter(protocol.observesUse).filter((key) => !covered.has(key))).toEqual([]); + for (const key of covered) expect(protocol.observesUse(key)).toBe(true); +}); + +test.each([ + ['POST', '/events', 'galleries'], ['POST', '/events/123/publish', 'galleries'], + ['POST', '/photos/repair-dimensions', 'photo_processing'], ['GET', '/events/123/photos/456/download', 'photo_exports'], + ['PUT', '/events/123/slideshow', 'slideshow'], ['POST', '/expenses/inbound', 'accounting_incoming_invoices'], + ['POST', '/expenses', 'accounting_expenses'], ['GET', '/tax-report/csv', 'accounting_tax_report'], + ['POST', '/deals/123/installment-plan', 'crm_installments'], ['GET', '/ledger/export', 'accounting_ledger'], + ['POST', '/quotes/presets', 'document_templates'], ['PUT', '/cms/pages/home', 'cms'], + ['POST', '/webhooks/123/test', 'webhooks'], ['POST', '/webhooks/123/deliveries/456/replay', 'webhooks'], + ['POST', '/email/send', 'messaging'], ['PUT', '/feedback/feedback/123/approve', 'feedback_moderation'], + ['GET', '/events/123/guests/export-all', 'guest_management'], ['POST', '/backup/picpeak/import', 'portable_backup'], + ['PUT', '/roles/123', 'admin_management'], ['POST', '/newsletters/123/queue', 'newsletters'] +])('fixed allowlist recognizes %s %s', (method, url, expected) => { + expect(capabilityKeys(method, url)).toContain(expected); + expect(JSON.stringify(capabilityKeys(method, url))).not.toContain('123'); +}); + +test.each([ + ['GET', '/events/faces/health'], ['GET', '/photos/repair-dimensions/status'], + ['POST', '/events/123/validate-rename'], ['POST', '/photos/123/chunked-upload/init'], + ['POST', '/photos/123/chunked-upload/456/chunk/0'], ['GET', '/dashboard/health'], + ['GET', '/customers'], ['GET', '/email/queue'], ['POST', '/email/flush-queue'], + ['POST', '/newsletters/123/recipients/resolve'], ['POST', '/newsletters/123/preview'], + ['POST', '/users/123/reset-password'], ['PUT', '/settings/security'], + ['POST', '/gallery/a/feedback'], ['POST', '/public/newsletter/unsubscribe'], + ['POST', '/customer/quotes/123/accept'], ['POST', '/usage/consent'] +])('no v2 observation for excluded %s %s', (method, url) => expect(capabilityKeys(method, url)).toEqual([])); + +test('trusted upload evidence retains only constant keys and configuration-only use cannot be recorded', () => { + const res = { locals: {} }; + acceptedUpload(res, { video: true, raw: true, s3: true }); + capabilityEvidence(res, 'PRIVATE-user@example.test', 'gallery_feedback_likes'); + expect(res.locals.productUsageFeatures.sort()).toEqual(['photo_management', 'video_uploads', 'camera_raw_uploads', 's3_storage', 's3_photo_storage'].sort()); +}); diff --git a/backend/__tests__/services/usageEnableDisableRace.test.js b/backend/__tests__/services/usageEnableDisableRace.test.js new file mode 100644 index 00000000..b7d24ff8 --- /dev/null +++ b/backend/__tests__/services/usageEnableDisableRace.test.js @@ -0,0 +1,227 @@ +/** + * /disable overlapping an in-flight /enable (#1110). + * + * While activation generates an identity and writes its binding file the row + * still reads `disabled`, so disable()'s conditional update matched nothing + * and the lease conflict from its tick() was swallowed. The admin was told + * participation was off, and the activation then completed and left it on — + * an opt-out silently ignored, which is the one thing this feature cannot do. + * + * enable() now claims its state with a single conditional UPDATE that also + * tests the cancellation flag, so whichever lands first wins outright. + */ +const knex = require('knex'); +const { UsageService } = require('../../src/usage/UsageService'); + +const SECRET = 'z'.repeat(48); + +// A report the envelope schema accepts. An empty payload fails validation +// during signing, so the packet would never reach the collector for reasons +// unrelated to what the test is checking. +function validReport() { + const { LEGACY_FEATURE_KEYS: FEATURE_KEYS } = require('../../src/usage/protocol.cjs'); + return { + picpeak_version: '3.0.0', + report_date: '2026-09-05', + generated_at: '2026-09-05T00:00:00.000Z', + features: Object.fromEntries( + FEATURE_KEYS.map((k) => [k, { configured: false, used: false }]) + ), + gallery_layouts: ['grid'], + }; +} + +async function bootDb() { + const db = knex({ + client: 'sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + await db.schema.createTable('product_usage_state', (t) => { + t.integer('id').primary(); + t.string('status', 30).notNullable().defaultTo('disabled'); + t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v1'); + t.boolean('notice_dismissed').notNullable().defaultTo(false); + t.string('installation_id', 64); + t.string('public_key', 59); + t.text('private_key_encrypted'); + t.string('instance_binding', 64); + t.bigInteger('sequence').notNullable().defaultTo(0); + t.text('pending_packet'); + t.text('last_packet'); + t.text('last_receipt'); + t.text('privacy_receipts'); + t.string('last_report_date', 10); + t.string('last_error', 80); + t.text('feedback_preferences'); + t.string('lease_token', 36); + t.bigInteger('lease_until').notNullable().defaultTo(0); + t.bigInteger('cancel_seq').notNullable().defaultTo(0); + t.integer('attempts').notNullable().defaultTo(0); + t.bigInteger('next_attempt_at').notNullable().defaultTo(0); + }); + await db.schema.createTable('product_usage_markers', (t) => { + t.string('feature', 60).primary(); + }); + await db('product_usage_state').insert({ id: 1 }); + return db; +} + +/** A service whose binding() is slow, so the race window is controllable. */ +function makeService(db, { onBinding } = {}) { + const service = new UsageService(db, { + secret: SECRET, + endpoint: 'http://127.0.0.1:9/', + fetch: async () => { throw new Error('collector must not be reached'); }, + }); + const realBinding = service.binding.bind(service); + service.binding = async (create = false) => { + if (create && onBinding) await onBinding(); + return realBinding === undefined ? 'x'.repeat(64) : 'b'.repeat(64); + }; + return service; +} + +describe('withdrawal during an in-flight activation', () => { + let db; + afterEach(async () => { if (db) await db.destroy(); db = null; }); + + it('honours a /disable that lands while /enable is still generating its identity', async () => { + db = await bootDb(); + let disableDone; + const service = makeService(db, { + // Fires inside enable(), before it claims the row — exactly the window + // where the status still reads `disabled`. + onBinding: async () => { disableDone = await service.disable(); }, + }); + + await service.enable('usage-consent.v1'); + + const row = await db('product_usage_state').where({ id: 1 }).first(); + expect(row.status).toBe('disabled'); + // Nothing was registered, so there is no identity and nothing to delete. + expect(row.installation_id).toBeNull(); + expect(row.pending_packet).toBeNull(); + expect(disableDone.status).toBe('disabled'); + }); + + it('activates normally when no withdrawal arrives', async () => { + db = await bootDb(); + const service = makeService(db); + await service.enable('usage-consent.v1'); + + const row = await db('product_usage_state').where({ id: 1 }).first(); + // The collector is unreachable here, so it stops at activation_pending — + // the point is that the claim succeeded and an identity exists. + expect(row.status).toBe('activation_pending'); + expect(row.installation_id).not.toBeNull(); + }); + + it('does not let a stale cancellation veto a later deliberate opt-in', async () => { + db = await bootDb(); + // A withdrawal from an earlier participation is already reflected in the + // counter when this activation reads it, so it cannot veto anything. + await db('product_usage_state').where({ id: 1 }).update({ cancel_seq: 7 }); + + const service = makeService(db); + await service.enable('usage-consent.v1'); + + const row = await db('product_usage_state').where({ id: 1 }).first(); + expect(row.status).toBe('activation_pending'); + expect(row.installation_id).not.toBeNull(); + }); + + it('honours a withdrawal even when an earlier one was never cleared', async () => { + // The case a boolean could not express: a stale cancellation is already + // set, and a fresh one lands mid-activation. With a flag both look the + // same; with a counter the second increment is visible. + db = await bootDb(); + await db('product_usage_state').where({ id: 1 }).update({ cancel_seq: 3 }); + + const service = makeService(db, { + onBinding: async () => { await service.disable(); }, + }); + await service.enable('usage-consent.v1'); + + const row = await db('product_usage_state').where({ id: 1 }).first(); + expect(row.status).toBe('disabled'); + expect(row.installation_id).toBeNull(); + }); + + it('does not dispatch a report when the withdrawal completes during preparation', async () => { + // deliver() checks for a withdrawal before the binding lookup, which is + // asynchronous. A /disable that COMPLETED during it used to have the + // report sent anyway — not an already-in-flight request, but a new one + // started after the operator had withdrawn. + db = await bootDb(); + const posted = []; + const service = new UsageService(db, { + secret: SECRET, + endpoint: 'http://127.0.0.1:9/', + fetch: async (_url, init) => { + posted.push(JSON.parse(init.body).packet.action); + throw new Error('collector unreachable'); + }, + }); + const identity = require('../../src/usage/protocol.cjs').generateIdentity(); + await db('product_usage_state').where({ id: 1 }).update({ + status: 'active', + installation_id: identity.installation_id, + public_key: identity.public_key, + private_key_encrypted: service.encrypt(identity.private_key), + instance_binding: 'b'.repeat(64), + sequence: 1, + pending_packet: JSON.stringify( + require('../../src/usage/protocol.cjs').makePacket( + { installation_id: identity.installation_id }, + 'report', + 2, + validReport(), + 'usage.v1' + ) + ), + }); + // The withdrawal lands while the binding lookup is awaited. + service.binding = async () => { + await db('product_usage_state').where({ id: 1 }).update({ + status: 'deletion_pending', pending_packet: null, + }); + return 'b'.repeat(64); + }; + + await service.deliver(await db('product_usage_state').where({ id: 1 }).first()); + + expect(posted).not.toContain('report'); + }); + + it('honours a withdrawal that lands between the lease claim and the state read', async () => { + // locked() claims the lease and reads the row in two statements. A + // /disable completing in that gap used to be adopted as this + // activation's own baseline and absorbed, so registration went ahead + // after the operator had withdrawn. + db = await bootDb(); + const service = makeService(db); + const realState = service.state.bind(service); + let fired = false; + service.state = async () => { + // The withdrawal must land BEFORE this read returns, so the row carries + // the incremented counter. Incrementing afterwards would hand back the + // old value and both the broken and fixed versions would behave the + // same — which is exactly how an earlier version of this test passed + // against the bug it was meant to catch. + const first = await realState(); + if (!fired && first.lease_token) { + fired = true; + await db('product_usage_state').where({ id: 1 }).increment('cancel_seq', 1); + return realState(); + } + return first; + }; + + await service.enable('usage-consent.v1'); + + const row = await db('product_usage_state').where({ id: 1 }).first(); + expect(row.status).toBe('disabled'); + expect(row.installation_id).toBeNull(); + }); +}); diff --git a/backend/__tests__/services/usageExportReceipt.test.js b/backend/__tests__/services/usageExportReceipt.test.js new file mode 100644 index 00000000..1dd4b5f0 --- /dev/null +++ b/backend/__tests__/services/usageExportReceipt.test.js @@ -0,0 +1,147 @@ +/** + * Two things the participant is entitled to have stated exactly. + * + * The export receipt is a privacy document — the artefact an operator shows a + * third party — so a count in it has to mean what its label says. It counted + * every packet in the participation (feedback, votes, portal sessions, the + * registration) and called the total "usage reports": an install that had sent + * one report and twenty feedback items reported twenty-one reports. + * + * The delete packet's sequence is the other: it reuses the last ACCEPTED + * sequence rather than taking the next one, unlike every other action. That is + * a contract with the collector, not an implementation detail — if the + * collector ever enforced strictly increasing sequences per installation, the + * withdrawal would be rejected forever and the operator could never leave. It + * is pinned here so the assumption is written down and a change to it has to + * be deliberate. + */ +const knex = require('knex'); +const { UsageService } = require('../../src/usage/UsageService'); +const { generateIdentity, verifyEnvelope } = require('../../src/usage/protocol.cjs'); + +const SECRET = 's'.repeat(48); + +async function bootDb() { + const db = knex({ + client: 'sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + await db.schema.createTable('product_usage_state', (t) => { + t.integer('id').primary(); + t.string('status', 30).notNullable().defaultTo('disabled'); + t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v2'); + t.boolean('notice_dismissed').notNullable().defaultTo(false); + t.string('installation_id', 64); + t.string('public_key', 59); + t.text('private_key_encrypted'); + t.string('instance_binding', 64); + t.bigInteger('sequence').notNullable().defaultTo(0); + t.text('pending_packet'); + t.text('last_packet'); + t.text('last_receipt'); + t.text('privacy_receipts'); + t.string('last_report_date', 10); + t.string('last_error', 80); + t.text('feedback_preferences'); + t.string('lease_token', 36); + t.bigInteger('lease_until').notNullable().defaultTo(0); + t.bigInteger('cancel_seq').notNullable().defaultTo(0); + t.integer('attempts').notNullable().defaultTo(0); + t.bigInteger('next_attempt_at').notNullable().defaultTo(0); + }); + await db('product_usage_state').insert({ id: 1 }); + await db.schema.createTable('product_usage_markers', (t) => t.string('feature', 60).primary()); + return db; +} + +const envelope = (action) => ({ packet: { action, installation_id: 'a'.repeat(64) } }); + +describe('the export receipt states what it actually counted', () => { + let db; + afterEach(async () => { if (db) await db.destroy(); db = null; }); + + const exportWith = async (packets) => { + db = await bootDb(); + await db('product_usage_state').where({ id: 1 }).update({ + status: 'active', + installation_id: 'a'.repeat(64), + }); + const service = new UsageService(db, { + secret: SECRET, + endpoint: 'https://usage.example.test', + now: () => Date.parse('2026-09-06T12:00:00.000Z'), + fetch: async () => ({ + ok: true, + headers: { get: () => null }, + body: (async function* () { + yield Buffer.from(JSON.stringify({ installation_id: 'a'.repeat(64), packets })); + })(), + }), + }); + await service.export(); + return JSON.parse((await db('product_usage_state').where({ id: 1 }).first()).privacy_receipts) + .last_export; + }; + + it('counts reports as reports and everything else separately', async () => { + const receipt = await exportWith([ + envelope('register'), + envelope('report'), + envelope('consent'), + envelope('feedback'), + envelope('feedback'), + envelope('vote'), + envelope('session'), + ]); + expect(receipt.report_count).toBe(1); + expect(receipt.packet_count).toBe(7); + expect(receipt.scope).toEqual([ + 'accepted usage reports', + 'accepted participant operations', + ]); + }); + + it('reports zero rather than a total when no report was ever accepted', async () => { + const receipt = await exportWith([envelope('register'), envelope('feedback')]); + expect(receipt.report_count).toBe(0); + expect(receipt.packet_count).toBe(2); + }); +}); + +describe('the delete packet reuses the last accepted sequence', () => { + let db; + afterEach(async () => { if (db) await db.destroy(); db = null; }); + + it('sends the accepted sequence, not the next one', async () => { + db = await bootDb(); + const identity = generateIdentity(); + const sent = []; + const service = new UsageService(db, { + secret: SECRET, + endpoint: 'https://usage.example.test', + now: () => Date.parse('2026-09-06T12:00:00.000Z'), + bindingPath: `${require('os').tmpdir()}/usage-delete-seq-${Date.now()}.key`, + fetch: async (_url, options) => { + const body = JSON.parse(options.body); + sent.push(verifyEnvelope(body, Date.parse('2026-09-06T12:00:00.000Z'))); + // Deliberately not a sequence-enforcing collector: this test pins what + // PicPeak sends, and the collector contract is what must match it. + throw new Error('stop after capturing the packet'); + }, + }); + await db('product_usage_state').where({ id: 1 }).update({ + status: 'deletion_pending', + installation_id: identity.installation_id, + public_key: identity.public_key, + private_key_encrypted: service.encrypt(identity.private_key), + sequence: 7, + }); + + await service.tick({ force: true }); + + expect(sent).toHaveLength(1); + expect(sent[0].action).toBe('delete'); + expect(sent[0].sequence).toBe(7); + }); +}); diff --git a/backend/__tests__/services/usageOutboundOnly.test.js b/backend/__tests__/services/usageOutboundOnly.test.js new file mode 100644 index 00000000..426337de --- /dev/null +++ b/backend/__tests__/services/usageOutboundOnly.test.js @@ -0,0 +1,73 @@ +/** + * The consent dialog tells the operator that this connection only ever runs + * outwards: PicPeak sends, and reads nothing back but the acknowledgement for + * the packet it just sent. That is a security claim — it is the reason a + * compromised collector cannot use this path to push code, configuration or + * content into an installation — so it is guarded here rather than left to + * review. + * + * These are source-inspection assertions on purpose. A behavioural test only + * proves the calls that exist today behave; this fails the moment someone adds + * a "check the collector for messages" fetch, a polling job, or an endpoint the + * collector could call. + */ +const fs = require('fs'); +const path = require('path'); + +const SRC = path.resolve(__dirname, '../../src'); +const service = fs.readFileSync(path.join(SRC, 'usage/UsageService.js'), 'utf8'); +const route = fs.readFileSync(path.join(SRC, 'routes/adminUsage.js'), 'utf8'); +const server = fs.readFileSync(path.resolve(__dirname, '../../server.js'), 'utf8'); + +test('the collector is contacted from exactly one place, and only by POST', () => { + // One transport helper. Anything else reaching for the network here would + // bypass the size cap, the redirect ban and the timeout as well. + const callSites = service.match(/this\.fetch\(/g) || []; + expect(callSites).toHaveLength(1); + + const post = service.slice(service.indexOf('async post(')); + expect(post).toContain('method: \'POST\''); + // A redirect is an instruction from the collector about where to go next. + expect(post).toContain('redirect: \'error\''); + expect(post).toContain('AbortSignal.timeout('); +}); + +test('only the two known collector paths are ever requested', () => { + const paths = [...service.matchAll(/this\.post\(\s*'([^']+)'/g)].map((m) => m[1]); + expect(paths.sort()).toEqual(['/api/envelopes', '/api/participant/lookup']); +}); + +test('nothing is read from a reply except the acknowledgement, checked field by field', () => { + // Every field of the receipt is compared against the packet that was sent. + for (const field of ['packet_id', 'installation_id', 'packet_digest', 'action', 'sequence', 'status']) + expect(service).toMatch(new RegExp(`receipt\\.${field} !==`)); + expect(service).toContain('throw new Error(\'Invalid collector receipt\')'); + + // The stored copy drops the one value that is not an echo of what we sent, + // and no read path hands it back out again. + expect(service).toContain('delete storedReceipt.session_token'); + expect(service).not.toMatch(/last_receipt:\s*state\.last_receipt/); + const status = service.slice(service.indexOf('async status()'), service.indexOf('async locked(')); + expect(status).not.toContain('last_receipt'); +}); + +test('the collector has no way in: no inbound route and no scheduled pull', () => { + // Every usage route is mounted behind adminAuth on the admin surface. + expect(server).toContain('app.use(\'/api/admin/usage\', require(\'./src/routes/adminUsage\'))'); + expect(route).toContain('router.use(adminAuth)'); + // No public/gallery/webhook mount for anything usage-related. + const publicMounts = [...server.matchAll(/app\.use\('\/api\/(public|gallery|customer|invite)[^']*',[^\n]*\)/g)] + .map((m) => m[0]); + for (const mount of publicMounts) expect(mount).not.toMatch(/[Uu]sage/); + + // Nothing schedules a collector call; the daily rollup is driven only by an + // authenticated admin hitting /activity. + expect(service).not.toMatch(/setInterval|setTimeout\s*\(\s*\(\)\s*=>\s*this\.tick/); + const dir = path.join(SRC, 'services'); + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith('.js')) continue; + if (entry.name === 'productUsageService.js') continue; + expect(fs.readFileSync(path.join(dir, entry.name), 'utf8')) + .not.toContain('productUsageService'); + } +}); diff --git a/backend/__tests__/services/usageServiceKeyRotation.test.js b/backend/__tests__/services/usageServiceKeyRotation.test.js new file mode 100644 index 00000000..18485d2f --- /dev/null +++ b/backend/__tests__/services/usageServiceKeyRotation.test.js @@ -0,0 +1,359 @@ +/** + * The signing key is encrypted with USAGE_ENCRYPTION_KEY, which defaults to + * JWT_SECRET. Rotating JWT_SECRET — the correct response to a suspected + * compromise — makes that key unreadable. + * + * Before this was named, the failure surfaced as a generic DELIVERY_FAILED + * that retried forever, and it silently blocked the DELETE packet as well: + * an operator who asked to withdraw had their local state cleared while the + * collector kept its copy, with nothing in the UI explaining why. + */ +const knex = require('knex'); +const { UsageService } = require('../../src/usage/UsageService'); +const { generateIdentity, makePacket } = require('../../src/usage/protocol.cjs'); + +const SECRET_A = 'a'.repeat(48); +const SECRET_B = 'b'.repeat(48); + +async function bootDb() { + const db = knex({ + client: 'sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + await db.schema.createTable('product_usage_state', (t) => { + t.integer('id').primary(); + t.string('status', 30).notNullable().defaultTo('disabled'); + t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v1'); + t.boolean('notice_dismissed').notNullable().defaultTo(false); + t.string('installation_id', 64); + t.string('public_key', 59); + t.text('private_key_encrypted'); + t.string('instance_binding', 64); + t.bigInteger('sequence').notNullable().defaultTo(0); + t.text('pending_packet'); + t.text('last_packet'); + t.text('last_receipt'); + t.text('privacy_receipts'); + t.string('last_report_date', 10); + t.string('last_error', 80); + t.text('feedback_preferences'); + t.string('lease_token', 36); + t.bigInteger('lease_until').notNullable().defaultTo(0); + t.integer('attempts').notNullable().defaultTo(0); + t.bigInteger('next_attempt_at').notNullable().defaultTo(0); + }); + await db('product_usage_state').insert({ id: 1 }); + return db; +} + +describe('usage signing key becomes unreadable after secret rotation', () => { + let db; + afterEach(async () => { if (db) await db.destroy(); db = null; }); + + it('names the failure instead of reporting a generic decrypt error', async () => { + db = await bootDb(); + const before = new UsageService(db, { secret: SECRET_A }); + const sealed = before.encrypt('the-signing-key'); + + // Same value, different secret — exactly what rotating JWT_SECRET does. + const after = new UsageService(db, { secret: SECRET_B }); + expect(() => after.decrypt(sealed)).toThrow( + expect.objectContaining({ code: 'SIGNING_KEY_UNREADABLE' }) + ); + }); + + it('still round-trips under the unrotated secret', async () => { + db = await bootDb(); + const service = new UsageService(db, { secret: SECRET_A }); + expect(service.decrypt(service.encrypt('the-signing-key'))).toBe('the-signing-key'); + }); + + it('records SIGNING_KEY_UNREADABLE rather than DELIVERY_FAILED, and does not flag an identity conflict', async () => { + db = await bootDb(); + const sealed = new UsageService(db, { secret: SECRET_A }).encrypt('key'); + await db('product_usage_state').where({ id: 1 }).update({ + status: 'active', + installation_id: 'a'.repeat(64), + public_key: 'p'.repeat(59), + private_key_encrypted: sealed, + sequence: 1, + pending_packet: JSON.stringify({ + action: 'delete', packet_id: 'x', installation_id: 'a'.repeat(64), sequence: 1, + }), + }); + + const service = new UsageService(db, { + secret: SECRET_B, + endpoint: 'http://127.0.0.1:9/', + // A delivery must never be attempted: signing fails first. + fetch: () => { throw new Error('network must not be reached'); }, + }); + await service.deliver(await db('product_usage_state').where({ id: 1 }).first()); + + const row = await db('product_usage_state').where({ id: 1 }).first(); + expect(row.last_error).toBe('SIGNING_KEY_UNREADABLE'); + // A key we cannot read is not evidence of a cloned installation. + expect(row.status).toBe('active'); + }); +}); + +/** + * Naming the failure told the operator what happened but left them nowhere to + * go: the delete packet can never be signed, so the row stays in + * deletion_pending forever, and enable() refuses because it is not `disabled`. + * An operator who rotated the secret precisely because it was compromised + * cannot restore it, so without an exit the feature is bricked. + */ +describe('abandoning a withdrawal that can never be signed', () => { + let db; + afterEach(async () => { if (db) await db.destroy(); db = null; }); + + const stuck = async () => { + db = await bootDb(); + await db.schema.createTable('product_usage_markers', (t) => { + t.string('feature', 60).primary(); + }); + await db('product_usage_markers').insert({ feature: 'crm' }); + await db('product_usage_state').where({ id: 1 }).update({ + status: 'deletion_pending', + installation_id: 'a'.repeat(64), + public_key: 'p'.repeat(59), + private_key_encrypted: new UsageService(db, { secret: SECRET_A }).encrypt('key'), + sequence: 4, + last_error: 'SIGNING_KEY_UNREADABLE', + }); + return new UsageService(db, { + secret: SECRET_B, + endpoint: 'https://usage.example.test', + bindingPath: `${require('os').tmpdir()}/usage-abandon-${Date.now()}.key`, + fetch: () => { throw new Error('network must not be reached'); }, + }); + }; + + it('clears the local identity and records the deletion as unconfirmed', async () => { + const service = await stuck(); + const status = await service.abandon(); + + expect(status.status).toBe('disabled'); + expect(status.installation_id).toBeNull(); + const row = await db('product_usage_state').where({ id: 1 }).first(); + expect(row.private_key_encrypted).toBeNull(); + expect(row.public_key).toBeNull(); + expect(row.last_error).toBeNull(); + expect(await db('product_usage_markers').count('* as c').first()).toEqual({ c: 0 }); + + // The receipt must not claim a deletion the collector never confirmed. + const receipt = JSON.parse(row.privacy_receipts).last_abandonment; + expect(receipt.status).toBe('collector-unconfirmed'); + expect(receipt.reason).toBe('SIGNING_KEY_UNREADABLE'); + expect(receipt.installation_id).toBe('a'.repeat(64)); + }); + + it('lets the operator rejoin afterwards', async () => { + const service = await stuck(); + await service.abandon(); + expect((await service.state()).status).toBe('disabled'); + }); + + it('refuses on a withdrawal that is merely undelivered', async () => { + const service = await stuck(); + await db('product_usage_state').where({ id: 1 }).update({ last_error: 'DELIVERY_FAILED' }); + await expect(service.abandon()).rejects.toThrow(/abandoned/); + expect((await service.state()).installation_id).toBe('a'.repeat(64)); + }); + + it('refuses while participation is active', async () => { + const service = await stuck(); + await db('product_usage_state').where({ id: 1 }).update({ status: 'active' }); + await expect(service.abandon()).rejects.toThrow(/abandoned/); + expect((await service.state()).installation_id).toBe('a'.repeat(64)); + }); +}); + +/** + * Every failed delivery used to be retried on the next admin request, and + * /activity is open to any authenticated admin while the settings ticker fires + * it every five minutes per open tab. A permanently rejected packet therefore + * produced one collector request per admin action, indefinitely. + */ +describe('delivery backoff', () => { + let db; + afterEach(async () => { if (db) await db.destroy(); db = null; }); + + // A real identity and a schema-valid packet, so the failure happens where + // this test claims it does — at the network — rather than in signPacket. + const activeWithPendingPacket = async (fetchImpl, now) => { + db = await bootDb(); + await db.schema.createTable('product_usage_markers', (t) => { + t.string('feature', 60).primary(); + }); + const service = new UsageService(db, { + secret: SECRET_A, + endpoint: 'https://usage.example.test', + now: () => now(), + fetch: fetchImpl, + }); + const identity = generateIdentity(); + await db('product_usage_state').where({ id: 1 }).update({ + status: 'active', + consent_version: 'usage-consent.v2', + installation_id: identity.installation_id, + public_key: identity.public_key, + private_key_encrypted: service.encrypt(identity.private_key), + sequence: 1, + pending_packet: JSON.stringify( + makePacket(identity, 'session', 2, {}, 'usage.v2') + ), + }); + return service; + }; + + it('paces the next unattended attempt after a failure, and lets Retry skip it', async () => { + let clock = 1_000_000; + let calls = 0; + const service = await activeWithPendingPacket(() => { + calls += 1; + throw new Error('collector unreachable'); + }, () => clock); + + await service.tick(); + expect(calls).toBe(1); + const paced = await service.state(); + expect(Number(paced.attempts)).toBe(1); + expect(Number(paced.next_attempt_at)).toBeGreaterThan(clock); + + // The unattended callers — /activity and the settings ticker — wait. + await service.tick(); + await service.tick(); + expect(calls).toBe(1); + + // The operator pressing Retry does not. + await service.tick({ force: true }); + expect(calls).toBe(2); + expect(Number((await service.state()).attempts)).toBe(2); + + // Once the window passes, the automatic sender tries again on its own. + clock = Number((await service.state()).next_attempt_at) + 1; + await service.tick(); + expect(calls).toBe(3); + }); + + it('grows the wait with consecutive failures and caps it at an hour', () => { + const service = new UsageService(null, { secret: SECRET_A, endpoint: 'https://usage.example.test' }); + expect(service.backoffMs(1)).toBe(2 * 60000); + expect(service.backoffMs(3)).toBe(8 * 60000); + expect(service.backoffMs(20)).toBe(60 * 60000); + }); + + it('clears the pacing once a packet is accepted', async () => { + const clock = 1_000_000; + const service = await activeWithPendingPacket(async () => { + throw new Error('collector unreachable'); + }, () => clock); + await service.tick(); + expect(Number((await service.state()).attempts)).toBe(1); + + await service.clearDeliveryBackoff(); + const cleared = await service.state(); + expect(Number(cleared.attempts)).toBe(0); + expect(Number(cleared.next_attempt_at)).toBe(0); + }); +}); + +/** + * The same dead end, reached the ordinary way. If an installation opts in to + * usage.v2 while the collector still only speaks usage.v1 — the deployment + * order the docs warn about — the registration is rejected outright. Nothing + * exists at the collector, and yet the operator could not clear the tab: + * disable moved to deletion_pending, retry was futile, enable refused, and the + * abandon hatch was gated on SIGNING_KEY_UNREADABLE, which this is not. + * + * Verified against the live collector before this was written: a valid v2 + * register is answered with INVALID_PACKET while the identical v1 flow is + * accepted. + */ +describe('a participation the collector never accepted', () => { + let db; + afterEach(async () => { if (db) await db.destroy(); db = null; }); + + const rejectingCollector = async (status) => { + db = await bootDb(); + await db.schema.createTable('product_usage_markers', (t) => { + t.string('feature', 60).primary(); + }); + const identity = generateIdentity(); + const service = new UsageService(db, { + secret: SECRET_A, + endpoint: 'https://usage.example.test', + bindingPath: `${require('os').tmpdir()}/usage-unreg-${Date.now()}-${Math.random()}.key`, + fetch: async () => ({ + ok: false, + status: 400, + headers: { get: () => null }, + body: (async function* () { yield Buffer.from(JSON.stringify({ error: 'INVALID_PACKET' })); })(), + }), + }); + await db('product_usage_state').where({ id: 1 }).update({ + status, + consent_version: 'usage-consent.v2', + installation_id: identity.installation_id, + public_key: identity.public_key, + private_key_encrypted: service.encrypt(identity.private_key), + sequence: 0, + pending_packet: JSON.stringify( + makePacket(identity, status === 'deletion_pending' ? 'delete' : 'register', 0, + status === 'deletion_pending' ? {} : { consent_version: 'usage-consent.v2' }, 'usage.v2') + ), + }); + return service; + }; + + it('names the rejection instead of blaming the network', async () => { + const service = await rejectingCollector('activation_pending'); + await service.tick({ force: true }); + expect((await service.state()).last_error).toBe('SCHEMA_NOT_ACCEPTED'); + }); + + it('offers the exit straight from activation_pending', async () => { + const service = await rejectingCollector('activation_pending'); + await service.tick({ force: true }); + const status = await service.status(); + expect(status.can_abandon).toBe(true); + expect(status.abandon_never_registered).toBe(true); + + await service.abandon(); + const after = await service.state(); + expect(after.status).toBe('disabled'); + expect(after.installation_id).toBeNull(); + // Provably nothing remote, so the receipt must not hedge. + expect(JSON.parse(after.privacy_receipts).last_abandonment.status) + .toBe('never-registered'); + }); + + it('offers it from deletion_pending too, once the withdrawal is also undeliverable', async () => { + const service = await rejectingCollector('deletion_pending'); + await service.tick({ force: true }); + expect((await service.status()).can_abandon).toBe(true); + await service.abandon(); + expect((await service.state()).status).toBe('disabled'); + }); + + it('never offers it while a registered participation could still be deleted remotely', async () => { + const service = await rejectingCollector('deletion_pending'); + // Something WAS accepted once: the collector may still hold reports, so + // clearing local state silently would be a lie. + await db('product_usage_state').where({ id: 1 }).update({ + sequence: 3, + last_receipt: JSON.stringify({ status: 'accepted' }), + last_error: 'DELIVERY_FAILED', + }); + expect((await service.status()).can_abandon).toBe(false); + await expect(service.abandon()).rejects.toThrow(/cannot be completed/); + }); + + it('does not offer it before a delivery has actually failed', async () => { + const service = await rejectingCollector('activation_pending'); + expect((await service.status()).can_abandon).toBe(false); + }); +}); diff --git a/backend/__tests__/services/usageSnapshotSignals.test.js b/backend/__tests__/services/usageSnapshotSignals.test.js new file mode 100644 index 00000000..c12b5407 --- /dev/null +++ b/backend/__tests__/services/usageSnapshotSignals.test.js @@ -0,0 +1,416 @@ +/** + * Report accuracy (#1110). + * + * Two signals were wrong in ways that only show up in the aggregate, where + * nobody can tell the number is wrong: preset-themed installs all reported + * `grid`, and CSS applied through a template reported no custom CSS at all. + * + * Also covers status() surviving a misconfigured collector URL — it used to + * throw, which took down the settings tab that is the only way to withdraw. + */ +const knex = require('knex'); +const { UsageService } = require('../../src/usage/UsageService'); +const { FEATURE_KEYS, CATALOG, generateIdentity, makePacket, signPacket, verifyEnvelope } = require('../../src/usage/protocol.cjs'); + +async function bootDb() { + const db = knex({ + client: 'sqlite3', + connection: { filename: ':memory:' }, + useNullAsDefault: true, + }); + await db.schema.createTable('product_usage_state', (t) => { + t.integer('id').primary(); + t.string('status', 30).notNullable().defaultTo('disabled'); + t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v1'); + t.boolean('notice_dismissed').notNullable().defaultTo(false); + t.string('installation_id', 64); + t.string('public_key', 59); + t.text('private_key_encrypted'); + t.string('instance_binding', 64); + t.bigInteger('sequence').notNullable().defaultTo(0); + t.text('pending_packet'); t.text('last_packet'); t.text('last_receipt'); + t.text('privacy_receipts'); + t.string('last_report_date', 10); t.string('last_error', 80); + t.text('feedback_preferences'); t.string('lease_token', 36); + t.bigInteger('lease_until').notNullable().defaultTo(0); + t.bigInteger('cancel_seq').notNullable().defaultTo(0); + t.integer('attempts').notNullable().defaultTo(0); + t.bigInteger('next_attempt_at').notNullable().defaultTo(0); + }); + await db('product_usage_state').insert({ id: 1 }); + await db.schema.createTable('product_usage_markers', (t) => t.string('feature', 60).primary()); + await db.schema.createTable('app_settings', (t) => { + t.string('setting_key').primary(); t.text('setting_value'); t.string('setting_type'); + }); + await db.schema.createTable('feature_flags', (t) => { + t.string('key').primary(); t.boolean('value'); + }); + await db.schema.createTable('events', (t) => { + t.increments('id'); t.text('color_theme'); t.string('external_path'); + t.integer('css_template_id'); + }); + await db.schema.createTable('css_templates', (t) => { + t.increments('id'); t.boolean('is_enabled'); t.text('css_content'); + }); + for (const table of ['email_configs', 'mail_accounts']) { + await db.schema.createTable(table, (t) => { t.increments('id'); t.string('smtp_host'); }); + } + await db.schema.createTable('whatsapp_configs', (t) => { + t.increments('id'); t.boolean('enabled'); t.string('phone_number_id'); t.string('access_token'); + }); + return db; +} + +const service = (db, over = {}) => + new UsageService(db, { secret: 'q'.repeat(48), ...over }); + +describe('gallery_layouts resolves what the gallery actually renders', () => { + let db; + afterEach(async () => { if (db) await db.destroy(); db = null; }); + + it('maps preset NAMES to their layouts instead of calling them all grid', async () => { + db = await bootDb(); + await db('events').insert([ + { color_theme: 'modernMasonry' }, + { color_theme: 'corporateTimeline' }, + { color_theme: 'galleryStory' }, + ]); + const report = await service(db).snapshot(); + expect(report.gallery_layouts.sort()).toEqual( + ['gallery-story', 'masonry', 'timeline'].sort() + ); + }); + + it('still reads a theme object', async () => { + db = await bootDb(); + await db('events').insert([{ color_theme: JSON.stringify({ galleryLayout: 'mosaic' }) }]); + expect((await service(db).snapshot()).gallery_layouts).toEqual(['mosaic']); + }); + + it('reports an unknown preset as other, not as grid', async () => { + // A preset added on the frontend must not silently inflate the grid count. + db = await bootDb(); + await db('events').insert([{ color_theme: 'somePresetAddedLater' }]); + expect((await service(db).snapshot()).gallery_layouts).toEqual(['other']); + }); + + it('uses the global theme for an event that has none of its own', async () => { + db = await bootDb(); + await db('app_settings').insert({ + setting_key: 'theme_config', + setting_value: JSON.stringify({ galleryLayout: 'carousel' }), + }); + await db('events').insert([{ color_theme: null }]); + expect((await service(db).snapshot()).gallery_layouts).toEqual(['carousel']); + }); +}); + +describe('custom_css counts CSS applied through a template', () => { + let db; + afterEach(async () => { if (db) await db.destroy(); db = null; }); + + it('is configured when an enabled template is applied to an event', async () => { + db = await bootDb(); + const [id] = await db('css_templates').insert({ is_enabled: true, css_content: '.a{}' }); + await db('events').insert([{ color_theme: null, css_template_id: id }]); + expect((await service(db).snapshot()).features.custom_css.configured).toBe(true); + }); + + it('is not configured when the applied template is disabled', async () => { + db = await bootDb(); + const [id] = await db('css_templates').insert({ is_enabled: false, css_content: '.a{}' }); + await db('events').insert([{ color_theme: null, css_template_id: id }]); + expect((await service(db).snapshot()).features.custom_css.configured).toBe(false); + }); + + it('is not configured when an enabled template is applied to nothing', async () => { + db = await bootDb(); + await db('css_templates').insert({ is_enabled: true, css_content: '.a{}' }); + await db('events').insert([{ color_theme: null }]); + expect((await service(db).snapshot()).features.custom_css.configured).toBe(false); + }); +}); + +describe('status survives a misconfigured collector URL', () => { + let db; + afterEach(async () => { if (db) await db.destroy(); db = null; }); + + it.each([ + ['a bare hostname', 'usage.picpeak.app'], + ['a URL with a path', 'https://usage.picpeak.app/collect'], + ['a URL with a query', 'https://usage.picpeak.app/?x=1'], + ])('reports %s as a configuration error rather than failing the request', async (_l, endpoint) => { + db = await bootDb(); + const status = await service(db, { endpoint }).status(); + expect(status.collector_error).toBe('INVALID_COLLECTOR_URL'); + expect(status.collector_url).toBeNull(); + // The operator can still read their state — and therefore still withdraw. + expect(status.status).toBe('disabled'); + }); + + it('reports no error for a valid collector', async () => { + db = await bootDb(); + const status = await service(db, { endpoint: 'https://usage.picpeak.app' }).status(); + expect(status.collector_error).toBeNull(); + expect(status.collector_url).toBe('https://usage.picpeak.app'); + }); +}); + +describe('S3 use is only implied by backups that write to the destination', () => { + let db; + afterEach(async () => { if (db) await db.destroy(); db = null; }); + + const withS3Destination = async (database) => { + await database('app_settings').insert({ + setting_key: 'backup_destination_type', + setting_value: JSON.stringify('s3'), + }); + await database('product_usage_state').where({ id: 1 }).update({ status: 'active' }); + }; + + it('marks S3 for a backup that uses the configured destination', async () => { + db = await bootDb(); + await withS3Destination(db); + await service(db).markUsed(['backup'], { destinationBackup: true }); + expect((await db('product_usage_markers').pluck('feature')).sort()) + .toEqual(['backup', 's3_storage']); + }); + + it('does NOT mark S3 for a local backup, even with S3 configured', async () => { + // /database-backup/* and /backup/picpeak/export produce a local file. They + // count as `backup`, but claiming S3 was used for them made merely + // configuring S3 and downloading an export report s3_storage.used. + db = await bootDb(); + await withS3Destination(db); + await service(db).markUsed(['backup']); + expect(await db('product_usage_markers').pluck('feature')).toEqual(['backup']); + }); + + it('does not mark S3 when the destination is not S3', async () => { + db = await bootDb(); + await db('app_settings').insert({ + setting_key: 'backup_destination_type', + setting_value: JSON.stringify('local'), + }); + await db('product_usage_state').where({ id: 1 }).update({ status: 'active' }); + await service(db).markUsed(['backup'], { destinationBackup: true }); + expect(await db('product_usage_markers').pluck('feature')).toEqual(['backup']); + }); +}); + +/** + * A signal whose answer is fixed by the shipped defaults is not a signal. + * PicPeak ships default_protection_level='standard' and + * enable_devtools_protection=true, so accepting either as evidence made + * gallery_image_protection true on a bare install with no galleries — a + * fleet-wide 100% that cannot separate a decision from an untouched default. + */ +describe('gallery_image_protection reports decisions, not shipped defaults', () => { + let db; + afterEach(async () => { if (db) await db.destroy(); db = null; }); + + const v2 = async () => { + db = await bootDb(); + await db.schema.alterTable('events', (t) => { + for (const column of ['disable_right_click', 'enable_devtools_protection', 'use_canvas_rendering']) t.boolean(column); + t.string('protection_level'); + }); + await db('product_usage_state').where({ id: 1 }) + .update({ status: 'active', consent_version: 'usage-consent.v2' }); + return service(db); + }; + const shipped = async () => { + // Exactly what migration 038 seeds, plus an event carrying the column + // defaults from the same migration. + await db('app_settings').insert([ + { setting_key: 'default_protection_level', setting_value: '"standard"' }, + { setting_key: 'enable_devtools_protection', setting_value: 'true' }, + { setting_key: 'enable_canvas_rendering', setting_value: 'false' }, + ]); + await db('events').insert({ + protection_level: 'standard', + enable_devtools_protection: true, + use_canvas_rendering: false, + disable_right_click: false, + }); + }; + + it('is false on a bare install with no galleries at all', async () => { + const client = await v2(); + expect((await client.snapshot()).features.gallery_image_protection) + .toEqual({ configured: false }); + }); + + it('is false when every value is still the shipped default', async () => { + const client = await v2(); + await shipped(); + expect((await client.snapshot()).features.gallery_image_protection) + .toEqual({ configured: false }); + }); + + it('ignores the devtools flag entirely, since it ships on', async () => { + const client = await v2(); + await shipped(); + // Turning it OFF is the only informative state it has, and that is the + // opposite of what this key claims — so neither state may set it. + await db('app_settings').where({ setting_key: 'enable_devtools_protection' }) + .update({ setting_value: 'false' }); + await db('events').update({ enable_devtools_protection: false }); + expect((await client.snapshot()).features.gallery_image_protection) + .toEqual({ configured: false }); + }); + + it.each([ + ['a stronger global level', async (db) => db('app_settings').where({ setting_key: 'default_protection_level' }).update({ setting_value: '"maximum"' })], + ['global canvas rendering', async (db) => db('app_settings').where({ setting_key: 'enable_canvas_rendering' }).update({ setting_value: 'true' })], + ['a stronger level on one gallery', async (db) => db('events').update({ protection_level: 'enhanced' })], + ['canvas rendering on one gallery', async (db) => db('events').update({ use_canvas_rendering: true })], + ['right-click disabled on one gallery', async (db) => db('events').update({ disable_right_click: true })], + ])('is true for %s', async (_label, change) => { + const client = await v2(); + await shipped(); + await change(db); + expect((await client.snapshot()).features.gallery_image_protection) + .toEqual({ configured: true }); + }); +}); + +/** + * The settings preview is the "see exactly what would be sent" view. It shared + * snapshot() with the real sender, and snapshot() records applied custom CSS + * as a lifetime marker — so reading the transparency view wrote a marker. + */ +describe('preview does not change what will be sent', () => { + let db; + afterEach(async () => { if (db) await db.destroy(); db = null; }); + + const withAppliedCss = async () => { + db = await bootDb(); + await db('product_usage_state').where({ id: 1 }) + .update({ status: 'active', consent_version: 'usage-consent.v2' }); + await db('app_settings').insert({ + setting_key: 'general_custom_css', setting_value: '".x{}"' + }); + return service(db); + }; + + it('reports custom_css as used without persisting the marker', async () => { + const client = await withAppliedCss(); + const preview = await client.preview(); + expect(preview.features.custom_css).toEqual({ configured: true, used: true }); + expect(await db('product_usage_markers').pluck('feature')).toEqual([]); + }); + + it('still persists it when the sender builds the real report', async () => { + const client = await withAppliedCss(); + await client.snapshot(); + expect(await db('product_usage_markers').pluck('feature')).toEqual(['custom_css']); + }); +}); + +describe('v2 technical configuration and privacy boundaries', () => { + let db; + let savedEnv; + beforeEach(() => { savedEnv = { ...process.env }; }); + afterEach(async () => { if (db) await db.destroy(); db = null; process.env = savedEnv; }); + async function expandedDb() { + db = await bootDb(); + await db('product_usage_state').where({ id: 1 }).update({ status: 'active', consent_version: 'usage-consent.v2' }); + await db.schema.alterTable('events', (t) => { + for (const column of ['allow_user_uploads', 'allow_downloads', 'client_access_enabled', 'watermark_downloads', 'reveal_mode', 'download_resolution_picker_enabled', 'disable_right_click', 'enable_devtools_protection', 'use_canvas_rendering']) t.boolean(column); + t.string('protection_level'); t.timestamp('expires_at'); t.string('event_name'); t.string('customer_email'); + }); + for (const table of ['email_configs', 'mail_accounts']) await db.schema.alterTable(table, (t) => { + t.boolean('enabled'); t.string('imap_host'); t.string('imap_user'); t.string('imap_pass'); + }); + await db.schema.createTable('event_feedback_settings', (t) => { + t.increments('id'); t.boolean('feedback_enabled'); t.string('identity_mode'); + for (const col of ['allow_likes', 'allow_ratings', 'allow_comments', 'allow_favorites', 'allow_reactions', 'allow_color_labels']) t.boolean(col); + }); + await db.schema.createTable('api_tokens', (t) => { t.increments('id'); t.timestamp('revoked_at'); t.timestamp('expires_at'); t.string('token_hash'); }); + await db.schema.createTable('webhooks', (t) => { t.increments('id'); t.boolean('active'); t.string('url'); t.string('secret'); }); + return service(db, { now: () => Date.parse('2026-09-06T12:00:00.000Z'), version: '3.124.1-beta.0' }); + } + + it('produces all 73 closed booleans, never exposing sensitive values or configuration-only used', async () => { + const client = await expandedDb(); + const flags = [...new Set(Object.values(CATALOG.features).map((f) => f.flag).filter(Boolean)), 'incomingMail', 'whatsapp']; + await db('feature_flags').insert([...new Set(flags)].map((key) => ({ key, value: true }))); + const settings = { + general_allowed_file_types: 'jpg,dng,mp4', general_public_site_enabled: true, + database_backup_enabled: true, backup_destination_type: 's3', backup_s3_bucket: 'PRIVATE-bucket', + oidc_enabled: true, oidc_issuer_url: 'https://PRIVATE.example.test', oidc_client_id: 'PRIVATE-client', + general_custom_css: '.PRIVATE { color:red; }' + }; + await db('app_settings').insert(Object.entries(settings).map(([setting_key, value]) => ({ setting_key, setting_value: JSON.stringify(value) }))); + await db('events').insert({ + event_name: 'PRIVATE PERSON', customer_email: 'PRIVATE@example.test', external_path: '/PRIVATE/path', + color_theme: JSON.stringify({ galleryLayout: 'gallery-story', privateName: 'PRIVATE' }), + allow_user_uploads: true, allow_downloads: true, client_access_enabled: true, watermark_downloads: true, + reveal_mode: true, download_resolution_picker_enabled: true, disable_right_click: true, + expires_at: '2028-01-01T00:00:00.000Z' + }); + await db('event_feedback_settings').insert({ feedback_enabled: true, identity_mode: 'guest', + allow_likes: true, allow_ratings: true, allow_comments: true, allow_favorites: true, allow_reactions: true, allow_color_labels: true }); + await db('email_configs').insert({ smtp_host: 'PRIVATE-host', imap_host: 'PRIVATE-host', imap_user: 'PRIVATE-user', imap_pass: 'PRIVATE-secret' }); + await db('whatsapp_configs').insert({ enabled: true, phone_number_id: 'PRIVATE-phone', access_token: 'PRIVATE-token' }); + await db('api_tokens').insert({ token_hash: 'PRIVATE-token', expires_at: '2028-01-01T00:00:00.000Z' }); + await db('webhooks').insert({ active: true, url: 'https://PRIVATE.example.test', secret: 'PRIVATE-secret' }); + Object.assign(process.env, { STORAGE_BACKEND: 's3', STORAGE_S3_BUCKET: 'PRIVATE', STORAGE_S3_ACCESS_KEY: 'PRIVATE', STORAGE_S3_SECRET_KEY: 'PRIVATE', EMAIL_WEBHOOK_URL: 'https://PRIVATE.example.test', EMAIL_WEBHOOK_SECRET: 'PRIVATE' }); + delete process.env.PICPEAK_SINGLE_CONTAINER; + await client.markUsed([...FEATURE_KEYS, 'PRIVATE@example.test']); + const report = await client.snapshot(); + expect(Object.keys(report.features)).toEqual(FEATURE_KEYS); + for (const [key, definition] of Object.entries(CATALOG.features)) { + expect(report.features[key].configured).toBe(true); + if (definition.used) expect(report.features[key].used).toBe(true); + else expect(report.features[key]).toEqual({ configured: true }); + } + expect(await db('product_usage_markers').pluck('feature')).toHaveLength(56); + expect(JSON.stringify(report)).not.toContain('PRIVATE'); + const identity = generateIdentity(); + const envelope = signPacket(makePacket(identity, 'report', 1, report), identity, new Date(report.generated_at)); + expect(verifyEnvelope(envelope, Date.parse(report.generated_at))).toEqual(envelope.packet); + }); + + it('applies parent/AIO gates and does not confuse disabled or expired config with availability', async () => { + const client = await expandedDb(); + process.env.PICPEAK_SINGLE_CONTAINER = 'yes'; + await db('feature_flags').insert(['bills', 'incomingInvoices', 'expenses', 'taxReport', 'faces', 'incomingMail'].map((key) => ({ key, value: true }))); + await db('api_tokens').insert([ + { revoked_at: '2026-01-01', expires_at: null }, + { revoked_at: null, expires_at: '2026-01-01' } + ]); + await db('webhooks').insert({ active: false }); + await db('mail_accounts').insert({ enabled: false, imap_host: 'PRIVATE', imap_user: 'PRIVATE', imap_pass: 'PRIVATE' }); + await db('event_feedback_settings').insert({ feedback_enabled: false, identity_mode: 'guest', allow_likes: true }); + await db('events').insert({ allow_user_uploads: false, reveal_mode: true }); + const report = await client.snapshot(); + for (const key of ['crm_invoices', 'accounting_incoming_invoices', 'accounting_expenses', 'accounting_tax_report', 'face_recognition', 'api_integration', 'webhooks', 'incoming_mail', 'gallery_feedback_likes', 'gallery_guest_accounts', 'gallery_reveal']) expect(report.features[key].configured).toBe(false); + expect(report.features.galleries).toEqual({ configured: true, used: false }); + expect(report.features.admin_management.configured).toBe(true); + expect(report.features.analytics_dashboard.configured).toBe(true); + }); + + it('handles missing optional tables, global protection defaults and durable consent boundaries', async () => { + db = await bootDb(); + const client = service(db); + await db('app_settings').insert({ setting_key: 'default_protection_level', setting_value: '"enhanced"' }); + await client.markUsed(FEATURE_KEYS); + expect(await db('product_usage_markers').pluck('feature')).toEqual([]); + await db('product_usage_state').update({ status: 'active' }); + await client.markUsed(['video_uploads', 'api_integration']); + expect(await db('product_usage_markers').pluck('feature')).toEqual([]); + expect(Object.keys((await client.snapshot()).features)).toHaveLength(19); + await db('product_usage_state').update({ consent_version: 'usage-consent.v2' }); + const report = await client.snapshot(); + expect(report.features.gallery_image_protection).toEqual({ configured: true }); + expect(report.features.api_integration).toEqual({ configured: false, used: false }); + expect(report.features.document_templates).toEqual({ configured: false, used: false }); + await client.markUsed(['video_uploads', 'gallery_downloads']); + expect(await db('product_usage_markers').pluck('feature')).toEqual(['video_uploads']); + await db('product_usage_state').update({ status: 'deletion_pending' }); + await client.markUsed(['api_integration']); + expect(await db('product_usage_markers').pluck('feature')).toEqual(['video_uploads']); + }); +}); diff --git a/backend/migrations/core/201_product_usage.js b/backend/migrations/core/201_product_usage.js new file mode 100644 index 00000000..b3c7dbf2 --- /dev/null +++ b/backend/migrations/core/201_product_usage.js @@ -0,0 +1,34 @@ +exports.up = async function (knex) { + if (!(await knex.schema.hasTable('product_usage_state'))) { + await knex.schema.createTable('product_usage_state', (t) => { + t.integer('id').primary(); + t.string('status', 30).notNullable().defaultTo('disabled'); + t.boolean('notice_dismissed').notNullable().defaultTo(false); + t.string('installation_id', 64); + t.string('public_key', 59); + t.text('private_key_encrypted'); + t.string('instance_binding', 64); + t.bigInteger('sequence').notNullable().defaultTo(0); + t.text('pending_packet'); + t.text('last_packet'); + t.text('last_receipt'); + t.string('last_report_date', 10); + t.string('last_error', 80); + t.text('feedback_preferences'); + t.string('lease_token', 36); + t.bigInteger('lease_until').notNullable().defaultTo(0); + }); + } + if (!(await knex('product_usage_state').where({ id: 1 }).first())) + await knex('product_usage_state').insert({ id: 1 }); + if (!(await knex.schema.hasTable('product_usage_markers'))) { + await knex.schema.createTable('product_usage_markers', (t) => { + t.string('feature', 60).primary(); + // A marker is only a capability name, never a timestamp or user/event ID. + }); + } +}; +exports.down = async function (knex) { + await knex.schema.dropTableIfExists('product_usage_markers'); + await knex.schema.dropTableIfExists('product_usage_state'); +}; diff --git a/backend/migrations/core/202_product_usage_cancel_requested.js b/backend/migrations/core/202_product_usage_cancel_requested.js new file mode 100644 index 00000000..97b3a1f0 --- /dev/null +++ b/backend/migrations/core/202_product_usage_cancel_requested.js @@ -0,0 +1,22 @@ +// Separate from 201 deliberately. 201 already shipped on this branch, and +// knex records it as applied — so folding the column into it would silently +// skip every database that had already run it, and the first /disable would +// fail on a missing column. Its own migration runs everywhere. +exports.up = async function (knex) { + if (!(await knex.schema.hasTable('product_usage_state'))) return; + if (await knex.schema.hasColumn('product_usage_state', 'cancel_requested')) return; + await knex.schema.alterTable('product_usage_state', (t) => { + // Set by /disable so an activation still generating its identity — during + // which the row still reads `disabled` — cannot go on to complete after + // the admin has asked to withdraw. + t.boolean('cancel_requested').notNullable().defaultTo(false); + }); +}; + +exports.down = async function (knex) { + if (!(await knex.schema.hasTable('product_usage_state'))) return; + if (!(await knex.schema.hasColumn('product_usage_state', 'cancel_requested'))) return; + await knex.schema.alterTable('product_usage_state', (t) => { + t.dropColumn('cancel_requested'); + }); +}; diff --git a/backend/migrations/core/203_product_usage_cancel_seq.js b/backend/migrations/core/203_product_usage_cancel_seq.js new file mode 100644 index 00000000..7fb8af2e --- /dev/null +++ b/backend/migrations/core/203_product_usage_cancel_seq.js @@ -0,0 +1,26 @@ +// Supersedes the boolean added in 202. A boolean cannot distinguish "a +// withdrawal arrived while this activation was starting" from "a withdrawal +// from an earlier participation was never cleared": clearing it needed its +// own write, and a /disable landing between the lease and that write was +// erased. A monotonic counter needs no clearing — enable() records the value +// it started with and claims only if it is unchanged, so any intervening +// withdrawal is visible whatever the previous state was. +exports.up = async function (knex) { + if (!(await knex.schema.hasTable('product_usage_state'))) return; + if (!(await knex.schema.hasColumn('product_usage_state', 'cancel_seq'))) + await knex.schema.alterTable('product_usage_state', (t) => { + t.bigInteger('cancel_seq').notNullable().defaultTo(0); + }); + if (await knex.schema.hasColumn('product_usage_state', 'cancel_requested')) + await knex.schema.alterTable('product_usage_state', (t) => { + t.dropColumn('cancel_requested'); + }); +}; + +exports.down = async function (knex) { + if (!(await knex.schema.hasTable('product_usage_state'))) return; + if (await knex.schema.hasColumn('product_usage_state', 'cancel_seq')) + await knex.schema.alterTable('product_usage_state', (t) => { + t.dropColumn('cancel_seq'); + }); +}; diff --git a/backend/migrations/core/204_product_usage_privacy_receipts.js b/backend/migrations/core/204_product_usage_privacy_receipts.js new file mode 100644 index 00000000..f76027ee --- /dev/null +++ b/backend/migrations/core/204_product_usage_privacy_receipts.js @@ -0,0 +1,34 @@ +// Bounded, local-only audit receipts. Never retain an installation identity, +// signing key, report/feedback payload or collector credential after opt-out. +exports.up = async function (knex) { + if ( + (await knex.schema.hasTable('product_usage_state')) && + !(await knex.schema.hasColumn('product_usage_state', 'privacy_receipts')) + ) { + await knex.schema.alterTable('product_usage_state', (t) => + t.text('privacy_receipts') + ); + } + if (await knex.schema.hasColumn('product_usage_state', 'last_receipt')) { + const row = await knex('product_usage_state').where({ id: 1 }).first(); + if (row?.last_receipt) { + const receipt = JSON.parse(row.last_receipt); + if (receipt.session_token) { + delete receipt.session_token; + await knex('product_usage_state') + .where({ id: 1 }) + .update({ last_receipt: JSON.stringify(receipt) }); + } + } + } +}; +exports.down = async function (knex) { + if ( + (await knex.schema.hasTable('product_usage_state')) && + (await knex.schema.hasColumn('product_usage_state', 'privacy_receipts')) + ) { + await knex.schema.alterTable('product_usage_state', (t) => + t.dropColumn('privacy_receipts') + ); + } +}; diff --git a/backend/migrations/core/205_product_usage_consent_version.js b/backend/migrations/core/205_product_usage_consent_version.js new file mode 100644 index 00000000..83f2c033 --- /dev/null +++ b/backend/migrations/core/205_product_usage_consent_version.js @@ -0,0 +1,20 @@ +// Existing participants retain their v1 consent and v1 allowlist. New fields +// require a separate explicit, signed upgrade; migrations never opt anyone in. +exports.up = async function (knex) { + if ( + (await knex.schema.hasTable('product_usage_state')) && + !(await knex.schema.hasColumn('product_usage_state', 'consent_version')) + ) { + await knex.schema.alterTable('product_usage_state', (t) => { + t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v1'); + }); + } +}; +exports.down = async function (knex) { + if ( + (await knex.schema.hasTable('product_usage_state')) && + (await knex.schema.hasColumn('product_usage_state', 'consent_version')) + ) { + await knex.schema.alterTable('product_usage_state', (t) => t.dropColumn('consent_version')); + } +}; diff --git a/backend/migrations/core/206_product_usage_delivery_backoff.js b/backend/migrations/core/206_product_usage_delivery_backoff.js new file mode 100644 index 00000000..19118a41 --- /dev/null +++ b/backend/migrations/core/206_product_usage_delivery_backoff.js @@ -0,0 +1,31 @@ +// Retry pacing for the collector. Without it every failed packet was retried +// on the next admin request: /activity is open to any authenticated admin and +// the settings ticker fires it every five minutes per open tab, so an +// installation whose packet the collector rejects permanently hammered it +// once per admin action, forever, with a failing request sitting on the +// critical path of that action. +// +// `attempts` counts consecutive failures and `next_attempt_at` is the epoch-ms +// gate the automatic sender honours. Explicit operator actions — Retry and +// Disable — pass through regardless; the point is to pace the unattended loop, +// not to make the admin wait out a backoff they asked to skip. +exports.up = async function (knex) { + if (!(await knex.schema.hasTable('product_usage_state'))) return; + if (!(await knex.schema.hasColumn('product_usage_state', 'attempts'))) + await knex.schema.alterTable('product_usage_state', (t) => { + t.integer('attempts').notNullable().defaultTo(0); + }); + if (!(await knex.schema.hasColumn('product_usage_state', 'next_attempt_at'))) + await knex.schema.alterTable('product_usage_state', (t) => { + t.bigInteger('next_attempt_at').notNullable().defaultTo(0); + }); +}; + +exports.down = async function (knex) { + if (!(await knex.schema.hasTable('product_usage_state'))) return; + for (const column of ['attempts', 'next_attempt_at']) + if (await knex.schema.hasColumn('product_usage_state', column)) + await knex.schema.alterTable('product_usage_state', (t) => { + t.dropColumn(column); + }); +}; diff --git a/backend/package-lock.json b/backend/package-lock.json index a495233a..2d8faaf3 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,16 +1,17 @@ { "name": "picpeak-backend", - "version": "3.122.5-beta.0", + "version": "3.123.0-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "picpeak-backend", - "version": "3.122.5-beta.0", + "version": "3.123.0-beta.0", "dependencies": { "@aws-sdk/client-s3": "^3.850.0", "@aws-sdk/lib-storage": "^3.850.0", "@aws-sdk/s3-request-presigner": "^3.850.0", + "ajv": "^8.20.0", "archiver": "^5.3.1", "axios": "1.18.1", "bcrypt": "6.0.0", @@ -1611,6 +1612,30 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/js": { "version": "8.57.1", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", @@ -4006,16 +4031,15 @@ } }, "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "dev": true, + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -5848,6 +5872,30 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, "node_modules/esm": { "version": "3.2.25", "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", @@ -6130,6 +6178,22 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fast-xml-builder": { "version": "1.1.9", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.9.tgz", @@ -8105,10 +8169,9 @@ "license": "MIT" }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -10805,6 +10868,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-main-filename": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index be3035f3..e60ac33b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -21,6 +21,7 @@ "@aws-sdk/client-s3": "^3.850.0", "@aws-sdk/lib-storage": "^3.850.0", "@aws-sdk/s3-request-presigner": "^3.850.0", + "ajv": "^8.20.0", "archiver": "^5.3.1", "axios": "1.18.1", "bcrypt": "6.0.0", diff --git a/backend/server.js b/backend/server.js index 09c3f744..33c3aa7a 100644 --- a/backend/server.js +++ b/backend/server.js @@ -824,6 +824,8 @@ app.get( // Routes app.use('/api/setup', setupRoutes); // public first-run bootstrap (self-closes after setup) app.use('/api/auth', authRoutes); +app.use('/api/admin', require('./src/middleware/productUsage').productUsage); +app.use('/api/admin/usage', require('./src/routes/adminUsage')); app.use('/api/admin/external-media', require('./src/routes/adminExternalMedia')); // Gallery routes - main routes first, then feedback routes app.use('/api/gallery', galleryRoutes); @@ -933,7 +935,7 @@ app.use('/api/admin/api-tokens', require('./src/routes/adminApiTokens')); app.use('/api/admin/webhooks', require('./src/routes/adminWebhooks')); // Public v1 API for n8n / external integrations (#322). Mounted under // /api/v1; auth handled per-route via apiTokenAuth (Bearer tokens). -app.use('/api/v1', require('./src/routes/v1/events')); +app.use('/api/v1', require('./src/middleware/productUsage').productUsageApi, require('./src/routes/v1/events')); // Swagger UI for the v1 API. Admin-gated since it lists endpoint shapes // that should not be enumerable to anonymous users (a common reduce-info-leak hardening). diff --git a/backend/src/middleware/productUsage.js b/backend/src/middleware/productUsage.js new file mode 100644 index 00000000..cbd4844e --- /dev/null +++ b/backend/src/middleware/productUsage.js @@ -0,0 +1,87 @@ +// Only successful authenticated ADMIN capability operations set a coarse +// marker. Never mount on gallery/customer/public routes. No request values, +// identifiers, paths, timing, or counts are retained or sent. +const service = require('../services/productUsageService'); +const logger = require('../utils/logger'); +const { capabilityKeys } = require('../usage/capabilityRules'); +// Mirrors emailWebhookTransport: the webhook is in play only when both are +// set, which is when adminEmail routes the test send through it. +const webhookTransportConfigured = () => + Boolean( + (process.env.EMAIL_WEBHOOK_URL || '').trim() && + (process.env.EMAIL_WEBHOOK_SECRET || '').trim() + ); + +const RULES = [ + [/^\/customers(?:\/|$)/, ['crm']], + [/^\/quotes(?:\/|$)/, ['crm', 'crm_quotes']], + [/^\/invoices(?:\/|$)/, ['crm', 'crm_invoices']], + [/^\/contracts(?:\/|$)/, ['crm', 'crm_contracts']], + [/^\/projects(?:\/|$)/, ['crm', 'crm_projects']], + [/^\/calendar(?:\/|$)/, ['crm', 'crm_calendar']], + [/^\/customers\/(?:[^/]+\/)?hour-entries(?:\/|$)/, ['crm', 'crm_hours']], + [/^\/customers\/(?:invite|[^/]+\/send-invite)(?:\/|$)/, ['customer_portal']], + [ + /^\/(?:ledger|expenses|tax-report|incoming-invoices)(?:\/|$)/, + ['accounting'] + ], + [/^\/workflows(?:\/|$)/, ['workflows']], + [/^\/newsletters(?:\/|$)/, ['newsletters']], + [/^\/events\/[^/]+\/(?:faces|people)(?:\/|$)/, ['face_recognition']], + [/^\/whatsapp\/(?:send|test)(?:\/|$)/, ['whatsapp']], + [ + /^\/(?:backup|database-backup)\/(?:run|backup|create|start|test|picpeak\/export)(?:\/|$)/, + ['backup'] + ], + [/^\/backup\/s3\/test-upload(?:\/|$)/, ['s3_storage']], + [/^\/email\/(?:test|send)(?:\/|$)/, ['smtp']], + [/^\/external-media(?:\/|$)/, ['share_mounts']] +]; +// Backup operations that write to the CONFIGURED destination, and so imply +// S3 use when that destination is S3. Deliberately excludes +// /backup/picpeak/export and everything under /database-backup/, which +// produce a local file regardless of where scheduled backups go. +const DESTINATION_BACKUP = /^\/backup\/(?:run|backup|create|start|test)(?:\/|$)/; + +function productUsage(req, res, next) { + const pathname = req.path; + res.once('finish', () => { + if (!req.admin?.id || res.statusCode < 200 || res.statusCode >= 300) return; + let features = RULES.filter(([pattern]) => + pattern.test(pathname) + ).flatMap(([, keys]) => keys); + // A webhook-only install sends /email/test through the webhook transport + // and never touches SMTP (adminEmail.js has an explicit path for it, + // #1225), so recording smtp here would permanently misclassify it. + if (features.includes('smtp') && webhookTransportConfigured()) + features = features.filter((f) => f !== 'smtp'); + if ( + process.env.STORAGE_BACKEND === 's3' && + /^\/(?:photos|events)\/[^/]+\/upload(?:\/|$)/.test(pathname) + ) + features.push('s3_storage'); + const expanded = [...new Set([ + ...capabilityKeys(req.method, pathname), + ...(res.locals.productUsageFeatures || []) + ])]; + if (features.length || expanded.length) + service + .markUsed(expanded, { + legacyFeatures: features, + destinationBackup: DESTINATION_BACKUP.test(pathname) + }) + .catch(() => logger.warn('Product usage marker could not be recorded')); + }); + next(); +} +// Integration calls can record one general capability, but never trigger the +// daily sender. Public/customer/gallery routes do not mount this middleware. +function productUsageApi(req, res, next) { + res.once('finish', () => { + if (!req.admin?.id || !req.apiToken || res.statusCode < 200 || res.statusCode >= 300) return; + service.markUsed(['api_integration'], { legacyFeatures: [] }) + .catch(() => logger.warn('Product usage API marker could not be recorded')); + }); + next(); +} +module.exports = { productUsage, productUsageApi, RULES, DESTINATION_BACKUP }; diff --git a/backend/src/routes/adminBackup.js b/backend/src/routes/adminBackup.js index 4590b7d3..3d8ab3a7 100644 --- a/backend/src/routes/adminBackup.js +++ b/backend/src/routes/adminBackup.js @@ -795,6 +795,8 @@ router.post('/s3/test-upload', adminAuth, requirePermission('backup.create'), as // Test deletion await s3Adapter.delete(testKey); + + if (contentMatch) require('../usage/capabilityEvidence').capabilityEvidence(res, 's3_storage', 's3_backups'); res.json({ success: true, diff --git a/backend/src/routes/adminEmail.js b/backend/src/routes/adminEmail.js index c7a016f1..1a30b528 100644 --- a/backend/src/routes/adminEmail.js +++ b/backend/src/routes/adminEmail.js @@ -1,4 +1,5 @@ const express = require('express'); +const { capabilityEvidence } = require('../usage/capabilityEvidence'); const nodemailer = require('nodemailer'); const { body, query, validationResult } = require('express-validator'); const { db, logActivity } = require('../database/db'); @@ -221,6 +222,7 @@ router.post('/incoming-config/test', adminAuth, requirePermission('email.view'), if (result && result.ok === false) { return res.status(400).json({ error: 'Incoming mail is not configured yet — enter host, username and password first.' }); } + if (result?.ok) capabilityEvidence(res, 'incoming_mail'); res.json(result); } catch (error) { logger.error('IMAP connection test error:', error); @@ -234,7 +236,10 @@ router.post('/incoming-config/roundtrip', adminAuth, requirePermission('email.se try { const emailIntakeService = require('../services/emailIntakeService'); const result = await emailIntakeService.roundTripTest(); - if (result.ok) return res.json(result); + if (result.ok) { + capabilityEvidence(res, 'incoming_mail', 'smtp'); + return res.json(result); + } const map = { smtp_unconfigured: 'Configure and save the outgoing SMTP settings first.', imap_unconfigured: 'Configure and save the incoming IMAP settings first.', @@ -257,6 +262,7 @@ router.post('/incoming-config/poll', adminAuth, requirePermission('email.view'), try { const emailIntakeService = require('../services/emailIntakeService'); const result = await emailIntakeService.pollOnce(); + if (result && !result.skipped) capabilityEvidence(res, 'incoming_mail'); res.json(result); // { processed } or { skipped: 'disabled'|'unconfigured'|'busy' } } catch (error) { logger.error('Manual poll error:', error); @@ -456,6 +462,7 @@ router.post('/accounts/test', adminAuth, messagingGate, requirePermission('email host: b.imap_host, port: b.imap_port, secure: b.imap_secure, user: b.imap_user, pass, folder: b.imap_folder || 'INBOX', }); + if (result?.ok) capabilityEvidence(res, 'incoming_mail'); res.json(result); } catch (error) { res.status(422).json({ ok: false, error: `Mailbox test failed (${error.message}).` }); @@ -511,6 +518,7 @@ router.post('/test', adminAuth, requirePermission('email.send'), async (req, res details: webhookError.message, }); } + capabilityEvidence(res, 'email_webhook'); return res.json({ message: 'Test email sent successfully' }); } @@ -587,6 +595,7 @@ router.post('/test', adminAuth, requirePermission('email.send'), async (req, res + await buildSignatureTextFor('en') }); + capabilityEvidence(res, 'smtp'); res.json({ message: 'Test email sent successfully' }); } catch (error) { logger.error('Test email error:', error); @@ -847,6 +856,8 @@ router.post('/send', adminAuth, messagingGate, requirePermission('email.send'), const emailProcessor = require('../services/emailProcessor'); const result = await emailProcessor.sendRawEmail({ to, cc, subject, html, accountKey }); + if (result.transport === 'webhook') capabilityEvidence(res, 'email_webhook'); + if (result.transport === 'smtp') capabilityEvidence(res, 'smtp'); await db('email_queue').insert({ recipient_email: to, @@ -1259,4 +1270,4 @@ router.post('/templates/:key/preview', adminAuth, requirePermission('email.view' } }); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/backend/src/routes/adminPhotos.js b/backend/src/routes/adminPhotos.js index 3194f7e8..ac0cc220 100644 --- a/backend/src/routes/adminPhotos.js +++ b/backend/src/routes/adminPhotos.js @@ -7,6 +7,7 @@ const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); const { ensureThumbnail } = require('../services/imageProcessor'); const { isVideoMimeType } = require('../services/videoProcessor'); +const { acceptedUpload } = require('../usage/capabilityEvidence'); const { generatePhotoFilename, buildContentDisposition } = require('../utils/filenameSanitizer'); const { getUseOriginalFilenames, @@ -357,6 +358,11 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r event, }); if (result.success) { + acceptedUpload(res, { + video: isVideoMimeType(file.mimetype), + raw: path.extname(file.originalname).toLowerCase() === '.dng', + s3: process.env.STORAGE_BACKEND === 's3' + }); replacedPhotos.push({ id: result.photo.id, filename: result.photo.filename, @@ -471,6 +477,8 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r .returning('id'); const photoId = inserted[0]?.id || inserted[0]; + acceptedUpload(res, { video: isVideo, raw: extension.toLowerCase() === '.dng', s3: process.env.STORAGE_BACKEND === 's3' }); + uploadedPhotos.push({ id: photoId, filename: newFilename, @@ -1720,6 +1728,11 @@ router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, requirePer 'admin', category_id || null ); + if (uploadedPhotos.length) acceptedUpload(res, { + video: isVideoMimeType(fileObj.mimetype), + raw: path.extname(fileObj.originalname).toLowerCase() === '.dng', + s3: process.env.STORAGE_BACKEND === 's3' + }); // Clean up temp directory try { diff --git a/backend/src/routes/adminUsage.js b/backend/src/routes/adminUsage.js new file mode 100644 index 00000000..e14f15bf --- /dev/null +++ b/backend/src/routes/adminUsage.js @@ -0,0 +1,181 @@ +const express = require('express'); +const crypto = require('crypto'); +const rateLimit = require('express-rate-limit'); +const { adminAuth } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/permissions'); +const { ValidationError } = require('../utils/errors'); +const service = require('../services/productUsageService'); +const { ProtocolError } = require('../usage/protocol.cjs'); +const router = express.Router(); +const wrap = (fn) => (req, res, next) => + Promise.resolve(fn(req, res)).catch((error) => { + // instanceof, not `error.name`: ProtocolError extends Error without + // setting `name`, so every instance reports 'Error' and this branch never + // ran. A malformed vote or feedback payload fell through to the global + // handler, which logs it as an unhandled programming error and answers + // INTERNAL_ERROR in production — losing the validation code the caller + // needs. protocol.cjs is vendored byte-identical with picpeak-usage, so + // the fix belongs here rather than in the class. + if (error instanceof ProtocolError) + return res + .status(400) + .json({ error: 'Invalid usage request', code: error.code }); + next(error); + }); +// The three routes below are the only ones whose effect is an outbound +// request to someone else's service, carrying operator-written free text +// (title 120, body 4000, name 80). The platform's general limiter skips +// authenticated requests by design, which is right for endpoints that only +// touch this installation and wrong for a relay: without this an admin +// session can push unbounded traffic at the collector. +// +// Keyed to the installation, not the caller's IP, because the budget being +// protected is "how much this install relays", and per-process because that +// is the same store the rest of the app uses — a multi-replica deployment +// gets one budget per replica, which still bounds the shape that matters. +const outboundLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, + max: 30, + keyGenerator: () => 'usage-outbound', + standardHeaders: true, + legacyHeaders: false, + handler: (_req, res) => + res.status(429).json({ + error: 'Too many usage submissions. Try again later.', + code: 'USAGE_RATE_LIMITED' + }) +}); + +router.use(adminAuth); +router.use((_req, res, next) => { + res.set('Cache-Control', 'no-store'); + next(); +}); +// Any authenticated admin can trigger the daily rollup; only settings editors +// see identity/packets or control consent. The route never accepts telemetry. +router.post( + '/activity', + wrap(async (_req, res) => { + try { + await service.tick(); + } catch (error) { + if (error.code !== 'CONFLICT') throw error; + } + res.json({ ok: true }); + }) +); +router.use(requirePermission('settings.edit')); +router.get( + '/', + wrap(async (_req, res) => res.json(await service.status())) +); +router.post( + '/dismiss', + wrap(async (_req, res) => res.json(await service.dismiss())) +); +router.post( + '/enable', + wrap(async (req, res) => + res.json(await service.enable(req.body.consent_version)) + ) +); +router.post( + '/consent', + wrap(async (req, res) => { + if (!req.body || Object.keys(req.body).length !== 1 || req.body.consent_version !== 'usage-consent.v2') + throw new ValidationError('Explicit usage v2 consent is required'); + res.json(await service.command('consent', { consent_version: 'usage-consent.v2' })); + }) +); +router.post( + '/disable', + wrap(async (_req, res) => res.json(await service.disable())) +); +// Reachable only from a withdrawal whose delete packet can never be signed; +// the service refuses in every other state. See UsageService.abandon(). +router.post( + '/abandon', + wrap(async (_req, res) => res.json(await service.abandon())) +); +// An operator asking for a retry skips the delivery backoff — that button +// exists precisely to not wait for the next window. +router.post( + '/retry', + wrap(async (_req, res) => res.json(await service.tick({ force: true }))) +); +router.get( + '/preview', + wrap(async (_req, res) => res.json(await service.preview())) +); +router.get( + '/export', + wrap(async (_req, res) => + res.attachment('picpeak-usage-packets.json').json(await service.export()) + ) +); +router.put( + '/feedback-preferences', + wrap(async (req, res) => res.json(await service.preferences(req.body))) +); +// Every field the packet schema requires. The allowlist used to let `name`, +// `allow_public` and `allow_marketing` be omitted, and the packet schema — +// which requires all of them — then failed with a bare INVALID_PACKET instead +// of naming the missing field. The UI always sends them; anything driving the +// API directly did not, and got an error it could not act on. +const FEEDBACK_FIELDS = [ + 'kind', + 'title', + 'body', + 'name', + 'allow_public', + 'allow_marketing' +]; +router.post( + '/feedback', + outboundLimiter, + wrap(async (req, res) => { + const body = req.body; + if (!body || typeof body !== 'object') + throw new ValidationError('Invalid feedback'); + const unknown = Object.keys(body).filter( + (key) => !FEEDBACK_FIELDS.includes(key) + ); + if (unknown.length) + throw new ValidationError( + `Unknown feedback fields: ${unknown.join(', ')}` + ); + for (const key of ['kind', 'title', 'body', 'name']) + if (typeof body[key] !== 'string') + throw new ValidationError(`Feedback field "${key}" must be a string`); + for (const key of ['allow_public', 'allow_marketing']) + if (typeof body[key] !== 'boolean') + throw new ValidationError(`Feedback field "${key}" must be a boolean`); + if (!body.title.trim() || !body.body.trim()) + throw new ValidationError('Feedback title and body are required'); + res.json( + await service.command('feedback', { + ...body, + feedback_id: crypto.randomUUID() + }) + ); + }) +); +router.post( + '/vote', + outboundLimiter, + wrap(async (req, res) => res.json(await service.command('vote', req.body))) +); +router.post( + '/portal-session', + outboundLimiter, + wrap(async (_req, res) => { + const result = await service.command('session', {}); + res.json({ + ...result, + url: result.receipt?.session_token + ? `${service.collectorUrl()}/#connect=${encodeURIComponent(result.receipt.session_token)}` + : null + }); + }) +); +module.exports = router; diff --git a/backend/src/routes/adminWhatsapp.js b/backend/src/routes/adminWhatsapp.js index 457bd20d..4b43c411 100644 --- a/backend/src/routes/adminWhatsapp.js +++ b/backend/src/routes/adminWhatsapp.js @@ -176,6 +176,7 @@ router.post('/test', adminAuth, requirePermission('whatsapp.manage'), async (req }; const testComponents = buildComponents(testData, language, params); const result = await sendWhatsAppMessage(phone, config, language, testComponents); + require('../usage/capabilityEvidence').capabilityEvidence(res, 'whatsapp'); res.json({ success: true, messageId: result.messageId }); } catch (error) { logger.error('WhatsApp test send error:', error); diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 6eb4b704..7a5565c1 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -1145,6 +1145,10 @@ router.get('/admin/sso/callback', async (req, res) => { await logActivity('admin_sso_login', { provider: 'oidc' }, null, { type: 'admin', id: admin.id, name: admin.username, }); + // Only a successful ADMIN SSO callback sets this opt-in capability marker. + // No token, claim, address, or account identifier reaches product usage. + require('../services/productUsageService').markUsed(['oauth']) + .catch(() => logger.warn('Product usage marker could not be recorded')); return res.redirect(`${frontendBase}/admin/dashboard`); } catch (error) { diff --git a/backend/src/services/emailProcessor.js b/backend/src/services/emailProcessor.js index 8652500b..59459f7b 100644 --- a/backend/src/services/emailProcessor.js +++ b/backend/src/services/emailProcessor.js @@ -1091,7 +1091,7 @@ async function sendRawEmail({ to, cc, subject, html, text, attachments, accountK ? await emailWebhookTransport.send(mail) : await tx.sendMail(mail); logger.info(`Manual email sent: ${info.messageId}`); - return { messageId: info.messageId, html }; + return { messageId: info.messageId, html, transport: viaWebhook ? 'webhook' : 'smtp' }; } /** diff --git a/backend/src/services/productUsageService.js b/backend/src/services/productUsageService.js new file mode 100644 index 00000000..7441476a --- /dev/null +++ b/backend/src/services/productUsageService.js @@ -0,0 +1,3 @@ +const { db } = require('../database/db'); +const { UsageService } = require('../usage/UsageService'); +module.exports = new UsageService(db); diff --git a/backend/src/usage/UsageService.js b/backend/src/usage/UsageService.js new file mode 100644 index 00000000..20037391 --- /dev/null +++ b/backend/src/usage/UsageService.js @@ -0,0 +1,1086 @@ +'use strict'; +const crypto = require('crypto'); +const fs = require('fs/promises'); +const path = require('path'); +const { getStoragePath } = require('../config/storage'); +const { formatBoolean } = require('../utils/dbCompat'); +const { + ConflictError, + ValidationError, + ServiceUnavailableError +} = require('../utils/errors'); +const { + generateIdentity, + makePacket, + signPacket, + verifyEnvelope, + digest, + canonical, + FEATURE_KEYS, + LEGACY_FEATURE_KEYS, + CATALOG, + CURRENT_SCHEMA_VERSION, + CURRENT_CONSENT_VERSION, + featureKeysFor, + observesUse, + LAYOUTS +} = require('./protocol.cjs'); + +// The collector this installation reports to. Declared once rather than +// inline, because it is a deployment choice: self-hosters point +// USAGE_COLLECTOR_URL at their own collector, and the admin UI links to +// whatever is configured rather than to this default. Kept out of the wire +// contract — `schema.cjs` is vendored byte-identical with picpeak-usage and +// its `$id` is a schema identity, not a delivery address. +const DEFAULT_COLLECTOR_URL = 'https://usage.picpeak.app'; + +const FLAG_MAP = { + crm: 'clients', + crm_quotes: 'quotes', + crm_invoices: 'bills', + crm_contracts: 'contracts', + crm_projects: 'projects', + crm_calendar: 'calendar', + crm_hours: 'hoursLogging', + customer_portal: 'customerPortal', + accounting: 'accounting', + workflows: 'workflows', + newsletters: 'newsletters', + face_recognition: 'faces', + whatsapp: 'whatsapp' +}; +const SETTING_KEYS = [ + 'oidc_enabled', + 'oidc_issuer_url', + 'oidc_client_id', + 'backup_enabled', + 'database_backup_enabled', + 'backup_destination_type', + 'backup_s3_bucket', + 'theme_config', + 'general_custom_css', + 'general_public_site_custom_css' +]; +const truth = (value) => value === true || value === 1 || value === '1'; + +// `events.color_theme` holds either a theme object or the NAME of a preset — +// the admin theme picker stores names, and eventTypeService seeds them too +// (`theme_preset: 'corporateTimeline'`). Reading only `value.galleryLayout` +// therefore reported `grid` for every preset-themed install. +// +// Only the layout each name maps to is duplicated here, not the presets +// themselves; frontend/src/types/theme.types.ts stays the source of truth. A +// name this map does not know reports `other` rather than a confident `grid`, +// so a preset added on the frontend degrades to "something else" instead of +// quietly inflating the grid count. +const PRESET_LAYOUTS = { + default: 'grid', + elegantWedding: 'grid', + modernMasonry: 'masonry', + birthdayFun: 'carousel', + corporateTimeline: 'timeline', + artisticMosaic: 'mosaic', + darkClassic: 'grid', + darkElegant: 'grid', + darkModern: 'masonry', + galleryPremium: 'gallery-premium', + galleryStory: 'gallery-story' +}; + +function resolveLayout(value) { + const named = + typeof value === 'string' + ? PRESET_LAYOUTS[value] + : value && typeof value === 'object' + ? value.galleryLayout + : null; + if (!named) return typeof value === 'string' ? 'other' : 'grid'; + return LAYOUTS.includes(named) ? named : 'other'; +} +const parse = (value) => { + try { + return JSON.parse(value); + } catch (_) { + return value; + } +}; + +class UsageService { + // The collector has provably never accepted anything from this identity: + // no packet was ever acknowledged, so there is nothing remote to delete. + // Abandoning such a participation is harmless, which is why it may be + // offered without the warning the registered case needs. + static neverAccepted(state) { + return Number(state?.sequence || 0) === 0 && !state?.last_receipt; + } + + // The two ways a participation can reach a state no amount of retrying will + // resolve. Kept as one predicate so the settings page and the endpoint can + // never disagree about whether the exit is available. + static abandonable(state) { + if (!['activation_pending', 'deletion_pending'].includes(state?.status)) + return false; + // The signing key is gone: the delete packet can never be produced. + if (state.last_error === 'SIGNING_KEY_UNREADABLE') return true; + // Or the collector never accepted anything and a delivery is failing — + // the ordinary shape of "opted in against a collector that does not speak + // this report version yet". Nothing is registered remotely, so clearing + // the local state costs nothing and is the only way out of the tab. + return Boolean(state.last_error) && UsageService.neverAccepted(state); + } + + schemaVersion(state) { + return state?.consent_version === CURRENT_CONSENT_VERSION + ? CURRENT_SCHEMA_VERSION : 'usage.v1'; + } + constructor(db, options = {}) { + this.db = db; + this.fetch = options.fetch || global.fetch; + this.now = options.now || (() => Date.now()); + this.version = options.version || require('../../package.json').version; + this.secret = + options.secret || + process.env.USAGE_ENCRYPTION_KEY || + process.env.JWT_SECRET; + // Falls back to the default whenever nothing usable is configured — + // unset, empty, or whitespace. Deployments that template the variable in + // (docker-compose writes USAGE_COLLECTOR_URL=${USAGE_COLLECTOR_URL:-...}) + // can hand over an empty string, and that must mean "use the default", + // not "no collector". A value that is present but malformed is NOT + // silently replaced: quietly retargeting a self-hoster's collector at + // ours would send their reports somewhere they did not choose. + const configured = (options.endpoint ?? process.env.USAGE_COLLECTOR_URL ?? '').trim(); + this.endpoint = configured || DEFAULT_COLLECTOR_URL; + this.bindingPath = + options.bindingPath || path.join(getStoragePath(), 'usage-instance.key'); + this.encKey = null; + } + + collectorUrl() { + const url = new URL(this.endpoint); + const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname); + if ( + url.username || + url.password || + url.search || + url.hash || + url.pathname !== '/' || + (url.protocol !== 'https:' && + !( + url.protocol === 'http:' && + loopback && + process.env.NODE_ENV !== 'production' + )) + ) { + throw new ValidationError('Invalid usage collector URL'); + } + return url.origin; + } + key() { + if (!this.secret || this.secret.length < 32) + throw new ServiceUnavailableError( + 'Usage signing-key encryption is not configured' + ); + if (!this.encKey) + this.encKey = crypto.scryptSync( + this.secret, + 'picpeak-product-usage-v1', + 32 + ); + return this.encKey; + } + encrypt(value) { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', this.key(), iv); + const data = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]); + return [iv, cipher.getAuthTag(), data] + .map((v) => v.toString('base64url')) + .join('.'); + } + decrypt(value) { + try { + const [iv, tag, data] = value + .split('.') + .map((v) => Buffer.from(v, 'base64url')); + const cipher = crypto.createDecipheriv('aes-256-gcm', this.key(), iv); + cipher.setAuthTag(tag); + return Buffer.concat([cipher.update(data), cipher.final()]).toString( + 'utf8' + ); + } catch (error) { + // The signing key is encrypted with USAGE_ENCRYPTION_KEY, which defaults + // to JWT_SECRET — so rotating JWT_SECRET, the correct response to a + // suspected compromise, makes this key unreadable. Without a name of its + // own that surfaced as a generic DELIVERY_FAILED that retried forever, + // and it silently blocks the DELETE packet too: participation could + // never be withdrawn from the collector. Tagged so the operator is told + // what actually happened. + const failure = new Error('Usage signing key cannot be decrypted'); + failure.code = 'SIGNING_KEY_UNREADABLE'; + throw failure; + } + } + async binding(create = false) { + if (create) { + await fs.mkdir(path.dirname(this.bindingPath), { recursive: true }); + try { + await fs.writeFile( + this.bindingPath, + crypto.randomBytes(32).toString('hex'), + { flag: 'wx', mode: 0o600 } + ); + } catch (error) { + if (error.code !== 'EEXIST') throw error; + } + } + try { + return digest(await fs.readFile(this.bindingPath)); + } catch (error) { + if (error.code === 'ENOENT') return null; + throw error; + } + } + async state() { + return this.db('product_usage_state').where({ id: 1 }).first(); + } + async status() { + const state = await this.state(); + // Reported, not thrown. status() used to call collectorUrl() bare, so a + // misconfigured USAGE_COLLECTOR_URL — a bare hostname, a path, a query, or + // http in production — failed this request outright. The settings page + // renders one generic failure when its status query errors, so the + // operator saw "the operation could not be completed" with no cause AND + // no way to reach their own state: they could not read the status or even + // withdraw, because every control on that tab is behind this call. + let collectorUrl = null; + let collectorError = null; + try { + collectorUrl = this.collectorUrl(); + } catch { + collectorError = 'INVALID_COLLECTOR_URL'; + } + return { + status: state.status, + notice_dismissed: Boolean(state.notice_dismissed), + installation_id: state.installation_id, + collector_url: collectorUrl, + collector_error: collectorError, + schema_version: this.schemaVersion(state), + available_schema_version: CURRENT_SCHEMA_VERSION, + consent_version: state.consent_version || 'usage-consent.v1', + consent_update_available: state.status === 'active' && this.schemaVersion(state) !== CURRENT_SCHEMA_VERSION, + last_report_date: state.last_report_date, + last_error: state.last_error, + // Epoch ms, or null when nothing is being paced. The settings page shows + // it so a waiting install reads as "waiting" rather than as broken. + retry_after: + Number(state.next_attempt_at || 0) > this.now() + ? Number(state.next_attempt_at) + : null, + // Whether the only remaining exit should be offered. Without it the tab + // shows a permanent error and no control that can clear it. + can_abandon: UsageService.abandonable(state), + // Distinguishes the harmless case (nothing was ever registered, so + // abandoning deletes nothing remote) from the one that needs a warning. + abandon_never_registered: UsageService.neverAccepted(state), + pending_action: state.pending_packet + ? JSON.parse(state.pending_packet).action + : null, + last_packet: state.last_packet ? JSON.parse(state.last_packet) : null, + privacy_receipts: state.privacy_receipts + ? JSON.parse(state.privacy_receipts) + : {}, + feedback_preferences: state.feedback_preferences + ? JSON.parse(state.feedback_preferences) + : { name: '' } + }; + } + + async locked(fn) { + const token = crypto.randomUUID(); + const updated = await this.db('product_usage_state') + .where({ id: 1 }) + .where('lease_until', '<=', this.now()) + .update({ lease_token: token, lease_until: this.now() + 60000 }); + if (!updated) + throw new ConflictError('Usage operation is already in progress'); + try { + return await fn(await this.state()); + } finally { + await this.db('product_usage_state') + .where({ id: 1, lease_token: token }) + .update({ lease_token: null, lease_until: 0 }); + } + } + + // Consecutive failures pace the unattended sender: 2, 4, 8, 16, 32 minutes, + // then hourly. Capped rather than unbounded because a collector that comes + // back after a long outage should be noticed within the hour, and a report + // is only due once per UTC day anyway. + backoffMs(attempts) { + return Math.min(2 ** Math.max(1, attempts), 60) * 60000; + } + async noteDeliveryFailure() { + const state = await this.state(); + const attempts = Number(state?.attempts || 0) + 1; + await this.db('product_usage_state') + .where({ id: 1 }) + .update({ + attempts, + next_attempt_at: this.now() + this.backoffMs(attempts) + }); + } + async clearDeliveryBackoff() { + await this.db('product_usage_state') + .where({ id: 1 }) + .update({ attempts: 0, next_attempt_at: 0 }); + } + + async dismiss() { + await this.db('product_usage_state') + .where({ id: 1 }) + .update({ notice_dismissed: formatBoolean(true) }); + return this.status(); + } + async enable(consent) { + if (!['usage-consent.v1', CURRENT_CONSENT_VERSION].includes(consent)) + throw new ValidationError('Explicit usage consent is required'); + // Read BEFORE the lease, deliberately. locked() claims the lease and then + // reads the row in a second statement; a /disable completing between + // those two would be adopted as this activation's own baseline and + // silently absorbed. Taking the baseline first inverts that: every + // increment from this point on — including one in that gap — is later + // than the value the claim tests for, so the claim fails and the + // withdrawal wins. An increment from BEFORE this read is a withdrawal the + // operator already completed, and a deliberate opt-in afterwards should + // not be vetoed by it. + const cancelSeq = Number((await this.state())?.cancel_seq || 0); + await this.locked(async (state) => { + if (state.status !== 'disabled') + throw new ConflictError( + 'Finish the current participation before rejoining' + ); + this.collectorUrl(); + const identity = generateIdentity(); + const pending = makePacket(identity, 'register', 0, { + consent_version: consent + }, this.schemaVersion({ consent_version: consent })); + // Identity generation and the binding file are the slow part, and the + // row still reads `disabled` throughout — which is why /disable could + // not see an activation in flight and its conditional update matched + // nothing. + const instanceBinding = await this.binding(true); + + // The claim itself carries the check. Re-reading the flag and then + // updating would only move the window rather than close it: this is one + // conditional UPDATE, so a /disable that lands first makes it match no + // rows and the registration is never sent. + const claimed = await this.db('product_usage_state') + .where({ id: 1, status: 'disabled', cancel_seq: cancelSeq }) + .update({ + status: 'activation_pending', + consent_version: consent, + notice_dismissed: formatBoolean(true), + installation_id: identity.installation_id, + public_key: identity.public_key, + private_key_encrypted: this.encrypt(identity.private_key), + instance_binding: instanceBinding, + sequence: 0, + pending_packet: JSON.stringify(pending), + last_error: null, + attempts: 0, + next_attempt_at: 0 + }); + // Withdrawn while activating. Participation stays off and nothing was + // registered, so there is nothing to delete remotely either. + if (!claimed) return; + + await this.deliver(await this.state()); + }); + return this.status(); + } + + async disable() { + // Counted unconditionally and first, because the interesting case is the + // one where there is seemingly nothing to stop: while /enable is still + // generating an identity the row reads `disabled`, so the conditional + // update below matches nothing and the lease conflict from tick() is + // swallowed — the admin was told participation was off while the + // activation went on to complete. enable() claims its state only if this + // counter is unchanged, so a withdrawal landing in that window wins. + await this.db('product_usage_state') + .where({ id: 1 }) + .increment('cancel_seq', 1); + + // Stop collection before waiting for an in-flight send. The sender checks + // state again before delivery and preserves this stop after its response. + await this.db('product_usage_state') + .where({ id: 1 }) + .whereNot({ status: 'disabled' }) + .update({ + status: 'deletion_pending', + feedback_preferences: null, + pending_packet: null, + last_packet: null, + last_receipt: null, + last_report_date: null, + attempts: 0, + next_attempt_at: 0 + }); + await this.db('product_usage_markers').delete(); + try { + await this.tick({ force: true }); + } catch (error) { + // A sender may still own the lease. Collection is already stopped and + // the next admin activity retries deletion after that sender finishes. + if (error.code !== 'CONFLICT') throw error; + } + return this.status(); + } + + // The escape hatch for a withdrawal that can never be signed. When + // USAGE_ENCRYPTION_KEY — or the JWT_SECRET it falls back to — has been + // rotated, the private key is unreadable, so the delete packet cannot be + // produced at all. Retrying and disabling both no-op forever, and enable() + // refuses because the row is not `disabled`: the feature is bricked with no + // control left. Restoring the old key material is the correct fix and stays + // the documented one, but an operator who rotated because of a suspected + // compromise no longer has it. + // + // This drops the local identity and says so honestly: collection is already + // stopped, but the collector was never told, so the receipt records + // `collector-unconfirmed` rather than claiming a deletion that did not + // happen. Deliberately not folded into enable() — abandoning an + // unconfirmed deletion is its own decision, not a side effect of opting in. + async abandon() { + await this.locked(async (state) => { + if (!UsageService.abandonable(state)) + throw new ConflictError( + 'Only a participation that cannot be completed can be abandoned' + ); + const neverRegistered = UsageService.neverAccepted(state); + await fs.unlink(this.bindingPath).catch((error) => { + if (error.code !== 'ENOENT') throw error; + }); + await this.db('product_usage_markers').delete(); + const receipts = state.privacy_receipts + ? JSON.parse(state.privacy_receipts) + : {}; + await this.db('product_usage_state') + .where({ id: 1 }) + .whereIn('status', ['activation_pending', 'deletion_pending']) + .update({ + status: 'disabled', + installation_id: null, + public_key: null, + private_key_encrypted: null, + instance_binding: null, + pending_packet: null, + last_packet: null, + last_receipt: null, + last_report_date: null, + last_error: null, + sequence: 0, + attempts: 0, + next_attempt_at: 0, + feedback_preferences: null, + privacy_receipts: JSON.stringify({ + ...receipts, + last_abandonment: { + receipt_version: 'local-audit.v1', + kind: 'abandonment', + receipt_id: crypto.randomUUID(), + confirmed_at: new Date(this.now()).toISOString(), + // Two different truths, and the receipt has to tell them apart. + // Nothing was ever accepted -> there is provably nothing at the + // collector. Otherwise the collector may still hold reports and + // was never told to delete them; saying "unconfirmed" is the + // only honest wording for that. + status: neverRegistered + ? 'never-registered' + : 'collector-unconfirmed', + reason: state.last_error, + installation_id: state.installation_id, + scope: ['local identity', 'local markers', 'local key material'] + } + }) + }); + }); + return this.status(); + } + + async post(pathname, body, maxResponseBytes = 65536) { + const response = await this.fetch(`${this.collectorUrl()}${pathname}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + redirect: 'error', + signal: AbortSignal.timeout(10000) + }); + if (Number(response.headers.get('content-length') || 0) > maxResponseBytes) + throw new ServiceUnavailableError('Invalid collector response'); + let raw = ''; + let bytes = 0; + const decoder = new TextDecoder(); + for await (const chunk of response.body) { + bytes += chunk.length; + if (bytes > maxResponseBytes) + throw new ServiceUnavailableError('Invalid collector response'); + raw += decoder.decode(chunk, { stream: true }); + } + raw += decoder.decode(); + const value = JSON.parse(raw); + if (!response.ok) { + const error = new Error('Collector rejected operation'); + error.code = value.error; + throw error; + } + return value; + } + async deliver(state) { + const packet = JSON.parse(state.pending_packet); + if ( + packet.action !== 'delete' && + (await this.state()).status === 'deletion_pending' + ) + return null; + try { + if ( + packet.action !== 'delete' && + state.instance_binding !== (await this.binding()) + ) { + await this.db('product_usage_state') + .where({ id: 1 }) + // Never over an opt-out. A withdrawal that arrived while this + // binding lookup was in flight would otherwise be replaced by + // identity_conflict, and tick() stops there — so the deletion the + // operator asked for would never be sent. The collector-conflict + // handler below already guards the same way. + .whereNot({ status: 'deletion_pending' }) + .update({ + status: 'identity_conflict', + last_error: 'INSTANCE_COPY_DETECTED' + }); + return null; + } + const envelope = signPacket( + packet, + { + public_key: state.public_key, + private_key: this.decrypt(state.private_key_encrypted) + }, + new Date(this.now()) + ); + // Last check before anything leaves. The guard at the top of this + // method runs before the binding lookup above, which is asynchronous — + // so a withdrawal that COMPLETED during it would previously still have + // had its registration or report dispatched afterwards. This is not + // about an already-in-flight request; it is about not starting one. + if ( + packet.action !== 'delete' && + (await this.state()).status === 'deletion_pending' + ) + return null; + const receipt = await this.post('/api/envelopes', envelope); + if ( + receipt.packet_id !== packet.packet_id || + receipt.installation_id !== packet.installation_id || + receipt.packet_digest !== digest(canonical(packet)) || + receipt.action !== packet.action || + receipt.sequence !== packet.sequence || + receipt.status !== (packet.action === 'delete' ? 'deleted' : 'accepted') + ) { + throw new Error('Invalid collector receipt'); + } + if (packet.action === 'delete') { + await fs.unlink(this.bindingPath).catch((error) => { + if (error.code !== 'ENOENT') throw error; + }); + await this.db('product_usage_markers').delete(); + await this.db('product_usage_state') + .where({ id: 1 }) + .update({ + status: 'disabled', + installation_id: null, + public_key: null, + private_key_encrypted: null, + instance_binding: null, + pending_packet: null, + last_packet: null, + last_receipt: null, + privacy_receipts: JSON.stringify({ + last_deletion: { + receipt_version: 'local-audit.v1', + kind: 'deletion', + receipt_id: crypto.randomUUID(), + confirmed_at: new Date(this.now()).toISOString(), + status: 'collector-confirmed', + scope: [ + 'reports', + 'snapshots', + 'feedback', + 'votes', + 'sessions', + 'operations', + 'registration' + ] + } + }), + last_report_date: null, + last_error: null, + sequence: 0, + attempts: 0, + next_attempt_at: 0, + feedback_preferences: null + }); + } else { + const storedReceipt = { ...receipt }; + delete storedReceipt.session_token; + const update = { + sequence: packet.sequence, + pending_packet: null, + last_error: null, + attempts: 0, + next_attempt_at: 0, + last_receipt: JSON.stringify(storedReceipt) + }; + if (packet.action === 'report') { + update.last_packet = JSON.stringify(envelope); + update.last_report_date = packet.payload.report_date; + } + // Activation goes in with the acknowledgement, not after it. Split + // across two writes, a failure or a stop between them left the row + // `activation_pending` with pending_packet already cleared — and + // tick() has nothing to retry from there, so the installation was + // registered with the collector but permanently stuck locally. + // Still guarded on activation_pending, so a withdrawal that arrived + // first is not overwritten. + const ack = this.db('product_usage_state').where({ id: 1 }); + if (packet.action === 'register') { + update.status = 'active'; + // Precisely activation_pending, not merely "not withdrawing" — + // this write is the one that turns participation on. + ack.where({ status: 'activation_pending' }); + } else { + ack.whereNot({ status: 'deletion_pending' }); + } + if (packet.action === 'consent') { + // Upgrade and reset the observation period atomically. A late receipt + // must never re-enable collection after an intervening opt-out. + await this.db.transaction(async (tx) => { + const upgraded = await tx('product_usage_state') + .where({ id: 1, status: 'active', installation_id: packet.installation_id }) + .update({ ...update, consent_version: CURRENT_CONSENT_VERSION }); + if (upgraded) await tx('product_usage_markers').delete(); + }); + } else { + await ack.update(update); + } + await this.db('product_usage_state') + .where({ id: 1, status: 'deletion_pending' }) + .update({ sequence: packet.sequence, pending_packet: null }); + } + return receipt; + } catch (error) { + const rejected = [ + 'INVALID_PACKET', + 'INVALID_REPORT_DATE', + 'INVALID_PUBLICATION_CONSENT', + 'REQUEST_NOT_FOUND', + 'FEEDBACK_CONFLICT' + ].includes(error.code); + if (rejected && ['feedback', 'vote', 'session'].includes(packet.action)) { + await this.db('product_usage_state') + .where({ id: 1 }) + .whereNot({ status: 'deletion_pending' }) + .update({ + pending_packet: null, + last_error: 'REQUEST_REJECTED', + attempts: 0, + next_attempt_at: 0 + }); + return null; + } + const conflict = [ + 'SEQUENCE_CONFLICT', + 'IDENTITY_CONFLICT', + 'IDENTITY_REVOKED', + 'NOT_REGISTERED', + 'PACKET_CONFLICT' + ].includes(error.code); + // A collector that answers INVALID_PACKET to a registration or a deletion + // does not understand the wire version we speak — most often because it + // has not been upgraded to usage.v2 yet. Retrying cannot fix that, and + // reporting it as DELIVERY_FAILED sent the operator looking for a + // network problem they do not have. + const code = conflict + ? error.code + : error.code === 'SIGNING_KEY_UNREADABLE' + ? 'SIGNING_KEY_UNREADABLE' + : rejected && ['register', 'delete'].includes(packet.action) + ? 'SCHEMA_NOT_ACCEPTED' + : 'DELIVERY_FAILED'; + await this.db('product_usage_state') + .where({ id: 1 }) + .update({ last_error: code }); + // Paced, not abandoned: the packet stays pending and the operator can + // still force a retry from the settings page. Only the automatic sender + // waits, which is what stops one permanently rejected packet from + // producing one collector request per admin click. + await this.noteDeliveryFailure(); + if (conflict && packet.action !== 'delete') { + await this.db('product_usage_state') + .where({ id: 1 }) + .whereNot({ status: 'deletion_pending' }) + .update({ status: 'identity_conflict' }); + } + return null; + } + } + + // `force` is what the Retry button and /disable pass: an operator asking for + // an attempt now must not be held behind a backoff they can see and want to + // skip. The unattended callers — /activity and the settings ticker — leave + // it off, so a failing packet costs one request per backoff window instead + // of one per admin action. + async tick({ force = false } = {}) { + await this.locked(async (state) => { + if (state.status === 'disabled') return; + if (!force && Number(state.next_attempt_at || 0) > this.now()) return; + if (state.status === 'deletion_pending') { + const packet = makePacket(state, 'delete', Number(state.sequence), {}, this.schemaVersion(state)); + state.pending_packet = JSON.stringify(packet); + await this.db('product_usage_state') + .where({ id: 1 }) + .update({ pending_packet: state.pending_packet }); + await this.deliver(state); + return; + } + if (state.status === 'identity_conflict') return; + if (state.pending_packet) { + await this.deliver(state); + state = await this.state(); + } + if (state.status !== 'active' || state.pending_packet) return; + if ( + state.last_report_date === + new Date(this.now()).toISOString().slice(0, 10) + ) + return; + const payload = await this.snapshot(this.schemaVersion(state)); + const packet = makePacket( + state, + 'report', + Number(state.sequence) + 1, + payload, + this.schemaVersion(state) + ); + state.pending_packet = JSON.stringify(packet); + // Only while still active. /disable clears pending_packet and moves the + // status without taking the lease, so an unconditional write here could + // put a report back into the outbox after the withdrawal had emptied + // it — and deliver() would then leave it there, since it declines to + // send anything but the delete. + const enqueued = await this.db('product_usage_state') + .where({ id: 1, status: 'active' }) + .update({ pending_packet: state.pending_packet }); + if (!enqueued) return; + await this.deliver(state); + }); + return this.status(); + } + + async markUsed(features, { destinationBackup = false, legacyFeatures } = {}) { + let allowed = [...new Set([...features, ...(legacyFeatures || [])])].filter((f) => + FEATURE_KEYS.includes(f) + ); + if (!allowed.length) return; + // Single-transaction status check prevents opt-out racing a late marker. + await this.db.transaction(async (tx) => { + const query = tx('product_usage_state').where({ id: 1 }); + if (this.db.client.config.client === 'pg') query.forUpdate(); + const state = await query.first(); + if (!state || state.status !== 'active') return; + const version = this.schemaVersion(state); + if (legacyFeatures) allowed = version === 'usage.v1' ? legacyFeatures : features; + allowed = allowed.filter((feature) => featureKeysFor(version).includes(feature) && observesUse(feature, version)); + if (!allowed.length) return; + // Only when the operation actually writes to the configured backup + // destination. Deriving this from "a backup ran while S3 is configured" + // marked S3 as USED for a local database backup or a .picpeak export, + // which the middleware also counts as `backup` — so merely configuring + // S3 and downloading a local export claimed S3 was in use. + if (destinationBackup && allowed.includes('backup')) { + const destination = await tx('app_settings') + .where({ setting_key: 'backup_destination_type' }) + .first(); + if (destination && parse(destination.setting_value) === 's3') { + allowed.push('s3_storage'); + if (version === CURRENT_SCHEMA_VERSION) allowed.push('s3_backups'); + } + } + await tx('product_usage_markers') + .insert([...new Set(allowed)].map((feature) => ({ feature }))) + .onConflict('feature') + .ignore(); + }); + } + + // `persist` is false for the settings preview. snapshot() records applied + // custom CSS as a lifetime marker, which meant the "see exactly what would + // be sent" view changed what gets sent — a read with a write behind it, in + // the one place whose whole job is transparency. The reported value is + // unaffected: the marker is derived here either way, and the next real + // report persists it. + async snapshot(version, { persist = true } = {}) { + version = version || this.schemaVersion(await this.state()); + const rows = await this.db('app_settings') + .whereIn('setting_key', SETTING_KEYS) + .select('setting_key', 'setting_value'); + const settings = Object.fromEntries( + rows.map((r) => [r.setting_key, parse(r.setting_value)]) + ); + const flagRows = await this.db('feature_flags') + .whereIn('key', version === CURRENT_SCHEMA_VERSION + ? [...new Set([...Object.values(FLAG_MAP), 'incomingMail', ...Object.values(CATALOG.features).map((f) => f.flag).filter(Boolean)])] + : Object.values(FLAG_MAP)) + .select('key', 'value'); + const flags = Object.fromEntries( + flagRows.map((r) => [r.key, truth(r.value)]) + ); + const used = new Set( + await this.db('product_usage_markers').pluck('feature') + ); + const features = Object.fromEntries( + LEGACY_FEATURE_KEYS.map((key) => [ + key, + { configured: Boolean(flags[FLAG_MAP[key]]), used: used.has(key) } + ]) + ); + features.oauth.configured = + truth(settings.oidc_enabled) && + Boolean(settings.oidc_issuer_url && settings.oidc_client_id); + // Either kind counts. The middleware records /backup/* and + // /database-backup/* under the same capability, so reading only + // backup_enabled reported `used: true, configured: false` for an install + // whose only backup is the scheduled database one. + features.backup.configured = + truth(settings.backup_enabled) || truth(settings.database_backup_enabled); + features.s3_storage.configured = + (settings.backup_destination_type === 's3' && + Boolean(settings.backup_s3_bucket)) || + (process.env.STORAGE_BACKEND === 's3' && + Boolean( + process.env.STORAGE_S3_BUCKET && + process.env.STORAGE_S3_ACCESS_KEY && + process.env.STORAGE_S3_SECRET_KEY + )); + features.share_mounts.configured = Boolean( + await this.db('events') + .whereNotNull('external_path') + .whereNot('external_path', '') + .select('id') + .first() + ); + features.smtp.configured = + Boolean( + await this.db('email_configs') + .whereNotNull('smtp_host') + .whereNot('smtp_host', '') + .select('id') + .first() + ) || + Boolean( + await this.db('mail_accounts') + .whereNotNull('smtp_host') + .whereNot('smtp_host', '') + .select('id') + .first() + ); + features.whatsapp.configured = + features.whatsapp.configured && + Boolean( + await this.db('whatsapp_configs') + .where({ enabled: formatBoolean(true) }) + .whereNot('phone_number_id', '') + .whereNot('access_token', '') + .select('id') + .first() + ); + const theme = settings.theme_config || {}; + // An enabled template applied to an event is gallery styling by the same + // definition as the settings fields — the Custom CSS tab is where both are + // authored. Existence only; template contents are never read. + const appliedTemplate = await this.db('css_templates') + .where('is_enabled', formatBoolean(true)) + .whereIn( + 'id', + this.db('events').whereNotNull('css_template_id').select('css_template_id') + ) + .select('id') + .first(); + features.custom_css.configured = Boolean( + settings.general_custom_css || + settings.general_public_site_custom_css || + theme.customCss || + appliedTemplate + ); + // Read only the theme field, never event names, IDs, sizes, counts, or photos. + const themes = await this.db('events').distinct('color_theme'); + const layouts = new Set(); + // An event with no theme of its own renders with the global one. + const inheritedLayout = resolveLayout(theme); + for (const row of themes) { + const value = parse(row.color_theme); + if (row.color_theme === null || row.color_theme === '') { + layouts.add(inheritedLayout); + } else { + layouts.add(resolveLayout(value)); + } + if (value && typeof value === 'object' && value.customCss) + features.custom_css.configured = true; + } + // Applied CSS is already a capability in use; no visitor observation is + // needed. Remember its presence as a coarse lifetime marker after consent. + if (features.custom_css.configured) { + if (persist) await this.markUsed(['custom_css']); + features.custom_css.used = true; + } + const now = new Date(this.now()).toISOString(); + const expanded = version === CURRENT_SCHEMA_VERSION + ? await require('./expandedSnapshot').expandSnapshot(this.db, { features, flags, used, now: this.now() }) + : features; + return { + picpeak_version: this.version, + report_date: now.slice(0, 10), + generated_at: now, + features: expanded, + gallery_layouts: [...layouts].sort() + }; + } + + async preview() { + const state = await this.state(); + if (state.status !== 'active') + throw new ConflictError('Usage participation is not active'); + return this.snapshot(null, { persist: false }); + } + async command(action, payload) { + let receipt; + await this.locked(async (state) => { + if (state.status !== 'active') + throw new ConflictError('Usage participation is not active'); + if (state.pending_packet) + throw new ConflictError('Retry the pending usage operation first'); + if (!['feedback', 'vote', 'session', 'consent'].includes(action)) + throw new ValidationError('Invalid usage action'); + if (action === 'consent' && state.consent_version === CURRENT_CONSENT_VERSION) + throw new ConflictError('Usage consent is already current'); + const packet = makePacket( + state, + action, + Number(state.sequence) + 1, + payload, + action === 'consent' ? CURRENT_SCHEMA_VERSION : this.schemaVersion(state) + ); + // Validate the complete packet before storing an un-sendable operation. + verifyEnvelope( + signPacket( + packet, + { + public_key: state.public_key, + private_key: this.decrypt(state.private_key_encrypted) + }, + new Date(this.now()) + ), + this.now() + ); + state.pending_packet = JSON.stringify(packet); + // Same guard as the report enqueue in tick(): a command captured while + // active must not restore its payload — feedback body and name + // included — into the outbox that /disable has just cleared. + const enqueued = await this.db('product_usage_state') + .where({ id: 1, status: 'active' }) + .update({ pending_packet: state.pending_packet }); + if (!enqueued) + throw new ConflictError('Usage participation is not active'); + receipt = await this.deliver(state); + }); + const state = await this.status(); + return { + delivered: Boolean(receipt), + queued: Boolean(state.pending_action), + receipt, + state + }; + } + async preferences(value) { + if ( + !value || + Object.keys(value).some((k) => k !== 'name') || + typeof value.name !== 'string' || + value.name.length > 80 + ) + throw new ValidationError('Invalid feedback preferences'); + const updated = await this.db('product_usage_state') + .where({ id: 1, status: 'active' }) + .update({ + feedback_preferences: JSON.stringify({ name: value.name.trim() }) + }); + if (!updated) throw new ConflictError('Usage participation is not active'); + return this.status(); + } + async export() { + const state = await this.state(); + if (!state.installation_id) throw new ConflictError('No usage identity'); + // Own-data export includes the complete retained history, not a truncated + // packet subset. The acceptance/receipt path above stays strictly bounded. + const result = await this.post( + '/api/participant/lookup', + { installation_id: state.installation_id }, + Infinity + ); + // Only the last export during this participation is retained locally. + // Do not restore audit state if opt-out/identity replacement happened while + // the export was in flight. The downloaded file carries the full receipt. + const receipts = state.privacy_receipts + ? JSON.parse(state.privacy_receipts) + : {}; + await this.db('product_usage_state') + .where({ + id: 1, + status: 'active', + installation_id: state.installation_id + }) + .update({ + privacy_receipts: JSON.stringify({ + ...receipts, + last_export: { + receipt_version: 'local-audit.v1', + kind: 'export', + receipt_id: crypto.randomUUID(), + confirmed_at: new Date(this.now()).toISOString(), + // Reports only. Counting every packet — feedback, votes, portal + // sessions, the registration — and labelling the total "usage + // reports" made a privacy receipt state something untrue about + // its own contents, which is exactly the document that has to be + // exact. `packet_count` keeps the total available alongside it. + report_count: Array.isArray(result.packets) + ? result.packets.filter( + (envelope) => envelope?.packet?.action === 'report' + ).length + : 0, + packet_count: Array.isArray(result.packets) + ? result.packets.length + : 0, + scope: [ + 'accepted usage reports', + 'accepted participant operations' + ] + } + }) + }); + return result; + } +} +module.exports = { UsageService, FLAG_MAP }; diff --git a/backend/src/usage/capabilityEvidence.js b/backend/src/usage/capabilityEvidence.js new file mode 100644 index 00000000..747f6d1f --- /dev/null +++ b/backend/src/usage/capabilityEvidence.js @@ -0,0 +1,19 @@ +'use strict'; +const { FEATURE_KEYS, observesUse } = require('./schema.cjs'); + +// Trusted route handlers call this AFTER their business operation succeeds. +// Only fixed, allowlisted keys reach finish middleware. It still requires an +// authenticated admin, a 2xx response and active consent before persisting. +function capabilityEvidence(res, ...keys) { + res.locals.productUsageFeatures = [...new Set([ + ...(res.locals.productUsageFeatures || []), + ...keys.filter((key) => FEATURE_KEYS.includes(key) && observesUse(key)) + ])]; +} +function acceptedUpload(res, { video = false, raw = false, s3 = false } = {}) { + capabilityEvidence(res, 'photo_management', + ...(video ? ['video_uploads'] : []), + ...(raw ? ['camera_raw_uploads'] : []), + ...(s3 ? ['s3_storage', 's3_photo_storage'] : [])); +} +module.exports = { capabilityEvidence, acceptedUpload }; diff --git a/backend/src/usage/capabilityRules.js b/backend/src/usage/capabilityRules.js new file mode 100644 index 00000000..5e8152e6 --- /dev/null +++ b/backend/src/usage/capabilityRules.js @@ -0,0 +1,79 @@ +'use strict'; + +// A fixed capability allowlist, not a route/click log. Only the resulting keys +// survive the request. No request body, query, path, IDs or response values are +// passed to the usage service. Read-only status/health/options polls are absent. +const WRITE = ['POST', 'PUT', 'PATCH', 'DELETE']; +const RULES_V2 = [ + [WRITE, /^\/customers(?:\/|$)/, ['crm']], + [WRITE, /^\/quotes(?:\/|$)/, ['crm', 'crm_quotes']], + [WRITE, /^\/invoices(?:\/|$)/, ['crm', 'crm_invoices']], + [WRITE, /^\/contracts(?:\/|$)/, ['crm', 'crm_contracts']], + [WRITE, /^\/projects(?:\/|$)/, ['crm', 'crm_projects']], + [['GET'], /^\/calendar\/items\/?$/, ['crm', 'crm_calendar']], + [WRITE, /^\/customers\/[^/]+\/(?:hour-entries|bill-combined|trigger-monthly-bill)(?:\/|$)/, ['crm', 'crm_hours']], + [['POST'], /^\/customers\/(?:invite|[^/]+\/send-invite)\/?$/, ['customer_portal']], + [WRITE, /^\/deals\/[^/]+\/installment-plan\/?$/, ['crm', 'crm_installments']], + [WRITE, /^\/(?:quotes\/presets|contracts\/blocks)(?:\/|$)/, ['document_templates']], + [WRITE, /^\/expenses\/inbound(?:\/|$)/, ['accounting', 'accounting_incoming_invoices']], + [WRITE, /^\/expenses(?:\/(?!inbound(?:\/|$))|$)/, ['accounting', 'accounting_expenses']], + [WRITE, /^\/ledger(?:\/|$)/, ['accounting', 'accounting_ledger']], + [['GET'], /^\/ledger\/export\/?$/, ['accounting', 'accounting_ledger']], + [['GET'], /^\/tax-report(?:\/(?:pdf|csv))?\/?$/, ['accounting', 'accounting_tax_report']], + [WRITE, /^\/workflows(?:\/|$)/, ['workflows']], + [WRITE, /^\/newsletters(?:\/[^/]+)?\/?$/, ['newsletters']], + [['POST'], /^\/newsletters\/[^/]+\/(?:test|queue|cancel)\/?$/, ['newsletters']], + [WRITE, /^\/events\/[^/]+\/(?:faces|people)(?:\/|$)/, ['face_recognition']], + [WRITE, /^\/events\/faces\/auto-categories\/?$/, ['face_recognition']], + [['POST'], /^\/external-media\/events\/[^/]+\/import-external\/?$/, ['share_mounts']], + [['POST'], /^\/events\/?$/, ['galleries']], + [['PUT', 'DELETE'], /^\/events\/[^/]+\/?$/, ['galleries']], + [['POST'], /^\/events\/[^/]+\/(?:publish|duplicate|toggle-status|extend|rename|reveal|reset-password)\/?$/, ['galleries']], + [['POST'], /^\/events\/(?:bulk-archive|bulk-delete)\/?$/, ['galleries', 'archive_management']], + [['POST'], /^\/events\/[^/]+\/archive\/?$/, ['archive_management']], + [['POST'], /^\/archives\/[^/]+\/restore\/?$/, ['archive_management']], + [['DELETE'], /^\/archives\/[^/]+\/?$/, ['archive_management']], + [['GET'], /^\/archives\/[^/]+\/download\/?$/, ['archive_management', 'photo_exports']], + [WRITE, /^\/(?:events|photos)\/[^/]+\/photos(?:\/|$)/, ['photo_management']], + [['POST'], /^\/photos\/photos\/[^/]+\/retry\/?$/, ['photo_processing']], + [['POST'], /^\/photos\/repair-(?:dimensions|capture-dates|orientation)\/?$/, ['photo_processing']], + [['POST', 'PUT'], /^\/thumbnails\/(?:settings|regenerate|regenerate-previews)\/?$/, ['photo_processing']], + [['POST'], /^\/photo-export\/[^/]+\/export\/?$/, ['photo_exports']], + [['GET'], /^\/(?:events|photos)\/[^/]+\/photos\/[^/]+\/download\/?$/, ['photo_exports']], + [['GET'], /^\/events\/[^/]+\/(?:qr|qr-print)\/?$/, ['gallery_sharing']], + [['POST'], /^\/events\/[^/]+\/(?:send-gallery-email|resend-email)\/?$/, ['gallery_sharing']], + [['POST'], /^\/events\/[^/]+\/short-urls\/?$/, ['gallery_sharing', 'short_links']], + [['DELETE'], /^\/short-urls\/[^/]+\/?$/, ['short_links']], + [WRITE, /^\/categories(?:\/|$)/, ['gallery_categories']], + [WRITE, /^\/event-types(?:\/|$)/, ['event_types']], + [WRITE, /^\/events\/[^/]+\/slideshow(?:\/|$)/, ['slideshow']], + [['PUT'], /^\/settings\/slideshow\/?$/, ['slideshow']], + [WRITE, /^\/transfers(?:\/|$)/, ['transfers']], + [['GET'], /^\/transfers\/[^/]+\/(?:download|extra-files\/[^/]+\/download|uploads\/[^/]+\/download)\/?$/, ['transfers']], + [['POST'], /^\/email\/send\/?$/, ['messaging']], + [WRITE, /^\/email\/(?:accounts|item\/[^/]+\/[^/]+(?:\/state)?)\/?$/, ['messaging']], + [WRITE, /^\/email\/templates(?:\/|$)/, ['email_templates']], + [['PUT'], /^\/settings\/theme\/?$/, ['branding']], + [WRITE, /^\/settings\/(?:branding|logo|favicon)(?:\/|$)/, ['branding']], + [WRITE, /^\/events\/[^/]+\/logo\/?$/, ['branding']], + [['PUT'], /^\/settings\/seo\/?$/, ['seo_customization']], + [WRITE, /^\/cms\/pages(?:\/|$)/, ['cms']], + [['POST'], /^\/webhooks\/[^/]+\/(?:test|deliveries\/[^/]+\/replay)\/?$/, ['webhooks']], + [WRITE, /^\/users(?:\/(?![^/]+\/reset-password(?:\/|$))|$)/, ['admin_management']], + [WRITE, /^\/roles(?:\/|$)/, ['admin_management']], + [['POST'], /^\/restore\/start\/?$/, ['restore']], + [['GET'], /^\/backup\/picpeak\/export\/?$/, ['backup', 'portable_backup']], + [['POST'], /^\/backup\/picpeak\/import\/?$/, ['restore', 'portable_backup']], + [['POST'], /^\/backup\/run\/?$/, ['backup']], + [['POST'], /^\/database-backup\/backup\/?$/, ['backup', 'database_backup']], + [['GET'], /^\/dashboard\/analytics\/?$/, ['analytics_dashboard']], + [WRITE, /^\/feedback\/(?:feedback|word-filters)(?:\/|$)/, ['feedback_moderation']], + [WRITE, /^\/events\/[^/]+\/guests(?:\/|$)/, ['guest_management']], + [['GET'], /^\/events\/[^/]+\/guests\/(?:export-all|[^/]+\/export)\/?$/, ['guest_management']], +]; + +function capabilityKeys(method, pathname) { + return [...new Set(RULES_V2.filter(([methods, pattern]) => methods.includes(method) && pattern.test(pathname)) + .flatMap(([, , keys]) => keys))]; +} +module.exports = { RULES_V2, capabilityKeys }; diff --git a/backend/src/usage/expandedSnapshot.js b/backend/src/usage/expandedSnapshot.js new file mode 100644 index 00000000..53ffe305 --- /dev/null +++ b/backend/src/usage/expandedSnapshot.js @@ -0,0 +1,121 @@ +'use strict'; +const { CATALOG, emptyFeatures } = require('./schema.cjs'); +const { formatBoolean } = require('../utils/dbCompat'); + +const truth = (value) => value === true || value === 1 || value === '1'; +const parse = (value) => { + for (let i = 0; i < 3 && typeof value === 'string'; i++) { + try { const decoded = JSON.parse(value); if (decoded === value) break; value = decoded; } + catch { break; } + } + return value; +}; + +// Technical configuration only. Never read photos, feedback contents, guest / +// customer / admin profiles, messages, audit logs, delivery logs or counts. +// Presence queries return a literal 1, not even a row's identifying primary key. +async function expandSnapshot(db, { features, flags, used, now }) { + const result = { ...emptyFeatures('usage.v2'), ...features }; + const effective = { analytics: true, userManagement: true, ...flags }; + if (!effective.quotes) effective.bills = false; + if (effective.bills) effective.accounting = true; + if (!effective.accounting) { + effective.incomingInvoices = false; + effective.expenses = false; + effective.taxReport = false; + } + effective.clients = ['customerPortal', 'quotes', 'bills', 'contracts', 'projects', 'calendar', 'hoursLogging', 'newsletters'] + .some((flag) => effective[flag]); + if (['1', 'true', 'yes'].includes(String(process.env.PICPEAK_SINGLE_CONTAINER || '').toLowerCase())) effective.faces = false; + for (const [key, definition] of Object.entries(CATALOG.features)) { + if (definition.configuration === 'builtin') result[key].configured = true; + if (definition.flag) result[key].configured = Boolean(effective[definition.flag]); + if (definition.used && key !== 'custom_css') result[key].used = used.has(key); + if (!definition.used) delete result[key].used; + } + // Applied custom CSS is detected locally without any visitor observation. + result.custom_css.used = features.custom_css.used; + + const has = async (table, columns) => { + if (!(await db.schema.hasTable(table))) return false; + for (const column of columns) if (!(await db.schema.hasColumn(table, column))) return false; + return true; + }; + const exists = async (table, columns, filter) => { + if (!(await has(table, columns))) return false; + const query = db(table); + filter(query); + return Boolean(await query.select(db.raw('1 as present')).first()); + }; + const enabled = (table, column, filter = () => {}) => exists(table, [column], (query) => { + query.where(column, formatBoolean(true)); filter(query); + }); + const settingKeys = [ + 'general_allowed_file_types', 'general_public_site_enabled', + 'download_resolution_picker_enabled', 'branding_watermark_enabled', + 'database_backup_enabled', 'backup_destination_type', 'backup_s3_bucket', + 'default_protection_level', 'enable_devtools_protection', 'enable_canvas_rendering' + ]; + const settings = Object.fromEntries((await db('app_settings') + .whereIn('setting_key', settingKeys).select('setting_key', 'setting_value')) + .map((row) => [row.setting_key, parse(row.setting_value)])); + const extensions = new Set(String(settings.general_allowed_file_types || 'jpg,jpeg,png,webp') + .toLowerCase().split(',').map((s) => s.trim().replace(/^\./, ''))); + result.video_uploads.configured = ['mp4', 'm4v', 'webm', 'mov', 'avi'].some((extension) => extensions.has(extension)); + result.camera_raw_uploads.configured = extensions.has('dng'); + result.public_site.configured = truth(settings.general_public_site_enabled); + result.database_backup.configured = truth(settings.database_backup_enabled); + result.email_webhook.configured = Boolean((process.env.EMAIL_WEBHOOK_URL || '').trim() && (process.env.EMAIL_WEBHOOK_SECRET || '').trim()); + result.s3_photo_storage.configured = process.env.STORAGE_BACKEND === 's3' && + Boolean(process.env.STORAGE_S3_BUCKET && process.env.STORAGE_S3_ACCESS_KEY && process.env.STORAGE_S3_SECRET_KEY); + result.s3_backups.configured = settings.backup_destination_type === 's3' && Boolean(settings.backup_s3_bucket); + result.crm_installments.configured = Boolean(effective.quotes || effective.bills); + result.document_templates.configured = Boolean(effective.quotes || effective.contracts); + const imapColumns = ['imap_host', 'imap_user', 'imap_pass']; + const imapPresent = (query) => { for (const column of imapColumns) query.whereNotNull(column).whereNot(column, ''); }; + result.incoming_mail.configured = Boolean(effective.incomingMail) && ( + await exists('email_configs', imapColumns, imapPresent) || + await exists('mail_accounts', [...imapColumns, 'enabled'], (query) => { imapPresent(query); query.where('enabled', formatBoolean(true)); }) + ); + result.api_integration.configured = await exists('api_tokens', ['revoked_at', 'expires_at'], (query) => { + query.whereNull('revoked_at').where((q) => q.whereNull('expires_at').orWhere('expires_at', '>', new Date(now).toISOString())); + }); + result.webhooks.configured = await enabled('webhooks', 'active'); + for (const [key, column] of Object.entries({ + gallery_guest_uploads: 'allow_user_uploads', gallery_downloads: 'allow_downloads', + gallery_client_access: 'client_access_enabled', gallery_watermarks: 'watermark_downloads' + })) result[key].configured = await enabled('events', column); + result.gallery_watermarks.configured ||= truth(settings.branding_watermark_enabled); + result.gallery_reveal.configured = await exists('events', ['allow_user_uploads', 'reveal_mode'], (query) => + query.where({ allow_user_uploads: formatBoolean(true), reveal_mode: formatBoolean(true) })); + result.gallery_expiration.configured = await exists('events', ['expires_at'], (query) => query.whereNotNull('expires_at')); + result.download_resolution_picker.configured = truth(settings.download_resolution_picker_enabled) || + await enabled('events', 'download_resolution_picker_enabled'); + // Only what an operator actually changed. PicPeak ships + // default_protection_level='standard' and enable_devtools_protection=true — + // globally and on every event row — so accepting either as evidence made + // this signal `true` on a bare install with no galleries at all. It reported + // fleet-wide 100% and could never separate a deliberate configuration from + // an untouched one, which is a field that costs consent budget and explains + // nothing. `enable_devtools_protection` is therefore not read at all: being + // on by default, its only informative state is off, which is the opposite + // of what this key claims. The remaining inputs each ship off ('standard' + // protection, no canvas rendering, right-click allowed), so a true here is + // always a decision someone made. + result.gallery_image_protection.configured = + ['enhanced', 'maximum'].includes(settings.default_protection_level) || + truth(settings.enable_canvas_rendering); + for (const column of ['disable_right_click', 'use_canvas_rendering']) + result.gallery_image_protection.configured ||= await enabled('events', column); + result.gallery_image_protection.configured ||= await exists('events', ['protection_level'], (query) => + query.whereIn('protection_level', ['enhanced', 'maximum'])); + for (const [suffix, column] of Object.entries({ + likes: 'allow_likes', ratings: 'allow_ratings', comments: 'allow_comments', + favorites: 'allow_favorites', reactions: 'allow_reactions', color_labels: 'allow_color_labels' + })) result['gallery_feedback_' + suffix].configured = await exists('event_feedback_settings', ['feedback_enabled', column], (query) => + query.where({ feedback_enabled: formatBoolean(true), [column]: formatBoolean(true) })); + result.gallery_guest_accounts.configured = await exists('event_feedback_settings', ['feedback_enabled', 'identity_mode'], (query) => + query.where('feedback_enabled', formatBoolean(true)).whereIn('identity_mode', ['guest', 'shared'])); + return result; +} +module.exports = { expandSnapshot }; diff --git a/backend/src/usage/features.v2.json b/backend/src/usage/features.v2.json new file mode 100644 index 00000000..9e51dd48 --- /dev/null +++ b/backend/src/usage/features.v2.json @@ -0,0 +1,1291 @@ +{ + "schema_version": "usage.v2", + "consent_version": "usage-consent.v2", + "features": { + "crm": { + "category": "crm", + "since": "usage.v1", + "measurement": "configuration_and_use", + "configuration": "flag", + "flag": "clients", + "name": { + "en": "Client management", + "de": "Kundenverwaltung" + }, + "configured": { + "en": "The clients capability switch is effectively enabled; only a boolean.", + "de": "Der Funktionsschalter clients ist effektiv aktiviert; nur ein Wahrheitswert." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "crm_quotes": { + "category": "crm", + "since": "usage.v1", + "measurement": "configuration_and_use", + "configuration": "flag", + "flag": "quotes", + "name": { + "en": "Quotes", + "de": "Angebote" + }, + "configured": { + "en": "The quotes capability switch is effectively enabled; only a boolean.", + "de": "Der Funktionsschalter quotes ist effektiv aktiviert; nur ein Wahrheitswert." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "crm_invoices": { + "category": "crm", + "since": "usage.v1", + "measurement": "configuration_and_use", + "configuration": "flag", + "flag": "bills", + "name": { + "en": "Invoices", + "de": "Rechnungen" + }, + "configured": { + "en": "The bills capability switch is effectively enabled; only a boolean.", + "de": "Der Funktionsschalter bills ist effektiv aktiviert; nur ein Wahrheitswert." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "crm_contracts": { + "category": "crm", + "since": "usage.v1", + "measurement": "configuration_and_use", + "configuration": "flag", + "flag": "contracts", + "name": { + "en": "Contracts", + "de": "Verträge" + }, + "configured": { + "en": "The contracts capability switch is effectively enabled; only a boolean.", + "de": "Der Funktionsschalter contracts ist effektiv aktiviert; nur ein Wahrheitswert." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "crm_projects": { + "category": "crm", + "since": "usage.v1", + "measurement": "configuration_and_use", + "configuration": "flag", + "flag": "projects", + "name": { + "en": "Projects", + "de": "Projekte" + }, + "configured": { + "en": "The projects capability switch is effectively enabled; only a boolean.", + "de": "Der Funktionsschalter projects ist effektiv aktiviert; nur ein Wahrheitswert." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "crm_calendar": { + "category": "crm", + "since": "usage.v1", + "measurement": "configuration_and_use", + "configuration": "flag", + "flag": "calendar", + "name": { + "en": "Admin calendar", + "de": "Admin-Kalender" + }, + "configured": { + "en": "The calendar capability switch is effectively enabled; only a boolean.", + "de": "Der Funktionsschalter calendar ist effektiv aktiviert; nur ein Wahrheitswert." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "crm_hours": { + "category": "crm", + "since": "usage.v1", + "measurement": "configuration_and_use", + "configuration": "flag", + "flag": "hoursLogging", + "name": { + "en": "Hours logging", + "de": "Zeiterfassung" + }, + "configured": { + "en": "The hoursLogging capability switch is effectively enabled; only a boolean.", + "de": "Der Funktionsschalter hoursLogging ist effektiv aktiviert; nur ein Wahrheitswert." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "customer_portal": { + "category": "crm", + "since": "usage.v1", + "measurement": "configuration_and_use", + "configuration": "flag", + "flag": "customerPortal", + "name": { + "en": "Customer portal", + "de": "Kundenportal" + }, + "configured": { + "en": "The customerPortal capability switch is effectively enabled; only a boolean.", + "de": "Der Funktionsschalter customerPortal ist effektiv aktiviert; nur ein Wahrheitswert." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "accounting": { + "category": "accounting", + "since": "usage.v1", + "measurement": "configuration_and_use", + "configuration": "flag", + "flag": "accounting", + "name": { + "en": "Accounting", + "de": "Buchhaltung" + }, + "configured": { + "en": "The accounting capability switch is effectively enabled; only a boolean.", + "de": "Der Funktionsschalter accounting ist effektiv aktiviert; nur ein Wahrheitswert." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "workflows": { + "category": "automation", + "since": "usage.v1", + "measurement": "configuration_and_use", + "configuration": "flag", + "flag": "workflows", + "name": { + "en": "Workflows", + "de": "Workflows" + }, + "configured": { + "en": "The workflows capability switch is effectively enabled; only a boolean.", + "de": "Der Funktionsschalter workflows ist effektiv aktiviert; nur ein Wahrheitswert." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "newsletters": { + "category": "communication", + "since": "usage.v1", + "measurement": "configuration_and_use", + "configuration": "flag", + "flag": "newsletters", + "name": { + "en": "Newsletters", + "de": "Newsletter" + }, + "configured": { + "en": "The newsletters capability switch is effectively enabled; only a boolean.", + "de": "Der Funktionsschalter newsletters ist effektiv aktiviert; nur ein Wahrheitswert." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "face_recognition": { + "category": "gallery", + "since": "usage.v1", + "measurement": "configuration_and_use", + "configuration": "flag", + "flag": "faces", + "name": { + "en": "Face recognition", + "de": "Gesichtserkennung" + }, + "configured": { + "en": "The faces capability switch is effectively enabled; only a boolean.", + "de": "Der Funktionsschalter faces ist effektiv aktiviert; nur ein Wahrheitswert." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "custom_css": { + "category": "appearance", + "since": "usage.v1", + "measurement": "configuration_and_use", + "configuration": "presence", + "name": { + "en": "Custom CSS", + "de": "Eigenes CSS" + }, + "configured": { + "en": "Custom CSS is configured globally or applied through a gallery/theme/template; CSS text is not sent.", + "de": "Eigenes CSS ist global oder über Galerie/Theme/Vorlage eingerichtet; CSS-Inhalte werden nicht gesendet." + }, + "used": { + "en": "Applied CSS observed after consent, without observing visitors.", + "de": "Angewendetes CSS nach Zustimmung festgestellt, ohne Besucher zu beobachten." + } + }, + "oauth": { + "category": "integration", + "since": "usage.v1", + "measurement": "configuration_and_use", + "configuration": "presence", + "name": { + "en": "Admin SSO", + "de": "Admin-SSO" + }, + "configured": { + "en": "Admin OIDC is enabled and issuer/client configuration is present; no provider or credential values.", + "de": "Admin-OIDC ist aktiviert und die Anbieter-/Client-Konfiguration vorhanden; keine Anbieter- oder Zugangsdaten." + }, + "used": { + "en": "Successful admin SSO login; no account, identity-provider or session details.", + "de": "Erfolgreiche Admin-SSO-Anmeldung; keine Konto-, Anbieter- oder Sitzungsdetails." + } + }, + "smtp": { + "category": "communication", + "since": "usage.v1", + "measurement": "configuration_and_use", + "configuration": "presence", + "name": { + "en": "SMTP delivery", + "de": "SMTP-Versand" + }, + "configured": { + "en": "An outgoing SMTP host is configured; no host, account, address or credentials.", + "de": "Ein ausgehender SMTP-Host ist konfiguriert; keine Hosts, Konten, Adressen oder Zugangsdaten." + }, + "used": { + "en": "A successful explicitly initiated admin SMTP test/send; no recipients or messages.", + "de": "Erfolgreicher ausdrücklich ausgelöster Admin-SMTP-Test/-Versand; keine Empfänger oder Nachrichten." + } + }, + "whatsapp": { + "category": "communication", + "since": "usage.v1", + "measurement": "configuration_and_use", + "configuration": "presence", + "name": { + "en": "WhatsApp integration", + "de": "WhatsApp-Integration" + }, + "configured": { + "en": "The WhatsApp capability is enabled and a usable configuration is present; no phone number, token or template.", + "de": "Die WhatsApp-Funktion ist aktiviert und eine nutzbare Konfiguration vorhanden; keine Telefonnummer, Tokens oder Vorlagen." + }, + "used": { + "en": "Successful admin integration test; no recipient, message or delivery history.", + "de": "Erfolgreicher Admin-Integrationstest; keine Empfänger, Nachrichten oder Zustellverläufe." + } + }, + "backup": { + "category": "operations", + "since": "usage.v1", + "measurement": "configuration_and_use", + "configuration": "presence", + "name": { + "en": "Backups", + "de": "Sicherungen" + }, + "configured": { + "en": "A full or database backup schedule is enabled; no schedule, path, storage sizes or backup names.", + "de": "Ein Voll- oder Datenbanksicherungsplan ist aktiviert; keine Zeitpläne, Pfade, Speichergrößen oder Sicherungsnamen." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "s3_storage": { + "category": "integration", + "since": "usage.v1", + "measurement": "configuration_and_use", + "configuration": "presence", + "name": { + "en": "S3 storage", + "de": "S3-Speicher" + }, + "configured": { + "en": "S3 is configured for media or backups; no bucket, endpoint, credentials or object keys.", + "de": "S3 ist für Medien oder Sicherungen konfiguriert; keine Buckets, Endpunkte, Zugangsdaten oder Objektschlüssel." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "share_mounts": { + "category": "integration", + "since": "usage.v1", + "measurement": "configuration_and_use", + "configuration": "presence", + "name": { + "en": "External folders", + "de": "Externe Ordner" + }, + "configured": { + "en": "At least one gallery uses an external folder; only existence, no folder paths or gallery identifiers.", + "de": "Mindestens eine Galerie verwendet einen externen Ordner; nur Existenz, keine Ordnerpfade oder Galeriekennungen." + }, + "used": { + "en": "An admin initiated an accepted external-folder import; no scanned paths, files or counts.", + "de": "Ein Admin hat einen angenommenen Import aus einem externen Ordner ausgelöst; keine Pfade, Dateien oder Anzahlen." + } + }, + "galleries": { + "category": "gallery", + "since": "usage.v2", + "measurement": "configuration_and_use", + "configuration": "builtin", + "name": { + "en": "Gallery management", + "de": "Galerieverwaltung" + }, + "configured": { + "en": "Built-in capability is available; this is not evidence of use.", + "de": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "photo_management": { + "category": "gallery", + "since": "usage.v2", + "measurement": "configuration_and_use", + "configuration": "builtin", + "name": { + "en": "Media management", + "de": "Medienverwaltung" + }, + "configured": { + "en": "Built-in capability is available; this is not evidence of use.", + "de": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "photo_exports": { + "category": "gallery", + "since": "usage.v2", + "measurement": "configuration_and_use", + "configuration": "builtin", + "name": { + "en": "Admin media export", + "de": "Admin-Medienexport" + }, + "configured": { + "en": "Built-in capability is available; this is not evidence of use.", + "de": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "photo_processing": { + "category": "gallery", + "since": "usage.v2", + "measurement": "configuration_and_use", + "configuration": "builtin", + "name": { + "en": "Media maintenance tools", + "de": "Medien-Wartungswerkzeuge" + }, + "configured": { + "en": "Built-in capability is available; this is not evidence of use.", + "de": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "archive_management": { + "category": "gallery", + "since": "usage.v2", + "measurement": "configuration_and_use", + "configuration": "builtin", + "name": { + "en": "Gallery archives", + "de": "Galeriearchive" + }, + "configured": { + "en": "Built-in capability is available; this is not evidence of use.", + "de": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "gallery_sharing": { + "category": "gallery", + "since": "usage.v2", + "measurement": "configuration_and_use", + "configuration": "builtin", + "name": { + "en": "Gallery sharing and QR", + "de": "Galeriefreigabe und QR" + }, + "configured": { + "en": "Built-in capability is available; this is not evidence of use.", + "de": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "short_links": { + "category": "gallery", + "since": "usage.v2", + "measurement": "configuration_and_use", + "configuration": "builtin", + "name": { + "en": "Short links", + "de": "Kurzlinks" + }, + "configured": { + "en": "Built-in capability is available; this is not evidence of use.", + "de": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "gallery_categories": { + "category": "gallery", + "since": "usage.v2", + "measurement": "configuration_and_use", + "configuration": "builtin", + "name": { + "en": "Photo categories", + "de": "Fotokategorien" + }, + "configured": { + "en": "Built-in capability is available; this is not evidence of use.", + "de": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "event_types": { + "category": "gallery", + "since": "usage.v2", + "measurement": "configuration_and_use", + "configuration": "builtin", + "name": { + "en": "Event types and presets", + "de": "Ereignistypen und Vorlagen" + }, + "configured": { + "en": "Built-in capability is available; this is not evidence of use.", + "de": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "slideshow": { + "category": "gallery", + "since": "usage.v2", + "measurement": "configuration_and_use", + "configuration": "flag", + "flag": "slideshow", + "name": { + "en": "Live slideshow", + "de": "Live-Diashow" + }, + "configured": { + "en": "The slideshow capability switch is effectively enabled; only a boolean.", + "de": "Der Funktionsschalter slideshow ist effektiv aktiviert; nur ein Wahrheitswert." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "transfers": { + "category": "gallery", + "since": "usage.v2", + "measurement": "configuration_and_use", + "configuration": "flag", + "flag": "transfers", + "name": { + "en": "PicTransfer", + "de": "PicTransfer" + }, + "configured": { + "en": "The transfers capability switch is effectively enabled; only a boolean.", + "de": "Der Funktionsschalter transfers ist effektiv aktiviert; nur ein Wahrheitswert." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "video_uploads": { + "category": "gallery", + "since": "usage.v2", + "measurement": "configuration_and_use", + "configuration": "presence", + "name": { + "en": "Admin video uploads", + "de": "Admin-Video-Uploads" + }, + "configured": { + "en": "Video extensions are allowed in global upload settings; no uploaded-file metadata.", + "de": "Videoformate sind in den globalen Upload-Einstellungen erlaubt; keine Metadaten hochgeladener Dateien." + }, + "used": { + "en": "At least one admin video file was successfully stored/accepted; no names, formats, lengths, sizes or processing/visitor history.", + "de": "Mindestens eine Admin-Videodatei wurde erfolgreich gespeichert/angenommen; keine Namen, Formate, Längen, Größen oder Verarbeitungs-/Besucherverläufe." + } + }, + "camera_raw_uploads": { + "category": "gallery", + "since": "usage.v2", + "measurement": "configuration_and_use", + "configuration": "presence", + "name": { + "en": "Admin camera RAW uploads", + "de": "Admin-Kamera-RAW-Uploads" + }, + "configured": { + "en": "Camera RAW (DNG) is allowed in global upload settings; no camera models or EXIF.", + "de": "Kamera-RAW (DNG) ist in den globalen Upload-Einstellungen erlaubt; keine Kameramodelle oder EXIF-Daten." + }, + "used": { + "en": "At least one admin camera RAW upload was stored/accepted; only the capability bit, no filename or metadata.", + "de": "Mindestens ein Admin-Kamera-RAW-Upload wurde gespeichert/angenommen; nur das Capability-Bit, keine Dateinamen oder Metadaten." + } + }, + "messaging": { + "category": "communication", + "since": "usage.v2", + "measurement": "configuration_and_use", + "configuration": "flag", + "flag": "messaging", + "name": { + "en": "Messaging tools", + "de": "Nachrichtenwerkzeuge" + }, + "configured": { + "en": "The messaging capability switch is effectively enabled; only a boolean.", + "de": "Der Funktionsschalter messaging ist effektiv aktiviert; nur ein Wahrheitswert." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "incoming_mail": { + "category": "communication", + "since": "usage.v2", + "measurement": "configuration_and_use", + "configuration": "presence", + "name": { + "en": "IMAP intake", + "de": "IMAP-Empfang" + }, + "configured": { + "en": "Incoming mail is enabled and an IMAP configuration is present; no mailbox, server, folders or credentials.", + "de": "Eingehende E-Mails sind aktiviert und eine IMAP-Konfiguration vorhanden; keine Postfächer, Server, Ordner oder Zugangsdaten." + }, + "used": { + "en": "A successful explicit admin connection test or non-skipped manual poll; no background intake, messages, attachments or counts.", + "de": "Erfolgreicher expliziter Admin-Verbindungstest oder nicht übersprungener manueller Abruf; kein Hintergrundempfang, keine Nachrichten, Anhänge oder Anzahlen." + } + }, + "reminder_emails": { + "category": "communication", + "since": "usage.v2", + "measurement": "configuration", + "configuration": "configuration", + "flag": "reminderEmails", + "name": { + "en": "Automatic event reminders", + "de": "Automatische Ereigniserinnerungen" + }, + "configured": { + "en": "The reminderEmails capability switch is effectively enabled; only a boolean.", + "de": "Der Funktionsschalter reminderEmails ist effektiv aktiviert; nur ein Wahrheitswert." + }, + "used": null + }, + "email_templates": { + "category": "communication", + "since": "usage.v2", + "measurement": "configuration_and_use", + "configuration": "builtin", + "name": { + "en": "Email templates", + "de": "E-Mail-Vorlagen" + }, + "configured": { + "en": "Built-in capability is available; this is not evidence of use.", + "de": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "email_webhook": { + "category": "communication", + "since": "usage.v2", + "measurement": "configuration_and_use", + "configuration": "presence", + "name": { + "en": "Email webhook transport", + "de": "E-Mail-Webhook-Transport" + }, + "configured": { + "en": "Both email webhook settings are present; no URL or secret.", + "de": "Beide E-Mail-Webhook-Einstellungen sind vorhanden; keine URL oder Geheimnisse." + }, + "used": { + "en": "Successful explicitly initiated admin send/test through the webhook transport; no recipients, messages or automatic deliveries.", + "de": "Erfolgreicher ausdrücklich ausgelöster Admin-Versand/-Test über den Webhook-Transport; keine Empfänger, Nachrichten oder automatischen Zustellungen." + } + }, + "accounting_incoming_invoices": { + "category": "accounting", + "since": "usage.v2", + "measurement": "configuration_and_use", + "configuration": "flag", + "flag": "incomingInvoices", + "name": { + "en": "Incoming invoices", + "de": "Eingangsrechnungen" + }, + "configured": { + "en": "The incomingInvoices capability switch is effectively enabled; only a boolean.", + "de": "Der Funktionsschalter incomingInvoices ist effektiv aktiviert; nur ein Wahrheitswert." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "accounting_expenses": { + "category": "accounting", + "since": "usage.v2", + "measurement": "configuration_and_use", + "configuration": "flag", + "flag": "expenses", + "name": { + "en": "Expenses", + "de": "Ausgaben" + }, + "configured": { + "en": "The expenses capability switch is effectively enabled; only a boolean.", + "de": "Der Funktionsschalter expenses ist effektiv aktiviert; nur ein Wahrheitswert." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "accounting_tax_report": { + "category": "accounting", + "since": "usage.v2", + "measurement": "configuration_and_use", + "configuration": "flag", + "flag": "taxReport", + "name": { + "en": "Tax reports", + "de": "Steuerberichte" + }, + "configured": { + "en": "The taxReport capability switch is effectively enabled; only a boolean.", + "de": "Der Funktionsschalter taxReport ist effektiv aktiviert; nur ein Wahrheitswert." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "accounting_ledger": { + "category": "accounting", + "since": "usage.v2", + "measurement": "configuration_and_use", + "configuration": "flag", + "flag": "accounting", + "name": { + "en": "Ledger and accounting export", + "de": "Kontenplan und Buchhaltungsexport" + }, + "configured": { + "en": "The accounting capability switch is effectively enabled; only a boolean.", + "de": "Der Funktionsschalter accounting ist effektiv aktiviert; nur ein Wahrheitswert." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "crm_installments": { + "category": "crm", + "since": "usage.v2", + "measurement": "configuration_and_use", + "configuration": "presence", + "name": { + "en": "Installment-plan tools", + "de": "Ratenplan-Werkzeuge" + }, + "configured": { + "en": "Quotes or invoices are enabled; no actual payment plans, amounts or statuses are inspected.", + "de": "Angebote oder Rechnungen sind aktiviert; tatsächliche Ratenpläne, Beträge oder Zahlungsstatus werden nicht geprüft." + }, + "used": { + "en": "An admin saved an installment plan; no dates, amounts, currencies, payment status or document IDs.", + "de": "Ein Admin hat einen Ratenplan gespeichert; keine Termine, Beträge, Währungen, Zahlungsstatus oder Dokumentkennungen." + } + }, + "document_templates": { + "category": "crm", + "since": "usage.v2", + "measurement": "configuration_and_use", + "configuration": "presence", + "name": { + "en": "Document presets and blocks", + "de": "Dokumentvorlagen und Bausteine" + }, + "configured": { + "en": "Quotes or contracts are enabled, making document presets/blocks available; no template content.", + "de": "Angebote oder Verträge sind aktiviert und stellen Dokumentvorlagen/-bausteine bereit; keine Vorlageninhalte." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "cms": { + "category": "appearance", + "since": "usage.v2", + "measurement": "configuration_and_use", + "configuration": "builtin", + "name": { + "en": "CMS pages", + "de": "CMS-Seiten" + }, + "configured": { + "en": "Built-in capability is available; this is not evidence of use.", + "de": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "public_site": { + "category": "appearance", + "since": "usage.v2", + "measurement": "configuration", + "configuration": "configuration", + "name": { + "en": "Public landing page", + "de": "Öffentliche Startseite" + }, + "configured": { + "en": "The public landing-page setting is enabled; no page HTML, texts, domains or visitors.", + "de": "Die Einstellung für die öffentliche Startseite ist aktiviert; keine HTML-Inhalte, Texte, Domains oder Besucher." + }, + "used": null + }, + "branding": { + "category": "appearance", + "since": "usage.v2", + "measurement": "configuration_and_use", + "configuration": "builtin", + "name": { + "en": "Branding settings", + "de": "Branding-Einstellungen" + }, + "configured": { + "en": "Built-in capability is available; this is not evidence of use.", + "de": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "seo_customization": { + "category": "appearance", + "since": "usage.v2", + "measurement": "configuration_and_use", + "configuration": "builtin", + "name": { + "en": "SEO settings", + "de": "SEO-Einstellungen" + }, + "configured": { + "en": "Built-in capability is available; this is not evidence of use.", + "de": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "admin_management": { + "category": "operations", + "since": "usage.v2", + "measurement": "configuration_and_use", + "configuration": "flag", + "flag": "userManagement", + "name": { + "en": "Admin and role management", + "de": "Admin- und Rollenverwaltung" + }, + "configured": { + "en": "The userManagement capability switch is effectively enabled; only a boolean.", + "de": "Der Funktionsschalter userManagement ist effektiv aktiviert; nur ein Wahrheitswert." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "api_integration": { + "category": "integration", + "since": "usage.v2", + "measurement": "configuration_and_use", + "configuration": "presence", + "name": { + "en": "HTTP API integration", + "de": "HTTP-API-Integration" + }, + "configured": { + "en": "An unrevoked, unexpired API credential exists; no tokens, names, scopes or owner data.", + "de": "Ein nicht widerrufener und nicht abgelaufener API-Zugang existiert; keine Tokens, Namen, Berechtigungswerte oder Inhaberdaten." + }, + "used": { + "en": "Successful authenticated HTTP API capability call; only this bit, never URLs, request values, token/owner IDs or call counts. Does not trigger a report.", + "de": "Erfolgreicher authentifizierter HTTP-API-Funktionsaufruf; nur dieses Bit, niemals URLs, Requestwerte, Token-/Inhaberkennungen oder Aufrufzahlen. Löst keinen Report aus." + } + }, + "webhooks": { + "category": "integration", + "since": "usage.v2", + "measurement": "configuration_and_use", + "configuration": "presence", + "name": { + "en": "Outbound webhooks", + "de": "Ausgehende Webhooks" + }, + "configured": { + "en": "At least one active webhook is configured; no destinations, subscriptions, secrets or delivery logs.", + "de": "Mindestens ein aktiver Webhook ist konfiguriert; keine Ziele, Abonnements, Geheimnisse oder Zustellprotokolle." + }, + "used": { + "en": "Successful explicit admin webhook test/replay; no automatic or visitor-triggered deliveries.", + "de": "Erfolgreicher expliziter Admin-Webhook-Test/-Replay; keine automatischen oder durch Besucher ausgelösten Zustellungen." + } + }, + "restore": { + "category": "operations", + "since": "usage.v2", + "measurement": "configuration_and_use", + "configuration": "builtin", + "name": { + "en": "Restore", + "de": "Wiederherstellung" + }, + "configured": { + "en": "Built-in capability is available; this is not evidence of use.", + "de": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "portable_backup": { + "category": "operations", + "since": "usage.v2", + "measurement": "configuration_and_use", + "configuration": "builtin", + "name": { + "en": "Portable PicPeak export/import", + "de": "Portabler PicPeak-Export/Import" + }, + "configured": { + "en": "Built-in capability is available; this is not evidence of use.", + "de": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "database_backup": { + "category": "operations", + "since": "usage.v2", + "measurement": "configuration_and_use", + "configuration": "presence", + "name": { + "en": "Database backups", + "de": "Datenbanksicherungen" + }, + "configured": { + "en": "Scheduled database backups are enabled; no schedules, file names or database contents.", + "de": "Geplante Datenbanksicherungen sind aktiviert; keine Zeitpläne, Dateinamen oder Datenbankinhalte." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "s3_photo_storage": { + "category": "integration", + "since": "usage.v2", + "measurement": "configuration_and_use", + "configuration": "presence", + "name": { + "en": "S3 media storage", + "de": "S3-Medienspeicher" + }, + "configured": { + "en": "S3 is the configured media backend and required credentials are present; no values are sent.", + "de": "S3 ist als Medienspeicher konfiguriert und erforderliche Zugangsdaten sind vorhanden; keine Werte werden gesendet." + }, + "used": { + "en": "Successful admin media storage/accepted upload to S3; no buckets, objects or sizes.", + "de": "Erfolgreiche Admin-Medienspeicherung/angenommener Upload nach S3; keine Buckets, Objekte oder Größen." + } + }, + "s3_backups": { + "category": "integration", + "since": "usage.v2", + "measurement": "configuration_and_use", + "configuration": "presence", + "name": { + "en": "S3 backup destination", + "de": "S3-Sicherungsziel" + }, + "configured": { + "en": "The configured backup destination is S3 with a bucket present; no bucket or credentials.", + "de": "Das konfigurierte Sicherungsziel ist S3 und ein Bucket ist angegeben; kein Bucketname oder Zugangsdaten." + }, + "used": { + "en": "An admin started a backup to the configured S3 destination or a successful S3 test upload; local exports never imply S3 use.", + "de": "Ein Admin hat eine Sicherung zum konfigurierten S3-Ziel oder einen erfolgreichen S3-Testupload gestartet; lokale Exporte implizieren keine S3-Nutzung." + } + }, + "analytics_dashboard": { + "category": "operations", + "since": "usage.v2", + "measurement": "configuration_and_use", + "configuration": "flag", + "flag": "analytics", + "name": { + "en": "Existing analytics module", + "de": "Bestehendes Analytics-Modul" + }, + "configured": { + "en": "The analytics capability switch is effectively enabled; only a boolean.", + "de": "Der Funktionsschalter analytics ist effektiv aktiviert; nur ein Wahrheitswert." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "feedback_moderation": { + "category": "gallery", + "since": "usage.v2", + "measurement": "configuration_and_use", + "configuration": "builtin", + "name": { + "en": "Feedback moderation", + "de": "Feedback-Moderation" + }, + "configured": { + "en": "Built-in capability is available; this is not evidence of use.", + "de": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "guest_management": { + "category": "gallery", + "since": "usage.v2", + "measurement": "configuration_and_use", + "configuration": "builtin", + "name": { + "en": "Guest administration tools", + "de": "Gastverwaltungswerkzeuge" + }, + "configured": { + "en": "Built-in capability is available; this is not evidence of use.", + "de": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis." + }, + "used": { + "en": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts.", + "de": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen." + } + }, + "gallery_feedback_likes": { + "category": "gallery_configuration", + "since": "usage.v2", + "measurement": "configuration", + "configuration": "configuration", + "name": { + "en": "Gallery likes enabled", + "de": "Galerie-Likes aktiviert" + }, + "configured": { + "en": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts.", + "de": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen." + }, + "used": null + }, + "gallery_feedback_ratings": { + "category": "gallery_configuration", + "since": "usage.v2", + "measurement": "configuration", + "configuration": "configuration", + "name": { + "en": "Gallery star ratings enabled", + "de": "Galerie-Sternebewertungen aktiviert" + }, + "configured": { + "en": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts.", + "de": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen." + }, + "used": null + }, + "gallery_feedback_comments": { + "category": "gallery_configuration", + "since": "usage.v2", + "measurement": "configuration", + "configuration": "configuration", + "name": { + "en": "Gallery comments enabled", + "de": "Galerie-Kommentare aktiviert" + }, + "configured": { + "en": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts.", + "de": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen." + }, + "used": null + }, + "gallery_feedback_favorites": { + "category": "gallery_configuration", + "since": "usage.v2", + "measurement": "configuration", + "configuration": "configuration", + "name": { + "en": "Gallery favorites enabled", + "de": "Galerie-Favoriten aktiviert" + }, + "configured": { + "en": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts.", + "de": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen." + }, + "used": null + }, + "gallery_feedback_reactions": { + "category": "gallery_configuration", + "since": "usage.v2", + "measurement": "configuration", + "configuration": "configuration", + "name": { + "en": "Gallery reactions enabled", + "de": "Galerie-Reaktionen aktiviert" + }, + "configured": { + "en": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts.", + "de": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen." + }, + "used": null + }, + "gallery_feedback_color_labels": { + "category": "gallery_configuration", + "since": "usage.v2", + "measurement": "configuration", + "configuration": "configuration", + "name": { + "en": "Gallery color labels enabled", + "de": "Galerie-Farblabels aktiviert" + }, + "configured": { + "en": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts.", + "de": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen." + }, + "used": null + }, + "gallery_guest_accounts": { + "category": "gallery_configuration", + "since": "usage.v2", + "measurement": "configuration", + "configuration": "configuration", + "name": { + "en": "Guest identities enabled", + "de": "Gastidentitäten aktiviert" + }, + "configured": { + "en": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts.", + "de": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen." + }, + "used": null + }, + "gallery_guest_uploads": { + "category": "gallery_configuration", + "since": "usage.v2", + "measurement": "configuration", + "configuration": "configuration", + "name": { + "en": "Guest uploads enabled", + "de": "Gast-Uploads aktiviert" + }, + "configured": { + "en": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts.", + "de": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen." + }, + "used": null + }, + "gallery_downloads": { + "category": "gallery_configuration", + "since": "usage.v2", + "measurement": "configuration", + "configuration": "configuration", + "name": { + "en": "Gallery downloads allowed", + "de": "Galerie-Downloads erlaubt" + }, + "configured": { + "en": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts.", + "de": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen." + }, + "used": null + }, + "download_resolution_picker": { + "category": "gallery_configuration", + "since": "usage.v2", + "measurement": "configuration", + "configuration": "configuration", + "name": { + "en": "Download resolution picker enabled", + "de": "Download-Auflösungswahl aktiviert" + }, + "configured": { + "en": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts.", + "de": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen." + }, + "used": null + }, + "gallery_client_access": { + "category": "gallery_configuration", + "since": "usage.v2", + "measurement": "configuration", + "configuration": "configuration", + "name": { + "en": "Client access enabled", + "de": "Client-Zugang aktiviert" + }, + "configured": { + "en": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts.", + "de": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen." + }, + "used": null + }, + "gallery_watermarks": { + "category": "gallery_configuration", + "since": "usage.v2", + "measurement": "configuration", + "configuration": "configuration", + "name": { + "en": "Watermarks enabled", + "de": "Wasserzeichen aktiviert" + }, + "configured": { + "en": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts.", + "de": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen." + }, + "used": null + }, + "gallery_image_protection": { + "category": "gallery_configuration", + "since": "usage.v2", + "measurement": "configuration", + "configuration": "configuration", + "name": { + "en": "Image protection enabled", + "de": "Bildschutz aktiviert" + }, + "configured": { + "en": "Enabled beyond the shipped defaults — a stronger protection level, canvas rendering, or right-click disabled — globally or on at least one gallery; only existence across the installation, never gallery IDs or counts.", + "de": "Über die Auslieferungsvorgaben hinaus aktiviert — höhere Schutzstufe, Canvas-Rendering oder deaktivierter Rechtsklick — global oder in mindestens einer Galerie; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen." + }, + "used": null + }, + "gallery_reveal": { + "category": "gallery_configuration", + "since": "usage.v2", + "measurement": "configuration", + "configuration": "configuration", + "name": { + "en": "Gallery reveal enabled", + "de": "Galerie-Enthüllung aktiviert" + }, + "configured": { + "en": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts.", + "de": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen." + }, + "used": null + }, + "gallery_expiration": { + "category": "gallery_configuration", + "since": "usage.v2", + "measurement": "configuration", + "configuration": "configuration", + "name": { + "en": "Gallery expiration configured", + "de": "Galerieablauf konfiguriert" + }, + "configured": { + "en": "At least one gallery has an expiry configured; no dates, gallery IDs or counts.", + "de": "Mindestens eine Galerie hat einen Ablauf konfiguriert; keine Daten, Galeriekennungen oder Anzahlen." + }, + "used": null + } + } +} diff --git a/backend/src/usage/protocol.cjs b/backend/src/usage/protocol.cjs new file mode 100644 index 00000000..8ea362a6 --- /dev/null +++ b/backend/src/usage/protocol.cjs @@ -0,0 +1,161 @@ +"use strict"; +const crypto = require("node:crypto"); +const Ajv = require("ajv"); +const { + envelopeSchema, + envelopeSchemas, + CURRENT_SCHEMA_VERSION, + FEATURE_KEYS, + LAYOUTS, + payloads, +} = require("./schema.cjs"); +const ajv = new Ajv({ allErrors: false, strict: true }); +const validators = new Map(Object.entries(envelopeSchemas).map( + ([version, schema]) => [version, ajv.compile(schema)], +)); +const validate = (envelope) => + Boolean(validators.get(envelope?.packet?.schema_version)?.(envelope)); +const MAX_BYTES = 16384; +const MAX_AGE_MS = 5 * 60 * 1000; + +class ProtocolError extends Error { + constructor(code, status = 400) { + super(code); + this.code = code; + this.status = status; + } +} +function canonical(value) { + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`) + .join(",")}}`; + } + return JSON.stringify(value); +} +const digest = (value) => + crypto.createHash("sha256").update(value).digest("hex"); +function publicKeyIdentity(publicKey) { + const bytes = Buffer.from(publicKey, "base64url"); + if (bytes.toString("base64url") !== publicKey || bytes.length !== 44) + throw new ProtocolError("INVALID_KEY"); + const key = crypto.createPublicKey({ + key: bytes, + format: "der", + type: "spki", + }); + if (key.asymmetricKeyType !== "ed25519") + throw new ProtocolError("INVALID_KEY"); + return { key, id: digest(bytes) }; +} +function generateIdentity() { + const keys = crypto.generateKeyPairSync("ed25519"); + const public_key = keys.publicKey + .export({ format: "der", type: "spki" }) + .toString("base64url"); + return { + installation_id: publicKeyIdentity(public_key).id, + public_key, + private_key: keys.privateKey.export({ format: "pem", type: "pkcs8" }), + }; +} +function makePacket(identity, action, sequence, payload, schemaVersion = CURRENT_SCHEMA_VERSION) { + return { + schema_version: schemaVersion, + installation_id: identity.installation_id, + packet_id: crypto.randomUUID(), + action, + sequence, + payload, + }; +} +function signPacket(packet, identity, now = new Date()) { + const signed = { + packet, + public_key: identity.public_key, + issued_at: now.toISOString(), + nonce: crypto.randomUUID(), + }; + const signature = crypto + .sign(null, Buffer.from(canonical(signed)), identity.private_key) + .toString("base64url"); + const envelope = { ...signed, signature }; + if ( + !validate(envelope) || + Buffer.byteLength(JSON.stringify(envelope)) > MAX_BYTES + ) + throw new ProtocolError("INVALID_PACKET"); + return envelope; +} +function verifyEnvelope(envelope, now = Date.now()) { + if ( + Buffer.byteLength(JSON.stringify(envelope) || "") > MAX_BYTES || + !validate(envelope) + ) + throw new ProtocolError("INVALID_PACKET"); + const issued = Date.parse(envelope.issued_at); + if ( + !Number.isFinite(issued) || + new Date(issued).toISOString() !== envelope.issued_at || + Math.abs(now - issued) > MAX_AGE_MS + ) { + throw new ProtocolError("EXPIRED_SIGNATURE", 401); + } + let identity; + try { + identity = publicKeyIdentity(envelope.public_key); + } catch (_) { + throw new ProtocolError("INVALID_KEY", 401); + } + if (identity.id !== envelope.packet.installation_id) + throw new ProtocolError("IDENTITY_MISMATCH", 401); + const { signature, ...signed } = envelope; + const signatureBytes = Buffer.from(signature, "base64url"); + if ( + signatureBytes.toString("base64url") !== signature || + !crypto.verify( + null, + Buffer.from(canonical(signed)), + identity.key, + signatureBytes, + ) + ) + throw new ProtocolError("INVALID_SIGNATURE", 401); + const { action, payload } = envelope.packet; + if (action === "report") { + const generated = Date.parse(payload.generated_at); + if ( + !Number.isFinite(generated) || + new Date(generated).toISOString() !== payload.generated_at || + payload.report_date !== payload.generated_at.slice(0, 10) || + generated > now + MAX_AGE_MS + ) + throw new ProtocolError("INVALID_REPORT_DATE"); + } + if ( + action === "feedback" && + payload.allow_marketing && + (payload.kind !== "testimonial" || !payload.allow_public) + ) { + throw new ProtocolError("INVALID_PUBLICATION_CONSENT"); + } + return envelope.packet; +} +module.exports = { + ...require("./schema.cjs"), + canonical, + digest, + generateIdentity, + makePacket, + signPacket, + verifyEnvelope, + ProtocolError, + MAX_BYTES, + MAX_AGE_MS, + FEATURE_KEYS, + LAYOUTS, + envelopeSchema, + payloads, +}; diff --git a/backend/src/usage/schema.cjs b/backend/src/usage/schema.cjs new file mode 100644 index 00000000..cd57ba70 --- /dev/null +++ b/backend/src/usage/schema.cjs @@ -0,0 +1,79 @@ +"use strict"; + +// Vendored byte-identical in PicPeak. v1 stays immutable; a larger allowlist +// has a new wire version and requires explicit, signed v2 consent. +const CATALOG = require("./features.v2.json"); +const CURRENT_SCHEMA_VERSION = "usage.v2"; +const CURRENT_CONSENT_VERSION = "usage-consent.v2"; +const LEGACY_FEATURE_KEYS = [ + "crm", "crm_quotes", "crm_invoices", "crm_contracts", "crm_projects", + "crm_calendar", "crm_hours", "customer_portal", "accounting", "workflows", + "newsletters", "face_recognition", "custom_css", "oauth", "smtp", + "whatsapp", "backup", "s3_storage", "share_mounts", +]; +const FEATURE_KEYS = Object.keys(CATALOG.features); +const LAYOUTS = ["grid", "masonry", "carousel", "timeline", "mosaic", "gallery-premium", "gallery-story", "other"]; +const object = (properties, required = Object.keys(properties)) => ({ + type: "object", additionalProperties: false, properties, required, +}); +const uuid = { type: "string", pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" }; +const hash = { type: "string", pattern: "^[0-9a-f]{64}$" }; +const timestamp = { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$" }; +const text = (maxLength, minLength = 1) => ({ type: "string", minLength, maxLength }); +const boolean = { type: "boolean" }; +const featureKeysFor = (version = CURRENT_SCHEMA_VERSION) => + version === "usage.v1" ? LEGACY_FEATURE_KEYS : version === "usage.v2" ? FEATURE_KEYS : []; +const observesUse = (key, version = CURRENT_SCHEMA_VERSION) => + version === "usage.v1" || CATALOG.features[key]?.measurement === "configuration_and_use"; +const emptyFeatures = (version = CURRENT_SCHEMA_VERSION) => Object.fromEntries( + featureKeysFor(version).map(key => [key, { + configured: false, ...(observesUse(key, version) ? { used: false } : {}) + }]) +); +const report = (version) => object({ + picpeak_version: { type: "string", maxLength: 48, pattern: "^\\d+\\.\\d+\\.\\d+(?:-(?:alpha|beta|rc)\\.\\d+)?$" }, + report_date: { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$" }, + generated_at: timestamp, + features: object(Object.fromEntries(featureKeysFor(version).map(key => [ + key, object({ configured: boolean, ...(observesUse(key, version) ? { used: boolean } : {}) }) + ]))), + gallery_layouts: { type: "array", uniqueItems: true, maxItems: LAYOUTS.length, items: { enum: LAYOUTS } }, +}); +const feedback = object({ + feedback_id: uuid, kind: { enum: ["feedback", "feature_request", "testimonial"] }, + title: text(120), body: text(4000), name: text(80, 0), + allow_public: boolean, allow_marketing: boolean, +}); +const makePayloads = (version) => ({ + register: object({ consent_version: { const: version === "usage.v1" ? "usage-consent.v1" : CURRENT_CONSENT_VERSION } }), + report: report(version), + delete: object({}), feedback, + vote: object({ feedback_id: uuid, voted: boolean }), + session: object({}), + ...(version === "usage.v2" ? { consent: object({ consent_version: { const: CURRENT_CONSENT_VERSION } }) } : {}), +}); +const payloadsByVersion = Object.fromEntries(["usage.v1", "usage.v2"].map(version => [version, makePayloads(version)])); +const envelopeSchemas = Object.fromEntries(Object.entries(payloadsByVersion).map(([version, actions]) => [version, { + $schema: "http://json-schema.org/draft-07/schema#", + $id: `https://usage.picpeak.app/schema/${version}.json`, + title: `PicPeak ${version} signed envelope`, + description: "Only report.payload is automatic feature telemetry. Other actions are explicit participant operations. See /transparency for field semantics and retention.", + ...object({ + packet: { oneOf: Object.entries(actions).map(([action, payload]) => object({ + schema_version: { const: version }, + installation_id: hash, packet_id: uuid, + sequence: { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, + action: { const: action }, payload, + })) }, + public_key: { type: "string", minLength: 59, maxLength: 59, pattern: "^[A-Za-z0-9_-]+$" }, + issued_at: timestamp, nonce: uuid, + signature: { type: "string", minLength: 86, maxLength: 86, pattern: "^[A-Za-z0-9_-]+$" }, + }), +}])); +const envelopeSchema = envelopeSchemas[CURRENT_SCHEMA_VERSION]; +const payloads = payloadsByVersion[CURRENT_SCHEMA_VERSION]; +module.exports = { + FEATURE_KEYS, LEGACY_FEATURE_KEYS, LAYOUTS, CATALOG, CURRENT_SCHEMA_VERSION, + CURRENT_CONSENT_VERSION, featureKeysFor, observesUse, emptyFeatures, + envelopeSchema, envelopeSchemas, payloads, payloadsByVersion, +}; diff --git a/docker-compose.yml b/docker-compose.yml index 4ebb703a..2ff5b814 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -81,6 +81,9 @@ services: # JWT_SECRET) and the break-glass override that re-enables local # password login when the IdP is down while SSO-only mode is active. - OIDC_ENCRYPTION_KEY=${OIDC_ENCRYPTION_KEY:-} + # Optional product usage (#1110); remains off until explicit in-app consent. + - USAGE_COLLECTOR_URL=${USAGE_COLLECTOR_URL:-https://usage.picpeak.app} + - USAGE_ENCRYPTION_KEY=${USAGE_ENCRYPTION_KEY:-} - OIDC_BREAK_GLASS=${OIDC_BREAK_GLASS:-} - ADMIN_URL=${ADMIN_URL:-} - TZ=${TZ:-UTC} diff --git a/docs/FEATURE_COVERAGE.md b/docs/FEATURE_COVERAGE.md new file mode 100644 index 00000000..12a6fc27 --- /dev/null +++ b/docs/FEATURE_COVERAGE.md @@ -0,0 +1,361 @@ +# Product-usage coverage: usage.v2 + +Reviewed PicPeak baseline: a5ff9264 (3.124.1-beta.0). Review scope: +all 81 current backend route families, +all 26 feature flags, admin routes/settings +and runtime/public boundaries. This is capability coverage, not instrumentation +of every UI field. Source of truth: `usage-coverage.v2.json`; the PicPeak inventory +test fails on an added/removed route family, literal route declaration or feature flag. + +## Privacy decision + +The purpose remains feature prioritization, fixes and maintenance from #1110. +Only **installation-wide booleans** and the existing fixed gallery-layout enums. +No user/customer/guest identity, business values, documents, photos, messages, +IP/domain/URL, per-action time, event IDs, frequencies or user-level history. +A stable installation fingerprint remains pseudonymous (not anonymous); rare +combinations can be distinctive. Participant-only dataset access and opt-out +deletion therefore remain mandatory. + +Of 73 capabilities, 19 were already present in v1 and 54 are new in v2: +56 configured/used pairs and 17 **configuration-only** signals. Configuration-only +signals omit `used` entirely; this is deliberately not a false “unused” value. +Guest-facing capabilities are measured from technical configuration only, never +from actual likes, comments, uploads, downloads, newsletter interactions or views. + +`configured` = current technical availability/configuration. Built-in means +available, not evidence of use. `used` = one monotonic yes/no bit since consent +to the current schema (v1: since joining; v2: since joining or explicit upgrade). +It means successful allowlisted **admin capability operation**, not necessarily +completion of a queued job. It is not an event log. Repeated operations do not +store anything more. The marker table contains only constant capability keys. + +## Consent and version transition + +- Existing participation and migration default to `usage-consent.v1`. A client + update alone does not collect any of the 54 new local markers or report fields. +- The settings page presents the full local EN/DE catalog before v2 opt-in or + upgrade; an unchecked checkbox requires an explicit decision. +- A signed `usage.v2 / consent` command updates the same installation, after all + prior queued operations have finished. It preserves its raw history and lookup + identity. No downgrade or automatic expansion occurs. +- Only a matching collector receipt upgrades local consent and atomically resets + local usage markers. Until confirmation, collection remains v1, even if a + receipt is lost. A pending consent is durable/retryable; opt-out always wins. +- No second report on the same UTC day. The first expanded report may be on the + next day of admin activity. API integration use alone does not trigger a report. +- Collector must be deployed first. Old collectors reject the new schema; + the client shows delivery pending instead of assuming consent or sending v2. +- v1 validation remains unchanged and old envelopes remain exportable exactly as + first received. Raw history contains the original schema version on each packet. +- Aggregate projections include their schema version. Absent v2 fields in v1 + projections are **unknown**, never false. `reported` and `used_reported` + supply each metric's real denominator. Configuration-only use has denominator + zero and is displayed as “Not collected”, not 0% adoption. + +## Every reported capability + +The static bilingual definitions below are also shipped as +`features.v2.json` in both applications, exposed at +`/schema/features.v2.json`, and displayed in both usage interfaces. +“Since” is the schema in which a key was introduced; definitions here describe v2. +Legacy v1 semantics remain documented separately in the protocol reference. + +| Key (EN / DE) | Since | Configured | Used | +| --- | --- | --- | --- | +| `crm` — Client management / Kundenverwaltung | usage.v1 | The clients capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `crm_quotes` — Quotes / Angebote | usage.v1 | The quotes capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `crm_invoices` — Invoices / Rechnungen | usage.v1 | The bills capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `crm_contracts` — Contracts / Verträge | usage.v1 | The contracts capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `crm_projects` — Projects / Projekte | usage.v1 | The projects capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `crm_calendar` — Admin calendar / Admin-Kalender | usage.v1 | The calendar capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `crm_hours` — Hours logging / Zeiterfassung | usage.v1 | The hoursLogging capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `customer_portal` — Customer portal / Kundenportal | usage.v1 | The customerPortal capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `accounting` — Accounting / Buchhaltung | usage.v1 | The accounting capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `workflows` — Workflows / Workflows | usage.v1 | The workflows capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `newsletters` — Newsletters / Newsletter | usage.v1 | The newsletters capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `face_recognition` — Face recognition / Gesichtserkennung | usage.v1 | The faces capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `custom_css` — Custom CSS / Eigenes CSS | usage.v1 | Custom CSS is configured globally or applied through a gallery/theme/template; CSS text is not sent. | Applied CSS observed after consent, without observing visitors. | +| `oauth` — Admin SSO / Admin-SSO | usage.v1 | Admin OIDC is enabled and issuer/client configuration is present; no provider or credential values. | Successful admin SSO login; no account, identity-provider or session details. | +| `smtp` — SMTP delivery / SMTP-Versand | usage.v1 | An outgoing SMTP host is configured; no host, account, address or credentials. | A successful explicitly initiated admin SMTP test/send; no recipients or messages. | +| `whatsapp` — WhatsApp integration / WhatsApp-Integration | usage.v1 | The WhatsApp capability is enabled and a usable configuration is present; no phone number, token or template. | Successful admin integration test; no recipient, message or delivery history. | +| `backup` — Backups / Sicherungen | usage.v1 | A full or database backup schedule is enabled; no schedule, path, storage sizes or backup names. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `s3_storage` — S3 storage / S3-Speicher | usage.v1 | S3 is configured for media or backups; no bucket, endpoint, credentials or object keys. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `share_mounts` — External folders / Externe Ordner | usage.v1 | At least one gallery uses an external folder; only existence, no folder paths or gallery identifiers. | An admin initiated an accepted external-folder import; no scanned paths, files or counts. | +| `galleries` — Gallery management / Galerieverwaltung | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `photo_management` — Media management / Medienverwaltung | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `photo_exports` — Admin media export / Admin-Medienexport | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `photo_processing` — Media maintenance tools / Medien-Wartungswerkzeuge | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `archive_management` — Gallery archives / Galeriearchive | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `gallery_sharing` — Gallery sharing and QR / Galeriefreigabe und QR | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `short_links` — Short links / Kurzlinks | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `gallery_categories` — Photo categories / Fotokategorien | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `event_types` — Event types and presets / Ereignistypen und Vorlagen | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `slideshow` — Live slideshow / Live-Diashow | usage.v2 | The slideshow capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `transfers` — PicTransfer / PicTransfer | usage.v2 | The transfers capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `video_uploads` — Admin video uploads / Admin-Video-Uploads | usage.v2 | Video extensions are allowed in global upload settings; no uploaded-file metadata. | At least one admin video file was successfully stored/accepted; no names, formats, lengths, sizes or processing/visitor history. | +| `camera_raw_uploads` — Admin camera RAW uploads / Admin-Kamera-RAW-Uploads | usage.v2 | Camera RAW (DNG) is allowed in global upload settings; no camera models or EXIF. | At least one admin camera RAW upload was stored/accepted; only the capability bit, no filename or metadata. | +| `messaging` — Messaging tools / Nachrichtenwerkzeuge | usage.v2 | The messaging capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `incoming_mail` — IMAP intake / IMAP-Empfang | usage.v2 | Incoming mail is enabled and an IMAP configuration is present; no mailbox, server, folders or credentials. | A successful explicit admin connection test or non-skipped manual poll; no background intake, messages, attachments or counts. | +| `reminder_emails` — Automatic event reminders / Automatische Ereigniserinnerungen | usage.v2 | The reminderEmails capability switch is effectively enabled; only a boolean. | **Not collected. Configuration only.** | +| `email_templates` — Email templates / E-Mail-Vorlagen | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `email_webhook` — Email webhook transport / E-Mail-Webhook-Transport | usage.v2 | Both email webhook settings are present; no URL or secret. | Successful explicitly initiated admin send/test through the webhook transport; no recipients, messages or automatic deliveries. | +| `accounting_incoming_invoices` — Incoming invoices / Eingangsrechnungen | usage.v2 | The incomingInvoices capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `accounting_expenses` — Expenses / Ausgaben | usage.v2 | The expenses capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `accounting_tax_report` — Tax reports / Steuerberichte | usage.v2 | The taxReport capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `accounting_ledger` — Ledger and accounting export / Kontenplan und Buchhaltungsexport | usage.v2 | The accounting capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `crm_installments` — Installment-plan tools / Ratenplan-Werkzeuge | usage.v2 | Quotes or invoices are enabled; no actual payment plans, amounts or statuses are inspected. | An admin saved an installment plan; no dates, amounts, currencies, payment status or document IDs. | +| `document_templates` — Document presets and blocks / Dokumentvorlagen und Bausteine | usage.v2 | Quotes or contracts are enabled, making document presets/blocks available; no template content. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `cms` — CMS pages / CMS-Seiten | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `public_site` — Public landing page / Öffentliche Startseite | usage.v2 | The public landing-page setting is enabled; no page HTML, texts, domains or visitors. | **Not collected. Configuration only.** | +| `branding` — Branding settings / Branding-Einstellungen | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `seo_customization` — SEO settings / SEO-Einstellungen | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `admin_management` — Admin and role management / Admin- und Rollenverwaltung | usage.v2 | The userManagement capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `api_integration` — HTTP API integration / HTTP-API-Integration | usage.v2 | An unrevoked, unexpired API credential exists; no tokens, names, scopes or owner data. | Successful authenticated HTTP API capability call; only this bit, never URLs, request values, token/owner IDs or call counts. Does not trigger a report. | +| `webhooks` — Outbound webhooks / Ausgehende Webhooks | usage.v2 | At least one active webhook is configured; no destinations, subscriptions, secrets or delivery logs. | Successful explicit admin webhook test/replay; no automatic or visitor-triggered deliveries. | +| `restore` — Restore / Wiederherstellung | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `portable_backup` — Portable PicPeak export/import / Portabler PicPeak-Export/Import | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `database_backup` — Database backups / Datenbanksicherungen | usage.v2 | Scheduled database backups are enabled; no schedules, file names or database contents. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `s3_photo_storage` — S3 media storage / S3-Medienspeicher | usage.v2 | S3 is the configured media backend and required credentials are present; no values are sent. | Successful admin media storage/accepted upload to S3; no buckets, objects or sizes. | +| `s3_backups` — S3 backup destination / S3-Sicherungsziel | usage.v2 | The configured backup destination is S3 with a bucket present; no bucket or credentials. | An admin started a backup to the configured S3 destination or a successful S3 test upload; local exports never imply S3 use. | +| `analytics_dashboard` — Existing analytics module / Bestehendes Analytics-Modul | usage.v2 | The analytics capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `feedback_moderation` — Feedback moderation / Feedback-Moderation | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `guest_management` — Guest administration tools / Gastverwaltungswerkzeuge | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. | +| `gallery_feedback_likes` — Gallery likes enabled / Galerie-Likes aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** | +| `gallery_feedback_ratings` — Gallery star ratings enabled / Galerie-Sternebewertungen aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** | +| `gallery_feedback_comments` — Gallery comments enabled / Galerie-Kommentare aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** | +| `gallery_feedback_favorites` — Gallery favorites enabled / Galerie-Favoriten aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** | +| `gallery_feedback_reactions` — Gallery reactions enabled / Galerie-Reaktionen aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** | +| `gallery_feedback_color_labels` — Gallery color labels enabled / Galerie-Farblabels aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** | +| `gallery_guest_accounts` — Guest identities enabled / Gastidentitäten aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** | +| `gallery_guest_uploads` — Guest uploads enabled / Gast-Uploads aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** | +| `gallery_downloads` — Gallery downloads allowed / Galerie-Downloads erlaubt | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** | +| `download_resolution_picker` — Download resolution picker enabled / Download-Auflösungswahl aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** | +| `gallery_client_access` — Client access enabled / Client-Zugang aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** | +| `gallery_watermarks` — Watermarks enabled / Wasserzeichen aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** | +| `gallery_image_protection` — Image protection enabled / Bildschutz aktiviert | usage.v2 | Enabled beyond the shipped defaults — a stronger protection level, canvas rendering, or right-click disabled — globally or on at least one gallery; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** | +| `gallery_reveal` — Gallery reveal enabled / Galerie-Enthüllung aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** | +| `gallery_expiration` — Gallery expiration configured / Galerieablauf konfiguriert | usage.v2 | At least one gallery has an expiry configured; no dates, gallery IDs or counts. | **Not collected. Configuration only.** | + +Gallery layouts (unchanged): `grid`, `masonry`, `carousel`, `timeline`, +`mosaic`, `gallery-premium`, `gallery-story`, `other`. Only set membership, +not how many galleries use a layout. Unknown names are normalized to other. + +## Exact observation sources + +PicPeak `backend/src/usage/capabilityRules.js` is the fixed method/path +allowlist; request paths, query/body/response values never leave the middleware. +Only the resulting constant keys reach `markUsed`, with active schema consent, +authenticated admin and 2xx response checks. Status/health polls are excluded. + +Additional trusted success evidence in `capabilityEvidence.js`: +accepted admin file storage (video / DNG / S3 booleans only, not chunk +initialization), successful manual SMTP or email-webhook send/test, non-skipped +manual IMAP poll/connection test, successful manual WhatsApp test, and successful +S3 backup roundtrip test. SMTP vs webhook uses the actual selected transport +(including per-account SMTP overrides), not just environment presence. +Webhook test/replay means **accepted enqueue**, never remote delivery tracking. + +`UsageService.snapshot` and `expandedSnapshot.js` inspect allowlisted settings, +effective flags and technical configuration existence. They do not query +customer/guest profiles, financial records, photos/EXIF, message/feedback bodies, +audit/security logs or delivery histories. Inherited technical defaults count as +configuration; disabled feature dependencies cannot be inferred as active. +Optional-module tables/columns are guarded. CSS/layout inspection maps locally +to presence/enums; no free-form CSS/theme content is sent. + +OAuth is marked only by the successful **admin** OIDC callback, without claims +or provider metadata. S3 backup use is inferred only for backup operations +writing to the configured destination; a local DB/portable export is not S3 use. +Background jobs and public/customer/visitor handlers never record product use. + +## Complete route-family decision matrix + +Paths below are relative to PicPeak `backend/src/routes/`. “Partial” means only +the disclosed allowlist/evidence, not every endpoint in that file. All literal +route declarations are captured in the companion inventory, with excluded +methods remaining unobserved. + +| Source | Decision / signals | Reason / limits | +| --- | --- | --- | +| `acceptInvite.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. | +| `admin.js` | composition | Router composition / helpers; decisions are recorded for each mounted family. | +| `adminApiTokens.js` | configuration: `api_integration` | Only existence of a valid credential; no marker from token listing/creation, no scope, owner, token, expiry date or last-used time. | +| `adminArchives.js` | partial: `galleries`, `archive_management`, `photo_exports` | Admin archive/delete/restore/download initiation only; filenames, histories, storage sizes and polling excluded. | +| `adminAuth.js` | excluded | Bootstrap, passwords/MFA/session/profile, per-person notifications, developer helpers and operational health/update/log polling are outside the prioritization purpose. | +| `adminBackup.js` | partial: `backup`, `portable_backup`, `restore`, `s3_storage`, `s3_backups` | Admin backup initiation, portable export/import and successful S3 roundtrip test. Local export never implies S3; names, schedules, sizes, contents and history excluded. | +| `adminBusinessProfile.js` | excluded | Business identity/bank/tax-address configuration and VAT-code helper surface are not separate usage signals. Billing/accounting capabilities are covered without profiling the business. | +| `adminCalendar.js` | partial: `crm`, `crm_calendar` | Authenticated admin calendar retrieval is capability use; no calendar entries, dates, recurrence, availability or bookings. | +| `adminCategories.js` | partial: `gallery_categories` | Admin category CRUD; no names, descriptions, colors or ordering values. | +| `adminCMS.js` | partial: `cms` | Admin CMS page CRUD only. Public page traffic, slug, HTML, text, links and media excluded. | +| `adminContracts.js` | partial: `crm`, `crm_contracts`, `document_templates` | Admin contract/block operations only; no legal text, signatures, signing parties or customer signing events. | +| `adminCssTemplates.js` | configuration: `custom_css` | Only existence of enabled applied CSS and locally observed application, not editing/viewing templates or any CSS text. | +| `adminCustomers.js` | partial: `crm`, `crm_hours`, `customer_portal` | Successful admin CRM/hour-entry/invitation operations only. No customer/account names, IDs, rates, billed hours, payment state or portal behavior. | +| `adminDashboard.js` | partial: `analytics_dashboard` | Admin analytics capability endpoint only; no stats, activities, health/CRM polls, underlying visitor data or dashboard values. | +| `adminDatabaseBackup.js` | partial: `backup`, `database_backup` | Admin database-backup initiation plus schedule-enabled boolean, no file data/history. | +| `adminDeals.js` | partial: `crm`, `crm_installments` | Admin installment-plan changes only. No actual plans, invoice links, amounts, paid states or deal reporting. | +| `adminDev.js` | excluded | Bootstrap, passwords/MFA/session/profile, per-person notifications, developer helpers and operational health/update/log polling are outside the prioritization purpose. | +| `adminEmail.js` | partial: `messaging`, `incoming_mail`, `smtp`, `email_templates`, `email_webhook`, `reminder_emails` | Admin message operation/template edit, actual successful manual send/test transport and non-skipped manual IMAP poll/test. Reminder flag configuration only. No automated sends/polls, received-message or recipient data, queue/log reads, mailbox addresses or templates. | +| `adminEventRename.js` | partial: `galleries` | Successful rename only, not validate-rename. No former/new names or identifiers. | +| `adminEvents/archiveBulk.js` | partial: `galleries`, `archive_management`, `photo_exports` | Admin archive/delete/restore/download initiation only; filenames, histories, storage sizes and polling excluded. | +| `adminEvents/crud.js` | partial: `galleries`, `gallery_guest_uploads`, `gallery_downloads`, `gallery_client_access`, `gallery_watermarks`, `gallery_reveal`, `gallery_expiration`, `gallery_sharing`, `custom_css` | Admin creation/edit/publish etc. set galleries; sharing has its own fixed key. Guest/download/protection/reveal/expiry are configuration only; themes contribute controlled layouts and CSS presence. No gallery metadata or guest action history. | +| `adminEvents/downloadResolutions.js` | configuration: `download_resolution_picker` | Only whether a picker is configured globally or in a gallery. No chosen resolution, download event or counts. | +| `adminEvents/faces.js` | partial: `face_recognition` | Effective flag plus successful admin faces/people operation. No health polling, embeddings, names, groups, detections or visitor searches. | +| `adminEvents/helpers.js` | composition | Router composition / helpers; decisions are recorded for each mounted family. | +| `adminEvents/index.js` | composition | Router composition / helpers; decisions are recorded for each mounted family. | +| `adminEvents/logo.js` | partial: `branding` | Successful admin logo operation only; image/filename/content excluded. | +| `adminEvents/qr.js` | partial: `gallery_sharing` | Admin QR generation only; no scans, tokens or URLs. | +| `adminEvents/resets.js` | partial: `galleries`, `gallery_sharing` | Admin gallery reset/sharing capability only; no password, recipient, token or reset statistics. | +| `adminEvents/slideshow.js` | partial: `slideshow` | Admin generate/disable/configure only, never kiosk viewers or slide advances. | +| `adminEventTypes.js` | partial: `event_types` | Admin event-type CRUD; preset contents/names excluded. | +| `adminExpenses.js` | partial: `accounting`, `accounting_expenses`, `accounting_incoming_invoices` | Admin expense/inbound-invoice operations; no financial values, suppliers, mileage/location, dates, receipt files or OCR text. | +| `adminExternalMedia.js` | partial: `share_mounts` | Only admin import operation; status/list/browse are not use. Snapshot checks external-path presence, never reports a path. | +| `adminFeatureFlags.js` | configuration: `crm`, `crm_quotes`, `crm_invoices`, `crm_contracts`, `crm_projects`, `crm_calendar`, `crm_hours`, `customer_portal`, `accounting`, `workflows`, `newsletters`, `face_recognition`, `slideshow`, `transfers`, `messaging`, `reminder_emails`, `accounting_incoming_invoices`, `accounting_expenses`, `accounting_tax_report`, `accounting_ledger`, `admin_management`, `analytics_dashboard` | Only allowlisted effective capability booleans. No marker from reading or saving feature flags. Disabled roadmap/developer flags excluded. | +| `adminFeedback.js` | partial: `feedback_moderation`, `gallery_feedback_likes`, `gallery_feedback_ratings`, `gallery_feedback_comments`, `gallery_feedback_favorites`, `gallery_feedback_reactions`, `gallery_feedback_color_labels`, `gallery_guest_accounts` | Admin moderation/word-filter operations only. Visitor feedback is not observed. Master-enabled per-gallery feedback-option booleans only; no contents, ratings, likes, colors, identities or word lists. | +| `adminGuests.js` | partial: `guest_management` | Admin guest management/export initiation only. No guest names, invitations, tokens, contact data, guest counts or visitor interactions. | +| `adminImageSecurity.js` | configuration: `gallery_image_protection` | Only gallery/global technical protection configuration existence. No security events, blocked IPs, request counts, threat scores or admin monitoring access. | +| `adminInvoices.js` | partial: `crm`, `crm_invoices` | Admin invoice operations only; no amounts, VAT/customer/payment values or payment-check responses. | +| `adminLedger.js` | partial: `accounting`, `accounting_ledger` | Admin ledger-account/VAT/mapping edits and ledger export initiation only; no account/currency/VAT identifiers or exported records. | +| `adminNewsletters.js` | partial: `newsletters` | Admin campaign changes/test/queue/cancel only. Recipient resolution, previews, subscriptions/unsubscribes, delivery/open/click data and automatic sending excluded. | +| `adminNotifications.js` | excluded | Bootstrap, passwords/MFA/session/profile, per-person notifications, developer helpers and operational health/update/log polling are outside the prioritization purpose. | +| `adminPhotoDimensions.js` | partial: `photo_processing` | Admin repair/regenerate/configuration initiation, never status polling or processing totals. | +| `adminPhotoExport.js` | partial: `photo_exports` | Admin export initiation only; export filters, selected files, sizes and contents excluded. | +| `adminPhotos.js` | partial: `photo_management`, `photo_exports`, `photo_processing`, `video_uploads`, `camera_raw_uploads`, `s3_storage`, `s3_photo_storage` | Successful admin edits/exports and accepted upload evidence only. Chunk init/status, failed uploads and public downloads excluded. Only video/RAW/S3 booleans survive, never file metadata/EXIF/content. | +| `adminProjects.js` | partial: `crm`, `crm_projects` | Admin project operations only; project/person names, business performance, metadata and totals excluded. | +| `adminQuotes.js` | partial: `crm`, `crm_quotes`, `document_templates` | Admin quote/preset operations only; no quote content, prices, customer acceptance or signatures. | +| `adminRestore.js` | partial: `restore` | Admin restore initiation only, never file selection, content, progress, errors or timing. | +| `adminRoles.js` | partial: `admin_management` | Admin account/role management capability; no names, permissions, role labels, password reset operations or active-user counts. Auth/self-profile endpoints excluded. | +| `adminSettings.js` | partial: `custom_css`, `oauth`, `smtp`, `backup`, `s3_storage`, `video_uploads`, `camera_raw_uploads`, `public_site`, `branding`, `seo_customization`, `slideshow`, `download_resolution_picker`, `gallery_watermarks`, `database_backup` | Only specified configuration presence/booleans and explicit branding/SEO/slideshow operations. Generic settings reads, security policies, passwords, storage data, SMTP/OIDC credentials, custom HTML/CSS/SEO values excluded. | +| `adminShortUrls.js` | partial: `gallery_sharing`, `short_links` | Admin short-link creation/deletion only; link/token/click metadata excluded. | +| `adminSystem.js` | excluded | Bootstrap, passwords/MFA/session/profile, per-person notifications, developer helpers and operational health/update/log polling are outside the prioritization purpose. | +| `adminSystemHealth.js` | excluded | Bootstrap, passwords/MFA/session/profile, per-person notifications, developer helpers and operational health/update/log polling are outside the prioritization purpose. | +| `adminTaxReport.js` | partial: `accounting`, `accounting_tax_report` | Admin tax report generation/export only; no totals, dates, tax regimes, geography or currency. | +| `adminThumbnails.js` | partial: `photo_processing` | Admin repair/regenerate/configuration initiation, never status polling or processing totals. | +| `adminTransfers.js` | partial: `transfers` | Admin transfer CRUD/files/link management/download only. Public recipients, received-file data, upload and download statistics excluded. | +| `adminUsage.js` | excluded | Consent, inspection, export, feedback, voting and deletion are explicit protocol operations; not product-use signals. Activity only triggers a due fixed report. | +| `adminUsers.js` | partial: `admin_management` | Admin account/role management capability; no names, permissions, role labels, password reset operations or active-user counts. Auth/self-profile endpoints excluded. | +| `adminVatCodes.js` | excluded | Business identity/bank/tax-address configuration and VAT-code helper surface are not separate usage signals. Billing/accounting capabilities are covered without profiling the business. | +| `adminWebhooks.js` | partial: `webhooks` | Active configuration existence plus successful admin manual test/replay enqueue. Actual network delivery/results/subscriptions/destinations excluded. | +| `adminWhatsapp.js` | partial: `whatsapp` | Effective configured sender and successful manual test only. No automated deliveries, phone numbers, templates or delivery statuses. | +| `adminWorkflows.js` | partial: `workflows` | Admin workflow authoring/approval/test initiation only. Runtime triggers, payloads, execution frequency/results and public approvals excluded. | +| `analyticsTrackerProxy.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. | +| `auth.js` | partial: `oauth` | Only successful admin OIDC callback sets oauth. Password/gallery authentication, MFA, account claims and provider details excluded. | +| `customer.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. | +| `customerAuth.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. | +| `gallery.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. | +| `galleryFeedback.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. | +| `galleryGuests.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. | +| `protectedImages.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. | +| `publicCMS.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. | +| `publicContracts.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. | +| `publicFonts.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. | +| `publicNewsletter.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. | +| `publicPaymentCheck.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. | +| `publicQuotes.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. | +| `publicSettings.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. | +| `publicTransfer.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. | +| `publicTransferUpload.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. | +| `publicWorkflowApprovals.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. | +| `secureImages.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. | +| `setup.js` | excluded | Bootstrap, passwords/MFA/session/profile, per-person notifications, developer helpers and operational health/update/log polling are outside the prioritization purpose. | +| `v1/events.js` | partial: `api_integration` | Single bit after successful admin-owned scoped API authentication. No request/response values; API requests do not trigger reports. | + +## Every admin settings tab + +These 29 current SettingsPage tabs are also inventoried and tested against +the frontend TabType. Page navigation itself is not tracked. + +| Tab | Capability / exclusion | +| --- | --- | +| `usage` | Explicit consent/report inspection/feedback is not itself adoption telemetry. | +| `features` | Only the allowlisted effective feature booleans; no settings visit/save marker. | +| `general` | `video_uploads`, `camera_raw_uploads`, `public_site`, `custom_css`. General technical upload/public-site/CSS configuration only; no title, URLs, limits, times, HTML or identity. | +| `events` | `galleries`, `gallery_guest_uploads`, `gallery_downloads`, `gallery_client_access`, `gallery_watermarks`, `gallery_image_protection`, `gallery_reveal`, `gallery_expiration`. Gallery operations and disclosed configuration only; no event/customer values or visitor use. | +| `eventTypes` | `event_types`. General admin event-type capability; no names or preset contents. | +| `branding` | `branding`, `gallery_watermarks`. Branding operation and watermark configuration only; no branding text, logos or colors. | +| `categories` | `gallery_categories`. Category management capability only; no names/order/category membership. | +| `thumbnails` | `photo_processing`. Admin processing settings/regeneration initiation only; no image data or progress. | +| `downloads` | `download_resolution_picker`. Configuration boolean only; no actual download/selection behavior or resolution values. | +| `styling` | `custom_css`. Presence/application only plus controlled gallery-layout enums, never CSS/theme values. | +| `cms` | `cms`, `public_site`. Admin page editing capability/public-site enabled only; no HTML, slugs or traffic. | +| `email` | `smtp`, `incoming_mail`, `messaging`, `email_templates`, `email_webhook`. Configuration and documented manual admin capability operations only; messages, recipients, automatic activity and mailbox values excluded. | +| `moderation` | `feedback_moderation`. Admin moderation/word-filter capability, never feedback content or visitor behavior. | +| `security` | Excluded password/MFA/session/rate-limit/security profiles and operations. | +| `sso` | `oauth`. Enabled/config-present and successful admin callback only; no claims/provider details. | +| `imageSecurity` | `gallery_image_protection`. Configuration presence only; no blocked-IP/security analytics or monitoring history. | +| `seo` | `seo_customization`. Admin SEO configuration operation only; no meta tags, URLs, robots or verification tokens. | +| `apiTokens` | `api_integration`. Valid credential presence and one successful scoped API capability bit; no tokens/scopes/owner metadata. | +| `webhooks` | `webhooks`. Active configuration and manual test/replay enqueue only; no delivery data. | +| `status` | Excluded operational health, diagnostics, resource data, update and storage polling. | +| `analytics` | `analytics_dashboard`. Analytics capability and admin aggregate-view use only; no embedded analytics results/tracker IDs or visitors. | +| `backup` | `backup`, `database_backup`, `portable_backup`, `restore`, `s3_backups`. Schedule presence/manual capability initiation only, no histories, sizes, paths or files. | +| `businessProfile` | Excluded business identity, bank accounts and addresses. | +| `crm` | `crm`, `crm_quotes`, `crm_invoices`, `crm_projects`, `crm_hours`, `customer_portal`, `crm_installments`. Only coarse module capabilities; no policies/amounts/customer/payment values. | +| `contracts` | `crm_contracts`, `document_templates`. Admin contract/template capability only; no legal text or signatures. | +| `reminderTemplates` | `reminder_emails`, `email_templates`. Reminder flag configuration and admin template editing only; no automatic reminder sends/recipients/content. | +| `accounting` | `accounting`, `accounting_incoming_invoices`, `accounting_expenses`, `accounting_tax_report`, `accounting_ledger`. Only module capabilities, no tax codes, rates, balances or business identity. | +| `whatsapp` | `whatsapp`. Configured integration plus manual test only; no phone numbers, tokens or automatic delivery. | +| `slideshow` | `slideshow`. Admin setup capability only; no kiosk viewers, slide progress or photos. | + +## Every feature flag (configuration decisions) + +| Flag | Signal / exclusion | +| --- | --- | +| `accounting` | `accounting`, `accounting_ledger`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. | +| `analytics` | `analytics_dashboard`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. | +| `bills` | `crm_invoices`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. | +| `calendar` | `crm_calendar`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. | +| `calendarBooking` | Excluded: disabled roadmap placeholder, not an implemented booking capability. | +| `clients` | `crm`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. | +| `contracts` | `crm_contracts`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. | +| `crmDevelopment` | Excluded: internal development/test helpers, not product adoption. | +| `customerPortal` | `customer_portal`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. | +| `expenses` | `accounting_expenses`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. | +| `faces` | `face_recognition`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. | +| `galleries` | `galleries`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. | +| `hoursLogging` | `crm_hours`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. | +| `incomingInvoices` | `accounting_incoming_invoices`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. | +| `incomingMail` | `incoming_mail`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. | +| `messaging` | `messaging`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. | +| `newsletters` | `newsletters`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. | +| `projects` | `crm_projects`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. | +| `quotes` | `crm_quotes`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. | +| `reminderEmails` | `reminder_emails`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. | +| `slideshow` | `slideshow`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. | +| `taxReport` | `accounting_tax_report`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. | +| `transfers` | `transfers`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. | +| `userManagement` | `admin_management`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. | +| `whatsapp` | `whatsapp`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. | +| `workflows` | `workflows`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. | + +## Deliberately excluded runtime and future features + +- Gallery/customer/public events and optional website analytics +- Automated newsletter, reminder, WhatsApp, webhook and IMAP jobs +- Security/audit logs, biometric embeddings and recognition results +- Operational health, migration, update and polling metrics +- Business/customer/user identities, geography, amounts and document contents +- Disabled calendarBooking and internal crmDevelopment; hosted future product #1111 +- Image fragmentation: removed from current PicPeak, not a live capability + +The Messages and Reminder Emails implementations were reviewed as real features, +despite stale placeholder comments. Reminder Emails remains configuration-only. +Calendar booking is still a disabled placeholder and is not presented as a +working capability. This review does not approve any public visitor tracking, +even if another optional analytics integration is configured. + +All exclusion decisions still permit the existing product functions themselves. +They restrict this usage program; they do not disable galleries, email or jobs. +Adding capabilities requires a documented scope review, updated inventory, +closed schema, both UI disclosures/docs and tests; a wider collection scope +requires renewed explicit consent, not a silent catalog expansion. + +## Verification obligations + +Required checks include unchanged v1 validation, closed v2 fields, all 73 +configuration signals and privacy canaries, all route/flag decisions, no +configuration-only use, disabled/pending/upgrade/opt-out boundaries, mixed-version +denominators, byte-identical protocol/catalogs, EN/DE UI catalog consistency, +raw export and deletion, SQLite/PostgreSQL and paired local Docker/browser tests. +Test outcomes are recorded separately; this document is not a claim of legal +certification or proof that modified self-hosted clients report truthfully. diff --git a/docs/PRODUCT_USAGE.md b/docs/PRODUCT_USAGE.md new file mode 100644 index 00000000..df9bb4fc --- /dev/null +++ b/docs/PRODUCT_USAGE.md @@ -0,0 +1,209 @@ +# Optional product usage and feedback (#1110) + +Tracking is disabled by default. After updating, settings editors see a +dismissible invitation in the admin shell. Only explicit consent in Settings → +Product usage & feedback registers an installation. Public galleries never +load the usage UI chunk or trigger product reports. + +The backend builds and signs allowlisted feature packets, sending them at most +daily on authenticated admin use. It does not send gallery visitors, click +histories, photo/gallery counts, names, emails, domains, filenames, or secrets. +The settings page provides disclosure, raw preview/export, a private lookup +hash, delivery status, feedback, and a short-lived voting connection. + +## Deployment + +Run the normal core migrations; migration 201 creates dedicated state/marker +tables. No identity or key is created by the migration. No extra browser +tracker or CORS policy is required. The collector is the separate +`picpeak-usage` app from [#1110](https://github.com/PicPeak/picpeak/issues/1110). + +| Backend variable | Default | Meaning | +| --- | --- | --- | +| `USAGE_COLLECTOR_URL` | https://usage.picpeak.app | Fixed operator-configured collector origin, HTTPS in production | +| `USAGE_ENCRYPTION_KEY` | JWT_SECRET | 32+ characters, encrypts the local Ed25519 key with AES-256-GCM | + +Both database engines are supported. The state and marker tables are created +by migrations 201-205 on PostgreSQL and SQLite alike, and the engine-sensitive +paths are covered by `__tests__/integration/productUsagePg.test.js` against a +real PostgreSQL — bigint columns come back as strings there, booleans are real +booleans rather than 0/1, and the marker write takes `SELECT ... FOR UPDATE` +only on that engine. That suite is gated behind `PICPEAK_PG_TEST_URL` and runs +in CI, which provides one. + +If `USAGE_COLLECTOR_URL` is unset, empty or blank the built-in default +`https://usage.picpeak.app` is used. A value that is present but malformed is +reported as a configuration error rather than being replaced by the default: +silently retargeting a self-hosted collector at ours would send reports +somewhere the operator did not choose. + +Local development can use an HTTP loopback collector outside production. The +collector URL is never writable through generic settings or request payloads. + +### The connection only runs outwards + +PicPeak sends; it never pulls. There is exactly one place in the service that +reaches the network, it is a POST, and it makes requests to exactly two paths: +`/api/envelopes` and — only when an operator asks for their own data export — +`/api/participant/lookup`. There is no scheduled job that contacts the +collector (the daily rollup is driven solely by an authenticated admin hitting +`/activity`), no route the collector could call, and `redirect: 'error'` so the +collector cannot even redirect a request elsewhere. + +From a reply the service reads only the acknowledgement for the packet it just +sent, and compares `packet_id`, `installation_id`, `packet_digest`, `action`, +`sequence` and `status` against that packet before accepting it; a mismatch is +an error and nothing else in the response is looked at. The stored copy drops +the session token, and no read path hands it back to the UI. A requested data +export is streamed to the operator as a file attachment and is never +interpreted or executed. + +The consequence is the point, and it is stated in the consent dialog: this +channel cannot deliver code, configuration or content into an installation — +not even from a collector that has been taken over. It is a one-way path by +design, not by convention, and `__tests__/services/usageOutboundOnly.test.js` +fails if that ever stops being true. +Keep the encryption material stable and protected; losing it makes the old +identity unable to sign deletion requests. Note that `USAGE_ENCRYPTION_KEY` +defaults to `JWT_SECRET`, so rotating `JWT_SECRET` without setting a dedicated +`USAGE_ENCRYPTION_KEY` first loses it. The settings page then reports +`SIGNING_KEY_UNREADABLE` rather than a generic delivery failure, because the +consequence is specific: reports stop and the deletion request can no longer +be signed either. Restoring the original key material is the correct fix and +completes the pending deletion. When it is genuinely gone — a rotation done +because the secret was compromised — the settings page offers **Discard local +identity** (`POST /api/admin/usage/abandon`), which is available in no other +state. It erases the local identity, key material and markers and records an +abandonment receipt marked `collector-unconfirmed`: the collector was never +told, so it keeps the reports already accepted, and the receipt says so rather +than claiming a deletion that did not happen. Participation can be started +again afterwards with a fresh identity. + +The same exit covers the other way a participation can become impossible to +finish: a collector that rejects the packet outright. Opting in to usage.v2 +against a collector that still only speaks usage.v1 — the deployment order +this document warns about above — is answered with `INVALID_PACKET`, which is +surfaced as `SCHEMA_NOT_ACCEPTED` rather than a generic delivery failure, +because retrying cannot resolve it. Nothing is registered in that case, so +**Discard local identity** is offered immediately and its receipt records +`never-registered` rather than an unconfirmed deletion. The exit is never +offered while a participation the collector *did* accept could still be +deleted remotely; that case keeps the explicit warning. + +Keys live in a dedicated database +table, not the generic readable settings. A random mode-0600 file at +`getStoragePath()/usage-instance.key` binds the database to its local storage. + +## Consent and deletion lifecycle + +### Versioned, explicit scope upgrades + +New participants explicitly consent to usage.v2. Existing v1 participants stay +on v1 until they review and explicitly accept the expanded scope; migration 205 +defaults their consent to v1. A signed consent command preserves the identity +and raw history. Collector confirmation atomically upgrades local consent and +resets the local used-marker observation period. Lost receipts/outages leave the +upgrade visibly pending and retryable, with v1-only collection until confirmed. +Opt-out still stops everything immediately. Deploy the v2 collector first. + +The [complete feature and privacy matrix](FEATURE_COVERAGE.md) lists all 73 +signals (19 existing, 54 new), all 81 current route families and 26 feature flags. +56 capabilities have configured/used booleans; 17 guest-facing or automatic +capabilities are configuration-only, without a used field. The full catalog is +available locally before consent in EN/DE and publicly in the usage portal. +Missing signals from older versions are unknown in aggregates, not unused. + +### Participation lifecycle + +Disabled → activation pending → active. Registration/delivery failures are +durable and retried. Multiple admin tabs/processes share a database lease; +only accepted receipts advance the sequence and report date. Re-signed retries +reuse the immutable packet ID so lost acknowledgements do not duplicate data. + +Retries are paced (migration 206). Consecutive failures set `attempts` and +`next_attempt_at`, and the unattended sender — the activity endpoint and the +settings ticker — waits for that gate: 2, 4, 8, 16, 32 minutes, then hourly. +Without it a packet the collector rejects permanently produced one collector +request per admin action, because any authenticated admin reaches the activity +endpoint and every open admin tab fires it every five minutes. Explicit +operator actions are not paced: **Retry** and opt-out send immediately, and the +settings page names the time of the next automatic attempt so a waiting +installation does not read as a broken one. + +Opt-out immediately stops collection, clears markers/previews/feedback +preferences, and enters deletion pending. It keeps only credentials and the +deletion operation until the collector confirms deletion. The collector removes +reports, projections, feedback/publications, votes, and sessions. PicPeak then +erases the local fingerprint, private key and binding. A later join generates +a fresh identity. Repeated deletion handles lost receipts safely. + +Migration 204 adds bounded, local-only privacy receipts and removes any legacy +plaintext voting token from the last collector receipt. A completed export +records its time, the number of accepted reports and the total number of +accepted packets separately — feedback, votes and portal sessions are +participant operations, not reports, and a receipt that folded them into one +"reports" figure stated something untrue about its own contents. Confirmed +opt-out replaces this with a deletion receipt containing only a random receipt +ID, time, status and fixed scope. It retains no old installation hash, key, payload or credential. The +settings page can download these receipts even after opt-out. They are local +records of the collector acknowledgement, not independent proof of storage +erasure. Downloaded exports carry their own dated receipt; the collector does +not create a permanent per-person access/export log. + +A missing/mismatched storage binding or conflicting collector sequence stops +reporting with identity conflict. A full clone of a signing identity cannot be +distinguished cryptographically. Do not run the same participation identity in +two deployments; disable/delete the old participation and rejoin. Deletion +affects any other copy that shared the same identity. + +## Feedback and permissions + +Only settings.edit can inspect identity/packets or change participation and +feedback preferences. Any authenticated admin may trigger the fixed daily +report; the activity endpoint accepts no telemetry input. Every usage endpoint +uses adminAuth, including token-type checks. Gallery tokens cannot use it. + +Feedback, votes and portal sessions share one installation-wide budget of 30 +per hour. They are the only endpoints whose effect is an outbound request +carrying operator-written free text, and the platform's general limiter skips +authenticated requests by design — correct for endpoints that touch only this +installation, wrong for a relay. Reading status, retrying and opting out are +never throttled: those are how an operator sees what is happening and how they +leave. + +Feedback is sent only on explicit submission. Each item defaults anonymous and +private; names, publication permission, and testimonial marketing permission +are separate choices. Published requests/testimonials require maintainer review. +Public voting uses a backend-authorized 15-minute session, never the lookup hash. + +## Contract + +The closed v1/v2 schemas are in `backend/src/usage/schema.cjs`, with signing in +`protocol.cjs`. Keep these and `features.v2.json` byte-identical to the collector's `protocol/` copies. +The collector serves its schema and complete source archive publicly. Aggregate +projections and the complete dataset are accessible to participating +installations only; raw reports require the installation's confidential lookup +hash. Raw exports contain the first accepted envelope of every unique usage +report. Re-signed transport retries are deduplicated; feedback, registration, +sessions and rejected requests are not usage reports. Full exports use a +consistent database snapshot at their start, not a 200-record total limit. +Feature semantics and retention are documented in its +`docs/PROTOCOL.md` and `docs/OPERATIONS.md`. + +Public, reviewed testimonials are separate from marketing approval. Homepage +integrations must use `/api/public/marketing-testimonials`, never the general +portal testimonial feed. Each page is bounded and exposes its continuation +cursor. Deletion removes the source publication; operators must also remove +any externally copied content and follow the documented backup/log policies. + +Used flags represent successful allowlisted admin capability calls since +consent to the current schema (v1: joining; v2: joining or explicit upgrade), +not visitor behavior or counts. OAuth marks successful admin SSO; +applied CSS is observed during report generation. Gallery layouts are controlled +enums extracted from event themes without IDs or counts. Other signals use the +explicit rules in `middleware/productUsage.js`, `usage/capabilityRules.js`, +`usage/capabilityEvidence.js`, `usage/expandedSnapshot.js` and `usage/UsageService.js`. + +Tests: `backend/__tests__/routes/adminUsage.test.js`, frontend +`features/settings/__tests__/ProductUsageTab.test.tsx`, and the collector's +cross-repository integration suite with isolated databases and real HTTP. diff --git a/docs/usage-coverage.v2.json b/docs/usage-coverage.v2.json new file mode 100644 index 00000000..93b483db --- /dev/null +++ b/docs/usage-coverage.v2.json @@ -0,0 +1,1758 @@ +{ + "settings_tabs": { + "usage": { + "signals": [], + "reason": "Explicit consent/report inspection/feedback is not itself adoption telemetry." + }, + "features": { + "signals": [], + "reason": "Only the allowlisted effective feature booleans; no settings visit/save marker." + }, + "general": { + "signals": [ + "video_uploads", + "camera_raw_uploads", + "public_site", + "custom_css" + ], + "reason": "General technical upload/public-site/CSS configuration only; no title, URLs, limits, times, HTML or identity." + }, + "events": { + "signals": [ + "galleries", + "gallery_guest_uploads", + "gallery_downloads", + "gallery_client_access", + "gallery_watermarks", + "gallery_image_protection", + "gallery_reveal", + "gallery_expiration" + ], + "reason": "Gallery operations and disclosed configuration only; no event/customer values or visitor use." + }, + "eventTypes": { + "signals": [ + "event_types" + ], + "reason": "General admin event-type capability; no names or preset contents." + }, + "branding": { + "signals": [ + "branding", + "gallery_watermarks" + ], + "reason": "Branding operation and watermark configuration only; no branding text, logos or colors." + }, + "categories": { + "signals": [ + "gallery_categories" + ], + "reason": "Category management capability only; no names/order/category membership." + }, + "thumbnails": { + "signals": [ + "photo_processing" + ], + "reason": "Admin processing settings/regeneration initiation only; no image data or progress." + }, + "downloads": { + "signals": [ + "download_resolution_picker" + ], + "reason": "Configuration boolean only; no actual download/selection behavior or resolution values." + }, + "styling": { + "signals": [ + "custom_css" + ], + "reason": "Presence/application only plus controlled gallery-layout enums, never CSS/theme values." + }, + "cms": { + "signals": [ + "cms", + "public_site" + ], + "reason": "Admin page editing capability/public-site enabled only; no HTML, slugs or traffic." + }, + "email": { + "signals": [ + "smtp", + "incoming_mail", + "messaging", + "email_templates", + "email_webhook" + ], + "reason": "Configuration and documented manual admin capability operations only; messages, recipients, automatic activity and mailbox values excluded." + }, + "moderation": { + "signals": [ + "feedback_moderation" + ], + "reason": "Admin moderation/word-filter capability, never feedback content or visitor behavior." + }, + "security": { + "signals": [], + "reason": "Excluded password/MFA/session/rate-limit/security profiles and operations." + }, + "sso": { + "signals": [ + "oauth" + ], + "reason": "Enabled/config-present and successful admin callback only; no claims/provider details." + }, + "imageSecurity": { + "signals": [ + "gallery_image_protection" + ], + "reason": "Configuration presence only; no blocked-IP/security analytics or monitoring history." + }, + "seo": { + "signals": [ + "seo_customization" + ], + "reason": "Admin SEO configuration operation only; no meta tags, URLs, robots or verification tokens." + }, + "apiTokens": { + "signals": [ + "api_integration" + ], + "reason": "Valid credential presence and one successful scoped API capability bit; no tokens/scopes/owner metadata." + }, + "webhooks": { + "signals": [ + "webhooks" + ], + "reason": "Active configuration and manual test/replay enqueue only; no delivery data." + }, + "status": { + "signals": [], + "reason": "Excluded operational health, diagnostics, resource data, update and storage polling." + }, + "analytics": { + "signals": [ + "analytics_dashboard" + ], + "reason": "Analytics capability and admin aggregate-view use only; no embedded analytics results/tracker IDs or visitors." + }, + "backup": { + "signals": [ + "backup", + "database_backup", + "portable_backup", + "restore", + "s3_backups" + ], + "reason": "Schedule presence/manual capability initiation only, no histories, sizes, paths or files." + }, + "businessProfile": { + "signals": [], + "reason": "Excluded business identity, bank accounts and addresses." + }, + "crm": { + "signals": [ + "crm", + "crm_quotes", + "crm_invoices", + "crm_projects", + "crm_hours", + "customer_portal", + "crm_installments" + ], + "reason": "Only coarse module capabilities; no policies/amounts/customer/payment values." + }, + "contracts": { + "signals": [ + "crm_contracts", + "document_templates" + ], + "reason": "Admin contract/template capability only; no legal text or signatures." + }, + "reminderTemplates": { + "signals": [ + "reminder_emails", + "email_templates" + ], + "reason": "Reminder flag configuration and admin template editing only; no automatic reminder sends/recipients/content." + }, + "accounting": { + "signals": [ + "accounting", + "accounting_incoming_invoices", + "accounting_expenses", + "accounting_tax_report", + "accounting_ledger" + ], + "reason": "Only module capabilities, no tax codes, rates, balances or business identity." + }, + "whatsapp": { + "signals": [ + "whatsapp" + ], + "reason": "Configured integration plus manual test only; no phone numbers, tokens or automatic delivery." + }, + "slideshow": { + "signals": [ + "slideshow" + ], + "reason": "Admin setup capability only; no kiosk viewers, slide progress or photos." + } + }, + "reviewed_picpeak_base": "a5ff9264 (3.124.1-beta.0)", + "schema_version": "usage.v2", + "purpose": "Feature prioritization, bug fixes and maintenance only; no user/visitor behavior, identifiers, content, counts or event histories.", + "route_families": { + "acceptInvite.js": { + "decision": "excluded", + "signals": [], + "reason": "Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers.", + "route_signatures": [ + "GET /:token", + "POST /:token" + ] + }, + "admin.js": { + "decision": "composition", + "signals": [], + "reason": "Router composition / helpers; decisions are recorded for each mounted family.", + "route_signatures": [] + }, + "adminApiTokens.js": { + "decision": "configuration", + "signals": [ + "api_integration" + ], + "reason": "Only existence of a valid credential; no marker from token listing/creation, no scope, owner, token, expiry date or last-used time.", + "route_signatures": [ + "GET /", + "POST /", + "DELETE /:id" + ] + }, + "adminArchives.js": { + "decision": "partial", + "signals": [ + "galleries", + "archive_management", + "photo_exports" + ], + "reason": "Admin archive/delete/restore/download initiation only; filenames, histories, storage sizes and polling excluded.", + "route_signatures": [ + "GET /", + "GET /:id", + "POST /:id/restore", + "GET /:id/download", + "DELETE /:id" + ] + }, + "adminAuth.js": { + "decision": "excluded", + "signals": [], + "reason": "Bootstrap, passwords/MFA/session/profile, per-person notifications, developer helpers and operational health/update/log polling are outside the prioritization purpose.", + "route_signatures": [ + "GET /profile", + "PUT /profile", + "POST /change-password", + "POST /logout", + "GET /mfa/status", + "POST /mfa/setup", + "POST /mfa/enable", + "POST /mfa/disable", + "POST /mfa/recovery-codes" + ] + }, + "adminBackup.js": { + "decision": "partial", + "signals": [ + "backup", + "portable_backup", + "restore", + "s3_storage", + "s3_backups" + ], + "reason": "Admin backup initiation, portable export/import and successful S3 roundtrip test. Local export never implies S3; names, schedules, sizes, contents and history excluded.", + "route_signatures": [ + "GET /config", + "PUT /config", + "GET /status", + "POST /run", + "GET /picpeak/export", + "POST /picpeak/import", + "GET /runs/:id", + "GET /files", + "DELETE /cleanup", + "POST /test-connection", + "GET /manifest/:backupRunId", + "POST /manifest/validate", + "GET /manifest/:backupRunId/download", + "GET /manifests/:backupId", + "GET /manifests/:backupId/download", + "POST /manifests/validate", + "GET /s3/buckets", + "GET /s3/files", + "DELETE /s3/cleanup", + "POST /s3/test-upload", + "GET /download/:backupId", + "GET /checksums", + "POST /estimate" + ] + }, + "adminBusinessProfile.js": { + "decision": "excluded", + "signals": [], + "reason": "Business identity/bank/tax-address configuration and VAT-code helper surface are not separate usage signals. Billing/accounting capabilities are covered without profiling the business.", + "route_signatures": [ + "GET /", + "GET /logo-diagnostic", + "POST /logo", + "DELETE /logo", + "PUT /", + "GET /bank-accounts", + "POST /bank-accounts", + "PUT /bank-accounts/:id", + "DELETE /bank-accounts/:id" + ] + }, + "adminCalendar.js": { + "decision": "partial", + "signals": [ + "crm", + "crm_calendar" + ], + "reason": "Authenticated admin calendar retrieval is capability use; no calendar entries, dates, recurrence, availability or bookings.", + "route_signatures": [ + "GET /items" + ] + }, + "adminCategories.js": { + "decision": "partial", + "signals": [ + "gallery_categories" + ], + "reason": "Admin category CRUD; no names, descriptions, colors or ordering values.", + "route_signatures": [ + "GET /global", + "GET /event/:eventId", + "POST /", + "PUT /:id", + "PUT /:id/hero", + "DELETE /:id", + "POST /reorder", + "DELETE /reorder/:eventId", + "POST /reorder-global" + ] + }, + "adminCMS.js": { + "decision": "partial", + "signals": [ + "cms" + ], + "reason": "Admin CMS page CRUD only. Public page traffic, slug, HTML, text, links and media excluded.", + "route_signatures": [ + "GET /pages", + "GET /pages/:slug", + "PUT /pages/:slug", + "POST /pages/:slug/logo", + "DELETE /pages/:slug/logo" + ] + }, + "adminContracts.js": { + "decision": "partial", + "signals": [ + "crm", + "crm_contracts", + "document_templates" + ], + "reason": "Admin contract/block operations only; no legal text, signatures, signing parties or customer signing events.", + "route_signatures": [ + "GET /blocks", + "POST /blocks", + "PUT /blocks/:id", + "DELETE /blocks/:id", + "GET /", + "POST /", + "GET /:id", + "PUT /:id", + "POST /:id/send", + "POST /:id/cancel", + "POST /:id/convert-to-event", + "POST /:id/convert-to-invoice", + "POST /:id/resend-signed", + "POST /:id/restamp-signatures", + "POST /:id/countersign", + "POST /:id/upload-signed-pdf", + "GET /:id/pdf", + "GET /:id/signed-pdf", + "GET /:id/audit-trail", + "GET /:id/verify-integrity", + "GET /:id/preview" + ] + }, + "adminCssTemplates.js": { + "decision": "configuration", + "signals": [ + "custom_css" + ], + "reason": "Only existence of enabled applied CSS and locally observed application, not editing/viewing templates or any CSS text.", + "route_signatures": [ + "GET /", + "GET /enabled", + "GET /:slotNumber", + "PUT /:slotNumber", + "POST /:slotNumber/reset" + ] + }, + "adminCustomers.js": { + "decision": "partial", + "signals": [ + "crm", + "crm_hours", + "customer_portal" + ], + "reason": "Successful admin CRM/hour-entry/invitation operations only. No customer/account names, IDs, rates, billed hours, payment state or portal behavior.", + "route_signatures": [ + "GET /", + "GET /search", + "GET /invitations", + "POST /invite", + "DELETE /invitations/:id", + "POST /", + "POST /:id/send-invite", + "GET /:id", + "PUT /:id", + "POST /:id/deactivate", + "POST /:id/reactivate", + "POST /:id/erase", + "POST /:id/password-reset", + "PUT /:id/events", + "GET /hour-entries/unbilled-summary", + "GET /:id/hour-entries", + "POST /:id/hour-entries", + "PUT /:id/hour-entries/:entryId", + "DELETE /:id/hour-entries/:entryId", + "POST /:id/hour-entries/bill", + "POST /:id/bill-combined", + "POST /:id/trigger-monthly-bill", + "GET /:id/monthly-draft" + ] + }, + "adminDashboard.js": { + "decision": "partial", + "signals": [ + "analytics_dashboard" + ], + "reason": "Admin analytics capability endpoint only; no stats, activities, health/CRM polls, underlying visitor data or dashboard values.", + "route_signatures": [ + "GET /stats", + "GET /activity", + "GET /health", + "GET /analytics", + "GET /crm-stats" + ] + }, + "adminDatabaseBackup.js": { + "decision": "partial", + "signals": [ + "backup", + "database_backup" + ], + "reason": "Admin database-backup initiation plus schedule-enabled boolean, no file data/history.", + "route_signatures": [ + "GET /status", + "PUT /config", + "POST /backup", + "GET /progress", + "GET /history", + "DELETE /cleanup", + "POST /test", + "GET /checksums" + ] + }, + "adminDeals.js": { + "decision": "partial", + "signals": [ + "crm", + "crm_installments" + ], + "reason": "Admin installment-plan changes only. No actual plans, invoice links, amounts, paid states or deal reporting.", + "route_signatures": [ + "GET /:uuid/documents", + "PUT /:uuid/installment-plan" + ] + }, + "adminDev.js": { + "decision": "excluded", + "signals": [], + "reason": "Bootstrap, passwords/MFA/session/profile, per-person notifications, developer helpers and operational health/update/log polling are outside the prioritization purpose.", + "route_signatures": [ + "GET /email-templates", + "POST /send-test-email" + ] + }, + "adminEmail.js": { + "decision": "partial", + "signals": [ + "messaging", + "incoming_mail", + "smtp", + "email_templates", + "email_webhook", + "reminder_emails" + ], + "reason": "Admin message operation/template edit, actual successful manual send/test transport and non-skipped manual IMAP poll/test. Reminder flag configuration only. No automated sends/polls, received-message or recipient data, queue/log reads, mailbox addresses or templates.", + "route_signatures": [ + "GET /config", + "POST /config", + "GET /incoming-config", + "POST /incoming-config", + "POST /incoming-config/folders", + "POST /incoming-config/test", + "POST /incoming-config/roundtrip", + "POST /incoming-config/poll", + "GET /received", + "GET /received/:id", + "POST /item/:kind/:id/state", + "DELETE /item/:kind/:id", + "GET /accounts", + "GET /identities", + "POST /accounts", + "POST /accounts/test", + "POST /test", + "POST /flush-queue", + "GET /queue", + "GET /queue/:id", + "POST /send", + "GET /templates", + "GET /templates/:key", + "PUT /templates/:key", + "POST /templates", + "POST /templates/:key/preview" + ] + }, + "adminEventRename.js": { + "decision": "partial", + "signals": [ + "galleries" + ], + "reason": "Successful rename only, not validate-rename. No former/new names or identifiers.", + "route_signatures": [ + "POST /:eventId/rename", + "POST /:eventId/validate-rename" + ] + }, + "adminEvents/archiveBulk.js": { + "decision": "partial", + "signals": [ + "galleries", + "archive_management", + "photo_exports" + ], + "reason": "Admin archive/delete/restore/download initiation only; filenames, histories, storage sizes and polling excluded.", + "route_signatures": [ + "POST /:id/archive", + "POST /bulk-archive", + "POST /bulk-delete" + ] + }, + "adminEvents/crud.js": { + "decision": "partial", + "signals": [ + "galleries", + "gallery_guest_uploads", + "gallery_downloads", + "gallery_client_access", + "gallery_watermarks", + "gallery_reveal", + "gallery_expiration", + "gallery_sharing", + "custom_css" + ], + "reason": "Admin creation/edit/publish etc. set galleries; sharing has its own fixed key. Guest/download/protection/reveal/expiry are configuration only; themes contribute controlled layouts and CSS presence. No gallery metadata or guest action history.", + "route_signatures": [ + "POST /", + "GET /", + "GET /:id", + "POST /:id/send-gallery-email", + "POST /:id/publish", + "POST /:id/duplicate", + "PUT /:id", + "POST /:id/reveal", + "DELETE /:id", + "POST /:id/toggle-status", + "POST /:id/extend" + ] + }, + "adminEvents/downloadResolutions.js": { + "decision": "configuration", + "signals": [ + "download_resolution_picker" + ], + "reason": "Only whether a picker is configured globally or in a gallery. No chosen resolution, download event or counts.", + "route_signatures": [ + "GET /:id/download-resolutions", + "PATCH /:id/download-resolutions" + ] + }, + "adminEvents/faces.js": { + "decision": "partial", + "signals": [ + "face_recognition" + ], + "reason": "Effective flag plus successful admin faces/people operation. No health polling, embeddings, names, groups, detections or visitor searches.", + "route_signatures": [ + "GET /faces/health", + "GET /:id/faces", + "PATCH /:id/faces", + "GET /:id/people", + "GET /:id/people/suggestions", + "POST /:id/people/suggestions/dismiss", + "PATCH /:id/people/:personId", + "POST /:id/people/merge", + "POST /:id/people/:personId/split", + "GET /:id/people/:personId/faces", + "POST /:id/faces/rescan", + "POST /:id/faces/recluster", + "GET /faces/auto-categories", + "PUT /faces/auto-categories", + "POST /:id/faces/categorize", + "DELETE /:id/faces/categorize", + "DELETE /:id/faces" + ] + }, + "adminEvents/helpers.js": { + "decision": "composition", + "signals": [], + "reason": "Router composition / helpers; decisions are recorded for each mounted family.", + "route_signatures": [] + }, + "adminEvents/index.js": { + "decision": "composition", + "signals": [], + "reason": "Router composition / helpers; decisions are recorded for each mounted family.", + "route_signatures": [] + }, + "adminEvents/logo.js": { + "decision": "partial", + "signals": [ + "branding" + ], + "reason": "Successful admin logo operation only; image/filename/content excluded.", + "route_signatures": [ + "POST /:id/logo", + "DELETE /:id/logo" + ] + }, + "adminEvents/qr.js": { + "decision": "partial", + "signals": [ + "gallery_sharing" + ], + "reason": "Admin QR generation only; no scans, tokens or URLs.", + "route_signatures": [ + "GET /:id/qr", + "GET /:id/qr-print" + ] + }, + "adminEvents/resets.js": { + "decision": "partial", + "signals": [ + "galleries", + "gallery_sharing" + ], + "reason": "Admin gallery reset/sharing capability only; no password, recipient, token or reset statistics.", + "route_signatures": [ + "POST /:id/reset-password", + "POST /:id/resend-email" + ] + }, + "adminEvents/slideshow.js": { + "decision": "partial", + "signals": [ + "slideshow" + ], + "reason": "Admin generate/disable/configure only, never kiosk viewers or slide advances.", + "route_signatures": [ + "POST /:id/slideshow/generate", + "POST /:id/slideshow/disable", + "PATCH /:id/slideshow" + ] + }, + "adminEventTypes.js": { + "decision": "partial", + "signals": [ + "event_types" + ], + "reason": "Admin event-type CRUD; preset contents/names excluded.", + "route_signatures": [ + "GET /", + "GET /active", + "GET /:id", + "POST /", + "PUT /:id", + "DELETE /:id", + "POST /reorder" + ] + }, + "adminExpenses.js": { + "decision": "partial", + "signals": [ + "accounting", + "accounting_expenses", + "accounting_incoming_invoices" + ], + "reason": "Admin expense/inbound-invoice operations; no financial values, suppliers, mileage/location, dates, receipt files or OCR text.", + "route_signatures": [ + "GET /categories", + "POST /categories", + "PATCH /categories/:id", + "DELETE /categories/:id", + "POST /inbound", + "GET /inbound", + "GET /inbound/pending-summary", + "POST /inbound/bill-pending", + "GET /inbound/by-customer/:customerAccountId", + "GET /inbound/:id/file", + "GET /inbound/:id/page/:n", + "GET /inbound/:id", + "PATCH /inbound/:id", + "POST /inbound/:id/categorize", + "POST /inbound/:id/rebill", + "POST /inbound/:id/supplier-payment", + "GET /", + "POST /", + "GET /:id/proof", + "GET /:id", + "PATCH /:id", + "POST /:id/invoice", + "POST /:id/paid" + ] + }, + "adminExternalMedia.js": { + "decision": "partial", + "signals": [ + "share_mounts" + ], + "reason": "Only admin import operation; status/list/browse are not use. Snapshot checks external-path presence, never reports a path.", + "route_signatures": [ + "GET /list", + "POST /events/:id/import-external" + ] + }, + "adminFeatureFlags.js": { + "decision": "configuration", + "signals": [ + "crm", + "crm_quotes", + "crm_invoices", + "crm_contracts", + "crm_projects", + "crm_calendar", + "crm_hours", + "customer_portal", + "accounting", + "workflows", + "newsletters", + "face_recognition", + "slideshow", + "transfers", + "messaging", + "reminder_emails", + "accounting_incoming_invoices", + "accounting_expenses", + "accounting_tax_report", + "accounting_ledger", + "admin_management", + "analytics_dashboard" + ], + "reason": "Only allowlisted effective capability booleans. No marker from reading or saving feature flags. Disabled roadmap/developer flags excluded.", + "route_signatures": [ + "GET /", + "PUT /" + ] + }, + "adminFeedback.js": { + "decision": "partial", + "signals": [ + "feedback_moderation", + "gallery_feedback_likes", + "gallery_feedback_ratings", + "gallery_feedback_comments", + "gallery_feedback_favorites", + "gallery_feedback_reactions", + "gallery_feedback_color_labels", + "gallery_guest_accounts" + ], + "reason": "Admin moderation/word-filter operations only. Visitor feedback is not observed. Master-enabled per-gallery feedback-option booleans only; no contents, ratings, likes, colors, identities or word lists.", + "route_signatures": [ + "GET /events/:eventId/feedback-settings", + "PUT /events/:eventId/feedback-settings", + "GET /events/:eventId/feedback", + "PUT /feedback/:feedbackId/:action", + "DELETE /feedback/:feedbackId", + "GET /events/:eventId/feedback-analytics", + "GET /events/:eventId/feedback/export", + "GET /feedback/pending-moderation", + "GET /word-filters", + "POST /word-filters", + "PUT /word-filters/:id", + "DELETE /word-filters/:id" + ] + }, + "adminGuests.js": { + "decision": "partial", + "signals": [ + "guest_management" + ], + "reason": "Admin guest management/export initiation only. No guest names, invitations, tokens, contact data, guest counts or visitor interactions.", + "route_signatures": [ + "GET /events/:eventId/guests", + "GET /events/:eventId/guests/aggregate", + "GET /events/:eventId/guests/invites", + "POST /events/:eventId/guests/invites", + "DELETE /events/:eventId/guests/invites/:inviteId", + "GET /events/:eventId/guests/export-all", + "GET /events/:eventId/guests/:guestId", + "GET /events/:eventId/guests/:guestId/export", + "DELETE /events/:eventId/guests/:guestId", + "POST /events/:eventId/guests/:keepId/merge" + ] + }, + "adminImageSecurity.js": { + "decision": "configuration", + "signals": [ + "gallery_image_protection" + ], + "reason": "Only gallery/global technical protection configuration existence. No security events, blocked IPs, request counts, threat scores or admin monitoring access.", + "route_signatures": [ + "GET /settings", + "PUT /settings", + "GET /dashboard", + "GET /logs", + "GET /events/:eventId/access-logs", + "POST /block-ip", + "DELETE /logs/cleanup", + "GET /export" + ] + }, + "adminInvoices.js": { + "decision": "partial", + "signals": [ + "crm", + "crm_invoices" + ], + "reason": "Admin invoice operations only; no amounts, VAT/customer/payment values or payment-check responses.", + "route_signatures": [ + "GET /", + "GET /:id", + "POST /", + "POST /import", + "PUT /:id", + "GET /:id/rebill-proofs", + "POST /:id/send", + "POST /:id/mark-paid", + "POST /:id/send-reminder", + "POST /:id/test-payment-check", + "POST /:id/reissue", + "POST /:id/release-for-delivery", + "POST /:id/cancel", + "GET /:id/pdf", + "POST /preview", + "GET /:id/payment-log" + ] + }, + "adminLedger.js": { + "decision": "partial", + "signals": [ + "accounting", + "accounting_ledger" + ], + "reason": "Admin ledger-account/VAT/mapping edits and ledger export initiation only; no account/currency/VAT identifiers or exported records.", + "route_signatures": [ + "GET /accounts", + "POST /accounts", + "PATCH /accounts/:id", + "DELETE /accounts/:id", + "GET /vat-codes", + "POST /vat-codes", + "PATCH /vat-codes/:id", + "DELETE /vat-codes/:id", + "GET /mappings", + "PATCH /mappings/category/:id", + "PATCH /mappings/settings", + "GET /export" + ] + }, + "adminNewsletters.js": { + "decision": "partial", + "signals": [ + "newsletters" + ], + "reason": "Admin campaign changes/test/queue/cancel only. Recipient resolution, previews, subscriptions/unsubscribes, delivery/open/click data and automatic sending excluded.", + "route_signatures": [ + "GET /", + "GET /:id", + "GET /:id/recipients", + "POST /", + "PUT /:id", + "DELETE /:id", + "POST /:id/preview", + "POST /:id/recipients/resolve", + "POST /:id/test", + "POST /:id/queue", + "POST /:id/cancel" + ] + }, + "adminNotifications.js": { + "decision": "excluded", + "signals": [], + "reason": "Bootstrap, passwords/MFA/session/profile, per-person notifications, developer helpers and operational health/update/log polling are outside the prioritization purpose.", + "route_signatures": [ + "GET /", + "PUT /:id/read", + "PUT /read-all", + "DELETE /clear-all" + ] + }, + "adminPhotoDimensions.js": { + "decision": "partial", + "signals": [ + "photo_processing" + ], + "reason": "Admin repair/regenerate/configuration initiation, never status polling or processing totals.", + "route_signatures": [ + "POST /repair-dimensions", + "GET /repair-dimensions/status", + "POST /repair-capture-dates", + "GET /repair-capture-dates/status", + "POST /repair-orientation", + "GET /repair-orientation/status" + ] + }, + "adminPhotoExport.js": { + "decision": "partial", + "signals": [ + "photo_exports" + ], + "reason": "Admin export initiation only; export filters, selected files, sizes and contents excluded.", + "route_signatures": [ + "GET /:eventId/filtered", + "GET /:eventId/filter-summary", + "POST /:eventId/export", + "GET /export-formats" + ] + }, + "adminPhotos.js": { + "decision": "partial", + "signals": [ + "photo_management", + "photo_exports", + "photo_processing", + "video_uploads", + "camera_raw_uploads", + "s3_storage", + "s3_photo_storage" + ], + "reason": "Successful admin edits/exports and accepted upload evidence only. Chunk init/status, failed uploads and public downloads excluded. Only video/RAW/S3 booleans survive, never file metadata/EXIF/content.", + "route_signatures": [ + "POST /:eventId/upload", + "GET /uploads/:upload_id/status", + "GET /uploads/:upload_id/stream", + "POST /photos/:photoId/retry", + "DELETE /:eventId/photos/:photoId", + "PUT /:eventId/photos/:photoId/mark", + "PATCH /:eventId/photos/:photoId", + "POST /:eventId/photos/bulk-delete", + "POST /:eventId/photos/bulk-update", + "GET /:eventId/photos/:photoId/download", + "GET /:eventId/photos", + "GET /:eventId/photo/:photoId", + "GET /:eventId/thumbnail/:photoId", + "GET /:eventId/preview/:photoId", + "GET /:eventId/debug", + "POST /:eventId/chunked-upload/init", + "POST /:eventId/chunked-upload/:uploadId/chunk/:chunkIndex", + "POST /:eventId/chunked-upload/:uploadId/complete", + "GET /:eventId/chunked-upload/:uploadId/status", + "DELETE /:eventId/chunked-upload/:uploadId" + ] + }, + "adminProjects.js": { + "decision": "partial", + "signals": [ + "crm", + "crm_projects" + ], + "reason": "Admin project operations only; project/person names, business performance, metadata and totals excluded.", + "route_signatures": [ + "GET /", + "POST /", + "GET /:id", + "PUT /:id", + "POST /:id/events", + "POST /:id/quotes", + "POST /:id/contracts", + "GET /:id/overview", + "GET /email/:emailId/preview", + "POST /email/:emailId/resend", + "POST /email/:emailId/cancel", + "POST /email/:emailId/retry", + "POST /email/:emailId/send-now" + ] + }, + "adminQuotes.js": { + "decision": "partial", + "signals": [ + "crm", + "crm_quotes", + "document_templates" + ], + "reason": "Admin quote/preset operations only; no quote content, prices, customer acceptance or signatures.", + "route_signatures": [ + "GET /", + "GET /:id", + "POST /", + "PUT /:id", + "POST /:id/send", + "POST /:id/duplicate", + "POST /:id/accept", + "POST /:id/decline", + "POST /:id/convert", + "POST /:id/convert-to-invoice", + "POST /:id/convert-to-contract", + "GET /:id/pdf", + "POST /preview", + "GET /presets/line-items", + "POST /presets/line-items", + "PUT /presets/line-items/:id", + "DELETE /presets/line-items/:id", + "GET /presets/payment-terms", + "POST /presets/payment-terms", + "PUT /presets/payment-terms/:id", + "DELETE /presets/payment-terms/:id", + "GET /presets/payment-net-days", + "POST /presets/payment-net-days", + "PUT /presets/payment-net-days/:id", + "DELETE /presets/payment-net-days/:id", + "GET /presets/payment-timing", + "POST /presets/payment-timing", + "PUT /presets/payment-timing/:id", + "DELETE /presets/payment-timing/:id" + ] + }, + "adminRestore.js": { + "decision": "partial", + "signals": [ + "restore" + ], + "reason": "Admin restore initiation only, never file selection, content, progress, errors or timing.", + "route_signatures": [ + "GET /status", + "POST /validate", + "POST /start", + "GET /progress", + "GET /run/:id", + "GET /run/:id/report", + "GET /available-backups", + "POST /list-backups", + "GET /settings", + "PUT /settings" + ] + }, + "adminRoles.js": { + "decision": "partial", + "signals": [ + "admin_management" + ], + "reason": "Admin account/role management capability; no names, permissions, role labels, password reset operations or active-user counts. Auth/self-profile endpoints excluded.", + "route_signatures": [ + "GET /", + "GET /permissions", + "POST /", + "POST /:id/clone", + "PUT /:id", + "DELETE /:id" + ] + }, + "adminSettings.js": { + "decision": "partial", + "signals": [ + "custom_css", + "oauth", + "smtp", + "backup", + "s3_storage", + "video_uploads", + "camera_raw_uploads", + "public_site", + "branding", + "seo_customization", + "slideshow", + "download_resolution_picker", + "gallery_watermarks", + "database_backup" + ], + "reason": "Only specified configuration presence/booleans and explicit branding/SEO/slideshow operations. Generic settings reads, security policies, passwords, storage data, SMTP/OIDC credentials, custom HTML/CSS/SEO values excluded.", + "route_signatures": [ + "GET /", + "GET /:type", + "GET /customer-surface", + "PUT /customer-surface", + "PUT /accounting", + "PUT /slideshow", + "GET /downloads", + "PUT /downloads", + "GET /sso", + "PUT /sso", + "POST /sso/test", + "GET /:type", + "GET /password/complexity", + "PUT /branding", + "POST /logo", + "DELETE /logo", + "POST /branding/watermark-logo", + "PUT /theme", + "PUT /general", + "PUT /security", + "PUT /analytics", + "PUT /seo", + "GET /storage/info", + "POST /favicon", + "PUT /security/rate-limit", + "GET /public-site/default", + "POST /public-site/reset" + ] + }, + "adminShortUrls.js": { + "decision": "partial", + "signals": [ + "gallery_sharing", + "short_links" + ], + "reason": "Admin short-link creation/deletion only; link/token/click metadata excluded.", + "route_signatures": [ + "GET /events/:eventId/short-urls", + "POST /events/:eventId/short-urls", + "DELETE /short-urls/:id" + ] + }, + "adminSystem.js": { + "decision": "excluded", + "signals": [], + "reason": "Bootstrap, passwords/MFA/session/profile, per-person notifications, developer helpers and operational health/update/log polling are outside the prioritization purpose.", + "route_signatures": [ + "GET /version", + "GET /updates", + "GET /updates/whatsnew", + "POST /updates/whatsnew/seen", + "GET /updates/changelog", + "GET /updates/instructions", + "GET /status", + "GET /database", + "GET /updates/notifications", + "PUT /updates/notifications", + "POST /updates/notifications/send", + "POST /updates/notifications/check" + ] + }, + "adminSystemHealth.js": { + "decision": "excluded", + "signals": [], + "reason": "Bootstrap, passwords/MFA/session/profile, per-person notifications, developer helpers and operational health/update/log polling are outside the prioritization purpose.", + "route_signatures": [ + "GET /backup-integrity", + "GET /backup-coverage", + "GET /failures", + "POST /failures/email/:id/retry", + "DELETE /failures/email/:id" + ] + }, + "adminTaxReport.js": { + "decision": "partial", + "signals": [ + "accounting", + "accounting_tax_report" + ], + "reason": "Admin tax report generation/export only; no totals, dates, tax regimes, geography or currency.", + "route_signatures": [ + "GET /", + "GET /pdf", + "GET /csv" + ] + }, + "adminThumbnails.js": { + "decision": "partial", + "signals": [ + "photo_processing" + ], + "reason": "Admin repair/regenerate/configuration initiation, never status polling or processing totals.", + "route_signatures": [ + "GET /settings", + "PUT /settings", + "POST /regenerate", + "POST /regenerate-previews", + "GET /regenerate/status" + ] + }, + "adminTransfers.js": { + "decision": "partial", + "signals": [ + "transfers" + ], + "reason": "Admin transfer CRUD/files/link management/download only. Public recipients, received-file data, upload and download statistics excluded.", + "route_signatures": [ + "GET /", + "POST /", + "GET /:id", + "PATCH /:id", + "DELETE /:id", + "POST /:id/files", + "DELETE /:id/files/:fileId", + "POST /:id/upload-files", + "DELETE /:id/extra-files/:extraId", + "GET /:id/extra-files/:extraId/download", + "POST /:id/upload-link", + "DELETE /:id/upload-link", + "GET /:id/download", + "GET /:id/uploads/:uploadId/download" + ] + }, + "adminUsage.js": { + "decision": "excluded", + "signals": [], + "reason": "Consent, inspection, export, feedback, voting, deletion and abandoning an unsignable deletion are explicit protocol operations; not product-use signals. Activity only triggers a due fixed report.", + "route_signatures": [ + "POST /activity", + "GET /", + "POST /dismiss", + "POST /enable", + "POST /consent", + "POST /disable", + "POST /abandon", + "POST /retry", + "GET /preview", + "GET /export", + "PUT /feedback-preferences", + "POST /feedback", + "POST /vote", + "POST /portal-session" + ] + }, + "adminUsers.js": { + "decision": "partial", + "signals": [ + "admin_management" + ], + "reason": "Admin account/role management capability; no names, permissions, role labels, password reset operations or active-user counts. Auth/self-profile endpoints excluded.", + "route_signatures": [ + "GET /me/permissions", + "GET /", + "GET /roles", + "GET /invitations", + "POST /invite", + "DELETE /invitations/:id", + "GET /:id", + "PUT /:id", + "POST /:id/deactivate", + "POST /:id/activate", + "DELETE /:id", + "POST /:id/reset-password" + ] + }, + "adminVatCodes.js": { + "decision": "excluded", + "signals": [], + "reason": "Business identity/bank/tax-address configuration and VAT-code helper surface are not separate usage signals. Billing/accounting capabilities are covered without profiling the business.", + "route_signatures": [ + "GET /" + ] + }, + "adminWebhooks.js": { + "decision": "partial", + "signals": [ + "webhooks" + ], + "reason": "Active configuration existence plus successful admin manual test/replay enqueue. Actual network delivery/results/subscriptions/destinations excluded.", + "route_signatures": [ + "GET /", + "POST /", + "GET /:id", + "PUT /:id", + "DELETE /:id", + "POST /:id/test", + "GET /:id/deliveries", + "GET /:id/deliveries/:deliveryId", + "POST /:id/deliveries/:deliveryId/replay" + ] + }, + "adminWhatsapp.js": { + "decision": "partial", + "signals": [ + "whatsapp" + ], + "reason": "Effective configured sender and successful manual test only. No automated deliveries, phone numbers, templates or delivery statuses.", + "route_signatures": [ + "GET /config", + "PUT /config", + "POST /test" + ] + }, + "adminWorkflows.js": { + "decision": "partial", + "signals": [ + "workflows" + ], + "reason": "Admin workflow authoring/approval/test initiation only. Runtime triggers, payloads, execution frequency/results and public approvals excluded.", + "route_signatures": [ + "GET /approvals", + "POST /approvals/:id/:action", + "GET /runs/:runId/steps", + "GET /:id/runs", + "POST /:id/test-run", + "GET /", + "GET /:id", + "POST /", + "PUT /:id", + "PATCH /:id/enabled", + "DELETE /:id" + ] + }, + "analyticsTrackerProxy.js": { + "decision": "excluded", + "signals": [], + "reason": "Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers.", + "route_signatures": [] + }, + "auth.js": { + "decision": "partial", + "signals": [ + "oauth" + ], + "reason": "Only successful admin OIDC callback sets oauth. Password/gallery authentication, MFA, account claims and provider details excluded.", + "route_signatures": [ + "POST /admin/login", + "POST /admin/login/mfa", + "POST /logout", + "POST /gallery/verify", + "POST /gallery/:slug/client-login", + "POST /gallery/share-login", + "POST /gallery/logout", + "GET /session", + "POST /admin/change-password", + "POST /password-strength", + "GET /admin/sso/login", + "GET /admin/sso/callback" + ] + }, + "customer.js": { + "decision": "excluded", + "signals": [], + "reason": "Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers.", + "route_signatures": [ + "GET /events", + "GET /events/:slug/access-token", + "GET /profile", + "PUT /profile", + "GET /profile/marketing", + "PUT /profile/marketing", + "POST /profile/password", + "GET /quotes", + "GET /invoices", + "GET /quotes/:id/pdf", + "GET /invoices/:id/pdf", + "GET /contracts", + "GET /contracts/:id/pdf" + ] + }, + "customerAuth.js": { + "decision": "excluded", + "signals": [], + "reason": "Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers.", + "route_signatures": [ + "POST /login", + "POST /logout", + "GET /session", + "GET /invite/:token", + "POST /accept-invite", + "GET /password-reset/:token", + "POST /password-reset" + ] + }, + "gallery.js": { + "decision": "excluded", + "signals": [], + "reason": "Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers.", + "route_signatures": [ + "GET /resolve/:identifier", + "GET /:slug/verify-token/:token", + "GET /:slug/info", + "GET /:slug/show/:token/session", + "GET /:slug/show/:token/state", + "GET /:slug/photos", + "GET /:slug/people", + "PATCH /:slug/photos/:photoId/visibility", + "PATCH /:slug/photos/visibility/bulk", + "GET /:slug/download/:photoId", + "GET /:slug/download-all", + "POST /:slug/download-selected", + "POST /:slug/download-jobs", + "GET /:slug/download-jobs/:token", + "GET /:slug/download-jobs/:token/file", + "POST /:slug/photo/:photoId/view", + "GET /:slug/photo/:photoId", + "GET /:slug/thumbnail/:photoId", + "GET /:slug/hero/:photoId", + "GET /:slug/preview/:photoId", + "GET /:slug/stats", + "POST /:eventId/upload", + "GET /:slug/uploads/status", + "GET /:slug/css-template" + ] + }, + "galleryFeedback.js": { + "decision": "excluded", + "signals": [], + "reason": "Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers.", + "route_signatures": [ + "GET /:slug/feedback-settings", + "GET /:slug/photos/:photoId/feedback", + "POST /:slug/photos/:photoId/feedback", + "GET /:slug/feedback-summary", + "GET /:slug/my-feedback" + ] + }, + "galleryGuests.js": { + "decision": "excluded", + "signals": [], + "reason": "Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers.", + "route_signatures": [ + "POST /:slug/guest", + "GET /:slug/guest/me", + "DELETE /:slug/guest/me", + "POST /:slug/guest/recover", + "POST /:slug/guest/verify", + "POST /:slug/guest/redeem" + ] + }, + "protectedImages.js": { + "decision": "excluded", + "signals": [], + "reason": "Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers.", + "route_signatures": [ + "GET /:slug/photo/:photoId/view", + "POST /:slug/photo/:photoId/generate-secure-token", + "POST /:slug/photo/:photoId/generate-url", + "GET /:slug/photo/:photoId/signed/:token" + ] + }, + "publicCMS.js": { + "decision": "excluded", + "signals": [], + "reason": "Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers.", + "route_signatures": [ + "GET /pages/:slug" + ] + }, + "publicContracts.js": { + "decision": "excluded", + "signals": [], + "reason": "Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers.", + "route_signatures": [ + "GET /:token", + "POST /:token/sign", + "POST /:token/upload-signed-pdf", + "GET /:token/pdf" + ] + }, + "publicFonts.js": { + "decision": "excluded", + "signals": [], + "reason": "Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers.", + "route_signatures": [ + "GET /" + ] + }, + "publicNewsletter.js": { + "decision": "excluded", + "signals": [], + "reason": "Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers.", + "route_signatures": [ + "GET /unsubscribe/:token", + "POST /unsubscribe/:token" + ] + }, + "publicPaymentCheck.js": { + "decision": "excluded", + "signals": [], + "reason": "Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers.", + "route_signatures": [ + "GET /:token", + "POST /:token" + ] + }, + "publicQuotes.js": { + "decision": "excluded", + "signals": [], + "reason": "Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers.", + "route_signatures": [ + "GET /:token", + "POST /:token/respond" + ] + }, + "publicSettings.js": { + "decision": "excluded", + "signals": [], + "reason": "Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers.", + "route_signatures": [ + "GET /" + ] + }, + "publicTransfer.js": { + "decision": "excluded", + "signals": [], + "reason": "Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers.", + "route_signatures": [ + "GET /:token", + "GET /:token/download", + "GET /:token/download/:fileId" + ] + }, + "publicTransferUpload.js": { + "decision": "excluded", + "signals": [], + "reason": "Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers.", + "route_signatures": [ + "GET /:token", + "POST /:token" + ] + }, + "publicWorkflowApprovals.js": { + "decision": "excluded", + "signals": [], + "reason": "Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers.", + "route_signatures": [ + "GET /:token/:action", + "POST /:token/:action" + ] + }, + "secureImages.js": { + "decision": "excluded", + "signals": [], + "reason": "Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers.", + "route_signatures": [ + "POST /:slug/generate-token", + "GET /:slug/secure/:photoId/:token", + "GET /:slug/secure-download/:photoId/:token", + "GET /security/stats" + ] + }, + "setup.js": { + "decision": "excluded", + "signals": [], + "reason": "Bootstrap, passwords/MFA/session/profile, per-person notifications, developer helpers and operational health/update/log polling are outside the prioritization purpose.", + "route_signatures": [ + "GET /status", + "POST /verify-token", + "POST /admin", + "POST /complete" + ] + }, + "v1/events.js": { + "decision": "partial", + "signals": [ + "api_integration" + ], + "reason": "Single bit after successful admin-owned scoped API authentication. No request/response values; API requests do not trigger reports.", + "route_signatures": [ + "POST /events", + "GET /events", + "GET /event-types", + "GET /events/:id", + "POST /events/:id/photos", + "GET /events/:id/share-link", + "GET /events/:id/photos" + ] + } + }, + "feature_flags": { + "accounting": { + "signals": [ + "accounting", + "accounting_ledger" + ], + "reason": "Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean." + }, + "analytics": { + "signals": [ + "analytics_dashboard" + ], + "reason": "Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean." + }, + "bills": { + "signals": [ + "crm_invoices" + ], + "reason": "Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean." + }, + "calendar": { + "signals": [ + "crm_calendar" + ], + "reason": "Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean." + }, + "calendarBooking": { + "signals": [], + "reason": "Excluded: disabled roadmap placeholder, not an implemented booking capability." + }, + "clients": { + "signals": [ + "crm" + ], + "reason": "Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean." + }, + "contracts": { + "signals": [ + "crm_contracts" + ], + "reason": "Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean." + }, + "crmDevelopment": { + "signals": [], + "reason": "Excluded: internal development/test helpers, not product adoption." + }, + "customerPortal": { + "signals": [ + "customer_portal" + ], + "reason": "Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean." + }, + "expenses": { + "signals": [ + "accounting_expenses" + ], + "reason": "Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean." + }, + "faces": { + "signals": [ + "face_recognition" + ], + "reason": "Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean." + }, + "galleries": { + "signals": [ + "galleries" + ], + "reason": "Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean." + }, + "hoursLogging": { + "signals": [ + "crm_hours" + ], + "reason": "Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean." + }, + "incomingInvoices": { + "signals": [ + "accounting_incoming_invoices" + ], + "reason": "Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean." + }, + "incomingMail": { + "signals": [ + "incoming_mail" + ], + "reason": "Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean." + }, + "messaging": { + "signals": [ + "messaging" + ], + "reason": "Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean." + }, + "newsletters": { + "signals": [ + "newsletters" + ], + "reason": "Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean." + }, + "projects": { + "signals": [ + "crm_projects" + ], + "reason": "Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean." + }, + "quotes": { + "signals": [ + "crm_quotes" + ], + "reason": "Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean." + }, + "reminderEmails": { + "signals": [ + "reminder_emails" + ], + "reason": "Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean." + }, + "slideshow": { + "signals": [ + "slideshow" + ], + "reason": "Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean." + }, + "taxReport": { + "signals": [ + "accounting_tax_report" + ], + "reason": "Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean." + }, + "transfers": { + "signals": [ + "transfers" + ], + "reason": "Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean." + }, + "userManagement": { + "signals": [ + "admin_management" + ], + "reason": "Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean." + }, + "whatsapp": { + "signals": [ + "whatsapp" + ], + "reason": "Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean." + }, + "workflows": { + "signals": [ + "workflows" + ], + "reason": "Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean." + } + }, + "excluded_runtime": [ + "Gallery/customer/public events and optional website analytics", + "Automated newsletter, reminder, WhatsApp, webhook and IMAP jobs", + "Security/audit logs, biometric embeddings and recognition results", + "Operational health, migration, update and polling metrics", + "Business/customer/user identities, geography, amounts and document contents", + "Disabled calendarBooking and internal crmDevelopment; hosted future product #1111", + "Image fragmentation: removed from current PicPeak, not a live capability" + ], + "configuration_only": [ + "reminder_emails", + "public_site", + "gallery_feedback_likes", + "gallery_feedback_ratings", + "gallery_feedback_comments", + "gallery_feedback_favorites", + "gallery_feedback_reactions", + "gallery_feedback_color_labels", + "gallery_guest_accounts", + "gallery_guest_uploads", + "gallery_downloads", + "download_resolution_picker", + "gallery_client_access", + "gallery_watermarks", + "gallery_image_protection", + "gallery_reveal", + "gallery_expiration" + ] +} diff --git a/frontend/src/components/admin/AdminLayout.tsx b/frontend/src/components/admin/AdminLayout.tsx index 6d7f65d8..34804422 100644 --- a/frontend/src/components/admin/AdminLayout.tsx +++ b/frontend/src/components/admin/AdminLayout.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { lazy, Suspense, useState } from 'react'; import { Outlet, Navigate } from 'react-router-dom'; import { useAdminAuth } from '../../contexts'; @@ -11,6 +11,7 @@ import { MigrationBanner } from './MigrationBanner'; import { MandatoryPasswordChangeModal } from './MandatoryPasswordChangeModal'; const SIDEBAR_COLLAPSED_KEY = 'admin-sidebar-collapsed'; +const ProductUsageNotice = lazy(() => import('./ProductUsageNotice')); export const AdminLayout: React.FC = () => { const { isAuthenticated, isLoading, mustChangePassword } = useAdminAuth(); @@ -125,6 +126,7 @@ const AdminLayoutInner: React.FC = ({ sidebarOpen, setSid (or remove this mount) after operators have had time to update their docker-compose.yml. See #669. */} + {!mustChangePassword && } {/* Page content - disabled when password change required. overflow moved up to the column so the scrollbar gutter is @@ -138,4 +140,4 @@ const AdminLayoutInner: React.FC = ({ sidebarOpen, setSid ); }; -AdminLayout.displayName = 'AdminLayout'; \ No newline at end of file +AdminLayout.displayName = 'AdminLayout'; diff --git a/frontend/src/components/admin/ProductUsageNotice.tsx b/frontend/src/components/admin/ProductUsageNotice.tsx new file mode 100644 index 00000000..1d22695d --- /dev/null +++ b/frontend/src/components/admin/ProductUsageNotice.tsx @@ -0,0 +1,108 @@ +import { useEffect } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { Link, useLocation } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { Sparkles } from 'lucide-react'; +import { usePermissions } from '../../contexts/PermissionsContext'; +import { productUsageService } from '../../services/productUsage.service'; + +// Where the invitation is allowed to appear. It is an invitation, not an +// alert, so it belongs on the pages an admin visits deliberately rather than +// on top of whatever task they are in the middle of. +const NOTICE_PATHS = ['/admin/dashboard', '/admin/settings']; + +// Loaded only inside the authenticated admin tree. Gallery routes never import +// this chunk, make usage requests, or record product usage markers. +export default function ProductUsageNotice() { + const { t } = useTranslation(); + const { hasPermission } = usePermissions(); + const queryClient = useQueryClient(); + const { pathname } = useLocation(); + const { data } = useQuery({ + queryKey: ['productUsage'], + queryFn: productUsageService.status, + enabled: hasPermission('settings.edit') + }); + // Deliberately above every visibility test, and deliberately NOT limited to + // the pages the banner is shown on. This ticker is what triggers the daily + // rollup — the backend has no scheduler — so tying it to the banner would + // mean an admin who works on Events and never opens the dashboard stops + // reporting altogether, and a participating install (where the banner never + // renders at all) would never report again. + useEffect(() => { + let running = false; + const tick = async () => { + if (document.visibilityState === 'hidden' || running) return; + running = true; + try { + await productUsageService.activity(); + } catch { + /* Best-effort delivery; settings show persisted retry state. */ + } finally { + running = false; + } + }; + void tick(); + const timer = window.setInterval(tick, 5 * 60 * 1000); + document.addEventListener('visibilitychange', tick); + return () => { + window.clearInterval(timer); + document.removeEventListener('visibilitychange', tick); + }; + }, []); + + if (!hasPermission('settings.edit') || !data) return null; + // Only while participation is off. `activation_pending`, `deletion_pending` + // and `identity_conflict` are all in-flight states the settings page + // explains properly; inviting someone to join in the middle of their own + // withdrawal would be worse than saying nothing. + if (data.status !== 'disabled' || data.notice_dismissed) return null; + if (!NOTICE_PATHS.some((path) => pathname.startsWith(path))) return null; + + return ( + + ); +} diff --git a/frontend/src/components/admin/__tests__/ProductUsageNotice.test.tsx b/frontend/src/components/admin/__tests__/ProductUsageNotice.test.tsx new file mode 100644 index 00000000..bf960277 --- /dev/null +++ b/frontend/src/components/admin/__tests__/ProductUsageNotice.test.tsx @@ -0,0 +1,124 @@ +/** + * The participation invitation (#1110). + * + * Two properties matter here and are easy to break by accident: + * + * - it is an INVITATION, so it appears only where an admin goes + * deliberately, and only while participation is actually off; + * - the activity ticker inside it is what triggers the daily rollup — the + * backend has no scheduler — so it must keep running on every admin page, + * including the ones where the banner is not rendered and the case where + * the install is already participating and the banner never renders at all. + */ +import { render, screen, waitFor, cleanup } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest'; +import ProductUsageNotice from '../ProductUsageNotice'; +import { productUsageService as service } from '../../../services/productUsage.service'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), + initReactI18next: { type: '3rdParty', init: () => {} } +})); +vi.mock('../../../contexts/PermissionsContext', () => ({ + usePermissions: () => ({ hasPermission: () => true }) +})); +vi.mock('../../../services/productUsage.service', () => ({ + productUsageService: { status: vi.fn(), activity: vi.fn(), dismiss: vi.fn() } +})); + +const status = (over = {}) => ({ + status: 'disabled', + notice_dismissed: false, + installation_id: null, + collector_url: 'https://collector.example', + schema_version: 'usage.v1', + last_report_date: null, + last_error: null, + pending_action: null, + last_packet: null, + feedback_preferences: { name: '' }, + ...over +}); + +function renderAt(path: string) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + + + + ); + // Absence only means something once the status has actually landed. + // Waiting on the service being *called* proved nothing: the component + // returns null while `data` is undefined, so every negative assertion + // passed even with the gate removed. + return { + settled: () => + waitFor(() => expect(client.getQueryData(['productUsage'])).toBeDefined()) + }; +} + +beforeEach(() => { + vi.mocked(service.status).mockResolvedValue(status() as never); + vi.mocked(service.activity).mockResolvedValue(undefined as never); +}); +afterEach(() => { cleanup(); vi.clearAllMocks(); }); + +describe('product usage notice', () => { + it.each(['/admin/dashboard', '/admin/settings', '/admin/settings?tab=usage'])( + 'invites participation on %s', + async (path) => { + renderAt(path); + expect(await screen.findByText('productUsage.noticeTitle')).toBeInTheDocument(); + // The dismissal is permanent, so the label must not promise a return. + expect(screen.getByText('productUsage.ignore')).toBeInTheDocument(); + expect(screen.getByText('productUsage.ignoreHint')).toBeInTheDocument(); + } + ); + + it.each(['/admin/events', '/admin/archives', '/admin/users'])( + 'stays out of the way on %s', + async (path) => { + const { settled } = renderAt(path); + await settled(); + expect(screen.queryByText('productUsage.noticeTitle')).not.toBeInTheDocument(); + } + ); + + it.each(['active', 'activation_pending', 'deletion_pending', 'identity_conflict'])( + 'does not invite while participation is %s', + async (state) => { + vi.mocked(service.status).mockResolvedValue(status({ status: state }) as never); + const { settled } = renderAt('/admin/dashboard'); + await settled(); + expect(screen.queryByText('productUsage.noticeTitle')).not.toBeInTheDocument(); + } + ); + + it('does not invite again once ignored', async () => { + vi.mocked(service.status).mockResolvedValue(status({ notice_dismissed: true }) as never); + const { settled } = renderAt('/admin/dashboard'); + await settled(); + expect(screen.queryByText('productUsage.noticeTitle')).not.toBeInTheDocument(); + }); + + it('still reports activity on a page where the banner is hidden', async () => { + // The rollup must not depend on which page the admin happens to be on. + const { settled } = renderAt('/admin/events'); + await waitFor(() => expect(service.activity).toHaveBeenCalled()); + await settled(); + expect(screen.queryByText('productUsage.noticeTitle')).not.toBeInTheDocument(); + }); + + it('still reports activity for an install that is already participating', async () => { + // The banner never renders in this state; reporting must continue anyway. + vi.mocked(service.status).mockResolvedValue(status({ status: 'active' }) as never); + const { settled } = renderAt('/admin/dashboard'); + await waitFor(() => expect(service.activity).toHaveBeenCalled()); + await settled(); + expect(screen.queryByText('productUsage.noticeTitle')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/features/settings/UsageCatalog.tsx b/frontend/src/features/settings/UsageCatalog.tsx new file mode 100644 index 00000000..4eaa25bb --- /dev/null +++ b/frontend/src/features/settings/UsageCatalog.tsx @@ -0,0 +1,35 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import catalog from './usageFeatures.v2.json'; + +/** Local, static disclosure: opening it never contacts the collector. */ +export function UsageCatalog() { + const { t } = useTranslation(); + const [search, setSearch] = useState(''); + const entries = Object.entries(catalog.features).filter(([key]) => + `${key} ${t(`productUsage.catalog.${key}.name`)}`.toLowerCase().includes(search.toLowerCase())); + return ( +
+ {t('productUsage.catalogTitle')} +

{t('productUsage.catalogExplanation')}

+ +
+ {entries.map(([key, definition]) => ( +
+

{t(`productUsage.catalog.${key}.name`)}

+

{key} · {definition.since}

+

{t('productUsage.configuredLabel')}: {t(`productUsage.catalog.${key}.configured`)}

+

{definition.used + ? `${t('productUsage.usedLabel')}: ${t(`productUsage.catalog.${key}.used`)}` + : t('productUsage.configurationOnly')}

+
+ ))} + {!entries.length &&

{t('productUsage.catalogEmpty')}

} +
+
+ ); +} diff --git a/frontend/src/features/settings/__tests__/ProductUsageTab.test.tsx b/frontend/src/features/settings/__tests__/ProductUsageTab.test.tsx new file mode 100644 index 00000000..e86c2644 --- /dev/null +++ b/frontend/src/features/settings/__tests__/ProductUsageTab.test.tsx @@ -0,0 +1,319 @@ +import { + render, + screen, + fireEvent, + waitFor, + cleanup, + within +} from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest'; +import ProductUsageTab from '../tabs/ProductUsageTab'; +import { + productUsageService as service, + type UsageStatus +} from '../../../services/productUsage.service'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), + // The tab imports from the components/common barrel, which reaches + // ErrorBoundary -> i18n/config, and that calls .use(initReactI18next) at + // import time. Same shim as FaceRecognitionCard.sidecarHealth.test.tsx. + initReactI18next: { type: '3rdParty', init: () => {} } +})); +vi.mock('../../../components/common/ConfirmDialog', () => ({ + useConfirm: () => async () => true +})); +vi.mock('../../../services/productUsage.service', () => ({ + productUsageService: { + status: vi.fn(), + enable: vi.fn(), + upgradeConsent: vi.fn(), + disable: vi.fn(), + retry: vi.fn(), + abandon: vi.fn(), + preview: vi.fn(), + export: vi.fn(), + preferences: vi.fn(), + feedback: vi.fn(), + portalSession: vi.fn() + } +})); +const status: UsageStatus = { + status: 'disabled', + notice_dismissed: false, + installation_id: null, + collector_url: 'https://usage.picpeak.app', + schema_version: 'usage.v1', + last_report_date: null, + last_error: null, + pending_action: null, + last_packet: null, + feedback_preferences: { name: 'Remembered private name' } +}; +const mount = () => + render( + + + + ); +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(service.status).mockResolvedValue({ ...status }); + HTMLDialogElement.prototype.showModal = function () { + this.setAttribute('open', ''); + }; +}); +afterEach(cleanup); +it('shows every v2 signal locally before participation, without collector calls', async () => { + mount(); + await screen.findByText('productUsage.catalogTitle'); + expect(screen.getAllByRole('heading', { level: 4, hidden: true })).toHaveLength(73); + expect(service.enable).not.toHaveBeenCalled(); + expect(service.preview).not.toHaveBeenCalled(); + expect(service.upgradeConsent).not.toHaveBeenCalled(); +}); +it('existing v1 requires renewed unchecked consent; cancellation keeps v1 unchanged', async () => { + vi.mocked(service.status).mockResolvedValue({ ...status, status: 'active', consent_update_available: true }); + vi.mocked(service.upgradeConsent).mockResolvedValue({ delivered: false, queued: true, state: { ...status, status: 'active', pending_action: 'consent' } }); + mount(); + fireEvent.click(await screen.findByText('productUsage.reviewUpgrade')); + let dialog = within(screen.getByRole('dialog')); + expect(dialog.getByRole('button', { name: 'productUsage.upgrade' })).toBeDisabled(); + expect(dialog.getByRole('checkbox')).not.toBeChecked(); + expect(dialog.getByText('productUsage.versionDisclosure')).toBeInTheDocument(); + fireEvent.click(dialog.getByRole('button', { name: 'productUsage.cancel' })); + expect(service.upgradeConsent).not.toHaveBeenCalled(); + fireEvent.click(screen.getByText('productUsage.reviewUpgrade')); + dialog = within(screen.getByRole('dialog')); + fireEvent.click(dialog.getByRole('checkbox')); + fireEvent.click(dialog.getByRole('button', { name: 'productUsage.upgrade' })); + await waitFor(() => expect(service.upgradeConsent).toHaveBeenCalledTimes(1)); + expect(service.enable).not.toHaveBeenCalled(); + expect(await screen.findByText('productUsage.queued')).toBeInTheDocument(); +}); +it('pending v2 confirmation clearly keeps v1 and cannot queue another upgrade', async () => { + vi.mocked(service.status).mockResolvedValue({ ...status, status: 'active', consent_update_available: true, pending_action: 'consent' }); + mount(); + expect(await screen.findByText('productUsage.upgradePending')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'productUsage.reviewUpgrade' })).toBeDisabled(); + expect(service.upgradeConsent).not.toHaveBeenCalled(); +}); +describe('product usage controls', () => { + it('offers identity-free audit receipts after opt-out without restoring participation controls', async () => { + vi.mocked(service.status).mockResolvedValue({ + ...status, + privacy_receipts: { + last_deletion: { + receipt_version: 'local-audit.v1', + kind: 'deletion', + status: 'collector-confirmed' + } + } + }); + mount(); + expect( + await screen.findByRole('button', { name: 'productUsage.auditDownload' }) + ).toBeEnabled(); + expect( + screen.getByText('productUsage.auditDescription') + ).toBeInTheDocument(); + expect( + screen.queryByLabelText('productUsage.hash') + ).not.toBeInTheDocument(); + expect( + screen.queryByText('productUsage.feedbackTitle') + ).not.toBeInTheDocument(); + }); + it('requires the disclosure and an unchecked-by-default consent before enabling', async () => { + mount(); + fireEvent.click(await screen.findByText('productUsage.review')); + const enable = screen.getByRole('button', { name: 'productUsage.enable' }); + expect(enable).toBeDisabled(); + for (const key of [ + 'fields', + 'excluded', + 'transport', + 'visibility', + 'deletion', + 'feedbackDisclosure' + ]) + expect(screen.getByText(`productUsage.${key}`)).toBeInTheDocument(); + expect(service.enable).not.toHaveBeenCalled(); + expect(service.preview).not.toHaveBeenCalled(); + fireEvent.click(screen.getByLabelText('productUsage.consentCheck')); + fireEvent.click(enable); + await waitFor(() => expect(service.enable).toHaveBeenCalledTimes(1)); + }); + it('sends anonymous private feedback even when a remembered name exists', async () => { + vi.mocked(service.status).mockResolvedValue({ + ...status, + status: 'active', + installation_id: 'a'.repeat(64) + }); + vi.mocked(service.feedback).mockResolvedValue({ + delivered: true, + state: { ...status, status: 'active' } + }); + mount(); + await screen.findByText('productUsage.feedbackTitle'); + expect(screen.getByLabelText('productUsage.includeName')).not.toBeChecked(); + fireEvent.change(screen.getByLabelText('productUsage.subject'), { + target: { value: 'Feedback title' } + }); + fireEvent.change(screen.getByLabelText('productUsage.message'), { + target: { value: 'Useful details' } + }); + fireEvent.click( + screen.getByRole('button', { name: 'productUsage.sendFeedback' }) + ); + await waitFor(() => + expect(service.feedback).toHaveBeenCalledWith({ + kind: 'feedback', + title: 'Feedback title', + body: 'Useful details', + name: '', + allow_public: false, + allow_marketing: false + }) + ); + }); + it('requires a separate marketing choice and clears it when publication permission is removed', async () => { + vi.mocked(service.status).mockResolvedValue({ + ...status, + status: 'active' + }); + mount(); + fireEvent.change(await screen.findByLabelText('productUsage.kind'), { + target: { value: 'testimonial' } + }); + expect(screen.getByLabelText('productUsage.allowPublic')).not.toBeChecked(); + expect(screen.getByLabelText('productUsage.allowMarketing')).toBeDisabled(); + fireEvent.click(screen.getByLabelText('productUsage.allowPublic')); + fireEvent.click(screen.getByLabelText('productUsage.allowMarketing')); + expect(screen.getByLabelText('productUsage.allowMarketing')).toBeChecked(); + fireEvent.click(screen.getByLabelText('productUsage.allowPublic')); + expect( + screen.getByLabelText('productUsage.allowMarketing') + ).not.toBeChecked(); + }); + it('keeps deletion pending explicit and offers retry without rejoining or sending feedback', async () => { + vi.mocked(service.status).mockResolvedValue({ + ...status, + status: 'deletion_pending', + last_error: 'DELIVERY_FAILED' + }); + mount(); + await screen.findByText('productUsage.states.deletion_pending'); + expect(screen.queryByText('productUsage.review')).not.toBeInTheDocument(); + expect( + screen.queryByText('productUsage.feedbackTitle') + ).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'productUsage.retry' })); + await waitFor(() => expect(service.retry).toHaveBeenCalled()); + }); +}); + +describe('a withdrawal that can never be signed', () => { + const stuck: UsageStatus = { + ...status, + status: 'deletion_pending', + installation_id: 'a'.repeat(64), + schema_version: 'usage.v2', + last_error: 'SIGNING_KEY_UNREADABLE', + can_abandon: true + }; + + it('explains the dead end and offers the only remaining exit', async () => { + vi.mocked(service.status).mockResolvedValue(stuck); + vi.mocked(service.abandon).mockResolvedValue({ ...status }); + mount(); + + // The operator is told what happened before being offered the exit. + await screen.findByText('productUsage.signingKeyUnreadable'); + await screen.findByText('productUsage.abandonExplanation'); + fireEvent.click(await screen.findByText('productUsage.abandon')); + await waitFor(() => expect(service.abandon).toHaveBeenCalledTimes(1)); + }); + + it('does not offer it for a withdrawal that is merely undelivered', async () => { + vi.mocked(service.status).mockResolvedValue({ + ...stuck, + last_error: 'DELIVERY_FAILED', + can_abandon: false + }); + mount(); + await screen.findByText('productUsage.deliveryProblem'); + expect(screen.queryByText('productUsage.abandon')).toBeNull(); + }); +}); + +it('says the sender is waiting rather than leaving a bare error on screen', async () => { + vi.mocked(service.status).mockResolvedValue({ + ...status, + status: 'active', + schema_version: 'usage.v2', + installation_id: 'a'.repeat(64), + last_error: 'DELIVERY_FAILED', + retry_after: Date.now() + 600000 + }); + mount(); + await screen.findByText('productUsage.retryScheduled'); +}); + +it('marks a deletion receipt as belonging to an earlier participation', async () => { + const receipts = { last_deletion: { kind: 'deletion' } }; + vi.mocked(service.status).mockResolvedValue({ + ...status, + status: 'active', + schema_version: 'usage.v2', + installation_id: 'a'.repeat(64), + privacy_receipts: receipts + }); + mount(); + await screen.findByText('productUsage.auditPreviousParticipation'); + + cleanup(); + // Withdrawn: the same receipt now describes the participation just ended, + // so the qualifier would be wrong. + vi.mocked(service.status).mockResolvedValue({ ...status, privacy_receipts: receipts }); + mount(); + await screen.findByText('productUsage.auditTitle'); + expect(screen.queryByText('productUsage.auditPreviousParticipation')).toBeNull(); +}); + +it('returns focus to the control that opened the consent dialog', async () => { + mount(); + const trigger = await screen.findByText('productUsage.review'); + trigger.focus(); + expect(document.activeElement).toBe(trigger); + + fireEvent.click(trigger); + await screen.findByText('productUsage.consentTitle'); + fireEvent.click(screen.getByText('productUsage.cancel')); + + // Without the restore this lands on , dropping a keyboard user back + // to the top of the page (WCAG 2.4.3). + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByText('productUsage.review') + ) + ); +}); + +// A security property, not a nicety: the consent dialog is where an operator +// decides whether to open a connection at all, so it has to say which way that +// connection runs. UsageService makes exactly two outbound POSTs and reads +// nothing but the acknowledgement for the packet it just sent. +it('states in the consent dialog that the connection only runs outwards', async () => { + mount(); + fireEvent.click(await screen.findByText('productUsage.review')); + const dialog = await screen.findByText('productUsage.consentTitle'); + expect(dialog).toBeTruthy(); + await screen.findByText('productUsage.sectionOneWay'); + await screen.findByText('productUsage.oneWay'); +}); diff --git a/frontend/src/features/settings/tabs/ProductUsageTab.tsx b/frontend/src/features/settings/tabs/ProductUsageTab.tsx new file mode 100644 index 00000000..fc731ed6 --- /dev/null +++ b/frontend/src/features/settings/tabs/ProductUsageTab.tsx @@ -0,0 +1,680 @@ +import { useEffect, useRef, useState, type ComponentType } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { + productUsageService as service, + type ProductFeedback +} from '../../../services/productUsage.service'; +import { + ArrowUpFromLine, + Globe, + ListChecks, + MessageSquare, + Send, + ShieldOff, + Sparkles, + Trash2 +} from 'lucide-react'; +import { useConfirm } from '../../../components/common/ConfirmDialog'; +import { Button, Card } from '../../../components/common'; +import { UsageCatalog } from '../UsageCatalog'; + +/** + * Sections of the disclosure, in reading order. Each is a translated + * paragraph; the heading and icon give it a shape you can scan instead of + * seven identical blocks of prose. + */ +const DISCLOSURE: { + key: string; + heading: string; + Icon: ComponentType<{ className?: string }>; +}[] = [ + { key: 'fields', heading: 'sectionFields', Icon: ListChecks }, + { key: 'excluded', heading: 'sectionExcluded', Icon: ShieldOff }, + { key: 'transport', heading: 'sectionTransport', Icon: Send }, + // Directly after transport, because it is a property of the transport and + // the reason the transport is shaped this way: the connection only ever + // runs outwards, so this cannot become a way to push anything in. + { key: 'oneWay', heading: 'sectionOneWay', Icon: ArrowUpFromLine }, + { key: 'visibility', heading: 'sectionVisibility', Icon: Globe }, + { key: 'deletion', heading: 'sectionDeletion', Icon: Trash2 }, + { key: 'feedbackDisclosure', heading: 'sectionFeedback', Icon: MessageSquare } +]; + +// `.btn` is whitespace-nowrap and `.btn-md` a fixed 2.5rem tall — right for +// short labels, wrong for the sentence-length ones in this tab, which ran off +// the card at 390px and then, once allowed to wrap, out of the fixed height. +// h-auto lets the second line have somewhere to go; min-h keeps a one-line +// button the same size as every other button beside it. +const WRAPPING_BUTTON = 'max-w-full whitespace-normal text-left h-auto min-h-[2.5rem]'; + +function ConsentDialog({ + close, + enable, + busy, + collector, + upgrade = false +}: { + close: () => void; + enable: () => void; + busy: boolean; + collector: string; + upgrade?: boolean; +}) { + const { t } = useTranslation(); + const ref = useRef(null); + const [checked, setChecked] = useState(false); + useEffect(() => { + // React unmounts this on close rather than only closing it, so + // the focus restoration showModal() normally performs has nothing left to + // return to and focus drops to — a keyboard user is thrown back to + // the top of the page every time they cancel (WCAG 2.4.3). Remember the + // opener and put focus back by hand. + const opener = document.activeElement as HTMLElement | null; + ref.current?.showModal(); + // showModal() focuses the first focusable descendant, which is the scroll + // region below — so its focus ring was drawn for everyone the moment the + // dialog opened, and because the dialog clips its sides an inset ring + // reads as two coloured bars across the disclosure rather than a ring. + // Focusing the dialog puts the ring back where it belongs: only when + // someone deliberately tabs to the region. + ref.current?.focus(); + return () => { + if (opener?.isConnected) opener.focus(); + }; + }, []); + return ( + +
+ + + +
+ +

+ {t('productUsage.purpose')} +

+
+
+ + {/* A scrollable region is focusable, which is correct for keyboard use — + but unstyled it drew a default ring that made the disclosure look + like a textarea. Given a real label and ring so it reads as what it + is: a document you can scroll. */} +
+ {DISCLOSURE.map(({ key, heading, Icon }) => ( +
+

+ + {t(`productUsage.${heading}`)} +

+

+ {t(`productUsage.${key}`, { collector })} +

+
+ ))} +

{t('productUsage.versionDisclosure')}

+ + + +
+ +
+ +
+ + +
+
+
+ ); +} + +export default function ProductUsageTab() { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const confirm = useConfirm(); + const { data, isPending, isError } = useQuery({ + queryKey: ['productUsage'], + queryFn: service.status, + refetchInterval: 30000 + }); + const [consent, setConsent] = useState(false); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(''); + const [preview, setPreview] = useState(null); + const [portalUrl, setPortalUrl] = useState(null); + const [named, setNamed] = useState(false); + const [form, setForm] = useState({ + kind: 'feedback', + title: '', + body: '', + name: '', + allow_public: false, + allow_marketing: false + }); + const run = async (fn: () => Promise) => { + setBusy(true); + setMessage(''); + try { + await fn(); + } catch { + setMessage(t('productUsage.failed')); + } finally { + setBusy(false); + await queryClient.invalidateQueries({ queryKey: ['productUsage'] }); + } + }; + const download = (value: unknown, filename = 'picpeak-usage-packets.json') => { + const url = URL.createObjectURL( + new Blob([JSON.stringify(value, null, 2)], { type: 'application/json' }) + ); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + anchor.click(); + window.setTimeout(() => URL.revokeObjectURL(url), 1000); + }; + if (isPending) return

{t('productUsage.loading')}

; + if (isError || !data) return

{t('productUsage.failed')}

; + const active = data.status === 'active'; + return ( +
+

{t('productUsage.purpose')}

+ +

+ {t(`productUsage.states.${data.status}`)} +

+

{t(`productUsage.stateDetails.${data.status}`)}

+ {data.status !== 'disabled' &&

{t('productUsage.currentSchema', { schema: data.schema_version })}

} + {data.consent_update_available && ( +
+

{t('productUsage.upgradeExplanation')}

+ +
+ )} + {data.pending_action === 'consent' &&

{t('productUsage.upgradePending')}

} + {data.installation_id && ( + + )} + {data.last_report_date && ( +

{t('productUsage.lastReport', { date: data.last_report_date })}

+ )} + {data.collector_error === 'INVALID_COLLECTOR_URL' && ( +

+ {/* Shown alongside the real controls, not instead of them: with a + bad URL the operator still needs to read their status and + still needs to be able to withdraw. */} + {t('productUsage.invalidCollectorUrl')} +

+ )} + {data.last_error && ( +

+ {/* Retrying cannot fix an unreadable signing key, and neither can + disabling: without the original encryption material the + deletion request cannot be signed either. Telling the operator + to retry would send them in a circle. */} + {t( + data.last_error === 'SIGNING_KEY_UNREADABLE' + ? 'productUsage.signingKeyUnreadable' + : data.last_error === 'SCHEMA_NOT_ACCEPTED' + ? 'productUsage.schemaNotAccepted' + : 'productUsage.deliveryProblem' + )} +

+ )} + {data.retry_after && ( + // A paced install is waiting, not broken. Without this the tab shows + // a delivery error and an idle Retry button, and nothing says the + // sender is going to try again on its own. +

+ {t('productUsage.retryScheduled', { + time: new Date(data.retry_after).toLocaleTimeString() + })} +

+ )} + {data.can_abandon && ( + // The one dead end the operator cannot retry out of. Offered only + // here, and worded so nobody mistakes it for a confirmed deletion. +
+

+ {t( + data.abandon_never_registered + ? 'productUsage.abandonExplanationUnregistered' + : 'productUsage.abandonExplanation' + )} +

+ +
+ )} +
+ {data.status === 'disabled' ? ( + + ) : ( + <> + + + + )} + {data.collector_url && ( + + {t('productUsage.transparency')} + + )} +
+
+ + {data.privacy_receipts && + Object.keys(data.privacy_receipts).length > 0 && ( + +

+ {t('productUsage.auditTitle')} +

+

{t('productUsage.auditDescription')}

+ {/* The receipts outlive the participation they describe: rejoining + does not clear them, so an active install would otherwise show + a bare "deletion confirmed" next to its own live participation + and read as a contradiction. */} + {active && + Boolean( + data.privacy_receipts.last_deletion || + data.privacy_receipts.last_abandonment + ) && ( +

+ {t('productUsage.auditPreviousParticipation')} +

+ )} + +
+ )} + {active && ( + <> + +

+ {t('productUsage.inspect')} +

+ {/* `.btn` sets whitespace-nowrap, and these labels are long + sentences in both locales — at 390px two of them ran past the + card and their text was simply cut off. Allowed to wrap and + capped at the container width instead. */} +
+ + + + +
+ {portalUrl && ( + + {t('productUsage.openPortal')} + + )} + {preview !== null && ( +
+                {JSON.stringify(preview, null, 2)}
+              
+ )} +
+ +
{ + e.preventDefault(); + void run(async () => { + const result = await service.feedback({ + ...form, + name: named ? form.name : '' + }); + setMessage( + t( + result.delivered + ? 'productUsage.feedbackSent' + : result.queued + ? 'productUsage.queued' + : 'productUsage.failed' + ) + ); + // Every consent choice resets with the item it was made for. + // Leaving `named` checked meant the next submission carried + // the previous name automatically, which contradicts the + // per-item, anonymous-by-default promise the disclosure makes + // — the remembered name stays in preferences, but attaching + // it is a decision taken again each time. + setNamed(false); + setForm({ + ...form, + title: '', + body: '', + allow_public: false, + allow_marketing: false + }); + }); + }} + > +

+ {t('productUsage.feedbackTitle')} +

+

{t('productUsage.feedbackDisclosure')}

+ + +