Skip to content

Commit d22cd61

Browse files
carsonp6Lightspark Eng
authored andcommitted
[js] gga example app: split main.ts into config/turnkey/webauthn/api-client/ui + flows modules (#28471)
## What Split the ~1450-line `src/main.ts` into a small ES-module tree: `config.ts`, `turnkey.ts` (crypto), `webauthn.ts` (ceremonies), `api-client.ts`, `ui.ts`, and a `flows/` directory (`customer`, `email-otp`, `oauth`, `passkey`, `manage`, `money`, shared `context`). `main.ts` becomes a thin bootstrap. ## Why P4 example app, PR 1 in `40-example-app-design.md` §1.5/§5. The single file mixed Turnkey crypto, HTTP, logging, DOM wiring, and every flow handler, and accreted into a tool only its author could drive. Carving it into modules lands first so later PRs touch small files; the `manage.ts` extraction also removes the 3× delete/export duplication (one shared panel instead of one per credential type). ## Place in the stack Base: #28470 (real WebAuthn ceremony + env-driven URL). Second PR of the **P4 example-app** stack. ## Notable points - **Pure refactor, no behavior change** — mechanical module move; `index.html` untouched. `tsc` + manual sandbox smoke prove equivalence. - Manual test tool (no automated UI tests). Type gate: `build` + `lint`/`format`. --- Part of the Turnkey login-family migration program. See `sparkcore/sparkcore/grid/docs/login-migration/00-program-plan.md`. GitOrigin-RevId: fe1f4c2ca75d37fb07e37b9902241db970ce26d8
1 parent 004dae2 commit d22cd61

13 files changed

Lines changed: 1676 additions & 1439 deletions

File tree

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// HTTP client + auth header + mode resolution.
2+
3+
import { API_BASE, type Mode } from "./config";
4+
import { el } from "./ui";
5+
6+
let authClientId: HTMLInputElement | null = null;
7+
let authClientSecret: HTMLInputElement | null = null;
8+
let modeSelect: HTMLSelectElement | null = null;
9+
10+
function getAuthClientId(): HTMLInputElement {
11+
if (!authClientId) authClientId = el<HTMLInputElement>("auth-client-id");
12+
return authClientId;
13+
}
14+
15+
function getAuthClientSecret(): HTMLInputElement {
16+
if (!authClientSecret)
17+
authClientSecret = el<HTMLInputElement>("auth-client-secret");
18+
return authClientSecret;
19+
}
20+
21+
function getModeSelect(): HTMLSelectElement {
22+
if (!modeSelect) modeSelect = el<HTMLSelectElement>("mode-select");
23+
return modeSelect;
24+
}
25+
26+
export function getMode(): Mode {
27+
return getModeSelect().value === "production" ? "production" : "sandbox";
28+
}
29+
30+
function getAuthHeader(): string {
31+
return (
32+
"Basic " +
33+
btoa(
34+
`${getAuthClientId().value.trim()}:${getAuthClientSecret().value.trim()}`,
35+
)
36+
);
37+
}
38+
39+
export async function apiPost(
40+
path: string,
41+
body: Record<string, unknown> | undefined,
42+
extraHeaders: Record<string, string> = {},
43+
): Promise<{ status: number; data: unknown }> {
44+
const res = await fetch(API_BASE + path, {
45+
method: "POST",
46+
headers: {
47+
"Content-Type": "application/json",
48+
Authorization: getAuthHeader(),
49+
...extraHeaders,
50+
},
51+
body: body === undefined ? undefined : JSON.stringify(body),
52+
});
53+
const raw = await res.text();
54+
const data = raw ? JSON.parse(raw) : null;
55+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${raw}`);
56+
return { status: res.status, data };
57+
}
58+
59+
export async function apiDelete(
60+
path: string,
61+
extraHeaders: Record<string, string> = {},
62+
): Promise<{ status: number; data: unknown }> {
63+
const res = await fetch(API_BASE + path, {
64+
method: "DELETE",
65+
headers: {
66+
Authorization: getAuthHeader(),
67+
...extraHeaders,
68+
},
69+
});
70+
const raw = await res.text();
71+
const data = raw ? JSON.parse(raw) : null;
72+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${raw}`);
73+
return { status: res.status, data };
74+
}
75+
76+
export async function apiPatch(
77+
path: string,
78+
body: Record<string, unknown>,
79+
extraHeaders: Record<string, string> = {},
80+
): Promise<{ status: number; data: unknown }> {
81+
const res = await fetch(API_BASE + path, {
82+
method: "PATCH",
83+
headers: {
84+
"Content-Type": "application/json",
85+
Authorization: getAuthHeader(),
86+
...extraHeaders,
87+
},
88+
body: JSON.stringify(body),
89+
});
90+
const raw = await res.text();
91+
const data = raw ? JSON.parse(raw) : null;
92+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${raw}`);
93+
return { status: res.status, data };
94+
}
95+
96+
export async function apiGet(path: string): Promise<unknown> {
97+
const res = await fetch(API_BASE + path, {
98+
headers: { Authorization: getAuthHeader() },
99+
});
100+
const raw = await res.text();
101+
const data = raw ? JSON.parse(raw) : null;
102+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${raw}`);
103+
return data;
104+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Grid Global Accounts — Example App: shared config + constants.
2+
3+
export type Mode = "sandbox" | "production";
4+
export type CredType = "email_otp" | "oauth" | "passkey";
5+
6+
// Sandbox magic signature injected into signed-retry headers and the execute
7+
// signature. In production these are wrong — a real stamp must be supplied.
8+
export const SANDBOX_SIG = "sandbox-valid-signature";
9+
10+
// All requests proxy through Vite at `/api` and forward to the configured Grid
11+
// backend. Credentials are entered manually in the UI — never embedded.
12+
export const API_BASE = "/api";
13+
14+
// Turnkey API stamp scheme — must match what `@turnkey/api-key-stamper` emits.
15+
export const TURNKEY_STAMP_SCHEME = "SIGNATURE_SCHEME_TK_API_P256";
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Cross-flow wallet context: account / credential / session ids shared between
2+
// the per-credential-type tabs and the money + manage flows.
3+
4+
import { el } from "../ui";
5+
6+
let ctxAccountId: HTMLInputElement | null = null;
7+
let ctxCredentialId: HTMLInputElement | null = null;
8+
let ctxSessionId: HTMLInputElement | null = null;
9+
10+
function accountIdEl(): HTMLInputElement {
11+
if (!ctxAccountId) ctxAccountId = el<HTMLInputElement>("ctx-account-id");
12+
return ctxAccountId;
13+
}
14+
function credentialIdEl(): HTMLInputElement {
15+
if (!ctxCredentialId)
16+
ctxCredentialId = el<HTMLInputElement>("ctx-credential-id");
17+
return ctxCredentialId;
18+
}
19+
function sessionIdEl(): HTMLInputElement {
20+
if (!ctxSessionId) ctxSessionId = el<HTMLInputElement>("ctx-session-id");
21+
return ctxSessionId;
22+
}
23+
24+
// First-call-wins by design: the account id is established once (Create
25+
// Customer) and shared across every credential-type tab, so a later per-type
26+
// flow must not clobber it. Credential/session ids below are per-type and do
27+
// overwrite. To switch accounts, clear the field in the UI.
28+
export function setCtxAccount(id: string): void {
29+
if (!accountIdEl().value) accountIdEl().value = id;
30+
}
31+
export function setCtxCredential(id: string): void {
32+
credentialIdEl().value = id;
33+
}
34+
export function setCtxSession(id: string): void {
35+
sessionIdEl().value = id;
36+
}
37+
38+
export function requireAccountId(): string {
39+
const id = accountIdEl().value.trim();
40+
if (!id)
41+
throw new Error(
42+
"Internal Account ID is required — run Create Customer first.",
43+
);
44+
return id;
45+
}
46+
47+
export function requireCredentialId(): string {
48+
const id = credentialIdEl().value.trim();
49+
if (!id)
50+
throw new Error(
51+
"Credential ID is required — run Create for this type first.",
52+
);
53+
return id;
54+
}
55+
56+
export function requireSessionId(): string {
57+
const id = sessionIdEl().value.trim();
58+
if (!id)
59+
throw new Error("Session ID is required — run Verify for this type first.");
60+
return id;
61+
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// Shared setup: create customer, platform config (OTP + branding), balance.
2+
3+
import { apiGet, apiPatch, apiPost } from "../api-client";
4+
import { addLog, bindClick, el, maybeEl } from "../ui";
5+
import { setCtxAccount } from "./context";
6+
7+
// ----- Create customer + Fetch balance -----
8+
9+
export function wireCustomerFlows(): void {
10+
const createPlatformCustomerId = el<HTMLInputElement>(
11+
"create-platform-customer-id",
12+
);
13+
const createCustomerName = el<HTMLInputElement>("create-customer-name");
14+
const createCustomerEmail = el<HTMLInputElement>("create-customer-email");
15+
const balanceCustomerId = el<HTMLInputElement>("balance-customer-id");
16+
17+
bindClick(
18+
"btn-create-customer",
19+
"create-customer-status",
20+
"Create Customer",
21+
"Creating customer...",
22+
async () => {
23+
const platformCustomerId =
24+
createPlatformCustomerId.value.trim() || `test-${Date.now()}`;
25+
const fullName = createCustomerName.value.trim() || "Test User";
26+
const email = createCustomerEmail.value.trim();
27+
const body: Record<string, unknown> = {
28+
customerType: "BUSINESS",
29+
platformCustomerId,
30+
region: "US",
31+
currencies: ["USDB"],
32+
businessInfo: {
33+
legalName: fullName,
34+
taxId: "12-3456789",
35+
incorporatedOn: "2020-01-01",
36+
},
37+
};
38+
if (email) body.email = email;
39+
const { data: customer } = await apiPost("/customers", body);
40+
addLog("Create Customer", customer);
41+
const customerId = (customer as Record<string, unknown>).id as string;
42+
if (!balanceCustomerId.value) balanceCustomerId.value = customerId;
43+
const accounts = (await apiGet(
44+
`/customers/internal-accounts?customerId=${customerId}&currency=USDB`,
45+
)) as { data: Array<{ id: string }> };
46+
addLog("Internal Accounts", accounts);
47+
if (accounts.data && accounts.data.length > 0) {
48+
setCtxAccount(accounts.data[0].id);
49+
return `Customer: ${customerId}\nAccount: ${accounts.data[0].id}\nEmbedded wallet pre-created at customer-create time.`;
50+
}
51+
return `Customer: ${customerId}\nNo USDB account found yet — wallet provisioning may be in progress.`;
52+
},
53+
);
54+
55+
bindClick(
56+
"btn-fetch-balance",
57+
"balance-status",
58+
"Fetch Balance",
59+
"Fetching balance...",
60+
async () => {
61+
const customerId = balanceCustomerId.value.trim();
62+
if (!customerId) throw new Error("Customer ID is required.");
63+
const data = (await apiGet(
64+
`/customers/internal-accounts?customerId=${encodeURIComponent(customerId)}`,
65+
)) as { data: Array<Record<string, unknown>> };
66+
addLog("Fetch Balance", data);
67+
return JSON.stringify(
68+
data.data?.map((a) => ({
69+
id: a.id,
70+
currency: a.currency,
71+
balance: a.balance,
72+
})) ?? [],
73+
null,
74+
2,
75+
);
76+
},
77+
);
78+
79+
wirePlatformConfigFlows();
80+
}
81+
82+
// ----- Platform config (OTP + branding) — GET to populate, PATCH to save -----
83+
84+
function wirePlatformConfigFlows(): void {
85+
const cfgAppName = maybeEl<HTMLInputElement>("cfg-app-name");
86+
const cfgOtpLength = maybeEl<HTMLInputElement>("cfg-otp-length");
87+
const cfgAlphanumeric = maybeEl<HTMLInputElement>("cfg-alphanumeric");
88+
const cfgExpirationSeconds = maybeEl<HTMLInputElement>(
89+
"cfg-expiration-seconds",
90+
);
91+
const cfgSendFromEmail = maybeEl<HTMLInputElement>("cfg-send-from-email");
92+
const cfgSendFromName = maybeEl<HTMLInputElement>("cfg-send-from-name");
93+
const cfgReplyToEmail = maybeEl<HTMLInputElement>("cfg-reply-to-email");
94+
const cfgLogoUrl = maybeEl<HTMLInputElement>("cfg-logo-url");
95+
96+
function readConfigForm(): Record<string, unknown> {
97+
// Only include fields the user touched (non-empty) so we PATCH a real partial.
98+
const ewc: Record<string, unknown> = {};
99+
if (cfgAppName?.value.trim()) ewc.appName = cfgAppName.value.trim();
100+
if (cfgOtpLength?.value.trim())
101+
ewc.otpLength = parseInt(cfgOtpLength.value, 10);
102+
if (cfgAlphanumeric) ewc.alphanumeric = cfgAlphanumeric.checked;
103+
if (cfgExpirationSeconds?.value.trim())
104+
ewc.expirationSeconds = parseInt(cfgExpirationSeconds.value, 10);
105+
if (cfgSendFromEmail?.value.trim())
106+
ewc.sendFromEmailAddress = cfgSendFromEmail.value.trim();
107+
if (cfgSendFromName?.value.trim())
108+
ewc.sendFromEmailSenderName = cfgSendFromName.value.trim();
109+
if (cfgReplyToEmail?.value.trim())
110+
ewc.replyToEmailAddress = cfgReplyToEmail.value.trim();
111+
if (cfgLogoUrl?.value.trim()) ewc.logoUrl = cfgLogoUrl.value.trim();
112+
return { embeddedWalletConfig: ewc };
113+
}
114+
115+
function applyConfigToForm(cfg: unknown): void {
116+
const ewc = (cfg as { embeddedWalletConfig?: Record<string, unknown> })
117+
?.embeddedWalletConfig;
118+
if (!ewc) return;
119+
if (cfgAppName && typeof ewc.appName === "string")
120+
cfgAppName.value = ewc.appName;
121+
if (cfgOtpLength && typeof ewc.otpLength === "number")
122+
cfgOtpLength.value = String(ewc.otpLength);
123+
if (cfgAlphanumeric && typeof ewc.alphanumeric === "boolean")
124+
cfgAlphanumeric.checked = ewc.alphanumeric;
125+
if (cfgExpirationSeconds && typeof ewc.expirationSeconds === "number")
126+
cfgExpirationSeconds.value = String(ewc.expirationSeconds);
127+
if (cfgSendFromEmail && typeof ewc.sendFromEmailAddress === "string")
128+
cfgSendFromEmail.value = ewc.sendFromEmailAddress;
129+
if (cfgSendFromName && typeof ewc.sendFromEmailSenderName === "string")
130+
cfgSendFromName.value = ewc.sendFromEmailSenderName;
131+
if (cfgReplyToEmail && typeof ewc.replyToEmailAddress === "string")
132+
cfgReplyToEmail.value = ewc.replyToEmailAddress;
133+
if (cfgLogoUrl && typeof ewc.logoUrl === "string")
134+
cfgLogoUrl.value = ewc.logoUrl;
135+
}
136+
137+
bindClick("btn-cfg-load", "cfg-status", "Load Config", "Loading…", async () => {
138+
const cfg = await apiGet("/config");
139+
addLog("GET /config", cfg);
140+
applyConfigToForm(cfg);
141+
return "Config loaded into form.";
142+
});
143+
144+
bindClick("btn-cfg-save", "cfg-status", "Save Config", "Saving…", async () => {
145+
const body = readConfigForm();
146+
const { data } = await apiPatch("/config", body);
147+
addLog("PATCH /config", data);
148+
return "Config saved.";
149+
});
150+
}

0 commit comments

Comments
 (0)