path-hosted-proposal.mjs
306 lines 9.1 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago
1 /**
2 * Hosted learning-path proposal parity (KN-WORK-PATH-LIST-b).
3 *
4 * Path write proposals live in the canister proposal store. Approve apply runs via
5 * POST …/learning-paths/proposals/:id/apply-approved (gateway hook after approve)
6 * into bridge hub_flow_store.json learning_paths[].
7 *
8 * @see docs/KN-WORK-PATH-LIST-FREEZE.md §4.4
9 */
10
11 import { parseCanisterProposalGetBody } from '../canister-proposal-response-parse.mjs';
12 import {
13 PATH_PROPOSAL_SOURCE,
14 PATH_REVIEW_QUEUE,
15 PATH_PROPOSAL_KINDS,
16 getPathWritesEnabled,
17 precheckApprovedPathProposal,
18 reconcileApprovedPathProposal,
19 } from './path-write.mjs';
20
21 export const FM_PROPOSAL_SOURCE = 'knowtation_proposal_source';
22 export const FM_PATH_PROPOSAL_KIND = 'path_proposal_kind';
23 export const FM_PATH_ID = 'path_id';
24
25 /**
26 * @param {unknown} frontmatter
27 * @returns {Record<string, unknown>}
28 */
29 export function parseProposalFrontmatter(frontmatter) {
30 if (frontmatter == null) return {};
31 if (typeof frontmatter === 'object' && !Array.isArray(frontmatter)) {
32 return /** @type {Record<string, unknown>} */ (frontmatter);
33 }
34 if (typeof frontmatter === 'string' && frontmatter.trim()) {
35 try {
36 const parsed = JSON.parse(frontmatter);
37 return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
38 ? /** @type {Record<string, unknown>} */ (parsed)
39 : {};
40 } catch {
41 return {};
42 }
43 }
44 return {};
45 }
46
47 /**
48 * @param {unknown} raw
49 * @returns {string}
50 */
51 function readProposalKind(raw) {
52 return typeof raw === 'string' ? raw.trim() : '';
53 }
54
55 /**
56 * Map a canister/self-hosted proposal into the shape precheckApprovedPathProposal expects.
57 * Returns null unless this is a learning-path proposal (source, review_queue, or mirror path).
58 *
59 * @param {Record<string, unknown>} proposal
60 * @returns {Record<string, unknown>|null}
61 */
62 export function normalizeCanisterProposalForPathPrecheck(proposal) {
63 if (!proposal || typeof proposal !== 'object') return null;
64
65 const fm = parseProposalFrontmatter(proposal.frontmatter);
66 const fromFm = fm[FM_PROPOSAL_SOURCE] === PATH_PROPOSAL_SOURCE;
67 const fromSource = proposal.source === PATH_PROPOSAL_SOURCE;
68 const fromQueue = proposal.review_queue === PATH_REVIEW_QUEUE || fm.review_queue === PATH_REVIEW_QUEUE;
69 const path =
70 typeof proposal.path === 'string' &&
71 proposal.path.replace(/^\/+/, '').startsWith('meta/learning-paths/proposals/');
72
73 if (!fromFm && !fromSource && !fromQueue && !path) return null;
74
75 let proposalKind =
76 readProposalKind(fm[FM_PATH_PROPOSAL_KIND]) ||
77 readProposalKind(fm.proposal_kind) ||
78 '';
79 if (!proposalKind) {
80 try {
81 const parsed = JSON.parse(typeof proposal.body === 'string' ? proposal.body : '');
82 if (parsed && typeof parsed === 'object' && typeof parsed.proposal_kind === 'string') {
83 proposalKind = parsed.proposal_kind.trim();
84 }
85 } catch {
86 // ignore
87 }
88 }
89
90 return {
91 ...proposal,
92 source: PATH_PROPOSAL_SOURCE,
93 review_queue: PATH_REVIEW_QUEUE,
94 frontmatter: {
95 ...fm,
96 [FM_PROPOSAL_SOURCE]: PATH_PROPOSAL_SOURCE,
97 proposal_kind: proposalKind,
98 [FM_PATH_PROPOSAL_KIND]: proposalKind,
99 },
100 };
101 }
102
103 /**
104 * Hosted hook classify: review_queue === learning-path AND kind in the closed allowlist.
105 *
106 * @param {Record<string, unknown>} proposal
107 * @returns {boolean}
108 */
109 export function isPathProposalForHostedApply(proposal) {
110 const normalized = normalizeCanisterProposalForPathPrecheck(proposal);
111 if (!normalized) return false;
112 const queue = normalized.review_queue === PATH_REVIEW_QUEUE;
113 const fm = parseProposalFrontmatter(normalized.frontmatter);
114 let kind = readProposalKind(fm.proposal_kind) || readProposalKind(fm[FM_PATH_PROPOSAL_KIND]);
115 if (!kind) {
116 try {
117 const parsed = JSON.parse(typeof normalized.body === 'string' ? normalized.body : '');
118 if (parsed && typeof parsed === 'object' && typeof parsed.proposal_kind === 'string') {
119 kind = parsed.proposal_kind.trim();
120 }
121 } catch {
122 kind = '';
123 }
124 }
125 return queue && PATH_PROPOSAL_KINDS.includes(/** @type {typeof PATH_PROPOSAL_KINDS[number]} */ (kind));
126 }
127
128 /**
129 * POST a path proposal to the canister (hosted bridge propose path).
130 *
131 * @param {{
132 * canisterUrl: string,
133 * headers: Record<string, string>,
134 * sessionBound?: boolean,
135 * input: Record<string, unknown>,
136 * }} opts
137 */
138 export async function createPathProposalOnCanister(opts) {
139 const base = String(opts.canisterUrl || '').replace(/\/$/, '');
140 if (!base) {
141 const err = new Error('CANISTER_URL required for hosted path proposals');
142 err.status = 503;
143 err.code = 'NOT_AVAILABLE';
144 throw err;
145 }
146
147 const input = opts.input;
148 const frontmatter = {
149 ...(input.frontmatter && typeof input.frontmatter === 'object' ? input.frontmatter : {}),
150 [FM_PROPOSAL_SOURCE]: PATH_PROPOSAL_SOURCE,
151 };
152 /** @type {Record<string, unknown>} */
153 const payload = {
154 path: input.path,
155 body: input.body ?? '',
156 intent: input.intent ?? '',
157 frontmatter,
158 };
159 if (input.review_queue) payload.review_queue = input.review_queue;
160 if (input.external_ref) payload.external_ref = input.external_ref;
161
162 const res = await fetch(`${base}/api/v1/proposals`, {
163 method: 'POST',
164 headers: {
165 Accept: 'application/json',
166 'Content-Type': 'application/json',
167 ...opts.headers,
168 },
169 body: JSON.stringify(payload),
170 });
171
172 const text = await res.text();
173 /** @type {Record<string, unknown>} */
174 let json = {};
175 try {
176 json = text ? JSON.parse(text) : {};
177 } catch {
178 json = {};
179 }
180
181 if (!res.ok) {
182 const err = new Error(
183 typeof json.error === 'string' ? json.error : text || `Canister proposal create ${res.status}`,
184 );
185 err.status = res.status;
186 err.code = typeof json.code === 'string' ? json.code : 'UPSTREAM_ERROR';
187 throw err;
188 }
189
190 const proposalId = typeof json.proposal_id === 'string' ? json.proposal_id : '';
191 if (!proposalId) {
192 const err = new Error('Canister proposal create missing proposal_id');
193 err.status = 502;
194 err.code = 'BAD_GATEWAY';
195 throw err;
196 }
197
198 const now = new Date().toISOString();
199 return {
200 proposal_id: proposalId,
201 path: typeof json.path === 'string' ? json.path : input.path,
202 status: typeof json.status === 'string' ? json.status : 'proposed',
203 vault_id: input.vault_id,
204 intent: input.intent,
205 body: input.body,
206 frontmatter,
207 external_ref: input.external_ref,
208 source: PATH_PROPOSAL_SOURCE,
209 review_queue: input.review_queue,
210 proposed_by: input.proposed_by,
211 created_at: now,
212 updated_at: now,
213 };
214 }
215
216 /**
217 * Fetch one proposal from the canister and normalize for path apply.
218 *
219 * @param {{
220 * canisterUrl: string,
221 * headers: Record<string, string>,
222 * proposalId: string,
223 * }} opts
224 */
225 export async function fetchCanisterProposalForPath(opts) {
226 const base = String(opts.canisterUrl || '').replace(/\/$/, '');
227 const proposalId = String(opts.proposalId || '').trim();
228 if (!base || !proposalId) {
229 return { ok: false, status: 400, code: 'BAD_REQUEST', error: 'canisterUrl and proposalId required' };
230 }
231
232 const res = await fetch(`${base}/api/v1/proposals/${encodeURIComponent(proposalId)}`, {
233 method: 'GET',
234 headers: { Accept: 'application/json', ...opts.headers },
235 });
236 const text = await res.text();
237 if (!res.ok) {
238 return {
239 ok: false,
240 status: res.status === 404 ? 404 : 502,
241 code: res.status === 404 ? 'NOT_FOUND' : 'BAD_GATEWAY',
242 error: text.slice(0, 200) || `Canister GET proposal ${res.status}`,
243 };
244 }
245
246 const raw = parseCanisterProposalGetBody(proposalId, text, {});
247 const normalized = normalizeCanisterProposalForPathPrecheck(raw);
248 if (!normalized) {
249 return { ok: false, status: 400, code: 'BAD_REQUEST', error: 'Not a path proposal' };
250 }
251 return { ok: true, proposal: normalized };
252 }
253
254 /**
255 * Apply an approved canister path proposal to bridge hub_flow_store.json.
256 *
257 * @param {{
258 * dataDir: string,
259 * canisterUrl: string,
260 * headers: Record<string, string>,
261 * proposalId: string,
262 * requireApproved?: boolean,
263 * }} opts
264 */
265 export async function applyApprovedPathProposalFromCanister(opts) {
266 if (!getPathWritesEnabled()) {
267 return {
268 ok: false,
269 status: 403,
270 code: 'PATH_WRITES_DISABLED',
271 error: 'Path writes are disabled',
272 };
273 }
274
275 const fetched = await fetchCanisterProposalForPath({
276 canisterUrl: opts.canisterUrl,
277 headers: opts.headers,
278 proposalId: opts.proposalId,
279 });
280 if (!fetched.ok) return fetched;
281
282 const proposal = fetched.proposal;
283 if (opts.requireApproved !== false && proposal.status !== 'approved') {
284 return {
285 ok: false,
286 status: 409,
287 code: 'CONFLICT',
288 error: 'Proposal must be approved before path index apply',
289 };
290 }
291
292 const precheck = precheckApprovedPathProposal(opts.dataDir, proposal);
293 if (!precheck.ok) return precheck;
294
295 const reconcile = reconcileApprovedPathProposal(opts.dataDir, precheck);
296 return {
297 ok: true,
298 payload: {
299 applied: true,
300 proposal_id: opts.proposalId,
301 vault_id: precheck.vaultId,
302 proposal_kind: precheck.proposalKind,
303 path_id: reconcile.path_id,
304 },
305 };
306 }
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago