media-routes.mjs
485 lines 17.9 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago
1 /**
2 * Hosted bridge REST routes for media write surfaces
3 * (SEC-SEAM-MEDIA-b — SM-C3, SM-C4, SM-C6, SM-C7).
4 *
5 * Gated by MEDIA_EXTERNAL_LINK_ENABLED / MEDIA_ATTACH_ENABLED (both default OFF →
6 * 403 MEDIA_*_DISABLED, same refusal codes as self-hosted hub/server.mjs). This
7 * module does NOT flip those envs (SM-C10).
8 *
9 * Role gates mirror self-hosted: writes require editor/admin (MEDIA_WRITE_ROLES);
10 * consent list + attachment list/get allow viewer/editor/admin/evaluator.
11 *
12 * Every mutating route runs inside withMediaBlobSync and every read hydrates the
13 * media stores from Blobs first: hosted Netlify lambdas have ephemeral DATA_DIR,
14 * so a consent granted on one instance must survive to the propose precheck on
15 * another, and an external-ref upsert must be visible to later attachment reads
16 * (capture blob-sync parity — SM-C6).
17 *
18 * media_attach propose uses the SM-C5 temp-stage: GET the canister target note,
19 * stage into a per-request temp vaultPath, run the SAME
20 * `handleMediaAttachProposeRequest` (note existence + base_state_id checks against
21 * the canister-fresh note), and stamp the propose-time `media_pointer` on the
22 * proposal via the hosted createProposal wrapper (G22).
23 *
24 * @see docs/SEC-SEAM-MEDIA-FREEZE.md
25 * @see hub/bridge/flow-capture-routes.mjs (pattern sibling)
26 */
27
28 import {
29 handleMediaLinkProposeRequest,
30 handleMediaAttachProposeRequest,
31 handleMediaImportConsentGrantRequest,
32 handleMediaImportConsentListRequest,
33 handleMediaImportConsentRevokeRequest,
34 resolveMediaPointerForAttach,
35 } from '../../lib/attachments/attachment-write.mjs';
36 import {
37 handleAttachmentListRequest,
38 handleAttachmentGetRequest,
39 } from '../../lib/attachments/attachment-handlers.mjs';
40 import {
41 createMediaProposalOnCanister,
42 applyApprovedMediaProposalFromCanister,
43 stageCanisterNoteToTempVault,
44 notePathFromRef,
45 } from '../../lib/attachments/media-hosted-proposal.mjs';
46 import { withMediaBlobSync, hydrateMediaStoresFromBlob } from './media-blob-store.mjs';
47 import { isSessionBoundActor } from '../gateway/access-token-authz.mjs';
48 import { verifyJwtWithSecretRotation } from '../lib/session-secret-rotation.mjs';
49
50 /** Self-hosted MEDIA_WRITE_ROLES parity (hub/server.mjs:1325). */
51 const MEDIA_WRITE_ROLE_SET = new Set(['editor', 'admin']);
52 /** Self-hosted MEDIA_CONSENT_READ_ROLES / attachment read parity (hub/server.mjs:1326). */
53 const MEDIA_READ_ROLE_SET = new Set(['viewer', 'editor', 'admin', 'evaluator']);
54
55 /**
56 * Map bridge role to media handler role (member → editor, matching capture bridge).
57 *
58 * @param {string} role
59 * @returns {string}
60 */
61 export function bridgeMediaHandlerRole(role) {
62 const r = typeof role === 'string' ? role.trim().toLowerCase() : '';
63 return r === 'member' || !r ? 'editor' : r;
64 }
65
66 /**
67 * @param {import('express').Express} app
68 * @param {{
69 * dataDir: string,
70 * canisterUrl: string,
71 * canisterHeaders: (extra?: Record<string, string>) => Record<string, string>,
72 * requireBridgeAuth: import('express').RequestHandler,
73 * resolveHostedBridgeContext: (req: import('express').Request, actorUid: string) => Promise<{
74 * ok: boolean,
75 * status?: number,
76 * error?: string,
77 * code?: string,
78 * vaultId?: string,
79 * effectiveCanisterUid?: string,
80 * actorUid?: string,
81 * }>,
82 * effectiveRole: (uid: string, storedRoles: Record<string, string>) => string,
83 * loadRoles: (blobStore: unknown) => Promise<Record<string, string>>,
84 * }} deps
85 */
86 export function registerBridgeMediaRoutes(app, deps) {
87 const {
88 dataDir,
89 canisterUrl,
90 canisterHeaders,
91 requireBridgeAuth,
92 resolveHostedBridgeContext,
93 effectiveRole,
94 loadRoles,
95 } = deps;
96
97 /**
98 * @param {import('express').Request} req
99 * @param {Set<string>} allowedRoles
100 */
101 async function mediaHandlerContext(req, allowedRoles) {
102 const hctx = await resolveHostedBridgeContext(req, req.uid);
103 if (!hctx.ok) return hctx;
104 const roles = await loadRoles(req.blobStore);
105 const role = bridgeMediaHandlerRole(effectiveRole(req.uid, roles));
106 if (!allowedRoles.has(role)) {
107 return { ok: false, status: 403, error: 'Insufficient role', code: 'FORBIDDEN' };
108 }
109 return { ok: true, hctx, role };
110 }
111
112 /**
113 * @param {import('express').Request} req
114 * @returns {boolean}
115 */
116 function sessionBoundFromReq(req) {
117 const auth = req.headers.authorization;
118 const token = auth && auth.startsWith('Bearer ') ? auth.slice(7) : null;
119 const secret = process.env.SESSION_SECRET;
120 if (!token || !secret) return false;
121 const payload = verifyJwtWithSecretRotation(token, secret, process.env.SESSION_SECRET_PREVIOUS);
122 return payload ? isSessionBoundActor(payload) : false;
123 }
124
125 /**
126 * Hosted createProposal for media handlers → canister proposal store (SM-C3).
127 *
128 * When `stampPointer` is set (media_attach), resolve the attach pointer ONCE at
129 * propose time against the staged temp vault and persist it as
130 * `media_meta.media_pointer` (+ frontmatter via mergeMediaFrontmatter) so hosted
131 * apply never needs a vault-wide walk on the bridge lambda (SM-C5 / G22).
132 *
133 * @param {{
134 * effectiveCanisterUid: string,
135 * actorUid: string,
136 * vaultId: string,
137 * sessionBound?: boolean,
138 * stampPointer?: { vaultPath: string, vaultConfig: object },
139 * }} ctx
140 */
141 function hostedCreateProposal(ctx) {
142 return async function createProposal(_dataDir, input) {
143 let mediaMeta = input.media_meta;
144 if (ctx.stampPointer && mediaMeta && mediaMeta.proposal_kind === 'media_attach') {
145 const pointer = resolveMediaPointerForAttach(
146 ctx.stampPointer.vaultPath,
147 ctx.stampPointer.vaultConfig,
148 String(mediaMeta.attachment_id || ''),
149 );
150 if (!pointer) {
151 const err = new Error('unknown_attachment');
152 err.status = 404;
153 err.code = 'unknown_attachment';
154 throw err;
155 }
156 mediaMeta = { ...mediaMeta, media_pointer: pointer };
157 }
158 return createMediaProposalOnCanister({
159 canisterUrl,
160 sessionBound: ctx.sessionBound === true,
161 headers: canisterHeaders({
162 'X-User-Id': ctx.effectiveCanisterUid,
163 'X-Actor-Id': ctx.actorUid,
164 'X-Vault-Id': ctx.vaultId,
165 }),
166 input: {
167 ...input,
168 media_meta: mediaMeta,
169 vault_id: ctx.vaultId,
170 proposed_by: ctx.actorUid,
171 },
172 });
173 };
174 }
175
176 /**
177 * @param {import('express').Response} res
178 * @param {unknown} err
179 */
180 function sendRouteError(res, err) {
181 const e =
182 err && typeof err === 'object'
183 ? /** @type {{ status?: number, code?: string, message?: string }} */ (err)
184 : {};
185 const status = typeof e.status === 'number' ? e.status : 500;
186 const code = typeof e.code === 'string' ? e.code : 'RUNTIME_ERROR';
187 const message = typeof e.message === 'string' ? e.message : String(err);
188 return res.status(status).json({ error: message, code });
189 }
190
191 app.post('/api/v1/attachments/link-proposals', requireBridgeAuth, async (req, res) => {
192 const ctx = await mediaHandlerContext(req, MEDIA_WRITE_ROLE_SET);
193 if (!ctx.ok) return res.status(ctx.status).json({ error: ctx.error, code: ctx.code });
194
195 const body = req.body && typeof req.body === 'object' ? req.body : {};
196 try {
197 const result = await withMediaBlobSync({
198 blobStore: req.blobStore ?? null,
199 dataDir,
200 run: () =>
201 handleMediaLinkProposeRequest({
202 dataDir,
203 vaultPath: dataDir,
204 vaultId: ctx.hctx.vaultId,
205 userId: req.uid,
206 role: ctx.role,
207 body,
208 intent: body.intent,
209 sessionBound: sessionBoundFromReq(req),
210 createProposal: hostedCreateProposal({
211 effectiveCanisterUid: ctx.hctx.effectiveCanisterUid,
212 actorUid: req.uid,
213 vaultId: ctx.hctx.vaultId,
214 sessionBound: sessionBoundFromReq(req),
215 }),
216 vaultConfig: {},
217 }),
218 });
219 if (!result.ok) {
220 return res.status(result.status).json({ error: result.error, code: result.code });
221 }
222 return res.status(201).json(result.payload);
223 } catch (err) {
224 return sendRouteError(res, err);
225 }
226 });
227
228 app.post('/api/v1/attachments/attach-proposals', requireBridgeAuth, async (req, res) => {
229 const ctx = await mediaHandlerContext(req, MEDIA_WRITE_ROLE_SET);
230 if (!ctx.ok) return res.status(ctx.status).json({ error: ctx.error, code: ctx.code });
231
232 const body = req.body && typeof req.body === 'object' ? req.body : {};
233 const noteRef = typeof body.note_ref === 'string' ? body.note_ref.trim() : '';
234
235 // SM-C5 temp-stage: hosted note lives on the canister, so stage it before the
236 // shared handler validates note existence / scope / base_state_id. Invalid or
237 // missing note_ref stages nothing → the shared handler refuses (unknown_note /
238 // MEDIA_DRAFT_INVALID) exactly like self-hosted.
239 let staged = null;
240 try {
241 if (noteRef) {
242 staged = await stageCanisterNoteToTempVault({
243 canisterUrl,
244 headers: canisterHeaders({
245 'X-User-Id': ctx.hctx.effectiveCanisterUid,
246 'X-Actor-Id': req.uid,
247 'X-Vault-Id': ctx.hctx.vaultId,
248 }),
249 notePath: notePathFromRef(noteRef),
250 });
251 if ('error' in staged) {
252 return res
253 .status(staged.error.status)
254 .json({ error: staged.error.error, code: staged.error.code });
255 }
256 }
257
258 const vaultPath = staged ? staged.vaultPath : dataDir;
259 const result = await withMediaBlobSync({
260 blobStore: req.blobStore ?? null,
261 dataDir,
262 run: () =>
263 handleMediaAttachProposeRequest({
264 dataDir,
265 vaultPath,
266 vaultId: ctx.hctx.vaultId,
267 userId: req.uid,
268 role: ctx.role,
269 body,
270 intent: body.intent,
271 sessionBound: sessionBoundFromReq(req),
272 // Canister GET fingerprint — not yaml-stage→readNote (body trimEnd flip).
273 liveStateIdOverride: staged?.liveStateId,
274 createProposal: hostedCreateProposal({
275 effectiveCanisterUid: ctx.hctx.effectiveCanisterUid,
276 actorUid: req.uid,
277 vaultId: ctx.hctx.vaultId,
278 sessionBound: sessionBoundFromReq(req),
279 stampPointer: { vaultPath, vaultConfig: {} },
280 }),
281 vaultConfig: {},
282 }),
283 });
284 if (!result.ok) {
285 return res.status(result.status).json({ error: result.error, code: result.code });
286 }
287 return res.status(201).json(result.payload);
288 } catch (err) {
289 return sendRouteError(res, err);
290 } finally {
291 if (staged && typeof staged.cleanup === 'function') staged.cleanup();
292 }
293 });
294
295 app.post('/api/v1/attachments/import-consents', requireBridgeAuth, async (req, res) => {
296 const ctx = await mediaHandlerContext(req, MEDIA_WRITE_ROLE_SET);
297 if (!ctx.ok) return res.status(ctx.status).json({ error: ctx.error, code: ctx.code });
298
299 const body = req.body && typeof req.body === 'object' ? req.body : {};
300 try {
301 const result = await withMediaBlobSync({
302 blobStore: req.blobStore ?? null,
303 dataDir,
304 run: () =>
305 handleMediaImportConsentGrantRequest({
306 dataDir,
307 vaultId: ctx.hctx.vaultId,
308 userId: req.uid,
309 role: ctx.role,
310 body,
311 }),
312 });
313 if (!result.ok) {
314 return res.status(result.status).json({ error: result.error, code: result.code });
315 }
316 return res.status(201).json(result.payload);
317 } catch (err) {
318 return sendRouteError(res, err);
319 }
320 });
321
322 app.get('/api/v1/attachments/import-consents', requireBridgeAuth, async (req, res) => {
323 const ctx = await mediaHandlerContext(req, MEDIA_READ_ROLE_SET);
324 if (!ctx.ok) return res.status(ctx.status).json({ error: ctx.error, code: ctx.code });
325
326 try {
327 await hydrateMediaStoresFromBlob(req.blobStore ?? null, dataDir);
328 const result = handleMediaImportConsentListRequest({
329 dataDir,
330 vaultId: ctx.hctx.vaultId,
331 userId: req.uid,
332 role: ctx.role,
333 scope: typeof req.query.scope === 'string' ? req.query.scope : undefined,
334 });
335 if (!result.ok) {
336 return res.status(result.status).json({ error: result.error, code: result.code });
337 }
338 return res.json(result.payload);
339 } catch (err) {
340 return sendRouteError(res, err);
341 }
342 });
343
344 app.delete('/api/v1/attachments/import-consents/:id', requireBridgeAuth, async (req, res) => {
345 const ctx = await mediaHandlerContext(req, MEDIA_WRITE_ROLE_SET);
346 if (!ctx.ok) return res.status(ctx.status).json({ error: ctx.error, code: ctx.code });
347
348 const consentId =
349 typeof req.params.id === 'string' ? decodeURIComponent(req.params.id).trim() : '';
350 try {
351 const result = await withMediaBlobSync({
352 blobStore: req.blobStore ?? null,
353 dataDir,
354 run: () =>
355 handleMediaImportConsentRevokeRequest({
356 dataDir,
357 vaultId: ctx.hctx.vaultId,
358 userId: req.uid,
359 role: ctx.role,
360 consentId,
361 }),
362 });
363 if (!result.ok) {
364 return res.status(result.status).json({ error: result.error, code: result.code });
365 }
366 return res.json(result.payload);
367 } catch (err) {
368 return sendRouteError(res, err);
369 }
370 });
371
372 // Hub-complete media apply (SM-C4). Called by the gateway post-approve hook; also
373 // re-callable by ops after fixing store state while the proposal is still
374 // `approved` (SM-C12 recovery). withMediaBlobSync hydrates the connector/consent/
375 // external-ref stores before the shared precheck and persists after apply.
376 app.post(
377 '/api/v1/attachments/proposals/:proposal_id/apply-approved',
378 requireBridgeAuth,
379 async (req, res) => {
380 const hctx = await resolveHostedBridgeContext(req, req.uid);
381 if (!hctx.ok) return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
382
383 const proposalId =
384 typeof req.params.proposal_id === 'string'
385 ? decodeURIComponent(req.params.proposal_id).trim()
386 : '';
387 if (!proposalId) {
388 return res.status(400).json({ error: 'proposal_id required', code: 'BAD_REQUEST' });
389 }
390
391 try {
392 const result = await withMediaBlobSync({
393 blobStore: req.blobStore ?? null,
394 dataDir,
395 run: () =>
396 applyApprovedMediaProposalFromCanister({
397 dataDir,
398 canisterUrl,
399 headers: canisterHeaders({
400 'X-User-Id': hctx.effectiveCanisterUid,
401 'X-Actor-Id': req.uid,
402 'X-Vault-Id': hctx.vaultId,
403 }),
404 proposalId,
405 requireApproved: true,
406 vaultId: hctx.vaultId,
407 }),
408 });
409 if (!result.ok) {
410 return res.status(result.status).json({ error: result.error, code: result.code });
411 }
412 return res.json(result.payload);
413 } catch (err) {
414 return sendRouteError(res, err);
415 }
416 },
417 );
418
419 // Hosted attachment list/get exposure (SM-C7) — same handlers as self-hosted so
420 // connector_ref rows from external-link apply are visible to Scooling hosted
421 // reads. vaultPath = dataDir: no vault filesystem on the bridge, so derivation
422 // covers external-ref (connector_ref) rows; vault-file/mist rows are self-hosted.
423 app.get('/api/v1/attachments', requireBridgeAuth, async (req, res) => {
424 const ctx = await mediaHandlerContext(req, MEDIA_READ_ROLE_SET);
425 if (!ctx.ok) return res.status(ctx.status).json({ error: ctx.error, code: ctx.code });
426
427 const limitRaw = req.query.limit != null ? parseInt(String(req.query.limit), 10) : undefined;
428 try {
429 await hydrateMediaStoresFromBlob(req.blobStore ?? null, dataDir);
430 const result = handleAttachmentListRequest({
431 dataDir,
432 vaultPath: dataDir,
433 vaultId: ctx.hctx.vaultId,
434 userId: req.uid,
435 role: ctx.role,
436 scope: typeof req.query.scope === 'string' ? req.query.scope : undefined,
437 note_ref: typeof req.query.note_ref === 'string' ? req.query.note_ref : undefined,
438 source: typeof req.query.source === 'string' ? req.query.source : undefined,
439 mime_class: typeof req.query.mime_class === 'string' ? req.query.mime_class : undefined,
440 storage_kind:
441 typeof req.query.storage_kind === 'string' ? req.query.storage_kind : undefined,
442 agent_visible: req.query.agent_visible === 'true',
443 limit: Number.isFinite(limitRaw) ? limitRaw : undefined,
444 hubScope: null,
445 vaultConfig: {},
446 });
447 if (!result.ok) {
448 return res.status(result.status).json({ error: result.error, code: result.code });
449 }
450 return res.json(result.payload);
451 } catch (err) {
452 return sendRouteError(res, err);
453 }
454 });
455
456 // MUST stay registered after GET /api/v1/attachments/import-consents (above) so
457 // 'import-consents' is never treated as an attachment id — static-path ordering
458 // (SM-C7, capture CHA-C5 parity).
459 app.get('/api/v1/attachments/:id', requireBridgeAuth, async (req, res) => {
460 const ctx = await mediaHandlerContext(req, MEDIA_READ_ROLE_SET);
461 if (!ctx.ok) return res.status(ctx.status).json({ error: ctx.error, code: ctx.code });
462
463 const attachmentId =
464 typeof req.params.id === 'string' ? decodeURIComponent(req.params.id).trim() : '';
465 try {
466 await hydrateMediaStoresFromBlob(req.blobStore ?? null, dataDir);
467 const result = handleAttachmentGetRequest({
468 dataDir,
469 vaultPath: dataDir,
470 vaultId: ctx.hctx.vaultId,
471 attachmentId,
472 userId: req.uid,
473 role: ctx.role,
474 hubScope: null,
475 vaultConfig: {},
476 });
477 if (!result.ok) {
478 return res.status(result.status).json({ error: result.error, code: result.code });
479 }
480 return res.json(result.payload);
481 } catch (err) {
482 return sendRouteError(res, err);
483 }
484 });
485 }
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago