@fire-spark/cli 0.0.0-stage → 0.13.3
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.
- package/bin/firespark.mjs +14 -0
- package/package.json +39 -3
- package/src/commands/doctor/client/admin.flow.ts +553 -0
- package/src/commands/doctor/client/catalog.json +1586 -0
- package/src/commands/doctor/client/catalog.ts +179 -0
- package/src/commands/doctor/client/checkout.flow.ts +400 -0
- package/src/commands/doctor/client/command.ts +246 -0
- package/src/commands/doctor/client/config.ts +346 -0
- package/src/commands/doctor/client/content.flow.ts +450 -0
- package/src/commands/doctor/client/copy.ts +248 -0
- package/src/commands/doctor/client/flow.ts +182 -0
- package/src/commands/doctor/client/log.flow.ts +410 -0
- package/src/commands/doctor/client/manifests.ts +161 -0
- package/src/commands/doctor/client/men.flow.ts +412 -0
- package/src/commands/doctor/client/ops.flow.ts +312 -0
- package/src/commands/doctor/client/options.ts +201 -0
- package/src/commands/doctor/client/ord.flow.ts +445 -0
- package/src/commands/doctor/client/order.ts +261 -0
- package/src/commands/doctor/client/pay.flow.ts +772 -0
- package/src/commands/doctor/client/repo.scan.ts +142 -0
- package/src/commands/doctor/client/runner.ts +182 -0
- package/src/commands/doctor/client/sdk.flow.ts +630 -0
- package/src/commands/doctor/client/store.flow.ts +577 -0
- package/src/commands/doctor/client/yaml.ts +179 -0
- package/src/commands/doctor/live/http.ts +81 -0
- package/src/commands/doctor/live/live.session.ts +250 -0
- package/src/commands/doctor/live/order.ts +95 -0
- package/src/commands/doctor/live/realtime.ts +161 -0
- package/src/commands/doctor/live/result.ts +76 -0
- package/src/commands/index.ts +8 -0
- package/src/core/command.types.ts +25 -0
- package/src/core/help.ts +85 -0
- package/src/core/i18n.ts +49 -0
- package/src/core/options.ts +131 -0
- package/src/core/output.ts +47 -0
- package/src/core/redact.ts +71 -0
- package/src/index.ts +4 -0
- package/src/main.ts +113 -0
- package/README.md +0 -3
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import type { Locale } from "../../../core/i18n";
|
|
2
|
+
import { translate } from "../../../core/i18n";
|
|
3
|
+
import { UsageError } from "../../../core/options";
|
|
4
|
+
import { COPY } from "./copy";
|
|
5
|
+
|
|
6
|
+
export interface YamlChannel {
|
|
7
|
+
channel_id: string | null;
|
|
8
|
+
name: string;
|
|
9
|
+
template: string;
|
|
10
|
+
template_version: string | null;
|
|
11
|
+
sdk_version: string | null;
|
|
12
|
+
deployed: boolean;
|
|
13
|
+
url: string | null;
|
|
14
|
+
dashboard_url: string | null;
|
|
15
|
+
api_url: string | null;
|
|
16
|
+
store_id: string | null;
|
|
17
|
+
fulfillment_id: string | null;
|
|
18
|
+
payment: string | null;
|
|
19
|
+
receiver: string | null;
|
|
20
|
+
evidence: string | null;
|
|
21
|
+
env: string | null;
|
|
22
|
+
merchant_id: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface DoctorFile {
|
|
26
|
+
channels: YamlChannel[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const KEYS = [
|
|
30
|
+
"channel_id",
|
|
31
|
+
"name",
|
|
32
|
+
"template",
|
|
33
|
+
"template_version",
|
|
34
|
+
"sdk_version",
|
|
35
|
+
"deployed",
|
|
36
|
+
"url",
|
|
37
|
+
"dashboard_url",
|
|
38
|
+
"api_url",
|
|
39
|
+
"store_id",
|
|
40
|
+
"fulfillment_id",
|
|
41
|
+
"payment",
|
|
42
|
+
"receiver",
|
|
43
|
+
"evidence",
|
|
44
|
+
"env",
|
|
45
|
+
"merchant_id"
|
|
46
|
+
] as const;
|
|
47
|
+
|
|
48
|
+
type ChannelKey = (typeof KEYS)[number];
|
|
49
|
+
|
|
50
|
+
const KEY_SET = new Set<string>(KEYS);
|
|
51
|
+
|
|
52
|
+
export const emptyChannel = (): YamlChannel => {
|
|
53
|
+
return {
|
|
54
|
+
channel_id: null,
|
|
55
|
+
name: "",
|
|
56
|
+
template: "",
|
|
57
|
+
template_version: null,
|
|
58
|
+
sdk_version: null,
|
|
59
|
+
deployed: false,
|
|
60
|
+
url: null,
|
|
61
|
+
dashboard_url: null,
|
|
62
|
+
api_url: null,
|
|
63
|
+
store_id: null,
|
|
64
|
+
fulfillment_id: null,
|
|
65
|
+
payment: null,
|
|
66
|
+
receiver: null,
|
|
67
|
+
evidence: null,
|
|
68
|
+
env: null,
|
|
69
|
+
merchant_id: null
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const quote = (value: string) => {
|
|
74
|
+
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
75
|
+
return `"${escaped}"`;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const scalar = (value: string | boolean | null) => {
|
|
79
|
+
if (value === null) return "null";
|
|
80
|
+
if (typeof value === "boolean") return value ? "true" : "false";
|
|
81
|
+
return quote(value);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const writeDoctorYaml = (file: DoctorFile) => {
|
|
85
|
+
const lines = ["channels:"];
|
|
86
|
+
if (file.channels.length === 0) {
|
|
87
|
+
lines.push(" []");
|
|
88
|
+
return `${lines.join("\n")}\n`;
|
|
89
|
+
}
|
|
90
|
+
for (const channel of file.channels) {
|
|
91
|
+
let first = true;
|
|
92
|
+
for (const key of KEYS) {
|
|
93
|
+
const rendered = scalar(channel[key]);
|
|
94
|
+
const pad = first ? " - " : " ";
|
|
95
|
+
lines.push(`${pad}${key}: ${rendered}`);
|
|
96
|
+
first = false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return `${lines.join("\n")}\n`;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const unquote = (raw: string): string | boolean | null => {
|
|
103
|
+
const text = raw.trim();
|
|
104
|
+
if (text === "null" || text === "~" || text === "") return null;
|
|
105
|
+
if (text === "true") return true;
|
|
106
|
+
if (text === "false") return false;
|
|
107
|
+
if (text.startsWith('"') && text.endsWith('"') && text.length >= 2) {
|
|
108
|
+
const inner = text.slice(1, -1);
|
|
109
|
+
return inner.replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
|
110
|
+
}
|
|
111
|
+
if (text.startsWith("'") && text.endsWith("'") && text.length >= 2) {
|
|
112
|
+
return text.slice(1, -1);
|
|
113
|
+
}
|
|
114
|
+
return text;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const asString = (value: string | boolean | null, key: string) => {
|
|
118
|
+
if (value === null) return null;
|
|
119
|
+
if (typeof value === "string") return value;
|
|
120
|
+
throw new Error(`${key} is not text`);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const assign = (channel: YamlChannel, key: ChannelKey, raw: string) => {
|
|
124
|
+
const value = unquote(raw);
|
|
125
|
+
if (key === "deployed") {
|
|
126
|
+
if (typeof value !== "boolean") throw new Error("deployed");
|
|
127
|
+
channel.deployed = value;
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const text = asString(value, key);
|
|
131
|
+
if (key === "channel_id") {
|
|
132
|
+
channel.channel_id = text;
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (key === "name") {
|
|
136
|
+
channel.name = text ?? "";
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (key === "template") {
|
|
140
|
+
channel.template = text ?? "";
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const bag = channel as unknown as Record<ChannelKey, string | null>;
|
|
144
|
+
bag[key] = text;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export const parseDoctorYaml = (body: string, path: string, locale: Locale) => {
|
|
148
|
+
const channels: YamlChannel[] = [];
|
|
149
|
+
let current: YamlChannel | null = null;
|
|
150
|
+
const fail = (detail: string): never => {
|
|
151
|
+
throw new UsageError(translate(locale, COPY.badYaml, { path, detail }));
|
|
152
|
+
};
|
|
153
|
+
const rows = body.split(/\r?\n/);
|
|
154
|
+
for (const row of rows) {
|
|
155
|
+
const line = row.trim();
|
|
156
|
+
if (!line || line.startsWith("#")) continue;
|
|
157
|
+
if (line === "channels:" || line === "channels: []" || line === "[]") {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const item = /^-\s+([^:]+):\s*(.*)$/.exec(line);
|
|
161
|
+
const field = item ? null : /^([^:]+):\s*(.*)$/.exec(line);
|
|
162
|
+
const match = item ?? field;
|
|
163
|
+
if (!match) fail(line);
|
|
164
|
+
const key = match?.[1]?.trim() ?? "";
|
|
165
|
+
const raw = match?.[2] ?? "";
|
|
166
|
+
if (!KEY_SET.has(key)) fail(key);
|
|
167
|
+
if (item || !current) {
|
|
168
|
+
current = emptyChannel();
|
|
169
|
+
channels.push(current);
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
assign(current, key as ChannelKey, raw);
|
|
173
|
+
} catch (error) {
|
|
174
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
175
|
+
fail(detail);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return { channels } satisfies DoctorFile;
|
|
179
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export type FetchLike = (
|
|
2
|
+
input: string,
|
|
3
|
+
init?: RequestInit
|
|
4
|
+
) => Promise<Response>;
|
|
5
|
+
|
|
6
|
+
export interface HttpRequest {
|
|
7
|
+
method: "GET" | "POST" | "PATCH" | "DELETE";
|
|
8
|
+
url: string;
|
|
9
|
+
token?: string;
|
|
10
|
+
body?: unknown;
|
|
11
|
+
timeoutMs: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface HttpResponse {
|
|
15
|
+
status: number;
|
|
16
|
+
headers: Headers;
|
|
17
|
+
json: unknown;
|
|
18
|
+
text: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class NetworkError extends Error {}
|
|
22
|
+
|
|
23
|
+
const parse = (text: string) => {
|
|
24
|
+
if (!text) return undefined;
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(text) as unknown;
|
|
27
|
+
} catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const request = async (
|
|
33
|
+
fetchImpl: FetchLike,
|
|
34
|
+
input: HttpRequest
|
|
35
|
+
): Promise<HttpResponse> => {
|
|
36
|
+
const headers: Record<string, string> = { accept: "application/json" };
|
|
37
|
+
if (input.token) headers.authorization = `Bearer ${input.token}`;
|
|
38
|
+
if (input.body !== undefined) headers["content-type"] = "application/json";
|
|
39
|
+
const raw = input.body;
|
|
40
|
+
const body = raw === undefined ? undefined : JSON.stringify(raw);
|
|
41
|
+
const signal = AbortSignal.timeout(input.timeoutMs);
|
|
42
|
+
const init = { method: input.method, headers, body, signal };
|
|
43
|
+
let response: Response;
|
|
44
|
+
try {
|
|
45
|
+
response = await fetchImpl(input.url, init);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
48
|
+
throw new NetworkError(detail);
|
|
49
|
+
}
|
|
50
|
+
const text = await response.text();
|
|
51
|
+
const b1 = { status: response.status, headers: response.headers };
|
|
52
|
+
const b2 = { json: parse(text), text };
|
|
53
|
+
return { ...b1, ...b2 };
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Short, single-line description of an error body for logs. */
|
|
57
|
+
export const errorDetail = (response: HttpResponse) => {
|
|
58
|
+
const json = response.json as
|
|
59
|
+
| { error?: unknown; message?: unknown; details?: unknown }
|
|
60
|
+
| undefined;
|
|
61
|
+
const isText = (part: unknown) => typeof part === "string";
|
|
62
|
+
const parts = [json?.error, json?.message].filter(isText);
|
|
63
|
+
const nested = (json?.error as { message?: unknown } | undefined)?.message;
|
|
64
|
+
if (typeof nested === "string") parts.push(nested);
|
|
65
|
+
if (json?.details !== undefined) parts.push(JSON.stringify(json.details));
|
|
66
|
+
const detail = parts.length > 0 ? parts.join(" · ") : response.text;
|
|
67
|
+
return detail.replace(/\s+/g, " ").trim().slice(0, 400) || "—";
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const dataOf = <T>(response: HttpResponse) => {
|
|
71
|
+
return (response.json as { data?: T } | undefined)?.data;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** Path without query string, safe to print. */
|
|
75
|
+
export const pathOf = (url: string) => {
|
|
76
|
+
try {
|
|
77
|
+
return new URL(url).pathname;
|
|
78
|
+
} catch {
|
|
79
|
+
return url.split("?")[0] ?? url;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import type { Redactor } from "../../../core/redact";
|
|
2
|
+
import type { FetchLike, HttpRequest, HttpResponse } from "./http";
|
|
3
|
+
import { NetworkError, dataOf, errorDetail, pathOf, request } from "./http";
|
|
4
|
+
import { REASONS, fail, skip } from "./result";
|
|
5
|
+
import type { CheckFail, CheckSkip } from "./result";
|
|
6
|
+
|
|
7
|
+
export const STOREFRONT = "/api/storefront/v1";
|
|
8
|
+
|
|
9
|
+
export interface MenuProduct {
|
|
10
|
+
id?: string;
|
|
11
|
+
uid?: string;
|
|
12
|
+
name?: string;
|
|
13
|
+
pricing?: { price?: number };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface Menu {
|
|
17
|
+
id?: string;
|
|
18
|
+
name?: string;
|
|
19
|
+
products?: MenuProduct[];
|
|
20
|
+
categories?: unknown[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** What ORD-01 needs to sign in and place one order. */
|
|
24
|
+
export interface LiveConfig {
|
|
25
|
+
apiUrl?: string;
|
|
26
|
+
firebaseApiKey?: string;
|
|
27
|
+
firebaseAuthUrl: string;
|
|
28
|
+
customerEmail?: string;
|
|
29
|
+
customerPassword?: string;
|
|
30
|
+
storeId?: string;
|
|
31
|
+
channelId?: string;
|
|
32
|
+
fulfillmentId?: string;
|
|
33
|
+
currency: string;
|
|
34
|
+
httpTimeoutMs: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface LiveDeps {
|
|
38
|
+
fetch: FetchLike;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type Step<T> =
|
|
42
|
+
| { ok: true; value: T }
|
|
43
|
+
| { ok: false; result: CheckFail | CheckSkip };
|
|
44
|
+
|
|
45
|
+
export interface LiveSession {
|
|
46
|
+
storefront: <T>(
|
|
47
|
+
hop: string,
|
|
48
|
+
method: "GET" | "POST" | "PATCH" | "DELETE",
|
|
49
|
+
path: string,
|
|
50
|
+
statuses: number[],
|
|
51
|
+
body?: unknown
|
|
52
|
+
) => Promise<Step<T>>;
|
|
53
|
+
probe: (
|
|
54
|
+
hop: string,
|
|
55
|
+
method: "GET" | "POST" | "PATCH" | "DELETE",
|
|
56
|
+
path: string,
|
|
57
|
+
statuses: number[],
|
|
58
|
+
body?: unknown
|
|
59
|
+
) => Promise<Step<HttpResponse>>;
|
|
60
|
+
menus: () => Promise<Step<Menu[]>>;
|
|
61
|
+
realtimeToken: () => Promise<Step<RealtimeToken>>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface RealtimeToken {
|
|
65
|
+
access_token?: string;
|
|
66
|
+
url?: string;
|
|
67
|
+
key?: string;
|
|
68
|
+
customer_topic?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const ok = <T>(value: T): Step<T> => ({ ok: true, value });
|
|
72
|
+
type Miss = CheckFail | CheckSkip;
|
|
73
|
+
const no = <T>(result: Miss): Step<T> => ({ ok: false, result });
|
|
74
|
+
|
|
75
|
+
const missingSignIn = (config: LiveConfig) => {
|
|
76
|
+
const names: string[] = [];
|
|
77
|
+
if (!config.firebaseApiKey) names.push("FIRESPARK_DOCTOR_FIREBASE_API_KEY");
|
|
78
|
+
if (!config.customerEmail) names.push("FIRESPARK_DOCTOR_CUSTOMER_EMAIL");
|
|
79
|
+
if (!config.customerPassword) {
|
|
80
|
+
names.push("FIRESPARK_DOCTOR_CUSTOMER_PASSWORD");
|
|
81
|
+
}
|
|
82
|
+
if (!config.apiUrl) names.push("api_url");
|
|
83
|
+
return names;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const menuQuery = (config: LiveConfig) => {
|
|
87
|
+
const params = new URLSearchParams();
|
|
88
|
+
if (config.storeId) params.set("store_id", config.storeId);
|
|
89
|
+
if (config.channelId) params.set("channel_id", config.channelId);
|
|
90
|
+
if (config.fulfillmentId) params.set("fulfillment_id", config.fulfillmentId);
|
|
91
|
+
return params;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const createLiveSession = (
|
|
95
|
+
config: LiveConfig,
|
|
96
|
+
deps: LiveDeps,
|
|
97
|
+
redactor: Redactor
|
|
98
|
+
): LiveSession => {
|
|
99
|
+
const memo = new Map<string, Promise<unknown>>();
|
|
100
|
+
const once = <T>(key: string, factory: () => Promise<T>) => {
|
|
101
|
+
if (!memo.has(key)) memo.set(key, factory());
|
|
102
|
+
return memo.get(key) as Promise<T>;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const signInGap = () => {
|
|
106
|
+
const names = missingSignIn(config);
|
|
107
|
+
if (names.length === 0) return undefined;
|
|
108
|
+
return skip(REASONS.liveMissing, { names: names.join(", ") });
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const call = async (
|
|
112
|
+
hop: string,
|
|
113
|
+
input: Omit<HttpRequest, "timeoutMs">
|
|
114
|
+
): Promise<Step<HttpResponse>> => {
|
|
115
|
+
try {
|
|
116
|
+
const timeoutMs = config.httpTimeoutMs;
|
|
117
|
+
const response = await request(deps.fetch, { ...input, timeoutMs });
|
|
118
|
+
return ok(response);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
const target = `${input.method} ${pathOf(input.url)}`;
|
|
121
|
+
let detail = String(error);
|
|
122
|
+
if (error instanceof NetworkError) detail = error.message;
|
|
123
|
+
return no(fail(hop, REASONS.network, { target, detail }));
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const api = (path: string) => `${config.apiUrl ?? ""}${STOREFRONT}${path}`;
|
|
128
|
+
|
|
129
|
+
const expectAt = (
|
|
130
|
+
hop: string,
|
|
131
|
+
response: HttpResponse,
|
|
132
|
+
statuses: number[],
|
|
133
|
+
method: string,
|
|
134
|
+
url: string
|
|
135
|
+
) => {
|
|
136
|
+
if (statuses.includes(response.status)) return undefined;
|
|
137
|
+
const path = pathOf(url);
|
|
138
|
+
const detail = errorDetail(response);
|
|
139
|
+
const status = response.status;
|
|
140
|
+
const vars = { method, path, status, detail };
|
|
141
|
+
return fail(hop, REASONS.httpStatus, vars);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const idToken = () => {
|
|
145
|
+
return once("idToken", async (): Promise<Step<string>> => {
|
|
146
|
+
const missing = signInGap();
|
|
147
|
+
if (missing) return no(missing);
|
|
148
|
+
const base = config.firebaseAuthUrl;
|
|
149
|
+
const key = encodeURIComponent(config.firebaseApiKey ?? "");
|
|
150
|
+
const url = `${base}/v1/accounts:signInWithPassword?key=${key}`;
|
|
151
|
+
const email = config.customerEmail;
|
|
152
|
+
const password = config.customerPassword;
|
|
153
|
+
const body = { email, password, returnSecureToken: true };
|
|
154
|
+
const step = await call("firebase-auth", { method: "POST", url, body });
|
|
155
|
+
if (!step.ok) return step;
|
|
156
|
+
const failed = expectAt("firebase-auth", step.value, [200], "POST", url);
|
|
157
|
+
if (failed) return no(failed);
|
|
158
|
+
const json = step.value.json as { idToken?: string } | undefined;
|
|
159
|
+
const token = json?.idToken;
|
|
160
|
+
if (!token) {
|
|
161
|
+
const field = "idToken";
|
|
162
|
+
const reason = fail("firebase-auth", REASONS.badShape, { field });
|
|
163
|
+
return no(reason);
|
|
164
|
+
}
|
|
165
|
+
redactor.add(token);
|
|
166
|
+
return ok(token);
|
|
167
|
+
});
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const accessToken = () => {
|
|
171
|
+
return once("accessToken", async (): Promise<Step<string>> => {
|
|
172
|
+
const id = await idToken();
|
|
173
|
+
if (!id.ok) return id;
|
|
174
|
+
const url = api("/oauth/exchange");
|
|
175
|
+
const grant_type = "urn:ietf:params:oauth:grant-type:token-exchange";
|
|
176
|
+
const subject_token_type = "urn:ietf:params:oauth:token-type:id_token";
|
|
177
|
+
const b1 = { grant_type, subject_token: id.value, subject_token_type };
|
|
178
|
+
const posted = { method: "POST" as const, url, body: b1 };
|
|
179
|
+
const step = await call("token-exchange", posted);
|
|
180
|
+
if (!step.ok) return step;
|
|
181
|
+
const codes = [200, 201];
|
|
182
|
+
const failed = expectAt("token-exchange", step.value, codes, "POST", url);
|
|
183
|
+
if (failed) return no(failed);
|
|
184
|
+
const json = step.value.json as
|
|
185
|
+
| { access_token?: string; data?: { access_token?: string } }
|
|
186
|
+
| undefined;
|
|
187
|
+
const token = json?.access_token ?? json?.data?.access_token;
|
|
188
|
+
if (!token) {
|
|
189
|
+
const field = "access_token";
|
|
190
|
+
return no(fail("token-exchange", REASONS.badShape, { field }));
|
|
191
|
+
}
|
|
192
|
+
redactor.add(token);
|
|
193
|
+
return ok(token);
|
|
194
|
+
});
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const raw = async (
|
|
198
|
+
hop: string,
|
|
199
|
+
method: "GET" | "POST" | "PATCH" | "DELETE",
|
|
200
|
+
path: string,
|
|
201
|
+
statuses: number[],
|
|
202
|
+
body?: unknown
|
|
203
|
+
): Promise<Step<HttpResponse>> => {
|
|
204
|
+
const token = await accessToken();
|
|
205
|
+
if (!token.ok) return token;
|
|
206
|
+
const url = api(path);
|
|
207
|
+
const sent = await call(hop, { method, url, token: token.value, body });
|
|
208
|
+
if (!sent.ok) return sent;
|
|
209
|
+
const failed = expectAt(hop, sent.value, statuses, method, url);
|
|
210
|
+
if (failed) return no(failed);
|
|
211
|
+
return sent;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const storefront = async <T>(
|
|
215
|
+
hop: string,
|
|
216
|
+
method: "GET" | "POST" | "PATCH" | "DELETE",
|
|
217
|
+
path: string,
|
|
218
|
+
statuses: number[],
|
|
219
|
+
body?: unknown
|
|
220
|
+
): Promise<Step<T>> => {
|
|
221
|
+
const step = await raw(hop, method, path, statuses, body);
|
|
222
|
+
if (!step.ok) return step;
|
|
223
|
+
const data = dataOf<T>(step.value);
|
|
224
|
+
if (data === undefined) {
|
|
225
|
+
return no(fail(hop, REASONS.badShape, { field: "data" }));
|
|
226
|
+
}
|
|
227
|
+
return ok(data);
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const menus = () => {
|
|
231
|
+
return once("menus", () => {
|
|
232
|
+
const query = menuQuery(config).toString();
|
|
233
|
+
const path = query ? `/menus?${query}` : "/menus";
|
|
234
|
+
return storefront<Menu[]>("menu-fetch", "GET", path, [200]);
|
|
235
|
+
});
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const realtimeToken = () => {
|
|
239
|
+
return once("realtimeToken", async () => {
|
|
240
|
+
const codes = [200, 201];
|
|
241
|
+
const path = "/realtime/token";
|
|
242
|
+
const args = ["realtime-token", "POST", path, codes, {}] as const;
|
|
243
|
+
const step = await storefront<RealtimeToken>(...args);
|
|
244
|
+
if (step.ok) redactor.add(step.value.access_token);
|
|
245
|
+
return step;
|
|
246
|
+
});
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
return { storefront, probe: raw, menus, realtimeToken };
|
|
250
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import type { LiveConfig, Menu, MenuProduct } from "./live.session";
|
|
4
|
+
|
|
5
|
+
export const doctorOrderId = () => {
|
|
6
|
+
return `ord-doctor-${randomBytes(8).toString("hex")}`;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const money = (price: number) => {
|
|
10
|
+
const unit = {
|
|
11
|
+
unit_subtotal: price,
|
|
12
|
+
unit_discount: 0,
|
|
13
|
+
unit_tax_total: 0,
|
|
14
|
+
unit_total: price
|
|
15
|
+
};
|
|
16
|
+
const line = {
|
|
17
|
+
subtotal: price,
|
|
18
|
+
discount_total: 0,
|
|
19
|
+
tax_total: 0,
|
|
20
|
+
total: price
|
|
21
|
+
};
|
|
22
|
+
const groupUnit = {
|
|
23
|
+
unit_group_subtotal: price,
|
|
24
|
+
unit_group_discount: 0,
|
|
25
|
+
unit_group_tax_total: 0,
|
|
26
|
+
unit_group_total: price
|
|
27
|
+
};
|
|
28
|
+
const group = {
|
|
29
|
+
group_subtotal: price,
|
|
30
|
+
group_discount: 0,
|
|
31
|
+
group_tax_total: 0,
|
|
32
|
+
group_total: price
|
|
33
|
+
};
|
|
34
|
+
return { ...unit, ...line, ...groupUnit, ...group };
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** One unit of one product, no tax, no discount. Place sends cash. */
|
|
38
|
+
export const buildOrderBody = (input: {
|
|
39
|
+
id: string;
|
|
40
|
+
config: LiveConfig;
|
|
41
|
+
product: MenuProduct;
|
|
42
|
+
menuId?: string;
|
|
43
|
+
lineUid: string;
|
|
44
|
+
}) => {
|
|
45
|
+
const { config, product } = input;
|
|
46
|
+
const price = product.pricing?.price ?? 0;
|
|
47
|
+
const head = {
|
|
48
|
+
type: "PRODUCT",
|
|
49
|
+
uid: input.lineUid,
|
|
50
|
+
id: product.id,
|
|
51
|
+
parent_uid: null,
|
|
52
|
+
parent_id: null
|
|
53
|
+
};
|
|
54
|
+
const meta = {
|
|
55
|
+
name: product.name ?? product.id,
|
|
56
|
+
menu_id: input.menuId,
|
|
57
|
+
metadata: { source: "firespark-doctor" }
|
|
58
|
+
};
|
|
59
|
+
const rates = { quantity: 1, tax_rate: null, discount_rate: 0 };
|
|
60
|
+
const line = { ...head, ...meta, ...rates, ...money(price) };
|
|
61
|
+
const names = {
|
|
62
|
+
display_name: "Fire Spark Doctor",
|
|
63
|
+
first_name: "Fire Spark",
|
|
64
|
+
last_name: "Doctor"
|
|
65
|
+
};
|
|
66
|
+
const customer_info = { ...names, email: config.customerEmail };
|
|
67
|
+
const phone = { phone: "+593999999999" };
|
|
68
|
+
const customer = { customer_info: { ...customer_info, ...phone } };
|
|
69
|
+
const totals = {
|
|
70
|
+
currency: config.currency,
|
|
71
|
+
subtotal: price,
|
|
72
|
+
discount_rate: 0,
|
|
73
|
+
discount_total: 0,
|
|
74
|
+
tax_rate: 0,
|
|
75
|
+
tax_total: 0,
|
|
76
|
+
total: price
|
|
77
|
+
};
|
|
78
|
+
const scope = {
|
|
79
|
+
store_id: config.storeId,
|
|
80
|
+
channel_id: config.channelId,
|
|
81
|
+
fulfillment_id: config.fulfillmentId
|
|
82
|
+
};
|
|
83
|
+
const body = { id: input.id, ...scope, is_anonymous: false, customer };
|
|
84
|
+
return { ...body, lines: [line], totals };
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** The configured product, otherwise the lowest id that has a price. */
|
|
88
|
+
export const pickProduct = (menus: Menu[], productId?: string) => {
|
|
89
|
+
const products = menus.flatMap(menu => menu.products ?? []);
|
|
90
|
+
if (productId) return products.find(product => product.id === productId);
|
|
91
|
+
const priced = products.filter(product => {
|
|
92
|
+
return typeof product.pricing?.price === "number" && product.id;
|
|
93
|
+
});
|
|
94
|
+
return priced.toSorted((a, b) => (a.id ?? "").localeCompare(b.id ?? ""))[0];
|
|
95
|
+
};
|