feat: expand opt-in capability coverage with versioned consent
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
@@ -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'
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
};
|
||||
+1
-1
@@ -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).
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
module.exports = router;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
+60
-119
@@ -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 };
|
||||
|
||||
@@ -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.
|
||||
+26
-5
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 (
|
||||
<details className="rounded border border-theme p-3">
|
||||
<summary className="cursor-pointer font-semibold">{t('productUsage.catalogTitle')}</summary>
|
||||
<p className="my-3 text-sm">{t('productUsage.catalogExplanation')}</p>
|
||||
<label className="block text-sm">
|
||||
{t('productUsage.catalogSearch')}
|
||||
<input type="search" value={search} onChange={(e) => setSearch(e.target.value)}
|
||||
className="my-2 w-full rounded border border-theme bg-theme-surface p-2" />
|
||||
</label>
|
||||
<div className="max-h-96 space-y-3 overflow-y-auto" tabIndex={0}>
|
||||
{entries.map(([key, definition]) => (
|
||||
<section key={key} className="border-t border-theme pt-2">
|
||||
<h4 className="font-semibold">{t(`productUsage.catalog.${key}.name`)}</h4>
|
||||
<p className="text-xs"><code>{key}</code> · {definition.since}</p>
|
||||
<p className="text-sm">{t('productUsage.configuredLabel')}: {t(`productUsage.catalog.${key}.configured`)}</p>
|
||||
<p className="text-sm">{definition.used
|
||||
? `${t('productUsage.usedLabel')}: ${t(`productUsage.catalog.${key}.used`)}`
|
||||
: t('productUsage.configurationOnly')}</p>
|
||||
</section>
|
||||
))}
|
||||
{!entries.length && <p>{t('productUsage.catalogEmpty')}</p>}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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<HTMLDialogElement>(null);
|
||||
@@ -112,6 +115,8 @@ function ConsentDialog({
|
||||
</p>
|
||||
</section>
|
||||
))}
|
||||
<p className="text-sm">{t('productUsage.versionDisclosure')}</p>
|
||||
<UsageCatalog />
|
||||
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-1 pt-1 text-sm">
|
||||
<a
|
||||
@@ -148,7 +153,7 @@ function ConsentDialog({
|
||||
{t('productUsage.cancel')}
|
||||
</Button>
|
||||
<Button onClick={enable} disabled={!checked || busy}>
|
||||
{t('productUsage.enable')}
|
||||
{t(upgrade ? 'productUsage.upgrade' : 'productUsage.enable')}
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -212,6 +217,16 @@ export default function ProductUsageTab() {
|
||||
{t(`productUsage.states.${data.status}`)}
|
||||
</h3>
|
||||
<p>{t(`productUsage.stateDetails.${data.status}`)}</p>
|
||||
{data.status !== 'disabled' && <p>{t('productUsage.currentSchema', { schema: data.schema_version })}</p>}
|
||||
{data.consent_update_available && (
|
||||
<div className="rounded border border-theme p-3 space-y-2">
|
||||
<p>{t('productUsage.upgradeExplanation')}</p>
|
||||
<Button disabled={busy || Boolean(data.pending_action) || !data.collector_url} onClick={() => setConsent(true)}>
|
||||
{t('productUsage.reviewUpgrade')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{data.pending_action === 'consent' && <p role="status">{t('productUsage.upgradePending')}</p>}
|
||||
{data.installation_id && (
|
||||
<label className="block">
|
||||
{t('productUsage.hash')}
|
||||
@@ -300,6 +315,7 @@ export default function ProductUsageTab() {
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
<UsageCatalog />
|
||||
{data.privacy_receipts &&
|
||||
Object.keys(data.privacy_receipts).length > 0 && (
|
||||
<Card padding="md" className="space-y-4">
|
||||
@@ -549,12 +565,17 @@ export default function ProductUsageTab() {
|
||||
{message && <p role="status">{message}</p>}
|
||||
{consent && (
|
||||
<ConsentDialog
|
||||
upgrade={active}
|
||||
collector={data.collector_url ?? ''}
|
||||
busy={busy}
|
||||
close={() => setConsent(false)}
|
||||
enable={() =>
|
||||
run(async () => {
|
||||
await service.enable();
|
||||
if (active) {
|
||||
const result = await service.upgradeConsent();
|
||||
if (!result.delivered) setMessage(t('productUsage.queued'));
|
||||
setPreview(null);
|
||||
} else await service.enable();
|
||||
setConsent(false);
|
||||
})
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,369 @@
|
||||
{
|
||||
"productUsage": {
|
||||
"fields": "usage.v2-Berichte enthalten einen Installationsfingerabdruck, PicPeak-Version, UTC-Berichtstag und Erstellungszeit, Schema-/Signaturmetadaten, feste Galerie-Layouts und 73 fest definierte Funktionssignale: 56 Konfiguriert/Genutzt-Paare und 17 reine Konfigurationswerte. Der vollständige Katalog unten erklärt jedes Feld. Keine Aktionsanzahlen und keine Besucherbeobachtung.",
|
||||
"catalogTitle": "Vollständiger Katalog: alle 73 Funktionssignale (usage.v2)",
|
||||
"catalogExplanation": "Konfiguriert beschreibt technische Verfügbarkeit oder Konfiguration, nicht Beliebtheit. Integrierte Funktionen sind immer verfügbar. Genutzt ist ein einziges installationsweites Ja/Nein seit Zustimmung zum aktuellen Schema (v1: seit Teilnahme; v2: seit Teilnahme oder ausdrücklicher Erweiterung). Angenommene Aufträge gelten als gestartet, nicht zwingend abgeschlossen. Reine Konfigurationssignale haben kein Genutzt-Feld. Die Marker speichern weder Person noch Ereignis-ID, Aktionszeit oder Häufigkeit.",
|
||||
"catalogSearch": "Funktionsname oder Schlüssel suchen",
|
||||
"catalogEmpty": "Keine passenden Funktionen.",
|
||||
"configuredLabel": "Konfiguriert",
|
||||
"usedLabel": "Genutzt",
|
||||
"configurationOnly": "Nur Konfiguration — tatsächliche Nutzung wird nicht erfasst.",
|
||||
"versionDisclosure": "Diese Zustimmung gilt für usage.v2 / usage-consent.v2. Bestehende v1-Teilnehmer teilen weiterhin nur die bisherigen 19 Funktionen, bis sie der Erweiterung ausdrücklich zustimmen. Vertrauliche Identität und Rohberichtshistorie bleiben erhalten; der lokale Beobachtungszeitraum der Nutzungsmarker beginnt mit der Collector-Bestätigung neu. Pro UTC-Tag wird höchstens ein Bericht angenommen; der erste v2-Bericht kann daher am nächsten aktiven Tag erfolgen. Vor Bestätigung werden keine neuen lokalen Marker erfasst.",
|
||||
"currentSchema": "Aktuelles Berichtsschema: {{schema}}",
|
||||
"reviewUpgrade": "Erweiterten Umfang von usage.v2 prüfen",
|
||||
"upgrade": "usage.v2 ausdrücklich zustimmen",
|
||||
"upgradeExplanation": "Deine bestehende usage.v1-Teilnahme bleibt unverändert. Prüfe den vollständigen erweiterten Katalog, bevor du über das Upgrade entscheidest. Eine Ablehnung beendet deine bisherige Teilnahme nicht.",
|
||||
"upgradePending": "Die signierte Erweiterung der Zustimmung wartet auf Bestätigung. Es wird nur der bisherige v1-Umfang erfasst. Versuche es erneut, sobald der Collector erreichbar ist, oder deaktiviere die Teilnahme zum Stoppen und Löschen.",
|
||||
"catalog": {
|
||||
"crm": {
|
||||
"name": "Kundenverwaltung",
|
||||
"configured": "Der Funktionsschalter clients ist effektiv aktiviert; nur ein Wahrheitswert.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"crm_quotes": {
|
||||
"name": "Angebote",
|
||||
"configured": "Der Funktionsschalter quotes ist effektiv aktiviert; nur ein Wahrheitswert.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"crm_invoices": {
|
||||
"name": "Rechnungen",
|
||||
"configured": "Der Funktionsschalter bills ist effektiv aktiviert; nur ein Wahrheitswert.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"crm_contracts": {
|
||||
"name": "Verträge",
|
||||
"configured": "Der Funktionsschalter contracts ist effektiv aktiviert; nur ein Wahrheitswert.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"crm_projects": {
|
||||
"name": "Projekte",
|
||||
"configured": "Der Funktionsschalter projects ist effektiv aktiviert; nur ein Wahrheitswert.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"crm_calendar": {
|
||||
"name": "Admin-Kalender",
|
||||
"configured": "Der Funktionsschalter calendar ist effektiv aktiviert; nur ein Wahrheitswert.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"crm_hours": {
|
||||
"name": "Zeiterfassung",
|
||||
"configured": "Der Funktionsschalter hoursLogging ist effektiv aktiviert; nur ein Wahrheitswert.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"customer_portal": {
|
||||
"name": "Kundenportal",
|
||||
"configured": "Der Funktionsschalter customerPortal ist effektiv aktiviert; nur ein Wahrheitswert.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"accounting": {
|
||||
"name": "Buchhaltung",
|
||||
"configured": "Der Funktionsschalter accounting ist effektiv aktiviert; nur ein Wahrheitswert.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"workflows": {
|
||||
"name": "Workflows",
|
||||
"configured": "Der Funktionsschalter workflows ist effektiv aktiviert; nur ein Wahrheitswert.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"newsletters": {
|
||||
"name": "Newsletter",
|
||||
"configured": "Der Funktionsschalter newsletters ist effektiv aktiviert; nur ein Wahrheitswert.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"face_recognition": {
|
||||
"name": "Gesichtserkennung",
|
||||
"configured": "Der Funktionsschalter faces ist effektiv aktiviert; nur ein Wahrheitswert.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"custom_css": {
|
||||
"name": "Eigenes CSS",
|
||||
"configured": "Eigenes CSS ist global oder über Galerie/Theme/Vorlage eingerichtet; CSS-Inhalte werden nicht gesendet.",
|
||||
"used": "Angewendetes CSS nach Zustimmung festgestellt, ohne Besucher zu beobachten."
|
||||
},
|
||||
"oauth": {
|
||||
"name": "Admin-SSO",
|
||||
"configured": "Admin-OIDC ist aktiviert und die Anbieter-/Client-Konfiguration vorhanden; keine Anbieter- oder Zugangsdaten.",
|
||||
"used": "Erfolgreiche Admin-SSO-Anmeldung; keine Konto-, Anbieter- oder Sitzungsdetails."
|
||||
},
|
||||
"smtp": {
|
||||
"name": "SMTP-Versand",
|
||||
"configured": "Ein ausgehender SMTP-Host ist konfiguriert; keine Hosts, Konten, Adressen oder Zugangsdaten.",
|
||||
"used": "Erfolgreicher ausdrücklich ausgelöster Admin-SMTP-Test/-Versand; keine Empfänger oder Nachrichten."
|
||||
},
|
||||
"whatsapp": {
|
||||
"name": "WhatsApp-Integration",
|
||||
"configured": "Die WhatsApp-Funktion ist aktiviert und eine nutzbare Konfiguration vorhanden; keine Telefonnummer, Tokens oder Vorlagen.",
|
||||
"used": "Erfolgreicher Admin-Integrationstest; keine Empfänger, Nachrichten oder Zustellverläufe."
|
||||
},
|
||||
"backup": {
|
||||
"name": "Sicherungen",
|
||||
"configured": "Ein Voll- oder Datenbanksicherungsplan ist aktiviert; keine Zeitpläne, Pfade, Speichergrößen oder Sicherungsnamen.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"s3_storage": {
|
||||
"name": "S3-Speicher",
|
||||
"configured": "S3 ist für Medien oder Sicherungen konfiguriert; keine Buckets, Endpunkte, Zugangsdaten oder Objektschlüssel.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"share_mounts": {
|
||||
"name": "Externe Ordner",
|
||||
"configured": "Mindestens eine Galerie verwendet einen externen Ordner; nur Existenz, keine Ordnerpfade oder Galeriekennungen.",
|
||||
"used": "Ein Admin hat einen angenommenen Import aus einem externen Ordner ausgelöst; keine Pfade, Dateien oder Anzahlen."
|
||||
},
|
||||
"galleries": {
|
||||
"name": "Galerieverwaltung",
|
||||
"configured": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"photo_management": {
|
||||
"name": "Medienverwaltung",
|
||||
"configured": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"photo_exports": {
|
||||
"name": "Admin-Medienexport",
|
||||
"configured": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"photo_processing": {
|
||||
"name": "Medien-Wartungswerkzeuge",
|
||||
"configured": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"archive_management": {
|
||||
"name": "Galeriearchive",
|
||||
"configured": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"gallery_sharing": {
|
||||
"name": "Galeriefreigabe und QR",
|
||||
"configured": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"short_links": {
|
||||
"name": "Kurzlinks",
|
||||
"configured": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"gallery_categories": {
|
||||
"name": "Fotokategorien",
|
||||
"configured": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"event_types": {
|
||||
"name": "Ereignistypen und Vorlagen",
|
||||
"configured": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"slideshow": {
|
||||
"name": "Live-Diashow",
|
||||
"configured": "Der Funktionsschalter slideshow ist effektiv aktiviert; nur ein Wahrheitswert.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"transfers": {
|
||||
"name": "PicTransfer",
|
||||
"configured": "Der Funktionsschalter transfers ist effektiv aktiviert; nur ein Wahrheitswert.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"video_uploads": {
|
||||
"name": "Admin-Video-Uploads",
|
||||
"configured": "Videoformate sind in den globalen Upload-Einstellungen erlaubt; keine Metadaten hochgeladener Dateien.",
|
||||
"used": "Mindestens eine Admin-Videodatei wurde erfolgreich gespeichert/angenommen; keine Namen, Formate, Längen, Größen oder Verarbeitungs-/Besucherverläufe."
|
||||
},
|
||||
"camera_raw_uploads": {
|
||||
"name": "Admin-Kamera-RAW-Uploads",
|
||||
"configured": "Kamera-RAW (DNG) ist in den globalen Upload-Einstellungen erlaubt; keine Kameramodelle oder EXIF-Daten.",
|
||||
"used": "Mindestens ein Admin-Kamera-RAW-Upload wurde gespeichert/angenommen; nur das Capability-Bit, keine Dateinamen oder Metadaten."
|
||||
},
|
||||
"messaging": {
|
||||
"name": "Nachrichtenwerkzeuge",
|
||||
"configured": "Der Funktionsschalter messaging ist effektiv aktiviert; nur ein Wahrheitswert.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"incoming_mail": {
|
||||
"name": "IMAP-Empfang",
|
||||
"configured": "Eingehende E-Mails sind aktiviert und eine IMAP-Konfiguration vorhanden; keine Postfächer, Server, Ordner oder Zugangsdaten.",
|
||||
"used": "Erfolgreicher expliziter Admin-Verbindungstest oder nicht übersprungener manueller Abruf; kein Hintergrundempfang, keine Nachrichten, Anhänge oder Anzahlen."
|
||||
},
|
||||
"reminder_emails": {
|
||||
"name": "Automatische Ereigniserinnerungen",
|
||||
"configured": "Der Funktionsschalter reminderEmails ist effektiv aktiviert; nur ein Wahrheitswert."
|
||||
},
|
||||
"email_templates": {
|
||||
"name": "E-Mail-Vorlagen",
|
||||
"configured": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"email_webhook": {
|
||||
"name": "E-Mail-Webhook-Transport",
|
||||
"configured": "Beide E-Mail-Webhook-Einstellungen sind vorhanden; keine URL oder Geheimnisse.",
|
||||
"used": "Erfolgreicher ausdrücklich ausgelöster Admin-Versand/-Test über den Webhook-Transport; keine Empfänger, Nachrichten oder automatischen Zustellungen."
|
||||
},
|
||||
"accounting_incoming_invoices": {
|
||||
"name": "Eingangsrechnungen",
|
||||
"configured": "Der Funktionsschalter incomingInvoices ist effektiv aktiviert; nur ein Wahrheitswert.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"accounting_expenses": {
|
||||
"name": "Ausgaben",
|
||||
"configured": "Der Funktionsschalter expenses ist effektiv aktiviert; nur ein Wahrheitswert.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"accounting_tax_report": {
|
||||
"name": "Steuerberichte",
|
||||
"configured": "Der Funktionsschalter taxReport ist effektiv aktiviert; nur ein Wahrheitswert.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"accounting_ledger": {
|
||||
"name": "Kontenplan und Buchhaltungsexport",
|
||||
"configured": "Der Funktionsschalter accounting ist effektiv aktiviert; nur ein Wahrheitswert.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"crm_installments": {
|
||||
"name": "Ratenplan-Werkzeuge",
|
||||
"configured": "Angebote oder Rechnungen sind aktiviert; tatsächliche Ratenpläne, Beträge oder Zahlungsstatus werden nicht geprüft.",
|
||||
"used": "Ein Admin hat einen Ratenplan gespeichert; keine Termine, Beträge, Währungen, Zahlungsstatus oder Dokumentkennungen."
|
||||
},
|
||||
"document_templates": {
|
||||
"name": "Dokumentvorlagen und Bausteine",
|
||||
"configured": "Angebote oder Verträge sind aktiviert und stellen Dokumentvorlagen/-bausteine bereit; keine Vorlageninhalte.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"cms": {
|
||||
"name": "CMS-Seiten",
|
||||
"configured": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"public_site": {
|
||||
"name": "Öffentliche Startseite",
|
||||
"configured": "Die Einstellung für die öffentliche Startseite ist aktiviert; keine HTML-Inhalte, Texte, Domains oder Besucher."
|
||||
},
|
||||
"branding": {
|
||||
"name": "Branding-Einstellungen",
|
||||
"configured": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"seo_customization": {
|
||||
"name": "SEO-Einstellungen",
|
||||
"configured": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"admin_management": {
|
||||
"name": "Admin- und Rollenverwaltung",
|
||||
"configured": "Der Funktionsschalter userManagement ist effektiv aktiviert; nur ein Wahrheitswert.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"api_integration": {
|
||||
"name": "HTTP-API-Integration",
|
||||
"configured": "Ein nicht widerrufener und nicht abgelaufener API-Zugang existiert; keine Tokens, Namen, Berechtigungswerte oder Inhaberdaten.",
|
||||
"used": "Erfolgreicher authentifizierter HTTP-API-Funktionsaufruf; nur dieses Bit, niemals URLs, Requestwerte, Token-/Inhaberkennungen oder Aufrufzahlen. Löst keinen Report aus."
|
||||
},
|
||||
"webhooks": {
|
||||
"name": "Ausgehende Webhooks",
|
||||
"configured": "Mindestens ein aktiver Webhook ist konfiguriert; keine Ziele, Abonnements, Geheimnisse oder Zustellprotokolle.",
|
||||
"used": "Erfolgreicher expliziter Admin-Webhook-Test/-Replay; keine automatischen oder durch Besucher ausgelösten Zustellungen."
|
||||
},
|
||||
"restore": {
|
||||
"name": "Wiederherstellung",
|
||||
"configured": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"portable_backup": {
|
||||
"name": "Portabler PicPeak-Export/Import",
|
||||
"configured": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"database_backup": {
|
||||
"name": "Datenbanksicherungen",
|
||||
"configured": "Geplante Datenbanksicherungen sind aktiviert; keine Zeitpläne, Dateinamen oder Datenbankinhalte.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"s3_photo_storage": {
|
||||
"name": "S3-Medienspeicher",
|
||||
"configured": "S3 ist als Medienspeicher konfiguriert und erforderliche Zugangsdaten sind vorhanden; keine Werte werden gesendet.",
|
||||
"used": "Erfolgreiche Admin-Medienspeicherung/angenommener Upload nach S3; keine Buckets, Objekte oder Größen."
|
||||
},
|
||||
"s3_backups": {
|
||||
"name": "S3-Sicherungsziel",
|
||||
"configured": "Das konfigurierte Sicherungsziel ist S3 und ein Bucket ist angegeben; kein Bucketname oder Zugangsdaten.",
|
||||
"used": "Ein Admin hat eine Sicherung zum konfigurierten S3-Ziel oder einen erfolgreichen S3-Testupload gestartet; lokale Exporte implizieren keine S3-Nutzung."
|
||||
},
|
||||
"analytics_dashboard": {
|
||||
"name": "Bestehendes Analytics-Modul",
|
||||
"configured": "Der Funktionsschalter analytics ist effektiv aktiviert; nur ein Wahrheitswert.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"feedback_moderation": {
|
||||
"name": "Feedback-Moderation",
|
||||
"configured": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"guest_management": {
|
||||
"name": "Gastverwaltungswerkzeuge",
|
||||
"configured": "Integrierte Funktion ist verfügbar; dies ist kein Nutzungsnachweis.",
|
||||
"used": "Eine dokumentierte erfolgreiche authentifizierte Admin-Funktionsoperation wurde seit Zustimmung zu diesem Schema beobachtet. Keine Person, Operationshistorie, Parameter oder Anzahlen."
|
||||
},
|
||||
"gallery_feedback_likes": {
|
||||
"name": "Galerie-Likes aktiviert",
|
||||
"configured": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen."
|
||||
},
|
||||
"gallery_feedback_ratings": {
|
||||
"name": "Galerie-Sternebewertungen aktiviert",
|
||||
"configured": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen."
|
||||
},
|
||||
"gallery_feedback_comments": {
|
||||
"name": "Galerie-Kommentare aktiviert",
|
||||
"configured": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen."
|
||||
},
|
||||
"gallery_feedback_favorites": {
|
||||
"name": "Galerie-Favoriten aktiviert",
|
||||
"configured": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen."
|
||||
},
|
||||
"gallery_feedback_reactions": {
|
||||
"name": "Galerie-Reaktionen aktiviert",
|
||||
"configured": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen."
|
||||
},
|
||||
"gallery_feedback_color_labels": {
|
||||
"name": "Galerie-Farblabels aktiviert",
|
||||
"configured": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen."
|
||||
},
|
||||
"gallery_guest_accounts": {
|
||||
"name": "Gastidentitäten aktiviert",
|
||||
"configured": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen."
|
||||
},
|
||||
"gallery_guest_uploads": {
|
||||
"name": "Gast-Uploads aktiviert",
|
||||
"configured": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen."
|
||||
},
|
||||
"gallery_downloads": {
|
||||
"name": "Galerie-Downloads erlaubt",
|
||||
"configured": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen."
|
||||
},
|
||||
"download_resolution_picker": {
|
||||
"name": "Download-Auflösungswahl aktiviert",
|
||||
"configured": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen."
|
||||
},
|
||||
"gallery_client_access": {
|
||||
"name": "Client-Zugang aktiviert",
|
||||
"configured": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen."
|
||||
},
|
||||
"gallery_watermarks": {
|
||||
"name": "Wasserzeichen aktiviert",
|
||||
"configured": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen."
|
||||
},
|
||||
"gallery_image_protection": {
|
||||
"name": "Bildschutz aktiviert",
|
||||
"configured": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen."
|
||||
},
|
||||
"gallery_reveal": {
|
||||
"name": "Galerie-Enthüllung aktiviert",
|
||||
"configured": "In der betreffenden Galerie-/Globalkonfiguration aktiviert; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen."
|
||||
},
|
||||
"gallery_expiration": {
|
||||
"name": "Galerieablauf konfiguriert",
|
||||
"configured": "Mindestens eine Galerie hat einen Ablauf konfiguriert; keine Daten, Galeriekennungen oder Anzahlen."
|
||||
}
|
||||
},
|
||||
"auditTitle": "Export- und Löschquittungen",
|
||||
"auditDescription": "Lade deine privaten Nachweise herunter. PicPeak speichert nur die letzte Exportquittung während der Teilnahme und die letzte Löschbestätigung. Sie enthalten keinen Installationshash, Schlüssel oder Bericht-/Feedbackinhalt. Opt-out entfernt die lokale Exportquittung; die Löschbestätigung ohne Identitätsbezug bleibt erhalten. Der Collector führt keinen Export- oder Zugriffsverlauf.",
|
||||
"auditDownload": "Datenschutzquittungen herunterladen",
|
||||
@@ -20,7 +384,6 @@
|
||||
"sectionVisibility": "Wo er sichtbar ist",
|
||||
"sectionDeletion": "Beenden und löschen",
|
||||
"sectionFeedback": "Feedback ist getrennt",
|
||||
"fields": "Berichte enthalten einen Installationsfingerabdruck, PicPeak-Version, Berichtstag, Schema- und Signaturmetadaten, Galerie-Layouts sowie Konfiguriert/Genutzt-Werte für CRM und Unterfunktionen, Buchhaltung, Workflows, Newsletter, Gesichtserkennung, eigenes CSS, OAuth, SMTP, WhatsApp, Backups, S3 und eingebundene Freigaben. „Genutzt“ bedeutet seit der Teilnahme beobachtet, nicht wie häufig.",
|
||||
"excluded": "Automatische Berichte enthalten keine Galeriebesucher, Klickverläufe, Foto- oder Galerieanzahlen, Namen, E-Mail-Adressen, Domains, Dateinamen oder Zugangsdaten.",
|
||||
"transport": "Dein PicPeak-Backend verwahrt den Signaturschlüssel und sendet einmal pro UTC-Tag bei Admin-Nutzung signierte Nutzungsberichte an {{collector}}. Du kannst Berichte vorab ansehen und jeden eindeutig angenommenen Bericht genau wie beim ersten Empfang herunterladen; Übertragungswiederholungen werden zusammengeführt. Abgelehnte Versuche und getrennt gesendetes Feedback gehören nicht zu diesem Berichtsexport.",
|
||||
"visibility": "Nur teilnehmende Installationen können den Funktionsdatensatz und aggregierte Ergebnisse einsehen, auch Gruppen mit nur einer Installation. Schema und Quellcode sind öffentlich; geprüfte Funktionswünsche und Empfehlungen werden nur mit Erlaubnis ihrer Verfasser veröffentlicht. Dein Fingerabdruck ist pseudonym, nicht anonym. Bewahre deinen Abfrage-Hash vertraulich auf: Er ermöglicht lesenden Zugriff auf deine eigenen Berichte und den Teilnehmerdatensatz.",
|
||||
|
||||
@@ -1,5 +1,369 @@
|
||||
{
|
||||
"productUsage": {
|
||||
"fields": "usage.v2 reports contain an installation fingerprint, PicPeak version, UTC report date and generation time, schema/signing metadata, controlled gallery layouts, and 73 fixed capability signals: 56 configured/used pairs and 17 configuration-only booleans. The complete catalog below defines every field. There are no action counts or visitor observations.",
|
||||
"catalogTitle": "Full catalog: all 73 capability signals (usage.v2)",
|
||||
"catalogExplanation": "Configured describes current technical availability or configuration, not popularity. Built-in capabilities are always available. Used is a single installation-wide yes/no bit since consent to the current schema (v1: since joining; v2: since joining or explicit upgrade). Accepted jobs count as initiated, not necessarily finished. Configuration-only capabilities have no used field. No actor, event identifier, action time or frequency is stored in these markers.",
|
||||
"catalogSearch": "Search capability name or key",
|
||||
"catalogEmpty": "No matching capabilities.",
|
||||
"configuredLabel": "Configured",
|
||||
"usedLabel": "Used",
|
||||
"configurationOnly": "Configuration only — actual use is not collected.",
|
||||
"versionDisclosure": "This consent covers usage.v2 / usage-consent.v2. Existing v1 participants continue sharing only the previous 19 capabilities unless they explicitly upgrade. The same private identity and raw history remain; the local usage-marker observation period restarts when the collector confirms the upgrade. At most one report per UTC day is accepted, so the first v2 report can be on the next active day. New local markers are not recorded before confirmation.",
|
||||
"currentSchema": "Current reporting schema: {{schema}}",
|
||||
"reviewUpgrade": "Review expanded usage.v2 scope",
|
||||
"upgrade": "Explicitly agree to usage.v2",
|
||||
"upgradeExplanation": "Your existing usage.v1 participation is unchanged. Review the complete expanded catalog before deciding whether to upgrade. Declining does not end your current participation.",
|
||||
"upgradePending": "The signed consent upgrade is pending confirmation. Only the existing v1 scope is collected. Retry when the collector is available, or disable participation to stop and delete.",
|
||||
"catalog": {
|
||||
"crm": {
|
||||
"name": "Client management",
|
||||
"configured": "The clients capability switch is effectively enabled; only a boolean.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"crm_quotes": {
|
||||
"name": "Quotes",
|
||||
"configured": "The quotes capability switch is effectively enabled; only a boolean.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"crm_invoices": {
|
||||
"name": "Invoices",
|
||||
"configured": "The bills capability switch is effectively enabled; only a boolean.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"crm_contracts": {
|
||||
"name": "Contracts",
|
||||
"configured": "The contracts capability switch is effectively enabled; only a boolean.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"crm_projects": {
|
||||
"name": "Projects",
|
||||
"configured": "The projects capability switch is effectively enabled; only a boolean.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"crm_calendar": {
|
||||
"name": "Admin calendar",
|
||||
"configured": "The calendar capability switch is effectively enabled; only a boolean.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"crm_hours": {
|
||||
"name": "Hours logging",
|
||||
"configured": "The hoursLogging capability switch is effectively enabled; only a boolean.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"customer_portal": {
|
||||
"name": "Customer portal",
|
||||
"configured": "The customerPortal capability switch is effectively enabled; only a boolean.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"accounting": {
|
||||
"name": "Accounting",
|
||||
"configured": "The accounting capability switch is effectively enabled; only a boolean.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"workflows": {
|
||||
"name": "Workflows",
|
||||
"configured": "The workflows capability switch is effectively enabled; only a boolean.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"newsletters": {
|
||||
"name": "Newsletters",
|
||||
"configured": "The newsletters capability switch is effectively enabled; only a boolean.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"face_recognition": {
|
||||
"name": "Face recognition",
|
||||
"configured": "The faces capability switch is effectively enabled; only a boolean.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"custom_css": {
|
||||
"name": "Custom CSS",
|
||||
"configured": "Custom CSS is configured globally or applied through a gallery/theme/template; CSS text is not sent.",
|
||||
"used": "Applied CSS observed after consent, without observing visitors."
|
||||
},
|
||||
"oauth": {
|
||||
"name": "Admin SSO",
|
||||
"configured": "Admin OIDC is enabled and issuer/client configuration is present; no provider or credential values.",
|
||||
"used": "Successful admin SSO login; no account, identity-provider or session details."
|
||||
},
|
||||
"smtp": {
|
||||
"name": "SMTP delivery",
|
||||
"configured": "An outgoing SMTP host is configured; no host, account, address or credentials.",
|
||||
"used": "A successful explicitly initiated admin SMTP test/send; no recipients or messages."
|
||||
},
|
||||
"whatsapp": {
|
||||
"name": "WhatsApp integration",
|
||||
"configured": "The WhatsApp capability is enabled and a usable configuration is present; no phone number, token or template.",
|
||||
"used": "Successful admin integration test; no recipient, message or delivery history."
|
||||
},
|
||||
"backup": {
|
||||
"name": "Backups",
|
||||
"configured": "A full or database backup schedule is enabled; no schedule, path, storage sizes or backup names.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"s3_storage": {
|
||||
"name": "S3 storage",
|
||||
"configured": "S3 is configured for media or backups; no bucket, endpoint, credentials or object keys.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"share_mounts": {
|
||||
"name": "External folders",
|
||||
"configured": "At least one gallery uses an external folder; only existence, no folder paths or gallery identifiers.",
|
||||
"used": "An admin initiated an accepted external-folder import; no scanned paths, files or counts."
|
||||
},
|
||||
"galleries": {
|
||||
"name": "Gallery management",
|
||||
"configured": "Built-in capability is available; this is not evidence of use.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"photo_management": {
|
||||
"name": "Media management",
|
||||
"configured": "Built-in capability is available; this is not evidence of use.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"photo_exports": {
|
||||
"name": "Admin media export",
|
||||
"configured": "Built-in capability is available; this is not evidence of use.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"photo_processing": {
|
||||
"name": "Media maintenance tools",
|
||||
"configured": "Built-in capability is available; this is not evidence of use.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"archive_management": {
|
||||
"name": "Gallery archives",
|
||||
"configured": "Built-in capability is available; this is not evidence of use.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"gallery_sharing": {
|
||||
"name": "Gallery sharing and QR",
|
||||
"configured": "Built-in capability is available; this is not evidence of use.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"short_links": {
|
||||
"name": "Short links",
|
||||
"configured": "Built-in capability is available; this is not evidence of use.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"gallery_categories": {
|
||||
"name": "Photo categories",
|
||||
"configured": "Built-in capability is available; this is not evidence of use.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"event_types": {
|
||||
"name": "Event types and presets",
|
||||
"configured": "Built-in capability is available; this is not evidence of use.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"slideshow": {
|
||||
"name": "Live slideshow",
|
||||
"configured": "The slideshow capability switch is effectively enabled; only a boolean.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"transfers": {
|
||||
"name": "PicTransfer",
|
||||
"configured": "The transfers capability switch is effectively enabled; only a boolean.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"video_uploads": {
|
||||
"name": "Admin video uploads",
|
||||
"configured": "Video extensions are allowed in global upload settings; no uploaded-file metadata.",
|
||||
"used": "At least one admin video file was successfully stored/accepted; no names, formats, lengths, sizes or processing/visitor history."
|
||||
},
|
||||
"camera_raw_uploads": {
|
||||
"name": "Admin camera RAW uploads",
|
||||
"configured": "Camera RAW (DNG) is allowed in global upload settings; no camera models or EXIF.",
|
||||
"used": "At least one admin camera RAW upload was stored/accepted; only the capability bit, no filename or metadata."
|
||||
},
|
||||
"messaging": {
|
||||
"name": "Messaging tools",
|
||||
"configured": "The messaging capability switch is effectively enabled; only a boolean.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"incoming_mail": {
|
||||
"name": "IMAP intake",
|
||||
"configured": "Incoming mail is enabled and an IMAP configuration is present; no mailbox, server, folders or credentials.",
|
||||
"used": "A successful explicit admin connection test or non-skipped manual poll; no background intake, messages, attachments or counts."
|
||||
},
|
||||
"reminder_emails": {
|
||||
"name": "Automatic event reminders",
|
||||
"configured": "The reminderEmails capability switch is effectively enabled; only a boolean."
|
||||
},
|
||||
"email_templates": {
|
||||
"name": "Email templates",
|
||||
"configured": "Built-in capability is available; this is not evidence of use.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"email_webhook": {
|
||||
"name": "Email webhook transport",
|
||||
"configured": "Both email webhook settings are present; no URL or secret.",
|
||||
"used": "Successful explicitly initiated admin send/test through the webhook transport; no recipients, messages or automatic deliveries."
|
||||
},
|
||||
"accounting_incoming_invoices": {
|
||||
"name": "Incoming invoices",
|
||||
"configured": "The incomingInvoices capability switch is effectively enabled; only a boolean.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"accounting_expenses": {
|
||||
"name": "Expenses",
|
||||
"configured": "The expenses capability switch is effectively enabled; only a boolean.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"accounting_tax_report": {
|
||||
"name": "Tax reports",
|
||||
"configured": "The taxReport capability switch is effectively enabled; only a boolean.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"accounting_ledger": {
|
||||
"name": "Ledger and accounting export",
|
||||
"configured": "The accounting capability switch is effectively enabled; only a boolean.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"crm_installments": {
|
||||
"name": "Installment-plan tools",
|
||||
"configured": "Quotes or invoices are enabled; no actual payment plans, amounts or statuses are inspected.",
|
||||
"used": "An admin saved an installment plan; no dates, amounts, currencies, payment status or document IDs."
|
||||
},
|
||||
"document_templates": {
|
||||
"name": "Document presets and blocks",
|
||||
"configured": "Quotes or contracts are enabled, making document presets/blocks available; no template content.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"cms": {
|
||||
"name": "CMS pages",
|
||||
"configured": "Built-in capability is available; this is not evidence of use.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"public_site": {
|
||||
"name": "Public landing page",
|
||||
"configured": "The public landing-page setting is enabled; no page HTML, texts, domains or visitors."
|
||||
},
|
||||
"branding": {
|
||||
"name": "Branding settings",
|
||||
"configured": "Built-in capability is available; this is not evidence of use.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"seo_customization": {
|
||||
"name": "SEO settings",
|
||||
"configured": "Built-in capability is available; this is not evidence of use.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"admin_management": {
|
||||
"name": "Admin and role management",
|
||||
"configured": "The userManagement capability switch is effectively enabled; only a boolean.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"api_integration": {
|
||||
"name": "HTTP API integration",
|
||||
"configured": "An unrevoked, unexpired API credential exists; no tokens, names, scopes or owner data.",
|
||||
"used": "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": {
|
||||
"name": "Outbound webhooks",
|
||||
"configured": "At least one active webhook is configured; no destinations, subscriptions, secrets or delivery logs.",
|
||||
"used": "Successful explicit admin webhook test/replay; no automatic or visitor-triggered deliveries."
|
||||
},
|
||||
"restore": {
|
||||
"name": "Restore",
|
||||
"configured": "Built-in capability is available; this is not evidence of use.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"portable_backup": {
|
||||
"name": "Portable PicPeak export/import",
|
||||
"configured": "Built-in capability is available; this is not evidence of use.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"database_backup": {
|
||||
"name": "Database backups",
|
||||
"configured": "Scheduled database backups are enabled; no schedules, file names or database contents.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"s3_photo_storage": {
|
||||
"name": "S3 media storage",
|
||||
"configured": "S3 is the configured media backend and required credentials are present; no values are sent.",
|
||||
"used": "Successful admin media storage/accepted upload to S3; no buckets, objects or sizes."
|
||||
},
|
||||
"s3_backups": {
|
||||
"name": "S3 backup destination",
|
||||
"configured": "The configured backup destination is S3 with a bucket present; no bucket or credentials.",
|
||||
"used": "An admin started a backup to the configured S3 destination or a successful S3 test upload; local exports never imply S3 use."
|
||||
},
|
||||
"analytics_dashboard": {
|
||||
"name": "Existing analytics module",
|
||||
"configured": "The analytics capability switch is effectively enabled; only a boolean.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"feedback_moderation": {
|
||||
"name": "Feedback moderation",
|
||||
"configured": "Built-in capability is available; this is not evidence of use.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"guest_management": {
|
||||
"name": "Guest administration tools",
|
||||
"configured": "Built-in capability is available; this is not evidence of use.",
|
||||
"used": "A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts."
|
||||
},
|
||||
"gallery_feedback_likes": {
|
||||
"name": "Gallery likes enabled",
|
||||
"configured": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts."
|
||||
},
|
||||
"gallery_feedback_ratings": {
|
||||
"name": "Gallery star ratings enabled",
|
||||
"configured": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts."
|
||||
},
|
||||
"gallery_feedback_comments": {
|
||||
"name": "Gallery comments enabled",
|
||||
"configured": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts."
|
||||
},
|
||||
"gallery_feedback_favorites": {
|
||||
"name": "Gallery favorites enabled",
|
||||
"configured": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts."
|
||||
},
|
||||
"gallery_feedback_reactions": {
|
||||
"name": "Gallery reactions enabled",
|
||||
"configured": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts."
|
||||
},
|
||||
"gallery_feedback_color_labels": {
|
||||
"name": "Gallery color labels enabled",
|
||||
"configured": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts."
|
||||
},
|
||||
"gallery_guest_accounts": {
|
||||
"name": "Guest identities enabled",
|
||||
"configured": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts."
|
||||
},
|
||||
"gallery_guest_uploads": {
|
||||
"name": "Guest uploads enabled",
|
||||
"configured": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts."
|
||||
},
|
||||
"gallery_downloads": {
|
||||
"name": "Gallery downloads allowed",
|
||||
"configured": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts."
|
||||
},
|
||||
"download_resolution_picker": {
|
||||
"name": "Download resolution picker enabled",
|
||||
"configured": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts."
|
||||
},
|
||||
"gallery_client_access": {
|
||||
"name": "Client access enabled",
|
||||
"configured": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts."
|
||||
},
|
||||
"gallery_watermarks": {
|
||||
"name": "Watermarks enabled",
|
||||
"configured": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts."
|
||||
},
|
||||
"gallery_image_protection": {
|
||||
"name": "Image protection enabled",
|
||||
"configured": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts."
|
||||
},
|
||||
"gallery_reveal": {
|
||||
"name": "Gallery reveal enabled",
|
||||
"configured": "Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts."
|
||||
},
|
||||
"gallery_expiration": {
|
||||
"name": "Gallery expiration configured",
|
||||
"configured": "At least one gallery has an expiry configured; no dates, gallery IDs or counts."
|
||||
}
|
||||
},
|
||||
"auditTitle": "Export and deletion receipts",
|
||||
"auditDescription": "Download your private audit receipts. PicPeak keeps only the latest export receipt during participation and the latest deletion confirmation. These contain no installation hash, key or report/feedback content. Opt-out removes the local export receipt; the identity-free deletion confirmation remains. The collector does not keep an export/access history.",
|
||||
"auditDownload": "Download privacy receipts",
|
||||
@@ -20,7 +384,6 @@
|
||||
"sectionVisibility": "Where it is visible",
|
||||
"sectionDeletion": "Leaving and deleting",
|
||||
"sectionFeedback": "Feedback is separate",
|
||||
"fields": "Reports contain an installation fingerprint, PicPeak version, report day, schema and signing metadata, gallery layout choices, and configured/used booleans for CRM and its subfeatures, accounting, workflows, newsletters, face recognition, custom CSS, OAuth, SMTP, WhatsApp, backups, S3, and share mounts. “Used” means observed since joining, not how often.",
|
||||
"excluded": "No gallery visitors, clickstreams, photo or gallery counts, names, emails, domains, filenames, or configuration secrets are included in automatic usage reports.",
|
||||
"transport": "Your PicPeak backend keeps the signing key and sends signed usage reports to {{collector}} once per UTC day during admin use. Preview reports and download each unique accepted report exactly as first received; transport retries are deduplicated. Rejected attempts and separately submitted feedback are not part of this report export.",
|
||||
"visibility": "Only participating installations can inspect the feature dataset and aggregate results, including groups of one. The schema and source are public; approved feature requests and testimonials are public only with their authors’ permission. Your fingerprint is pseudonymous, not anonymous. Keep your lookup hash private: it grants read-only access to your own reports and the participant dataset.",
|
||||
|
||||
@@ -12,6 +12,9 @@ export interface UsageStatus {
|
||||
collector_url: string | null;
|
||||
collector_error?: 'INVALID_COLLECTOR_URL' | null;
|
||||
schema_version: string;
|
||||
available_schema_version?: string;
|
||||
consent_version?: string;
|
||||
consent_update_available?: boolean;
|
||||
last_report_date: string | null;
|
||||
last_error: string | null;
|
||||
pending_action: string | null;
|
||||
@@ -40,10 +43,13 @@ export const productUsageService = {
|
||||
async enable(): Promise<UsageStatus> {
|
||||
return (
|
||||
await api.post('/admin/usage/enable', {
|
||||
consent_version: 'usage-consent.v1'
|
||||
consent_version: 'usage-consent.v2'
|
||||
})
|
||||
).data;
|
||||
},
|
||||
async upgradeConsent(): Promise<{ delivered: boolean; queued: boolean; state: UsageStatus }> {
|
||||
return (await api.post('/admin/usage/consent', { consent_version: 'usage-consent.v2' })).data;
|
||||
},
|
||||
async disable(): Promise<UsageStatus> {
|
||||
return (await api.post('/admin/usage/disable')).data;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user