mirror of
https://github.com/gadgethd/ukmesh.git
synced 2026-10-15 18:50:05 +00:00
* 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>
270 lines
9.5 KiB
TypeScript
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;
|
|
}
|