Setting the file. One moment.
Generate Invoice Download Backend · Wix Headless Replatform · wix/skills · Skills Docs
ContentsBack to the top of the page 15.10
Workflow
— line 315
This file
Number 15.31
Position 31 of 93
Type JavaScript
Size 17 KB
Lines 342 scripts/ generate-invoice-download-backend.mjs
JavaScript · 342 lines · 17 KB
// can read that order, and resolves the private file id server-side. If it accepted a file id
12 // from the client, any member could ask for another member's tax invoice by guessing or
13 // harvesting one — and an invoice carries a name, an address and a purchase history.
14 //
15 // The preserved document is a PRIVATE Media file, so it is not reachable by URL without a
16 // generated download link. That link is minted per request with an explicit short expiry: the
17 // API's own default is 600 minutes, which is not short-lived for a tax document.
18 //
19 // The field names are read from the migration's own contract at GENERATION time rather than
20 // written out here. Two copies of a field name is exactly the drift the contract adapter exists
21 // to prevent, and this generator is the third consumer of it.
22 const require = createRequire ( import . meta .url);
23 const invoiceContract = require ( "../../wix-replatform/resources/rp-target-wix/lib/order-invoice-contract.js" );
24
25 // The scaffold ships @wix/sdk, @wix/astro and @wix/essentials, and nothing else. Without these
26 // the generated route imports modules that are not installed and the build fails — which is
27 // better than the previous behaviour, where it built fine and answered 401 forever.
28 export const REQUIRED_DEPENDENCIES = Object. freeze ({
29 "@wix/ecom" : "^1.0.0" ,
30 "@wix/media" : "^1.0.0" ,
31 "@wix/members" : "^1.0.0" ,
32 "@wix/essentials" : "^1.0.6" ,
33 });
34
35 function handlerSource ({ fileIdField , numberField , contractVersion }) {
36 return `// GENERATED by generate-invoice-download-backend.mjs — do not edit by hand.
37 // Field names generated from order-invoice contract ${ contractVersion }.
38 //
39 // Hands a signed-in member a short-lived download link for the invoice document preserved on one
40 // of THEIR orders.
41 //
42 // The security contract, in order:
43 // 1. A client-supplied file id is REJECTED outright. The client names an order; the server
44 // resolves the file. Accepting a file id would let any member fetch any member's invoice.
45 // 2. The member must be authenticated.
46 // 3. The order must be readable BY THAT MEMBER. Order scoping is the authorization boundary.
47 // 4. The file id is read from the order's own extended fields, server-side.
48 // 5. The download URL is minted with an explicit short expiry and is never logged.
49
50 export const INVOICE_FILE_FIELD = ${ JSON . stringify ( fileIdField ) };
51 export const INVOICE_NUMBER_FIELD = ${ JSON . stringify ( numberField ) };
52 export const DOWNLOAD_EXPIRY_MINUTES = 10;
53
54 export function createInvoiceDownloadHandler({ getMemberId, getOrderForMember, generateDownloadUrl }) {
55 if (typeof getMemberId !== "function") throw new Error("getMemberId is required");
56 if (typeof getOrderForMember !== "function") throw new Error("getOrderForMember is required");
57 if (typeof generateDownloadUrl !== "function") throw new Error("generateDownloadUrl is required");
58
59 return async function handleInvoiceDownload(request = {}) {
60 try {
61 return await respond(request);
62 } catch (error) {
63 // A dependency being down is not a refusal. Reported without detail — the member learns
64 // nothing about the order either way — but with a status a client may retry and monitoring
65 // can tell apart from a denial.
66 if (error && error.transient === true) return { status: 503, error: "service-temporarily-unavailable" };
67 throw error;
68 }
69 };
70
71 async function respond(request) {
72 const body = request.body && typeof request.body === "object" ? request.body : {};
73
74 // 1. The client may not name a file. Any attempt is refused rather than ignored, so a
75 // misbuilt caller fails loudly instead of silently working for the wrong reason.
76 for (const forbidden of ["fileId", "file_id", "fileID", "mediaId", "media_id"]) {
77 if (Object.prototype.hasOwnProperty.call(body, forbidden)) {
78 return { status: 400, error: "client-supplied-file-id-rejected" };
79 }
80 }
81
82 const orderId = typeof body.orderId === "string" ? body.orderId.trim() : "";
83 if (!orderId) return { status: 400, error: "order-id-required" };
84
85 // 2. Authentication.
86 const memberId = await getMemberId(request);
87 if (!memberId) return { status: 401, error: "authentication-required" };
88
89 // 3. Authorization is the order read: if this member cannot read this order, there is nothing
90 // to talk about. Never fall back to an unscoped order read.
91 const order = await getOrderForMember({ orderId, memberId });
92 if (!order) return { status: 403, error: "order-not-readable-by-member" };
93
94 // 4. Resolve the file server-side.
95 const namespaces = (order.extendedFields && order.extendedFields.namespaces) || {};
96 const fields = namespaces._user_fields || {};
97 const fileId = typeof fields[INVOICE_FILE_FIELD] === "string" ? fields[INVOICE_FILE_FIELD].trim() : "";
98 if (!fileId) return { status: 404, error: "no-preserved-invoice-for-order" };
99
100 // 5. Mint a short-lived link. The URL is returned to the caller and never logged.
101 const url = await generateDownloadUrl({ fileId, expirationInMinutes: DOWNLOAD_EXPIRY_MINUTES });
102 if (!url) return { status: 502, error: "download-url-unavailable" };
103
104 return {
105 status: 200,
106 url,
107 expiresInMinutes: DOWNLOAD_EXPIRY_MINUTES,
108 // The invoice number is the merchant's own reference and is safe to show. The file id is
109 // internal and is deliberately not echoed.
110 invoiceNumber: typeof fields[INVOICE_NUMBER_FIELD] === "string" ? fields[INVOICE_NUMBER_FIELD] : null,
111 };
112 }
113 }
114 ` ;
115 }
116
117 // The authorization logic, with no Wix import in it. Split out from the adapters deliberately:
118 // a module that statically imports @wix/ecom cannot be loaded by a test without the whole SDK
119 // installed, and "we could not test it" is how the previous version of this backend shipped a
120 // seam that answered 401 to everything. What is left in the adapters file is wiring with no
121 // branches in it.
122 const AUTHORIZATION_SOURCE = `// GENERATED by generate-invoice-download-backend.mjs — do not edit by hand.
123 //
124 // Who may download which invoice. No Wix imports here on purpose, so this file can be run and
125 // tested on its own; the adapters supply the four functions that talk to Wix.
126 //
127 // Two facts shape it, both from the API docs rather than assumed:
128 //
129 // 1. Get Order called with a MEMBER's identity returns the order only if that member placed it.
130 // That check is Wix's, not ours, and it is the authorization boundary.
131 // 2. The migration's invoice fields are declared readable by \` apps \` and \` users \` — deliberately
132 // NOT by \` users-of-users \` , because they are merchant data. So the member-identity read that
133 // proves ownership does NOT return the field values, and a second, elevated read is needed
134 // to get them.
135 //
136 // So ownership is established by the unelevated read, and only then is anything elevated. The
137 // rule is then checked a SECOND time, by us, against the elevated order's buyerInfo.
138 //
139 // Be precise about what that second check is worth. It is a second IMPLEMENTATION, not a second
140 // source of evidence: both it and Wix's check rest on the same stored \` buyerInfo.memberId \` . It
141 // catches an elevated read that answers about the wrong order, or a change in what elevation
142 // returns. It does NOT catch an order attributed to the wrong member in the first place -- if the
143 // import writes Alice's order with Bob's memberId, both checks agree and Bob gets Alice's
144 // invoice. Correct \` buyerInfo.memberId \` on imported orders is a PREREQUISITE of this feature,
145 // not something it can verify. An order carrying no memberId fails closed.
146
147 // The SDK renames \` id \` to \` _id \` . Read both rather than pick one and be wrong on a version bump.
148 export function orderIdOf(order) {
149 if (!order || typeof order !== "object") return null;
150 const value = order._id ?? order.id;
151 return typeof value === "string" && value.length > 0 ? value : null;
152 }
153
154 // Not every failure is a refusal. Collapsing them told a member "you are not allowed" during a
155 // Wix outage, and left monitoring unable to tell a denial from a dependency being down.
156 //
157 // The Wix SDK throws \` SDKError \` carrying \` response.status \` . A 401, 403 or 404 is a real answer
158 // to the question asked, and becomes \` null \` — indistinguishable, on purpose, so a prober cannot
159 // learn from the difference between "not yours" and "does not exist". Anything else, including a
160 // 5xx and a network error with no status at all, is us failing rather than the caller being
161 // refused, and is raised so the route can say so.
162 const DENIAL_STATUSES = new Set([401, 403, 404]);
163
164 export class InvoiceBackendUnavailable extends Error {
165 constructor(status) {
166 super( \` the invoice backend could not reach Wix (status \$ {status ?? "unknown"}) \` );
167 this.name = "InvoiceBackendUnavailable";
168 this.transient = true;
169 this.status = status ?? null;
170 }
171 }
172
173 async function orNull(operation) {
174 try {
175 return (await operation()) ?? null;
176 } catch (error) {
177 const status = error && error.response && error.response.status;
178 if (DENIAL_STATUSES.has(status)) return null;
179 throw new InvoiceBackendUnavailable(status);
180 }
181 }
182
183 export function createInvoiceAdapters({
184 getCurrentMember,
185 getOrderAsCaller,
186 getOrderElevated,
187 generateDownloadUrlElevated,
188 }) {
189 for (const [name, fn] of Object.entries({ getCurrentMember, getOrderAsCaller, getOrderElevated, generateDownloadUrlElevated })) {
190 if (typeof fn !== "function") throw new Error( \`\$ {name} is required \` );
191 }
192
193 return {
194 async getMemberId() {
195 // \` getCurrentMember() \` resolves to \` { member } \` , NOT to the member. The docs example shows
196 // a bare member object and is wrong; the published type is
197 // \` Promise<GetMyMemberResponse> \` with \` member?: Member \` . Reading \` _id \` off the response
198 // made every signed-in member resolve as anonymous, so the route answered 401 to everyone
199 // — the same failure as the \` locals \` seam it replaced, one layer in.
200 //
201 // Both shapes are accepted: the wrapper is what ships, the bare member is what the docs
202 // promise, and being wrong about which one arrives must not lock members out again.
203 const response = await orNull(getCurrentMember);
204 const member = (response && response.member) || response;
205 const id = member && (member._id ?? member.id);
206 return typeof id === "string" && id.length > 0 ? id : null;
207 },
208
209 async getOrderForMember({ orderId, memberId }) {
210 if (typeof orderId !== "string" || orderId.length === 0) return null;
211 if (typeof memberId !== "string" || memberId.length === 0) return null;
212
213 // 1. Wix's check. Nothing is elevated until this passes.
214 const asCaller = await orNull(() => getOrderAsCaller(orderId));
215 if (!asCaller || orderIdOf(asCaller) !== orderId) return null;
216
217 // 2. Our own read, elevated, because the fields are not member-readable.
218 const elevated = await orNull(() => getOrderElevated(orderId));
219 if (!elevated || orderIdOf(elevated) !== orderId) return null;
220
221 // 3. Our check, independent of Wix's. An order whose buyer is not this member is not this
222 // member's order, and an order with no member buyer at all belongs to nobody who can ask
223 // for it here. Email is never accepted as proof: it is changeable and guessable.
224 const buyerMemberId = elevated.buyerInfo && elevated.buyerInfo.memberId;
225 if (typeof buyerMemberId !== "string" || buyerMemberId !== memberId) return null;
226
227 return elevated;
228 },
229
230 async generateDownloadUrl({ fileId, expirationInMinutes }) {
231 if (typeof fileId !== "string" || fileId.length === 0) return null;
232 const result = await orNull(() => generateDownloadUrlElevated(fileId, { expirationInMinutes }));
233 const first = result && Array.isArray(result.downloadUrls) ? result.downloadUrls[0] : null;
234 const url = first && first.url;
235 return typeof url === "string" && url.length > 0 ? url : null;
236 },
237 };
238 }
239 ` ;
240
241 const ADAPTERS_SOURCE = `// GENERATED by generate-invoice-download-backend.mjs — do not edit by hand.
242 //
243 // Wiring only. Every decision lives in invoice-download-authorization.mjs; this file exists to
244 // name the four Wix calls, and there is deliberately no logic in it to get wrong.
245 import { members } from "@wix/members";
246 import { orders } from "@wix/ecom";
247 import { files } from "@wix/media";
248 import { auth } from "@wix/essentials";
249 import { createInvoiceAdapters } from "./invoice-download-authorization.mjs";
250
251 export const wixInvoiceAdapters = createInvoiceAdapters({
252 getCurrentMember: () => members.getCurrentMember(),
253 // Unelevated ON PURPOSE. This call running as the caller is the whole authorization mechanism.
254 getOrderAsCaller: (id) => orders.getOrder(id),
255 getOrderElevated: (id) => auth.elevate(orders.getOrder)(id),
256 generateDownloadUrlElevated: (fileId, options) => auth.elevate(files.generateFileDownloadUrl)(fileId, options),
257 });
258 ` ;
259
260 const ROUTE_SOURCE = `// GENERATED by generate-invoice-download-backend.mjs — do not edit by hand.
261 //
262 // Thin transport wrapper. All authorization lives in the handler and the adapters; this file only
263 // moves JSON.
264 import { createInvoiceDownloadHandler } from "../../lib/invoice-download.mjs";
265 import { wixInvoiceAdapters } from "../../lib/invoice-download-adapters.mjs";
266
267 const handler = createInvoiceDownloadHandler(wixInvoiceAdapters);
268
269 export async function POST({ request }) {
270 let body = {};
271 try {
272 body = await request.json();
273 } catch {
274 return new Response(JSON.stringify({ error: "invalid-json" }), { status: 400, headers: { "content-type": "application/json" } });
275 }
276
277 const result = await handler({ body });
278 const { status, ...payload } = result;
279 return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } });
280 }
281 ` ;
282
283 export function buildHandlerSource () {
284 return handlerSource ({
285 fileIdField: invoiceContract. ROLES .fileId,
286 numberField: invoiceContract. ROLES .number,
287 contractVersion: invoiceContract. version (),
288 });
289 }
290
291 // Adds what the generated route imports. Merges rather than overwrites: the scaffold owns its own
292 // versions, and a project that already pins one of these keeps its pin.
293 export async function ensureInvoiceDependencies ({ outputDir }) {
294 const packagePath = path. join (outputDir, "package.json" );
295 let manifest;
296 try {
297 manifest = await readJson (packagePath);
298 } catch {
299 return { packagePath, added: [], skipped: "no package.json at the output dir" };
300 }
301 const dependencies = manifest.dependencies || {};
302 const added = [];
303 for ( const [ name , version ] of Object. entries ( REQUIRED_DEPENDENCIES )) {
304 if ( ! dependencies[name]) {
305 dependencies[name] = version;
306 added. push (name);
307 }
308 }
309 if (added. length === 0 ) return { packagePath, added };
310 manifest.dependencies = dependencies;
311 await writeJson (packagePath, manifest);
312 return { packagePath, added };
313 }
314
315 export async function generateInvoiceDownloadBackend ({ outputDir }) {
316 const handlerPath = path. join (outputDir, "src" , "lib" , "invoice-download.mjs" );
317 const authorizationPath = path. join (outputDir, "src" , "lib" , "invoice-download-authorization.mjs" );
318 const adaptersPath = path. join (outputDir, "src" , "lib" , "invoice-download-adapters.mjs" );
319 const routePath = path. join (outputDir, "src" , "pages" , "api" , "invoice-download.mjs" );
320 await writeText (handlerPath, HANDLER_SOURCE );
321 await writeText (authorizationPath, AUTHORIZATION_SOURCE );
322 await writeText (adaptersPath, ADAPTERS_SOURCE );
323 await writeText (routePath, ROUTE_SOURCE );
324 const dependencies = await ensureInvoiceDependencies ({ outputDir });
325 return { handlerPath, authorizationPath, adaptersPath, routePath, dependencies };
326 }
327
328 async function main () {
329 const args = parseArgs ();
330 const outputDir = resolveOutputDir (args._[ 0 ] || args.url, args.out);
331 const written = await generateInvoiceDownloadBackend ({ outputDir });
332 if (args.json) process.stdout. write ( `${ JSON . stringify ( written , null , 2 ) } \n ` );
333 }
334
335 // Computed once so consumers -- the tests included -- read exactly the bytes that get written.
336 export const HANDLER_SOURCE = buildHandlerSource ();
337
338 export { AUTHORIZATION_SOURCE, ADAPTERS_SOURCE, ROUTE_SOURCE };
339
340 if ( import . meta .url === `file://${ process . argv [ 1 ] }` ) {
341 await main ();
342 }