flow-routes.mjs
185 lines 6.2 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago
1 /**
2 * Hosted bridge REST routes for Flow authoring write propose
3 * (FLOW-WRITE-LIVE-GATEWAY-PROXY — parity with task-routes 2G).
4 *
5 * Gated by FLOW_AUTHORING_WRITES (default OFF → 403 FLOW_AUTHORING_DISABLED).
6 * Capture routes live in flow-capture-routes.mjs (FLOW-CAPTURE-LIVE-KN-b).
7 * Run/consent routes live in flow-run-routes.mjs (SITE-FINISH-FLOW-RUN-KN-b).
8 * Does NOT mount Delegation write routes.
9 *
10 * @see docs/FLOW-AUTHORING-WRITEBACK-CONTRACT-7A-L1.md
11 * @see hub/bridge/task-routes.mjs
12 */
13
14 import { handleFlowProposeRequest } from '../../lib/flow/flow-authoring.mjs';
15 import { createFlowProposalOnCanister } from '../../lib/flow/flow-hosted-proposal.mjs';
16 import { isSessionBoundActor } from '../gateway/access-token-authz.mjs';
17 import { verifyJwtWithSecretRotation } from '../lib/session-secret-rotation.mjs';
18
19 /**
20 * Map bridge role to flow handler role (member → editor, matching self-hosted hub/server.mjs).
21 *
22 * @param {string} role
23 * @returns {string}
24 */
25 export function bridgeFlowHandlerRole(role) {
26 const r = typeof role === 'string' ? role.trim().toLowerCase() : '';
27 return r === 'member' || !r ? 'editor' : r;
28 }
29
30 /**
31 * @param {import('express').Express} app
32 * @param {{
33 * dataDir: string,
34 * canisterUrl: string,
35 * canisterHeaders: (extra?: Record<string, string>) => Record<string, string>,
36 * requireBridgeAuth: import('express').RequestHandler,
37 * resolveHostedBridgeContext: (req: import('express').Request, actorUid: string) => Promise<{
38 * ok: boolean,
39 * status?: number,
40 * error?: string,
41 * code?: string,
42 * vaultId?: string,
43 * effectiveCanisterUid?: string,
44 * actorUid?: string,
45 * }>,
46 * effectiveRole: (uid: string, storedRoles: Record<string, string>) => string,
47 * loadRoles: (blobStore: unknown) => Promise<Record<string, string>>,
48 * }} deps
49 */
50 export function registerBridgeFlowRoutes(app, deps) {
51 const {
52 dataDir,
53 canisterUrl,
54 canisterHeaders,
55 requireBridgeAuth,
56 resolveHostedBridgeContext,
57 effectiveRole,
58 loadRoles,
59 } = deps;
60
61 /**
62 * @param {import('express').Request} req
63 */
64 async function flowHandlerContext(req) {
65 const hctx = await resolveHostedBridgeContext(req, req.uid);
66 if (!hctx.ok) return hctx;
67 const roles = await loadRoles(req.blobStore);
68 const role = bridgeFlowHandlerRole(effectiveRole(req.uid, roles));
69 return { ok: true, hctx, role };
70 }
71
72 /**
73 * @param {import('express').Request} req
74 * @returns {boolean}
75 */
76 function sessionBoundFromReq(req) {
77 const auth = req.headers.authorization;
78 const token = auth && auth.startsWith('Bearer ') ? auth.slice(7) : null;
79 const secret = process.env.SESSION_SECRET;
80 if (!token || !secret) return false;
81 const payload = verifyJwtWithSecretRotation(token, secret, process.env.SESSION_SECRET_PREVIOUS);
82 return payload ? isSessionBoundActor(payload) : false;
83 }
84
85 /**
86 * @param {{
87 * effectiveCanisterUid: string,
88 * actorUid: string,
89 * vaultId: string,
90 * sessionBound?: boolean,
91 * }} ctx
92 */
93 function hostedCreateProposal(ctx) {
94 return async function createProposal(_dataDir, input) {
95 return createFlowProposalOnCanister({
96 canisterUrl,
97 sessionBound: ctx.sessionBound === true,
98 headers: canisterHeaders({
99 'X-User-Id': ctx.effectiveCanisterUid,
100 'X-Actor-Id': ctx.actorUid,
101 'X-Vault-Id': ctx.vaultId,
102 }),
103 input: {
104 ...input,
105 vault_id: ctx.vaultId,
106 proposed_by: ctx.actorUid,
107 },
108 });
109 };
110 }
111
112 /**
113 * @param {import('express').Response} res
114 * @param {unknown} err
115 */
116 function sendRouteError(res, err) {
117 const e =
118 err && typeof err === 'object'
119 ? /** @type {{ status?: number, code?: string, message?: string }} */ (err)
120 : {};
121 const status = typeof e.status === 'number' ? e.status : 500;
122 const code = typeof e.code === 'string' ? e.code : 'RUNTIME_ERROR';
123 const message = typeof e.message === 'string' ? e.message : String(err);
124 return res.status(status).json({ error: message, code });
125 }
126
127 /**
128 * @param {import('express').Request} req
129 * @param {import('express').Response} res
130 * @param {'new'|'edit'|'import'} kind
131 * @param {{ flowId?: string }} [extra]
132 */
133 async function runFlowPropose(req, res, kind, extra = {}) {
134 const ctx = await flowHandlerContext(req);
135 if (!ctx.ok) return res.status(ctx.status).json({ error: ctx.error, code: ctx.code });
136
137 const body = req.body && typeof req.body === 'object' ? req.body : {};
138 try {
139 const result = await handleFlowProposeRequest({
140 dataDir,
141 vaultId: ctx.hctx.vaultId,
142 userId: req.uid,
143 role: ctx.role,
144 kind,
145 flow: body.flow,
146 steps: body.steps,
147 bundle: kind === 'import' ? body.bundle ?? { flow: body.flow, steps: body.steps } : undefined,
148 intent: body.intent,
149 flowId: extra.flowId,
150 baseVersion: body.base_version,
151 baseStateId: body.base_state_id,
152 externalRef: body.external_ref,
153 sourceVaultHint: body.source_vault_hint,
154 sessionBound: sessionBoundFromReq(req),
155 createProposal: hostedCreateProposal({
156 effectiveCanisterUid: ctx.hctx.effectiveCanisterUid,
157 actorUid: req.uid,
158 vaultId: ctx.hctx.vaultId,
159 sessionBound: sessionBoundFromReq(req),
160 }),
161 });
162 if (!result.ok) {
163 return res.status(result.status).json({ error: result.error, code: result.code });
164 }
165 return res.status(201).json(result.payload);
166 } catch (err) {
167 return sendRouteError(res, err);
168 }
169 }
170
171 // Static path before :id — import must not be captured as a flow id.
172 app.post('/api/v1/flows/import', requireBridgeAuth, async (req, res) => {
173 return runFlowPropose(req, res, 'import');
174 });
175
176 app.post('/api/v1/flows', requireBridgeAuth, async (req, res) => {
177 return runFlowPropose(req, res, 'new');
178 });
179
180 app.post('/api/v1/flows/:id/proposals', requireBridgeAuth, async (req, res) => {
181 const flowId =
182 typeof req.params.id === 'string' ? decodeURIComponent(req.params.id).trim() : '';
183 return runFlowPropose(req, res, 'edit', { flowId });
184 });
185 }
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago