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