Files
ukmesh/backend/src/db/dataLifecycle.ts
T
hermes-gadgetandgadgethd 24206957bb feat(owner): land owner-controlled packet sharing (rescued VPS WIP) (#101)
* feat(owner): add owner-controlled packet sharing

* docs: record B3 packet-sharing handoff

* docs(api): regenerate openapi for packet-sharing routes

* test(db): extend migration registry expectation to 052-054

---------

Co-authored-by: gadgethd <111318106+gadgethd@users.noreply.github.com>
2026-10-01 00:39:40 +01:00

270 lines
9.5 KiB
TypeScript

import {
assertFreshRestoreReceipt,
loadVerifiedRestoreReceipt,
} from '../backup/receipt.js';
export type DataLifecyclePolicy = Readonly<{
table: string;
timestampColumn: string;
retention: string;
kind: 'hypertable' | 'row-table';
featureImpact: readonly string[];
}>;
export type DataCompressionPolicy = Readonly<{
table: 'packets' | 'packet_paths' | 'node_status_samples' | 'node_neighbor_samples';
compressAfter: string;
compressionSegmentBy: 'network';
compressionOrderBy: 'time DESC';
}>;
// Compression-only targets are intentionally separate from deletable targets:
// packet_paths is present here and can never enter a retention allowlist.
export const DATA_COMPRESSION_POLICIES: readonly DataCompressionPolicy[] = Object.freeze([
{ table: 'packets', compressAfter: '14 days', compressionSegmentBy: 'network', compressionOrderBy: 'time DESC' },
{ table: 'packet_paths', compressAfter: '14 days', compressionSegmentBy: 'network', compressionOrderBy: 'time DESC' },
{ table: 'node_status_samples', compressAfter: '14 days', compressionSegmentBy: 'network', compressionOrderBy: 'time DESC' },
{ table: 'node_neighbor_samples', compressAfter: '1 day', compressionSegmentBy: 'network', compressionOrderBy: 'time DESC' },
]);
export const DATA_LIFECYCLE_POLICIES: readonly DataLifecyclePolicy[] = Object.freeze([
{
table: 'packets',
timestampColumn: 'time',
retention: '30 days',
kind: 'hypertable',
featureImpact: [
'message content and raw packet data older than 30 days are removed',
'raw pathing data is preserved content-stripped in packet_paths (no retention)',
'public and owner packet/path views are limited to 30 days or less',
],
},
{
table: 'node_status_samples',
timestampColumn: 'time',
retention: '180 days',
kind: 'hypertable',
featureImpact: [
'owner status telemetry older than 180 days becomes restore-only',
],
},
{
table: 'node_neighbor_samples',
timestampColumn: 'time',
retention: '7 days',
kind: 'hypertable',
featureImpact: [
'owner heard-neighbor history older than seven days becomes restore-only',
],
},
{
table: 'frontend_error_events',
timestampColumn: 'time',
retention: '30 days',
kind: 'row-table',
featureImpact: ['anonymous browser diagnostic trends older than 30 days are removed'],
},
{
table: 'packet_decryptions',
timestampColumn: 'created_at',
retention: '30 days',
kind: 'row-table',
featureImpact: ['decrypted packet content is diagnostics-only and removed after 30 days'],
},
{
table: 'worker_health_snapshots',
timestampColumn: 'ts',
retention: '7 days',
kind: 'row-table',
featureImpact: ['operator worker-health history older than seven days is removed'],
},
{
table: 'operational_check_results',
timestampColumn: 'ts',
retention: '14 days',
kind: 'row-table',
featureImpact: ['synthetic journey history older than 14 days is removed'],
},
{
table: 'observer_region_packet_sightings',
timestampColumn: 'last_seen',
retention: '8 days',
kind: 'row-table',
featureImpact: ['observer-region packet rollups retain the advertised seven-day window'],
},
{
table: 'observer_region_observer_sightings',
timestampColumn: 'last_seen',
retention: '31 days',
kind: 'row-table',
featureImpact: ['stale observer-region evidence older than the 30-day cleanup window is removed'],
},
{
table: 'owner_alert_deliveries',
timestampColumn: 'created_at',
retention: '90 days',
kind: 'row-table',
featureImpact: ['delivered owner-alert audit rows older than 90 days are removed'],
},
{
table: 'owner_packet_share_deliveries',
timestampColumn: 'created_at',
retention: '30 days',
kind: 'row-table',
featureImpact: ['terminal owner packet-sharing queue and audit rows older than 30 days are removed'],
},
{
table: 'observer_registration_requests',
timestampColumn: 'updated_at',
retention: '365 days',
kind: 'row-table',
featureImpact: [
'terminal observer registration PII older than one year is removed; pending requests expire after 90 days',
],
},
{
table: 'operator_audit_events',
timestampColumn: 'created_at',
retention: '730 days',
kind: 'row-table',
featureImpact: ['operator action evidence older than two years becomes restore-only'],
},
{
table: 'link_job_commits',
timestampColumn: 'completed_at',
retention: '180 days',
kind: 'row-table',
featureImpact: ['link job idempotency evidence older than 180 days is removed'],
},
{
table: 'ml_model_variant_packet_results',
timestampColumn: 'created_at',
retention: '180 days',
kind: 'row-table',
featureImpact: ['per-packet model evaluation evidence older than 180 days is removed'],
},
]);
export const REQUIRED_CORE_RETENTION_TARGETS = Object.freeze([
'packets',
'node_status_samples',
'node_neighbor_samples',
] as const);
export const REQUIRED_CORE_COMPRESSION_TARGETS = Object.freeze([
'packets',
'packet_paths',
'node_status_samples',
'node_neighbor_samples',
] as const);
export type DataLifecycleConfigurationStatus = Readonly<{
retentionEnabled: boolean;
compressionEnabled: boolean;
missingRetentionTargets: readonly string[];
missingCompressionTargets: readonly string[];
ready: boolean;
}>;
export function dataLifecycleConfigurationStatus(
env: NodeJS.ProcessEnv = process.env,
): DataLifecycleConfigurationStatus {
const retentionEnabled = env['DATA_LIFECYCLE_RETENTION_ENABLED'] === 'true';
const compressionEnabled = env['DATA_LIFECYCLE_COMPRESSION_ENABLED'] === 'true';
const retentionTargets = configuredLifecycleTargets(env['DATA_LIFECYCLE_RETENTION_TARGETS']);
const compressionTargets = configuredCompressionTargets(env['DATA_LIFECYCLE_COMPRESSION_TARGETS']);
const missingRetentionTargets = REQUIRED_CORE_RETENTION_TARGETS.filter(
(target) => !retentionTargets.has(target),
);
const missingCompressionTargets = REQUIRED_CORE_COMPRESSION_TARGETS.filter(
(target) => !compressionTargets.has(target),
);
return {
retentionEnabled,
compressionEnabled,
missingRetentionTargets,
missingCompressionTargets,
ready: (!retentionEnabled || missingRetentionTargets.length === 0)
&& (!compressionEnabled || missingCompressionTargets.length === 0),
};
}
export function lifecyclePolicy(table: string): DataLifecyclePolicy {
const policy = DATA_LIFECYCLE_POLICIES.find((candidate) => candidate.table === table);
if (!policy) throw new Error(`unsupported data lifecycle target: ${table}`);
return policy;
}
export function compressionPolicy(table: string): DataCompressionPolicy {
const policy = DATA_COMPRESSION_POLICIES.find((candidate) => candidate.table === table);
if (!policy) throw new Error(`unsupported compression target: ${table}`);
return policy;
}
export function configuredLifecycleTargets(raw: string | undefined): Set<string> {
const targets = new Set(
String(raw ?? '').split(',').map((value) => value.trim()).filter(Boolean),
);
for (const target of targets) lifecyclePolicy(target);
return targets;
}
export function configuredCompressionTargets(raw: string | undefined): Set<string> {
const targets = new Set(
String(raw ?? '').split(',').map((value) => value.trim()).filter(Boolean),
);
for (const target of targets) compressionPolicy(target);
return targets;
}
export function assertDataLifecycleGate(options: {
action: 'compression' | 'retention';
target: string;
approval: string | undefined;
env?: NodeJS.ProcessEnv;
now?: Date;
}): DataLifecyclePolicy | DataCompressionPolicy {
const env = options.env ?? process.env;
const now = options.now ?? new Date();
const policy = options.action === 'compression'
? compressionPolicy(options.target)
: lifecyclePolicy(options.target);
const flag = options.action === 'compression'
? 'DATA_LIFECYCLE_COMPRESSION_ENABLED'
: 'DATA_LIFECYCLE_RETENTION_ENABLED';
if (env[flag] !== 'true') throw new Error(`${flag}=true is required`);
const targets = options.action === 'compression'
? configuredCompressionTargets(env['DATA_LIFECYCLE_COMPRESSION_TARGETS'])
: configuredLifecycleTargets(env['DATA_LIFECYCLE_RETENTION_TARGETS']);
const requiredTargets = options.action === 'compression'
? REQUIRED_CORE_COMPRESSION_TARGETS
: REQUIRED_CORE_RETENTION_TARGETS;
const targetVariable = options.action === 'compression'
? 'DATA_LIFECYCLE_COMPRESSION_TARGETS'
: 'DATA_LIFECYCLE_RETENTION_TARGETS';
const missingRequiredTargets = requiredTargets.filter((target) => !targets.has(target));
if (missingRequiredTargets.length > 0) {
throw new Error(
`${targetVariable} must include required core targets: ${missingRequiredTargets.join(',')}`,
);
}
if (!targets.has(policy.table)) {
throw new Error(`${targetVariable} must include ${policy.table}`);
}
const expectedApproval = `apply-data-lifecycle-${options.action}-${policy.table}`;
if (options.approval !== expectedApproval) {
throw new Error(`--approve=${expectedApproval} is required`);
}
const receipt = loadVerifiedRestoreReceipt({
receiptPath: env['DATA_LIFECYCLE_RESTORE_RECEIPT_PATH'],
signaturePath: env['DATA_LIFECYCLE_RESTORE_RECEIPT_SIGNATURE_PATH'],
verifyKeyPath: env['DATA_LIFECYCLE_RECEIPT_VERIFY_KEY_PATH'],
});
const maximumAgeDays = Number(env['DATA_LIFECYCLE_RECEIPT_MAX_AGE_DAYS'] ?? 7);
assertFreshRestoreReceipt(receipt, {
now,
maximumAgeDays: Number.isFinite(maximumAgeDays) ? maximumAgeDays : 7,
});
return policy;
}