path-routes.mjs
245 lines 8.2 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 10 days ago
1 /**
2 * Hosted bridge REST routes for learning-path list/get + gated propose/apply (KN-WORK-PATH-LIST-b).
3 *
4 * Mutating routes wrap withExternalProtocolBlobSync (same hub_flow_store.json — no new blob file).
5 *
6 * @see docs/KN-WORK-PATH-LIST-FREEZE.md
7 */
8
9 import { handlePathListRequest, handlePathGetRequest, parsePathListLimit } from '../../lib/path/path-handlers.mjs';
10 import { handlePathProposeRequest } from '../../lib/path/path-write.mjs';
11 import {
12 createPathProposalOnCanister,
13 applyApprovedPathProposalFromCanister,
14 } from '../../lib/path/path-hosted-proposal.mjs';
15 import { withExternalProtocolBlobSync } from './external-agent-blob-store.mjs';
16 import { isSessionBoundActor } from '../gateway/access-token-authz.mjs';
17 import { verifyJwtWithSecretRotation } from '../lib/session-secret-rotation.mjs';
18 import { bridgeTaskHandlerRole } from './task-routes.mjs';
19
20 /**
21 * @param {import('express').Express} app
22 * @param {{
23 * dataDir: string,
24 * canisterUrl: string,
25 * canisterHeaders: (extra?: Record<string, string>) => Record<string, string>,
26 * requireBridgeAuth: import('express').RequestHandler,
27 * resolveHostedBridgeContext: (req: import('express').Request, actorUid: string) => Promise<{
28 * ok: boolean,
29 * status?: number,
30 * error?: string,
31 * code?: string,
32 * vaultId?: string,
33 * effectiveCanisterUid?: string,
34 * actorUid?: string,
35 * }>,
36 * effectiveRole: (uid: string, storedRoles: Record<string, string>) => string,
37 * loadRoles: (blobStore: unknown) => Promise<Record<string, string>>,
38 * }} deps
39 */
40 export function registerBridgePathRoutes(app, deps) {
41 const {
42 dataDir,
43 canisterUrl,
44 canisterHeaders,
45 requireBridgeAuth,
46 resolveHostedBridgeContext,
47 effectiveRole,
48 loadRoles,
49 } = deps;
50
51 /**
52 * @param {import('express').Request} req
53 */
54 async function vaultContext(req) {
55 return resolveHostedBridgeContext(req, req.uid);
56 }
57
58 /**
59 * @param {import('express').Request} req
60 */
61 async function pathHandlerContext(req) {
62 const hctx = await vaultContext(req);
63 if (!hctx.ok) return hctx;
64 const roles = await loadRoles(req.blobStore);
65 const role = bridgeTaskHandlerRole(effectiveRole(req.uid, roles));
66 return { ok: true, hctx, role };
67 }
68
69 /**
70 * @param {import('express').Request} req
71 * @returns {boolean}
72 */
73 function sessionBoundFromReq(req) {
74 const auth = req.headers.authorization;
75 const token = auth && auth.startsWith('Bearer ') ? auth.slice(7) : null;
76 const secret = process.env.SESSION_SECRET;
77 if (!token || !secret) return false;
78 const payload = verifyJwtWithSecretRotation(token, secret, process.env.SESSION_SECRET_PREVIOUS);
79 return payload ? isSessionBoundActor(payload) : false;
80 }
81
82 /**
83 * @param {{
84 * effectiveCanisterUid: string,
85 * actorUid: string,
86 * vaultId: string,
87 * sessionBound?: boolean,
88 * }} ctx
89 */
90 function hostedCreateProposal(ctx) {
91 return async function createProposal(_dataDir, input) {
92 return createPathProposalOnCanister({
93 canisterUrl,
94 sessionBound: ctx.sessionBound === true,
95 headers: canisterHeaders({
96 'X-User-Id': ctx.effectiveCanisterUid,
97 'X-Actor-Id': ctx.actorUid,
98 'X-Vault-Id': ctx.vaultId,
99 }),
100 input: {
101 ...input,
102 vault_id: ctx.vaultId,
103 proposed_by: ctx.actorUid,
104 },
105 });
106 };
107 }
108
109 /**
110 * @param {import('express').Response} res
111 * @param {unknown} err
112 */
113 function sendRouteError(res, err) {
114 const e = err && typeof err === 'object' ? /** @type {{ status?: number, code?: string, message?: string }} */ (err) : {};
115 const status = typeof e.status === 'number' ? e.status : 500;
116 const code = typeof e.code === 'string' ? e.code : 'RUNTIME_ERROR';
117 const message = typeof e.message === 'string' ? e.message : String(err);
118 return res.status(status).json({ error: message, code });
119 }
120
121 app.get('/api/v1/learning-paths', requireBridgeAuth, async (req, res) => {
122 const ctx = await pathHandlerContext(req);
123 if (!ctx.ok) return res.status(ctx.status).json({ error: ctx.error, code: ctx.code });
124
125 const result = await withExternalProtocolBlobSync({
126 blobStore: req.blobStore ?? null,
127 dataDir,
128 run: () =>
129 handlePathListRequest({
130 dataDir,
131 vaultId: ctx.hctx.vaultId,
132 userId: req.uid,
133 role: ctx.role,
134 scope: typeof req.query.scope === 'string' ? req.query.scope : undefined,
135 workspace_id: typeof req.query.workspace_id === 'string' ? req.query.workspace_id : undefined,
136 status: typeof req.query.status === 'string' ? req.query.status : undefined,
137 limit: parsePathListLimit(req.query.limit),
138 }),
139 });
140 if (!result.ok) {
141 return res.status(result.status).json({ error: result.error, code: result.code });
142 }
143 return res.json(result.payload);
144 });
145
146 app.get('/api/v1/learning-paths/:path_id', requireBridgeAuth, async (req, res) => {
147 const ctx = await pathHandlerContext(req);
148 if (!ctx.ok) return res.status(ctx.status).json({ error: ctx.error, code: ctx.code });
149
150 const pathId =
151 typeof req.params.path_id === 'string' ? decodeURIComponent(req.params.path_id).trim() : '';
152 const result = await withExternalProtocolBlobSync({
153 blobStore: req.blobStore ?? null,
154 dataDir,
155 run: () =>
156 handlePathGetRequest({
157 dataDir,
158 vaultId: ctx.hctx.vaultId,
159 pathId,
160 userId: req.uid,
161 role: ctx.role,
162 }),
163 });
164 if (!result.ok) {
165 return res.status(result.status).json({ error: result.error, code: result.code });
166 }
167 return res.json(result.payload);
168 });
169
170 app.post('/api/v1/learning-paths/proposals', requireBridgeAuth, async (req, res) => {
171 const ctx = await pathHandlerContext(req);
172 if (!ctx.ok) return res.status(ctx.status).json({ error: ctx.error, code: ctx.code });
173
174 const body = req.body && typeof req.body === 'object' ? req.body : {};
175 const proposalKind =
176 typeof body.proposal_kind === 'string' && body.proposal_kind.trim()
177 ? body.proposal_kind.trim()
178 : 'path_create';
179 try {
180 const result = await withExternalProtocolBlobSync({
181 blobStore: req.blobStore ?? null,
182 dataDir,
183 run: () =>
184 handlePathProposeRequest({
185 dataDir,
186 vaultId: ctx.hctx.vaultId,
187 userId: req.uid,
188 role: ctx.role,
189 proposalKind,
190 body,
191 intent: body.intent,
192 sessionBound: sessionBoundFromReq(req),
193 createProposal: hostedCreateProposal({
194 effectiveCanisterUid: ctx.hctx.effectiveCanisterUid,
195 actorUid: req.uid,
196 vaultId: ctx.hctx.vaultId,
197 sessionBound: sessionBoundFromReq(req),
198 }),
199 }),
200 });
201 if (!result.ok) {
202 return res.status(result.status).json({ error: result.error, code: result.code });
203 }
204 return res.status(201).json(result.payload);
205 } catch (err) {
206 return sendRouteError(res, err);
207 }
208 });
209
210 app.post(
211 '/api/v1/learning-paths/proposals/:proposal_id/apply-approved',
212 requireBridgeAuth,
213 async (req, res) => {
214 const hctx = await vaultContext(req);
215 if (!hctx.ok) return res.status(hctx.status).json({ error: hctx.error, code: hctx.code });
216
217 const proposalId =
218 typeof req.params.proposal_id === 'string' ? decodeURIComponent(req.params.proposal_id).trim() : '';
219 if (!proposalId) {
220 return res.status(400).json({ error: 'proposal_id required', code: 'BAD_REQUEST' });
221 }
222
223 const result = await withExternalProtocolBlobSync({
224 blobStore: req.blobStore ?? null,
225 dataDir,
226 run: () =>
227 applyApprovedPathProposalFromCanister({
228 dataDir,
229 canisterUrl,
230 headers: canisterHeaders({
231 'X-User-Id': hctx.effectiveCanisterUid,
232 'X-Actor-Id': req.uid,
233 'X-Vault-Id': hctx.vaultId,
234 }),
235 proposalId,
236 requireApproved: true,
237 }),
238 });
239 if (!result.ok) {
240 return res.status(result.status).json({ error: result.error, code: result.code });
241 }
242 return res.json(result.payload);
243 },
244 );
245 }
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 10 days ago