operator-canister-backup.mjs
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