@redeemid/emdash 0.0.0-stage → 0.0.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.
@@ -0,0 +1,49 @@
1
+ /**
2
+ * POST /_emdash/api/auth/redeemid/buy: add a plan to the cart and check out.
3
+ *
4
+ * Takes a product handle, looks its variant up again on Shopify (never
5
+ * trusting a posted variant or price), adds it to the visitor's Shopify
6
+ * cart and sends them to that cart's checkout. A
7
+ * signed-in member's email is passed along so the order lands on the same
8
+ * customer. Tags, and so access, still only change when Shopify says so.
9
+ */
10
+
11
+ import type { APIRoute } from "astro";
12
+
13
+ import { readConfig, storeDomain } from "../config.js";
14
+ import { safeReturn } from "../access.js";
15
+ import { memberForUser, stores } from "../storage.js";
16
+ import { saveCart, readCart } from "../cart.js";
17
+ import { addToCart, plan } from "../storefront.js";
18
+ import { fail } from "./shared.js";
19
+
20
+ export const prerender = false;
21
+
22
+ export const POST: APIRoute = async ({ request, locals, session, redirect }) => {
23
+ const form = await request.formData().catch(() => null);
24
+ const returnTo = safeReturn(String(form?.get("return") ?? "/"));
25
+ const handle = String(form?.get("plan") ?? "").trim();
26
+ const emdash = (locals as { emdash?: { config?: unknown; db?: unknown } }).emdash;
27
+ const config = readConfig(emdash?.config);
28
+ if (!config || !storeDomain(config)) return fail(redirect, returnTo, "not_configured");
29
+ if (!/^[a-z0-9][a-z0-9-]*$/i.test(handle)) return fail(redirect, returnTo, "plan_unavailable");
30
+
31
+ try {
32
+ const p = await plan(config, handle);
33
+ if (!p?.variantId || !p.availableForSale) return fail(redirect, returnTo, "plan_unavailable");
34
+ // Provider routes are public, so read the member from the session.
35
+ const user = (await session?.get("user")) as { id?: string } | undefined;
36
+ const member = user?.id && emdash?.db ? await memberForUser(stores(emdash.db), user.id) : null;
37
+ const current = await readCart(session);
38
+ const cart = await addToCart(
39
+ config,
40
+ current?.id ?? null,
41
+ { variantId: p.variantId, sellingPlanId: p.sellingPlanId },
42
+ { email: member?.email ?? null, buyerIp: request.headers.get("cf-connecting-ip") },
43
+ );
44
+ saveCart(session, { id: cart.id, count: cart.totalQuantity });
45
+ return redirect(cart.checkoutUrl, 303);
46
+ } catch {
47
+ return fail(redirect, returnTo, "checkout_failed");
48
+ }
49
+ };
@@ -0,0 +1,106 @@
1
+ /**
2
+ * GET /_emdash/api/auth/redeemid/callback
3
+ *
4
+ * Finishes Shopify customer account sign-in: checks state, exchanges the code,
5
+ * verifies the ID token, reads the customer, links or creates an EmDash
6
+ * Subscriber, mirrors the customer and starts the session.
7
+ *
8
+ * Refused: staff accounts (any role above Subscriber), disabled users, sites
9
+ * whose setup is not finished, and customers without a verified email.
10
+ */
11
+
12
+ import { findOrCreateOAuthUser, OAuthError, Role } from "@emdash-cms/auth";
13
+ import { createKyselyAdapter, type AuthTables } from "@emdash-cms/auth/adapters/kysely";
14
+ import type { APIRoute } from "astro";
15
+ import { OptionsRepository } from "emdash/api/route-utils";
16
+ import type { Kysely } from "kysely";
17
+
18
+ import { env, ENV, PROVIDER_ID, readConfig, storeDomain } from "../config.js";
19
+ import { discover, exchangeCode, identity, ShopifyError, verifyIdToken } from "../shopify.js";
20
+ import { emptyMember, stores } from "../storage.js";
21
+ import { callbackUrl, fail, FLOW_TTL_MS, SESSION_FLOW, SESSION_ID_TOKEN, type Flow } from "./shared.js";
22
+
23
+ export const prerender = false;
24
+
25
+ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
26
+ const url = new URL(request.url);
27
+ const emdash = (locals as { emdash?: { db?: unknown; config?: unknown } }).emdash;
28
+ const flow = (await session?.get(SESSION_FLOW)) as Flow | undefined;
29
+ session?.delete(SESSION_FLOW);
30
+ const returnTo = flow?.returnTo ?? "/";
31
+
32
+ const config = readConfig(emdash?.config);
33
+ const clientId = env(ENV.clientId);
34
+ if (!config || !storeDomain(config) || !clientId || !emdash?.db || !session) return fail(redirect, returnTo, "not_configured");
35
+
36
+ if (url.searchParams.get("error")) return fail(redirect, returnTo, "sign_in_cancelled");
37
+
38
+ const state = url.searchParams.get("state");
39
+ const code = url.searchParams.get("code");
40
+ if (!flow || !state || state !== flow.state || !code || Date.now() - flow.at > FLOW_TTL_MS) {
41
+ return fail(redirect, returnTo, "sign_in_expired");
42
+ }
43
+
44
+ try {
45
+ const options = new OptionsRepository(emdash.db as ConstructorParameters<typeof OptionsRepository>[0]);
46
+ const setup = await options.get("emdash:setup_complete");
47
+ // Never let a customer become the first (admin) user.
48
+ if (setup !== true && setup !== "true") return fail(redirect, returnTo, "site_not_ready");
49
+
50
+ const d = await discover(config);
51
+ const tokens = await exchangeCode(d, {
52
+ clientId,
53
+ clientSecret: env(ENV.clientSecret),
54
+ redirectUri: callbackUrl(url, emdash.config),
55
+ origin: new URL(callbackUrl(url, emdash.config)).origin,
56
+ code,
57
+ verifier: flow.verifier,
58
+ });
59
+ const claims = await verifyIdToken(d, tokens.idToken, { clientId, nonce: flow.nonce });
60
+ const who = await identity(d, tokens.accessToken);
61
+
62
+ const email = (typeof claims.email === "string" ? claims.email : who.email)?.toLowerCase() ?? null;
63
+ if (!email) return fail(redirect, returnTo, "email_required");
64
+ const emailVerified = claims.email_verified === true;
65
+
66
+ const adapter = createKyselyAdapter(emdash.db as Kysely<AuthTables>);
67
+
68
+ // Staff accounts never sign in through the store, even with the same email.
69
+ const linked = await adapter.getOAuthAccount(PROVIDER_ID, who.customerId);
70
+ const existing = linked ? await adapter.getUserById(linked.userId) : await adapter.getUserByEmail(email);
71
+ if (existing && existing.role > Role.SUBSCRIBER) return fail(redirect, returnTo, "staff_account");
72
+
73
+ const name = [who.firstName, who.lastName].filter(Boolean).join(" ") || null;
74
+ const user = await findOrCreateOAuthUser(
75
+ adapter,
76
+ PROVIDER_ID,
77
+ { id: who.customerId, email, name, avatarUrl: null, emailVerified },
78
+ async () => ({ allowed: true, role: Role.SUBSCRIBER }),
79
+ { requireVerifiedEmail: true },
80
+ );
81
+ if (user.disabled) return fail(redirect, returnTo, "account_disabled");
82
+ if (user.role > Role.SUBSCRIBER) return fail(redirect, returnTo, "staff_account");
83
+
84
+ const s = stores(emdash.db);
85
+ const member = (await s.members.get(who.customerId)) ?? emptyMember(who.customerId);
86
+ await s.members.put(who.customerId, {
87
+ ...member,
88
+ userId: user.id,
89
+ email,
90
+ firstName: who.firstName,
91
+ lastName: who.lastName,
92
+ tags: who.tags,
93
+ syncedAt: new Date().toISOString(),
94
+ source: "sign-in",
95
+ });
96
+
97
+ session.regenerate?.();
98
+ session.set("user", { id: user.id });
99
+ session.set(SESSION_ID_TOKEN, tokens.idToken);
100
+ return redirect(returnTo);
101
+ } catch (e) {
102
+ const code = e instanceof ShopifyError ? e.code : e instanceof OAuthError ? "signup_not_allowed" : "sign_in_failed";
103
+ console.error("[redeemid] sign-in failed:", (e as Error).message);
104
+ return fail(redirect, returnTo, code);
105
+ }
106
+ };
@@ -0,0 +1,37 @@
1
+ /**
2
+ * GET /_emdash/api/auth/redeemid/cart?return=/path
3
+ *
4
+ * Sends the visitor to their Shopify cart's checkout. With no cart, or one
5
+ * Shopify has closed (its checkout completed), they go back with the
6
+ * `cart_empty` notice and the stored cart is cleared.
7
+ */
8
+
9
+ import type { APIRoute } from "astro";
10
+
11
+ import { safeReturn } from "../access.js";
12
+ import { readCart, saveCart } from "../cart.js";
13
+ import { readConfig, storeDomain } from "../config.js";
14
+ import { getCart } from "../storefront.js";
15
+ import { fail } from "./shared.js";
16
+
17
+ export const prerender = false;
18
+
19
+ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
20
+ const returnTo = safeReturn(new URL(request.url).searchParams.get("return"));
21
+ const config = readConfig((locals as { emdash?: { config?: unknown } }).emdash?.config);
22
+ if (!config || !storeDomain(config)) return fail(redirect, returnTo, "not_configured");
23
+
24
+ const ref = await readCart(session);
25
+ if (!ref) return fail(redirect, returnTo, "cart_empty");
26
+ try {
27
+ const cart = await getCart(config, ref.id, { buyerIp: request.headers.get("cf-connecting-ip") });
28
+ if (!cart || cart.totalQuantity < 1) {
29
+ saveCart(session, null);
30
+ return fail(redirect, returnTo, "cart_empty");
31
+ }
32
+ saveCart(session, { id: cart.id, count: cart.totalQuantity });
33
+ return redirect(cart.checkoutUrl, 303);
34
+ } catch {
35
+ return fail(redirect, returnTo, "checkout_failed");
36
+ }
37
+ };
@@ -0,0 +1,49 @@
1
+ /**
2
+ * GET /_emdash/api/auth/redeemid/login?return=/path
3
+ *
4
+ * Starts Shopify customer account sign-in (authorization code + PKCE).
5
+ */
6
+
7
+ import type { APIRoute } from "astro";
8
+
9
+ import { safeReturn } from "../access.js";
10
+ import { env, ENV, readConfig, storeDomain } from "../config.js";
11
+ import { authorizeUrl, challengeFor, discover, randomToken } from "../shopify.js";
12
+ import { callbackUrl, fail, SESSION_FLOW, type Flow } from "./shared.js";
13
+
14
+ export const prerender = false;
15
+
16
+ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
17
+ const url = new URL(request.url);
18
+ const returnTo = safeReturn(url.searchParams.get("return"));
19
+ const emdash = (locals as { emdash?: { config?: unknown } }).emdash;
20
+ const config = readConfig(emdash?.config);
21
+ const clientId = env(ENV.clientId);
22
+
23
+ if (!config || !storeDomain(config) || !clientId || !session) return fail(redirect, returnTo, "not_configured");
24
+
25
+ try {
26
+ const d = await discover(config);
27
+ const flow: Flow = {
28
+ state: randomToken(),
29
+ nonce: randomToken(),
30
+ verifier: randomToken(48),
31
+ returnTo,
32
+ at: Date.now(),
33
+ };
34
+ session.set(SESSION_FLOW, flow);
35
+ return redirect(
36
+ authorizeUrl(d, {
37
+ clientId,
38
+ redirectUri: callbackUrl(url, emdash?.config),
39
+ state: flow.state,
40
+ nonce: flow.nonce,
41
+ challenge: await challengeFor(flow.verifier),
42
+ locale: request.headers.get("accept-language")?.split(",")[0]?.trim(),
43
+ }),
44
+ );
45
+ } catch (e) {
46
+ console.error("[redeemid] sign-in start failed:", (e as Error).message);
47
+ return fail(redirect, returnTo, "sign_in_unavailable");
48
+ }
49
+ };
@@ -0,0 +1,41 @@
1
+ /**
2
+ * POST /_emdash/api/auth/redeemid/logout?return=/path
3
+ *
4
+ * Ends the site session, then the Shopify customer session when the store
5
+ * publishes an end-session endpoint. After the store session ends the member
6
+ * lands on the site root (the logout URI registered in Shopify); without a
7
+ * store session they go straight back to `return`. POST only, so a link elsewhere can't
8
+ * sign members out.
9
+ */
10
+
11
+ import type { APIRoute } from "astro";
12
+ import { getPublicOrigin } from "emdash/api/route-utils";
13
+
14
+ import { safeReturn } from "../access.js";
15
+ import { readConfig } from "../config.js";
16
+ import { discover, logoutUrl } from "../shopify.js";
17
+ import { SESSION_ID_TOKEN } from "./shared.js";
18
+
19
+ export const prerender = false;
20
+
21
+ export const POST: APIRoute = async ({ request, locals, session, redirect }) => {
22
+ const url = new URL(request.url);
23
+ const returnTo = safeReturn(url.searchParams.get("return"));
24
+ const emdash = (locals as { emdash?: { config?: unknown } }).emdash;
25
+ const idToken = (await session?.get(SESSION_ID_TOKEN)) as string | undefined;
26
+ session?.destroy();
27
+
28
+ const config = readConfig(emdash?.config);
29
+ if (config && idToken) {
30
+ try {
31
+ const origin = getPublicOrigin(url, emdash?.config as Parameters<typeof getPublicOrigin>[1]);
32
+ // Shopify only returns to a registered logout URI, so register the
33
+ // site root and come back there rather than to the page.
34
+ const to = logoutUrl(await discover(config), idToken, `${origin}/`);
35
+ if (to) return redirect(to);
36
+ } catch {
37
+ // The site session is already gone; Shopify's ends on its own.
38
+ }
39
+ }
40
+ return redirect(returnTo);
41
+ };
@@ -0,0 +1,26 @@
1
+ import { getPublicOrigin } from "emdash/api/route-utils";
2
+
3
+ export const SESSION_FLOW = "redeemid:flow";
4
+ export const SESSION_ID_TOKEN = "redeemid:id_token";
5
+ /** A sign-in started more than this long ago is refused. */
6
+ export const FLOW_TTL_MS = 10 * 60 * 1000;
7
+
8
+ export interface Flow {
9
+ state: string;
10
+ nonce: string;
11
+ verifier: string;
12
+ returnTo: string;
13
+ at: number;
14
+ }
15
+
16
+ export const CALLBACK_PATH = "/_emdash/api/auth/redeemid/callback";
17
+
18
+ export function callbackUrl(url: URL, emdashConfig: unknown): string {
19
+ return `${getPublicOrigin(url, emdashConfig as Parameters<typeof getPublicOrigin>[1])}${CALLBACK_PATH}`;
20
+ }
21
+
22
+ /** Back to where the member was, with a notice code the theme can show. */
23
+ export function fail(redirect: (to: string) => Response, returnTo: string, code: string): Response {
24
+ const sep = returnTo.includes("?") ? "&" : "?";
25
+ return redirect(`${returnTo}${sep}redeemid_notice=${encodeURIComponent(code)}`);
26
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * POST /_emdash/api/auth/redeemid/webhook
3
+ *
4
+ * Shopify webhooks. Verifies the HMAC over the raw body, drops duplicate
5
+ * deliveries, and updates the member mirror. Storage errors answer 500 so
6
+ * Shopify retries; a failed delivery is never recorded as seen.
7
+ */
8
+
9
+ import type { APIRoute } from "astro";
10
+
11
+ import { env, ENV } from "../config.js";
12
+ import { stores } from "../storage.js";
13
+ import { apply, kindOf, verifyHmac } from "../webhooks.js";
14
+
15
+ export const prerender = false;
16
+
17
+ const MAX_BYTES = 1024 * 1024;
18
+
19
+ export const POST: APIRoute = async ({ request, locals }) => {
20
+ const emdash = (locals as { emdash?: { db?: unknown } }).emdash;
21
+ const secret = env(ENV.webhookSecret);
22
+ if (!emdash?.db || !secret) return new Response("Not configured", { status: 503 });
23
+
24
+ const body = new Uint8Array(await request.arrayBuffer());
25
+ if (body.byteLength > MAX_BYTES) return new Response("Too large", { status: 413 });
26
+
27
+ const ok = await verifyHmac(body, request.headers.get("x-shopify-hmac-sha256") ?? "", secret);
28
+ if (!ok) return new Response("Bad signature", { status: 401 });
29
+
30
+ const topic = (request.headers.get("x-shopify-topic") ?? "").toLowerCase();
31
+ const delivery = request.headers.get("x-shopify-event-id") || request.headers.get("x-shopify-webhook-id");
32
+ if (!kindOf(topic)) return new Response("Ignored", { status: 200 });
33
+
34
+ let payload: Record<string, unknown>;
35
+ try {
36
+ payload = JSON.parse(new TextDecoder().decode(body)) as Record<string, unknown>;
37
+ } catch {
38
+ return new Response("Bad JSON", { status: 400 });
39
+ }
40
+
41
+ const s = stores(emdash.db);
42
+ if (delivery) {
43
+ const claimed = await s.deliveries.compareAndSet(delivery, null, {
44
+ topic,
45
+ receivedAt: new Date().toISOString(),
46
+ });
47
+ if (!claimed.applied) return new Response("Duplicate", { status: 200 });
48
+ }
49
+
50
+ try {
51
+ const outcome = await apply(s, topic, payload);
52
+ return new Response(outcome, { status: 200 });
53
+ } catch (e) {
54
+ console.error("[redeemid] webhook failed:", topic, (e as Error).message);
55
+ if (delivery) await s.deliveries.delete(delivery).catch(() => undefined);
56
+ return new Response("Retry", { status: 500 });
57
+ }
58
+ };
package/src/shopify.ts ADDED
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Shopify Customer Account API: discovery, PKCE, token exchange, ID token
3
+ * checks and the identity query. Web APIs only, so it runs on Node and Workers.
4
+ */
5
+
6
+ import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
7
+
8
+ import { shopOrigin, type RedeemIDConfig } from "./config.js";
9
+
10
+ /** Customer Account API version, pinned on the discovered GraphQL URL. */
11
+ export const API_VERSION = "2026-10";
12
+ export const SCOPES = "openid email customer-account-api:full";
13
+
14
+ export interface Discovery {
15
+ issuer: string;
16
+ authorizationEndpoint: string;
17
+ tokenEndpoint: string;
18
+ endSessionEndpoint: string;
19
+ jwksUri: string;
20
+ graphqlApi: string;
21
+ }
22
+
23
+ const DISCOVERY_TTL_MS = 60 * 60 * 1000;
24
+ const discoveryCache = new Map<string, { at: number; value: Discovery }>();
25
+ const jwksCache = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
26
+
27
+ /**
28
+ * Shopify answers 403 to requests with no User-Agent, and fetch on
29
+ * Cloudflare Workers sends none, so every server-side call names itself.
30
+ */
31
+ export const USER_AGENT = "@redeemid/emdash (+https://github.com/webmultipliers/redeemid-emdash)";
32
+
33
+ export class ShopifyError extends Error {
34
+ constructor(
35
+ public readonly code: string,
36
+ message: string,
37
+ ) {
38
+ super(message);
39
+ }
40
+ }
41
+
42
+ /** Point the discovered GraphQL URL at the pinned version. */
43
+ export function pinVersion(url: string): string {
44
+ return url.replace(/\/api\/(?:\d{4}-\d{2}|unstable)\/graphql(?:\.json)?$/, `/api/${API_VERSION}/graphql`);
45
+ }
46
+
47
+ export async function discover(config: RedeemIDConfig, fetcher: typeof fetch = fetch): Promise<Discovery> {
48
+ const origin = shopOrigin(config);
49
+ const hit = discoveryCache.get(origin);
50
+ if (hit && Date.now() - hit.at < DISCOVERY_TTL_MS) return hit.value;
51
+
52
+ const [openid, api] = await Promise.all([
53
+ getJson(fetcher, `${origin}/.well-known/openid-configuration`),
54
+ getJson(fetcher, `${origin}/.well-known/customer-account-api`),
55
+ ]);
56
+ const str = (o: Record<string, unknown>, k: string) => (typeof o[k] === "string" ? (o[k] as string) : "");
57
+
58
+ const value: Discovery = {
59
+ issuer: str(openid, "issuer"),
60
+ authorizationEndpoint: str(openid, "authorization_endpoint"),
61
+ tokenEndpoint: str(openid, "token_endpoint"),
62
+ endSessionEndpoint: str(openid, "end_session_endpoint"),
63
+ jwksUri: str(openid, "jwks_uri"),
64
+ graphqlApi: pinVersion(str(api, "graphql_api")),
65
+ };
66
+ if (!value.authorizationEndpoint || !value.tokenEndpoint || !value.issuer || !value.jwksUri) {
67
+ throw new ShopifyError("discovery_failed", "The store did not publish customer account sign-in.");
68
+ }
69
+ discoveryCache.set(origin, { at: Date.now(), value });
70
+ return value;
71
+ }
72
+
73
+ async function getJson(fetcher: typeof fetch, url: string): Promise<Record<string, unknown>> {
74
+ const res = await fetcher(url, { headers: { Accept: "application/json", "User-Agent": USER_AGENT } });
75
+ if (!res.ok) throw new ShopifyError("discovery_failed", `Discovery failed (${res.status}) for ${url}`);
76
+ return (await res.json()) as Record<string, unknown>;
77
+ }
78
+
79
+ // ---------------------------------------------------------------- PKCE
80
+
81
+ export function randomToken(bytes = 32): string {
82
+ const buf = new Uint8Array(bytes);
83
+ crypto.getRandomValues(buf);
84
+ return base64url(buf);
85
+ }
86
+
87
+ export async function challengeFor(verifier: string): Promise<string> {
88
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
89
+ return base64url(new Uint8Array(digest));
90
+ }
91
+
92
+ export function base64url(bytes: Uint8Array): string {
93
+ let s = "";
94
+ for (const b of bytes) s += String.fromCharCode(b);
95
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
96
+ }
97
+
98
+ export function authorizeUrl(
99
+ d: Discovery,
100
+ args: { clientId: string; redirectUri: string; state: string; nonce: string; challenge: string; locale?: string },
101
+ ): string {
102
+ const url = new URL(d.authorizationEndpoint);
103
+ url.searchParams.set("scope", SCOPES);
104
+ url.searchParams.set("client_id", args.clientId);
105
+ url.searchParams.set("response_type", "code");
106
+ url.searchParams.set("redirect_uri", args.redirectUri);
107
+ url.searchParams.set("state", args.state);
108
+ url.searchParams.set("nonce", args.nonce);
109
+ url.searchParams.set("code_challenge", args.challenge);
110
+ url.searchParams.set("code_challenge_method", "S256");
111
+ if (args.locale) url.searchParams.set("ui_locales", args.locale);
112
+ return url.toString();
113
+ }
114
+
115
+ // ---------------------------------------------------------------- tokens
116
+
117
+ export interface Tokens {
118
+ accessToken: string;
119
+ idToken: string;
120
+ }
121
+
122
+ export async function exchangeCode(
123
+ d: Discovery,
124
+ args: { clientId: string; clientSecret: string; redirectUri: string; origin?: string; code: string; verifier: string },
125
+ fetcher: typeof fetch = fetch,
126
+ ): Promise<Tokens> {
127
+ const body = new URLSearchParams({
128
+ grant_type: "authorization_code",
129
+ client_id: args.clientId,
130
+ redirect_uri: args.redirectUri,
131
+ code: args.code,
132
+ code_verifier: args.verifier,
133
+ });
134
+ const headers: Record<string, string> = {
135
+ "Content-Type": "application/x-www-form-urlencoded",
136
+ Accept: "application/json",
137
+ "User-Agent": USER_AGENT,
138
+ };
139
+ // Public clients are checked against their registered JavaScript origins.
140
+ if (args.origin) headers.Origin = args.origin;
141
+ if (args.clientSecret) headers.Authorization = `Basic ${btoa(`${args.clientId}:${args.clientSecret}`)}`;
142
+
143
+ const res = await fetcher(d.tokenEndpoint, { method: "POST", headers, body });
144
+ const data = (await res.json().catch(() => ({}))) as Record<string, unknown>;
145
+ if (!res.ok || typeof data.access_token !== "string" || typeof data.id_token !== "string") {
146
+ throw new ShopifyError("token_failed", `Token exchange failed (${res.status}).`);
147
+ }
148
+ return { accessToken: data.access_token, idToken: data.id_token };
149
+ }
150
+
151
+ /** Verify the ID token's signature, issuer, audience, expiry and nonce. */
152
+ export async function verifyIdToken(
153
+ d: Discovery,
154
+ idToken: string,
155
+ args: { clientId: string; nonce: string },
156
+ ): Promise<JWTPayload> {
157
+ let jwks = jwksCache.get(d.jwksUri);
158
+ if (!jwks) {
159
+ jwks = createRemoteJWKSet(new URL(d.jwksUri), { headers: { "User-Agent": USER_AGENT } });
160
+ jwksCache.set(d.jwksUri, jwks);
161
+ }
162
+ try {
163
+ const { payload } = await jwtVerify(idToken, jwks, {
164
+ issuer: d.issuer,
165
+ audience: args.clientId,
166
+ clockTolerance: 60,
167
+ });
168
+ if (payload.nonce !== args.nonce) throw new ShopifyError("bad_nonce", "ID token nonce mismatch.");
169
+ return payload;
170
+ } catch (e) {
171
+ if (e instanceof ShopifyError) throw e;
172
+ throw new ShopifyError("bad_id_token", `ID token rejected: ${(e as Error).message}`);
173
+ }
174
+ }
175
+
176
+ // ---------------------------------------------------------------- identity
177
+
178
+ export interface Identity {
179
+ customerId: string;
180
+ email: string | null;
181
+ firstName: string | null;
182
+ lastName: string | null;
183
+ tags: string[];
184
+ }
185
+
186
+ export const IDENTITY_QUERY = `query RedeemIDIdentity {
187
+ customer {
188
+ id
189
+ firstName
190
+ lastName
191
+ emailAddress { emailAddress }
192
+ tags
193
+ }
194
+ }`;
195
+
196
+ export async function identity(d: Discovery, accessToken: string, fetcher: typeof fetch = fetch): Promise<Identity> {
197
+ if (!d.graphqlApi) throw new ShopifyError("identity_failed", "The store did not publish the Customer Account API.");
198
+ const res = await fetcher(d.graphqlApi, {
199
+ method: "POST",
200
+ // The Customer Account API takes the bare access token, without "Bearer".
201
+ headers: { "Content-Type": "application/json", Authorization: accessToken, "User-Agent": USER_AGENT },
202
+ body: JSON.stringify({ query: IDENTITY_QUERY }),
203
+ });
204
+ const data = (await res.json().catch(() => ({}))) as {
205
+ data?: { customer?: Record<string, unknown> | null };
206
+ };
207
+ const c = data.data?.customer;
208
+ const gid = typeof c?.id === "string" ? c.id : "";
209
+ const m = /^gid:\/\/shopify\/Customer\/(\d+)$/.exec(gid);
210
+ if (!res.ok || !c || !m?.[1]) throw new ShopifyError("identity_failed", "Shopify did not return a customer.");
211
+ const s = (v: unknown) => (typeof v === "string" && v !== "" ? v : null);
212
+ const email = (c.emailAddress as { emailAddress?: unknown } | null)?.emailAddress;
213
+ return {
214
+ customerId: m[1],
215
+ email: s(email)?.toLowerCase() ?? null,
216
+ firstName: s(c.firstName),
217
+ lastName: s(c.lastName),
218
+ tags: Array.isArray(c.tags) ? c.tags.filter((t): t is string => typeof t === "string") : [],
219
+ };
220
+ }
221
+
222
+ export function logoutUrl(d: Discovery, idToken: string, after: string): string | null {
223
+ if (!d.endSessionEndpoint) return null;
224
+ const url = new URL(d.endSessionEndpoint);
225
+ url.searchParams.set("id_token_hint", idToken);
226
+ url.searchParams.set("post_logout_redirect_uri", after);
227
+ return url.toString();
228
+ }
229
+
230
+ /** For tests: forget cached discovery and keys. */
231
+ export function resetCaches(): void {
232
+ discoveryCache.clear();
233
+ jwksCache.clear();
234
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The site's RedeemID config at request time.
3
+ *
4
+ * EmDash's anonymous fast path leaves `locals.emdash` without its config, so
5
+ * page checks read the integration's serialized config module instead. That
6
+ * module only exists inside the site's Vite build.
7
+ */
8
+ // @ts-expect-error -- virtual module provided by the EmDash integration
9
+ import virtualConfig from "virtual:emdash/config";
10
+
11
+ import { readConfig, type RedeemIDConfig } from "./config.js";
12
+
13
+ export function siteConfig(emdashConfig?: unknown): RedeemIDConfig | null {
14
+ return readConfig(emdashConfig) ?? readConfig(virtualConfig);
15
+ }