capture-approve-hosted.mjs
128 lines 4.1 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 11 days ago
1 /**
2 * Gateway hook: after canister proposal approve, apply bridge capture indexes
3 * (CAPTURE-HOSTED-APPLY-KN-b / CHA-C1 — parity with task/delegation hooks).
4 *
5 * Hosted canister approve commits BEFORE this hook runs (CHA-C11): a precheck or
6 * apply failure after a successful approve yields an approved proposal with no
7 * Flow, surfaced honestly as `capture_index_applied: false`. Ops may re-call the
8 * bridge apply-approved route after fixing store state.
9 *
10 * T5 / personal self-apply stays refuse-all for capture (SD-23) — this hook only
11 * runs after `assertHostedProposalApproveDiscard` allowed the approve.
12 */
13
14 import { proposalIdFromApprovePath } from '../../lib/muse-thin-bridge.mjs';
15 import {
16 fetchCanisterProposalForCapture,
17 normalizeCanisterProposalForCapturePrecheck,
18 } from '../../lib/flow/flow-capture-hosted-proposal.mjs';
19
20 const PROPOSAL_APPROVE_RE = /^\/api\/v1\/proposals\/[^/]+\/approve\/?$/;
21
22 /**
23 * @param {Record<string, unknown>} proposal
24 * @returns {boolean}
25 */
26 function isCaptureProposal(proposal) {
27 return normalizeCanisterProposalForCapturePrecheck(proposal) != null;
28 }
29
30 /**
31 * @param {{
32 * method: string,
33 * pathOnly: string,
34 * upstreamStatus: number,
35 * canisterUrl: string,
36 * bridgeUrl: string,
37 * authorization: string|undefined,
38 * vaultId: string,
39 * effectiveUserId: string,
40 * actorUserId: string,
41 * canisterAuthHeaders: () => Record<string, string>,
42 * }} ctx
43 * @returns {Promise<{ applied: boolean, error?: string, code?: string, payload?: Record<string, unknown> }|null>}
44 */
45 export async function maybeApplyHostedCaptureAfterApprove(ctx) {
46 if (ctx.method !== 'POST' || !PROPOSAL_APPROVE_RE.test(ctx.pathOnly)) return null;
47 if (ctx.upstreamStatus < 200 || ctx.upstreamStatus >= 300) return null;
48
49 const proposalId = proposalIdFromApprovePath(ctx.pathOnly);
50 if (!proposalId || !ctx.bridgeUrl || !ctx.canisterUrl) return null;
51
52 const headers = {
53 ...ctx.canisterAuthHeaders(),
54 'X-User-Id': ctx.effectiveUserId,
55 'X-Actor-Id': ctx.actorUserId,
56 'X-Vault-Id': ctx.vaultId,
57 };
58
59 const fetched = await fetchCanisterProposalForCapture({
60 canisterUrl: ctx.canisterUrl,
61 headers,
62 proposalId,
63 });
64 if (!fetched.ok) return null;
65 if (!isCaptureProposal(fetched.proposal)) return null;
66
67 const bridgeRes = await fetch(
68 `${ctx.bridgeUrl.replace(/\/$/, '')}/api/v1/flows/capture/proposals/${encodeURIComponent(proposalId)}/apply-approved`,
69 {
70 method: 'POST',
71 headers: {
72 Accept: 'application/json',
73 'Content-Type': 'application/json',
74 ...(ctx.authorization ? { Authorization: ctx.authorization } : {}),
75 'X-Vault-Id': ctx.vaultId,
76 },
77 body: JSON.stringify({}),
78 },
79 );
80
81 const text = await bridgeRes.text();
82 /** @type {Record<string, unknown>} */
83 let json = {};
84 try {
85 json = text ? JSON.parse(text) : {};
86 } catch {
87 json = {};
88 }
89
90 if (!bridgeRes.ok) {
91 return {
92 applied: false,
93 error: typeof json.error === 'string' ? json.error : text.slice(0, 200),
94 code: typeof json.code === 'string' ? json.code : 'CAPTURE_APPLY_FAILED',
95 };
96 }
97
98 return {
99 applied: true,
100 payload: json && typeof json === 'object' ? /** @type {Record<string, unknown>} */ (json) : {},
101 };
102 }
103
104 /**
105 * Merge capture apply outcome into canister approve JSON for the Hub client (CHA-C1).
106 *
107 * @param {string} responseText
108 * @param {{ applied: boolean, error?: string, code?: string, payload?: Record<string, unknown> }|null} applyOutcome
109 * @returns {string}
110 */
111 export function mergeCaptureApplyIntoApproveResponse(responseText, applyOutcome) {
112 if (!applyOutcome) return responseText;
113 try {
114 const body = JSON.parse(responseText);
115 if (!body || typeof body !== 'object') return responseText;
116 body.capture_index_applied = applyOutcome.applied;
117 if (applyOutcome.applied && applyOutcome.payload) {
118 body.capture_apply = applyOutcome.payload;
119 }
120 if (!applyOutcome.applied) {
121 body.capture_apply_error = applyOutcome.error ?? 'Capture apply failed';
122 body.capture_apply_code = applyOutcome.code ?? 'CAPTURE_APPLY_FAILED';
123 }
124 return JSON.stringify(body);
125 } catch {
126 return responseText;
127 }
128 }
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 11 days ago