Merge pull request #1304 from PicPeak/codex/1110-product-usage
feat: add opt-in product usage and feedback (#1110)
This commit is contained in:
@@ -336,3 +336,9 @@ LOGS=./logs
|
|||||||
# Do NOT rely on FRONTEND_API_URL in Compose. Instead, keep VITE_API_URL=/api and
|
# Do NOT rely on FRONTEND_API_URL in Compose. Instead, keep VITE_API_URL=/api and
|
||||||
# let the frontend Nginx proxy /api to the backend. Only if you rebuild the frontend
|
# let the frontend Nginx proxy /api to the backend. Only if you rebuild the frontend
|
||||||
# should you change VITE_API_URL at build time.
|
# should you change VITE_API_URL at build time.
|
||||||
|
|
||||||
|
# Optional product usage (#1110): disabled until explicit in-app consent.
|
||||||
|
# USAGE_COLLECTOR_URL=https://usage.picpeak.app
|
||||||
|
# Backend signing-key encryption (32+ characters); defaults to JWT_SECRET.
|
||||||
|
# Keep this value stable until participation has been deleted.
|
||||||
|
# USAGE_ENCRYPTION_KEY=
|
||||||
|
|||||||
@@ -120,3 +120,9 @@ ARCHIVE_PATH=/app/storage/events/archived
|
|||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Optional product usage (#1110): disabled until explicit in-app consent.
|
||||||
|
# USAGE_COLLECTOR_URL=https://usage.picpeak.app
|
||||||
|
# Encryption material for the backend-only signing key (32+ characters).
|
||||||
|
# Defaults to JWT_SECRET; keep it stable until participation has been deleted.
|
||||||
|
# USAGE_ENCRYPTION_KEY=
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
/**
|
||||||
|
* PostgreSQL checks for product usage (#1110).
|
||||||
|
*
|
||||||
|
* Gated: runs only when PICPEAK_PG_TEST_URL points at a throwaway database, e.g.
|
||||||
|
* PICPEAK_PG_TEST_URL="postgres://picpeak:[email protected]:7102/picpeak_usage_pg_test" \
|
||||||
|
* npx jest __tests__/integration/productUsagePg.test.js
|
||||||
|
*
|
||||||
|
* What SQLite cannot answer:
|
||||||
|
* - `cancel_seq` and `sequence` are bigint, and node-postgres returns bigint
|
||||||
|
* as a STRING. The withdrawal guard compares that value, so a `'1' !== 1`
|
||||||
|
* slip would let an activation complete after an opt-out — and SQLite,
|
||||||
|
* which hands back a number, would never show it.
|
||||||
|
* - booleans are real booleans here, not 0/1, which is what every
|
||||||
|
* `configured` signal in a report is built from.
|
||||||
|
* - markUsed takes SELECT ... FOR UPDATE on this engine only.
|
||||||
|
*/
|
||||||
|
const knex = require('knex');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const { generateIdentity, makePacket } = require('../../src/usage/protocol.cjs');
|
||||||
|
|
||||||
|
const PG_URL = process.env.PICPEAK_PG_TEST_URL;
|
||||||
|
const maybe = PG_URL ? describe : describe.skip;
|
||||||
|
|
||||||
|
maybe('product usage on Postgres', () => {
|
||||||
|
let db;
|
||||||
|
let UsageService;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Its own schema, not `public`. CI hands every gated suite the same
|
||||||
|
// PICPEAK_PG_TEST_URL and runs jest with parallel workers, and both
|
||||||
|
// picpeakRestorePg and externalRelpathFoldPg drop and recreate `events`
|
||||||
|
// and `app_settings` there. Sharing that would have made all three
|
||||||
|
// intermittently destroy each other's fixtures. The service queries
|
||||||
|
// unqualified table names, so a searchPath keeps it entirely in here.
|
||||||
|
const bootstrap = knex({
|
||||||
|
client: 'pg', connection: PG_URL, pool: { min: 0, max: 2 }
|
||||||
|
});
|
||||||
|
await bootstrap.raw('DROP SCHEMA IF EXISTS usage_pg_test CASCADE');
|
||||||
|
await bootstrap.raw('CREATE SCHEMA usage_pg_test');
|
||||||
|
await bootstrap.destroy();
|
||||||
|
|
||||||
|
db = knex({
|
||||||
|
client: 'pg',
|
||||||
|
connection: PG_URL,
|
||||||
|
searchPath: ['usage_pg_test'],
|
||||||
|
pool: { min: 0, max: 10 }
|
||||||
|
});
|
||||||
|
// The real migrations, on the real engine.
|
||||||
|
await require('../../migrations/core/201_product_usage').up(db);
|
||||||
|
await require('../../migrations/core/202_product_usage_cancel_requested').up(db);
|
||||||
|
await require('../../migrations/core/203_product_usage_cancel_seq').up(db);
|
||||||
|
await require('../../migrations/core/204_product_usage_privacy_receipts').up(db);
|
||||||
|
await require('../../migrations/core/205_product_usage_consent_version').up(db);
|
||||||
|
await require('../../migrations/core/206_product_usage_delivery_backoff').up(db);
|
||||||
|
|
||||||
|
await db.schema.createTable('app_settings', (t) => {
|
||||||
|
t.string('setting_key').primary(); t.text('setting_value'); t.string('setting_type');
|
||||||
|
});
|
||||||
|
await db.schema.createTable('feature_flags', (t) => {
|
||||||
|
t.string('key').primary(); t.boolean('value');
|
||||||
|
});
|
||||||
|
await db.schema.createTable('events', (t) => {
|
||||||
|
t.increments('id'); t.text('color_theme'); t.string('external_path'); t.integer('css_template_id');
|
||||||
|
});
|
||||||
|
await db.schema.createTable('css_templates', (t) => {
|
||||||
|
t.increments('id'); t.boolean('is_enabled'); t.text('css_content');
|
||||||
|
});
|
||||||
|
for (const table of ['email_configs', 'mail_accounts']) {
|
||||||
|
await db.schema.createTable(table, (t) => { t.increments('id'); t.string('smtp_host'); });
|
||||||
|
}
|
||||||
|
await db.schema.createTable('whatsapp_configs', (t) => {
|
||||||
|
t.increments('id'); t.boolean('enabled'); t.string('phone_number_id'); t.string('access_token');
|
||||||
|
});
|
||||||
|
|
||||||
|
({ UsageService } = require('../../src/usage/UsageService'));
|
||||||
|
}, 120000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (db) {
|
||||||
|
await db.raw('DROP SCHEMA IF EXISTS usage_pg_test CASCADE');
|
||||||
|
await db.destroy();
|
||||||
|
}
|
||||||
|
fs.rmSync(bindingDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await db('product_usage_markers').delete();
|
||||||
|
await db('product_usage_state').delete();
|
||||||
|
await db('product_usage_state').insert({ id: 1 });
|
||||||
|
await db('events').delete();
|
||||||
|
await db('css_templates').delete();
|
||||||
|
await db('feature_flags').delete();
|
||||||
|
await db('app_settings').delete();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The instance-binding file defaults to STORAGE_PATH, which is '/storage'
|
||||||
|
// in a bare test process. Point it at a temp dir so the real binding code
|
||||||
|
// runs rather than being stubbed out.
|
||||||
|
const bindingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-usage-pg-'));
|
||||||
|
|
||||||
|
const service = (over = {}) =>
|
||||||
|
new UsageService(db, {
|
||||||
|
secret: 'p'.repeat(48),
|
||||||
|
endpoint: 'http://127.0.0.1:9/',
|
||||||
|
bindingPath: path.join(bindingDir, 'usage-instance.key'),
|
||||||
|
fetch: async () => { throw new Error('collector unreachable in tests'); },
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates the columns with the types the code expects', async () => {
|
||||||
|
const cols = await db('product_usage_state').columnInfo();
|
||||||
|
expect(cols.cancel_seq).toBeDefined();
|
||||||
|
expect(cols.cancel_requested).toBeUndefined(); // dropped by 203
|
||||||
|
expect(cols.sequence).toBeDefined();
|
||||||
|
expect(cols.privacy_receipts).toBeDefined();
|
||||||
|
expect(cols.consent_version).toBeDefined();
|
||||||
|
// next_attempt_at is a bigint like sequence and cancel_seq, so pg hands it
|
||||||
|
// back as a STRING — the tick() gate compares it against a number.
|
||||||
|
expect(cols.attempts).toBeDefined();
|
||||||
|
expect(cols.next_attempt_at).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reruns the backoff migration safely', async () => {
|
||||||
|
const migration = require('../../migrations/core/206_product_usage_delivery_backoff');
|
||||||
|
await migration.up(db);
|
||||||
|
await migration.up(db);
|
||||||
|
const row = await db('product_usage_state').where({ id: 1 }).first();
|
||||||
|
expect(Number(row.attempts)).toBe(0);
|
||||||
|
expect(Number(row.next_attempt_at)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours the retry gate even though pg returns next_attempt_at as a string', async () => {
|
||||||
|
let clock = 5_000_000;
|
||||||
|
let calls = 0;
|
||||||
|
const identity = generateIdentity();
|
||||||
|
const client = service({
|
||||||
|
now: () => clock,
|
||||||
|
fetch: async () => { calls += 1; throw new Error('collector unreachable'); },
|
||||||
|
});
|
||||||
|
await db('product_usage_state').where({ id: 1 }).update({
|
||||||
|
status: 'active',
|
||||||
|
consent_version: 'usage-consent.v2',
|
||||||
|
installation_id: identity.installation_id,
|
||||||
|
public_key: identity.public_key,
|
||||||
|
private_key_encrypted: client.encrypt(identity.private_key),
|
||||||
|
sequence: 1,
|
||||||
|
attempts: 0,
|
||||||
|
next_attempt_at: 0,
|
||||||
|
pending_packet: JSON.stringify(makePacket(identity, 'session', 2, {}, 'usage.v2')),
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.tick();
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
const paced = await db('product_usage_state').where({ id: 1 }).first();
|
||||||
|
// A '5000120000' > 5000000 string comparison would be a different answer.
|
||||||
|
expect(typeof paced.next_attempt_at).toBe('string');
|
||||||
|
await client.tick();
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
|
||||||
|
clock = Number(paced.next_attempt_at) + 1;
|
||||||
|
await client.tick();
|
||||||
|
expect(calls).toBe(2);
|
||||||
|
|
||||||
|
await db('product_usage_state').where({ id: 1 }).update({
|
||||||
|
status: 'disabled', pending_packet: null, attempts: 0, next_attempt_at: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reruns the receipt migration safely and scrubs legacy plaintext sessions', async () => {
|
||||||
|
const migration = require('../../migrations/core/204_product_usage_privacy_receipts');
|
||||||
|
await db('product_usage_state').where({ id: 1 }).update({
|
||||||
|
last_receipt: JSON.stringify({ status: 'accepted', session_token: 'synthetic-old-token' })
|
||||||
|
});
|
||||||
|
await migration.up(db);
|
||||||
|
await migration.up(db);
|
||||||
|
const row = await db('product_usage_state').where({ id: 1 }).first();
|
||||||
|
expect(JSON.parse(row.last_receipt)).toEqual({ status: 'accepted' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('migration preserves v1 consent and v2 snapshot works with PostgreSQL booleans and optional modules', async () => {
|
||||||
|
const migration = require('../../migrations/core/205_product_usage_consent_version');
|
||||||
|
await migration.up(db); await migration.up(db);
|
||||||
|
const svc = service();
|
||||||
|
await db('product_usage_state').where({ id: 1 }).update({ status: 'active' });
|
||||||
|
await svc.markUsed(['video_uploads']);
|
||||||
|
expect(await db('product_usage_markers').pluck('feature')).toEqual([]);
|
||||||
|
expect((await svc.status()).schema_version).toBe('usage.v1');
|
||||||
|
await db('product_usage_state').where({ id: 1 }).update({ consent_version: 'usage-consent.v2' });
|
||||||
|
await db('feature_flags').insert({ key: 'quotes', value: true });
|
||||||
|
await db('app_settings').insert({ setting_key: 'general_allowed_file_types', setting_value: '"dng,mp4"' });
|
||||||
|
await svc.markUsed(['video_uploads', 'gallery_downloads']);
|
||||||
|
const report = await svc.snapshot();
|
||||||
|
expect(Object.keys(report.features)).toHaveLength(73);
|
||||||
|
expect(report.features.video_uploads).toEqual({ configured: true, used: true });
|
||||||
|
expect(report.features.camera_raw_uploads).toEqual({ configured: true, used: false });
|
||||||
|
expect(report.features.gallery_downloads).toEqual({ configured: false });
|
||||||
|
expect(report.features.crm.configured).toBe(true);
|
||||||
|
expect(report.features.api_integration.configured).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads bigint cancel_seq correctly even though pg returns it as a string', async () => {
|
||||||
|
await db('product_usage_state').where({ id: 1 }).update({ cancel_seq: 5 });
|
||||||
|
const row = await db('product_usage_state').where({ id: 1 }).first();
|
||||||
|
// The thing SQLite hides: this is a string here.
|
||||||
|
expect(typeof row.cancel_seq).toBe('string');
|
||||||
|
expect(Number(row.cancel_seq)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours a withdrawal that lands while an activation is starting', async () => {
|
||||||
|
const svc = service();
|
||||||
|
const realBinding = svc.binding.bind(svc);
|
||||||
|
svc.binding = async (create = false) => {
|
||||||
|
// The withdrawal lands inside the window where the row still reads
|
||||||
|
// `disabled`, with the real binding write still happening.
|
||||||
|
if (create) await svc.disable();
|
||||||
|
return realBinding(create);
|
||||||
|
};
|
||||||
|
await svc.enable('usage-consent.v1');
|
||||||
|
|
||||||
|
const row = await db('product_usage_state').where({ id: 1 }).first();
|
||||||
|
expect(row.status).toBe('disabled');
|
||||||
|
expect(row.installation_id).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('activates when no withdrawal arrives', async () => {
|
||||||
|
await service().enable('usage-consent.v1');
|
||||||
|
const row = await db('product_usage_state').where({ id: 1 }).first();
|
||||||
|
expect(row.status).toBe('activation_pending');
|
||||||
|
expect(row.installation_id).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records markers only while active, using SELECT ... FOR UPDATE', async () => {
|
||||||
|
const svc = service();
|
||||||
|
await svc.markUsed(['crm']);
|
||||||
|
expect(await db('product_usage_markers').count('* as c').first()).toMatchObject({ c: '0' });
|
||||||
|
|
||||||
|
await db('product_usage_state').where({ id: 1 }).update({ status: 'active' });
|
||||||
|
await svc.markUsed(['crm', 'newsletters']);
|
||||||
|
const rows = await db('product_usage_markers').pluck('feature');
|
||||||
|
expect(rows.sort()).toEqual(['crm', 'newsletters']);
|
||||||
|
|
||||||
|
// onConflict().ignore() must not throw on a repeat.
|
||||||
|
await svc.markUsed(['crm']);
|
||||||
|
expect((await db('product_usage_markers').pluck('feature')).length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a report from real booleans, not 0/1', async () => {
|
||||||
|
await db('feature_flags').insert([
|
||||||
|
{ key: 'clients', value: true },
|
||||||
|
{ key: 'newsletters', value: false },
|
||||||
|
]);
|
||||||
|
await db('product_usage_state').where({ id: 1 }).update({ status: 'active' });
|
||||||
|
await service().markUsed(['crm']);
|
||||||
|
|
||||||
|
const report = await service().snapshot();
|
||||||
|
expect(report.features.crm.configured).toBe(true);
|
||||||
|
expect(report.features.crm.used).toBe(true);
|
||||||
|
expect(report.features.newsletters.configured).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves preset layouts and template CSS on this engine too', async () => {
|
||||||
|
const [tpl] = await db('css_templates').insert({ is_enabled: true, css_content: '.a{}' }).returning('id');
|
||||||
|
const templateId = typeof tpl === 'object' ? tpl.id : tpl;
|
||||||
|
await db('events').insert([
|
||||||
|
{ color_theme: 'modernMasonry' },
|
||||||
|
{ color_theme: null, css_template_id: templateId },
|
||||||
|
]);
|
||||||
|
await db('app_settings').insert({
|
||||||
|
setting_key: 'theme_config',
|
||||||
|
setting_value: JSON.stringify({ galleryLayout: 'carousel' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = await service().snapshot();
|
||||||
|
expect(report.gallery_layouts.sort()).toEqual(['carousel', 'masonry']);
|
||||||
|
expect(report.features.custom_css.configured).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
const request = require('supertest');
|
||||||
|
const express = require('express');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const mockDb = require('knex')({
|
||||||
|
client: 'sqlite3',
|
||||||
|
connection: { filename: ':memory:' },
|
||||||
|
useNullAsDefault: true
|
||||||
|
});
|
||||||
|
jest.mock('../../src/database/db', () => ({
|
||||||
|
get db() {
|
||||||
|
return mockDb;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
jest.mock('../../src/utils/tokenRevocation', () => ({
|
||||||
|
isTokenRevoked: jest.fn().mockResolvedValue(false)
|
||||||
|
}));
|
||||||
|
jest.mock('../../src/utils/sessionCutoff', () => ({
|
||||||
|
isTokenBeforeCutoff: jest.fn().mockResolvedValue(false)
|
||||||
|
}));
|
||||||
|
jest.mock('../../src/utils/logger', () => ({
|
||||||
|
warn: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
debug: jest.fn(),
|
||||||
|
info: jest.fn()
|
||||||
|
}));
|
||||||
|
jest.mock('../../src/services/productUsageService', () =>
|
||||||
|
Object.fromEntries(
|
||||||
|
[
|
||||||
|
'tick',
|
||||||
|
'status',
|
||||||
|
'dismiss',
|
||||||
|
'enable',
|
||||||
|
'disable',
|
||||||
|
'abandon',
|
||||||
|
'preview',
|
||||||
|
'export',
|
||||||
|
'preferences',
|
||||||
|
'command',
|
||||||
|
'markUsed'
|
||||||
|
].map((key) => [key, jest.fn().mockResolvedValue({ status: 'disabled' })])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const service = require('../../src/services/productUsageService');
|
||||||
|
const { productUsage } = require('../../src/middleware/productUsage');
|
||||||
|
const { productUsageApi } = require('../../src/middleware/productUsage');
|
||||||
|
const SECRET = 'usage-auth-test-secret-not-a-live-credential';
|
||||||
|
const token = (type, id = 1) =>
|
||||||
|
jwt.sign({ type, id }, SECRET, {
|
||||||
|
issuer: 'picpeak-auth',
|
||||||
|
algorithm: 'HS256'
|
||||||
|
});
|
||||||
|
let app;
|
||||||
|
beforeAll(async () => {
|
||||||
|
process.env.JWT_SECRET = SECRET;
|
||||||
|
await mockDb.schema.createTable('roles', (t) => {
|
||||||
|
t.increments('id');
|
||||||
|
t.string('name');
|
||||||
|
});
|
||||||
|
await mockDb.schema.createTable('admin_users', (t) => {
|
||||||
|
t.increments('id');
|
||||||
|
t.string('username');
|
||||||
|
t.string('email');
|
||||||
|
t.integer('role_id');
|
||||||
|
t.boolean('is_active');
|
||||||
|
t.timestamp('password_changed_at');
|
||||||
|
});
|
||||||
|
await mockDb.schema.createTable('permissions', (t) => {
|
||||||
|
t.increments('id');
|
||||||
|
t.string('name');
|
||||||
|
});
|
||||||
|
await mockDb.schema.createTable('role_permissions', (t) => {
|
||||||
|
t.integer('role_id');
|
||||||
|
t.integer('permission_id');
|
||||||
|
});
|
||||||
|
await mockDb('roles').insert([
|
||||||
|
{ id: 1, name: 'super_admin' },
|
||||||
|
{ id: 2, name: 'viewer' }
|
||||||
|
]);
|
||||||
|
await mockDb('admin_users').insert([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
username: 'owner',
|
||||||
|
email: '[email protected]',
|
||||||
|
role_id: 1,
|
||||||
|
is_active: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
username: 'viewer',
|
||||||
|
email: '[email protected]',
|
||||||
|
role_id: 2,
|
||||||
|
is_active: 1
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
await mockDb('permissions').insert({ id: 1, name: 'settings.edit' });
|
||||||
|
await mockDb('role_permissions').insert({ role_id: 1, permission_id: 1 });
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/api/admin/usage', require('../../src/routes/adminUsage'));
|
||||||
|
app.use((err, _req, res, _next) =>
|
||||||
|
res.status(err.statusCode || 500).json({ code: err.code })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
afterAll(() => mockDb.destroy());
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
test('scoped API use records only its fixed v2 capability and never triggers a report', () => {
|
||||||
|
const simulate = (admin, apiToken, statusCode) => {
|
||||||
|
const res = new (require('events').EventEmitter)(); res.statusCode = statusCode;
|
||||||
|
productUsageApi({ admin, apiToken, body: { user: '[email protected]' } }, res, () => {});
|
||||||
|
res.emit('finish');
|
||||||
|
};
|
||||||
|
simulate(null, { id: 99 }, 200);
|
||||||
|
simulate({ id: 42 }, null, 200);
|
||||||
|
simulate({ id: 42 }, { id: 99 }, 403);
|
||||||
|
expect(service.markUsed).not.toHaveBeenCalled();
|
||||||
|
simulate({ id: 42 }, { id: 99 }, 200);
|
||||||
|
expect(service.markUsed).toHaveBeenCalledWith(['api_integration'], { legacyFeatures: [] });
|
||||||
|
expect(service.tick).not.toHaveBeenCalled();
|
||||||
|
expect(JSON.stringify(service.markUsed.mock.calls)).not.toMatch(/PRIVATE|42|99/);
|
||||||
|
});
|
||||||
|
|
||||||
|
const ROUTES = [
|
||||||
|
['get', '/'],
|
||||||
|
['post', '/activity'],
|
||||||
|
['post', '/enable'],
|
||||||
|
['post', '/consent'],
|
||||||
|
['post', '/disable'],
|
||||||
|
['post', '/abandon'],
|
||||||
|
['post', '/retry'],
|
||||||
|
['post', '/dismiss'],
|
||||||
|
['get', '/preview'],
|
||||||
|
['get', '/export'],
|
||||||
|
['put', '/feedback-preferences'],
|
||||||
|
['post', '/feedback'],
|
||||||
|
['post', '/vote'],
|
||||||
|
['post', '/portal-session']
|
||||||
|
];
|
||||||
|
test.each(ROUTES)(
|
||||||
|
'%s %s rejects unauthenticated and gallery tokens',
|
||||||
|
async (method, route) => {
|
||||||
|
await request(app)[method](`/api/admin/usage${route}`).send({}).expect(401);
|
||||||
|
await request(app)[method](`/api/admin/usage${route}`)
|
||||||
|
.set('Authorization', `Bearer ${token('gallery')}`)
|
||||||
|
.send({})
|
||||||
|
.expect(403);
|
||||||
|
expect(service.tick).not.toHaveBeenCalled();
|
||||||
|
expect(service.enable).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
test.each(ROUTES.filter(([, route]) => route !== '/activity'))(
|
||||||
|
'%s %s requires settings.edit',
|
||||||
|
async (method, route) => {
|
||||||
|
await request(app)[method](`/api/admin/usage${route}`)
|
||||||
|
.set('Authorization', `Bearer ${token('admin', 2)}`)
|
||||||
|
.send({})
|
||||||
|
.expect(403);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
test('an authenticated admin can trigger cadence without seeing identity or packet data', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/admin/usage/activity')
|
||||||
|
.set('Authorization', `Bearer ${token('admin', 2)}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(response.body).toEqual({ ok: true });
|
||||||
|
expect(service.tick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
test('owner sees no-store status and supplies consent to the service', async () => {
|
||||||
|
await request(app)
|
||||||
|
.get('/api/admin/usage')
|
||||||
|
.set('Authorization', `Bearer ${token('admin')}`)
|
||||||
|
.expect('Cache-Control', 'no-store')
|
||||||
|
.expect(200);
|
||||||
|
await request(app)
|
||||||
|
.post('/api/admin/usage/enable')
|
||||||
|
.set('Authorization', `Bearer ${token('admin')}`)
|
||||||
|
.send({ consent_version: 'usage-consent.v1' })
|
||||||
|
.expect(200);
|
||||||
|
expect(service.enable).toHaveBeenCalledWith('usage-consent.v1');
|
||||||
|
});
|
||||||
|
test('public/gallery paths and failed/unauthenticated admin operations never set feature markers', async () => {
|
||||||
|
const { EventEmitter } = require('events');
|
||||||
|
const simulate = (path, admin, statusCode) => {
|
||||||
|
const res = new EventEmitter();
|
||||||
|
res.locals = {};
|
||||||
|
res.statusCode = statusCode;
|
||||||
|
productUsage({ path, method: 'POST', admin }, res, () => {});
|
||||||
|
res.emit('finish');
|
||||||
|
};
|
||||||
|
simulate('/gallery/example', null, 200);
|
||||||
|
simulate('/customers', null, 200);
|
||||||
|
simulate('/quotes', { id: 1 }, 403);
|
||||||
|
expect(service.markUsed).not.toHaveBeenCalled();
|
||||||
|
simulate('/customers/42/hour-entries', { id: 1 }, 200);
|
||||||
|
// The second argument tells markUsed whether this operation writes to the
|
||||||
|
// configured backup destination; a CRM route never does.
|
||||||
|
expect(service.markUsed).toHaveBeenCalledWith(
|
||||||
|
expect.arrayContaining(['crm', 'crm_hours']),
|
||||||
|
expect.objectContaining({ destinationBackup: false })
|
||||||
|
);
|
||||||
|
expect(JSON.stringify(service.markUsed.mock.calls)).not.toContain('42');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('consent upgrade accepts exactly the explicit v2 choice, never extra fields', async () => {
|
||||||
|
for (const data of [{}, { consent_version: 'usage-consent.v1' }, { consent_version: 'usage-consent.v2', user: 'PRIVATE' }])
|
||||||
|
await request(app).post('/api/admin/usage/consent').set('Authorization', `Bearer ${token('admin')}`).send(data).expect(400);
|
||||||
|
expect(service.command).not.toHaveBeenCalled();
|
||||||
|
await request(app).post('/api/admin/usage/consent').set('Authorization', `Bearer ${token('admin')}`)
|
||||||
|
.send({ consent_version: 'usage-consent.v2' }).expect(200);
|
||||||
|
expect(service.command).toHaveBeenCalledWith('consent', { consent_version: 'usage-consent.v2' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only a backup that writes to the configured destination flags S3', () => {
|
||||||
|
// /database-backup/* and /backup/picpeak/export produce a local file, so
|
||||||
|
// they must not imply S3 use just because S3 is the configured destination.
|
||||||
|
const seen = [];
|
||||||
|
const simulate = (pathname) => {
|
||||||
|
service.markUsed.mockClear();
|
||||||
|
const res = new (require('events').EventEmitter)();
|
||||||
|
res.locals = {};
|
||||||
|
res.statusCode = 200;
|
||||||
|
productUsage({ path: pathname, method: pathname.endsWith('/export') ? 'GET' : 'POST', admin: { id: 1 } }, res, () => {});
|
||||||
|
res.emit('finish');
|
||||||
|
seen.push([pathname, service.markUsed.mock.calls[0]?.[1]?.destinationBackup]);
|
||||||
|
};
|
||||||
|
simulate('/backup/run');
|
||||||
|
simulate('/database-backup/backup');
|
||||||
|
simulate('/backup/picpeak/export');
|
||||||
|
expect(seen).toEqual([
|
||||||
|
['/backup/run', true],
|
||||||
|
['/database-backup/backup', false],
|
||||||
|
['/backup/picpeak/export', false],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The route allowlist and the packet schema have to agree. The allowlist used
|
||||||
|
// to let `name`, `allow_public` and `allow_marketing` be omitted while the
|
||||||
|
// schema requires all three, so an API caller got a bare INVALID_PACKET from
|
||||||
|
// deep inside signing instead of being told which field was missing.
|
||||||
|
const VALID_FEEDBACK = {
|
||||||
|
kind: 'feedback',
|
||||||
|
title: 'Title',
|
||||||
|
body: 'Body',
|
||||||
|
name: '',
|
||||||
|
allow_public: false,
|
||||||
|
allow_marketing: false
|
||||||
|
};
|
||||||
|
test.each([
|
||||||
|
['no body at all', {}],
|
||||||
|
['missing name', { ...VALID_FEEDBACK, name: undefined }],
|
||||||
|
['missing allow_public', { ...VALID_FEEDBACK, allow_public: undefined }],
|
||||||
|
['missing allow_marketing', { ...VALID_FEEDBACK, allow_marketing: undefined }],
|
||||||
|
['a boolean sent as a string', { ...VALID_FEEDBACK, allow_public: 'true' }],
|
||||||
|
['a title of only whitespace', { ...VALID_FEEDBACK, title: ' ' }],
|
||||||
|
['an unknown field', { ...VALID_FEEDBACK, ownerId: 7 }]
|
||||||
|
])('feedback rejects %s before anything is signed', async (_label, data) => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/admin/usage/feedback')
|
||||||
|
.set('Authorization', `Bearer ${token('admin')}`)
|
||||||
|
.send(JSON.parse(JSON.stringify(data)))
|
||||||
|
.expect(400);
|
||||||
|
// Named, not a bare protocol failure the caller cannot act on.
|
||||||
|
expect(response.body.code).toBe('VALIDATION_ERROR');
|
||||||
|
expect(service.command).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
test('feedback accepts the complete payload and mints the id server-side', async () => {
|
||||||
|
await request(app)
|
||||||
|
.post('/api/admin/usage/feedback')
|
||||||
|
.set('Authorization', `Bearer ${token('admin')}`)
|
||||||
|
.send({ ...VALID_FEEDBACK, name: 'QA' })
|
||||||
|
.expect(200);
|
||||||
|
expect(service.command).toHaveBeenCalledWith(
|
||||||
|
'feedback',
|
||||||
|
expect.objectContaining({ name: 'QA', feedback_id: expect.any(String) })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Runs last on purpose: the limiter's budget is per-process and shared with
|
||||||
|
// every test above that reaches an outbound route, so consuming it here cannot
|
||||||
|
// starve them. The assertion is deliberately about the property — some request
|
||||||
|
// is refused and the service stops being called — rather than an exact count,
|
||||||
|
// which would depend on how much budget earlier tests used.
|
||||||
|
test('the outbound routes are throttled so an admin session cannot flood the collector', async () => {
|
||||||
|
const codes = [];
|
||||||
|
for (let i = 0; i < 45; i += 1) {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/admin/usage/feedback')
|
||||||
|
.set('Authorization', `Bearer ${token('admin')}`)
|
||||||
|
.send({ ...VALID_FEEDBACK, title: `flood ${i}` });
|
||||||
|
codes.push(response.status);
|
||||||
|
if (response.status === 429) {
|
||||||
|
expect(response.body.code).toBe('USAGE_RATE_LIMITED');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(codes).toContain(429);
|
||||||
|
expect(service.command.mock.calls.length).toBeLessThan(codes.length);
|
||||||
|
|
||||||
|
// The same budget covers the other two routes that relay to the collector.
|
||||||
|
await request(app)
|
||||||
|
.post('/api/admin/usage/vote')
|
||||||
|
.set('Authorization', `Bearer ${token('admin')}`)
|
||||||
|
.send({ feedback_id: '11111111-1111-4111-8111-111111111111', voted: true })
|
||||||
|
.expect(429);
|
||||||
|
await request(app)
|
||||||
|
.post('/api/admin/usage/portal-session')
|
||||||
|
.set('Authorization', `Bearer ${token('admin')}`)
|
||||||
|
.expect(429);
|
||||||
|
|
||||||
|
// Reading status and withdrawing must never be throttled: those are how an
|
||||||
|
// operator sees what is happening and how they get out.
|
||||||
|
await request(app)
|
||||||
|
.get('/api/admin/usage')
|
||||||
|
.set('Authorization', `Bearer ${token('admin')}`)
|
||||||
|
.expect(200);
|
||||||
|
await request(app)
|
||||||
|
.post('/api/admin/usage/disable')
|
||||||
|
.set('Authorization', `Bearer ${token('admin')}`)
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
@@ -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, '[email protected]', 'gallery_feedback_likes');
|
||||||
|
expect(res.locals.productUsageFeatures.sort()).toEqual(['photo_management', 'video_uploads', 'camera_raw_uploads', 's3_storage', 's3_photo_storage'].sort());
|
||||||
|
});
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
/**
|
||||||
|
* /disable overlapping an in-flight /enable (#1110).
|
||||||
|
*
|
||||||
|
* While activation generates an identity and writes its binding file the row
|
||||||
|
* still reads `disabled`, so disable()'s conditional update matched nothing
|
||||||
|
* and the lease conflict from its tick() was swallowed. The admin was told
|
||||||
|
* participation was off, and the activation then completed and left it on —
|
||||||
|
* an opt-out silently ignored, which is the one thing this feature cannot do.
|
||||||
|
*
|
||||||
|
* enable() now claims its state with a single conditional UPDATE that also
|
||||||
|
* tests the cancellation flag, so whichever lands first wins outright.
|
||||||
|
*/
|
||||||
|
const knex = require('knex');
|
||||||
|
const { UsageService } = require('../../src/usage/UsageService');
|
||||||
|
|
||||||
|
const SECRET = 'z'.repeat(48);
|
||||||
|
|
||||||
|
// A report the envelope schema accepts. An empty payload fails validation
|
||||||
|
// during signing, so the packet would never reach the collector for reasons
|
||||||
|
// unrelated to what the test is checking.
|
||||||
|
function validReport() {
|
||||||
|
const { LEGACY_FEATURE_KEYS: FEATURE_KEYS } = require('../../src/usage/protocol.cjs');
|
||||||
|
return {
|
||||||
|
picpeak_version: '3.0.0',
|
||||||
|
report_date: '2026-09-05',
|
||||||
|
generated_at: '2026-09-05T00:00:00.000Z',
|
||||||
|
features: Object.fromEntries(
|
||||||
|
FEATURE_KEYS.map((k) => [k, { configured: false, used: false }])
|
||||||
|
),
|
||||||
|
gallery_layouts: ['grid'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootDb() {
|
||||||
|
const db = knex({
|
||||||
|
client: 'sqlite3',
|
||||||
|
connection: { filename: ':memory:' },
|
||||||
|
useNullAsDefault: true,
|
||||||
|
});
|
||||||
|
await db.schema.createTable('product_usage_state', (t) => {
|
||||||
|
t.integer('id').primary();
|
||||||
|
t.string('status', 30).notNullable().defaultTo('disabled');
|
||||||
|
t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v1');
|
||||||
|
t.boolean('notice_dismissed').notNullable().defaultTo(false);
|
||||||
|
t.string('installation_id', 64);
|
||||||
|
t.string('public_key', 59);
|
||||||
|
t.text('private_key_encrypted');
|
||||||
|
t.string('instance_binding', 64);
|
||||||
|
t.bigInteger('sequence').notNullable().defaultTo(0);
|
||||||
|
t.text('pending_packet');
|
||||||
|
t.text('last_packet');
|
||||||
|
t.text('last_receipt');
|
||||||
|
t.text('privacy_receipts');
|
||||||
|
t.string('last_report_date', 10);
|
||||||
|
t.string('last_error', 80);
|
||||||
|
t.text('feedback_preferences');
|
||||||
|
t.string('lease_token', 36);
|
||||||
|
t.bigInteger('lease_until').notNullable().defaultTo(0);
|
||||||
|
t.bigInteger('cancel_seq').notNullable().defaultTo(0);
|
||||||
|
t.integer('attempts').notNullable().defaultTo(0);
|
||||||
|
t.bigInteger('next_attempt_at').notNullable().defaultTo(0);
|
||||||
|
});
|
||||||
|
await db.schema.createTable('product_usage_markers', (t) => {
|
||||||
|
t.string('feature', 60).primary();
|
||||||
|
});
|
||||||
|
await db('product_usage_state').insert({ id: 1 });
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A service whose binding() is slow, so the race window is controllable. */
|
||||||
|
function makeService(db, { onBinding } = {}) {
|
||||||
|
const service = new UsageService(db, {
|
||||||
|
secret: SECRET,
|
||||||
|
endpoint: 'http://127.0.0.1:9/',
|
||||||
|
fetch: async () => { throw new Error('collector must not be reached'); },
|
||||||
|
});
|
||||||
|
const realBinding = service.binding.bind(service);
|
||||||
|
service.binding = async (create = false) => {
|
||||||
|
if (create && onBinding) await onBinding();
|
||||||
|
return realBinding === undefined ? 'x'.repeat(64) : 'b'.repeat(64);
|
||||||
|
};
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('withdrawal during an in-flight activation', () => {
|
||||||
|
let db;
|
||||||
|
afterEach(async () => { if (db) await db.destroy(); db = null; });
|
||||||
|
|
||||||
|
it('honours a /disable that lands while /enable is still generating its identity', async () => {
|
||||||
|
db = await bootDb();
|
||||||
|
let disableDone;
|
||||||
|
const service = makeService(db, {
|
||||||
|
// Fires inside enable(), before it claims the row — exactly the window
|
||||||
|
// where the status still reads `disabled`.
|
||||||
|
onBinding: async () => { disableDone = await service.disable(); },
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.enable('usage-consent.v1');
|
||||||
|
|
||||||
|
const row = await db('product_usage_state').where({ id: 1 }).first();
|
||||||
|
expect(row.status).toBe('disabled');
|
||||||
|
// Nothing was registered, so there is no identity and nothing to delete.
|
||||||
|
expect(row.installation_id).toBeNull();
|
||||||
|
expect(row.pending_packet).toBeNull();
|
||||||
|
expect(disableDone.status).toBe('disabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('activates normally when no withdrawal arrives', async () => {
|
||||||
|
db = await bootDb();
|
||||||
|
const service = makeService(db);
|
||||||
|
await service.enable('usage-consent.v1');
|
||||||
|
|
||||||
|
const row = await db('product_usage_state').where({ id: 1 }).first();
|
||||||
|
// The collector is unreachable here, so it stops at activation_pending —
|
||||||
|
// the point is that the claim succeeded and an identity exists.
|
||||||
|
expect(row.status).toBe('activation_pending');
|
||||||
|
expect(row.installation_id).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let a stale cancellation veto a later deliberate opt-in', async () => {
|
||||||
|
db = await bootDb();
|
||||||
|
// A withdrawal from an earlier participation is already reflected in the
|
||||||
|
// counter when this activation reads it, so it cannot veto anything.
|
||||||
|
await db('product_usage_state').where({ id: 1 }).update({ cancel_seq: 7 });
|
||||||
|
|
||||||
|
const service = makeService(db);
|
||||||
|
await service.enable('usage-consent.v1');
|
||||||
|
|
||||||
|
const row = await db('product_usage_state').where({ id: 1 }).first();
|
||||||
|
expect(row.status).toBe('activation_pending');
|
||||||
|
expect(row.installation_id).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours a withdrawal even when an earlier one was never cleared', async () => {
|
||||||
|
// The case a boolean could not express: a stale cancellation is already
|
||||||
|
// set, and a fresh one lands mid-activation. With a flag both look the
|
||||||
|
// same; with a counter the second increment is visible.
|
||||||
|
db = await bootDb();
|
||||||
|
await db('product_usage_state').where({ id: 1 }).update({ cancel_seq: 3 });
|
||||||
|
|
||||||
|
const service = makeService(db, {
|
||||||
|
onBinding: async () => { await service.disable(); },
|
||||||
|
});
|
||||||
|
await service.enable('usage-consent.v1');
|
||||||
|
|
||||||
|
const row = await db('product_usage_state').where({ id: 1 }).first();
|
||||||
|
expect(row.status).toBe('disabled');
|
||||||
|
expect(row.installation_id).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not dispatch a report when the withdrawal completes during preparation', async () => {
|
||||||
|
// deliver() checks for a withdrawal before the binding lookup, which is
|
||||||
|
// asynchronous. A /disable that COMPLETED during it used to have the
|
||||||
|
// report sent anyway — not an already-in-flight request, but a new one
|
||||||
|
// started after the operator had withdrawn.
|
||||||
|
db = await bootDb();
|
||||||
|
const posted = [];
|
||||||
|
const service = new UsageService(db, {
|
||||||
|
secret: SECRET,
|
||||||
|
endpoint: 'http://127.0.0.1:9/',
|
||||||
|
fetch: async (_url, init) => {
|
||||||
|
posted.push(JSON.parse(init.body).packet.action);
|
||||||
|
throw new Error('collector unreachable');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const identity = require('../../src/usage/protocol.cjs').generateIdentity();
|
||||||
|
await db('product_usage_state').where({ id: 1 }).update({
|
||||||
|
status: 'active',
|
||||||
|
installation_id: identity.installation_id,
|
||||||
|
public_key: identity.public_key,
|
||||||
|
private_key_encrypted: service.encrypt(identity.private_key),
|
||||||
|
instance_binding: 'b'.repeat(64),
|
||||||
|
sequence: 1,
|
||||||
|
pending_packet: JSON.stringify(
|
||||||
|
require('../../src/usage/protocol.cjs').makePacket(
|
||||||
|
{ installation_id: identity.installation_id },
|
||||||
|
'report',
|
||||||
|
2,
|
||||||
|
validReport(),
|
||||||
|
'usage.v1'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
// The withdrawal lands while the binding lookup is awaited.
|
||||||
|
service.binding = async () => {
|
||||||
|
await db('product_usage_state').where({ id: 1 }).update({
|
||||||
|
status: 'deletion_pending', pending_packet: null,
|
||||||
|
});
|
||||||
|
return 'b'.repeat(64);
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.deliver(await db('product_usage_state').where({ id: 1 }).first());
|
||||||
|
|
||||||
|
expect(posted).not.toContain('report');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours a withdrawal that lands between the lease claim and the state read', async () => {
|
||||||
|
// locked() claims the lease and reads the row in two statements. A
|
||||||
|
// /disable completing in that gap used to be adopted as this
|
||||||
|
// activation's own baseline and absorbed, so registration went ahead
|
||||||
|
// after the operator had withdrawn.
|
||||||
|
db = await bootDb();
|
||||||
|
const service = makeService(db);
|
||||||
|
const realState = service.state.bind(service);
|
||||||
|
let fired = false;
|
||||||
|
service.state = async () => {
|
||||||
|
// The withdrawal must land BEFORE this read returns, so the row carries
|
||||||
|
// the incremented counter. Incrementing afterwards would hand back the
|
||||||
|
// old value and both the broken and fixed versions would behave the
|
||||||
|
// same — which is exactly how an earlier version of this test passed
|
||||||
|
// against the bug it was meant to catch.
|
||||||
|
const first = await realState();
|
||||||
|
if (!fired && first.lease_token) {
|
||||||
|
fired = true;
|
||||||
|
await db('product_usage_state').where({ id: 1 }).increment('cancel_seq', 1);
|
||||||
|
return realState();
|
||||||
|
}
|
||||||
|
return first;
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.enable('usage-consent.v1');
|
||||||
|
|
||||||
|
const row = await db('product_usage_state').where({ id: 1 }).first();
|
||||||
|
expect(row.status).toBe('disabled');
|
||||||
|
expect(row.installation_id).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* Two things the participant is entitled to have stated exactly.
|
||||||
|
*
|
||||||
|
* The export receipt is a privacy document — the artefact an operator shows a
|
||||||
|
* third party — so a count in it has to mean what its label says. It counted
|
||||||
|
* every packet in the participation (feedback, votes, portal sessions, the
|
||||||
|
* registration) and called the total "usage reports": an install that had sent
|
||||||
|
* one report and twenty feedback items reported twenty-one reports.
|
||||||
|
*
|
||||||
|
* The delete packet's sequence is the other: it reuses the last ACCEPTED
|
||||||
|
* sequence rather than taking the next one, unlike every other action. That is
|
||||||
|
* a contract with the collector, not an implementation detail — if the
|
||||||
|
* collector ever enforced strictly increasing sequences per installation, the
|
||||||
|
* withdrawal would be rejected forever and the operator could never leave. It
|
||||||
|
* is pinned here so the assumption is written down and a change to it has to
|
||||||
|
* be deliberate.
|
||||||
|
*/
|
||||||
|
const knex = require('knex');
|
||||||
|
const { UsageService } = require('../../src/usage/UsageService');
|
||||||
|
const { generateIdentity, verifyEnvelope } = require('../../src/usage/protocol.cjs');
|
||||||
|
|
||||||
|
const SECRET = 's'.repeat(48);
|
||||||
|
|
||||||
|
async function bootDb() {
|
||||||
|
const db = knex({
|
||||||
|
client: 'sqlite3',
|
||||||
|
connection: { filename: ':memory:' },
|
||||||
|
useNullAsDefault: true,
|
||||||
|
});
|
||||||
|
await db.schema.createTable('product_usage_state', (t) => {
|
||||||
|
t.integer('id').primary();
|
||||||
|
t.string('status', 30).notNullable().defaultTo('disabled');
|
||||||
|
t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v2');
|
||||||
|
t.boolean('notice_dismissed').notNullable().defaultTo(false);
|
||||||
|
t.string('installation_id', 64);
|
||||||
|
t.string('public_key', 59);
|
||||||
|
t.text('private_key_encrypted');
|
||||||
|
t.string('instance_binding', 64);
|
||||||
|
t.bigInteger('sequence').notNullable().defaultTo(0);
|
||||||
|
t.text('pending_packet');
|
||||||
|
t.text('last_packet');
|
||||||
|
t.text('last_receipt');
|
||||||
|
t.text('privacy_receipts');
|
||||||
|
t.string('last_report_date', 10);
|
||||||
|
t.string('last_error', 80);
|
||||||
|
t.text('feedback_preferences');
|
||||||
|
t.string('lease_token', 36);
|
||||||
|
t.bigInteger('lease_until').notNullable().defaultTo(0);
|
||||||
|
t.bigInteger('cancel_seq').notNullable().defaultTo(0);
|
||||||
|
t.integer('attempts').notNullable().defaultTo(0);
|
||||||
|
t.bigInteger('next_attempt_at').notNullable().defaultTo(0);
|
||||||
|
});
|
||||||
|
await db('product_usage_state').insert({ id: 1 });
|
||||||
|
await db.schema.createTable('product_usage_markers', (t) => t.string('feature', 60).primary());
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
const envelope = (action) => ({ packet: { action, installation_id: 'a'.repeat(64) } });
|
||||||
|
|
||||||
|
describe('the export receipt states what it actually counted', () => {
|
||||||
|
let db;
|
||||||
|
afterEach(async () => { if (db) await db.destroy(); db = null; });
|
||||||
|
|
||||||
|
const exportWith = async (packets) => {
|
||||||
|
db = await bootDb();
|
||||||
|
await db('product_usage_state').where({ id: 1 }).update({
|
||||||
|
status: 'active',
|
||||||
|
installation_id: 'a'.repeat(64),
|
||||||
|
});
|
||||||
|
const service = new UsageService(db, {
|
||||||
|
secret: SECRET,
|
||||||
|
endpoint: 'https://usage.example.test',
|
||||||
|
now: () => Date.parse('2026-09-06T12:00:00.000Z'),
|
||||||
|
fetch: async () => ({
|
||||||
|
ok: true,
|
||||||
|
headers: { get: () => null },
|
||||||
|
body: (async function* () {
|
||||||
|
yield Buffer.from(JSON.stringify({ installation_id: 'a'.repeat(64), packets }));
|
||||||
|
})(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await service.export();
|
||||||
|
return JSON.parse((await db('product_usage_state').where({ id: 1 }).first()).privacy_receipts)
|
||||||
|
.last_export;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('counts reports as reports and everything else separately', async () => {
|
||||||
|
const receipt = await exportWith([
|
||||||
|
envelope('register'),
|
||||||
|
envelope('report'),
|
||||||
|
envelope('consent'),
|
||||||
|
envelope('feedback'),
|
||||||
|
envelope('feedback'),
|
||||||
|
envelope('vote'),
|
||||||
|
envelope('session'),
|
||||||
|
]);
|
||||||
|
expect(receipt.report_count).toBe(1);
|
||||||
|
expect(receipt.packet_count).toBe(7);
|
||||||
|
expect(receipt.scope).toEqual([
|
||||||
|
'accepted usage reports',
|
||||||
|
'accepted participant operations',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports zero rather than a total when no report was ever accepted', async () => {
|
||||||
|
const receipt = await exportWith([envelope('register'), envelope('feedback')]);
|
||||||
|
expect(receipt.report_count).toBe(0);
|
||||||
|
expect(receipt.packet_count).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the delete packet reuses the last accepted sequence', () => {
|
||||||
|
let db;
|
||||||
|
afterEach(async () => { if (db) await db.destroy(); db = null; });
|
||||||
|
|
||||||
|
it('sends the accepted sequence, not the next one', async () => {
|
||||||
|
db = await bootDb();
|
||||||
|
const identity = generateIdentity();
|
||||||
|
const sent = [];
|
||||||
|
const service = new UsageService(db, {
|
||||||
|
secret: SECRET,
|
||||||
|
endpoint: 'https://usage.example.test',
|
||||||
|
now: () => Date.parse('2026-09-06T12:00:00.000Z'),
|
||||||
|
bindingPath: `${require('os').tmpdir()}/usage-delete-seq-${Date.now()}.key`,
|
||||||
|
fetch: async (_url, options) => {
|
||||||
|
const body = JSON.parse(options.body);
|
||||||
|
sent.push(verifyEnvelope(body, Date.parse('2026-09-06T12:00:00.000Z')));
|
||||||
|
// Deliberately not a sequence-enforcing collector: this test pins what
|
||||||
|
// PicPeak sends, and the collector contract is what must match it.
|
||||||
|
throw new Error('stop after capturing the packet');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await db('product_usage_state').where({ id: 1 }).update({
|
||||||
|
status: 'deletion_pending',
|
||||||
|
installation_id: identity.installation_id,
|
||||||
|
public_key: identity.public_key,
|
||||||
|
private_key_encrypted: service.encrypt(identity.private_key),
|
||||||
|
sequence: 7,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.tick({ force: true });
|
||||||
|
|
||||||
|
expect(sent).toHaveLength(1);
|
||||||
|
expect(sent[0].action).toBe('delete');
|
||||||
|
expect(sent[0].sequence).toBe(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* The consent dialog tells the operator that this connection only ever runs
|
||||||
|
* outwards: PicPeak sends, and reads nothing back but the acknowledgement for
|
||||||
|
* the packet it just sent. That is a security claim — it is the reason a
|
||||||
|
* compromised collector cannot use this path to push code, configuration or
|
||||||
|
* content into an installation — so it is guarded here rather than left to
|
||||||
|
* review.
|
||||||
|
*
|
||||||
|
* These are source-inspection assertions on purpose. A behavioural test only
|
||||||
|
* proves the calls that exist today behave; this fails the moment someone adds
|
||||||
|
* a "check the collector for messages" fetch, a polling job, or an endpoint the
|
||||||
|
* collector could call.
|
||||||
|
*/
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const SRC = path.resolve(__dirname, '../../src');
|
||||||
|
const service = fs.readFileSync(path.join(SRC, 'usage/UsageService.js'), 'utf8');
|
||||||
|
const route = fs.readFileSync(path.join(SRC, 'routes/adminUsage.js'), 'utf8');
|
||||||
|
const server = fs.readFileSync(path.resolve(__dirname, '../../server.js'), 'utf8');
|
||||||
|
|
||||||
|
test('the collector is contacted from exactly one place, and only by POST', () => {
|
||||||
|
// One transport helper. Anything else reaching for the network here would
|
||||||
|
// bypass the size cap, the redirect ban and the timeout as well.
|
||||||
|
const callSites = service.match(/this\.fetch\(/g) || [];
|
||||||
|
expect(callSites).toHaveLength(1);
|
||||||
|
|
||||||
|
const post = service.slice(service.indexOf('async post('));
|
||||||
|
expect(post).toContain('method: \'POST\'');
|
||||||
|
// A redirect is an instruction from the collector about where to go next.
|
||||||
|
expect(post).toContain('redirect: \'error\'');
|
||||||
|
expect(post).toContain('AbortSignal.timeout(');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only the two known collector paths are ever requested', () => {
|
||||||
|
const paths = [...service.matchAll(/this\.post\(\s*'([^']+)'/g)].map((m) => m[1]);
|
||||||
|
expect(paths.sort()).toEqual(['/api/envelopes', '/api/participant/lookup']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nothing is read from a reply except the acknowledgement, checked field by field', () => {
|
||||||
|
// Every field of the receipt is compared against the packet that was sent.
|
||||||
|
for (const field of ['packet_id', 'installation_id', 'packet_digest', 'action', 'sequence', 'status'])
|
||||||
|
expect(service).toMatch(new RegExp(`receipt\\.${field} !==`));
|
||||||
|
expect(service).toContain('throw new Error(\'Invalid collector receipt\')');
|
||||||
|
|
||||||
|
// The stored copy drops the one value that is not an echo of what we sent,
|
||||||
|
// and no read path hands it back out again.
|
||||||
|
expect(service).toContain('delete storedReceipt.session_token');
|
||||||
|
expect(service).not.toMatch(/last_receipt:\s*state\.last_receipt/);
|
||||||
|
const status = service.slice(service.indexOf('async status()'), service.indexOf('async locked('));
|
||||||
|
expect(status).not.toContain('last_receipt');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the collector has no way in: no inbound route and no scheduled pull', () => {
|
||||||
|
// Every usage route is mounted behind adminAuth on the admin surface.
|
||||||
|
expect(server).toContain('app.use(\'/api/admin/usage\', require(\'./src/routes/adminUsage\'))');
|
||||||
|
expect(route).toContain('router.use(adminAuth)');
|
||||||
|
// No public/gallery/webhook mount for anything usage-related.
|
||||||
|
const publicMounts = [...server.matchAll(/app\.use\('\/api\/(public|gallery|customer|invite)[^']*',[^\n]*\)/g)]
|
||||||
|
.map((m) => m[0]);
|
||||||
|
for (const mount of publicMounts) expect(mount).not.toMatch(/[Uu]sage/);
|
||||||
|
|
||||||
|
// Nothing schedules a collector call; the daily rollup is driven only by an
|
||||||
|
// authenticated admin hitting /activity.
|
||||||
|
expect(service).not.toMatch(/setInterval|setTimeout\s*\(\s*\(\)\s*=>\s*this\.tick/);
|
||||||
|
const dir = path.join(SRC, 'services');
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (!entry.isFile() || !entry.name.endsWith('.js')) continue;
|
||||||
|
if (entry.name === 'productUsageService.js') continue;
|
||||||
|
expect(fs.readFileSync(path.join(dir, entry.name), 'utf8'))
|
||||||
|
.not.toContain('productUsageService');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
/**
|
||||||
|
* The signing key is encrypted with USAGE_ENCRYPTION_KEY, which defaults to
|
||||||
|
* JWT_SECRET. Rotating JWT_SECRET — the correct response to a suspected
|
||||||
|
* compromise — makes that key unreadable.
|
||||||
|
*
|
||||||
|
* Before this was named, the failure surfaced as a generic DELIVERY_FAILED
|
||||||
|
* that retried forever, and it silently blocked the DELETE packet as well:
|
||||||
|
* an operator who asked to withdraw had their local state cleared while the
|
||||||
|
* collector kept its copy, with nothing in the UI explaining why.
|
||||||
|
*/
|
||||||
|
const knex = require('knex');
|
||||||
|
const { UsageService } = require('../../src/usage/UsageService');
|
||||||
|
const { generateIdentity, makePacket } = require('../../src/usage/protocol.cjs');
|
||||||
|
|
||||||
|
const SECRET_A = 'a'.repeat(48);
|
||||||
|
const SECRET_B = 'b'.repeat(48);
|
||||||
|
|
||||||
|
async function bootDb() {
|
||||||
|
const db = knex({
|
||||||
|
client: 'sqlite3',
|
||||||
|
connection: { filename: ':memory:' },
|
||||||
|
useNullAsDefault: true,
|
||||||
|
});
|
||||||
|
await db.schema.createTable('product_usage_state', (t) => {
|
||||||
|
t.integer('id').primary();
|
||||||
|
t.string('status', 30).notNullable().defaultTo('disabled');
|
||||||
|
t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v1');
|
||||||
|
t.boolean('notice_dismissed').notNullable().defaultTo(false);
|
||||||
|
t.string('installation_id', 64);
|
||||||
|
t.string('public_key', 59);
|
||||||
|
t.text('private_key_encrypted');
|
||||||
|
t.string('instance_binding', 64);
|
||||||
|
t.bigInteger('sequence').notNullable().defaultTo(0);
|
||||||
|
t.text('pending_packet');
|
||||||
|
t.text('last_packet');
|
||||||
|
t.text('last_receipt');
|
||||||
|
t.text('privacy_receipts');
|
||||||
|
t.string('last_report_date', 10);
|
||||||
|
t.string('last_error', 80);
|
||||||
|
t.text('feedback_preferences');
|
||||||
|
t.string('lease_token', 36);
|
||||||
|
t.bigInteger('lease_until').notNullable().defaultTo(0);
|
||||||
|
t.integer('attempts').notNullable().defaultTo(0);
|
||||||
|
t.bigInteger('next_attempt_at').notNullable().defaultTo(0);
|
||||||
|
});
|
||||||
|
await db('product_usage_state').insert({ id: 1 });
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('usage signing key becomes unreadable after secret rotation', () => {
|
||||||
|
let db;
|
||||||
|
afterEach(async () => { if (db) await db.destroy(); db = null; });
|
||||||
|
|
||||||
|
it('names the failure instead of reporting a generic decrypt error', async () => {
|
||||||
|
db = await bootDb();
|
||||||
|
const before = new UsageService(db, { secret: SECRET_A });
|
||||||
|
const sealed = before.encrypt('the-signing-key');
|
||||||
|
|
||||||
|
// Same value, different secret — exactly what rotating JWT_SECRET does.
|
||||||
|
const after = new UsageService(db, { secret: SECRET_B });
|
||||||
|
expect(() => after.decrypt(sealed)).toThrow(
|
||||||
|
expect.objectContaining({ code: 'SIGNING_KEY_UNREADABLE' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still round-trips under the unrotated secret', async () => {
|
||||||
|
db = await bootDb();
|
||||||
|
const service = new UsageService(db, { secret: SECRET_A });
|
||||||
|
expect(service.decrypt(service.encrypt('the-signing-key'))).toBe('the-signing-key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records SIGNING_KEY_UNREADABLE rather than DELIVERY_FAILED, and does not flag an identity conflict', async () => {
|
||||||
|
db = await bootDb();
|
||||||
|
const sealed = new UsageService(db, { secret: SECRET_A }).encrypt('key');
|
||||||
|
await db('product_usage_state').where({ id: 1 }).update({
|
||||||
|
status: 'active',
|
||||||
|
installation_id: 'a'.repeat(64),
|
||||||
|
public_key: 'p'.repeat(59),
|
||||||
|
private_key_encrypted: sealed,
|
||||||
|
sequence: 1,
|
||||||
|
pending_packet: JSON.stringify({
|
||||||
|
action: 'delete', packet_id: 'x', installation_id: 'a'.repeat(64), sequence: 1,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = new UsageService(db, {
|
||||||
|
secret: SECRET_B,
|
||||||
|
endpoint: 'http://127.0.0.1:9/',
|
||||||
|
// A delivery must never be attempted: signing fails first.
|
||||||
|
fetch: () => { throw new Error('network must not be reached'); },
|
||||||
|
});
|
||||||
|
await service.deliver(await db('product_usage_state').where({ id: 1 }).first());
|
||||||
|
|
||||||
|
const row = await db('product_usage_state').where({ id: 1 }).first();
|
||||||
|
expect(row.last_error).toBe('SIGNING_KEY_UNREADABLE');
|
||||||
|
// A key we cannot read is not evidence of a cloned installation.
|
||||||
|
expect(row.status).toBe('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Naming the failure told the operator what happened but left them nowhere to
|
||||||
|
* go: the delete packet can never be signed, so the row stays in
|
||||||
|
* deletion_pending forever, and enable() refuses because it is not `disabled`.
|
||||||
|
* An operator who rotated the secret precisely because it was compromised
|
||||||
|
* cannot restore it, so without an exit the feature is bricked.
|
||||||
|
*/
|
||||||
|
describe('abandoning a withdrawal that can never be signed', () => {
|
||||||
|
let db;
|
||||||
|
afterEach(async () => { if (db) await db.destroy(); db = null; });
|
||||||
|
|
||||||
|
const stuck = async () => {
|
||||||
|
db = await bootDb();
|
||||||
|
await db.schema.createTable('product_usage_markers', (t) => {
|
||||||
|
t.string('feature', 60).primary();
|
||||||
|
});
|
||||||
|
await db('product_usage_markers').insert({ feature: 'crm' });
|
||||||
|
await db('product_usage_state').where({ id: 1 }).update({
|
||||||
|
status: 'deletion_pending',
|
||||||
|
installation_id: 'a'.repeat(64),
|
||||||
|
public_key: 'p'.repeat(59),
|
||||||
|
private_key_encrypted: new UsageService(db, { secret: SECRET_A }).encrypt('key'),
|
||||||
|
sequence: 4,
|
||||||
|
last_error: 'SIGNING_KEY_UNREADABLE',
|
||||||
|
});
|
||||||
|
return new UsageService(db, {
|
||||||
|
secret: SECRET_B,
|
||||||
|
endpoint: 'https://usage.example.test',
|
||||||
|
bindingPath: `${require('os').tmpdir()}/usage-abandon-${Date.now()}.key`,
|
||||||
|
fetch: () => { throw new Error('network must not be reached'); },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
it('clears the local identity and records the deletion as unconfirmed', async () => {
|
||||||
|
const service = await stuck();
|
||||||
|
const status = await service.abandon();
|
||||||
|
|
||||||
|
expect(status.status).toBe('disabled');
|
||||||
|
expect(status.installation_id).toBeNull();
|
||||||
|
const row = await db('product_usage_state').where({ id: 1 }).first();
|
||||||
|
expect(row.private_key_encrypted).toBeNull();
|
||||||
|
expect(row.public_key).toBeNull();
|
||||||
|
expect(row.last_error).toBeNull();
|
||||||
|
expect(await db('product_usage_markers').count('* as c').first()).toEqual({ c: 0 });
|
||||||
|
|
||||||
|
// The receipt must not claim a deletion the collector never confirmed.
|
||||||
|
const receipt = JSON.parse(row.privacy_receipts).last_abandonment;
|
||||||
|
expect(receipt.status).toBe('collector-unconfirmed');
|
||||||
|
expect(receipt.reason).toBe('SIGNING_KEY_UNREADABLE');
|
||||||
|
expect(receipt.installation_id).toBe('a'.repeat(64));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets the operator rejoin afterwards', async () => {
|
||||||
|
const service = await stuck();
|
||||||
|
await service.abandon();
|
||||||
|
expect((await service.state()).status).toBe('disabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses on a withdrawal that is merely undelivered', async () => {
|
||||||
|
const service = await stuck();
|
||||||
|
await db('product_usage_state').where({ id: 1 }).update({ last_error: 'DELIVERY_FAILED' });
|
||||||
|
await expect(service.abandon()).rejects.toThrow(/abandoned/);
|
||||||
|
expect((await service.state()).installation_id).toBe('a'.repeat(64));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses while participation is active', async () => {
|
||||||
|
const service = await stuck();
|
||||||
|
await db('product_usage_state').where({ id: 1 }).update({ status: 'active' });
|
||||||
|
await expect(service.abandon()).rejects.toThrow(/abandoned/);
|
||||||
|
expect((await service.state()).installation_id).toBe('a'.repeat(64));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every failed delivery used to be retried on the next admin request, and
|
||||||
|
* /activity is open to any authenticated admin while the settings ticker fires
|
||||||
|
* it every five minutes per open tab. A permanently rejected packet therefore
|
||||||
|
* produced one collector request per admin action, indefinitely.
|
||||||
|
*/
|
||||||
|
describe('delivery backoff', () => {
|
||||||
|
let db;
|
||||||
|
afterEach(async () => { if (db) await db.destroy(); db = null; });
|
||||||
|
|
||||||
|
// A real identity and a schema-valid packet, so the failure happens where
|
||||||
|
// this test claims it does — at the network — rather than in signPacket.
|
||||||
|
const activeWithPendingPacket = async (fetchImpl, now) => {
|
||||||
|
db = await bootDb();
|
||||||
|
await db.schema.createTable('product_usage_markers', (t) => {
|
||||||
|
t.string('feature', 60).primary();
|
||||||
|
});
|
||||||
|
const service = new UsageService(db, {
|
||||||
|
secret: SECRET_A,
|
||||||
|
endpoint: 'https://usage.example.test',
|
||||||
|
now: () => now(),
|
||||||
|
fetch: fetchImpl,
|
||||||
|
});
|
||||||
|
const identity = generateIdentity();
|
||||||
|
await db('product_usage_state').where({ id: 1 }).update({
|
||||||
|
status: 'active',
|
||||||
|
consent_version: 'usage-consent.v2',
|
||||||
|
installation_id: identity.installation_id,
|
||||||
|
public_key: identity.public_key,
|
||||||
|
private_key_encrypted: service.encrypt(identity.private_key),
|
||||||
|
sequence: 1,
|
||||||
|
pending_packet: JSON.stringify(
|
||||||
|
makePacket(identity, 'session', 2, {}, 'usage.v2')
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return service;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('paces the next unattended attempt after a failure, and lets Retry skip it', async () => {
|
||||||
|
let clock = 1_000_000;
|
||||||
|
let calls = 0;
|
||||||
|
const service = await activeWithPendingPacket(() => {
|
||||||
|
calls += 1;
|
||||||
|
throw new Error('collector unreachable');
|
||||||
|
}, () => clock);
|
||||||
|
|
||||||
|
await service.tick();
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
const paced = await service.state();
|
||||||
|
expect(Number(paced.attempts)).toBe(1);
|
||||||
|
expect(Number(paced.next_attempt_at)).toBeGreaterThan(clock);
|
||||||
|
|
||||||
|
// The unattended callers — /activity and the settings ticker — wait.
|
||||||
|
await service.tick();
|
||||||
|
await service.tick();
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
|
||||||
|
// The operator pressing Retry does not.
|
||||||
|
await service.tick({ force: true });
|
||||||
|
expect(calls).toBe(2);
|
||||||
|
expect(Number((await service.state()).attempts)).toBe(2);
|
||||||
|
|
||||||
|
// Once the window passes, the automatic sender tries again on its own.
|
||||||
|
clock = Number((await service.state()).next_attempt_at) + 1;
|
||||||
|
await service.tick();
|
||||||
|
expect(calls).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grows the wait with consecutive failures and caps it at an hour', () => {
|
||||||
|
const service = new UsageService(null, { secret: SECRET_A, endpoint: 'https://usage.example.test' });
|
||||||
|
expect(service.backoffMs(1)).toBe(2 * 60000);
|
||||||
|
expect(service.backoffMs(3)).toBe(8 * 60000);
|
||||||
|
expect(service.backoffMs(20)).toBe(60 * 60000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the pacing once a packet is accepted', async () => {
|
||||||
|
const clock = 1_000_000;
|
||||||
|
const service = await activeWithPendingPacket(async () => {
|
||||||
|
throw new Error('collector unreachable');
|
||||||
|
}, () => clock);
|
||||||
|
await service.tick();
|
||||||
|
expect(Number((await service.state()).attempts)).toBe(1);
|
||||||
|
|
||||||
|
await service.clearDeliveryBackoff();
|
||||||
|
const cleared = await service.state();
|
||||||
|
expect(Number(cleared.attempts)).toBe(0);
|
||||||
|
expect(Number(cleared.next_attempt_at)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same dead end, reached the ordinary way. If an installation opts in to
|
||||||
|
* usage.v2 while the collector still only speaks usage.v1 — the deployment
|
||||||
|
* order the docs warn about — the registration is rejected outright. Nothing
|
||||||
|
* exists at the collector, and yet the operator could not clear the tab:
|
||||||
|
* disable moved to deletion_pending, retry was futile, enable refused, and the
|
||||||
|
* abandon hatch was gated on SIGNING_KEY_UNREADABLE, which this is not.
|
||||||
|
*
|
||||||
|
* Verified against the live collector before this was written: a valid v2
|
||||||
|
* register is answered with INVALID_PACKET while the identical v1 flow is
|
||||||
|
* accepted.
|
||||||
|
*/
|
||||||
|
describe('a participation the collector never accepted', () => {
|
||||||
|
let db;
|
||||||
|
afterEach(async () => { if (db) await db.destroy(); db = null; });
|
||||||
|
|
||||||
|
const rejectingCollector = async (status) => {
|
||||||
|
db = await bootDb();
|
||||||
|
await db.schema.createTable('product_usage_markers', (t) => {
|
||||||
|
t.string('feature', 60).primary();
|
||||||
|
});
|
||||||
|
const identity = generateIdentity();
|
||||||
|
const service = new UsageService(db, {
|
||||||
|
secret: SECRET_A,
|
||||||
|
endpoint: 'https://usage.example.test',
|
||||||
|
bindingPath: `${require('os').tmpdir()}/usage-unreg-${Date.now()}-${Math.random()}.key`,
|
||||||
|
fetch: async () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
headers: { get: () => null },
|
||||||
|
body: (async function* () { yield Buffer.from(JSON.stringify({ error: 'INVALID_PACKET' })); })(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await db('product_usage_state').where({ id: 1 }).update({
|
||||||
|
status,
|
||||||
|
consent_version: 'usage-consent.v2',
|
||||||
|
installation_id: identity.installation_id,
|
||||||
|
public_key: identity.public_key,
|
||||||
|
private_key_encrypted: service.encrypt(identity.private_key),
|
||||||
|
sequence: 0,
|
||||||
|
pending_packet: JSON.stringify(
|
||||||
|
makePacket(identity, status === 'deletion_pending' ? 'delete' : 'register', 0,
|
||||||
|
status === 'deletion_pending' ? {} : { consent_version: 'usage-consent.v2' }, 'usage.v2')
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return service;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('names the rejection instead of blaming the network', async () => {
|
||||||
|
const service = await rejectingCollector('activation_pending');
|
||||||
|
await service.tick({ force: true });
|
||||||
|
expect((await service.state()).last_error).toBe('SCHEMA_NOT_ACCEPTED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers the exit straight from activation_pending', async () => {
|
||||||
|
const service = await rejectingCollector('activation_pending');
|
||||||
|
await service.tick({ force: true });
|
||||||
|
const status = await service.status();
|
||||||
|
expect(status.can_abandon).toBe(true);
|
||||||
|
expect(status.abandon_never_registered).toBe(true);
|
||||||
|
|
||||||
|
await service.abandon();
|
||||||
|
const after = await service.state();
|
||||||
|
expect(after.status).toBe('disabled');
|
||||||
|
expect(after.installation_id).toBeNull();
|
||||||
|
// Provably nothing remote, so the receipt must not hedge.
|
||||||
|
expect(JSON.parse(after.privacy_receipts).last_abandonment.status)
|
||||||
|
.toBe('never-registered');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers it from deletion_pending too, once the withdrawal is also undeliverable', async () => {
|
||||||
|
const service = await rejectingCollector('deletion_pending');
|
||||||
|
await service.tick({ force: true });
|
||||||
|
expect((await service.status()).can_abandon).toBe(true);
|
||||||
|
await service.abandon();
|
||||||
|
expect((await service.state()).status).toBe('disabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never offers it while a registered participation could still be deleted remotely', async () => {
|
||||||
|
const service = await rejectingCollector('deletion_pending');
|
||||||
|
// Something WAS accepted once: the collector may still hold reports, so
|
||||||
|
// clearing local state silently would be a lie.
|
||||||
|
await db('product_usage_state').where({ id: 1 }).update({
|
||||||
|
sequence: 3,
|
||||||
|
last_receipt: JSON.stringify({ status: 'accepted' }),
|
||||||
|
last_error: 'DELIVERY_FAILED',
|
||||||
|
});
|
||||||
|
expect((await service.status()).can_abandon).toBe(false);
|
||||||
|
await expect(service.abandon()).rejects.toThrow(/cannot be completed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not offer it before a delivery has actually failed', async () => {
|
||||||
|
const service = await rejectingCollector('activation_pending');
|
||||||
|
expect((await service.status()).can_abandon).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
/**
|
||||||
|
* Report accuracy (#1110).
|
||||||
|
*
|
||||||
|
* Two signals were wrong in ways that only show up in the aggregate, where
|
||||||
|
* nobody can tell the number is wrong: preset-themed installs all reported
|
||||||
|
* `grid`, and CSS applied through a template reported no custom CSS at all.
|
||||||
|
*
|
||||||
|
* Also covers status() surviving a misconfigured collector URL — it used to
|
||||||
|
* throw, which took down the settings tab that is the only way to withdraw.
|
||||||
|
*/
|
||||||
|
const knex = require('knex');
|
||||||
|
const { UsageService } = require('../../src/usage/UsageService');
|
||||||
|
const { FEATURE_KEYS, CATALOG, generateIdentity, makePacket, signPacket, verifyEnvelope } = require('../../src/usage/protocol.cjs');
|
||||||
|
|
||||||
|
async function bootDb() {
|
||||||
|
const db = knex({
|
||||||
|
client: 'sqlite3',
|
||||||
|
connection: { filename: ':memory:' },
|
||||||
|
useNullAsDefault: true,
|
||||||
|
});
|
||||||
|
await db.schema.createTable('product_usage_state', (t) => {
|
||||||
|
t.integer('id').primary();
|
||||||
|
t.string('status', 30).notNullable().defaultTo('disabled');
|
||||||
|
t.string('consent_version', 40).notNullable().defaultTo('usage-consent.v1');
|
||||||
|
t.boolean('notice_dismissed').notNullable().defaultTo(false);
|
||||||
|
t.string('installation_id', 64);
|
||||||
|
t.string('public_key', 59);
|
||||||
|
t.text('private_key_encrypted');
|
||||||
|
t.string('instance_binding', 64);
|
||||||
|
t.bigInteger('sequence').notNullable().defaultTo(0);
|
||||||
|
t.text('pending_packet'); t.text('last_packet'); t.text('last_receipt');
|
||||||
|
t.text('privacy_receipts');
|
||||||
|
t.string('last_report_date', 10); t.string('last_error', 80);
|
||||||
|
t.text('feedback_preferences'); t.string('lease_token', 36);
|
||||||
|
t.bigInteger('lease_until').notNullable().defaultTo(0);
|
||||||
|
t.bigInteger('cancel_seq').notNullable().defaultTo(0);
|
||||||
|
t.integer('attempts').notNullable().defaultTo(0);
|
||||||
|
t.bigInteger('next_attempt_at').notNullable().defaultTo(0);
|
||||||
|
});
|
||||||
|
await db('product_usage_state').insert({ id: 1 });
|
||||||
|
await db.schema.createTable('product_usage_markers', (t) => t.string('feature', 60).primary());
|
||||||
|
await db.schema.createTable('app_settings', (t) => {
|
||||||
|
t.string('setting_key').primary(); t.text('setting_value'); t.string('setting_type');
|
||||||
|
});
|
||||||
|
await db.schema.createTable('feature_flags', (t) => {
|
||||||
|
t.string('key').primary(); t.boolean('value');
|
||||||
|
});
|
||||||
|
await db.schema.createTable('events', (t) => {
|
||||||
|
t.increments('id'); t.text('color_theme'); t.string('external_path');
|
||||||
|
t.integer('css_template_id');
|
||||||
|
});
|
||||||
|
await db.schema.createTable('css_templates', (t) => {
|
||||||
|
t.increments('id'); t.boolean('is_enabled'); t.text('css_content');
|
||||||
|
});
|
||||||
|
for (const table of ['email_configs', 'mail_accounts']) {
|
||||||
|
await db.schema.createTable(table, (t) => { t.increments('id'); t.string('smtp_host'); });
|
||||||
|
}
|
||||||
|
await db.schema.createTable('whatsapp_configs', (t) => {
|
||||||
|
t.increments('id'); t.boolean('enabled'); t.string('phone_number_id'); t.string('access_token');
|
||||||
|
});
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = (db, over = {}) =>
|
||||||
|
new UsageService(db, { secret: 'q'.repeat(48), ...over });
|
||||||
|
|
||||||
|
describe('gallery_layouts resolves what the gallery actually renders', () => {
|
||||||
|
let db;
|
||||||
|
afterEach(async () => { if (db) await db.destroy(); db = null; });
|
||||||
|
|
||||||
|
it('maps preset NAMES to their layouts instead of calling them all grid', async () => {
|
||||||
|
db = await bootDb();
|
||||||
|
await db('events').insert([
|
||||||
|
{ color_theme: 'modernMasonry' },
|
||||||
|
{ color_theme: 'corporateTimeline' },
|
||||||
|
{ color_theme: 'galleryStory' },
|
||||||
|
]);
|
||||||
|
const report = await service(db).snapshot();
|
||||||
|
expect(report.gallery_layouts.sort()).toEqual(
|
||||||
|
['gallery-story', 'masonry', 'timeline'].sort()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still reads a theme object', async () => {
|
||||||
|
db = await bootDb();
|
||||||
|
await db('events').insert([{ color_theme: JSON.stringify({ galleryLayout: 'mosaic' }) }]);
|
||||||
|
expect((await service(db).snapshot()).gallery_layouts).toEqual(['mosaic']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports an unknown preset as other, not as grid', async () => {
|
||||||
|
// A preset added on the frontend must not silently inflate the grid count.
|
||||||
|
db = await bootDb();
|
||||||
|
await db('events').insert([{ color_theme: 'somePresetAddedLater' }]);
|
||||||
|
expect((await service(db).snapshot()).gallery_layouts).toEqual(['other']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the global theme for an event that has none of its own', async () => {
|
||||||
|
db = await bootDb();
|
||||||
|
await db('app_settings').insert({
|
||||||
|
setting_key: 'theme_config',
|
||||||
|
setting_value: JSON.stringify({ galleryLayout: 'carousel' }),
|
||||||
|
});
|
||||||
|
await db('events').insert([{ color_theme: null }]);
|
||||||
|
expect((await service(db).snapshot()).gallery_layouts).toEqual(['carousel']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('custom_css counts CSS applied through a template', () => {
|
||||||
|
let db;
|
||||||
|
afterEach(async () => { if (db) await db.destroy(); db = null; });
|
||||||
|
|
||||||
|
it('is configured when an enabled template is applied to an event', async () => {
|
||||||
|
db = await bootDb();
|
||||||
|
const [id] = await db('css_templates').insert({ is_enabled: true, css_content: '.a{}' });
|
||||||
|
await db('events').insert([{ color_theme: null, css_template_id: id }]);
|
||||||
|
expect((await service(db).snapshot()).features.custom_css.configured).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not configured when the applied template is disabled', async () => {
|
||||||
|
db = await bootDb();
|
||||||
|
const [id] = await db('css_templates').insert({ is_enabled: false, css_content: '.a{}' });
|
||||||
|
await db('events').insert([{ color_theme: null, css_template_id: id }]);
|
||||||
|
expect((await service(db).snapshot()).features.custom_css.configured).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not configured when an enabled template is applied to nothing', async () => {
|
||||||
|
db = await bootDb();
|
||||||
|
await db('css_templates').insert({ is_enabled: true, css_content: '.a{}' });
|
||||||
|
await db('events').insert([{ color_theme: null }]);
|
||||||
|
expect((await service(db).snapshot()).features.custom_css.configured).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('status survives a misconfigured collector URL', () => {
|
||||||
|
let db;
|
||||||
|
afterEach(async () => { if (db) await db.destroy(); db = null; });
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['a bare hostname', 'usage.picpeak.app'],
|
||||||
|
['a URL with a path', 'https://usage.picpeak.app/collect'],
|
||||||
|
['a URL with a query', 'https://usage.picpeak.app/?x=1'],
|
||||||
|
])('reports %s as a configuration error rather than failing the request', async (_l, endpoint) => {
|
||||||
|
db = await bootDb();
|
||||||
|
const status = await service(db, { endpoint }).status();
|
||||||
|
expect(status.collector_error).toBe('INVALID_COLLECTOR_URL');
|
||||||
|
expect(status.collector_url).toBeNull();
|
||||||
|
// The operator can still read their state — and therefore still withdraw.
|
||||||
|
expect(status.status).toBe('disabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports no error for a valid collector', async () => {
|
||||||
|
db = await bootDb();
|
||||||
|
const status = await service(db, { endpoint: 'https://usage.picpeak.app' }).status();
|
||||||
|
expect(status.collector_error).toBeNull();
|
||||||
|
expect(status.collector_url).toBe('https://usage.picpeak.app');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('S3 use is only implied by backups that write to the destination', () => {
|
||||||
|
let db;
|
||||||
|
afterEach(async () => { if (db) await db.destroy(); db = null; });
|
||||||
|
|
||||||
|
const withS3Destination = async (database) => {
|
||||||
|
await database('app_settings').insert({
|
||||||
|
setting_key: 'backup_destination_type',
|
||||||
|
setting_value: JSON.stringify('s3'),
|
||||||
|
});
|
||||||
|
await database('product_usage_state').where({ id: 1 }).update({ status: 'active' });
|
||||||
|
};
|
||||||
|
|
||||||
|
it('marks S3 for a backup that uses the configured destination', async () => {
|
||||||
|
db = await bootDb();
|
||||||
|
await withS3Destination(db);
|
||||||
|
await service(db).markUsed(['backup'], { destinationBackup: true });
|
||||||
|
expect((await db('product_usage_markers').pluck('feature')).sort())
|
||||||
|
.toEqual(['backup', 's3_storage']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT mark S3 for a local backup, even with S3 configured', async () => {
|
||||||
|
// /database-backup/* and /backup/picpeak/export produce a local file. They
|
||||||
|
// count as `backup`, but claiming S3 was used for them made merely
|
||||||
|
// configuring S3 and downloading an export report s3_storage.used.
|
||||||
|
db = await bootDb();
|
||||||
|
await withS3Destination(db);
|
||||||
|
await service(db).markUsed(['backup']);
|
||||||
|
expect(await db('product_usage_markers').pluck('feature')).toEqual(['backup']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mark S3 when the destination is not S3', async () => {
|
||||||
|
db = await bootDb();
|
||||||
|
await db('app_settings').insert({
|
||||||
|
setting_key: 'backup_destination_type',
|
||||||
|
setting_value: JSON.stringify('local'),
|
||||||
|
});
|
||||||
|
await db('product_usage_state').where({ id: 1 }).update({ status: 'active' });
|
||||||
|
await service(db).markUsed(['backup'], { destinationBackup: true });
|
||||||
|
expect(await db('product_usage_markers').pluck('feature')).toEqual(['backup']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A signal whose answer is fixed by the shipped defaults is not a signal.
|
||||||
|
* PicPeak ships default_protection_level='standard' and
|
||||||
|
* enable_devtools_protection=true, so accepting either as evidence made
|
||||||
|
* gallery_image_protection true on a bare install with no galleries — a
|
||||||
|
* fleet-wide 100% that cannot separate a decision from an untouched default.
|
||||||
|
*/
|
||||||
|
describe('gallery_image_protection reports decisions, not shipped defaults', () => {
|
||||||
|
let db;
|
||||||
|
afterEach(async () => { if (db) await db.destroy(); db = null; });
|
||||||
|
|
||||||
|
const v2 = async () => {
|
||||||
|
db = await bootDb();
|
||||||
|
await db.schema.alterTable('events', (t) => {
|
||||||
|
for (const column of ['disable_right_click', 'enable_devtools_protection', 'use_canvas_rendering']) t.boolean(column);
|
||||||
|
t.string('protection_level');
|
||||||
|
});
|
||||||
|
await db('product_usage_state').where({ id: 1 })
|
||||||
|
.update({ status: 'active', consent_version: 'usage-consent.v2' });
|
||||||
|
return service(db);
|
||||||
|
};
|
||||||
|
const shipped = async () => {
|
||||||
|
// Exactly what migration 038 seeds, plus an event carrying the column
|
||||||
|
// defaults from the same migration.
|
||||||
|
await db('app_settings').insert([
|
||||||
|
{ setting_key: 'default_protection_level', setting_value: '"standard"' },
|
||||||
|
{ setting_key: 'enable_devtools_protection', setting_value: 'true' },
|
||||||
|
{ setting_key: 'enable_canvas_rendering', setting_value: 'false' },
|
||||||
|
]);
|
||||||
|
await db('events').insert({
|
||||||
|
protection_level: 'standard',
|
||||||
|
enable_devtools_protection: true,
|
||||||
|
use_canvas_rendering: false,
|
||||||
|
disable_right_click: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
it('is false on a bare install with no galleries at all', async () => {
|
||||||
|
const client = await v2();
|
||||||
|
expect((await client.snapshot()).features.gallery_image_protection)
|
||||||
|
.toEqual({ configured: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false when every value is still the shipped default', async () => {
|
||||||
|
const client = await v2();
|
||||||
|
await shipped();
|
||||||
|
expect((await client.snapshot()).features.gallery_image_protection)
|
||||||
|
.toEqual({ configured: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores the devtools flag entirely, since it ships on', async () => {
|
||||||
|
const client = await v2();
|
||||||
|
await shipped();
|
||||||
|
// Turning it OFF is the only informative state it has, and that is the
|
||||||
|
// opposite of what this key claims — so neither state may set it.
|
||||||
|
await db('app_settings').where({ setting_key: 'enable_devtools_protection' })
|
||||||
|
.update({ setting_value: 'false' });
|
||||||
|
await db('events').update({ enable_devtools_protection: false });
|
||||||
|
expect((await client.snapshot()).features.gallery_image_protection)
|
||||||
|
.toEqual({ configured: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['a stronger global level', async (db) => db('app_settings').where({ setting_key: 'default_protection_level' }).update({ setting_value: '"maximum"' })],
|
||||||
|
['global canvas rendering', async (db) => db('app_settings').where({ setting_key: 'enable_canvas_rendering' }).update({ setting_value: 'true' })],
|
||||||
|
['a stronger level on one gallery', async (db) => db('events').update({ protection_level: 'enhanced' })],
|
||||||
|
['canvas rendering on one gallery', async (db) => db('events').update({ use_canvas_rendering: true })],
|
||||||
|
['right-click disabled on one gallery', async (db) => db('events').update({ disable_right_click: true })],
|
||||||
|
])('is true for %s', async (_label, change) => {
|
||||||
|
const client = await v2();
|
||||||
|
await shipped();
|
||||||
|
await change(db);
|
||||||
|
expect((await client.snapshot()).features.gallery_image_protection)
|
||||||
|
.toEqual({ configured: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The settings preview is the "see exactly what would be sent" view. It shared
|
||||||
|
* snapshot() with the real sender, and snapshot() records applied custom CSS
|
||||||
|
* as a lifetime marker — so reading the transparency view wrote a marker.
|
||||||
|
*/
|
||||||
|
describe('preview does not change what will be sent', () => {
|
||||||
|
let db;
|
||||||
|
afterEach(async () => { if (db) await db.destroy(); db = null; });
|
||||||
|
|
||||||
|
const withAppliedCss = async () => {
|
||||||
|
db = await bootDb();
|
||||||
|
await db('product_usage_state').where({ id: 1 })
|
||||||
|
.update({ status: 'active', consent_version: 'usage-consent.v2' });
|
||||||
|
await db('app_settings').insert({
|
||||||
|
setting_key: 'general_custom_css', setting_value: '".x{}"'
|
||||||
|
});
|
||||||
|
return service(db);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('reports custom_css as used without persisting the marker', async () => {
|
||||||
|
const client = await withAppliedCss();
|
||||||
|
const preview = await client.preview();
|
||||||
|
expect(preview.features.custom_css).toEqual({ configured: true, used: true });
|
||||||
|
expect(await db('product_usage_markers').pluck('feature')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still persists it when the sender builds the real report', async () => {
|
||||||
|
const client = await withAppliedCss();
|
||||||
|
await client.snapshot();
|
||||||
|
expect(await db('product_usage_markers').pluck('feature')).toEqual(['custom_css']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('v2 technical configuration and privacy boundaries', () => {
|
||||||
|
let db;
|
||||||
|
let savedEnv;
|
||||||
|
beforeEach(() => { savedEnv = { ...process.env }; });
|
||||||
|
afterEach(async () => { if (db) await db.destroy(); db = null; process.env = savedEnv; });
|
||||||
|
async function expandedDb() {
|
||||||
|
db = await bootDb();
|
||||||
|
await db('product_usage_state').where({ id: 1 }).update({ status: 'active', consent_version: 'usage-consent.v2' });
|
||||||
|
await db.schema.alterTable('events', (t) => {
|
||||||
|
for (const column of ['allow_user_uploads', 'allow_downloads', 'client_access_enabled', 'watermark_downloads', 'reveal_mode', 'download_resolution_picker_enabled', 'disable_right_click', 'enable_devtools_protection', 'use_canvas_rendering']) t.boolean(column);
|
||||||
|
t.string('protection_level'); t.timestamp('expires_at'); t.string('event_name'); t.string('customer_email');
|
||||||
|
});
|
||||||
|
for (const table of ['email_configs', 'mail_accounts']) await db.schema.alterTable(table, (t) => {
|
||||||
|
t.boolean('enabled'); t.string('imap_host'); t.string('imap_user'); t.string('imap_pass');
|
||||||
|
});
|
||||||
|
await db.schema.createTable('event_feedback_settings', (t) => {
|
||||||
|
t.increments('id'); t.boolean('feedback_enabled'); t.string('identity_mode');
|
||||||
|
for (const col of ['allow_likes', 'allow_ratings', 'allow_comments', 'allow_favorites', 'allow_reactions', 'allow_color_labels']) t.boolean(col);
|
||||||
|
});
|
||||||
|
await db.schema.createTable('api_tokens', (t) => { t.increments('id'); t.timestamp('revoked_at'); t.timestamp('expires_at'); t.string('token_hash'); });
|
||||||
|
await db.schema.createTable('webhooks', (t) => { t.increments('id'); t.boolean('active'); t.string('url'); t.string('secret'); });
|
||||||
|
return service(db, { now: () => Date.parse('2026-09-06T12:00:00.000Z'), version: '3.124.1-beta.0' });
|
||||||
|
}
|
||||||
|
|
||||||
|
it('produces all 73 closed booleans, never exposing sensitive values or configuration-only used', async () => {
|
||||||
|
const client = await expandedDb();
|
||||||
|
const flags = [...new Set(Object.values(CATALOG.features).map((f) => f.flag).filter(Boolean)), 'incomingMail', 'whatsapp'];
|
||||||
|
await db('feature_flags').insert([...new Set(flags)].map((key) => ({ key, value: true })));
|
||||||
|
const settings = {
|
||||||
|
general_allowed_file_types: 'jpg,dng,mp4', general_public_site_enabled: true,
|
||||||
|
database_backup_enabled: true, backup_destination_type: 's3', backup_s3_bucket: 'PRIVATE-bucket',
|
||||||
|
oidc_enabled: true, oidc_issuer_url: 'https://PRIVATE.example.test', oidc_client_id: 'PRIVATE-client',
|
||||||
|
general_custom_css: '.PRIVATE { color:red; }'
|
||||||
|
};
|
||||||
|
await db('app_settings').insert(Object.entries(settings).map(([setting_key, value]) => ({ setting_key, setting_value: JSON.stringify(value) })));
|
||||||
|
await db('events').insert({
|
||||||
|
event_name: 'PRIVATE PERSON', customer_email: '[email protected]', 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, '[email protected]']);
|
||||||
|
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,34 @@
|
|||||||
|
exports.up = async function (knex) {
|
||||||
|
if (!(await knex.schema.hasTable('product_usage_state'))) {
|
||||||
|
await knex.schema.createTable('product_usage_state', (t) => {
|
||||||
|
t.integer('id').primary();
|
||||||
|
t.string('status', 30).notNullable().defaultTo('disabled');
|
||||||
|
t.boolean('notice_dismissed').notNullable().defaultTo(false);
|
||||||
|
t.string('installation_id', 64);
|
||||||
|
t.string('public_key', 59);
|
||||||
|
t.text('private_key_encrypted');
|
||||||
|
t.string('instance_binding', 64);
|
||||||
|
t.bigInteger('sequence').notNullable().defaultTo(0);
|
||||||
|
t.text('pending_packet');
|
||||||
|
t.text('last_packet');
|
||||||
|
t.text('last_receipt');
|
||||||
|
t.string('last_report_date', 10);
|
||||||
|
t.string('last_error', 80);
|
||||||
|
t.text('feedback_preferences');
|
||||||
|
t.string('lease_token', 36);
|
||||||
|
t.bigInteger('lease_until').notNullable().defaultTo(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!(await knex('product_usage_state').where({ id: 1 }).first()))
|
||||||
|
await knex('product_usage_state').insert({ id: 1 });
|
||||||
|
if (!(await knex.schema.hasTable('product_usage_markers'))) {
|
||||||
|
await knex.schema.createTable('product_usage_markers', (t) => {
|
||||||
|
t.string('feature', 60).primary();
|
||||||
|
// A marker is only a capability name, never a timestamp or user/event ID.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
exports.down = async function (knex) {
|
||||||
|
await knex.schema.dropTableIfExists('product_usage_markers');
|
||||||
|
await knex.schema.dropTableIfExists('product_usage_state');
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// Separate from 201 deliberately. 201 already shipped on this branch, and
|
||||||
|
// knex records it as applied — so folding the column into it would silently
|
||||||
|
// skip every database that had already run it, and the first /disable would
|
||||||
|
// fail on a missing column. Its own migration runs everywhere.
|
||||||
|
exports.up = async function (knex) {
|
||||||
|
if (!(await knex.schema.hasTable('product_usage_state'))) return;
|
||||||
|
if (await knex.schema.hasColumn('product_usage_state', 'cancel_requested')) return;
|
||||||
|
await knex.schema.alterTable('product_usage_state', (t) => {
|
||||||
|
// Set by /disable so an activation still generating its identity — during
|
||||||
|
// which the row still reads `disabled` — cannot go on to complete after
|
||||||
|
// the admin has asked to withdraw.
|
||||||
|
t.boolean('cancel_requested').notNullable().defaultTo(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function (knex) {
|
||||||
|
if (!(await knex.schema.hasTable('product_usage_state'))) return;
|
||||||
|
if (!(await knex.schema.hasColumn('product_usage_state', 'cancel_requested'))) return;
|
||||||
|
await knex.schema.alterTable('product_usage_state', (t) => {
|
||||||
|
t.dropColumn('cancel_requested');
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// Supersedes the boolean added in 202. A boolean cannot distinguish "a
|
||||||
|
// withdrawal arrived while this activation was starting" from "a withdrawal
|
||||||
|
// from an earlier participation was never cleared": clearing it needed its
|
||||||
|
// own write, and a /disable landing between the lease and that write was
|
||||||
|
// erased. A monotonic counter needs no clearing — enable() records the value
|
||||||
|
// it started with and claims only if it is unchanged, so any intervening
|
||||||
|
// withdrawal is visible whatever the previous state was.
|
||||||
|
exports.up = async function (knex) {
|
||||||
|
if (!(await knex.schema.hasTable('product_usage_state'))) return;
|
||||||
|
if (!(await knex.schema.hasColumn('product_usage_state', 'cancel_seq')))
|
||||||
|
await knex.schema.alterTable('product_usage_state', (t) => {
|
||||||
|
t.bigInteger('cancel_seq').notNullable().defaultTo(0);
|
||||||
|
});
|
||||||
|
if (await knex.schema.hasColumn('product_usage_state', 'cancel_requested'))
|
||||||
|
await knex.schema.alterTable('product_usage_state', (t) => {
|
||||||
|
t.dropColumn('cancel_requested');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function (knex) {
|
||||||
|
if (!(await knex.schema.hasTable('product_usage_state'))) return;
|
||||||
|
if (await knex.schema.hasColumn('product_usage_state', 'cancel_seq'))
|
||||||
|
await knex.schema.alterTable('product_usage_state', (t) => {
|
||||||
|
t.dropColumn('cancel_seq');
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Bounded, local-only audit receipts. Never retain an installation identity,
|
||||||
|
// signing key, report/feedback payload or collector credential after opt-out.
|
||||||
|
exports.up = async function (knex) {
|
||||||
|
if (
|
||||||
|
(await knex.schema.hasTable('product_usage_state')) &&
|
||||||
|
!(await knex.schema.hasColumn('product_usage_state', 'privacy_receipts'))
|
||||||
|
) {
|
||||||
|
await knex.schema.alterTable('product_usage_state', (t) =>
|
||||||
|
t.text('privacy_receipts')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (await knex.schema.hasColumn('product_usage_state', 'last_receipt')) {
|
||||||
|
const row = await knex('product_usage_state').where({ id: 1 }).first();
|
||||||
|
if (row?.last_receipt) {
|
||||||
|
const receipt = JSON.parse(row.last_receipt);
|
||||||
|
if (receipt.session_token) {
|
||||||
|
delete receipt.session_token;
|
||||||
|
await knex('product_usage_state')
|
||||||
|
.where({ id: 1 })
|
||||||
|
.update({ last_receipt: JSON.stringify(receipt) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
exports.down = async function (knex) {
|
||||||
|
if (
|
||||||
|
(await knex.schema.hasTable('product_usage_state')) &&
|
||||||
|
(await knex.schema.hasColumn('product_usage_state', 'privacy_receipts'))
|
||||||
|
) {
|
||||||
|
await knex.schema.alterTable('product_usage_state', (t) =>
|
||||||
|
t.dropColumn('privacy_receipts')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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'));
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Retry pacing for the collector. Without it every failed packet was retried
|
||||||
|
// on the next admin request: /activity is open to any authenticated admin and
|
||||||
|
// the settings ticker fires it every five minutes per open tab, so an
|
||||||
|
// installation whose packet the collector rejects permanently hammered it
|
||||||
|
// once per admin action, forever, with a failing request sitting on the
|
||||||
|
// critical path of that action.
|
||||||
|
//
|
||||||
|
// `attempts` counts consecutive failures and `next_attempt_at` is the epoch-ms
|
||||||
|
// gate the automatic sender honours. Explicit operator actions — Retry and
|
||||||
|
// Disable — pass through regardless; the point is to pace the unattended loop,
|
||||||
|
// not to make the admin wait out a backoff they asked to skip.
|
||||||
|
exports.up = async function (knex) {
|
||||||
|
if (!(await knex.schema.hasTable('product_usage_state'))) return;
|
||||||
|
if (!(await knex.schema.hasColumn('product_usage_state', 'attempts')))
|
||||||
|
await knex.schema.alterTable('product_usage_state', (t) => {
|
||||||
|
t.integer('attempts').notNullable().defaultTo(0);
|
||||||
|
});
|
||||||
|
if (!(await knex.schema.hasColumn('product_usage_state', 'next_attempt_at')))
|
||||||
|
await knex.schema.alterTable('product_usage_state', (t) => {
|
||||||
|
t.bigInteger('next_attempt_at').notNullable().defaultTo(0);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function (knex) {
|
||||||
|
if (!(await knex.schema.hasTable('product_usage_state'))) return;
|
||||||
|
for (const column of ['attempts', 'next_attempt_at'])
|
||||||
|
if (await knex.schema.hasColumn('product_usage_state', column))
|
||||||
|
await knex.schema.alterTable('product_usage_state', (t) => {
|
||||||
|
t.dropColumn(column);
|
||||||
|
});
|
||||||
|
};
|
||||||
Generated
+86
-14
@@ -1,16 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "3.122.5-beta.0",
|
"version": "3.123.0-beta.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "3.122.5-beta.0",
|
"version": "3.123.0-beta.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.850.0",
|
"@aws-sdk/client-s3": "^3.850.0",
|
||||||
"@aws-sdk/lib-storage": "^3.850.0",
|
"@aws-sdk/lib-storage": "^3.850.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.850.0",
|
"@aws-sdk/s3-request-presigner": "^3.850.0",
|
||||||
|
"ajv": "^8.20.0",
|
||||||
"archiver": "^5.3.1",
|
"archiver": "^5.3.1",
|
||||||
"axios": "1.18.1",
|
"axios": "1.18.1",
|
||||||
"bcrypt": "6.0.0",
|
"bcrypt": "6.0.0",
|
||||||
@@ -1611,6 +1612,30 @@
|
|||||||
"url": "https://opencollective.com/eslint"
|
"url": "https://opencollective.com/eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@eslint/eslintrc/node_modules/ajv": {
|
||||||
|
"version": "6.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||||
|
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-deep-equal": "^3.1.1",
|
||||||
|
"fast-json-stable-stringify": "^2.0.0",
|
||||||
|
"json-schema-traverse": "^0.4.1",
|
||||||
|
"uri-js": "^4.2.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": {
|
||||||
|
"version": "0.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||||
|
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@eslint/js": {
|
"node_modules/@eslint/js": {
|
||||||
"version": "8.57.1",
|
"version": "8.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
|
||||||
@@ -4006,16 +4031,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "6.14.0",
|
"version": "8.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||||
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.1",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"fast-json-stable-stringify": "^2.0.0",
|
"fast-uri": "^3.0.1",
|
||||||
"json-schema-traverse": "^0.4.1",
|
"json-schema-traverse": "^1.0.0",
|
||||||
"uri-js": "^4.2.2"
|
"require-from-string": "^2.0.2"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -5848,6 +5872,30 @@
|
|||||||
"url": "https://opencollective.com/eslint"
|
"url": "https://opencollective.com/eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eslint/node_modules/ajv": {
|
||||||
|
"version": "6.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||||
|
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-deep-equal": "^3.1.1",
|
||||||
|
"fast-json-stable-stringify": "^2.0.0",
|
||||||
|
"json-schema-traverse": "^0.4.1",
|
||||||
|
"uri-js": "^4.2.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/eslint/node_modules/json-schema-traverse": {
|
||||||
|
"version": "0.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||||
|
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/esm": {
|
"node_modules/esm": {
|
||||||
"version": "3.2.25",
|
"version": "3.2.25",
|
||||||
"resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz",
|
"resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz",
|
||||||
@@ -6130,6 +6178,22 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-uri": {
|
||||||
|
"version": "3.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
||||||
|
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/fast-xml-builder": {
|
"node_modules/fast-xml-builder": {
|
||||||
"version": "1.1.9",
|
"version": "1.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.9.tgz",
|
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.9.tgz",
|
||||||
@@ -8105,10 +8169,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/json-schema-traverse": {
|
"node_modules/json-schema-traverse": {
|
||||||
"version": "0.4.1",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/json-stable-stringify-without-jsonify": {
|
"node_modules/json-stable-stringify-without-jsonify": {
|
||||||
@@ -10805,6 +10868,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-from-string": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/require-main-filename": {
|
"node_modules/require-main-filename": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"@aws-sdk/client-s3": "^3.850.0",
|
"@aws-sdk/client-s3": "^3.850.0",
|
||||||
"@aws-sdk/lib-storage": "^3.850.0",
|
"@aws-sdk/lib-storage": "^3.850.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.850.0",
|
"@aws-sdk/s3-request-presigner": "^3.850.0",
|
||||||
|
"ajv": "^8.20.0",
|
||||||
"archiver": "^5.3.1",
|
"archiver": "^5.3.1",
|
||||||
"axios": "1.18.1",
|
"axios": "1.18.1",
|
||||||
"bcrypt": "6.0.0",
|
"bcrypt": "6.0.0",
|
||||||
|
|||||||
+3
-1
@@ -824,6 +824,8 @@ app.get(
|
|||||||
// Routes
|
// Routes
|
||||||
app.use('/api/setup', setupRoutes); // public first-run bootstrap (self-closes after setup)
|
app.use('/api/setup', setupRoutes); // public first-run bootstrap (self-closes after setup)
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
|
app.use('/api/admin', require('./src/middleware/productUsage').productUsage);
|
||||||
|
app.use('/api/admin/usage', require('./src/routes/adminUsage'));
|
||||||
app.use('/api/admin/external-media', require('./src/routes/adminExternalMedia'));
|
app.use('/api/admin/external-media', require('./src/routes/adminExternalMedia'));
|
||||||
// Gallery routes - main routes first, then feedback routes
|
// Gallery routes - main routes first, then feedback routes
|
||||||
app.use('/api/gallery', galleryRoutes);
|
app.use('/api/gallery', galleryRoutes);
|
||||||
@@ -933,7 +935,7 @@ app.use('/api/admin/api-tokens', require('./src/routes/adminApiTokens'));
|
|||||||
app.use('/api/admin/webhooks', require('./src/routes/adminWebhooks'));
|
app.use('/api/admin/webhooks', require('./src/routes/adminWebhooks'));
|
||||||
// Public v1 API for n8n / external integrations (#322). Mounted under
|
// Public v1 API for n8n / external integrations (#322). Mounted under
|
||||||
// /api/v1; auth handled per-route via apiTokenAuth (Bearer tokens).
|
// /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
|
// 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).
|
// that should not be enumerable to anonymous users (a common reduce-info-leak hardening).
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// Only successful authenticated ADMIN capability operations set a coarse
|
||||||
|
// marker. Never mount on gallery/customer/public routes. No request values,
|
||||||
|
// identifiers, paths, timing, or counts are retained or sent.
|
||||||
|
const service = require('../services/productUsageService');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
const { capabilityKeys } = require('../usage/capabilityRules');
|
||||||
|
// Mirrors emailWebhookTransport: the webhook is in play only when both are
|
||||||
|
// set, which is when adminEmail routes the test send through it.
|
||||||
|
const webhookTransportConfigured = () =>
|
||||||
|
Boolean(
|
||||||
|
(process.env.EMAIL_WEBHOOK_URL || '').trim() &&
|
||||||
|
(process.env.EMAIL_WEBHOOK_SECRET || '').trim()
|
||||||
|
);
|
||||||
|
|
||||||
|
const RULES = [
|
||||||
|
[/^\/customers(?:\/|$)/, ['crm']],
|
||||||
|
[/^\/quotes(?:\/|$)/, ['crm', 'crm_quotes']],
|
||||||
|
[/^\/invoices(?:\/|$)/, ['crm', 'crm_invoices']],
|
||||||
|
[/^\/contracts(?:\/|$)/, ['crm', 'crm_contracts']],
|
||||||
|
[/^\/projects(?:\/|$)/, ['crm', 'crm_projects']],
|
||||||
|
[/^\/calendar(?:\/|$)/, ['crm', 'crm_calendar']],
|
||||||
|
[/^\/customers\/(?:[^/]+\/)?hour-entries(?:\/|$)/, ['crm', 'crm_hours']],
|
||||||
|
[/^\/customers\/(?:invite|[^/]+\/send-invite)(?:\/|$)/, ['customer_portal']],
|
||||||
|
[
|
||||||
|
/^\/(?:ledger|expenses|tax-report|incoming-invoices)(?:\/|$)/,
|
||||||
|
['accounting']
|
||||||
|
],
|
||||||
|
[/^\/workflows(?:\/|$)/, ['workflows']],
|
||||||
|
[/^\/newsletters(?:\/|$)/, ['newsletters']],
|
||||||
|
[/^\/events\/[^/]+\/(?:faces|people)(?:\/|$)/, ['face_recognition']],
|
||||||
|
[/^\/whatsapp\/(?:send|test)(?:\/|$)/, ['whatsapp']],
|
||||||
|
[
|
||||||
|
/^\/(?:backup|database-backup)\/(?:run|backup|create|start|test|picpeak\/export)(?:\/|$)/,
|
||||||
|
['backup']
|
||||||
|
],
|
||||||
|
[/^\/backup\/s3\/test-upload(?:\/|$)/, ['s3_storage']],
|
||||||
|
[/^\/email\/(?:test|send)(?:\/|$)/, ['smtp']],
|
||||||
|
[/^\/external-media(?:\/|$)/, ['share_mounts']]
|
||||||
|
];
|
||||||
|
// Backup operations that write to the CONFIGURED destination, and so imply
|
||||||
|
// S3 use when that destination is S3. Deliberately excludes
|
||||||
|
// /backup/picpeak/export and everything under /database-backup/, which
|
||||||
|
// produce a local file regardless of where scheduled backups go.
|
||||||
|
const DESTINATION_BACKUP = /^\/backup\/(?:run|backup|create|start|test)(?:\/|$)/;
|
||||||
|
|
||||||
|
function productUsage(req, res, next) {
|
||||||
|
const pathname = req.path;
|
||||||
|
res.once('finish', () => {
|
||||||
|
if (!req.admin?.id || res.statusCode < 200 || res.statusCode >= 300) return;
|
||||||
|
let features = RULES.filter(([pattern]) =>
|
||||||
|
pattern.test(pathname)
|
||||||
|
).flatMap(([, keys]) => keys);
|
||||||
|
// A webhook-only install sends /email/test through the webhook transport
|
||||||
|
// and never touches SMTP (adminEmail.js has an explicit path for it,
|
||||||
|
// #1225), so recording smtp here would permanently misclassify it.
|
||||||
|
if (features.includes('smtp') && webhookTransportConfigured())
|
||||||
|
features = features.filter((f) => f !== 'smtp');
|
||||||
|
if (
|
||||||
|
process.env.STORAGE_BACKEND === 's3' &&
|
||||||
|
/^\/(?:photos|events)\/[^/]+\/upload(?:\/|$)/.test(pathname)
|
||||||
|
)
|
||||||
|
features.push('s3_storage');
|
||||||
|
const expanded = [...new Set([
|
||||||
|
...capabilityKeys(req.method, pathname),
|
||||||
|
...(res.locals.productUsageFeatures || [])
|
||||||
|
])];
|
||||||
|
if (features.length || expanded.length)
|
||||||
|
service
|
||||||
|
.markUsed(expanded, {
|
||||||
|
legacyFeatures: features,
|
||||||
|
destinationBackup: DESTINATION_BACKUP.test(pathname)
|
||||||
|
})
|
||||||
|
.catch(() => logger.warn('Product usage marker could not be recorded'));
|
||||||
|
});
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
// Integration calls can record one general capability, but never trigger the
|
||||||
|
// daily sender. Public/customer/gallery routes do not mount this middleware.
|
||||||
|
function productUsageApi(req, res, next) {
|
||||||
|
res.once('finish', () => {
|
||||||
|
if (!req.admin?.id || !req.apiToken || res.statusCode < 200 || res.statusCode >= 300) return;
|
||||||
|
service.markUsed(['api_integration'], { legacyFeatures: [] })
|
||||||
|
.catch(() => logger.warn('Product usage API marker could not be recorded'));
|
||||||
|
});
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
module.exports = { productUsage, productUsageApi, RULES, DESTINATION_BACKUP };
|
||||||
@@ -796,6 +796,8 @@ router.post('/s3/test-upload', adminAuth, requirePermission('backup.create'), as
|
|||||||
// Test deletion
|
// Test deletion
|
||||||
await s3Adapter.delete(testKey);
|
await s3Adapter.delete(testKey);
|
||||||
|
|
||||||
|
if (contentMatch) require('../usage/capabilityEvidence').capabilityEvidence(res, 's3_storage', 's3_backups');
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
testKey: testKey,
|
testKey: testKey,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const { capabilityEvidence } = require('../usage/capabilityEvidence');
|
||||||
const nodemailer = require('nodemailer');
|
const nodemailer = require('nodemailer');
|
||||||
const { body, query, validationResult } = require('express-validator');
|
const { body, query, validationResult } = require('express-validator');
|
||||||
const { db, logActivity } = require('../database/db');
|
const { db, logActivity } = require('../database/db');
|
||||||
@@ -221,6 +222,7 @@ router.post('/incoming-config/test', adminAuth, requirePermission('email.view'),
|
|||||||
if (result && result.ok === false) {
|
if (result && result.ok === false) {
|
||||||
return res.status(400).json({ error: 'Incoming mail is not configured yet — enter host, username and password first.' });
|
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);
|
res.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('IMAP connection test error:', error);
|
logger.error('IMAP connection test error:', error);
|
||||||
@@ -234,7 +236,10 @@ router.post('/incoming-config/roundtrip', adminAuth, requirePermission('email.se
|
|||||||
try {
|
try {
|
||||||
const emailIntakeService = require('../services/emailIntakeService');
|
const emailIntakeService = require('../services/emailIntakeService');
|
||||||
const result = await emailIntakeService.roundTripTest();
|
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 = {
|
const map = {
|
||||||
smtp_unconfigured: 'Configure and save the outgoing SMTP settings first.',
|
smtp_unconfigured: 'Configure and save the outgoing SMTP settings first.',
|
||||||
imap_unconfigured: 'Configure and save the incoming IMAP 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 {
|
try {
|
||||||
const emailIntakeService = require('../services/emailIntakeService');
|
const emailIntakeService = require('../services/emailIntakeService');
|
||||||
const result = await emailIntakeService.pollOnce();
|
const result = await emailIntakeService.pollOnce();
|
||||||
|
if (result && !result.skipped) capabilityEvidence(res, 'incoming_mail');
|
||||||
res.json(result); // { processed } or { skipped: 'disabled'|'unconfigured'|'busy' }
|
res.json(result); // { processed } or { skipped: 'disabled'|'unconfigured'|'busy' }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Manual poll error:', 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,
|
host: b.imap_host, port: b.imap_port, secure: b.imap_secure,
|
||||||
user: b.imap_user, pass, folder: b.imap_folder || 'INBOX',
|
user: b.imap_user, pass, folder: b.imap_folder || 'INBOX',
|
||||||
});
|
});
|
||||||
|
if (result?.ok) capabilityEvidence(res, 'incoming_mail');
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(422).json({ ok: false, error: `Mailbox test failed (${error.message}).` });
|
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,
|
details: webhookError.message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
capabilityEvidence(res, 'email_webhook');
|
||||||
return res.json({ message: 'Test email sent successfully' });
|
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')
|
+ await buildSignatureTextFor('en')
|
||||||
});
|
});
|
||||||
|
|
||||||
|
capabilityEvidence(res, 'smtp');
|
||||||
res.json({ message: 'Test email sent successfully' });
|
res.json({ message: 'Test email sent successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Test email error:', 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 emailProcessor = require('../services/emailProcessor');
|
||||||
const result = await emailProcessor.sendRawEmail({ to, cc, subject, html, accountKey });
|
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({
|
await db('email_queue').insert({
|
||||||
recipient_email: to,
|
recipient_email: to,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const { adminAuth } = require('../middleware/auth');
|
|||||||
const { requirePermission } = require('../middleware/permissions');
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const { ensureThumbnail } = require('../services/imageProcessor');
|
const { ensureThumbnail } = require('../services/imageProcessor');
|
||||||
const { isVideoMimeType } = require('../services/videoProcessor');
|
const { isVideoMimeType } = require('../services/videoProcessor');
|
||||||
|
const { acceptedUpload } = require('../usage/capabilityEvidence');
|
||||||
const { generatePhotoFilename, buildContentDisposition } = require('../utils/filenameSanitizer');
|
const { generatePhotoFilename, buildContentDisposition } = require('../utils/filenameSanitizer');
|
||||||
const {
|
const {
|
||||||
getUseOriginalFilenames,
|
getUseOriginalFilenames,
|
||||||
@@ -357,6 +358,11 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r
|
|||||||
event,
|
event,
|
||||||
});
|
});
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
acceptedUpload(res, {
|
||||||
|
video: isVideoMimeType(file.mimetype),
|
||||||
|
raw: path.extname(file.originalname).toLowerCase() === '.dng',
|
||||||
|
s3: process.env.STORAGE_BACKEND === 's3'
|
||||||
|
});
|
||||||
replacedPhotos.push({
|
replacedPhotos.push({
|
||||||
id: result.photo.id,
|
id: result.photo.id,
|
||||||
filename: result.photo.filename,
|
filename: result.photo.filename,
|
||||||
@@ -471,6 +477,8 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r
|
|||||||
.returning('id');
|
.returning('id');
|
||||||
const photoId = inserted[0]?.id || inserted[0];
|
const photoId = inserted[0]?.id || inserted[0];
|
||||||
|
|
||||||
|
acceptedUpload(res, { video: isVideo, raw: extension.toLowerCase() === '.dng', s3: process.env.STORAGE_BACKEND === 's3' });
|
||||||
|
|
||||||
uploadedPhotos.push({
|
uploadedPhotos.push({
|
||||||
id: photoId,
|
id: photoId,
|
||||||
filename: newFilename,
|
filename: newFilename,
|
||||||
@@ -1720,6 +1728,11 @@ router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, requirePer
|
|||||||
'admin',
|
'admin',
|
||||||
category_id || null
|
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
|
// Clean up temp directory
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const rateLimit = require('express-rate-limit');
|
||||||
|
const { adminAuth } = require('../middleware/auth');
|
||||||
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
|
const { ValidationError } = require('../utils/errors');
|
||||||
|
const service = require('../services/productUsageService');
|
||||||
|
const { ProtocolError } = require('../usage/protocol.cjs');
|
||||||
|
const router = express.Router();
|
||||||
|
const wrap = (fn) => (req, res, next) =>
|
||||||
|
Promise.resolve(fn(req, res)).catch((error) => {
|
||||||
|
// instanceof, not `error.name`: ProtocolError extends Error without
|
||||||
|
// setting `name`, so every instance reports 'Error' and this branch never
|
||||||
|
// ran. A malformed vote or feedback payload fell through to the global
|
||||||
|
// handler, which logs it as an unhandled programming error and answers
|
||||||
|
// INTERNAL_ERROR in production — losing the validation code the caller
|
||||||
|
// needs. protocol.cjs is vendored byte-identical with picpeak-usage, so
|
||||||
|
// the fix belongs here rather than in the class.
|
||||||
|
if (error instanceof ProtocolError)
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'Invalid usage request', code: error.code });
|
||||||
|
next(error);
|
||||||
|
});
|
||||||
|
// The three routes below are the only ones whose effect is an outbound
|
||||||
|
// request to someone else's service, carrying operator-written free text
|
||||||
|
// (title 120, body 4000, name 80). The platform's general limiter skips
|
||||||
|
// authenticated requests by design, which is right for endpoints that only
|
||||||
|
// touch this installation and wrong for a relay: without this an admin
|
||||||
|
// session can push unbounded traffic at the collector.
|
||||||
|
//
|
||||||
|
// Keyed to the installation, not the caller's IP, because the budget being
|
||||||
|
// protected is "how much this install relays", and per-process because that
|
||||||
|
// is the same store the rest of the app uses — a multi-replica deployment
|
||||||
|
// gets one budget per replica, which still bounds the shape that matters.
|
||||||
|
const outboundLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 60 * 1000,
|
||||||
|
max: 30,
|
||||||
|
keyGenerator: () => 'usage-outbound',
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
handler: (_req, res) =>
|
||||||
|
res.status(429).json({
|
||||||
|
error: 'Too many usage submissions. Try again later.',
|
||||||
|
code: 'USAGE_RATE_LIMITED'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
router.use(adminAuth);
|
||||||
|
router.use((_req, res, next) => {
|
||||||
|
res.set('Cache-Control', 'no-store');
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
// Any authenticated admin can trigger the daily rollup; only settings editors
|
||||||
|
// see identity/packets or control consent. The route never accepts telemetry.
|
||||||
|
router.post(
|
||||||
|
'/activity',
|
||||||
|
wrap(async (_req, res) => {
|
||||||
|
try {
|
||||||
|
await service.tick();
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== 'CONFLICT') throw error;
|
||||||
|
}
|
||||||
|
res.json({ ok: true });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
router.use(requirePermission('settings.edit'));
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
wrap(async (_req, res) => res.json(await service.status()))
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
'/dismiss',
|
||||||
|
wrap(async (_req, res) => res.json(await service.dismiss()))
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
'/enable',
|
||||||
|
wrap(async (req, res) =>
|
||||||
|
res.json(await service.enable(req.body.consent_version))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
'/consent',
|
||||||
|
wrap(async (req, res) => {
|
||||||
|
if (!req.body || Object.keys(req.body).length !== 1 || req.body.consent_version !== 'usage-consent.v2')
|
||||||
|
throw new ValidationError('Explicit usage v2 consent is required');
|
||||||
|
res.json(await service.command('consent', { consent_version: 'usage-consent.v2' }));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
'/disable',
|
||||||
|
wrap(async (_req, res) => res.json(await service.disable()))
|
||||||
|
);
|
||||||
|
// Reachable only from a withdrawal whose delete packet can never be signed;
|
||||||
|
// the service refuses in every other state. See UsageService.abandon().
|
||||||
|
router.post(
|
||||||
|
'/abandon',
|
||||||
|
wrap(async (_req, res) => res.json(await service.abandon()))
|
||||||
|
);
|
||||||
|
// An operator asking for a retry skips the delivery backoff — that button
|
||||||
|
// exists precisely to not wait for the next window.
|
||||||
|
router.post(
|
||||||
|
'/retry',
|
||||||
|
wrap(async (_req, res) => res.json(await service.tick({ force: true })))
|
||||||
|
);
|
||||||
|
router.get(
|
||||||
|
'/preview',
|
||||||
|
wrap(async (_req, res) => res.json(await service.preview()))
|
||||||
|
);
|
||||||
|
router.get(
|
||||||
|
'/export',
|
||||||
|
wrap(async (_req, res) =>
|
||||||
|
res.attachment('picpeak-usage-packets.json').json(await service.export())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
router.put(
|
||||||
|
'/feedback-preferences',
|
||||||
|
wrap(async (req, res) => res.json(await service.preferences(req.body)))
|
||||||
|
);
|
||||||
|
// Every field the packet schema requires. The allowlist used to let `name`,
|
||||||
|
// `allow_public` and `allow_marketing` be omitted, and the packet schema —
|
||||||
|
// which requires all of them — then failed with a bare INVALID_PACKET instead
|
||||||
|
// of naming the missing field. The UI always sends them; anything driving the
|
||||||
|
// API directly did not, and got an error it could not act on.
|
||||||
|
const FEEDBACK_FIELDS = [
|
||||||
|
'kind',
|
||||||
|
'title',
|
||||||
|
'body',
|
||||||
|
'name',
|
||||||
|
'allow_public',
|
||||||
|
'allow_marketing'
|
||||||
|
];
|
||||||
|
router.post(
|
||||||
|
'/feedback',
|
||||||
|
outboundLimiter,
|
||||||
|
wrap(async (req, res) => {
|
||||||
|
const body = req.body;
|
||||||
|
if (!body || typeof body !== 'object')
|
||||||
|
throw new ValidationError('Invalid feedback');
|
||||||
|
const unknown = Object.keys(body).filter(
|
||||||
|
(key) => !FEEDBACK_FIELDS.includes(key)
|
||||||
|
);
|
||||||
|
if (unknown.length)
|
||||||
|
throw new ValidationError(
|
||||||
|
`Unknown feedback fields: ${unknown.join(', ')}`
|
||||||
|
);
|
||||||
|
for (const key of ['kind', 'title', 'body', 'name'])
|
||||||
|
if (typeof body[key] !== 'string')
|
||||||
|
throw new ValidationError(`Feedback field "${key}" must be a string`);
|
||||||
|
for (const key of ['allow_public', 'allow_marketing'])
|
||||||
|
if (typeof body[key] !== 'boolean')
|
||||||
|
throw new ValidationError(`Feedback field "${key}" must be a boolean`);
|
||||||
|
if (!body.title.trim() || !body.body.trim())
|
||||||
|
throw new ValidationError('Feedback title and body are required');
|
||||||
|
res.json(
|
||||||
|
await service.command('feedback', {
|
||||||
|
...body,
|
||||||
|
feedback_id: crypto.randomUUID()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
'/vote',
|
||||||
|
outboundLimiter,
|
||||||
|
wrap(async (req, res) => res.json(await service.command('vote', req.body)))
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
'/portal-session',
|
||||||
|
outboundLimiter,
|
||||||
|
wrap(async (_req, res) => {
|
||||||
|
const result = await service.command('session', {});
|
||||||
|
res.json({
|
||||||
|
...result,
|
||||||
|
url: result.receipt?.session_token
|
||||||
|
? `${service.collectorUrl()}/#connect=${encodeURIComponent(result.receipt.session_token)}`
|
||||||
|
: null
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
module.exports = router;
|
||||||
@@ -176,6 +176,7 @@ router.post('/test', adminAuth, requirePermission('whatsapp.manage'), async (req
|
|||||||
};
|
};
|
||||||
const testComponents = buildComponents(testData, language, params);
|
const testComponents = buildComponents(testData, language, params);
|
||||||
const result = await sendWhatsAppMessage(phone, config, language, testComponents);
|
const result = await sendWhatsAppMessage(phone, config, language, testComponents);
|
||||||
|
require('../usage/capabilityEvidence').capabilityEvidence(res, 'whatsapp');
|
||||||
res.json({ success: true, messageId: result.messageId });
|
res.json({ success: true, messageId: result.messageId });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('WhatsApp test send error:', error);
|
logger.error('WhatsApp test send error:', error);
|
||||||
|
|||||||
@@ -1145,6 +1145,10 @@ router.get('/admin/sso/callback', async (req, res) => {
|
|||||||
await logActivity('admin_sso_login', { provider: 'oidc' }, null, {
|
await logActivity('admin_sso_login', { provider: 'oidc' }, null, {
|
||||||
type: 'admin', id: admin.id, name: admin.username,
|
type: 'admin', id: admin.id, name: admin.username,
|
||||||
});
|
});
|
||||||
|
// Only a successful ADMIN SSO callback sets this opt-in capability marker.
|
||||||
|
// No token, claim, address, or account identifier reaches product usage.
|
||||||
|
require('../services/productUsageService').markUsed(['oauth'])
|
||||||
|
.catch(() => logger.warn('Product usage marker could not be recorded'));
|
||||||
|
|
||||||
return res.redirect(`${frontendBase}/admin/dashboard`);
|
return res.redirect(`${frontendBase}/admin/dashboard`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1091,7 +1091,7 @@ async function sendRawEmail({ to, cc, subject, html, text, attachments, accountK
|
|||||||
? await emailWebhookTransport.send(mail)
|
? await emailWebhookTransport.send(mail)
|
||||||
: await tx.sendMail(mail);
|
: await tx.sendMail(mail);
|
||||||
logger.info(`Manual email sent: ${info.messageId}`);
|
logger.info(`Manual email sent: ${info.messageId}`);
|
||||||
return { messageId: info.messageId, html };
|
return { messageId: info.messageId, html, transport: viaWebhook ? 'webhook' : 'smtp' };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
const { db } = require('../database/db');
|
||||||
|
const { UsageService } = require('../usage/UsageService');
|
||||||
|
module.exports = new UsageService(db);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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,121 @@
|
|||||||
|
'use strict';
|
||||||
|
const { CATALOG, emptyFeatures } = require('./schema.cjs');
|
||||||
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
|
|
||||||
|
const truth = (value) => value === true || value === 1 || value === '1';
|
||||||
|
const parse = (value) => {
|
||||||
|
for (let i = 0; i < 3 && typeof value === 'string'; i++) {
|
||||||
|
try { const decoded = JSON.parse(value); if (decoded === value) break; value = decoded; }
|
||||||
|
catch { break; }
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Technical configuration only. Never read photos, feedback contents, guest /
|
||||||
|
// customer / admin profiles, messages, audit logs, delivery logs or counts.
|
||||||
|
// Presence queries return a literal 1, not even a row's identifying primary key.
|
||||||
|
async function expandSnapshot(db, { features, flags, used, now }) {
|
||||||
|
const result = { ...emptyFeatures('usage.v2'), ...features };
|
||||||
|
const effective = { analytics: true, userManagement: true, ...flags };
|
||||||
|
if (!effective.quotes) effective.bills = false;
|
||||||
|
if (effective.bills) effective.accounting = true;
|
||||||
|
if (!effective.accounting) {
|
||||||
|
effective.incomingInvoices = false;
|
||||||
|
effective.expenses = false;
|
||||||
|
effective.taxReport = false;
|
||||||
|
}
|
||||||
|
effective.clients = ['customerPortal', 'quotes', 'bills', 'contracts', 'projects', 'calendar', 'hoursLogging', 'newsletters']
|
||||||
|
.some((flag) => effective[flag]);
|
||||||
|
if (['1', 'true', 'yes'].includes(String(process.env.PICPEAK_SINGLE_CONTAINER || '').toLowerCase())) effective.faces = false;
|
||||||
|
for (const [key, definition] of Object.entries(CATALOG.features)) {
|
||||||
|
if (definition.configuration === 'builtin') result[key].configured = true;
|
||||||
|
if (definition.flag) result[key].configured = Boolean(effective[definition.flag]);
|
||||||
|
if (definition.used && key !== 'custom_css') result[key].used = used.has(key);
|
||||||
|
if (!definition.used) delete result[key].used;
|
||||||
|
}
|
||||||
|
// Applied custom CSS is detected locally without any visitor observation.
|
||||||
|
result.custom_css.used = features.custom_css.used;
|
||||||
|
|
||||||
|
const has = async (table, columns) => {
|
||||||
|
if (!(await db.schema.hasTable(table))) return false;
|
||||||
|
for (const column of columns) if (!(await db.schema.hasColumn(table, column))) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const exists = async (table, columns, filter) => {
|
||||||
|
if (!(await has(table, columns))) return false;
|
||||||
|
const query = db(table);
|
||||||
|
filter(query);
|
||||||
|
return Boolean(await query.select(db.raw('1 as present')).first());
|
||||||
|
};
|
||||||
|
const enabled = (table, column, filter = () => {}) => exists(table, [column], (query) => {
|
||||||
|
query.where(column, formatBoolean(true)); filter(query);
|
||||||
|
});
|
||||||
|
const settingKeys = [
|
||||||
|
'general_allowed_file_types', 'general_public_site_enabled',
|
||||||
|
'download_resolution_picker_enabled', 'branding_watermark_enabled',
|
||||||
|
'database_backup_enabled', 'backup_destination_type', 'backup_s3_bucket',
|
||||||
|
'default_protection_level', 'enable_devtools_protection', 'enable_canvas_rendering'
|
||||||
|
];
|
||||||
|
const settings = Object.fromEntries((await db('app_settings')
|
||||||
|
.whereIn('setting_key', settingKeys).select('setting_key', 'setting_value'))
|
||||||
|
.map((row) => [row.setting_key, parse(row.setting_value)]));
|
||||||
|
const extensions = new Set(String(settings.general_allowed_file_types || 'jpg,jpeg,png,webp')
|
||||||
|
.toLowerCase().split(',').map((s) => s.trim().replace(/^\./, '')));
|
||||||
|
result.video_uploads.configured = ['mp4', 'm4v', 'webm', 'mov', 'avi'].some((extension) => extensions.has(extension));
|
||||||
|
result.camera_raw_uploads.configured = extensions.has('dng');
|
||||||
|
result.public_site.configured = truth(settings.general_public_site_enabled);
|
||||||
|
result.database_backup.configured = truth(settings.database_backup_enabled);
|
||||||
|
result.email_webhook.configured = Boolean((process.env.EMAIL_WEBHOOK_URL || '').trim() && (process.env.EMAIL_WEBHOOK_SECRET || '').trim());
|
||||||
|
result.s3_photo_storage.configured = process.env.STORAGE_BACKEND === 's3' &&
|
||||||
|
Boolean(process.env.STORAGE_S3_BUCKET && process.env.STORAGE_S3_ACCESS_KEY && process.env.STORAGE_S3_SECRET_KEY);
|
||||||
|
result.s3_backups.configured = settings.backup_destination_type === 's3' && Boolean(settings.backup_s3_bucket);
|
||||||
|
result.crm_installments.configured = Boolean(effective.quotes || effective.bills);
|
||||||
|
result.document_templates.configured = Boolean(effective.quotes || effective.contracts);
|
||||||
|
const imapColumns = ['imap_host', 'imap_user', 'imap_pass'];
|
||||||
|
const imapPresent = (query) => { for (const column of imapColumns) query.whereNotNull(column).whereNot(column, ''); };
|
||||||
|
result.incoming_mail.configured = Boolean(effective.incomingMail) && (
|
||||||
|
await exists('email_configs', imapColumns, imapPresent) ||
|
||||||
|
await exists('mail_accounts', [...imapColumns, 'enabled'], (query) => { imapPresent(query); query.where('enabled', formatBoolean(true)); })
|
||||||
|
);
|
||||||
|
result.api_integration.configured = await exists('api_tokens', ['revoked_at', 'expires_at'], (query) => {
|
||||||
|
query.whereNull('revoked_at').where((q) => q.whereNull('expires_at').orWhere('expires_at', '>', new Date(now).toISOString()));
|
||||||
|
});
|
||||||
|
result.webhooks.configured = await enabled('webhooks', 'active');
|
||||||
|
for (const [key, column] of Object.entries({
|
||||||
|
gallery_guest_uploads: 'allow_user_uploads', gallery_downloads: 'allow_downloads',
|
||||||
|
gallery_client_access: 'client_access_enabled', gallery_watermarks: 'watermark_downloads'
|
||||||
|
})) result[key].configured = await enabled('events', column);
|
||||||
|
result.gallery_watermarks.configured ||= truth(settings.branding_watermark_enabled);
|
||||||
|
result.gallery_reveal.configured = await exists('events', ['allow_user_uploads', 'reveal_mode'], (query) =>
|
||||||
|
query.where({ allow_user_uploads: formatBoolean(true), reveal_mode: formatBoolean(true) }));
|
||||||
|
result.gallery_expiration.configured = await exists('events', ['expires_at'], (query) => query.whereNotNull('expires_at'));
|
||||||
|
result.download_resolution_picker.configured = truth(settings.download_resolution_picker_enabled) ||
|
||||||
|
await enabled('events', 'download_resolution_picker_enabled');
|
||||||
|
// Only what an operator actually changed. PicPeak ships
|
||||||
|
// default_protection_level='standard' and enable_devtools_protection=true —
|
||||||
|
// globally and on every event row — so accepting either as evidence made
|
||||||
|
// this signal `true` on a bare install with no galleries at all. It reported
|
||||||
|
// fleet-wide 100% and could never separate a deliberate configuration from
|
||||||
|
// an untouched one, which is a field that costs consent budget and explains
|
||||||
|
// nothing. `enable_devtools_protection` is therefore not read at all: being
|
||||||
|
// on by default, its only informative state is off, which is the opposite
|
||||||
|
// of what this key claims. The remaining inputs each ship off ('standard'
|
||||||
|
// protection, no canvas rendering, right-click allowed), so a true here is
|
||||||
|
// always a decision someone made.
|
||||||
|
result.gallery_image_protection.configured =
|
||||||
|
['enhanced', 'maximum'].includes(settings.default_protection_level) ||
|
||||||
|
truth(settings.enable_canvas_rendering);
|
||||||
|
for (const column of ['disable_right_click', 'use_canvas_rendering'])
|
||||||
|
result.gallery_image_protection.configured ||= await enabled('events', column);
|
||||||
|
result.gallery_image_protection.configured ||= await exists('events', ['protection_level'], (query) =>
|
||||||
|
query.whereIn('protection_level', ['enhanced', 'maximum']));
|
||||||
|
for (const [suffix, column] of Object.entries({
|
||||||
|
likes: 'allow_likes', ratings: 'allow_ratings', comments: 'allow_comments',
|
||||||
|
favorites: 'allow_favorites', reactions: 'allow_reactions', color_labels: 'allow_color_labels'
|
||||||
|
})) result['gallery_feedback_' + suffix].configured = await exists('event_feedback_settings', ['feedback_enabled', column], (query) =>
|
||||||
|
query.where({ feedback_enabled: formatBoolean(true), [column]: formatBoolean(true) }));
|
||||||
|
result.gallery_guest_accounts.configured = await exists('event_feedback_settings', ['feedback_enabled', 'identity_mode'], (query) =>
|
||||||
|
query.where('feedback_enabled', formatBoolean(true)).whereIn('identity_mode', ['guest', 'shared']));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
module.exports = { expandSnapshot };
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,161 @@
|
|||||||
|
"use strict";
|
||||||
|
const crypto = require("node:crypto");
|
||||||
|
const Ajv = require("ajv");
|
||||||
|
const {
|
||||||
|
envelopeSchema,
|
||||||
|
envelopeSchemas,
|
||||||
|
CURRENT_SCHEMA_VERSION,
|
||||||
|
FEATURE_KEYS,
|
||||||
|
LAYOUTS,
|
||||||
|
payloads,
|
||||||
|
} = require("./schema.cjs");
|
||||||
|
const ajv = new Ajv({ allErrors: false, strict: true });
|
||||||
|
const validators = new Map(Object.entries(envelopeSchemas).map(
|
||||||
|
([version, schema]) => [version, ajv.compile(schema)],
|
||||||
|
));
|
||||||
|
const validate = (envelope) =>
|
||||||
|
Boolean(validators.get(envelope?.packet?.schema_version)?.(envelope));
|
||||||
|
const MAX_BYTES = 16384;
|
||||||
|
const MAX_AGE_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
class ProtocolError extends Error {
|
||||||
|
constructor(code, status = 400) {
|
||||||
|
super(code);
|
||||||
|
this.code = code;
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function canonical(value) {
|
||||||
|
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
return `{${Object.keys(value)
|
||||||
|
.sort()
|
||||||
|
.map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`)
|
||||||
|
.join(",")}}`;
|
||||||
|
}
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
const digest = (value) =>
|
||||||
|
crypto.createHash("sha256").update(value).digest("hex");
|
||||||
|
function publicKeyIdentity(publicKey) {
|
||||||
|
const bytes = Buffer.from(publicKey, "base64url");
|
||||||
|
if (bytes.toString("base64url") !== publicKey || bytes.length !== 44)
|
||||||
|
throw new ProtocolError("INVALID_KEY");
|
||||||
|
const key = crypto.createPublicKey({
|
||||||
|
key: bytes,
|
||||||
|
format: "der",
|
||||||
|
type: "spki",
|
||||||
|
});
|
||||||
|
if (key.asymmetricKeyType !== "ed25519")
|
||||||
|
throw new ProtocolError("INVALID_KEY");
|
||||||
|
return { key, id: digest(bytes) };
|
||||||
|
}
|
||||||
|
function generateIdentity() {
|
||||||
|
const keys = crypto.generateKeyPairSync("ed25519");
|
||||||
|
const public_key = keys.publicKey
|
||||||
|
.export({ format: "der", type: "spki" })
|
||||||
|
.toString("base64url");
|
||||||
|
return {
|
||||||
|
installation_id: publicKeyIdentity(public_key).id,
|
||||||
|
public_key,
|
||||||
|
private_key: keys.privateKey.export({ format: "pem", type: "pkcs8" }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function makePacket(identity, action, sequence, payload, schemaVersion = CURRENT_SCHEMA_VERSION) {
|
||||||
|
return {
|
||||||
|
schema_version: schemaVersion,
|
||||||
|
installation_id: identity.installation_id,
|
||||||
|
packet_id: crypto.randomUUID(),
|
||||||
|
action,
|
||||||
|
sequence,
|
||||||
|
payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function signPacket(packet, identity, now = new Date()) {
|
||||||
|
const signed = {
|
||||||
|
packet,
|
||||||
|
public_key: identity.public_key,
|
||||||
|
issued_at: now.toISOString(),
|
||||||
|
nonce: crypto.randomUUID(),
|
||||||
|
};
|
||||||
|
const signature = crypto
|
||||||
|
.sign(null, Buffer.from(canonical(signed)), identity.private_key)
|
||||||
|
.toString("base64url");
|
||||||
|
const envelope = { ...signed, signature };
|
||||||
|
if (
|
||||||
|
!validate(envelope) ||
|
||||||
|
Buffer.byteLength(JSON.stringify(envelope)) > MAX_BYTES
|
||||||
|
)
|
||||||
|
throw new ProtocolError("INVALID_PACKET");
|
||||||
|
return envelope;
|
||||||
|
}
|
||||||
|
function verifyEnvelope(envelope, now = Date.now()) {
|
||||||
|
if (
|
||||||
|
Buffer.byteLength(JSON.stringify(envelope) || "") > MAX_BYTES ||
|
||||||
|
!validate(envelope)
|
||||||
|
)
|
||||||
|
throw new ProtocolError("INVALID_PACKET");
|
||||||
|
const issued = Date.parse(envelope.issued_at);
|
||||||
|
if (
|
||||||
|
!Number.isFinite(issued) ||
|
||||||
|
new Date(issued).toISOString() !== envelope.issued_at ||
|
||||||
|
Math.abs(now - issued) > MAX_AGE_MS
|
||||||
|
) {
|
||||||
|
throw new ProtocolError("EXPIRED_SIGNATURE", 401);
|
||||||
|
}
|
||||||
|
let identity;
|
||||||
|
try {
|
||||||
|
identity = publicKeyIdentity(envelope.public_key);
|
||||||
|
} catch (_) {
|
||||||
|
throw new ProtocolError("INVALID_KEY", 401);
|
||||||
|
}
|
||||||
|
if (identity.id !== envelope.packet.installation_id)
|
||||||
|
throw new ProtocolError("IDENTITY_MISMATCH", 401);
|
||||||
|
const { signature, ...signed } = envelope;
|
||||||
|
const signatureBytes = Buffer.from(signature, "base64url");
|
||||||
|
if (
|
||||||
|
signatureBytes.toString("base64url") !== signature ||
|
||||||
|
!crypto.verify(
|
||||||
|
null,
|
||||||
|
Buffer.from(canonical(signed)),
|
||||||
|
identity.key,
|
||||||
|
signatureBytes,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new ProtocolError("INVALID_SIGNATURE", 401);
|
||||||
|
const { action, payload } = envelope.packet;
|
||||||
|
if (action === "report") {
|
||||||
|
const generated = Date.parse(payload.generated_at);
|
||||||
|
if (
|
||||||
|
!Number.isFinite(generated) ||
|
||||||
|
new Date(generated).toISOString() !== payload.generated_at ||
|
||||||
|
payload.report_date !== payload.generated_at.slice(0, 10) ||
|
||||||
|
generated > now + MAX_AGE_MS
|
||||||
|
)
|
||||||
|
throw new ProtocolError("INVALID_REPORT_DATE");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
action === "feedback" &&
|
||||||
|
payload.allow_marketing &&
|
||||||
|
(payload.kind !== "testimonial" || !payload.allow_public)
|
||||||
|
) {
|
||||||
|
throw new ProtocolError("INVALID_PUBLICATION_CONSENT");
|
||||||
|
}
|
||||||
|
return envelope.packet;
|
||||||
|
}
|
||||||
|
module.exports = {
|
||||||
|
...require("./schema.cjs"),
|
||||||
|
canonical,
|
||||||
|
digest,
|
||||||
|
generateIdentity,
|
||||||
|
makePacket,
|
||||||
|
signPacket,
|
||||||
|
verifyEnvelope,
|
||||||
|
ProtocolError,
|
||||||
|
MAX_BYTES,
|
||||||
|
MAX_AGE_MS,
|
||||||
|
FEATURE_KEYS,
|
||||||
|
LAYOUTS,
|
||||||
|
envelopeSchema,
|
||||||
|
payloads,
|
||||||
|
};
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
// Vendored byte-identical in PicPeak. v1 stays immutable; a larger allowlist
|
||||||
|
// has a new wire version and requires explicit, signed v2 consent.
|
||||||
|
const CATALOG = require("./features.v2.json");
|
||||||
|
const CURRENT_SCHEMA_VERSION = "usage.v2";
|
||||||
|
const CURRENT_CONSENT_VERSION = "usage-consent.v2";
|
||||||
|
const LEGACY_FEATURE_KEYS = [
|
||||||
|
"crm", "crm_quotes", "crm_invoices", "crm_contracts", "crm_projects",
|
||||||
|
"crm_calendar", "crm_hours", "customer_portal", "accounting", "workflows",
|
||||||
|
"newsletters", "face_recognition", "custom_css", "oauth", "smtp",
|
||||||
|
"whatsapp", "backup", "s3_storage", "share_mounts",
|
||||||
|
];
|
||||||
|
const FEATURE_KEYS = Object.keys(CATALOG.features);
|
||||||
|
const LAYOUTS = ["grid", "masonry", "carousel", "timeline", "mosaic", "gallery-premium", "gallery-story", "other"];
|
||||||
|
const object = (properties, required = Object.keys(properties)) => ({
|
||||||
|
type: "object", additionalProperties: false, properties, required,
|
||||||
|
});
|
||||||
|
const uuid = { type: "string", pattern: "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" };
|
||||||
|
const hash = { type: "string", pattern: "^[0-9a-f]{64}$" };
|
||||||
|
const timestamp = { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$" };
|
||||||
|
const text = (maxLength, minLength = 1) => ({ type: "string", minLength, maxLength });
|
||||||
|
const boolean = { type: "boolean" };
|
||||||
|
const featureKeysFor = (version = CURRENT_SCHEMA_VERSION) =>
|
||||||
|
version === "usage.v1" ? LEGACY_FEATURE_KEYS : version === "usage.v2" ? FEATURE_KEYS : [];
|
||||||
|
const observesUse = (key, version = CURRENT_SCHEMA_VERSION) =>
|
||||||
|
version === "usage.v1" || CATALOG.features[key]?.measurement === "configuration_and_use";
|
||||||
|
const emptyFeatures = (version = CURRENT_SCHEMA_VERSION) => Object.fromEntries(
|
||||||
|
featureKeysFor(version).map(key => [key, {
|
||||||
|
configured: false, ...(observesUse(key, version) ? { used: false } : {})
|
||||||
|
}])
|
||||||
|
);
|
||||||
|
const report = (version) => object({
|
||||||
|
picpeak_version: { type: "string", maxLength: 48, pattern: "^\\d+\\.\\d+\\.\\d+(?:-(?:alpha|beta|rc)\\.\\d+)?$" },
|
||||||
|
report_date: { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$" },
|
||||||
|
generated_at: timestamp,
|
||||||
|
features: object(Object.fromEntries(featureKeysFor(version).map(key => [
|
||||||
|
key, object({ configured: boolean, ...(observesUse(key, version) ? { used: boolean } : {}) })
|
||||||
|
]))),
|
||||||
|
gallery_layouts: { type: "array", uniqueItems: true, maxItems: LAYOUTS.length, items: { enum: LAYOUTS } },
|
||||||
|
});
|
||||||
|
const feedback = object({
|
||||||
|
feedback_id: uuid, kind: { enum: ["feedback", "feature_request", "testimonial"] },
|
||||||
|
title: text(120), body: text(4000), name: text(80, 0),
|
||||||
|
allow_public: boolean, allow_marketing: boolean,
|
||||||
|
});
|
||||||
|
const makePayloads = (version) => ({
|
||||||
|
register: object({ consent_version: { const: version === "usage.v1" ? "usage-consent.v1" : CURRENT_CONSENT_VERSION } }),
|
||||||
|
report: report(version),
|
||||||
|
delete: object({}), feedback,
|
||||||
|
vote: object({ feedback_id: uuid, voted: boolean }),
|
||||||
|
session: object({}),
|
||||||
|
...(version === "usage.v2" ? { consent: object({ consent_version: { const: CURRENT_CONSENT_VERSION } }) } : {}),
|
||||||
|
});
|
||||||
|
const payloadsByVersion = Object.fromEntries(["usage.v1", "usage.v2"].map(version => [version, makePayloads(version)]));
|
||||||
|
const envelopeSchemas = Object.fromEntries(Object.entries(payloadsByVersion).map(([version, actions]) => [version, {
|
||||||
|
$schema: "http://json-schema.org/draft-07/schema#",
|
||||||
|
$id: `https://usage.picpeak.app/schema/${version}.json`,
|
||||||
|
title: `PicPeak ${version} signed envelope`,
|
||||||
|
description: "Only report.payload is automatic feature telemetry. Other actions are explicit participant operations. See /transparency for field semantics and retention.",
|
||||||
|
...object({
|
||||||
|
packet: { oneOf: Object.entries(actions).map(([action, payload]) => object({
|
||||||
|
schema_version: { const: version },
|
||||||
|
installation_id: hash, packet_id: uuid,
|
||||||
|
sequence: { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
|
||||||
|
action: { const: action }, payload,
|
||||||
|
})) },
|
||||||
|
public_key: { type: "string", minLength: 59, maxLength: 59, pattern: "^[A-Za-z0-9_-]+$" },
|
||||||
|
issued_at: timestamp, nonce: uuid,
|
||||||
|
signature: { type: "string", minLength: 86, maxLength: 86, pattern: "^[A-Za-z0-9_-]+$" },
|
||||||
|
}),
|
||||||
|
}]));
|
||||||
|
const envelopeSchema = envelopeSchemas[CURRENT_SCHEMA_VERSION];
|
||||||
|
const payloads = payloadsByVersion[CURRENT_SCHEMA_VERSION];
|
||||||
|
module.exports = {
|
||||||
|
FEATURE_KEYS, LEGACY_FEATURE_KEYS, LAYOUTS, CATALOG, CURRENT_SCHEMA_VERSION,
|
||||||
|
CURRENT_CONSENT_VERSION, featureKeysFor, observesUse, emptyFeatures,
|
||||||
|
envelopeSchema, envelopeSchemas, payloads, payloadsByVersion,
|
||||||
|
};
|
||||||
@@ -81,6 +81,9 @@ services:
|
|||||||
# JWT_SECRET) and the break-glass override that re-enables local
|
# JWT_SECRET) and the break-glass override that re-enables local
|
||||||
# password login when the IdP is down while SSO-only mode is active.
|
# password login when the IdP is down while SSO-only mode is active.
|
||||||
- OIDC_ENCRYPTION_KEY=${OIDC_ENCRYPTION_KEY:-}
|
- OIDC_ENCRYPTION_KEY=${OIDC_ENCRYPTION_KEY:-}
|
||||||
|
# Optional product usage (#1110); remains off until explicit in-app consent.
|
||||||
|
- USAGE_COLLECTOR_URL=${USAGE_COLLECTOR_URL:-https://usage.picpeak.app}
|
||||||
|
- USAGE_ENCRYPTION_KEY=${USAGE_ENCRYPTION_KEY:-}
|
||||||
- OIDC_BREAK_GLASS=${OIDC_BREAK_GLASS:-}
|
- OIDC_BREAK_GLASS=${OIDC_BREAK_GLASS:-}
|
||||||
- ADMIN_URL=${ADMIN_URL:-}
|
- ADMIN_URL=${ADMIN_URL:-}
|
||||||
- TZ=${TZ:-UTC}
|
- TZ=${TZ:-UTC}
|
||||||
|
|||||||
@@ -0,0 +1,361 @@
|
|||||||
|
# Product-usage coverage: usage.v2
|
||||||
|
|
||||||
|
Reviewed PicPeak baseline: a5ff9264 (3.124.1-beta.0). Review scope:
|
||||||
|
all 81 current backend route families,
|
||||||
|
all 26 feature flags, admin routes/settings
|
||||||
|
and runtime/public boundaries. This is capability coverage, not instrumentation
|
||||||
|
of every UI field. Source of truth: `usage-coverage.v2.json`; the PicPeak inventory
|
||||||
|
test fails on an added/removed route family, literal route declaration or feature flag.
|
||||||
|
|
||||||
|
## Privacy decision
|
||||||
|
|
||||||
|
The purpose remains feature prioritization, fixes and maintenance from #1110.
|
||||||
|
Only **installation-wide booleans** and the existing fixed gallery-layout enums.
|
||||||
|
No user/customer/guest identity, business values, documents, photos, messages,
|
||||||
|
IP/domain/URL, per-action time, event IDs, frequencies or user-level history.
|
||||||
|
A stable installation fingerprint remains pseudonymous (not anonymous); rare
|
||||||
|
combinations can be distinctive. Participant-only dataset access and opt-out
|
||||||
|
deletion therefore remain mandatory.
|
||||||
|
|
||||||
|
Of 73 capabilities, 19 were already present in v1 and 54 are new in v2:
|
||||||
|
56 configured/used pairs and 17 **configuration-only** signals. Configuration-only
|
||||||
|
signals omit `used` entirely; this is deliberately not a false “unused” value.
|
||||||
|
Guest-facing capabilities are measured from technical configuration only, never
|
||||||
|
from actual likes, comments, uploads, downloads, newsletter interactions or views.
|
||||||
|
|
||||||
|
`configured` = current technical availability/configuration. Built-in means
|
||||||
|
available, not evidence of use. `used` = one monotonic yes/no bit since consent
|
||||||
|
to the current schema (v1: since joining; v2: since joining or explicit upgrade).
|
||||||
|
It means successful allowlisted **admin capability operation**, not necessarily
|
||||||
|
completion of a queued job. It is not an event log. Repeated operations do not
|
||||||
|
store anything more. The marker table contains only constant capability keys.
|
||||||
|
|
||||||
|
## Consent and version transition
|
||||||
|
|
||||||
|
- Existing participation and migration default to `usage-consent.v1`. A client
|
||||||
|
update alone does not collect any of the 54 new local markers or report fields.
|
||||||
|
- The settings page presents the full local EN/DE catalog before v2 opt-in or
|
||||||
|
upgrade; an unchecked checkbox requires an explicit decision.
|
||||||
|
- A signed `usage.v2 / consent` command updates the same installation, after all
|
||||||
|
prior queued operations have finished. It preserves its raw history and lookup
|
||||||
|
identity. No downgrade or automatic expansion occurs.
|
||||||
|
- Only a matching collector receipt upgrades local consent and atomically resets
|
||||||
|
local usage markers. Until confirmation, collection remains v1, even if a
|
||||||
|
receipt is lost. A pending consent is durable/retryable; opt-out always wins.
|
||||||
|
- No second report on the same UTC day. The first expanded report may be on the
|
||||||
|
next day of admin activity. API integration use alone does not trigger a report.
|
||||||
|
- Collector must be deployed first. Old collectors reject the new schema;
|
||||||
|
the client shows delivery pending instead of assuming consent or sending v2.
|
||||||
|
- v1 validation remains unchanged and old envelopes remain exportable exactly as
|
||||||
|
first received. Raw history contains the original schema version on each packet.
|
||||||
|
- Aggregate projections include their schema version. Absent v2 fields in v1
|
||||||
|
projections are **unknown**, never false. `reported` and `used_reported`
|
||||||
|
supply each metric's real denominator. Configuration-only use has denominator
|
||||||
|
zero and is displayed as “Not collected”, not 0% adoption.
|
||||||
|
|
||||||
|
## Every reported capability
|
||||||
|
|
||||||
|
The static bilingual definitions below are also shipped as
|
||||||
|
`features.v2.json` in both applications, exposed at
|
||||||
|
`/schema/features.v2.json`, and displayed in both usage interfaces.
|
||||||
|
“Since” is the schema in which a key was introduced; definitions here describe v2.
|
||||||
|
Legacy v1 semantics remain documented separately in the protocol reference.
|
||||||
|
|
||||||
|
| Key (EN / DE) | Since | Configured | Used |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `crm` — Client management / Kundenverwaltung | usage.v1 | The clients capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `crm_quotes` — Quotes / Angebote | usage.v1 | The quotes capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `crm_invoices` — Invoices / Rechnungen | usage.v1 | The bills capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `crm_contracts` — Contracts / Verträge | usage.v1 | The contracts capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `crm_projects` — Projects / Projekte | usage.v1 | The projects capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `crm_calendar` — Admin calendar / Admin-Kalender | usage.v1 | The calendar capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `crm_hours` — Hours logging / Zeiterfassung | usage.v1 | The hoursLogging capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `customer_portal` — Customer portal / Kundenportal | usage.v1 | The customerPortal capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `accounting` — Accounting / Buchhaltung | usage.v1 | The accounting capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `workflows` — Workflows / Workflows | usage.v1 | The workflows capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `newsletters` — Newsletters / Newsletter | usage.v1 | The newsletters capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `face_recognition` — Face recognition / Gesichtserkennung | usage.v1 | The faces capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `custom_css` — Custom CSS / Eigenes CSS | usage.v1 | Custom CSS is configured globally or applied through a gallery/theme/template; CSS text is not sent. | Applied CSS observed after consent, without observing visitors. |
|
||||||
|
| `oauth` — Admin SSO / Admin-SSO | usage.v1 | Admin OIDC is enabled and issuer/client configuration is present; no provider or credential values. | Successful admin SSO login; no account, identity-provider or session details. |
|
||||||
|
| `smtp` — SMTP delivery / SMTP-Versand | usage.v1 | An outgoing SMTP host is configured; no host, account, address or credentials. | A successful explicitly initiated admin SMTP test/send; no recipients or messages. |
|
||||||
|
| `whatsapp` — WhatsApp integration / WhatsApp-Integration | usage.v1 | The WhatsApp capability is enabled and a usable configuration is present; no phone number, token or template. | Successful admin integration test; no recipient, message or delivery history. |
|
||||||
|
| `backup` — Backups / Sicherungen | usage.v1 | A full or database backup schedule is enabled; no schedule, path, storage sizes or backup names. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `s3_storage` — S3 storage / S3-Speicher | usage.v1 | S3 is configured for media or backups; no bucket, endpoint, credentials or object keys. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `share_mounts` — External folders / Externe Ordner | usage.v1 | At least one gallery uses an external folder; only existence, no folder paths or gallery identifiers. | An admin initiated an accepted external-folder import; no scanned paths, files or counts. |
|
||||||
|
| `galleries` — Gallery management / Galerieverwaltung | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `photo_management` — Media management / Medienverwaltung | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `photo_exports` — Admin media export / Admin-Medienexport | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `photo_processing` — Media maintenance tools / Medien-Wartungswerkzeuge | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `archive_management` — Gallery archives / Galeriearchive | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `gallery_sharing` — Gallery sharing and QR / Galeriefreigabe und QR | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `short_links` — Short links / Kurzlinks | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `gallery_categories` — Photo categories / Fotokategorien | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `event_types` — Event types and presets / Ereignistypen und Vorlagen | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `slideshow` — Live slideshow / Live-Diashow | usage.v2 | The slideshow capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `transfers` — PicTransfer / PicTransfer | usage.v2 | The transfers capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `video_uploads` — Admin video uploads / Admin-Video-Uploads | usage.v2 | Video extensions are allowed in global upload settings; no uploaded-file metadata. | At least one admin video file was successfully stored/accepted; no names, formats, lengths, sizes or processing/visitor history. |
|
||||||
|
| `camera_raw_uploads` — Admin camera RAW uploads / Admin-Kamera-RAW-Uploads | usage.v2 | Camera RAW (DNG) is allowed in global upload settings; no camera models or EXIF. | At least one admin camera RAW upload was stored/accepted; only the capability bit, no filename or metadata. |
|
||||||
|
| `messaging` — Messaging tools / Nachrichtenwerkzeuge | usage.v2 | The messaging capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `incoming_mail` — IMAP intake / IMAP-Empfang | usage.v2 | Incoming mail is enabled and an IMAP configuration is present; no mailbox, server, folders or credentials. | A successful explicit admin connection test or non-skipped manual poll; no background intake, messages, attachments or counts. |
|
||||||
|
| `reminder_emails` — Automatic event reminders / Automatische Ereigniserinnerungen | usage.v2 | The reminderEmails capability switch is effectively enabled; only a boolean. | **Not collected. Configuration only.** |
|
||||||
|
| `email_templates` — Email templates / E-Mail-Vorlagen | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `email_webhook` — Email webhook transport / E-Mail-Webhook-Transport | usage.v2 | Both email webhook settings are present; no URL or secret. | Successful explicitly initiated admin send/test through the webhook transport; no recipients, messages or automatic deliveries. |
|
||||||
|
| `accounting_incoming_invoices` — Incoming invoices / Eingangsrechnungen | usage.v2 | The incomingInvoices capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `accounting_expenses` — Expenses / Ausgaben | usage.v2 | The expenses capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `accounting_tax_report` — Tax reports / Steuerberichte | usage.v2 | The taxReport capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `accounting_ledger` — Ledger and accounting export / Kontenplan und Buchhaltungsexport | usage.v2 | The accounting capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `crm_installments` — Installment-plan tools / Ratenplan-Werkzeuge | usage.v2 | Quotes or invoices are enabled; no actual payment plans, amounts or statuses are inspected. | An admin saved an installment plan; no dates, amounts, currencies, payment status or document IDs. |
|
||||||
|
| `document_templates` — Document presets and blocks / Dokumentvorlagen und Bausteine | usage.v2 | Quotes or contracts are enabled, making document presets/blocks available; no template content. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `cms` — CMS pages / CMS-Seiten | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `public_site` — Public landing page / Öffentliche Startseite | usage.v2 | The public landing-page setting is enabled; no page HTML, texts, domains or visitors. | **Not collected. Configuration only.** |
|
||||||
|
| `branding` — Branding settings / Branding-Einstellungen | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `seo_customization` — SEO settings / SEO-Einstellungen | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `admin_management` — Admin and role management / Admin- und Rollenverwaltung | usage.v2 | The userManagement capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `api_integration` — HTTP API integration / HTTP-API-Integration | usage.v2 | An unrevoked, unexpired API credential exists; no tokens, names, scopes or owner data. | Successful authenticated HTTP API capability call; only this bit, never URLs, request values, token/owner IDs or call counts. Does not trigger a report. |
|
||||||
|
| `webhooks` — Outbound webhooks / Ausgehende Webhooks | usage.v2 | At least one active webhook is configured; no destinations, subscriptions, secrets or delivery logs. | Successful explicit admin webhook test/replay; no automatic or visitor-triggered deliveries. |
|
||||||
|
| `restore` — Restore / Wiederherstellung | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `portable_backup` — Portable PicPeak export/import / Portabler PicPeak-Export/Import | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `database_backup` — Database backups / Datenbanksicherungen | usage.v2 | Scheduled database backups are enabled; no schedules, file names or database contents. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `s3_photo_storage` — S3 media storage / S3-Medienspeicher | usage.v2 | S3 is the configured media backend and required credentials are present; no values are sent. | Successful admin media storage/accepted upload to S3; no buckets, objects or sizes. |
|
||||||
|
| `s3_backups` — S3 backup destination / S3-Sicherungsziel | usage.v2 | The configured backup destination is S3 with a bucket present; no bucket or credentials. | An admin started a backup to the configured S3 destination or a successful S3 test upload; local exports never imply S3 use. |
|
||||||
|
| `analytics_dashboard` — Existing analytics module / Bestehendes Analytics-Modul | usage.v2 | The analytics capability switch is effectively enabled; only a boolean. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `feedback_moderation` — Feedback moderation / Feedback-Moderation | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `guest_management` — Guest administration tools / Gastverwaltungswerkzeuge | usage.v2 | Built-in capability is available; this is not evidence of use. | A documented successful authenticated admin capability operation was observed since consent to this schema. No actor, operation history, parameters or counts. |
|
||||||
|
| `gallery_feedback_likes` — Gallery likes enabled / Galerie-Likes aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** |
|
||||||
|
| `gallery_feedback_ratings` — Gallery star ratings enabled / Galerie-Sternebewertungen aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** |
|
||||||
|
| `gallery_feedback_comments` — Gallery comments enabled / Galerie-Kommentare aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** |
|
||||||
|
| `gallery_feedback_favorites` — Gallery favorites enabled / Galerie-Favoriten aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** |
|
||||||
|
| `gallery_feedback_reactions` — Gallery reactions enabled / Galerie-Reaktionen aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** |
|
||||||
|
| `gallery_feedback_color_labels` — Gallery color labels enabled / Galerie-Farblabels aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** |
|
||||||
|
| `gallery_guest_accounts` — Guest identities enabled / Gastidentitäten aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** |
|
||||||
|
| `gallery_guest_uploads` — Guest uploads enabled / Gast-Uploads aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** |
|
||||||
|
| `gallery_downloads` — Gallery downloads allowed / Galerie-Downloads erlaubt | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** |
|
||||||
|
| `download_resolution_picker` — Download resolution picker enabled / Download-Auflösungswahl aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** |
|
||||||
|
| `gallery_client_access` — Client access enabled / Client-Zugang aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** |
|
||||||
|
| `gallery_watermarks` — Watermarks enabled / Wasserzeichen aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** |
|
||||||
|
| `gallery_image_protection` — Image protection enabled / Bildschutz aktiviert | usage.v2 | Enabled beyond the shipped defaults — a stronger protection level, canvas rendering, or right-click disabled — globally or on at least one gallery; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** |
|
||||||
|
| `gallery_reveal` — Gallery reveal enabled / Galerie-Enthüllung aktiviert | usage.v2 | Enabled in applicable gallery/global configuration; only existence across the installation, never gallery IDs or counts. | **Not collected. Configuration only.** |
|
||||||
|
| `gallery_expiration` — Gallery expiration configured / Galerieablauf konfiguriert | usage.v2 | At least one gallery has an expiry configured; no dates, gallery IDs or counts. | **Not collected. Configuration only.** |
|
||||||
|
|
||||||
|
Gallery layouts (unchanged): `grid`, `masonry`, `carousel`, `timeline`,
|
||||||
|
`mosaic`, `gallery-premium`, `gallery-story`, `other`. Only set membership,
|
||||||
|
not how many galleries use a layout. Unknown names are normalized to other.
|
||||||
|
|
||||||
|
## Exact observation sources
|
||||||
|
|
||||||
|
PicPeak `backend/src/usage/capabilityRules.js` is the fixed method/path
|
||||||
|
allowlist; request paths, query/body/response values never leave the middleware.
|
||||||
|
Only the resulting constant keys reach `markUsed`, with active schema consent,
|
||||||
|
authenticated admin and 2xx response checks. Status/health polls are excluded.
|
||||||
|
|
||||||
|
Additional trusted success evidence in `capabilityEvidence.js`:
|
||||||
|
accepted admin file storage (video / DNG / S3 booleans only, not chunk
|
||||||
|
initialization), successful manual SMTP or email-webhook send/test, non-skipped
|
||||||
|
manual IMAP poll/connection test, successful manual WhatsApp test, and successful
|
||||||
|
S3 backup roundtrip test. SMTP vs webhook uses the actual selected transport
|
||||||
|
(including per-account SMTP overrides), not just environment presence.
|
||||||
|
Webhook test/replay means **accepted enqueue**, never remote delivery tracking.
|
||||||
|
|
||||||
|
`UsageService.snapshot` and `expandedSnapshot.js` inspect allowlisted settings,
|
||||||
|
effective flags and technical configuration existence. They do not query
|
||||||
|
customer/guest profiles, financial records, photos/EXIF, message/feedback bodies,
|
||||||
|
audit/security logs or delivery histories. Inherited technical defaults count as
|
||||||
|
configuration; disabled feature dependencies cannot be inferred as active.
|
||||||
|
Optional-module tables/columns are guarded. CSS/layout inspection maps locally
|
||||||
|
to presence/enums; no free-form CSS/theme content is sent.
|
||||||
|
|
||||||
|
OAuth is marked only by the successful **admin** OIDC callback, without claims
|
||||||
|
or provider metadata. S3 backup use is inferred only for backup operations
|
||||||
|
writing to the configured destination; a local DB/portable export is not S3 use.
|
||||||
|
Background jobs and public/customer/visitor handlers never record product use.
|
||||||
|
|
||||||
|
## Complete route-family decision matrix
|
||||||
|
|
||||||
|
Paths below are relative to PicPeak `backend/src/routes/`. “Partial” means only
|
||||||
|
the disclosed allowlist/evidence, not every endpoint in that file. All literal
|
||||||
|
route declarations are captured in the companion inventory, with excluded
|
||||||
|
methods remaining unobserved.
|
||||||
|
|
||||||
|
| Source | Decision / signals | Reason / limits |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `acceptInvite.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
|
||||||
|
| `admin.js` | composition | Router composition / helpers; decisions are recorded for each mounted family. |
|
||||||
|
| `adminApiTokens.js` | configuration: `api_integration` | Only existence of a valid credential; no marker from token listing/creation, no scope, owner, token, expiry date or last-used time. |
|
||||||
|
| `adminArchives.js` | partial: `galleries`, `archive_management`, `photo_exports` | Admin archive/delete/restore/download initiation only; filenames, histories, storage sizes and polling excluded. |
|
||||||
|
| `adminAuth.js` | excluded | Bootstrap, passwords/MFA/session/profile, per-person notifications, developer helpers and operational health/update/log polling are outside the prioritization purpose. |
|
||||||
|
| `adminBackup.js` | partial: `backup`, `portable_backup`, `restore`, `s3_storage`, `s3_backups` | Admin backup initiation, portable export/import and successful S3 roundtrip test. Local export never implies S3; names, schedules, sizes, contents and history excluded. |
|
||||||
|
| `adminBusinessProfile.js` | excluded | Business identity/bank/tax-address configuration and VAT-code helper surface are not separate usage signals. Billing/accounting capabilities are covered without profiling the business. |
|
||||||
|
| `adminCalendar.js` | partial: `crm`, `crm_calendar` | Authenticated admin calendar retrieval is capability use; no calendar entries, dates, recurrence, availability or bookings. |
|
||||||
|
| `adminCategories.js` | partial: `gallery_categories` | Admin category CRUD; no names, descriptions, colors or ordering values. |
|
||||||
|
| `adminCMS.js` | partial: `cms` | Admin CMS page CRUD only. Public page traffic, slug, HTML, text, links and media excluded. |
|
||||||
|
| `adminContracts.js` | partial: `crm`, `crm_contracts`, `document_templates` | Admin contract/block operations only; no legal text, signatures, signing parties or customer signing events. |
|
||||||
|
| `adminCssTemplates.js` | configuration: `custom_css` | Only existence of enabled applied CSS and locally observed application, not editing/viewing templates or any CSS text. |
|
||||||
|
| `adminCustomers.js` | partial: `crm`, `crm_hours`, `customer_portal` | Successful admin CRM/hour-entry/invitation operations only. No customer/account names, IDs, rates, billed hours, payment state or portal behavior. |
|
||||||
|
| `adminDashboard.js` | partial: `analytics_dashboard` | Admin analytics capability endpoint only; no stats, activities, health/CRM polls, underlying visitor data or dashboard values. |
|
||||||
|
| `adminDatabaseBackup.js` | partial: `backup`, `database_backup` | Admin database-backup initiation plus schedule-enabled boolean, no file data/history. |
|
||||||
|
| `adminDeals.js` | partial: `crm`, `crm_installments` | Admin installment-plan changes only. No actual plans, invoice links, amounts, paid states or deal reporting. |
|
||||||
|
| `adminDev.js` | excluded | Bootstrap, passwords/MFA/session/profile, per-person notifications, developer helpers and operational health/update/log polling are outside the prioritization purpose. |
|
||||||
|
| `adminEmail.js` | partial: `messaging`, `incoming_mail`, `smtp`, `email_templates`, `email_webhook`, `reminder_emails` | Admin message operation/template edit, actual successful manual send/test transport and non-skipped manual IMAP poll/test. Reminder flag configuration only. No automated sends/polls, received-message or recipient data, queue/log reads, mailbox addresses or templates. |
|
||||||
|
| `adminEventRename.js` | partial: `galleries` | Successful rename only, not validate-rename. No former/new names or identifiers. |
|
||||||
|
| `adminEvents/archiveBulk.js` | partial: `galleries`, `archive_management`, `photo_exports` | Admin archive/delete/restore/download initiation only; filenames, histories, storage sizes and polling excluded. |
|
||||||
|
| `adminEvents/crud.js` | partial: `galleries`, `gallery_guest_uploads`, `gallery_downloads`, `gallery_client_access`, `gallery_watermarks`, `gallery_reveal`, `gallery_expiration`, `gallery_sharing`, `custom_css` | Admin creation/edit/publish etc. set galleries; sharing has its own fixed key. Guest/download/protection/reveal/expiry are configuration only; themes contribute controlled layouts and CSS presence. No gallery metadata or guest action history. |
|
||||||
|
| `adminEvents/downloadResolutions.js` | configuration: `download_resolution_picker` | Only whether a picker is configured globally or in a gallery. No chosen resolution, download event or counts. |
|
||||||
|
| `adminEvents/faces.js` | partial: `face_recognition` | Effective flag plus successful admin faces/people operation. No health polling, embeddings, names, groups, detections or visitor searches. |
|
||||||
|
| `adminEvents/helpers.js` | composition | Router composition / helpers; decisions are recorded for each mounted family. |
|
||||||
|
| `adminEvents/index.js` | composition | Router composition / helpers; decisions are recorded for each mounted family. |
|
||||||
|
| `adminEvents/logo.js` | partial: `branding` | Successful admin logo operation only; image/filename/content excluded. |
|
||||||
|
| `adminEvents/qr.js` | partial: `gallery_sharing` | Admin QR generation only; no scans, tokens or URLs. |
|
||||||
|
| `adminEvents/resets.js` | partial: `galleries`, `gallery_sharing` | Admin gallery reset/sharing capability only; no password, recipient, token or reset statistics. |
|
||||||
|
| `adminEvents/slideshow.js` | partial: `slideshow` | Admin generate/disable/configure only, never kiosk viewers or slide advances. |
|
||||||
|
| `adminEventTypes.js` | partial: `event_types` | Admin event-type CRUD; preset contents/names excluded. |
|
||||||
|
| `adminExpenses.js` | partial: `accounting`, `accounting_expenses`, `accounting_incoming_invoices` | Admin expense/inbound-invoice operations; no financial values, suppliers, mileage/location, dates, receipt files or OCR text. |
|
||||||
|
| `adminExternalMedia.js` | partial: `share_mounts` | Only admin import operation; status/list/browse are not use. Snapshot checks external-path presence, never reports a path. |
|
||||||
|
| `adminFeatureFlags.js` | configuration: `crm`, `crm_quotes`, `crm_invoices`, `crm_contracts`, `crm_projects`, `crm_calendar`, `crm_hours`, `customer_portal`, `accounting`, `workflows`, `newsletters`, `face_recognition`, `slideshow`, `transfers`, `messaging`, `reminder_emails`, `accounting_incoming_invoices`, `accounting_expenses`, `accounting_tax_report`, `accounting_ledger`, `admin_management`, `analytics_dashboard` | Only allowlisted effective capability booleans. No marker from reading or saving feature flags. Disabled roadmap/developer flags excluded. |
|
||||||
|
| `adminFeedback.js` | partial: `feedback_moderation`, `gallery_feedback_likes`, `gallery_feedback_ratings`, `gallery_feedback_comments`, `gallery_feedback_favorites`, `gallery_feedback_reactions`, `gallery_feedback_color_labels`, `gallery_guest_accounts` | Admin moderation/word-filter operations only. Visitor feedback is not observed. Master-enabled per-gallery feedback-option booleans only; no contents, ratings, likes, colors, identities or word lists. |
|
||||||
|
| `adminGuests.js` | partial: `guest_management` | Admin guest management/export initiation only. No guest names, invitations, tokens, contact data, guest counts or visitor interactions. |
|
||||||
|
| `adminImageSecurity.js` | configuration: `gallery_image_protection` | Only gallery/global technical protection configuration existence. No security events, blocked IPs, request counts, threat scores or admin monitoring access. |
|
||||||
|
| `adminInvoices.js` | partial: `crm`, `crm_invoices` | Admin invoice operations only; no amounts, VAT/customer/payment values or payment-check responses. |
|
||||||
|
| `adminLedger.js` | partial: `accounting`, `accounting_ledger` | Admin ledger-account/VAT/mapping edits and ledger export initiation only; no account/currency/VAT identifiers or exported records. |
|
||||||
|
| `adminNewsletters.js` | partial: `newsletters` | Admin campaign changes/test/queue/cancel only. Recipient resolution, previews, subscriptions/unsubscribes, delivery/open/click data and automatic sending excluded. |
|
||||||
|
| `adminNotifications.js` | excluded | Bootstrap, passwords/MFA/session/profile, per-person notifications, developer helpers and operational health/update/log polling are outside the prioritization purpose. |
|
||||||
|
| `adminPhotoDimensions.js` | partial: `photo_processing` | Admin repair/regenerate/configuration initiation, never status polling or processing totals. |
|
||||||
|
| `adminPhotoExport.js` | partial: `photo_exports` | Admin export initiation only; export filters, selected files, sizes and contents excluded. |
|
||||||
|
| `adminPhotos.js` | partial: `photo_management`, `photo_exports`, `photo_processing`, `video_uploads`, `camera_raw_uploads`, `s3_storage`, `s3_photo_storage` | Successful admin edits/exports and accepted upload evidence only. Chunk init/status, failed uploads and public downloads excluded. Only video/RAW/S3 booleans survive, never file metadata/EXIF/content. |
|
||||||
|
| `adminProjects.js` | partial: `crm`, `crm_projects` | Admin project operations only; project/person names, business performance, metadata and totals excluded. |
|
||||||
|
| `adminQuotes.js` | partial: `crm`, `crm_quotes`, `document_templates` | Admin quote/preset operations only; no quote content, prices, customer acceptance or signatures. |
|
||||||
|
| `adminRestore.js` | partial: `restore` | Admin restore initiation only, never file selection, content, progress, errors or timing. |
|
||||||
|
| `adminRoles.js` | partial: `admin_management` | Admin account/role management capability; no names, permissions, role labels, password reset operations or active-user counts. Auth/self-profile endpoints excluded. |
|
||||||
|
| `adminSettings.js` | partial: `custom_css`, `oauth`, `smtp`, `backup`, `s3_storage`, `video_uploads`, `camera_raw_uploads`, `public_site`, `branding`, `seo_customization`, `slideshow`, `download_resolution_picker`, `gallery_watermarks`, `database_backup` | Only specified configuration presence/booleans and explicit branding/SEO/slideshow operations. Generic settings reads, security policies, passwords, storage data, SMTP/OIDC credentials, custom HTML/CSS/SEO values excluded. |
|
||||||
|
| `adminShortUrls.js` | partial: `gallery_sharing`, `short_links` | Admin short-link creation/deletion only; link/token/click metadata excluded. |
|
||||||
|
| `adminSystem.js` | excluded | Bootstrap, passwords/MFA/session/profile, per-person notifications, developer helpers and operational health/update/log polling are outside the prioritization purpose. |
|
||||||
|
| `adminSystemHealth.js` | excluded | Bootstrap, passwords/MFA/session/profile, per-person notifications, developer helpers and operational health/update/log polling are outside the prioritization purpose. |
|
||||||
|
| `adminTaxReport.js` | partial: `accounting`, `accounting_tax_report` | Admin tax report generation/export only; no totals, dates, tax regimes, geography or currency. |
|
||||||
|
| `adminThumbnails.js` | partial: `photo_processing` | Admin repair/regenerate/configuration initiation, never status polling or processing totals. |
|
||||||
|
| `adminTransfers.js` | partial: `transfers` | Admin transfer CRUD/files/link management/download only. Public recipients, received-file data, upload and download statistics excluded. |
|
||||||
|
| `adminUsage.js` | excluded | Consent, inspection, export, feedback, voting and deletion are explicit protocol operations; not product-use signals. Activity only triggers a due fixed report. |
|
||||||
|
| `adminUsers.js` | partial: `admin_management` | Admin account/role management capability; no names, permissions, role labels, password reset operations or active-user counts. Auth/self-profile endpoints excluded. |
|
||||||
|
| `adminVatCodes.js` | excluded | Business identity/bank/tax-address configuration and VAT-code helper surface are not separate usage signals. Billing/accounting capabilities are covered without profiling the business. |
|
||||||
|
| `adminWebhooks.js` | partial: `webhooks` | Active configuration existence plus successful admin manual test/replay enqueue. Actual network delivery/results/subscriptions/destinations excluded. |
|
||||||
|
| `adminWhatsapp.js` | partial: `whatsapp` | Effective configured sender and successful manual test only. No automated deliveries, phone numbers, templates or delivery statuses. |
|
||||||
|
| `adminWorkflows.js` | partial: `workflows` | Admin workflow authoring/approval/test initiation only. Runtime triggers, payloads, execution frequency/results and public approvals excluded. |
|
||||||
|
| `analyticsTrackerProxy.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
|
||||||
|
| `auth.js` | partial: `oauth` | Only successful admin OIDC callback sets oauth. Password/gallery authentication, MFA, account claims and provider details excluded. |
|
||||||
|
| `customer.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
|
||||||
|
| `customerAuth.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
|
||||||
|
| `gallery.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
|
||||||
|
| `galleryFeedback.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
|
||||||
|
| `galleryGuests.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
|
||||||
|
| `protectedImages.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
|
||||||
|
| `publicCMS.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
|
||||||
|
| `publicContracts.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
|
||||||
|
| `publicFonts.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
|
||||||
|
| `publicNewsletter.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
|
||||||
|
| `publicPaymentCheck.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
|
||||||
|
| `publicQuotes.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
|
||||||
|
| `publicSettings.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
|
||||||
|
| `publicTransfer.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
|
||||||
|
| `publicTransferUpload.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
|
||||||
|
| `publicWorkflowApprovals.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
|
||||||
|
| `secureImages.js` | excluded | Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers. |
|
||||||
|
| `setup.js` | excluded | Bootstrap, passwords/MFA/session/profile, per-person notifications, developer helpers and operational health/update/log polling are outside the prioritization purpose. |
|
||||||
|
| `v1/events.js` | partial: `api_integration` | Single bit after successful admin-owned scoped API authentication. No request/response values; API requests do not trigger reports. |
|
||||||
|
|
||||||
|
## Every admin settings tab
|
||||||
|
|
||||||
|
These 29 current SettingsPage tabs are also inventoried and tested against
|
||||||
|
the frontend TabType. Page navigation itself is not tracked.
|
||||||
|
|
||||||
|
| Tab | Capability / exclusion |
|
||||||
|
| --- | --- |
|
||||||
|
| `usage` | Explicit consent/report inspection/feedback is not itself adoption telemetry. |
|
||||||
|
| `features` | Only the allowlisted effective feature booleans; no settings visit/save marker. |
|
||||||
|
| `general` | `video_uploads`, `camera_raw_uploads`, `public_site`, `custom_css`. General technical upload/public-site/CSS configuration only; no title, URLs, limits, times, HTML or identity. |
|
||||||
|
| `events` | `galleries`, `gallery_guest_uploads`, `gallery_downloads`, `gallery_client_access`, `gallery_watermarks`, `gallery_image_protection`, `gallery_reveal`, `gallery_expiration`. Gallery operations and disclosed configuration only; no event/customer values or visitor use. |
|
||||||
|
| `eventTypes` | `event_types`. General admin event-type capability; no names or preset contents. |
|
||||||
|
| `branding` | `branding`, `gallery_watermarks`. Branding operation and watermark configuration only; no branding text, logos or colors. |
|
||||||
|
| `categories` | `gallery_categories`. Category management capability only; no names/order/category membership. |
|
||||||
|
| `thumbnails` | `photo_processing`. Admin processing settings/regeneration initiation only; no image data or progress. |
|
||||||
|
| `downloads` | `download_resolution_picker`. Configuration boolean only; no actual download/selection behavior or resolution values. |
|
||||||
|
| `styling` | `custom_css`. Presence/application only plus controlled gallery-layout enums, never CSS/theme values. |
|
||||||
|
| `cms` | `cms`, `public_site`. Admin page editing capability/public-site enabled only; no HTML, slugs or traffic. |
|
||||||
|
| `email` | `smtp`, `incoming_mail`, `messaging`, `email_templates`, `email_webhook`. Configuration and documented manual admin capability operations only; messages, recipients, automatic activity and mailbox values excluded. |
|
||||||
|
| `moderation` | `feedback_moderation`. Admin moderation/word-filter capability, never feedback content or visitor behavior. |
|
||||||
|
| `security` | Excluded password/MFA/session/rate-limit/security profiles and operations. |
|
||||||
|
| `sso` | `oauth`. Enabled/config-present and successful admin callback only; no claims/provider details. |
|
||||||
|
| `imageSecurity` | `gallery_image_protection`. Configuration presence only; no blocked-IP/security analytics or monitoring history. |
|
||||||
|
| `seo` | `seo_customization`. Admin SEO configuration operation only; no meta tags, URLs, robots or verification tokens. |
|
||||||
|
| `apiTokens` | `api_integration`. Valid credential presence and one successful scoped API capability bit; no tokens/scopes/owner metadata. |
|
||||||
|
| `webhooks` | `webhooks`. Active configuration and manual test/replay enqueue only; no delivery data. |
|
||||||
|
| `status` | Excluded operational health, diagnostics, resource data, update and storage polling. |
|
||||||
|
| `analytics` | `analytics_dashboard`. Analytics capability and admin aggregate-view use only; no embedded analytics results/tracker IDs or visitors. |
|
||||||
|
| `backup` | `backup`, `database_backup`, `portable_backup`, `restore`, `s3_backups`. Schedule presence/manual capability initiation only, no histories, sizes, paths or files. |
|
||||||
|
| `businessProfile` | Excluded business identity, bank accounts and addresses. |
|
||||||
|
| `crm` | `crm`, `crm_quotes`, `crm_invoices`, `crm_projects`, `crm_hours`, `customer_portal`, `crm_installments`. Only coarse module capabilities; no policies/amounts/customer/payment values. |
|
||||||
|
| `contracts` | `crm_contracts`, `document_templates`. Admin contract/template capability only; no legal text or signatures. |
|
||||||
|
| `reminderTemplates` | `reminder_emails`, `email_templates`. Reminder flag configuration and admin template editing only; no automatic reminder sends/recipients/content. |
|
||||||
|
| `accounting` | `accounting`, `accounting_incoming_invoices`, `accounting_expenses`, `accounting_tax_report`, `accounting_ledger`. Only module capabilities, no tax codes, rates, balances or business identity. |
|
||||||
|
| `whatsapp` | `whatsapp`. Configured integration plus manual test only; no phone numbers, tokens or automatic delivery. |
|
||||||
|
| `slideshow` | `slideshow`. Admin setup capability only; no kiosk viewers, slide progress or photos. |
|
||||||
|
|
||||||
|
## Every feature flag (configuration decisions)
|
||||||
|
|
||||||
|
| Flag | Signal / exclusion |
|
||||||
|
| --- | --- |
|
||||||
|
| `accounting` | `accounting`, `accounting_ledger`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
|
||||||
|
| `analytics` | `analytics_dashboard`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
|
||||||
|
| `bills` | `crm_invoices`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
|
||||||
|
| `calendar` | `crm_calendar`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
|
||||||
|
| `calendarBooking` | Excluded: disabled roadmap placeholder, not an implemented booking capability. |
|
||||||
|
| `clients` | `crm`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
|
||||||
|
| `contracts` | `crm_contracts`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
|
||||||
|
| `crmDevelopment` | Excluded: internal development/test helpers, not product adoption. |
|
||||||
|
| `customerPortal` | `customer_portal`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
|
||||||
|
| `expenses` | `accounting_expenses`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
|
||||||
|
| `faces` | `face_recognition`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
|
||||||
|
| `galleries` | `galleries`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
|
||||||
|
| `hoursLogging` | `crm_hours`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
|
||||||
|
| `incomingInvoices` | `accounting_incoming_invoices`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
|
||||||
|
| `incomingMail` | `incoming_mail`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
|
||||||
|
| `messaging` | `messaging`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
|
||||||
|
| `newsletters` | `newsletters`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
|
||||||
|
| `projects` | `crm_projects`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
|
||||||
|
| `quotes` | `crm_quotes`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
|
||||||
|
| `reminderEmails` | `reminder_emails`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
|
||||||
|
| `slideshow` | `slideshow`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
|
||||||
|
| `taxReport` | `accounting_tax_report`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
|
||||||
|
| `transfers` | `transfers`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
|
||||||
|
| `userManagement` | `admin_management`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
|
||||||
|
| `whatsapp` | `whatsapp`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
|
||||||
|
| `workflows` | `workflows`. Only effective configuration boolean; dependency rules apply, no flag values/history beyond this boolean. |
|
||||||
|
|
||||||
|
## Deliberately excluded runtime and future features
|
||||||
|
|
||||||
|
- Gallery/customer/public events and optional website analytics
|
||||||
|
- Automated newsletter, reminder, WhatsApp, webhook and IMAP jobs
|
||||||
|
- Security/audit logs, biometric embeddings and recognition results
|
||||||
|
- Operational health, migration, update and polling metrics
|
||||||
|
- Business/customer/user identities, geography, amounts and document contents
|
||||||
|
- Disabled calendarBooking and internal crmDevelopment; hosted future product #1111
|
||||||
|
- Image fragmentation: removed from current PicPeak, not a live capability
|
||||||
|
|
||||||
|
The Messages and Reminder Emails implementations were reviewed as real features,
|
||||||
|
despite stale placeholder comments. Reminder Emails remains configuration-only.
|
||||||
|
Calendar booking is still a disabled placeholder and is not presented as a
|
||||||
|
working capability. This review does not approve any public visitor tracking,
|
||||||
|
even if another optional analytics integration is configured.
|
||||||
|
|
||||||
|
All exclusion decisions still permit the existing product functions themselves.
|
||||||
|
They restrict this usage program; they do not disable galleries, email or jobs.
|
||||||
|
Adding capabilities requires a documented scope review, updated inventory,
|
||||||
|
closed schema, both UI disclosures/docs and tests; a wider collection scope
|
||||||
|
requires renewed explicit consent, not a silent catalog expansion.
|
||||||
|
|
||||||
|
## Verification obligations
|
||||||
|
|
||||||
|
Required checks include unchanged v1 validation, closed v2 fields, all 73
|
||||||
|
configuration signals and privacy canaries, all route/flag decisions, no
|
||||||
|
configuration-only use, disabled/pending/upgrade/opt-out boundaries, mixed-version
|
||||||
|
denominators, byte-identical protocol/catalogs, EN/DE UI catalog consistency,
|
||||||
|
raw export and deletion, SQLite/PostgreSQL and paired local Docker/browser tests.
|
||||||
|
Test outcomes are recorded separately; this document is not a claim of legal
|
||||||
|
certification or proof that modified self-hosted clients report truthfully.
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
# Optional product usage and feedback (#1110)
|
||||||
|
|
||||||
|
Tracking is disabled by default. After updating, settings editors see a
|
||||||
|
dismissible invitation in the admin shell. Only explicit consent in Settings →
|
||||||
|
Product usage & feedback registers an installation. Public galleries never
|
||||||
|
load the usage UI chunk or trigger product reports.
|
||||||
|
|
||||||
|
The backend builds and signs allowlisted feature packets, sending them at most
|
||||||
|
daily on authenticated admin use. It does not send gallery visitors, click
|
||||||
|
histories, photo/gallery counts, names, emails, domains, filenames, or secrets.
|
||||||
|
The settings page provides disclosure, raw preview/export, a private lookup
|
||||||
|
hash, delivery status, feedback, and a short-lived voting connection.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Run the normal core migrations; migration 201 creates dedicated state/marker
|
||||||
|
tables. No identity or key is created by the migration. No extra browser
|
||||||
|
tracker or CORS policy is required. The collector is the separate
|
||||||
|
`picpeak-usage` app from [#1110](https://github.com/PicPeak/picpeak/issues/1110).
|
||||||
|
|
||||||
|
| Backend variable | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `USAGE_COLLECTOR_URL` | https://usage.picpeak.app | Fixed operator-configured collector origin, HTTPS in production |
|
||||||
|
| `USAGE_ENCRYPTION_KEY` | JWT_SECRET | 32+ characters, encrypts the local Ed25519 key with AES-256-GCM |
|
||||||
|
|
||||||
|
Both database engines are supported. The state and marker tables are created
|
||||||
|
by migrations 201-205 on PostgreSQL and SQLite alike, and the engine-sensitive
|
||||||
|
paths are covered by `__tests__/integration/productUsagePg.test.js` against a
|
||||||
|
real PostgreSQL — bigint columns come back as strings there, booleans are real
|
||||||
|
booleans rather than 0/1, and the marker write takes `SELECT ... FOR UPDATE`
|
||||||
|
only on that engine. That suite is gated behind `PICPEAK_PG_TEST_URL` and runs
|
||||||
|
in CI, which provides one.
|
||||||
|
|
||||||
|
If `USAGE_COLLECTOR_URL` is unset, empty or blank the built-in default
|
||||||
|
`https://usage.picpeak.app` is used. A value that is present but malformed is
|
||||||
|
reported as a configuration error rather than being replaced by the default:
|
||||||
|
silently retargeting a self-hosted collector at ours would send reports
|
||||||
|
somewhere the operator did not choose.
|
||||||
|
|
||||||
|
Local development can use an HTTP loopback collector outside production. The
|
||||||
|
collector URL is never writable through generic settings or request payloads.
|
||||||
|
|
||||||
|
### The connection only runs outwards
|
||||||
|
|
||||||
|
PicPeak sends; it never pulls. There is exactly one place in the service that
|
||||||
|
reaches the network, it is a POST, and it makes requests to exactly two paths:
|
||||||
|
`/api/envelopes` and — only when an operator asks for their own data export —
|
||||||
|
`/api/participant/lookup`. There is no scheduled job that contacts the
|
||||||
|
collector (the daily rollup is driven solely by an authenticated admin hitting
|
||||||
|
`/activity`), no route the collector could call, and `redirect: 'error'` so the
|
||||||
|
collector cannot even redirect a request elsewhere.
|
||||||
|
|
||||||
|
From a reply the service reads only the acknowledgement for the packet it just
|
||||||
|
sent, and compares `packet_id`, `installation_id`, `packet_digest`, `action`,
|
||||||
|
`sequence` and `status` against that packet before accepting it; a mismatch is
|
||||||
|
an error and nothing else in the response is looked at. The stored copy drops
|
||||||
|
the session token, and no read path hands it back to the UI. A requested data
|
||||||
|
export is streamed to the operator as a file attachment and is never
|
||||||
|
interpreted or executed.
|
||||||
|
|
||||||
|
The consequence is the point, and it is stated in the consent dialog: this
|
||||||
|
channel cannot deliver code, configuration or content into an installation —
|
||||||
|
not even from a collector that has been taken over. It is a one-way path by
|
||||||
|
design, not by convention, and `__tests__/services/usageOutboundOnly.test.js`
|
||||||
|
fails if that ever stops being true.
|
||||||
|
Keep the encryption material stable and protected; losing it makes the old
|
||||||
|
identity unable to sign deletion requests. Note that `USAGE_ENCRYPTION_KEY`
|
||||||
|
defaults to `JWT_SECRET`, so rotating `JWT_SECRET` without setting a dedicated
|
||||||
|
`USAGE_ENCRYPTION_KEY` first loses it. The settings page then reports
|
||||||
|
`SIGNING_KEY_UNREADABLE` rather than a generic delivery failure, because the
|
||||||
|
consequence is specific: reports stop and the deletion request can no longer
|
||||||
|
be signed either. Restoring the original key material is the correct fix and
|
||||||
|
completes the pending deletion. When it is genuinely gone — a rotation done
|
||||||
|
because the secret was compromised — the settings page offers **Discard local
|
||||||
|
identity** (`POST /api/admin/usage/abandon`), which is available in no other
|
||||||
|
state. It erases the local identity, key material and markers and records an
|
||||||
|
abandonment receipt marked `collector-unconfirmed`: the collector was never
|
||||||
|
told, so it keeps the reports already accepted, and the receipt says so rather
|
||||||
|
than claiming a deletion that did not happen. Participation can be started
|
||||||
|
again afterwards with a fresh identity.
|
||||||
|
|
||||||
|
The same exit covers the other way a participation can become impossible to
|
||||||
|
finish: a collector that rejects the packet outright. Opting in to usage.v2
|
||||||
|
against a collector that still only speaks usage.v1 — the deployment order
|
||||||
|
this document warns about above — is answered with `INVALID_PACKET`, which is
|
||||||
|
surfaced as `SCHEMA_NOT_ACCEPTED` rather than a generic delivery failure,
|
||||||
|
because retrying cannot resolve it. Nothing is registered in that case, so
|
||||||
|
**Discard local identity** is offered immediately and its receipt records
|
||||||
|
`never-registered` rather than an unconfirmed deletion. The exit is never
|
||||||
|
offered while a participation the collector *did* accept could still be
|
||||||
|
deleted remotely; that case keeps the explicit warning.
|
||||||
|
|
||||||
|
Keys live in a dedicated database
|
||||||
|
table, not the generic readable settings. A random mode-0600 file at
|
||||||
|
`getStoragePath()/usage-instance.key` binds the database to its local storage.
|
||||||
|
|
||||||
|
## Consent and deletion lifecycle
|
||||||
|
|
||||||
|
### Versioned, explicit scope upgrades
|
||||||
|
|
||||||
|
New participants explicitly consent to usage.v2. Existing v1 participants stay
|
||||||
|
on v1 until they review and explicitly accept the expanded scope; migration 205
|
||||||
|
defaults their consent to v1. A signed consent command preserves the identity
|
||||||
|
and raw history. Collector confirmation atomically upgrades local consent and
|
||||||
|
resets the local used-marker observation period. Lost receipts/outages leave the
|
||||||
|
upgrade visibly pending and retryable, with v1-only collection until confirmed.
|
||||||
|
Opt-out still stops everything immediately. Deploy the v2 collector first.
|
||||||
|
|
||||||
|
The [complete feature and privacy matrix](FEATURE_COVERAGE.md) lists all 73
|
||||||
|
signals (19 existing, 54 new), all 81 current route families and 26 feature flags.
|
||||||
|
56 capabilities have configured/used booleans; 17 guest-facing or automatic
|
||||||
|
capabilities are configuration-only, without a used field. The full catalog is
|
||||||
|
available locally before consent in EN/DE and publicly in the usage portal.
|
||||||
|
Missing signals from older versions are unknown in aggregates, not unused.
|
||||||
|
|
||||||
|
### Participation lifecycle
|
||||||
|
|
||||||
|
Disabled → activation pending → active. Registration/delivery failures are
|
||||||
|
durable and retried. Multiple admin tabs/processes share a database lease;
|
||||||
|
only accepted receipts advance the sequence and report date. Re-signed retries
|
||||||
|
reuse the immutable packet ID so lost acknowledgements do not duplicate data.
|
||||||
|
|
||||||
|
Retries are paced (migration 206). Consecutive failures set `attempts` and
|
||||||
|
`next_attempt_at`, and the unattended sender — the activity endpoint and the
|
||||||
|
settings ticker — waits for that gate: 2, 4, 8, 16, 32 minutes, then hourly.
|
||||||
|
Without it a packet the collector rejects permanently produced one collector
|
||||||
|
request per admin action, because any authenticated admin reaches the activity
|
||||||
|
endpoint and every open admin tab fires it every five minutes. Explicit
|
||||||
|
operator actions are not paced: **Retry** and opt-out send immediately, and the
|
||||||
|
settings page names the time of the next automatic attempt so a waiting
|
||||||
|
installation does not read as a broken one.
|
||||||
|
|
||||||
|
Opt-out immediately stops collection, clears markers/previews/feedback
|
||||||
|
preferences, and enters deletion pending. It keeps only credentials and the
|
||||||
|
deletion operation until the collector confirms deletion. The collector removes
|
||||||
|
reports, projections, feedback/publications, votes, and sessions. PicPeak then
|
||||||
|
erases the local fingerprint, private key and binding. A later join generates
|
||||||
|
a fresh identity. Repeated deletion handles lost receipts safely.
|
||||||
|
|
||||||
|
Migration 204 adds bounded, local-only privacy receipts and removes any legacy
|
||||||
|
plaintext voting token from the last collector receipt. A completed export
|
||||||
|
records its time, the number of accepted reports and the total number of
|
||||||
|
accepted packets separately — feedback, votes and portal sessions are
|
||||||
|
participant operations, not reports, and a receipt that folded them into one
|
||||||
|
"reports" figure stated something untrue about its own contents. Confirmed
|
||||||
|
opt-out replaces this with a deletion receipt containing only a random receipt
|
||||||
|
ID, time, status and fixed scope. It retains no old installation hash, key, payload or credential. The
|
||||||
|
settings page can download these receipts even after opt-out. They are local
|
||||||
|
records of the collector acknowledgement, not independent proof of storage
|
||||||
|
erasure. Downloaded exports carry their own dated receipt; the collector does
|
||||||
|
not create a permanent per-person access/export log.
|
||||||
|
|
||||||
|
A missing/mismatched storage binding or conflicting collector sequence stops
|
||||||
|
reporting with identity conflict. A full clone of a signing identity cannot be
|
||||||
|
distinguished cryptographically. Do not run the same participation identity in
|
||||||
|
two deployments; disable/delete the old participation and rejoin. Deletion
|
||||||
|
affects any other copy that shared the same identity.
|
||||||
|
|
||||||
|
## Feedback and permissions
|
||||||
|
|
||||||
|
Only settings.edit can inspect identity/packets or change participation and
|
||||||
|
feedback preferences. Any authenticated admin may trigger the fixed daily
|
||||||
|
report; the activity endpoint accepts no telemetry input. Every usage endpoint
|
||||||
|
uses adminAuth, including token-type checks. Gallery tokens cannot use it.
|
||||||
|
|
||||||
|
Feedback, votes and portal sessions share one installation-wide budget of 30
|
||||||
|
per hour. They are the only endpoints whose effect is an outbound request
|
||||||
|
carrying operator-written free text, and the platform's general limiter skips
|
||||||
|
authenticated requests by design — correct for endpoints that touch only this
|
||||||
|
installation, wrong for a relay. Reading status, retrying and opting out are
|
||||||
|
never throttled: those are how an operator sees what is happening and how they
|
||||||
|
leave.
|
||||||
|
|
||||||
|
Feedback is sent only on explicit submission. Each item defaults anonymous and
|
||||||
|
private; names, publication permission, and testimonial marketing permission
|
||||||
|
are separate choices. Published requests/testimonials require maintainer review.
|
||||||
|
Public voting uses a backend-authorized 15-minute session, never the lookup hash.
|
||||||
|
|
||||||
|
## Contract
|
||||||
|
|
||||||
|
The closed v1/v2 schemas are in `backend/src/usage/schema.cjs`, with signing in
|
||||||
|
`protocol.cjs`. Keep these and `features.v2.json` byte-identical to the collector's `protocol/` copies.
|
||||||
|
The collector serves its schema and complete source archive publicly. Aggregate
|
||||||
|
projections and the complete dataset are accessible to participating
|
||||||
|
installations only; raw reports require the installation's confidential lookup
|
||||||
|
hash. Raw exports contain the first accepted envelope of every unique usage
|
||||||
|
report. Re-signed transport retries are deduplicated; feedback, registration,
|
||||||
|
sessions and rejected requests are not usage reports. Full exports use a
|
||||||
|
consistent database snapshot at their start, not a 200-record total limit.
|
||||||
|
Feature semantics and retention are documented in its
|
||||||
|
`docs/PROTOCOL.md` and `docs/OPERATIONS.md`.
|
||||||
|
|
||||||
|
Public, reviewed testimonials are separate from marketing approval. Homepage
|
||||||
|
integrations must use `/api/public/marketing-testimonials`, never the general
|
||||||
|
portal testimonial feed. Each page is bounded and exposes its continuation
|
||||||
|
cursor. Deletion removes the source publication; operators must also remove
|
||||||
|
any externally copied content and follow the documented backup/log policies.
|
||||||
|
|
||||||
|
Used flags represent successful allowlisted admin capability calls since
|
||||||
|
consent to the current schema (v1: joining; v2: joining or explicit upgrade),
|
||||||
|
not visitor behavior or counts. OAuth marks successful admin SSO;
|
||||||
|
applied CSS is observed during report generation. Gallery layouts are controlled
|
||||||
|
enums extracted from event themes without IDs or counts. Other signals use the
|
||||||
|
explicit rules in `middleware/productUsage.js`, `usage/capabilityRules.js`,
|
||||||
|
`usage/capabilityEvidence.js`, `usage/expandedSnapshot.js` and `usage/UsageService.js`.
|
||||||
|
|
||||||
|
Tests: `backend/__tests__/routes/adminUsage.test.js`, frontend
|
||||||
|
`features/settings/__tests__/ProductUsageTab.test.tsx`, and the collector's
|
||||||
|
cross-repository integration suite with isolated databases and real HTTP.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { lazy, Suspense, useState } from 'react';
|
||||||
import { Outlet, Navigate } from 'react-router-dom';
|
import { Outlet, Navigate } from 'react-router-dom';
|
||||||
|
|
||||||
import { useAdminAuth } from '../../contexts';
|
import { useAdminAuth } from '../../contexts';
|
||||||
@@ -11,6 +11,7 @@ import { MigrationBanner } from './MigrationBanner';
|
|||||||
import { MandatoryPasswordChangeModal } from './MandatoryPasswordChangeModal';
|
import { MandatoryPasswordChangeModal } from './MandatoryPasswordChangeModal';
|
||||||
|
|
||||||
const SIDEBAR_COLLAPSED_KEY = 'admin-sidebar-collapsed';
|
const SIDEBAR_COLLAPSED_KEY = 'admin-sidebar-collapsed';
|
||||||
|
const ProductUsageNotice = lazy(() => import('./ProductUsageNotice'));
|
||||||
|
|
||||||
export const AdminLayout: React.FC = () => {
|
export const AdminLayout: React.FC = () => {
|
||||||
const { isAuthenticated, isLoading, mustChangePassword } = useAdminAuth();
|
const { isAuthenticated, isLoading, mustChangePassword } = useAdminAuth();
|
||||||
@@ -125,6 +126,7 @@ const AdminLayoutInner: React.FC<AdminLayoutInnerProps> = ({ sidebarOpen, setSid
|
|||||||
(or remove this mount) after operators have had time to update their
|
(or remove this mount) after operators have had time to update their
|
||||||
docker-compose.yml. See #669. */}
|
docker-compose.yml. See #669. */}
|
||||||
<MigrationBanner />
|
<MigrationBanner />
|
||||||
|
{!mustChangePassword && <Suspense fallback={null}><ProductUsageNotice /></Suspense>}
|
||||||
|
|
||||||
{/* Page content - disabled when password change required.
|
{/* Page content - disabled when password change required.
|
||||||
overflow moved up to the column so the scrollbar gutter is
|
overflow moved up to the column so the scrollbar gutter is
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Sparkles } from 'lucide-react';
|
||||||
|
import { usePermissions } from '../../contexts/PermissionsContext';
|
||||||
|
import { productUsageService } from '../../services/productUsage.service';
|
||||||
|
|
||||||
|
// Where the invitation is allowed to appear. It is an invitation, not an
|
||||||
|
// alert, so it belongs on the pages an admin visits deliberately rather than
|
||||||
|
// on top of whatever task they are in the middle of.
|
||||||
|
const NOTICE_PATHS = ['/admin/dashboard', '/admin/settings'];
|
||||||
|
|
||||||
|
// Loaded only inside the authenticated admin tree. Gallery routes never import
|
||||||
|
// this chunk, make usage requests, or record product usage markers.
|
||||||
|
export default function ProductUsageNotice() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { hasPermission } = usePermissions();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['productUsage'],
|
||||||
|
queryFn: productUsageService.status,
|
||||||
|
enabled: hasPermission('settings.edit')
|
||||||
|
});
|
||||||
|
// Deliberately above every visibility test, and deliberately NOT limited to
|
||||||
|
// the pages the banner is shown on. This ticker is what triggers the daily
|
||||||
|
// rollup — the backend has no scheduler — so tying it to the banner would
|
||||||
|
// mean an admin who works on Events and never opens the dashboard stops
|
||||||
|
// reporting altogether, and a participating install (where the banner never
|
||||||
|
// renders at all) would never report again.
|
||||||
|
useEffect(() => {
|
||||||
|
let running = false;
|
||||||
|
const tick = async () => {
|
||||||
|
if (document.visibilityState === 'hidden' || running) return;
|
||||||
|
running = true;
|
||||||
|
try {
|
||||||
|
await productUsageService.activity();
|
||||||
|
} catch {
|
||||||
|
/* Best-effort delivery; settings show persisted retry state. */
|
||||||
|
} finally {
|
||||||
|
running = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void tick();
|
||||||
|
const timer = window.setInterval(tick, 5 * 60 * 1000);
|
||||||
|
document.addEventListener('visibilitychange', tick);
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
document.removeEventListener('visibilitychange', tick);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!hasPermission('settings.edit') || !data) return null;
|
||||||
|
// Only while participation is off. `activation_pending`, `deletion_pending`
|
||||||
|
// and `identity_conflict` are all in-flight states the settings page
|
||||||
|
// explains properly; inviting someone to join in the middle of their own
|
||||||
|
// withdrawal would be worse than saying nothing.
|
||||||
|
if (data.status !== 'disabled' || data.notice_dismissed) return null;
|
||||||
|
if (!NOTICE_PATHS.some((path) => pathname.startsWith(path))) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className="mx-6 mt-4 rounded-lg border border-primary-200 dark:border-primary-800 bg-primary-50 dark:bg-primary-900/20 p-4"
|
||||||
|
aria-label={t('productUsage.title')}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Sparkles className="w-5 h-5 flex-shrink-0 mt-0.5 text-primary-600 dark:text-primary-300" />
|
||||||
|
<div className="min-w-0 text-sm text-primary-900 dark:text-primary-100">
|
||||||
|
<p className="font-medium">{t('productUsage.noticeTitle')}</p>
|
||||||
|
<p className="mt-0.5 text-primary-800 dark:text-primary-200">
|
||||||
|
{t('productUsage.notice')}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-4">
|
||||||
|
<Link
|
||||||
|
className="font-medium underline hover:no-underline"
|
||||||
|
to="/admin/settings?tab=usage"
|
||||||
|
>
|
||||||
|
{t('productUsage.review')}
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="underline hover:no-underline"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
['productUsage'],
|
||||||
|
await productUsageService.dismiss()
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
/* The notice remains available. */
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('productUsage.ignore')}
|
||||||
|
</button>
|
||||||
|
{/* Dismissing is permanent — it sets notice_dismissed on the
|
||||||
|
server, not a session flag — so the label says "Ignore" and
|
||||||
|
this line says where to find it again. "Not now" implied the
|
||||||
|
invitation would come back, and it never does. */}
|
||||||
|
<span className="text-primary-700 dark:text-primary-300">
|
||||||
|
{t('productUsage.ignoreHint')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* The participation invitation (#1110).
|
||||||
|
*
|
||||||
|
* Two properties matter here and are easy to break by accident:
|
||||||
|
*
|
||||||
|
* - it is an INVITATION, so it appears only where an admin goes
|
||||||
|
* deliberately, and only while participation is actually off;
|
||||||
|
* - the activity ticker inside it is what triggers the daily rollup — the
|
||||||
|
* backend has no scheduler — so it must keep running on every admin page,
|
||||||
|
* including the ones where the banner is not rendered and the case where
|
||||||
|
* the install is already participating and the banner never renders at all.
|
||||||
|
*/
|
||||||
|
import { render, screen, waitFor, cleanup } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest';
|
||||||
|
import ProductUsageNotice from '../ProductUsageNotice';
|
||||||
|
import { productUsageService as service } from '../../../services/productUsage.service';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({ t: (key: string) => key }),
|
||||||
|
initReactI18next: { type: '3rdParty', init: () => {} }
|
||||||
|
}));
|
||||||
|
vi.mock('../../../contexts/PermissionsContext', () => ({
|
||||||
|
usePermissions: () => ({ hasPermission: () => true })
|
||||||
|
}));
|
||||||
|
vi.mock('../../../services/productUsage.service', () => ({
|
||||||
|
productUsageService: { status: vi.fn(), activity: vi.fn(), dismiss: vi.fn() }
|
||||||
|
}));
|
||||||
|
|
||||||
|
const status = (over = {}) => ({
|
||||||
|
status: 'disabled',
|
||||||
|
notice_dismissed: false,
|
||||||
|
installation_id: null,
|
||||||
|
collector_url: 'https://collector.example',
|
||||||
|
schema_version: 'usage.v1',
|
||||||
|
last_report_date: null,
|
||||||
|
last_error: null,
|
||||||
|
pending_action: null,
|
||||||
|
last_packet: null,
|
||||||
|
feedback_preferences: { name: '' },
|
||||||
|
...over
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderAt(path: string) {
|
||||||
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={client}>
|
||||||
|
<MemoryRouter initialEntries={[path]}>
|
||||||
|
<ProductUsageNotice />
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
// Absence only means something once the status has actually landed.
|
||||||
|
// Waiting on the service being *called* proved nothing: the component
|
||||||
|
// returns null while `data` is undefined, so every negative assertion
|
||||||
|
// passed even with the gate removed.
|
||||||
|
return {
|
||||||
|
settled: () =>
|
||||||
|
waitFor(() => expect(client.getQueryData(['productUsage'])).toBeDefined())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(service.status).mockResolvedValue(status() as never);
|
||||||
|
vi.mocked(service.activity).mockResolvedValue(undefined as never);
|
||||||
|
});
|
||||||
|
afterEach(() => { cleanup(); vi.clearAllMocks(); });
|
||||||
|
|
||||||
|
describe('product usage notice', () => {
|
||||||
|
it.each(['/admin/dashboard', '/admin/settings', '/admin/settings?tab=usage'])(
|
||||||
|
'invites participation on %s',
|
||||||
|
async (path) => {
|
||||||
|
renderAt(path);
|
||||||
|
expect(await screen.findByText('productUsage.noticeTitle')).toBeInTheDocument();
|
||||||
|
// The dismissal is permanent, so the label must not promise a return.
|
||||||
|
expect(screen.getByText('productUsage.ignore')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('productUsage.ignoreHint')).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(['/admin/events', '/admin/archives', '/admin/users'])(
|
||||||
|
'stays out of the way on %s',
|
||||||
|
async (path) => {
|
||||||
|
const { settled } = renderAt(path);
|
||||||
|
await settled();
|
||||||
|
expect(screen.queryByText('productUsage.noticeTitle')).not.toBeInTheDocument();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(['active', 'activation_pending', 'deletion_pending', 'identity_conflict'])(
|
||||||
|
'does not invite while participation is %s',
|
||||||
|
async (state) => {
|
||||||
|
vi.mocked(service.status).mockResolvedValue(status({ status: state }) as never);
|
||||||
|
const { settled } = renderAt('/admin/dashboard');
|
||||||
|
await settled();
|
||||||
|
expect(screen.queryByText('productUsage.noticeTitle')).not.toBeInTheDocument();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it('does not invite again once ignored', async () => {
|
||||||
|
vi.mocked(service.status).mockResolvedValue(status({ notice_dismissed: true }) as never);
|
||||||
|
const { settled } = renderAt('/admin/dashboard');
|
||||||
|
await settled();
|
||||||
|
expect(screen.queryByText('productUsage.noticeTitle')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still reports activity on a page where the banner is hidden', async () => {
|
||||||
|
// The rollup must not depend on which page the admin happens to be on.
|
||||||
|
const { settled } = renderAt('/admin/events');
|
||||||
|
await waitFor(() => expect(service.activity).toHaveBeenCalled());
|
||||||
|
await settled();
|
||||||
|
expect(screen.queryByText('productUsage.noticeTitle')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still reports activity for an install that is already participating', async () => {
|
||||||
|
// The banner never renders in this state; reporting must continue anyway.
|
||||||
|
vi.mocked(service.status).mockResolvedValue(status({ status: 'active' }) as never);
|
||||||
|
const { settled } = renderAt('/admin/dashboard');
|
||||||
|
await waitFor(() => expect(service.activity).toHaveBeenCalled());
|
||||||
|
await settled();
|
||||||
|
expect(screen.queryByText('productUsage.noticeTitle')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import {
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
fireEvent,
|
||||||
|
waitFor,
|
||||||
|
cleanup,
|
||||||
|
within
|
||||||
|
} from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest';
|
||||||
|
import ProductUsageTab from '../tabs/ProductUsageTab';
|
||||||
|
import {
|
||||||
|
productUsageService as service,
|
||||||
|
type UsageStatus
|
||||||
|
} from '../../../services/productUsage.service';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({ t: (key: string) => key }),
|
||||||
|
// The tab imports from the components/common barrel, which reaches
|
||||||
|
// ErrorBoundary -> i18n/config, and that calls .use(initReactI18next) at
|
||||||
|
// import time. Same shim as FaceRecognitionCard.sidecarHealth.test.tsx.
|
||||||
|
initReactI18next: { type: '3rdParty', init: () => {} }
|
||||||
|
}));
|
||||||
|
vi.mock('../../../components/common/ConfirmDialog', () => ({
|
||||||
|
useConfirm: () => async () => true
|
||||||
|
}));
|
||||||
|
vi.mock('../../../services/productUsage.service', () => ({
|
||||||
|
productUsageService: {
|
||||||
|
status: vi.fn(),
|
||||||
|
enable: vi.fn(),
|
||||||
|
upgradeConsent: vi.fn(),
|
||||||
|
disable: vi.fn(),
|
||||||
|
retry: vi.fn(),
|
||||||
|
abandon: vi.fn(),
|
||||||
|
preview: vi.fn(),
|
||||||
|
export: vi.fn(),
|
||||||
|
preferences: vi.fn(),
|
||||||
|
feedback: vi.fn(),
|
||||||
|
portalSession: vi.fn()
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const status: UsageStatus = {
|
||||||
|
status: 'disabled',
|
||||||
|
notice_dismissed: false,
|
||||||
|
installation_id: null,
|
||||||
|
collector_url: 'https://usage.picpeak.app',
|
||||||
|
schema_version: 'usage.v1',
|
||||||
|
last_report_date: null,
|
||||||
|
last_error: null,
|
||||||
|
pending_action: null,
|
||||||
|
last_packet: null,
|
||||||
|
feedback_preferences: { name: 'Remembered private name' }
|
||||||
|
};
|
||||||
|
const mount = () =>
|
||||||
|
render(
|
||||||
|
<QueryClientProvider
|
||||||
|
client={
|
||||||
|
new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ProductUsageTab />
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(service.status).mockResolvedValue({ ...status });
|
||||||
|
HTMLDialogElement.prototype.showModal = function () {
|
||||||
|
this.setAttribute('open', '');
|
||||||
|
};
|
||||||
|
});
|
||||||
|
afterEach(cleanup);
|
||||||
|
it('shows every v2 signal locally before participation, without collector calls', async () => {
|
||||||
|
mount();
|
||||||
|
await screen.findByText('productUsage.catalogTitle');
|
||||||
|
expect(screen.getAllByRole('heading', { level: 4, hidden: true })).toHaveLength(73);
|
||||||
|
expect(service.enable).not.toHaveBeenCalled();
|
||||||
|
expect(service.preview).not.toHaveBeenCalled();
|
||||||
|
expect(service.upgradeConsent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('existing v1 requires renewed unchecked consent; cancellation keeps v1 unchanged', async () => {
|
||||||
|
vi.mocked(service.status).mockResolvedValue({ ...status, status: 'active', consent_update_available: true });
|
||||||
|
vi.mocked(service.upgradeConsent).mockResolvedValue({ delivered: false, queued: true, state: { ...status, status: 'active', pending_action: 'consent' } });
|
||||||
|
mount();
|
||||||
|
fireEvent.click(await screen.findByText('productUsage.reviewUpgrade'));
|
||||||
|
let dialog = within(screen.getByRole('dialog'));
|
||||||
|
expect(dialog.getByRole('button', { name: 'productUsage.upgrade' })).toBeDisabled();
|
||||||
|
expect(dialog.getByRole('checkbox')).not.toBeChecked();
|
||||||
|
expect(dialog.getByText('productUsage.versionDisclosure')).toBeInTheDocument();
|
||||||
|
fireEvent.click(dialog.getByRole('button', { name: 'productUsage.cancel' }));
|
||||||
|
expect(service.upgradeConsent).not.toHaveBeenCalled();
|
||||||
|
fireEvent.click(screen.getByText('productUsage.reviewUpgrade'));
|
||||||
|
dialog = within(screen.getByRole('dialog'));
|
||||||
|
fireEvent.click(dialog.getByRole('checkbox'));
|
||||||
|
fireEvent.click(dialog.getByRole('button', { name: 'productUsage.upgrade' }));
|
||||||
|
await waitFor(() => expect(service.upgradeConsent).toHaveBeenCalledTimes(1));
|
||||||
|
expect(service.enable).not.toHaveBeenCalled();
|
||||||
|
expect(await screen.findByText('productUsage.queued')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
it('pending v2 confirmation clearly keeps v1 and cannot queue another upgrade', async () => {
|
||||||
|
vi.mocked(service.status).mockResolvedValue({ ...status, status: 'active', consent_update_available: true, pending_action: 'consent' });
|
||||||
|
mount();
|
||||||
|
expect(await screen.findByText('productUsage.upgradePending')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'productUsage.reviewUpgrade' })).toBeDisabled();
|
||||||
|
expect(service.upgradeConsent).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
describe('product usage controls', () => {
|
||||||
|
it('offers identity-free audit receipts after opt-out without restoring participation controls', async () => {
|
||||||
|
vi.mocked(service.status).mockResolvedValue({
|
||||||
|
...status,
|
||||||
|
privacy_receipts: {
|
||||||
|
last_deletion: {
|
||||||
|
receipt_version: 'local-audit.v1',
|
||||||
|
kind: 'deletion',
|
||||||
|
status: 'collector-confirmed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
mount();
|
||||||
|
expect(
|
||||||
|
await screen.findByRole('button', { name: 'productUsage.auditDownload' })
|
||||||
|
).toBeEnabled();
|
||||||
|
expect(
|
||||||
|
screen.getByText('productUsage.auditDescription')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByLabelText('productUsage.hash')
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByText('productUsage.feedbackTitle')
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
it('requires the disclosure and an unchecked-by-default consent before enabling', async () => {
|
||||||
|
mount();
|
||||||
|
fireEvent.click(await screen.findByText('productUsage.review'));
|
||||||
|
const enable = screen.getByRole('button', { name: 'productUsage.enable' });
|
||||||
|
expect(enable).toBeDisabled();
|
||||||
|
for (const key of [
|
||||||
|
'fields',
|
||||||
|
'excluded',
|
||||||
|
'transport',
|
||||||
|
'visibility',
|
||||||
|
'deletion',
|
||||||
|
'feedbackDisclosure'
|
||||||
|
])
|
||||||
|
expect(screen.getByText(`productUsage.${key}`)).toBeInTheDocument();
|
||||||
|
expect(service.enable).not.toHaveBeenCalled();
|
||||||
|
expect(service.preview).not.toHaveBeenCalled();
|
||||||
|
fireEvent.click(screen.getByLabelText('productUsage.consentCheck'));
|
||||||
|
fireEvent.click(enable);
|
||||||
|
await waitFor(() => expect(service.enable).toHaveBeenCalledTimes(1));
|
||||||
|
});
|
||||||
|
it('sends anonymous private feedback even when a remembered name exists', async () => {
|
||||||
|
vi.mocked(service.status).mockResolvedValue({
|
||||||
|
...status,
|
||||||
|
status: 'active',
|
||||||
|
installation_id: 'a'.repeat(64)
|
||||||
|
});
|
||||||
|
vi.mocked(service.feedback).mockResolvedValue({
|
||||||
|
delivered: true,
|
||||||
|
state: { ...status, status: 'active' }
|
||||||
|
});
|
||||||
|
mount();
|
||||||
|
await screen.findByText('productUsage.feedbackTitle');
|
||||||
|
expect(screen.getByLabelText('productUsage.includeName')).not.toBeChecked();
|
||||||
|
fireEvent.change(screen.getByLabelText('productUsage.subject'), {
|
||||||
|
target: { value: 'Feedback title' }
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText('productUsage.message'), {
|
||||||
|
target: { value: 'Useful details' }
|
||||||
|
});
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole('button', { name: 'productUsage.sendFeedback' })
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(service.feedback).toHaveBeenCalledWith({
|
||||||
|
kind: 'feedback',
|
||||||
|
title: 'Feedback title',
|
||||||
|
body: 'Useful details',
|
||||||
|
name: '',
|
||||||
|
allow_public: false,
|
||||||
|
allow_marketing: false
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('requires a separate marketing choice and clears it when publication permission is removed', async () => {
|
||||||
|
vi.mocked(service.status).mockResolvedValue({
|
||||||
|
...status,
|
||||||
|
status: 'active'
|
||||||
|
});
|
||||||
|
mount();
|
||||||
|
fireEvent.change(await screen.findByLabelText('productUsage.kind'), {
|
||||||
|
target: { value: 'testimonial' }
|
||||||
|
});
|
||||||
|
expect(screen.getByLabelText('productUsage.allowPublic')).not.toBeChecked();
|
||||||
|
expect(screen.getByLabelText('productUsage.allowMarketing')).toBeDisabled();
|
||||||
|
fireEvent.click(screen.getByLabelText('productUsage.allowPublic'));
|
||||||
|
fireEvent.click(screen.getByLabelText('productUsage.allowMarketing'));
|
||||||
|
expect(screen.getByLabelText('productUsage.allowMarketing')).toBeChecked();
|
||||||
|
fireEvent.click(screen.getByLabelText('productUsage.allowPublic'));
|
||||||
|
expect(
|
||||||
|
screen.getByLabelText('productUsage.allowMarketing')
|
||||||
|
).not.toBeChecked();
|
||||||
|
});
|
||||||
|
it('keeps deletion pending explicit and offers retry without rejoining or sending feedback', async () => {
|
||||||
|
vi.mocked(service.status).mockResolvedValue({
|
||||||
|
...status,
|
||||||
|
status: 'deletion_pending',
|
||||||
|
last_error: 'DELIVERY_FAILED'
|
||||||
|
});
|
||||||
|
mount();
|
||||||
|
await screen.findByText('productUsage.states.deletion_pending');
|
||||||
|
expect(screen.queryByText('productUsage.review')).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByText('productUsage.feedbackTitle')
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'productUsage.retry' }));
|
||||||
|
await waitFor(() => expect(service.retry).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a withdrawal that can never be signed', () => {
|
||||||
|
const stuck: UsageStatus = {
|
||||||
|
...status,
|
||||||
|
status: 'deletion_pending',
|
||||||
|
installation_id: 'a'.repeat(64),
|
||||||
|
schema_version: 'usage.v2',
|
||||||
|
last_error: 'SIGNING_KEY_UNREADABLE',
|
||||||
|
can_abandon: true
|
||||||
|
};
|
||||||
|
|
||||||
|
it('explains the dead end and offers the only remaining exit', async () => {
|
||||||
|
vi.mocked(service.status).mockResolvedValue(stuck);
|
||||||
|
vi.mocked(service.abandon).mockResolvedValue({ ...status });
|
||||||
|
mount();
|
||||||
|
|
||||||
|
// The operator is told what happened before being offered the exit.
|
||||||
|
await screen.findByText('productUsage.signingKeyUnreadable');
|
||||||
|
await screen.findByText('productUsage.abandonExplanation');
|
||||||
|
fireEvent.click(await screen.findByText('productUsage.abandon'));
|
||||||
|
await waitFor(() => expect(service.abandon).toHaveBeenCalledTimes(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not offer it for a withdrawal that is merely undelivered', async () => {
|
||||||
|
vi.mocked(service.status).mockResolvedValue({
|
||||||
|
...stuck,
|
||||||
|
last_error: 'DELIVERY_FAILED',
|
||||||
|
can_abandon: false
|
||||||
|
});
|
||||||
|
mount();
|
||||||
|
await screen.findByText('productUsage.deliveryProblem');
|
||||||
|
expect(screen.queryByText('productUsage.abandon')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says the sender is waiting rather than leaving a bare error on screen', async () => {
|
||||||
|
vi.mocked(service.status).mockResolvedValue({
|
||||||
|
...status,
|
||||||
|
status: 'active',
|
||||||
|
schema_version: 'usage.v2',
|
||||||
|
installation_id: 'a'.repeat(64),
|
||||||
|
last_error: 'DELIVERY_FAILED',
|
||||||
|
retry_after: Date.now() + 600000
|
||||||
|
});
|
||||||
|
mount();
|
||||||
|
await screen.findByText('productUsage.retryScheduled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks a deletion receipt as belonging to an earlier participation', async () => {
|
||||||
|
const receipts = { last_deletion: { kind: 'deletion' } };
|
||||||
|
vi.mocked(service.status).mockResolvedValue({
|
||||||
|
...status,
|
||||||
|
status: 'active',
|
||||||
|
schema_version: 'usage.v2',
|
||||||
|
installation_id: 'a'.repeat(64),
|
||||||
|
privacy_receipts: receipts
|
||||||
|
});
|
||||||
|
mount();
|
||||||
|
await screen.findByText('productUsage.auditPreviousParticipation');
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
// Withdrawn: the same receipt now describes the participation just ended,
|
||||||
|
// so the qualifier would be wrong.
|
||||||
|
vi.mocked(service.status).mockResolvedValue({ ...status, privacy_receipts: receipts });
|
||||||
|
mount();
|
||||||
|
await screen.findByText('productUsage.auditTitle');
|
||||||
|
expect(screen.queryByText('productUsage.auditPreviousParticipation')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns focus to the control that opened the consent dialog', async () => {
|
||||||
|
mount();
|
||||||
|
const trigger = await screen.findByText('productUsage.review');
|
||||||
|
trigger.focus();
|
||||||
|
expect(document.activeElement).toBe(trigger);
|
||||||
|
|
||||||
|
fireEvent.click(trigger);
|
||||||
|
await screen.findByText('productUsage.consentTitle');
|
||||||
|
fireEvent.click(screen.getByText('productUsage.cancel'));
|
||||||
|
|
||||||
|
// Without the restore this lands on <body>, dropping a keyboard user back
|
||||||
|
// to the top of the page (WCAG 2.4.3).
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(document.activeElement).toBe(
|
||||||
|
screen.getByText('productUsage.review')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A security property, not a nicety: the consent dialog is where an operator
|
||||||
|
// decides whether to open a connection at all, so it has to say which way that
|
||||||
|
// connection runs. UsageService makes exactly two outbound POSTs and reads
|
||||||
|
// nothing but the acknowledgement for the packet it just sent.
|
||||||
|
it('states in the consent dialog that the connection only runs outwards', async () => {
|
||||||
|
mount();
|
||||||
|
fireEvent.click(await screen.findByText('productUsage.review'));
|
||||||
|
const dialog = await screen.findByText('productUsage.consentTitle');
|
||||||
|
expect(dialog).toBeTruthy();
|
||||||
|
await screen.findByText('productUsage.sectionOneWay');
|
||||||
|
await screen.findByText('productUsage.oneWay');
|
||||||
|
});
|
||||||
@@ -0,0 +1,680 @@
|
|||||||
|
import { useEffect, useRef, useState, type ComponentType } from 'react';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
productUsageService as service,
|
||||||
|
type ProductFeedback
|
||||||
|
} from '../../../services/productUsage.service';
|
||||||
|
import {
|
||||||
|
ArrowUpFromLine,
|
||||||
|
Globe,
|
||||||
|
ListChecks,
|
||||||
|
MessageSquare,
|
||||||
|
Send,
|
||||||
|
ShieldOff,
|
||||||
|
Sparkles,
|
||||||
|
Trash2
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useConfirm } from '../../../components/common/ConfirmDialog';
|
||||||
|
import { Button, Card } from '../../../components/common';
|
||||||
|
import { UsageCatalog } from '../UsageCatalog';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sections of the disclosure, in reading order. Each is a translated
|
||||||
|
* paragraph; the heading and icon give it a shape you can scan instead of
|
||||||
|
* seven identical blocks of prose.
|
||||||
|
*/
|
||||||
|
const DISCLOSURE: {
|
||||||
|
key: string;
|
||||||
|
heading: string;
|
||||||
|
Icon: ComponentType<{ className?: string }>;
|
||||||
|
}[] = [
|
||||||
|
{ key: 'fields', heading: 'sectionFields', Icon: ListChecks },
|
||||||
|
{ key: 'excluded', heading: 'sectionExcluded', Icon: ShieldOff },
|
||||||
|
{ key: 'transport', heading: 'sectionTransport', Icon: Send },
|
||||||
|
// Directly after transport, because it is a property of the transport and
|
||||||
|
// the reason the transport is shaped this way: the connection only ever
|
||||||
|
// runs outwards, so this cannot become a way to push anything in.
|
||||||
|
{ key: 'oneWay', heading: 'sectionOneWay', Icon: ArrowUpFromLine },
|
||||||
|
{ key: 'visibility', heading: 'sectionVisibility', Icon: Globe },
|
||||||
|
{ key: 'deletion', heading: 'sectionDeletion', Icon: Trash2 },
|
||||||
|
{ key: 'feedbackDisclosure', heading: 'sectionFeedback', Icon: MessageSquare }
|
||||||
|
];
|
||||||
|
|
||||||
|
// `.btn` is whitespace-nowrap and `.btn-md` a fixed 2.5rem tall — right for
|
||||||
|
// short labels, wrong for the sentence-length ones in this tab, which ran off
|
||||||
|
// the card at 390px and then, once allowed to wrap, out of the fixed height.
|
||||||
|
// h-auto lets the second line have somewhere to go; min-h keeps a one-line
|
||||||
|
// button the same size as every other button beside it.
|
||||||
|
const WRAPPING_BUTTON = 'max-w-full whitespace-normal text-left h-auto min-h-[2.5rem]';
|
||||||
|
|
||||||
|
function ConsentDialog({
|
||||||
|
close,
|
||||||
|
enable,
|
||||||
|
busy,
|
||||||
|
collector,
|
||||||
|
upgrade = false
|
||||||
|
}: {
|
||||||
|
close: () => void;
|
||||||
|
enable: () => void;
|
||||||
|
busy: boolean;
|
||||||
|
collector: string;
|
||||||
|
upgrade?: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const ref = useRef<HTMLDialogElement>(null);
|
||||||
|
const [checked, setChecked] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
// React unmounts this <dialog> on close rather than only closing it, so
|
||||||
|
// the focus restoration showModal() normally performs has nothing left to
|
||||||
|
// return to and focus drops to <body> — a keyboard user is thrown back to
|
||||||
|
// the top of the page every time they cancel (WCAG 2.4.3). Remember the
|
||||||
|
// opener and put focus back by hand.
|
||||||
|
const opener = document.activeElement as HTMLElement | null;
|
||||||
|
ref.current?.showModal();
|
||||||
|
// showModal() focuses the first focusable descendant, which is the scroll
|
||||||
|
// region below — so its focus ring was drawn for everyone the moment the
|
||||||
|
// dialog opened, and because the dialog clips its sides an inset ring
|
||||||
|
// reads as two coloured bars across the disclosure rather than a ring.
|
||||||
|
// Focusing the dialog puts the ring back where it belongs: only when
|
||||||
|
// someone deliberately tabs to the region.
|
||||||
|
ref.current?.focus();
|
||||||
|
return () => {
|
||||||
|
if (opener?.isConnected) opener.focus();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<dialog
|
||||||
|
ref={ref}
|
||||||
|
onCancel={close}
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-labelledby="usage-consent-title"
|
||||||
|
// Column layout with its own scroll region, so the title stays put and
|
||||||
|
// the actions never scroll out of reach on a short screen.
|
||||||
|
//
|
||||||
|
// Surface is class-driven rather than `bg-theme-surface`: that variable
|
||||||
|
// does not follow dark mode, so it stayed white while the dark: text
|
||||||
|
// variants below turned near-white. neutral-800 is what `.card`
|
||||||
|
// resolves to in dark, which is what the rest of the admin UI uses.
|
||||||
|
className="w-full max-w-2xl max-h-[85vh] flex flex-col overflow-hidden rounded-xl p-0 bg-white dark:bg-neutral-800 shadow-xl backdrop:bg-black/50 focus:outline-none"
|
||||||
|
>
|
||||||
|
<header className="flex items-start gap-3 px-6 pt-6 pb-4">
|
||||||
|
<span className="mt-0.5 flex h-9 w-9 flex-none items-center justify-center rounded-full bg-primary-50 dark:bg-primary-900/30">
|
||||||
|
<Sparkles className="h-5 w-5 text-primary-600 dark:text-primary-300" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2
|
||||||
|
id="usage-consent-title"
|
||||||
|
className="text-lg font-semibold text-neutral-900 dark:text-neutral-100"
|
||||||
|
>
|
||||||
|
{t('productUsage.consentTitle')}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
|
{t('productUsage.purpose')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* A scrollable region is focusable, which is correct for keyboard use —
|
||||||
|
but unstyled it drew a default ring that made the disclosure look
|
||||||
|
like a textarea. Given a real label and ring so it reads as what it
|
||||||
|
is: a document you can scroll. */}
|
||||||
|
<div
|
||||||
|
tabIndex={0}
|
||||||
|
role="group"
|
||||||
|
aria-label={t('productUsage.consentTitle') as string}
|
||||||
|
className="flex-1 overflow-y-auto border-y border-neutral-200 dark:border-neutral-700 px-6 py-4 space-y-4 focus:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary-400"
|
||||||
|
>
|
||||||
|
{DISCLOSURE.map(({ key, heading, Icon }) => (
|
||||||
|
<section key={key}>
|
||||||
|
<h3 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
|
||||||
|
<Icon className="h-3.5 w-3.5" />
|
||||||
|
{t(`productUsage.${heading}`)}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
|
{t(`productUsage.${key}`, { collector })}
|
||||||
|
</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
|
||||||
|
className="text-primary-600 dark:text-primary-400 hover:underline"
|
||||||
|
href={collector}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{t('productUsage.linkCollector')}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
className="text-primary-600 dark:text-primary-400 hover:underline"
|
||||||
|
href={`${collector}/transparency`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{t('productUsage.transparency')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="px-6 pt-4 pb-6 space-y-4">
|
||||||
|
<label className="flex items-start gap-2.5 text-sm text-neutral-800 dark:text-neutral-200">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-0.5 h-4 w-4 flex-none"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => setChecked(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>{t('productUsage.consentCheck')}</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button variant="outline" onClick={close} disabled={busy}>
|
||||||
|
{t('productUsage.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={enable} disabled={!checked || busy}>
|
||||||
|
{t(upgrade ? 'productUsage.upgrade' : 'productUsage.enable')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductUsageTab() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const { data, isPending, isError } = useQuery({
|
||||||
|
queryKey: ['productUsage'],
|
||||||
|
queryFn: service.status,
|
||||||
|
refetchInterval: 30000
|
||||||
|
});
|
||||||
|
const [consent, setConsent] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [preview, setPreview] = useState<unknown>(null);
|
||||||
|
const [portalUrl, setPortalUrl] = useState<string | null>(null);
|
||||||
|
const [named, setNamed] = useState(false);
|
||||||
|
const [form, setForm] = useState<ProductFeedback>({
|
||||||
|
kind: 'feedback',
|
||||||
|
title: '',
|
||||||
|
body: '',
|
||||||
|
name: '',
|
||||||
|
allow_public: false,
|
||||||
|
allow_marketing: false
|
||||||
|
});
|
||||||
|
const run = async (fn: () => Promise<void>) => {
|
||||||
|
setBusy(true);
|
||||||
|
setMessage('');
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
} catch {
|
||||||
|
setMessage(t('productUsage.failed'));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['productUsage'] });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const download = (value: unknown, filename = 'picpeak-usage-packets.json') => {
|
||||||
|
const url = URL.createObjectURL(
|
||||||
|
new Blob([JSON.stringify(value, null, 2)], { type: 'application/json' })
|
||||||
|
);
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = filename;
|
||||||
|
anchor.click();
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
};
|
||||||
|
if (isPending) return <p>{t('productUsage.loading')}</p>;
|
||||||
|
if (isError || !data) return <p role="alert">{t('productUsage.failed')}</p>;
|
||||||
|
const active = data.status === 'active';
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 text-theme">
|
||||||
|
<p>{t('productUsage.purpose')}</p>
|
||||||
|
<Card padding="md" className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
|
{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')}
|
||||||
|
<input
|
||||||
|
className="mt-1 w-full rounded border border-theme bg-theme-surface p-2 font-mono text-sm"
|
||||||
|
readOnly
|
||||||
|
value={data.installation_id}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{data.last_report_date && (
|
||||||
|
<p>{t('productUsage.lastReport', { date: data.last_report_date })}</p>
|
||||||
|
)}
|
||||||
|
{data.collector_error === 'INVALID_COLLECTOR_URL' && (
|
||||||
|
<p role="alert" className="text-amber-700 dark:text-amber-300">
|
||||||
|
{/* Shown alongside the real controls, not instead of them: with a
|
||||||
|
bad URL the operator still needs to read their status and
|
||||||
|
still needs to be able to withdraw. */}
|
||||||
|
{t('productUsage.invalidCollectorUrl')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{data.last_error && (
|
||||||
|
<p role="status">
|
||||||
|
{/* Retrying cannot fix an unreadable signing key, and neither can
|
||||||
|
disabling: without the original encryption material the
|
||||||
|
deletion request cannot be signed either. Telling the operator
|
||||||
|
to retry would send them in a circle. */}
|
||||||
|
{t(
|
||||||
|
data.last_error === 'SIGNING_KEY_UNREADABLE'
|
||||||
|
? 'productUsage.signingKeyUnreadable'
|
||||||
|
: data.last_error === 'SCHEMA_NOT_ACCEPTED'
|
||||||
|
? 'productUsage.schemaNotAccepted'
|
||||||
|
: 'productUsage.deliveryProblem'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{data.retry_after && (
|
||||||
|
// A paced install is waiting, not broken. Without this the tab shows
|
||||||
|
// a delivery error and an idle Retry button, and nothing says the
|
||||||
|
// sender is going to try again on its own.
|
||||||
|
<p role="status" className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
|
{t('productUsage.retryScheduled', {
|
||||||
|
time: new Date(data.retry_after).toLocaleTimeString()
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{data.can_abandon && (
|
||||||
|
// The one dead end the operator cannot retry out of. Offered only
|
||||||
|
// here, and worded so nobody mistakes it for a confirmed deletion.
|
||||||
|
<div className="rounded border border-amber-300 dark:border-amber-700 p-3 space-y-2">
|
||||||
|
<p>
|
||||||
|
{t(
|
||||||
|
data.abandon_never_registered
|
||||||
|
? 'productUsage.abandonExplanationUnregistered'
|
||||||
|
: 'productUsage.abandonExplanation'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={WRAPPING_BUTTON}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={async () => {
|
||||||
|
if (
|
||||||
|
await confirm({
|
||||||
|
title: t('productUsage.abandon'),
|
||||||
|
message: t(
|
||||||
|
data.abandon_never_registered
|
||||||
|
? 'productUsage.abandonConfirmUnregistered'
|
||||||
|
: 'productUsage.abandonConfirm'
|
||||||
|
),
|
||||||
|
confirmLabel: t('productUsage.abandon'),
|
||||||
|
variant: 'danger'
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
await run(async () => {
|
||||||
|
await service.abandon();
|
||||||
|
setPreview(null);
|
||||||
|
setPortalUrl(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('productUsage.abandon')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{data.status === 'disabled' ? (
|
||||||
|
<Button disabled={busy} onClick={() => setConsent(true)}>
|
||||||
|
{t('productUsage.review')}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
run(async () => {
|
||||||
|
await service.retry();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('productUsage.retry')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy || data.status === 'deletion_pending'}
|
||||||
|
onClick={async () => {
|
||||||
|
if (
|
||||||
|
await confirm({
|
||||||
|
title: t('productUsage.disable'),
|
||||||
|
message: t('productUsage.deletion'),
|
||||||
|
confirmLabel: t('productUsage.disable'),
|
||||||
|
variant: 'danger'
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
await run(async () => {
|
||||||
|
await service.disable();
|
||||||
|
setPreview(null);
|
||||||
|
setPortalUrl(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('productUsage.disable')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{data.collector_url && (
|
||||||
|
<a
|
||||||
|
className="text-sm text-primary-600 dark:text-primary-400 hover:underline self-center"
|
||||||
|
href={`${data.collector_url}/transparency`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{t('productUsage.transparency')}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<UsageCatalog />
|
||||||
|
{data.privacy_receipts &&
|
||||||
|
Object.keys(data.privacy_receipts).length > 0 && (
|
||||||
|
<Card padding="md" className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
{t('productUsage.auditTitle')}
|
||||||
|
</h3>
|
||||||
|
<p>{t('productUsage.auditDescription')}</p>
|
||||||
|
{/* The receipts outlive the participation they describe: rejoining
|
||||||
|
does not clear them, so an active install would otherwise show
|
||||||
|
a bare "deletion confirmed" next to its own live participation
|
||||||
|
and read as a contradiction. */}
|
||||||
|
{active &&
|
||||||
|
Boolean(
|
||||||
|
data.privacy_receipts.last_deletion ||
|
||||||
|
data.privacy_receipts.last_abandonment
|
||||||
|
) && (
|
||||||
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
|
{t('productUsage.auditPreviousParticipation')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() =>
|
||||||
|
download(
|
||||||
|
data.privacy_receipts,
|
||||||
|
'picpeak-usage-privacy-receipts.json'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('productUsage.auditDownload')}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
{active && (
|
||||||
|
<>
|
||||||
|
<Card padding="md" className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
|
{t('productUsage.inspect')}
|
||||||
|
</h3>
|
||||||
|
{/* `.btn` sets whitespace-nowrap, and these labels are long
|
||||||
|
sentences in both locales — at 390px two of them ran past the
|
||||||
|
card and their text was simply cut off. Allowed to wrap and
|
||||||
|
capped at the container width instead. */}
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={WRAPPING_BUTTON}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
run(async () => setPreview(await service.preview()))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('productUsage.preview')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={WRAPPING_BUTTON}
|
||||||
|
disabled={busy || !data.last_packet}
|
||||||
|
onClick={() => setPreview(data.last_packet)}
|
||||||
|
>
|
||||||
|
{t('productUsage.lastPacket')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={WRAPPING_BUTTON}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
run(async () => download(await service.export()))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('productUsage.export')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={WRAPPING_BUTTON}
|
||||||
|
disabled={busy || Boolean(data.pending_action)}
|
||||||
|
onClick={() =>
|
||||||
|
run(async () => {
|
||||||
|
const result = await service.portalSession();
|
||||||
|
setPortalUrl(result.url);
|
||||||
|
if (!result.delivered) setMessage(t('productUsage.queued'));
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('productUsage.connect')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{portalUrl && (
|
||||||
|
<a
|
||||||
|
href={portalUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="underline"
|
||||||
|
>
|
||||||
|
{t('productUsage.openPortal')}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{preview !== null && (
|
||||||
|
<pre
|
||||||
|
className="max-h-96 overflow-auto rounded border border-theme p-3 text-xs"
|
||||||
|
aria-label={t('productUsage.preview')}
|
||||||
|
>
|
||||||
|
{JSON.stringify(preview, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
<Card padding="md">
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void run(async () => {
|
||||||
|
const result = await service.feedback({
|
||||||
|
...form,
|
||||||
|
name: named ? form.name : ''
|
||||||
|
});
|
||||||
|
setMessage(
|
||||||
|
t(
|
||||||
|
result.delivered
|
||||||
|
? 'productUsage.feedbackSent'
|
||||||
|
: result.queued
|
||||||
|
? 'productUsage.queued'
|
||||||
|
: 'productUsage.failed'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
// Every consent choice resets with the item it was made for.
|
||||||
|
// Leaving `named` checked meant the next submission carried
|
||||||
|
// the previous name automatically, which contradicts the
|
||||||
|
// per-item, anonymous-by-default promise the disclosure makes
|
||||||
|
// — the remembered name stays in preferences, but attaching
|
||||||
|
// it is a decision taken again each time.
|
||||||
|
setNamed(false);
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
title: '',
|
||||||
|
body: '',
|
||||||
|
allow_public: false,
|
||||||
|
allow_marketing: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
|
{t('productUsage.feedbackTitle')}
|
||||||
|
</h3>
|
||||||
|
<p>{t('productUsage.feedbackDisclosure')}</p>
|
||||||
|
<label className="block">
|
||||||
|
{t('productUsage.kind')}
|
||||||
|
<select
|
||||||
|
aria-label={t('productUsage.kind')}
|
||||||
|
className="block mt-1 rounded border border-theme bg-theme-surface p-2"
|
||||||
|
value={form.kind}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
kind: e.target.value as ProductFeedback['kind'],
|
||||||
|
allow_public: false,
|
||||||
|
allow_marketing: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{['feedback', 'feature_request', 'testimonial'].map((kind) => (
|
||||||
|
<option key={kind} value={kind}>
|
||||||
|
{t(`productUsage.kinds.${kind}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
{t('productUsage.subject')}
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
maxLength={120}
|
||||||
|
className="block mt-1 w-full rounded border border-theme bg-theme-surface p-2"
|
||||||
|
value={form.title}
|
||||||
|
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
{t('productUsage.message')}
|
||||||
|
<textarea
|
||||||
|
required
|
||||||
|
maxLength={4000}
|
||||||
|
rows={5}
|
||||||
|
className="block mt-1 w-full rounded border border-theme bg-theme-surface p-2"
|
||||||
|
value={form.body}
|
||||||
|
onChange={(e) => setForm({ ...form, body: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={named}
|
||||||
|
onChange={(e) => {
|
||||||
|
setNamed(e.target.checked);
|
||||||
|
if (e.target.checked && !form.name)
|
||||||
|
setForm({ ...form, name: data.feedback_preferences.name });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{t('productUsage.includeName')}
|
||||||
|
</label>
|
||||||
|
{named && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block">
|
||||||
|
{t('productUsage.name')}
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
maxLength={80}
|
||||||
|
className="block mt-1 rounded border border-theme bg-theme-surface p-2"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
run(async () => {
|
||||||
|
await service.preferences(form.name);
|
||||||
|
setMessage(t('productUsage.saved'));
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('productUsage.saveName')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{form.kind !== 'feedback' && (
|
||||||
|
<label className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.allow_public}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
allow_public: e.target.checked,
|
||||||
|
allow_marketing: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{t('productUsage.allowPublic')}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{form.kind === 'testimonial' && (
|
||||||
|
<label className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.allow_marketing}
|
||||||
|
disabled={!form.allow_public}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, allow_marketing: e.target.checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{t('productUsage.allowMarketing')}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy || Boolean(data.pending_action)}
|
||||||
|
>
|
||||||
|
{t('productUsage.sendFeedback')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{message && <p role="status">{message}</p>}
|
||||||
|
{consent && (
|
||||||
|
<ConsentDialog
|
||||||
|
upgrade={active}
|
||||||
|
collector={data.collector_url ?? ''}
|
||||||
|
busy={busy}
|
||||||
|
close={() => setConsent(false)}
|
||||||
|
enable={() =>
|
||||||
|
run(async () => {
|
||||||
|
if (active) {
|
||||||
|
const result = await service.upgradeConsent();
|
||||||
|
if (!result.delivered) setMessage(t('productUsage.queued'));
|
||||||
|
setPreview(null);
|
||||||
|
} else await service.enable();
|
||||||
|
setConsent(false);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,454 @@
|
|||||||
{
|
{
|
||||||
|
"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": "Ihre bestehende usage.v1-Teilnahme bleibt unverändert. Prüfen Sie den vollständigen erweiterten Katalog, bevor Sie über das Upgrade entscheiden. Eine Ablehnung beendet Ihre bisherige Teilnahme nicht.",
|
||||||
|
"upgradePending": "Die signierte Erweiterung der Zustimmung wartet auf Bestätigung. Es wird nur der bisherige v1-Umfang erfasst. Versuchen Sie es erneut, sobald der Collector erreichbar ist, oder deaktivieren Sie 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": "Über die Auslieferungsvorgaben hinaus aktiviert — höhere Schutzstufe, Canvas-Rendering oder deaktivierter Rechtsklick — global oder in mindestens einer Galerie; nur installationsweite Existenz, niemals Galeriekennungen oder Anzahlen."
|
||||||
|
},
|
||||||
|
"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": "Laden Sie Ihre 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",
|
||||||
|
"title": "Produktnutzung & Feedback",
|
||||||
|
"noticeTitle": "Gestalten Sie PicPeak mit",
|
||||||
|
"notice": "Optionale Nutzungsberichte zeigen, welche Funktionen für die Community wichtig sind. Die Übermittlung ist aus, bis Sie sich aktiv dafür entscheiden.",
|
||||||
|
"ignore": "Ignorieren",
|
||||||
|
"ignoreHint": "Wenn Sie ignorieren, erscheint dieser Hinweis nicht erneut — Sie können weiterhin unter Einstellungen → Produktnutzung teilnehmen.",
|
||||||
|
"review": "Teilnahme prüfen",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"loading": "Teilnahmeeinstellungen werden geladen…",
|
||||||
|
"failed": "Der Vorgang konnte nicht abgeschlossen werden. Prüfen Sie den Status und versuchen Sie es erneut.",
|
||||||
|
"purpose": "Hilf bei der Priorisierung von PicPeak-Funktionen, Fehlerbehebungen und Wartung mit groben Informationen über teilnehmende Installationen.",
|
||||||
|
"consentTitle": "Produktnutzung freiwillig teilen",
|
||||||
|
"sectionFields": "Was ein Bericht enthält",
|
||||||
|
"sectionExcluded": "Was niemals enthalten ist",
|
||||||
|
"sectionTransport": "Wie er gesendet wird",
|
||||||
|
"sectionVisibility": "Wo er sichtbar ist",
|
||||||
|
"sectionDeletion": "Beenden und löschen",
|
||||||
|
"sectionFeedback": "Feedback ist getrennt",
|
||||||
|
"excluded": "Automatische Berichte enthalten keine Galeriebesucher, Klickverläufe, Foto- oder Galerieanzahlen, Namen, E-Mail-Adressen, Domains, Dateinamen oder Zugangsdaten.",
|
||||||
|
"transport": "Ihr PicPeak-Backend verwahrt den Signaturschlüssel und sendet einmal pro UTC-Tag bei Admin-Nutzung signierte Nutzungsberichte an {{collector}}. Sie können 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.",
|
||||||
|
"sectionOneWay": "Nur senden — kein Rückkanal",
|
||||||
|
"oneWay": "PicPeak sendet ausschließlich. Es ruft beim Collector nichts ab, holt sich keine Anweisungen und stellt ihm keinen Endpunkt bereit, den er aufrufen könnte — auf diesem Weg gibt es weder einen geplanten Job noch eine eingehende Route. Aus einer Antwort wird nur die Bestätigung für das eben gesendete Paket gelesen, und jedes ihrer Felder wird gegen dieses Paket geprüft, bevor sie angenommen wird; alles andere wird verworfen. Ein von Ihnen angeforderter Datenexport wird Ihnen als Datei übergeben und niemals ausgewertet oder ausgeführt. Über diesen Kanal können also weder Code noch Konfiguration oder Inhalte in Ihre Installation gelangen — auch nicht von einem übernommenen Collector.",
|
||||||
|
"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. Ihr Fingerabdruck ist pseudonym, nicht anonym. Bewahren Sie Ihren Abfrage-Hash vertraulich auf: Er ermöglicht lesenden Zugriff auf Ihre eigenen Berichte und den Teilnehmerdatensatz.",
|
||||||
|
"deletion": "Deaktivieren stoppt die Erfassung sofort und fordert die Löschung der Berichte, Aggregatbeiträge, Rückmeldungen, Veröffentlichungen, Stimmen und Sitzungen an. Bei einem Ausfall bleiben nur die zur Löschung nötigen Zugangsdaten erhalten; die Oberfläche zeigt die ausstehende Löschung. Nach Bestätigung werden Hash und Schlüssel lokal gelöscht; eine erneute Teilnahme erzeugt eine neue Identität. Der Collector behält einen Einweg-Sperrwert und kurzlebige Missbrauchszähler ohne Installationsbezug. PicPeak speichert eine herunterladbare lokale Löschquittung ohne den alten Hash, Schlüssel oder Inhalte.",
|
||||||
|
"feedbackDisclosure": "Feedback wird getrennt von automatischen Berichten und nur beim Absenden übertragen. Jeder Beitrag ist anonym, sofern Sie keinen Namen angeben, und nur für Betreuer sichtbar, sofern Sie die Veröffentlichung nicht ausdrücklich erlauben. Öffentliche Beiträge werden geprüft. Die Verwendung einer Empfehlung für Marketing benötigt eine zusätzliche Erlaubnis.",
|
||||||
|
"consentCheck": "Ich habe diese Hinweise gelesen und stimme der Teilnahme ausdrücklich zu.",
|
||||||
|
"enable": "Produktnutzung aktivieren",
|
||||||
|
"disable": "Deaktivieren & Daten löschen",
|
||||||
|
"retry": "Erneut versuchen / fälligen Bericht senden",
|
||||||
|
"transparency": "Öffentliches Schema & Datenschutzhinweise",
|
||||||
|
"linkCollector": "Wohin Berichte gesendet werden",
|
||||||
|
"hash": "Ihr vertraulicher Abfrage-Hash",
|
||||||
|
"lastReport": "Zuletzt angenommener Bericht: {{date}} (UTC)",
|
||||||
|
"deliveryProblem": "Die Übertragung benötigt Aufmerksamkeit. Bei Löschung oder Identitätskonflikt ist die Erfassung gestoppt. Versuchen Sie es erneut oder deaktivieren Sie die Teilnahme, um die Daten zu löschen.",
|
||||||
|
"invalidCollectorUrl": "Die konfigurierte Collector-URL ist ungültig, daher kann die Teilnahme weder gestartet noch übermittelt werden. Setzen Sie USAGE_COLLECTOR_URL auf einen https-Origin ohne Pfad, Query oder Zugangsdaten (oder lassen Sie sie leer, um den Standard zu verwenden).",
|
||||||
|
"signingKeyUnreadable": "Der Signaturschlüssel für die Nutzungsdaten kann nicht gelesen werden. Meist wurde USAGE_ENCRYPTION_KEY — oder das als Rückfallwert genutzte JWT_SECRET — geändert. Berichte können nicht gesendet und auch die Löschanfrage kann nicht signiert werden. Stellen Sie das ursprüngliche Schlüsselmaterial wieder her, um die Löschung abzuschließen; erneutes Senden oder Deaktivieren allein behebt dies nicht.",
|
||||||
|
"schemaNotAccepted": "Der Collector hat das Paket rundheraus abgelehnt — er nimmt diese Berichtsversion also noch nicht an, meist weil er nicht aktualisiert wurde. Erneutes Senden ändert daran nichts. Es wurde nichts registriert; Sie können die Teilnahme verwerfen und erneut beitreten, sobald der Collector sie unterstützt.",
|
||||||
|
"inspect": "Genau sehen, was geteilt wird",
|
||||||
|
"preview": "Nächsten Bericht ansehen",
|
||||||
|
"lastPacket": "Zuletzt angenommener signierter Nutzungsbericht",
|
||||||
|
"export": "Alle angenommenen Nutzungsberichte herunterladen",
|
||||||
|
"connect": "Mit Wünschen & Abstimmungen verbinden",
|
||||||
|
"openPortal": "Portal öffnen (Abstimmungssitzung für 15 Minuten)",
|
||||||
|
"queued": "Der Vorgang ist zur erneuten Übertragung gespeichert. Der Empfang ist noch nicht bestätigt.",
|
||||||
|
"feedbackTitle": "Feedback & Funktionswünsche",
|
||||||
|
"kind": "Art",
|
||||||
|
"subject": "Titel",
|
||||||
|
"message": "Ihre Nachricht",
|
||||||
|
"includeName": "Diesem Beitrag einen Namen hinzufügen",
|
||||||
|
"name": "Anzeigename",
|
||||||
|
"saveName": "Diesen Namen lokal merken",
|
||||||
|
"saved": "Einstellung gespeichert. Neue Beiträge sind weiterhin standardmäßig anonym.",
|
||||||
|
"allowPublic": "Ich erlaube die Veröffentlichung dieses Textes und des angegebenen Namens im Nutzungsportal nach Prüfung.",
|
||||||
|
"allowMarketing": "Ich erlaube zusätzlich die Verwendung dieser Empfehlung und des angegebenen Namens für Marketing auf der PicPeak-Homepage.",
|
||||||
|
"sendFeedback": "Feedback absenden",
|
||||||
|
"feedbackSent": "Feedback erhalten. Eine Veröffentlichung erfordert Ihre Erlaubnis und die Prüfung durch Betreuer.",
|
||||||
|
"states": {
|
||||||
|
"disabled": "Teilnahme ist deaktiviert",
|
||||||
|
"activation_pending": "Aktivierung ausstehend",
|
||||||
|
"active": "Sie nehmen teil",
|
||||||
|
"deletion_pending": "Löschung ausstehend",
|
||||||
|
"identity_conflict": "Konflikt der Installationsidentität"
|
||||||
|
},
|
||||||
|
"stateDetails": {
|
||||||
|
"disabled": "Es werden keine Produktnutzungsdaten erfasst oder gesendet. Prüfen Sie die Hinweise, bevor Sie sich entscheiden.",
|
||||||
|
"activation_pending": "Die Zustimmung ist gespeichert. Die Registrierung wird bei Admin-Nutzung oder über „Erneut versuchen“ wiederholt.",
|
||||||
|
"active": "Nur die beschriebenen Funktionssignale werden erfasst. Tagesberichte werden bei Admin-Nutzung gesendet.",
|
||||||
|
"deletion_pending": "Erfassung und Berichte sind gestoppt. Die Signaturdaten bleiben ausschließlich für die bestätigte Löschung erhalten. Versuchen Sie es erneut, sobald der Dienst erreichbar ist.",
|
||||||
|
"identity_conflict": "Möglicherweise wurde diese Installation wiederhergestellt oder kopiert, oder die Berichtsfolge stimmt nicht mehr mit dem Dienst überein. Berichte sind gestoppt. Deaktiviere und lösche die alte Teilnahme vor einem erneuten Beitritt mit neuer Identität. Dadurch werden auch die Daten einer weiteren Kopie derselben Identität gelöscht."
|
||||||
|
},
|
||||||
|
"kinds": {
|
||||||
|
"feedback": "Privates Feedback",
|
||||||
|
"feature_request": "Funktionswunsch",
|
||||||
|
"testimonial": "Empfehlung"
|
||||||
|
},
|
||||||
|
"retryScheduled": "Der nächste automatische Versuch erfolgt um {{time}}. „Erneut versuchen“ sendet sofort.",
|
||||||
|
"abandon": "Lokale Identität verwerfen",
|
||||||
|
"abandonExplanation": "Die Löschanfrage kann ohne das ursprüngliche Schlüsselmaterial nicht signiert werden. Wenn Sie es nicht wiederherstellen können, lässt sich die lokale Identität verwerfen: Erfassung und Schlüssel werden hier entfernt, der Collector bestätigt die Löschung dabei aber nicht.",
|
||||||
|
"abandonExplanationUnregistered": "Diese Teilnahme wurde vom Collector nie angenommen, dort ist also nichts gespeichert und es gibt nichts zu löschen. Sie können sie hier verwerfen und jederzeit neu beginnen.",
|
||||||
|
"abandonConfirm": "Installationsidentität, Schlüsselmaterial und alle lokalen Marker werden gelöscht. Der Collector wird nicht benachrichtigt und behält die bisher gesendeten Berichte — die Quittung hält das als unbestätigt fest. Danach ist eine neue Teilnahme wieder möglich.",
|
||||||
|
"abandonConfirmUnregistered": "Installationsidentität, Schlüsselmaterial und alle lokalen Marker werden gelöscht. Der Collector hat diese Teilnahme nie angenommen, es wird also nirgendwo sonst etwas entfernt. Danach ist eine neue Teilnahme wieder möglich.",
|
||||||
|
"auditPreviousParticipation": "Löschbestätigungen beziehen sich auf eine frühere Teilnahme, nicht auf die aktuelle."
|
||||||
|
},
|
||||||
"userManagement": {
|
"userManagement": {
|
||||||
"title": "Benutzerverwaltung",
|
"title": "Benutzerverwaltung",
|
||||||
"subtitle": "Admin-Benutzer und Einladungen verwalten",
|
"subtitle": "Admin-Benutzer und Einladungen verwalten",
|
||||||
|
|||||||
@@ -1,4 +1,454 @@
|
|||||||
{
|
{
|
||||||
|
"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 beyond the shipped defaults — a stronger protection level, canvas rendering, or right-click disabled — globally or on at least one gallery; only existence across the installation, never gallery IDs or counts."
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"title": "Product usage & feedback",
|
||||||
|
"noticeTitle": "Help shape PicPeak",
|
||||||
|
"notice": "Optional product usage reports show which features matter to the community. Reporting is off until you choose to participate.",
|
||||||
|
"ignore": "Ignore",
|
||||||
|
"ignoreHint": "If you ignore this, the notice won't appear again — you can still join from Settings → Product usage.",
|
||||||
|
"review": "Review participation",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"loading": "Loading participation settings…",
|
||||||
|
"failed": "The operation could not be completed. Check the status and try again.",
|
||||||
|
"purpose": "Help prioritize PicPeak features, fixes, and maintenance using coarse information about participating installations.",
|
||||||
|
"consentTitle": "Choose whether to share product usage",
|
||||||
|
"sectionFields": "What a report contains",
|
||||||
|
"sectionExcluded": "What is never included",
|
||||||
|
"sectionTransport": "How it is sent",
|
||||||
|
"sectionVisibility": "Where it is visible",
|
||||||
|
"sectionDeletion": "Leaving and deleting",
|
||||||
|
"sectionFeedback": "Feedback is separate",
|
||||||
|
"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.",
|
||||||
|
"sectionOneWay": "Sending only — no return channel",
|
||||||
|
"oneWay": "PicPeak only sends. It never fetches anything from the collector, never asks it for instructions, and exposes no endpoint the collector could call — there is no scheduled job and no inbound route on this path. From a reply it reads only the acknowledgement for the packet it just sent, and checks every field of that acknowledgement against the packet before accepting it; anything else is discarded. A data export you request yourself is handed to you as a file and is never interpreted or executed. So this channel cannot deliver code, configuration or content into your installation — not even from a collector that has been taken over.",
|
||||||
|
"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.",
|
||||||
|
"deletion": "Disabling immediately stops collection and requests deletion of reports, aggregate contributions, feedback, published items, votes and sessions. During an outage, only credentials needed to finish deletion remain and the UI shows deletion pending. After confirmation, the local hash and key are erased; rejoining creates a new identity. The collector retains a one-way revocation digest and short-lived identity-free abuse counters. PicPeak keeps a downloadable local deletion receipt without the old hash, key or payloads.",
|
||||||
|
"feedbackDisclosure": "Feedback is separate from automatic reports and is sent only when you submit it. Each item is anonymous unless you include a name, and private to maintainers unless you explicitly permit publication. Public items require maintainer review. Marketing use of a testimonial requires separate permission.",
|
||||||
|
"consentCheck": "I have read this disclosure and explicitly agree to participate.",
|
||||||
|
"enable": "Enable product usage",
|
||||||
|
"disable": "Disable & delete my data",
|
||||||
|
"retry": "Retry / send if due",
|
||||||
|
"transparency": "Read the public schema & privacy details",
|
||||||
|
"linkCollector": "Where reports are sent",
|
||||||
|
"hash": "Your private lookup hash",
|
||||||
|
"lastReport": "Last accepted report: {{date}} (UTC)",
|
||||||
|
"deliveryProblem": "Delivery needs attention. Collection stops during deletion or an identity conflict. Use retry, or disable participation to delete its data.",
|
||||||
|
"invalidCollectorUrl": "The configured usage collector URL is not valid, so participation cannot be started or delivered. Set USAGE_COLLECTOR_URL to an https origin with no path, query or credentials (or leave it unset to use the default).",
|
||||||
|
"signingKeyUnreadable": "The usage signing key cannot be read, which usually means USAGE_ENCRYPTION_KEY — or JWT_SECRET, which it falls back to — was changed. Reports cannot be sent and the deletion request cannot be signed either. Restore the original encryption material to finish deletion; retrying or disabling will not resolve it on its own.",
|
||||||
|
"schemaNotAccepted": "The collector rejected the packet outright, which means it does not accept this report version yet — usually a collector that has not been upgraded. Retrying will not change that. Nothing has been registered, so you can discard the participation and join again once the collector supports it.",
|
||||||
|
"inspect": "See exactly what is shared",
|
||||||
|
"preview": "Preview next report",
|
||||||
|
"lastPacket": "Last accepted signed usage report",
|
||||||
|
"export": "Download all accepted usage reports",
|
||||||
|
"connect": "Connect to requests & voting",
|
||||||
|
"openPortal": "Open the portal (15-minute voting session)",
|
||||||
|
"queued": "The operation is saved for retry. It has not been confirmed as delivered.",
|
||||||
|
"feedbackTitle": "Feedback & feature requests",
|
||||||
|
"kind": "Type",
|
||||||
|
"subject": "Title",
|
||||||
|
"message": "Your message",
|
||||||
|
"includeName": "Include a name with this item",
|
||||||
|
"name": "Display name",
|
||||||
|
"saveName": "Remember this name locally",
|
||||||
|
"saved": "Preference saved. Future items still default to anonymous.",
|
||||||
|
"allowPublic": "I allow this text and included name to be published on the usage portal after review.",
|
||||||
|
"allowMarketing": "I also allow PicPeak to use this testimonial and included name for homepage marketing.",
|
||||||
|
"sendFeedback": "Submit feedback",
|
||||||
|
"feedbackSent": "Feedback received. Publication requires your permission and maintainer review.",
|
||||||
|
"states": {
|
||||||
|
"disabled": "Participation is off",
|
||||||
|
"activation_pending": "Activation pending",
|
||||||
|
"active": "You are participating",
|
||||||
|
"deletion_pending": "Deletion pending",
|
||||||
|
"identity_conflict": "Installation identity conflict"
|
||||||
|
},
|
||||||
|
"stateDetails": {
|
||||||
|
"disabled": "No product usage is collected or sent. You can review the details before deciding.",
|
||||||
|
"activation_pending": "Consent is saved. Registration will be retried when you use PicPeak or choose retry.",
|
||||||
|
"active": "Only the disclosed feature signals are collected. Daily reports run when an admin uses PicPeak.",
|
||||||
|
"deletion_pending": "Collection and reporting are stopped. The signing credentials remain only to finish authenticated deletion. Retry when the collector is reachable.",
|
||||||
|
"identity_conflict": "This may be a restored or cloned installation, or its report sequence no longer matches the collector. Reporting is stopped. Disable and delete the old participation before joining with a new identity; this also deletes data shared by another copy of the same identity."
|
||||||
|
},
|
||||||
|
"kinds": {
|
||||||
|
"feedback": "Private feedback",
|
||||||
|
"feature_request": "Feature request",
|
||||||
|
"testimonial": "Testimonial"
|
||||||
|
},
|
||||||
|
"retryScheduled": "The next automatic attempt is at {{time}}. \"Retry\" sends immediately.",
|
||||||
|
"abandon": "Discard local identity",
|
||||||
|
"abandonExplanation": "The deletion request cannot be signed without the original encryption material. If you cannot restore it, you can discard the local identity: collection and keys are removed here, but the collector does not confirm the deletion.",
|
||||||
|
"abandonExplanationUnregistered": "This participation was never accepted by the collector, so nothing is stored there and there is nothing to delete. You can discard it here and start again at any time.",
|
||||||
|
"abandonConfirm": "This deletes the installation identity, the key material and every local marker. The collector is not notified and keeps the reports already sent — the receipt records that as unconfirmed. You can join again afterwards.",
|
||||||
|
"abandonConfirmUnregistered": "This deletes the local installation identity, the key material and every local marker. The collector never accepted this participation, so nothing is removed anywhere else. You can join again afterwards.",
|
||||||
|
"auditPreviousParticipation": "Deletion confirmations refer to an earlier participation, not the current one."
|
||||||
|
},
|
||||||
"userManagement": {
|
"userManagement": {
|
||||||
"title": "User Management",
|
"title": "User Management",
|
||||||
"subtitle": "Manage admin users and invitations",
|
"subtitle": "Manage admin users and invitations",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { lazy, Suspense, useState, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Loading } from '../../components/common';
|
import { Loading } from '../../components/common';
|
||||||
|
const ProductUsageTab = lazy(() => import('../../features/settings/tabs/ProductUsageTab'));
|
||||||
import {
|
import {
|
||||||
useSettingsState,
|
useSettingsState,
|
||||||
FeaturesTab,
|
FeaturesTab,
|
||||||
@@ -64,6 +65,7 @@ import { Briefcase, Receipt, ScrollText, Landmark, Smartphone, MonitorPlay } fro
|
|||||||
// Tab keys driving the inner-nav. Must include every key used in
|
// Tab keys driving the inner-nav. Must include every key used in
|
||||||
// `navGroups` below and in the switch at the bottom of the component.
|
// `navGroups` below and in the switch at the bottom of the component.
|
||||||
type TabType =
|
type TabType =
|
||||||
|
| 'usage'
|
||||||
| 'features'
|
| 'features'
|
||||||
| 'general'
|
| 'general'
|
||||||
| 'events'
|
| 'events'
|
||||||
@@ -107,6 +109,7 @@ interface NavGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ALL_TAB_KEYS: TabType[] = [
|
const ALL_TAB_KEYS: TabType[] = [
|
||||||
|
'usage',
|
||||||
'features', 'general', 'events', 'eventTypes',
|
'features', 'general', 'events', 'eventTypes',
|
||||||
'branding', 'categories', 'thumbnails', 'downloads', 'styling', 'cms',
|
'branding', 'categories', 'thumbnails', 'downloads', 'styling', 'cms',
|
||||||
'email', 'moderation',
|
'email', 'moderation',
|
||||||
@@ -129,6 +132,7 @@ function isValidTab(value: string | null): value is TabType {
|
|||||||
// Settings via the broadened sidebar gate and sees only the tabs whose specific
|
// Settings via the broadened sidebar gate and sees only the tabs whose specific
|
||||||
// permission it holds. Backend routes enforce the same perms regardless of UI.
|
// permission it holds. Backend routes enforce the same perms regardless of UI.
|
||||||
const TAB_PERMISSIONS: Record<TabType, string[]> = {
|
const TAB_PERMISSIONS: Record<TabType, string[]> = {
|
||||||
|
usage: ['settings.edit'],
|
||||||
features: ['settings.view', 'settings.features'],
|
features: ['settings.view', 'settings.features'],
|
||||||
general: ['settings.view', 'settings.domains'],
|
general: ['settings.view', 'settings.domains'],
|
||||||
events: ['settings.view'],
|
events: ['settings.view'],
|
||||||
@@ -382,6 +386,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
items: [
|
items: [
|
||||||
{ key: 'status', label: t('settings.systemStatus.title'), icon: Activity },
|
{ key: 'status', label: t('settings.systemStatus.title'), icon: Activity },
|
||||||
{ key: 'analytics', label: t('settings.analytics.title'), icon: BarChart3 },
|
{ key: 'analytics', label: t('settings.analytics.title'), icon: BarChart3 },
|
||||||
|
{ key: 'usage', label: t('productUsage.title'), icon: Shield },
|
||||||
{ key: 'backup', label: t('settings.backup.title', 'Backup'), icon: HardDrive },
|
{ key: 'backup', label: t('settings.backup.title', 'Backup'), icon: HardDrive },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -538,6 +543,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
{activeTab === 'reminderTemplates' && <ReminderTemplatesPage />}
|
{activeTab === 'reminderTemplates' && <ReminderTemplatesPage />}
|
||||||
{activeTab === 'accounting' && <AccountingTab />}
|
{activeTab === 'accounting' && <AccountingTab />}
|
||||||
{activeTab === 'whatsapp' && <WhatsAppTab />}
|
{activeTab === 'whatsapp' && <WhatsAppTab />}
|
||||||
|
{activeTab === 'usage' && hasAnyPermission(['settings.edit']) && <Suspense fallback={<Loading />}><ProductUsageTab /></Suspense>}
|
||||||
|
|
||||||
{activeTab === 'status' && (
|
{activeTab === 'status' && (
|
||||||
<StatusTab
|
<StatusTab
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { api } from '../config/api';
|
||||||
|
|
||||||
|
export interface UsageStatus {
|
||||||
|
status:
|
||||||
|
| 'disabled'
|
||||||
|
| 'activation_pending'
|
||||||
|
| 'active'
|
||||||
|
| 'deletion_pending'
|
||||||
|
| 'identity_conflict';
|
||||||
|
notice_dismissed: boolean;
|
||||||
|
installation_id: string | null;
|
||||||
|
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;
|
||||||
|
/** Epoch ms the paced sender is waiting for, or null when nothing is paced. */
|
||||||
|
retry_after?: number | null;
|
||||||
|
/** True when the participation cannot be completed and the only exit is to discard it. */
|
||||||
|
can_abandon?: boolean;
|
||||||
|
/** True when the collector never accepted anything, so discarding deletes nothing remote. */
|
||||||
|
abandon_never_registered?: boolean;
|
||||||
|
pending_action: string | null;
|
||||||
|
last_packet: unknown;
|
||||||
|
privacy_receipts?: Record<string, unknown>;
|
||||||
|
feedback_preferences: { name: string };
|
||||||
|
}
|
||||||
|
export interface ProductFeedback {
|
||||||
|
kind: 'feedback' | 'feature_request' | 'testimonial';
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
name: string;
|
||||||
|
allow_public: boolean;
|
||||||
|
allow_marketing: boolean;
|
||||||
|
}
|
||||||
|
export const productUsageService = {
|
||||||
|
async status(): Promise<UsageStatus> {
|
||||||
|
return (await api.get('/admin/usage')).data;
|
||||||
|
},
|
||||||
|
async activity(): Promise<void> {
|
||||||
|
await api.post('/admin/usage/activity');
|
||||||
|
},
|
||||||
|
async dismiss(): Promise<UsageStatus> {
|
||||||
|
return (await api.post('/admin/usage/dismiss')).data;
|
||||||
|
},
|
||||||
|
async enable(): Promise<UsageStatus> {
|
||||||
|
return (
|
||||||
|
await api.post('/admin/usage/enable', {
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
async retry(): Promise<UsageStatus> {
|
||||||
|
return (await api.post('/admin/usage/retry')).data;
|
||||||
|
},
|
||||||
|
async abandon(): Promise<UsageStatus> {
|
||||||
|
return (await api.post('/admin/usage/abandon')).data;
|
||||||
|
},
|
||||||
|
async preview(): Promise<unknown> {
|
||||||
|
return (await api.get('/admin/usage/preview')).data;
|
||||||
|
},
|
||||||
|
async export(): Promise<unknown> {
|
||||||
|
return (await api.get('/admin/usage/export')).data;
|
||||||
|
},
|
||||||
|
async preferences(name: string): Promise<UsageStatus> {
|
||||||
|
return (await api.put('/admin/usage/feedback-preferences', { name })).data;
|
||||||
|
},
|
||||||
|
async feedback(
|
||||||
|
value: ProductFeedback
|
||||||
|
): Promise<{ delivered: boolean; queued?: boolean; state: UsageStatus }> {
|
||||||
|
return (await api.post('/admin/usage/feedback', value)).data;
|
||||||
|
},
|
||||||
|
async portalSession(): Promise<{ delivered: boolean; url: string | null }> {
|
||||||
|
return (await api.post('/admin/usage/portal-session')).data;
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user