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 && (