metadata-bulk-canister.mjs
327 lines 11.5 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 11 days ago
1 /**
2 * Hosted gateway: bulk delete/rename by effective project slug via canister orchestration.
3 * @see docs/HUB-METADATA-BULK-OPS.md
4 */
5
6 import { verifyJwtWithSecretRotation } from '../lib/session-secret-rotation.mjs';
7 import { effectiveProjectSlug, normalizeSlug } from '../../lib/vault.mjs';
8 import { materializeListFrontmatter } from './note-facets.mjs';
9 import { applyScopeFilterToNotes } from '../lib/scope-filter.mjs';
10 import { mergeHostedNoteBodyForCanister } from './apply-note-provenance.mjs';
11
12 /**
13 * @param {{
14 * CANISTER_URL: string,
15 * CANISTER_AUTH_SECRET: string,
16 * BRIDGE_URL: string,
17 * SESSION_SECRET: string,
18 * SESSION_SECRET_PREVIOUS?: string,
19 * getUserId: (req: import('express').Request) => string | null,
20 * getHostedAccessContext: (req: import('express').Request) => Promise<Record<string, unknown>|null>,
21 * }} deps
22 */
23 export function createMetadataBulkHandlers(deps) {
24 const { CANISTER_URL, CANISTER_AUTH_SECRET, BRIDGE_URL, SESSION_SECRET, SESSION_SECRET_PREVIOUS, getUserId, getHostedAccessContext } = deps;
25
26 async function resolveRole(req) {
27 const auth = req.headers.authorization;
28 const token = auth && auth.startsWith('Bearer ') ? auth.slice(7) : null;
29 let role = 'member';
30 if (token && SESSION_SECRET) {
31 const p = verifyJwtWithSecretRotation(token, SESSION_SECRET, SESSION_SECRET_PREVIOUS);
32 if (p && typeof p === 'object' && p.role) role = String(p.role);
33 }
34 if (BRIDGE_URL && auth) {
35 try {
36 const r = await fetch(BRIDGE_URL + '/api/v1/role', {
37 headers: { Authorization: auth, Accept: 'application/json' },
38 });
39 if (r.ok) {
40 const d = await r.json();
41 if (d && d.role) role = String(d.role);
42 }
43 } catch (_) {
44 /* keep JWT role */
45 }
46 }
47 return role;
48 }
49
50 function roleAllowsBulk(role) {
51 return String(role).toLowerCase() !== 'viewer';
52 }
53
54 /**
55 * @returns {Promise<{ uid: string, effective: string, vaultId: string, hctx: Record<string, unknown>|null } | { err: { status: number, json: object } }>}
56 */
57 async function resolveCtx(req) {
58 const uid = getUserId(req);
59 if (!uid) return { err: { status: 401, json: { error: 'Unauthorized', code: 'UNAUTHORIZED' } } };
60 if (!CANISTER_URL) {
61 return {
62 err: {
63 status: 503,
64 json: { error: 'Hosted vault (canister) is not configured.', code: 'SERVICE_UNAVAILABLE' },
65 },
66 };
67 }
68 const vaultId = String(req.headers['x-vault-id'] || 'default').trim() || 'default';
69 const hctx = await getHostedAccessContext(req);
70 const effective =
71 hctx && typeof hctx.effective_canister_user_id === 'string' && hctx.effective_canister_user_id.trim()
72 ? hctx.effective_canister_user_id.trim()
73 : uid;
74 if (hctx && Array.isArray(hctx.allowed_vault_ids) && !hctx.allowed_vault_ids.includes(vaultId)) {
75 return { err: { status: 403, json: { error: 'Access to this vault is not allowed.', code: 'FORBIDDEN' } } };
76 }
77 return { uid, effective, vaultId, hctx };
78 }
79
80 function scopeActive(hctx) {
81 const s = hctx && hctx.scope && typeof hctx.scope === 'object' ? hctx.scope : null;
82 return Boolean(s && (s.projects?.length || s.folders?.length));
83 }
84
85 /**
86 * @param {string} uid
87 * @param {string} effective
88 * @param {string} vaultId
89 */
90 function readHeaders(uid, effective, vaultId) {
91 const h = {
92 Accept: 'application/json',
93 'x-user-id': effective,
94 'x-actor-id': uid,
95 'x-vault-id': vaultId,
96 };
97 if (CANISTER_AUTH_SECRET) h['x-gateway-auth'] = CANISTER_AUTH_SECRET;
98 return h;
99 }
100
101 /**
102 * @param {string} uid
103 * @param {string} effective
104 * @param {string} vaultId
105 */
106 function writeHeaders(uid, effective, vaultId) {
107 return {
108 ...readHeaders(uid, effective, vaultId),
109 'Content-Type': 'application/json',
110 };
111 }
112
113 /**
114 * @param {string} uid
115 * @param {string} effective
116 * @param {string} vaultId
117 */
118 async function fetchNotesJson(uid, effective, vaultId) {
119 const url = `${CANISTER_URL}/api/v1/notes`;
120 const r = await fetch(url, { headers: readHeaders(uid, effective, vaultId) });
121 const text = await r.text();
122 if (!r.ok) {
123 const err = new Error(`canister_notes_http_${r.status}`);
124 /** @type {any} */ (err).status = r.status;
125 /** @type {any} */ (err).body = text;
126 throw err;
127 }
128 try {
129 return text ? JSON.parse(text) : { notes: [] };
130 } catch (e) {
131 const err = new Error('canister_notes_json');
132 /** @type {any} */ (err).cause = e;
133 throw err;
134 }
135 }
136
137 /**
138 * @param {Array<{ path?: string, frontmatter?: unknown, body?: string }>} rows
139 * @param {string} slug
140 * @param {Record<string, unknown>|null} hctx
141 */
142 function pathsMatchingProjectSlug(rows, slug, hctx) {
143 /** @type {{ path: string, project: string|null }[]} */
144 let matches = [];
145 for (const n of rows) {
146 if (!n || typeof n !== 'object' || !n.path) continue;
147 const fm = materializeListFrontmatter(n.frontmatter);
148 const eff = effectiveProjectSlug(String(n.path), fm);
149 if (eff === slug) {
150 matches.push({ path: String(n.path).replace(/\\/g, '/'), project: eff ?? null });
151 }
152 }
153 if (hctx && scopeActive(hctx)) {
154 const scope = /** @type {{ projects?: string[], folders?: string[] }} */ (hctx.scope);
155 matches = applyScopeFilterToNotes(matches, scope);
156 }
157 return matches.map((m) => m.path);
158 }
159
160 /**
161 * @param {string} uid
162 * @param {string} effective
163 * @param {string} vaultId
164 * @param {Set<string>} pathSet
165 */
166 async function discardProposalsForPaths(uid, effective, vaultId, pathSet) {
167 if (pathSet.size === 0) return 0;
168 const r = await fetch(`${CANISTER_URL}/api/v1/proposals`, {
169 headers: readHeaders(uid, effective, vaultId),
170 });
171 const text = await r.text();
172 if (!r.ok) return 0;
173 let data;
174 try {
175 data = text ? JSON.parse(text) : { proposals: [] };
176 } catch {
177 return 0;
178 }
179 const proposals = Array.isArray(data.proposals) ? data.proposals : [];
180 let discarded = 0;
181 for (const p of proposals) {
182 if (!p || p.status !== 'proposed' || !p.proposal_id) continue;
183 const pv = p.vault_id != null && String(p.vault_id).trim() ? String(p.vault_id).trim() : 'default';
184 if (pv !== vaultId) continue;
185 const normPath = String(p.path || '').replace(/\\/g, '/');
186 if (!pathSet.has(normPath)) continue;
187 const dr = await fetch(
188 `${CANISTER_URL}/api/v1/proposals/${encodeURIComponent(p.proposal_id)}/discard`,
189 {
190 method: 'POST',
191 headers: writeHeaders(uid, effective, vaultId),
192 body: '{}',
193 },
194 );
195 if (dr.ok) discarded += 1;
196 }
197 return discarded;
198 }
199
200 /** @param {import('express').Request} req */
201 /** @param {import('express').Response} res */
202 async function deleteByProject(req, res) {
203 const role = await resolveRole(req);
204 if (!roleAllowsBulk(role)) {
205 return res.status(403).json({ error: 'This action requires a different role.', code: 'FORBIDDEN' });
206 }
207 const ctx = await resolveCtx(req);
208 if ('err' in ctx) return res.status(ctx.err.status).json(ctx.err.json);
209 const { uid, effective, vaultId, hctx } = ctx;
210
211 const raw = req.body && req.body.project != null ? String(req.body.project) : '';
212 const slug = normalizeSlug(raw.trim());
213 if (!slug) {
214 return res.status(400).json({ error: 'project slug required', code: 'BAD_REQUEST' });
215 }
216
217 let data;
218 try {
219 data = await fetchNotesJson(uid, effective, vaultId);
220 } catch (e) {
221 console.error('[gateway] delete-by-project: fetch notes', e?.message || e);
222 return res.status(502).json({ error: 'Could not list notes from vault.', code: 'BAD_GATEWAY' });
223 }
224 const rows = Array.isArray(data.notes) ? data.notes : [];
225 const pathsToDelete = pathsMatchingProjectSlug(rows, slug, hctx);
226 const normalizedPaths = pathsToDelete.map((p) => String(p).replace(/\\/g, '/'));
227
228 for (const p of normalizedPaths) {
229 const url = `${CANISTER_URL}/api/v1/notes/${encodeURIComponent(p)}`;
230 const dr = await fetch(url, {
231 method: 'DELETE',
232 headers: readHeaders(uid, effective, vaultId),
233 });
234 if (!dr.ok && dr.status !== 404) {
235 console.error('[gateway] delete-by-project: DELETE failed', p, dr.status);
236 return res.status(502).json({
237 error: 'Could not delete one or more notes on the vault.',
238 code: 'BAD_GATEWAY',
239 path: p,
240 });
241 }
242 }
243
244 const pathSet = new Set(normalizedPaths);
245 let proposals_discarded = 0;
246 try {
247 proposals_discarded = await discardProposalsForPaths(uid, effective, vaultId, pathSet);
248 } catch (e) {
249 console.error('[gateway] delete-by-project: proposals', e?.message || e);
250 }
251
252 return res.json({
253 deleted: normalizedPaths.length,
254 paths: normalizedPaths,
255 proposals_discarded,
256 });
257 }
258
259 /** @param {import('express').Request} req */
260 /** @param {import('express').Response} res */
261 async function renameProject(req, res) {
262 const role = await resolveRole(req);
263 if (!roleAllowsBulk(role)) {
264 return res.status(403).json({ error: 'This action requires a different role.', code: 'FORBIDDEN' });
265 }
266 const ctx = await resolveCtx(req);
267 if ('err' in ctx) return res.status(ctx.err.status).json(ctx.err.json);
268 const { uid, effective, vaultId, hctx } = ctx;
269
270 const fromRaw = req.body && req.body.from != null ? String(req.body.from) : '';
271 const toRaw = req.body && req.body.to != null ? String(req.body.to) : '';
272 const from = normalizeSlug(fromRaw.trim());
273 const to = normalizeSlug(toRaw.trim());
274 if (!from || !to) {
275 return res.status(400).json({ error: 'from and to project slugs required', code: 'BAD_REQUEST' });
276 }
277 if (from === to) {
278 return res.json({ updated: 0, paths: [] });
279 }
280
281 let data;
282 try {
283 data = await fetchNotesJson(uid, effective, vaultId);
284 } catch (e) {
285 console.error('[gateway] rename-project: fetch notes', e?.message || e);
286 return res.status(502).json({ error: 'Could not list notes from vault.', code: 'BAD_GATEWAY' });
287 }
288 const rows = Array.isArray(data.notes) ? data.notes : [];
289 let pathsToUpdate = pathsMatchingProjectSlug(rows, from, hctx);
290 pathsToUpdate = [...new Set(pathsToUpdate.map((p) => String(p).replace(/\\/g, '/')))];
291 const updatedPaths = [];
292
293 for (const notePath of pathsToUpdate) {
294 const row = rows.find((n) => n && n.path && String(n.path).replace(/\\/g, '/') === notePath);
295 if (!row) continue;
296 const fmPrev = materializeListFrontmatter(row.frontmatter);
297 const nextFm = { ...fmPrev, project: to };
298 const bodyPayload = mergeHostedNoteBodyForCanister(
299 {
300 path: notePath,
301 body: typeof row.body === 'string' ? row.body : '',
302 frontmatter: nextFm,
303 },
304 uid,
305 );
306 const pr = await fetch(`${CANISTER_URL}/api/v1/notes`, {
307 method: 'POST',
308 headers: writeHeaders(uid, effective, vaultId),
309 body: JSON.stringify(bodyPayload),
310 });
311 if (!pr.ok) {
312 const t = await pr.text();
313 console.error('[gateway] rename-project: POST note failed', notePath, pr.status, t?.slice(0, 200));
314 return res.status(502).json({
315 error: 'Could not update one or more notes on the vault.',
316 code: 'BAD_GATEWAY',
317 path: notePath,
318 });
319 }
320 updatedPaths.push(notePath);
321 }
322
323 return res.json({ updated: updatedPaths.length, paths: updatedPaths });
324 }
325
326 return { deleteByProject, renameProject };
327 }
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 11 days ago