From a7382591bfd73c841ea91fe821e2ab739c2990d0 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sun, 6 Sep 2026 00:56:58 +0200 Subject: [PATCH] feat: expand opt-in capability coverage with versioned consent --- .../integration/productUsagePg.test.js | 23 + backend/__tests__/routes/adminUsage.test.js | 33 +- .../services/usageCoverageInventory.test.js | 111 ++ .../services/usageEnableDisableRace.test.js | 6 +- .../services/usageServiceKeyRotation.test.js | 1 + .../services/usageSnapshotSignals.test.js | 109 + .../core/205_product_usage_consent_version.js | 20 + backend/server.js | 2 +- backend/src/middleware/productUsage.js | 22 +- backend/src/routes/adminBackup.js | 2 + backend/src/routes/adminEmail.js | 15 +- backend/src/routes/adminPhotos.js | 13 + backend/src/routes/adminUsage.js | 8 + backend/src/routes/adminWhatsapp.js | 1 + backend/src/services/emailProcessor.js | 2 +- backend/src/usage/UsageService.js | 75 +- backend/src/usage/capabilityEvidence.js | 19 + backend/src/usage/capabilityRules.js | 79 + backend/src/usage/expandedSnapshot.js | 109 + backend/src/usage/features.v2.json | 1291 ++++++++++++ backend/src/usage/protocol.cjs | 16 +- backend/src/usage/schema.cjs | 179 +- docs/FEATURE_COVERAGE.md | 361 ++++ docs/PRODUCT_USAGE.md | 31 +- docs/usage-coverage.v2.json | 1757 +++++++++++++++++ .../src/features/settings/UsageCatalog.tsx | 35 + .../__tests__/ProductUsageTab.test.tsx | 38 +- .../settings/tabs/ProductUsageTab.tsx | 27 +- .../features/settings/usageFeatures.v2.json | 1291 ++++++++++++ frontend/src/i18n/locales/de.json | 365 +++- frontend/src/i18n/locales/en.json | 365 +++- frontend/src/services/productUsage.service.ts | 8 +- 32 files changed, 6250 insertions(+), 164 deletions(-) create mode 100644 backend/__tests__/services/usageCoverageInventory.test.js create mode 100644 backend/migrations/core/205_product_usage_consent_version.js create mode 100644 backend/src/usage/capabilityEvidence.js create mode 100644 backend/src/usage/capabilityRules.js create mode 100644 backend/src/usage/expandedSnapshot.js create mode 100644 backend/src/usage/features.v2.json create mode 100644 docs/FEATURE_COVERAGE.md create mode 100644 docs/usage-coverage.v2.json create mode 100644 frontend/src/features/settings/UsageCatalog.tsx create mode 100644 frontend/src/features/settings/usageFeatures.v2.json diff --git a/backend/__tests__/integration/productUsagePg.test.js b/backend/__tests__/integration/productUsagePg.test.js index 83c736a1..aed5fb06 100644 --- a/backend/__tests__/integration/productUsagePg.test.js +++ b/backend/__tests__/integration/productUsagePg.test.js @@ -51,6 +51,7 @@ maybe('product usage on Postgres', () => { 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 db.schema.createTable('app_settings', (t) => { t.string('setting_key').primary(); t.text('setting_value'); t.string('setting_type'); @@ -112,6 +113,7 @@ maybe('product usage on Postgres', () => { expect(cols.cancel_requested).toBeUndefined(); // dropped by 203 expect(cols.sequence).toBeDefined(); expect(cols.privacy_receipts).toBeDefined(); + expect(cols.consent_version).toBeDefined(); }); it('reruns the receipt migration safely and scrubs legacy plaintext sessions', async () => { @@ -125,6 +127,27 @@ maybe('product usage on Postgres', () => { 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(); diff --git a/backend/__tests__/routes/adminUsage.test.js b/backend/__tests__/routes/adminUsage.test.js index 187f4d4b..9ea9a8e4 100644 --- a/backend/__tests__/routes/adminUsage.test.js +++ b/backend/__tests__/routes/adminUsage.test.js @@ -41,6 +41,7 @@ jest.mock('../../src/services/productUsageService', () => ); 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, { @@ -102,10 +103,27 @@ beforeAll(async () => { 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', '/retry'], ['post', '/dismiss'], @@ -162,8 +180,9 @@ test('public/gallery paths and failed/unauthenticated admin operations never set const { EventEmitter } = require('events'); const simulate = (path, admin, statusCode) => { const res = new EventEmitter(); + res.locals = {}; res.statusCode = statusCode; - productUsage({ path, admin }, res, () => {}); + productUsage({ path, method: 'POST', admin }, res, () => {}); res.emit('finish'); }; simulate('/gallery/example', null, 200); @@ -180,6 +199,15 @@ test('public/gallery paths and failed/unauthenticated admin operations never set 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. @@ -187,8 +215,9 @@ test('only a backup that writes to the configured destination flags S3', () => { const simulate = (pathname) => { service.markUsed.mockClear(); const res = new (require('events').EventEmitter)(); + res.locals = {}; res.statusCode = 200; - productUsage({ path: pathname, admin: { id: 1 } }, res, () => {}); + 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]); }; 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 index 9e8cb06e..4612dc2c 100644 --- a/backend/__tests__/services/usageEnableDisableRace.test.js +++ b/backend/__tests__/services/usageEnableDisableRace.test.js @@ -19,7 +19,7 @@ const SECRET = 'z'.repeat(48); // during signing, so the packet would never reach the collector for reasons // unrelated to what the test is checking. function validReport() { - const { FEATURE_KEYS } = require('../../src/usage/protocol.cjs'); + const { LEGACY_FEATURE_KEYS: FEATURE_KEYS } = require('../../src/usage/protocol.cjs'); return { picpeak_version: '3.0.0', report_date: '2026-09-05', @@ -40,6 +40,7 @@ async function bootDb() { 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); @@ -173,7 +174,8 @@ describe('withdrawal during an in-flight activation', () => { { installation_id: identity.installation_id }, 'report', 2, - validReport() + validReport(), + 'usage.v1' ) ), }); diff --git a/backend/__tests__/services/usageServiceKeyRotation.test.js b/backend/__tests__/services/usageServiceKeyRotation.test.js index 0b52248d..938ef6e9 100644 --- a/backend/__tests__/services/usageServiceKeyRotation.test.js +++ b/backend/__tests__/services/usageServiceKeyRotation.test.js @@ -23,6 +23,7 @@ async function bootDb() { 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); diff --git a/backend/__tests__/services/usageSnapshotSignals.test.js b/backend/__tests__/services/usageSnapshotSignals.test.js index 850edfe3..5b395466 100644 --- a/backend/__tests__/services/usageSnapshotSignals.test.js +++ b/backend/__tests__/services/usageSnapshotSignals.test.js @@ -10,6 +10,7 @@ */ 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({ @@ -20,6 +21,7 @@ async function bootDb() { 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); @@ -193,3 +195,110 @@ describe('S3 use is only implied by backups that write to the destination', () = expect(await db('product_usage_markers').pluck('feature')).toEqual(['backup']); }); }); + +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/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/server.js b/backend/server.js index 5e3df127..33c3aa7a 100644 --- a/backend/server.js +++ b/backend/server.js @@ -935,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 index 853a74e5..cbd4844e 100644 --- a/backend/src/middleware/productUsage.js +++ b/backend/src/middleware/productUsage.js @@ -3,6 +3,7 @@ // 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 = () => @@ -59,13 +60,28 @@ function productUsage(req, res, next) { /^\/(?:photos|events)\/[^/]+\/upload(?:\/|$)/.test(pathname) ) features.push('s3_storage'); - if (features.length) + const expanded = [...new Set([ + ...capabilityKeys(req.method, pathname), + ...(res.locals.productUsageFeatures || []) + ])]; + if (features.length || expanded.length) service - .markUsed(features, { + .markUsed(expanded, { + legacyFeatures: features, destinationBackup: DESTINATION_BACKUP.test(pathname) }) .catch(() => logger.warn('Product usage marker could not be recorded')); }); next(); } -module.exports = { productUsage, RULES, DESTINATION_BACKUP }; +// 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 index 9506dddc..44af6c5d 100644 --- a/backend/src/routes/adminUsage.js +++ b/backend/src/routes/adminUsage.js @@ -54,6 +54,14 @@ router.post( 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())) 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/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/usage/UsageService.js b/backend/src/usage/UsageService.js index c02ce6d9..9b5618da 100644 --- a/backend/src/usage/UsageService.js +++ b/backend/src/usage/UsageService.js @@ -17,6 +17,12 @@ const { digest, canonical, FEATURE_KEYS, + LEGACY_FEATURE_KEYS, + CATALOG, + CURRENT_SCHEMA_VERSION, + CURRENT_CONSENT_VERSION, + featureKeysFor, + observesUse, LAYOUTS } = require('./protocol.cjs'); @@ -100,6 +106,10 @@ const parse = (value) => { }; class UsageService { + 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; @@ -232,7 +242,10 @@ class UsageService { installation_id: state.installation_id, collector_url: collectorUrl, collector_error: collectorError, - schema_version: 'usage.v1', + 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, pending_action: state.pending_packet @@ -272,7 +285,7 @@ class UsageService { return this.status(); } async enable(consent) { - if (consent !== 'usage-consent.v1') + 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 @@ -293,7 +306,7 @@ class UsageService { 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 @@ -308,6 +321,7 @@ class UsageService { .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, @@ -513,7 +527,18 @@ class UsageService { } else { ack.whereNot({ status: 'deletion_pending' }); } - await ack.update(update); + 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 }); @@ -563,7 +588,7 @@ class UsageService { await this.locked(async (state) => { if (state.status === 'disabled') return; if (state.status === 'deletion_pending') { - const packet = makePacket(state, 'delete', Number(state.sequence), {}); + 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 }) @@ -582,12 +607,13 @@ class UsageService { new Date(this.now()).toISOString().slice(0, 10) ) return; - const payload = await this.snapshot(); + const payload = await this.snapshot(this.schemaVersion(state)); const packet = makePacket( state, 'report', Number(state.sequence) + 1, - payload + payload, + this.schemaVersion(state) ); state.pending_packet = JSON.stringify(packet); // Only while still active. /disable clears pending_packet and moves the @@ -604,8 +630,8 @@ class UsageService { return this.status(); } - async markUsed(features, { destinationBackup = false } = {}) { - const allowed = [...new Set(features)].filter((f) => + async markUsed(features, { destinationBackup = false, legacyFeatures } = {}) { + let allowed = [...new Set([...features, ...(legacyFeatures || [])])].filter((f) => FEATURE_KEYS.includes(f) ); if (!allowed.length) return; @@ -615,6 +641,10 @@ class UsageService { 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, @@ -624,17 +654,20 @@ class UsageService { const destination = await tx('app_settings') .where({ setting_key: 'backup_destination_type' }) .first(); - if (destination && parse(destination.setting_value) === 's3') + 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(allowed.map((feature) => ({ feature }))) + .insert([...new Set(allowed)].map((feature) => ({ feature }))) .onConflict('feature') .ignore(); }); } - async snapshot() { + async snapshot(version) { + version = version || this.schemaVersion(await this.state()); const rows = await this.db('app_settings') .whereIn('setting_key', SETTING_KEYS) .select('setting_key', 'setting_value'); @@ -642,7 +675,9 @@ class UsageService { rows.map((r) => [r.setting_key, parse(r.setting_value)]) ); const flagRows = await this.db('feature_flags') - .whereIn('key', Object.values(FLAG_MAP)) + .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)]) @@ -651,7 +686,7 @@ class UsageService { await this.db('product_usage_markers').pluck('feature') ); const features = Object.fromEntries( - FEATURE_KEYS.map((key) => [ + LEGACY_FEATURE_KEYS.map((key) => [ key, { configured: Boolean(flags[FLAG_MAP[key]]), used: used.has(key) } ]) @@ -746,11 +781,14 @@ class UsageService { 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, + features: expanded, gallery_layouts: [...layouts].sort() }; } @@ -768,13 +806,16 @@ class UsageService { 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'].includes(action)) + 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 + payload, + action === 'consent' ? CURRENT_SCHEMA_VERSION : this.schemaVersion(state) ); // Validate the complete packet before storing an un-sendable operation. verifyEnvelope( 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..dea7c7ea --- /dev/null +++ b/backend/src/usage/expandedSnapshot.js @@ -0,0 +1,109 @@ +'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'); + result.gallery_image_protection.configured = ['standard', 'enhanced', 'maximum'].includes(settings.default_protection_level) || + truth(settings.enable_devtools_protection) || truth(settings.enable_canvas_rendering); + for (const column of ['disable_right_click', 'enable_devtools_protection', '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', ['standard', '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..a7e08eed --- /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 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_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 index 1547cea6..8ea362a6 100644 --- a/backend/src/usage/protocol.cjs +++ b/backend/src/usage/protocol.cjs @@ -3,13 +3,18 @@ const crypto = require("node:crypto"); const Ajv = require("ajv"); const { envelopeSchema, + envelopeSchemas, + CURRENT_SCHEMA_VERSION, FEATURE_KEYS, LAYOUTS, payloads, } = require("./schema.cjs"); -const validate = new Ajv({ allErrors: false, strict: true }).compile( - envelopeSchema, -); +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; @@ -56,9 +61,9 @@ function generateIdentity() { private_key: keys.privateKey.export({ format: "pem", type: "pkcs8" }), }; } -function makePacket(identity, action, sequence, payload) { +function makePacket(identity, action, sequence, payload, schemaVersion = CURRENT_SCHEMA_VERSION) { return { - schema_version: "usage.v1", + schema_version: schemaVersion, installation_id: identity.installation_id, packet_id: crypto.randomUUID(), action, @@ -139,6 +144,7 @@ function verifyEnvelope(envelope, now = Date.now()) { return envelope.packet; } module.exports = { + ...require("./schema.cjs"), canonical, digest, generateIdentity, diff --git a/backend/src/usage/schema.cjs b/backend/src/usage/schema.cjs index 3459a371..cd57ba70 100644 --- a/backend/src/usage/schema.cjs +++ b/backend/src/usage/schema.cjs @@ -1,138 +1,79 @@ "use strict"; -// Vendored unchanged in PicPeak. Changing the wire contract requires a new -// schema version and matching conformance tests in both repositories. -const 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 LAYOUTS = [ - "grid", - "masonry", - "carousel", - "timeline", - "mosaic", - "gallery-premium", - "gallery-story", - "other", +// 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, + 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 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 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 features = object( - Object.fromEntries( - FEATURE_KEYS.map((key) => [ - key, - object({ configured: boolean, used: 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 = object({ - picpeak_version: { - type: "string", - maxLength: 48, - pattern: "^\\d+\\.\\d+\\.\\d+(?:-(?:alpha|beta|rc)\\.\\d+)?$", - }, +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, - gallery_layouts: { - type: "array", - uniqueItems: true, - maxItems: LAYOUTS.length, - items: { enum: LAYOUTS }, - }, + 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, + 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 payloads = { - register: object({ consent_version: { const: "usage-consent.v1" } }), - report, - delete: object({}), - feedback, +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({}), -}; -const packetBase = { - schema_version: { const: "usage.v1" }, - installation_id: hash, - packet_id: uuid, - sequence: { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, -}; -const packetSchema = { - oneOf: Object.entries(payloads).map(([action, payload]) => - object({ - ...packetBase, - action: { const: action }, - payload, - }), - ), -}; -const envelopeSchema = { + ...(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/usage.v1.json", - title: "PicPeak usage.v1 signed envelope", - description: - "Only report.payload is automatic feature telemetry. Other actions are explicit participant operations. See /transparency for field semantics and retention.", + $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: packetSchema, - 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_-]+$", - }, + 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, }; -module.exports = { FEATURE_KEYS, LAYOUTS, envelopeSchema, payloads }; diff --git a/docs/FEATURE_COVERAGE.md b/docs/FEATURE_COVERAGE.md new file mode 100644 index 00000000..5ca0694d --- /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 in applicable gallery/global configuration; 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 index 99a9ea19..d60036fa 100644 --- a/docs/PRODUCT_USAGE.md +++ b/docs/PRODUCT_USAGE.md @@ -24,7 +24,7 @@ tracker or CORS policy is required. The collector is the separate | `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-204 on PostgreSQL and SQLite alike, and the engine-sensitive +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` @@ -51,6 +51,25 @@ table, not the generic readable settings. A random mode-0600 file at ## 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 @@ -93,8 +112,8 @@ Public voting uses a backend-authorized 15-minute session, never the lookup hash ## Contract -The closed schema is in `backend/src/usage/schema.cjs`, with signing in -`protocol.cjs`. Keep both byte-identical to the collector's `protocol/` copies. +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 @@ -112,10 +131,12 @@ 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 -joining, not visitor behavior or counts. OAuth marks successful admin SSO; +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` and `usage/UsageService.js`. +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 diff --git a/docs/usage-coverage.v2.json b/docs/usage-coverage.v2.json new file mode 100644 index 00000000..f7aa0264 --- /dev/null +++ b/docs/usage-coverage.v2.json @@ -0,0 +1,1757 @@ +{ + "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 and 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 /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/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 index feb62912..37439c25 100644 --- a/frontend/src/features/settings/__tests__/ProductUsageTab.test.tsx +++ b/frontend/src/features/settings/__tests__/ProductUsageTab.test.tsx @@ -3,7 +3,8 @@ import { screen, fireEvent, waitFor, - cleanup + cleanup, + within } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest'; @@ -27,6 +28,7 @@ vi.mock('../../../services/productUsage.service', () => ({ productUsageService: { status: vi.fn(), enable: vi.fn(), + upgradeConsent: vi.fn(), disable: vi.fn(), retry: vi.fn(), preview: vi.fn(), @@ -66,6 +68,40 @@ beforeEach(() => { }; }); 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({ diff --git a/frontend/src/features/settings/tabs/ProductUsageTab.tsx b/frontend/src/features/settings/tabs/ProductUsageTab.tsx index e37e560b..d91f6351 100644 --- a/frontend/src/features/settings/tabs/ProductUsageTab.tsx +++ b/frontend/src/features/settings/tabs/ProductUsageTab.tsx @@ -16,6 +16,7 @@ import { } 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 @@ -39,12 +40,14 @@ function ConsentDialog({ close, enable, busy, - collector + collector, + upgrade = false }: { close: () => void; enable: () => void; busy: boolean; collector: string; + upgrade?: boolean; }) { const { t } = useTranslation(); const ref = useRef(null); @@ -112,6 +115,8 @@ function ConsentDialog({

))} +

{t('productUsage.versionDisclosure')}

+
@@ -212,6 +217,16 @@ export default function ProductUsageTab() { {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 && (