@fire-spark/cli 0.13.2 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,410 @@
1
+ import type { Copy } from "../../../core/i18n";
2
+ import { fromLive, isOutcome, isRecord, openLive, prepare } from "./flow";
3
+ import { queryOf, readPage, say, textOf, withSafeFix } from "./flow";
4
+ import { readSources } from "./repo.scan";
5
+ import type { CheckBinding, Outcome, RunCtx } from "./runner";
6
+ import type { YamlChannel } from "./yaml";
7
+
8
+ const line = (es: string, en: string, pt: string): Copy => {
9
+ return { "es-EC": es, "en-US": en, "pt-BR": pt };
10
+ };
11
+
12
+ const COPY = {
13
+ channel: line(
14
+ "{id} no aplica al canal {template}.",
15
+ "{id} does not apply to the {template} channel.",
16
+ "{id} não se aplica ao canal {template}."
17
+ ),
18
+ list: line(
19
+ "El menú no es una lista.",
20
+ "The menu is not a list.",
21
+ "O cardápio não é uma lista."
22
+ ),
23
+ need: line(
24
+ "{id} se omite: falta {field}.",
25
+ "{id} is skipped: {field} is missing.",
26
+ "{id} é omitido: falta {field}."
27
+ ),
28
+ shape: line(
29
+ "La respuesta no tiene la forma esperada.",
30
+ "The response does not have the expected shape.",
31
+ "A resposta não tem a forma esperada."
32
+ ),
33
+ page: line(
34
+ "La página publicada respondió {status}.",
35
+ "The published page returned {status}.",
36
+ "A página publicada respondeu {status}."
37
+ ),
38
+ biometric: line(
39
+ "El repo del canal no importa el desbloqueo biométrico.",
40
+ "The channel repo does not import biometric unlock.",
41
+ "O repo do canal não importa o desbloqueio biométrico."
42
+ ),
43
+ handoff: line(
44
+ "La página publicada no muestra un enlace de handoff.",
45
+ "The published page does not show a handoff link.",
46
+ "A página publicada não mostra um link de handoff."
47
+ ),
48
+ guard: line(
49
+ "Una orden sin un token válido no respondió 401.",
50
+ "An order request without a valid token did not return 401.",
51
+ "Um pedido sem um token válido não respondeu 401."
52
+ ),
53
+ orders: line(
54
+ "El listado de órdenes del cliente identificado no es una lista.",
55
+ "The identified customer's order list is not a list.",
56
+ "A lista de pedidos do cliente identificado não é uma lista."
57
+ )
58
+ };
59
+
60
+ const LIVE = [
61
+ "api_url",
62
+ "channel_id",
63
+ "FIRESPARK_DOCTOR_CUSTOMER_EMAIL",
64
+ "FIRESPARK_DOCTOR_CUSTOMER_PASSWORD",
65
+ "FIRESPARK_DOCTOR_FIREBASE_API_KEY"
66
+ ] as const;
67
+
68
+ const applies = (ctx: RunCtx, id: string, allowed: ReadonlySet<string>) => {
69
+ const ready = prepare(ctx, ["channel_id"]);
70
+ if (isOutcome(ready)) return ready;
71
+ const template = textOf(ready.template);
72
+ if (allowed.has(template)) return null;
73
+ return say(ctx.locale, "skip", COPY.channel, { id, template });
74
+ };
75
+
76
+ const missingEnv = (ctx: RunCtx, id: string, field: string) => {
77
+ return say(ctx.locale, "skip", COPY.need, { id, field });
78
+ };
79
+
80
+ const firstGap = (env: NodeJS.ProcessEnv, fields: readonly string[]) => {
81
+ for (const field of fields) {
82
+ if (!textOf(env[field])) return field;
83
+ }
84
+ return "";
85
+ };
86
+
87
+ const DEVICE = [
88
+ "FIRESPARK_DOCTOR_DEVICE_UID",
89
+ "FIRESPARK_DOCTOR_DEVICE_SECRET"
90
+ ] as const;
91
+
92
+ const menusOf = async (ctx: RunCtx, channel: YamlChannel) => {
93
+ const live = openLive(ctx, channel);
94
+ const path = queryOf("/menus", { channel_id: textOf(channel.channel_id) });
95
+ const codes = [200];
96
+ const args = ["menus", "GET", path, codes] as const;
97
+ const step = await live.storefront<unknown>(...args);
98
+ if (!step.ok) return fromLive(ctx.locale, step.result);
99
+ if (!Array.isArray(step.value)) return say(ctx.locale, "fail", COPY.list);
100
+ return { live };
101
+ };
102
+
103
+ const ordersUrl = (channel: YamlChannel) => {
104
+ const root = textOf(channel.api_url).replace(/\/$/, "");
105
+ return `${root}/api/storefront/v1/orders`;
106
+ };
107
+
108
+ const statusOf = async (
109
+ url: string,
110
+ authorization?: string,
111
+ method = "GET",
112
+ body?: string
113
+ ) => {
114
+ const headers: Record<string, string> = {};
115
+ if (authorization) headers.authorization = authorization;
116
+ if (body !== undefined) headers["content-type"] = "application/json";
117
+ try {
118
+ const signal = AbortSignal.timeout(15000);
119
+ const response = await fetch(url, { method, headers, body, signal });
120
+ return response.status;
121
+ } catch {
122
+ return 0;
123
+ }
124
+ };
125
+
126
+ const pageOf = async (url: string) => {
127
+ try {
128
+ return await readPage(url);
129
+ } catch {
130
+ return { status: 0, body: "" };
131
+ }
132
+ };
133
+
134
+ const runLog02 = async (ctx: RunCtx): Promise<Outcome> => {
135
+ const skipped = applies(ctx, "LOG-02", new Set(["kiosk", "web", "app"]));
136
+ if (skipped) return skipped;
137
+ const ready = prepare(ctx, [...LIVE, "url"]);
138
+ if (isOutcome(ready)) return ready;
139
+ const loaded = await menusOf(ctx, ready);
140
+ if (isOutcome(loaded)) return loaded;
141
+ const codes = [200];
142
+ const args = ["customer", "GET", "/customers/me", codes] as const;
143
+ const step = await loaded.live.storefront<unknown>(...args);
144
+ if (!step.ok) return fromLive(ctx.locale, step.result);
145
+ if (!isRecord(step.value)) return say(ctx.locale, "fail", COPY.shape);
146
+ const email = step.value.email;
147
+ if (typeof email !== "string" || !email.includes("@")) {
148
+ return say(ctx.locale, "fail", COPY.shape);
149
+ }
150
+ const files = await readSources(ctx.cwd);
151
+ const google = files.find(file => file.body.includes("GoogleSignIn"));
152
+ if (
153
+ google &&
154
+ !google.body.includes("clientId") &&
155
+ !google.body.includes("webClientId")
156
+ ) {
157
+ return say(ctx.locale, "fail", COPY.shape);
158
+ }
159
+ return { status: "pass" };
160
+ };
161
+
162
+ const runLog05 = async (ctx: RunCtx): Promise<Outcome> => {
163
+ const skipped = applies(ctx, "LOG-05", new Set(["pos"]));
164
+ if (skipped) return skipped;
165
+ const files = await readSources(ctx.cwd);
166
+ const found = files.some(file => {
167
+ if (file.body.includes("expo-local-authentication")) return true;
168
+ return file.body.includes("LocalAuthentication");
169
+ });
170
+ if (!found) return say(ctx.locale, "fail", COPY.biometric);
171
+ return { status: "pass" };
172
+ };
173
+
174
+ const postJson = async (url: string, body: unknown) => {
175
+ const headers = { "content-type": "application/json" };
176
+ const payload = JSON.stringify(body);
177
+ const signal = AbortSignal.timeout(15000);
178
+ const init = { method: "POST", headers, body: payload, signal };
179
+ const response = await fetch(url, init);
180
+ const json = (await response.json()) as Record<string, unknown>;
181
+ return { status: response.status, json };
182
+ };
183
+
184
+ const guestMenus = async (ctx: RunCtx, channel: YamlChannel) => {
185
+ const auth = textOf(ctx.env.FIRESPARK_DOCTOR_FIREBASE_AUTH_URL);
186
+ const key = textOf(ctx.env.FIRESPARK_DOCTOR_FIREBASE_API_KEY);
187
+ if (!auth || !key) {
188
+ return missingEnv(ctx, "LOG-08", "FIRESPARK_DOCTOR_FIREBASE_AUTH_URL");
189
+ }
190
+ const api = textOf(channel.api_url).replace(/\/$/, "");
191
+ const signUp = `${auth.replace(/\/$/, "")}/v1/accounts:signUp?key=${key}`;
192
+ try {
193
+ const created = await postJson(signUp, { returnSecureToken: true });
194
+ const idToken = created.json.idToken;
195
+ if (created.status !== 200 || typeof idToken !== "string" || !idToken) {
196
+ return say(ctx.locale, "fail", COPY.shape);
197
+ }
198
+ const grant = "urn:ietf:params:oauth:grant-type:token-exchange";
199
+ const subject = "urn:ietf:params:oauth:token-type:id_token";
200
+ const exchange = {
201
+ grant_type: grant,
202
+ subject_token: idToken,
203
+ subject_token_type: subject
204
+ };
205
+ const exchangeUrl = `${api}/api/storefront/v1/oauth/exchange`;
206
+ const traded = await postJson(exchangeUrl, exchange);
207
+ const bag = traded.json.data;
208
+ const nested = isRecord(bag) ? bag.access_token : undefined;
209
+ const token = traded.json.access_token ?? nested;
210
+ if (traded.status !== 200 || typeof token !== "string" || !token) {
211
+ return say(ctx.locale, "fail", COPY.shape);
212
+ }
213
+ const channelId = textOf(channel.channel_id);
214
+ const menus = `${api}/api/storefront/v1/menus?channel_id=${channelId}`;
215
+ const header = `Bearer ${token}`;
216
+ const status = await statusOf(menus, header);
217
+ if (status !== 200) return say(ctx.locale, "fail", COPY.guard);
218
+ return { status: "pass" as const };
219
+ } catch {
220
+ return say(ctx.locale, "fail", COPY.shape);
221
+ }
222
+ };
223
+
224
+ const runLog08 = async (ctx: RunCtx): Promise<Outcome> => {
225
+ const skipped = applies(ctx, "LOG-08", new Set(["kiosk", "web", "app"]));
226
+ if (skipped) return skipped;
227
+ const ready = prepare(ctx, LIVE);
228
+ if (isOutcome(ready)) return ready;
229
+ const guest = await guestMenus(ctx, ready);
230
+ if (guest.status !== "pass") return guest;
231
+ const denied = await statusOf(ordersUrl(ready));
232
+ if (denied !== 401) return say(ctx.locale, "fail", COPY.guard);
233
+ return { status: "pass" };
234
+ };
235
+
236
+ const runLog09 = async (ctx: RunCtx): Promise<Outcome> => {
237
+ const skipped = applies(ctx, "LOG-09", new Set(["web", "app"]));
238
+ if (skipped) return skipped;
239
+ const ready = prepare(ctx, [...LIVE, "url"]);
240
+ if (isOutcome(ready)) return ready;
241
+ const page = await pageOf(textOf(ready.url));
242
+ if (page.status === 0 || page.status >= 400) {
243
+ return say(ctx.locale, "fail", COPY.page, { status: page.status });
244
+ }
245
+ const lower = page.body.toLowerCase();
246
+ const login = lower.includes("iniciar sesión") || lower.includes("login");
247
+ if (!login) return say(ctx.locale, "fail", COPY.guard);
248
+ const loaded = await menusOf(ctx, ready);
249
+ if (isOutcome(loaded)) return loaded;
250
+ const codes = [200];
251
+ const args = ["orders", "GET", "/orders", codes] as const;
252
+ const step = await loaded.live.storefront<unknown>(...args);
253
+ if (!step.ok) return fromLive(ctx.locale, step.result);
254
+ if (!Array.isArray(step.value)) return say(ctx.locale, "fail", COPY.orders);
255
+ return { status: "pass" };
256
+ };
257
+
258
+ const runLog10 = async (ctx: RunCtx): Promise<Outcome> => {
259
+ const allowed = new Set(["kiosk", "web", "app", "chat", "call-center"]);
260
+ const skipped = applies(ctx, "LOG-10", allowed);
261
+ if (skipped) return skipped;
262
+ const ready = prepare(ctx, [...LIVE, "url"]);
263
+ if (isOutcome(ready)) return ready;
264
+ const loaded = await menusOf(ctx, ready);
265
+ if (isOutcome(loaded)) return loaded;
266
+ const codes = [200];
267
+ const me = await loaded.live.storefront<unknown>(
268
+ "customer",
269
+ "GET",
270
+ "/customers/me",
271
+ codes
272
+ );
273
+ if (!me.ok) return fromLive(ctx.locale, me.result);
274
+ const root = textOf(ready.url).replace(/\/$/, "");
275
+ const cleared = await statusOf(
276
+ `${root}/api/auth/session`,
277
+ undefined,
278
+ "DELETE"
279
+ );
280
+ if (cleared < 200 || cleared >= 300)
281
+ return say(ctx.locale, "fail", COPY.guard);
282
+ const denied = await statusOf(ordersUrl(ready));
283
+ if (denied !== 401) return say(ctx.locale, "fail", COPY.guard);
284
+ return { status: "pass" };
285
+ };
286
+
287
+ const runLog01 = async (ctx: RunCtx): Promise<Outcome> => {
288
+ const skipped = applies(ctx, "LOG-01", new Set(["dashboard"]));
289
+ if (skipped) return skipped;
290
+ const ready = prepare(ctx, LIVE);
291
+ if (isOutcome(ready)) return ready;
292
+ const denied = await statusOf(ordersUrl(ready));
293
+ if (denied !== 401) return say(ctx.locale, "fail", COPY.guard);
294
+ const loaded = await menusOf(ctx, ready);
295
+ if (isOutcome(loaded)) return loaded;
296
+ const codes = [200];
297
+ const args = ["orders", "GET", "/orders", codes] as const;
298
+ const step = await loaded.live.storefront<unknown>(...args);
299
+ if (!step.ok) return fromLive(ctx.locale, step.result);
300
+ const token = textOf(ctx.env.FIRESPARK_DOCTOR_READONLY_TOKEN);
301
+ if (!token) {
302
+ return missingEnv(ctx, "LOG-01", "FIRESPARK_DOCTOR_READONLY_TOKEN");
303
+ }
304
+ const header = `Bearer ${token}`;
305
+ const wrote = await statusOf(ordersUrl(ready), header, "POST", "{}");
306
+ if (wrote !== 401 && wrote !== 403) {
307
+ return say(ctx.locale, "fail", COPY.guard);
308
+ }
309
+ return { status: "pass" };
310
+ };
311
+
312
+ const runLog03 = async (ctx: RunCtx): Promise<Outcome> => {
313
+ const skipped = applies(ctx, "LOG-03", new Set(["kiosk", "dashboard"]));
314
+ if (skipped) return skipped;
315
+ const ready = prepare(ctx, LIVE);
316
+ if (isOutcome(ready)) return ready;
317
+ const field = firstGap(ctx.env, DEVICE);
318
+ if (field) return missingEnv(ctx, "LOG-03", field);
319
+ const live = openLive(ctx, ready);
320
+ const device_uid = textOf(ctx.env.FIRESPARK_DOCTOR_DEVICE_UID);
321
+ const device_secret = textOf(ctx.env.FIRESPARK_DOCTOR_DEVICE_SECRET);
322
+ const version = { app_version_code: 1, app_version_name: "doctor" };
323
+ const body = { device_uid, device_secret, ...version };
324
+ const codes = [200];
325
+ const args = ["kiosk-me", "POST", "/kiosk/devices/me", codes, body] as const;
326
+ const step = await live.probe(...args);
327
+ if (!step.ok) return fromLive(ctx.locale, step.result);
328
+ if (!isRecord(step.value.json)) return say(ctx.locale, "fail", COPY.shape);
329
+ return { status: "pass" };
330
+ };
331
+
332
+ const runLog04 = async (ctx: RunCtx): Promise<Outcome> => {
333
+ const skipped = applies(ctx, "LOG-04", new Set(["pos", "dashboard"]));
334
+ if (skipped) return skipped;
335
+ const ready = prepare(ctx, LIVE);
336
+ if (isOutcome(ready)) return ready;
337
+ const pinFields = [...DEVICE, "FIRESPARK_DOCTOR_OPERATOR_ID"] as const;
338
+ const more = [...pinFields, "FIRESPARK_DOCTOR_OPERATOR_PIN"] as const;
339
+ const field = firstGap(ctx.env, more);
340
+ if (field) return missingEnv(ctx, "LOG-04", field);
341
+ const live = openLive(ctx, ready);
342
+ const device_uid = textOf(ctx.env.FIRESPARK_DOCTOR_DEVICE_UID);
343
+ const device_secret = textOf(ctx.env.FIRESPARK_DOCTOR_DEVICE_SECRET);
344
+ const operator_id = textOf(ctx.env.FIRESPARK_DOCTOR_OPERATOR_ID);
345
+ const pin = textOf(ctx.env.FIRESPARK_DOCTOR_OPERATOR_PIN);
346
+ const body = { device_uid, device_secret, operator_id, pin };
347
+ const codes = [200];
348
+ const path = "/pos/operators/verify";
349
+ const args = ["pin", "POST", path, codes, body] as const;
350
+ const step = await live.storefront<unknown>(...args);
351
+ if (!step.ok) return fromLive(ctx.locale, step.result);
352
+ if (!isRecord(step.value)) return say(ctx.locale, "fail", COPY.shape);
353
+ return { status: "pass" };
354
+ };
355
+
356
+ const runLog06 = async (ctx: RunCtx): Promise<Outcome> => {
357
+ const allowed = new Set(["call-center", "dashboard"]);
358
+ const skipped = applies(ctx, "LOG-06", allowed);
359
+ if (skipped) return skipped;
360
+ const ready = prepare(ctx, LIVE);
361
+ if (isOutcome(ready)) return ready;
362
+ const subject = textOf(ctx.env.FIRESPARK_DOCTOR_HANDOFF_TOKEN);
363
+ if (!subject)
364
+ return missingEnv(ctx, "LOG-06", "FIRESPARK_DOCTOR_HANDOFF_TOKEN");
365
+ const live = openLive(ctx, ready);
366
+ const channelId = textOf(ready.channel_id);
367
+ const body = { channel_id: channelId, subject_token: subject };
368
+ const path = "/call-center/sessions";
369
+ const createdCodes = [200, 201];
370
+ const step = await live.probe("agent", "POST", path, createdCodes, body);
371
+ if (!step.ok) return fromLive(ctx.locale, step.result);
372
+ const meCodes = [200];
373
+ const me = await live.probe("agent", "GET", "/call-center/me", meCodes);
374
+ if (!me.ok) return fromLive(ctx.locale, me.result);
375
+ const bad = { channel_id: channelId, subject_token: "doctor-invalid" };
376
+ const deniedCodes = [401, 403];
377
+ const denied = await live.probe("agent", "POST", path, deniedCodes, bad);
378
+ if (!denied.ok) return fromLive(ctx.locale, denied.result);
379
+ return { status: "pass" };
380
+ };
381
+
382
+ const runLog07 = async (ctx: RunCtx): Promise<Outcome> => {
383
+ const skipped = applies(ctx, "LOG-07", new Set(["chat"]));
384
+ if (skipped) return skipped;
385
+ const ready = prepare(ctx, ["url"]);
386
+ if (isOutcome(ready)) return ready;
387
+ const page = await pageOf(textOf(ready.url));
388
+ if (page.status === 0 || page.status >= 400) {
389
+ return say(ctx.locale, "fail", COPY.page, { status: page.status });
390
+ }
391
+ if (!page.body.toLowerCase().includes("handoff")) {
392
+ return say(ctx.locale, "fail", COPY.handoff);
393
+ }
394
+ const field = "FIRESPARK_DOCTOR_HANDOFF_TOKEN";
395
+ if (!textOf(ctx.env[field])) return missingEnv(ctx, "LOG-07", field);
396
+ return { status: "pass" };
397
+ };
398
+
399
+ export const LOG_BINDINGS: Record<string, CheckBinding> = {
400
+ "LOG-01": withSafeFix(runLog01),
401
+ "LOG-02": withSafeFix(runLog02),
402
+ "LOG-03": withSafeFix(runLog03),
403
+ "LOG-04": withSafeFix(runLog04),
404
+ "LOG-05": withSafeFix(runLog05),
405
+ "LOG-06": withSafeFix(runLog06),
406
+ "LOG-07": withSafeFix(runLog07),
407
+ "LOG-08": withSafeFix(runLog08),
408
+ "LOG-09": withSafeFix(runLog09),
409
+ "LOG-10": withSafeFix(runLog10)
410
+ };