metadata-bulk-canister.mjs
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