operator-canister-backup.mjs
171 lines 5.0 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 import crypto from 'node:crypto';
2 import { parseCanisterProposalGetBody } from './canister-proposal-response-parse.mjs';
3
4 /** File magic for AES-256-GCM operator backups (4 bytes). */
5 export const OPERATOR_BACKUP_MAGIC = Buffer.from('KTB1', 'ascii');
6
7 /**
8 * @param {string} baseUrl — no trailing slash
9 * @param {string} userId — X-User-Id
10 * @param {string} vaultId
11 * @returns {Promise<object[]>}
12 */
13 export async function fetchNotesFromExport(baseUrl, userId, vaultId) {
14 const base = baseUrl.replace(/\/$/, '');
15 const r = await fetch(`${base}/api/v1/export`, {
16 method: 'GET',
17 headers: {
18 'X-User-Id': userId,
19 'X-Vault-Id': vaultId,
20 Accept: 'application/json',
21 },
22 });
23 if (!r.ok) {
24 throw new Error(`export ${r.status}`);
25 }
26 const data = await r.json();
27 return Array.isArray(data.notes) ? data.notes : [];
28 }
29
30 /**
31 * Full proposal documents (list + GET each id). Operator export: full partition, no team scope filter.
32 *
33 * @param {string} baseUrl
34 * @param {string} userId
35 * @param {string} vaultId
36 * @returns {Promise<object[]>}
37 */
38 export async function fetchFullProposalsForOperatorExport(baseUrl, userId, vaultId) {
39 const base = baseUrl.replace(/\/$/, '');
40 const headers = {
41 'X-User-Id': userId,
42 'X-Vault-Id': vaultId,
43 Accept: 'application/json',
44 };
45 const listRes = await fetch(`${base}/api/v1/proposals`, { method: 'GET', headers });
46 if (!listRes.ok) {
47 throw new Error(`proposals list ${listRes.status}`);
48 }
49 const listJson = await listRes.json();
50 const stubs = Array.isArray(listJson.proposals) ? listJson.proposals : [];
51 const full = [];
52 for (const stub of stubs) {
53 const id = stub && stub.proposal_id ? String(stub.proposal_id) : '';
54 if (!id) continue;
55 const oneRes = await fetch(`${base}/api/v1/proposals/${encodeURIComponent(id)}`, {
56 method: 'GET',
57 headers,
58 });
59 if (!oneRes.ok) {
60 throw new Error(`proposal ${id} ${oneRes.status}`);
61 }
62 const text = await oneRes.text();
63 const body = parseCanisterProposalGetBody(id, text, stub);
64 if (body._knowtation_backup_json_unparseable) {
65 console.error('[operator-canister-backup] proposal JSON parse failed', id, text.slice(0, 240));
66 }
67 full.push(body);
68 }
69 return full;
70 }
71
72 /**
73 * @param {string} vaultId
74 * @param {object[]} notes
75 * @param {object[]} proposals
76 */
77 export function buildOperatorVaultPayload(vaultId, notes, proposals) {
78 return {
79 format_version: 2,
80 kind: 'knowtation-operator-vault-export',
81 exported_at: new Date().toISOString(),
82 vault_id: vaultId,
83 notes,
84 proposals,
85 };
86 }
87
88 /**
89 * AES-256-GCM; wire format: MAGIC (4) + iv (12) + ciphertext + authTag (16).
90 *
91 * @param {string} plainUtf8
92 * @param {string} keyHex — 64 hex chars (32 bytes)
93 * @returns {Buffer}
94 */
95 export function encryptOperatorBackupUtf8(plainUtf8, keyHex) {
96 const key = Buffer.from(String(keyHex).trim(), 'hex');
97 if (key.length !== 32) {
98 throw new Error(
99 'KNOWTATION_CANISTER_BACKUP_ENCRYPT_KEY_HEX must be exactly 64 hex characters (32-byte AES-256 key)',
100 );
101 }
102 const iv = crypto.randomBytes(12);
103 const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
104 const enc = Buffer.concat([cipher.update(plainUtf8, 'utf8'), cipher.final()]);
105 const tag = cipher.getAuthTag();
106 return Buffer.concat([OPERATOR_BACKUP_MAGIC, iv, enc, tag]);
107 }
108
109 /**
110 * @param {Buffer} buf
111 * @param {string} keyHex
112 * @returns {string} utf8 plaintext
113 */
114 export function decryptOperatorBackupToUtf8(buf, keyHex) {
115 const key = Buffer.from(String(keyHex).trim(), 'hex');
116 if (key.length !== 32) {
117 throw new Error('Invalid key length');
118 }
119 if (buf.length < 4 + 12 + 16 || !buf.subarray(0, 4).equals(OPERATOR_BACKUP_MAGIC)) {
120 throw new Error('Invalid operator backup file (magic)');
121 }
122 const iv = buf.subarray(4, 16);
123 const tag = buf.subarray(buf.length - 16);
124 const ciphertext = buf.subarray(16, buf.length - 16);
125 const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
126 decipher.setAuthTag(tag);
127 return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
128 }
129
130 /**
131 * @param {{ bucket: string, key: string, body: Buffer, region?: string }} opts
132 */
133 export async function putS3Object(opts) {
134 const { S3Client, PutObjectCommand } = await import('@aws-sdk/client-s3');
135 let region = String(opts.region || process.env.AWS_REGION || 'us-east-1').trim();
136 if (!region) region = 'us-east-1';
137 const client = new S3Client({ region });
138 await client.send(
139 new PutObjectCommand({
140 Bucket: opts.bucket,
141 Key: opts.key,
142 Body: opts.body,
143 ServerSideEncryption: 'AES256',
144 }),
145 );
146 }
147
148 /**
149 * @param {string} vaultId
150 */
151 export function safeVaultFileToken(vaultId) {
152 return String(vaultId || 'default').replace(/[/:]/g, '_');
153 }
154
155 /**
156 * @param {Date} [d]
157 * @returns {string} e.g. 20260408T153022Z
158 */
159 export function utcBackupStamp(d = new Date()) {
160 const iso = d.toISOString();
161 return (
162 iso.slice(0, 4) +
163 iso.slice(5, 7) +
164 iso.slice(8, 10) +
165 'T' +
166 iso.slice(11, 13) +
167 iso.slice(14, 16) +
168 iso.slice(17, 19) +
169 'Z'
170 );
171 }
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 1 day ago