operator-full-export.mjs
147 lines 4.8 KB
Raw
sha256:8915fe406161f95c1681f9469375e7bae5b28c884f00bedbdef65e4b0cd0738d docs(flow): commit FLOW-V0-SPEC.md hygiene for 7A-INT merge Human 11 hours ago
1 /**
2 * Operator full logical export: paginated user index from canister + per-user notes/proposals.
3 * @see scripts/canister-operator-full-export.mjs
4 */
5 import {
6 encryptOperatorBackupUtf8,
7 fetchFullProposalsForOperatorExport,
8 fetchNotesFromExport,
9 putS3Object,
10 utcBackupStamp,
11 } from './operator-canister-backup.mjs';
12
13 /**
14 * @param {string} baseUrl
15 * @param {string} operatorKey — X-Operator-Export-Key
16 * @param {string} [cursor]
17 * @param {number} [limit]
18 * @returns {Promise<{ format_version: number, kind: string, user_ids: string[], next_cursor: string, done: boolean }>}
19 */
20 export async function fetchOperatorUserIndexPage(baseUrl, operatorKey, cursor = '', limit = 200) {
21 const base = baseUrl.replace(/\/$/, '');
22 const u = new URL(`${base}/api/v1/operator/export`);
23 if (cursor !== '' && cursor != null) u.searchParams.set('cursor', String(cursor));
24 if (limit) u.searchParams.set('limit', String(limit));
25 const r = await fetch(u.toString(), {
26 method: 'GET',
27 headers: {
28 'X-Operator-Export-Key': operatorKey,
29 Accept: 'application/json',
30 },
31 });
32 if (!r.ok) {
33 const t = await r.text().catch(() => '');
34 const body = t.slice(0, 200);
35 if (r.status === 401) {
36 throw new Error(
37 `operator user index 401 ${body} — X-Operator-Export-Key does not match hub stable secret. Set GitHub secret KNOWTATION_OPERATOR_EXPORT_KEY to the exact same string passed to admin_set_operator_export_secret (dfx canister call hub ...).`,
38 );
39 }
40 if (r.status === 503) {
41 throw new Error(
42 `operator user index 503 ${body} — Operator export not configured on canister. Run admin_set_operator_export_secret as a controller, then retry.`,
43 );
44 }
45 throw new Error(`operator user index ${r.status} ${body}`);
46 }
47 return r.json();
48 }
49
50 /**
51 * @param {string} baseUrl
52 * @param {string} operatorKey
53 * @param {number} [pageLimit]
54 * @returns {Promise<string[]>}
55 */
56 export async function collectAllOperatorUserIds(baseUrl, operatorKey, pageLimit = 200) {
57 const ids = [];
58 let cursor = '';
59 for (;;) {
60 const page = await fetchOperatorUserIndexPage(baseUrl, operatorKey, cursor, pageLimit);
61 const batch = Array.isArray(page.user_ids) ? page.user_ids : [];
62 ids.push(...batch);
63 if (page.done) break;
64 cursor = page.next_cursor != null ? String(page.next_cursor) : '';
65 if (cursor === '') break;
66 }
67 return ids;
68 }
69
70 /**
71 * @param {string} baseUrl
72 * @param {string} userId
73 * @returns {Promise<string[]>}
74 */
75 export async function fetchVaultIdsForUser(baseUrl, userId) {
76 const base = baseUrl.replace(/\/$/, '');
77 const r = await fetch(`${base}/api/v1/vaults`, {
78 method: 'GET',
79 headers: {
80 'X-User-Id': userId,
81 Accept: 'application/json',
82 },
83 });
84 if (!r.ok) {
85 throw new Error(`vaults ${r.status}`);
86 }
87 const data = await r.json();
88 const vaults = Array.isArray(data.vaults) ? data.vaults : [];
89 const ids = vaults.map((v) => (v && v.id != null ? String(v.id) : '')).filter(Boolean);
90 return ids.length > 0 ? ids : ['default'];
91 }
92
93 /**
94 * Merge proposals from multiple vault-scoped fetches (dedupe by proposal_id).
95 * @param {string} baseUrl
96 * @param {string} userId
97 * @param {string[]} vaultIds
98 * @returns {Promise<object[]>}
99 */
100 export async function fetchFullProposalsForUserAllVaults(baseUrl, userId, vaultIds) {
101 const byId = new Map();
102 for (const vid of vaultIds) {
103 const list = await fetchFullProposalsForOperatorExport(baseUrl, userId, vid);
104 for (const p of list) {
105 const id = p && p.proposal_id ? String(p.proposal_id) : '';
106 if (id) byId.set(id, p);
107 }
108 }
109 return [...byId.values()];
110 }
111
112 /**
113 * @param {Array<{ user_id: string, vaults: Array<{ vault_id: string, notes: object[] }>, proposals: object[] }>} users
114 */
115 export function buildOperatorFullExportPayload(users) {
116 return {
117 format_version: 4,
118 kind: 'knowtation-operator-full-export',
119 exported_at: new Date().toISOString(),
120 users,
121 };
122 }
123
124 /**
125 * @param {string} baseUrl
126 * @param {string} operatorKey
127 * @param {(msg: string) => void} [log]
128 * @returns {Promise<object>}
129 */
130 export async function buildFullOperatorExportJson(baseUrl, operatorKey, log = () => {}) {
131 const userIds = await collectAllOperatorUserIds(baseUrl, operatorKey);
132 log(`operator full export: ${userIds.length} user id(s)`);
133 const users = [];
134 for (const userId of userIds) {
135 const vaultIds = await fetchVaultIdsForUser(baseUrl, userId);
136 const vaults = [];
137 for (const vaultId of vaultIds) {
138 const notes = await fetchNotesFromExport(baseUrl, userId, vaultId);
139 vaults.push({ vault_id: vaultId, notes });
140 }
141 const proposals = await fetchFullProposalsForUserAllVaults(baseUrl, userId, vaultIds);
142 users.push({ user_id: userId, vaults, proposals });
143 }
144 return buildOperatorFullExportPayload(users);
145 }
146
147 export { encryptOperatorBackupUtf8, putS3Object, utcBackupStamp };
File History 1 commit
sha256:8915fe406161f95c1681f9469375e7bae5b28c884f00bedbdef65e4b0cd0738d docs(flow): commit FLOW-V0-SPEC.md hygiene for 7A-INT merge Human 11 hours ago