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