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