@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.
package/src/storage.ts ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The member mirror: what Shopify says about each linked customer.
3
+ *
4
+ * Stored with EmDash's auth-provider storage (namespace `auth:redeemid`), the
5
+ * one plugin-style store that is public API for provider routes and page code
6
+ * alike. Nothing on the site grants access: a member's levels are
7
+ * computed from these mirrored tags only.
8
+ */
9
+
10
+ import type { StorageCollection } from "emdash";
11
+ import { getAuthProviderStorage } from "emdash/api/route-utils";
12
+
13
+ import { PROVIDER_ID } from "./config.js";
14
+
15
+ export interface MemberRecord {
16
+ /** Numeric Shopify customer id, also the record id. */
17
+ customerId: string;
18
+ /** EmDash user id once the customer has signed in. */
19
+ userId: string | null;
20
+ email: string | null;
21
+ firstName: string | null;
22
+ lastName: string | null;
23
+ tags: string[];
24
+ /** From customers/enable|disable webhooks only. */
25
+ status: "enabled" | "disabled";
26
+ /** Shopify updated_at (ms) of the last profile write, to drop out-of-order webhooks. */
27
+ profileUpdatedAt: number | null;
28
+ /** Shopify updated_at (ms) of the last status write. */
29
+ statusUpdatedAt: number | null;
30
+ /** When anything was last written, ISO. */
31
+ syncedAt: string;
32
+ /** What wrote it last: sign-in or a webhook topic. */
33
+ source: string;
34
+ }
35
+
36
+ export interface DeliveryRecord {
37
+ topic: string;
38
+ receivedAt: string;
39
+ }
40
+
41
+ export const STORAGE_CONFIG = {
42
+ members: { indexes: ["email", "userId"] },
43
+ deliveries: { indexes: ["receivedAt"] },
44
+ };
45
+
46
+ export interface Stores {
47
+ members: StorageCollection<MemberRecord>;
48
+ deliveries: StorageCollection<DeliveryRecord>;
49
+ }
50
+
51
+ export function stores(db: unknown): Stores {
52
+ const s = getAuthProviderStorage(db as Parameters<typeof getAuthProviderStorage>[0], PROVIDER_ID, STORAGE_CONFIG);
53
+ return s as unknown as Stores;
54
+ }
55
+
56
+ export function emptyMember(customerId: string): MemberRecord {
57
+ return {
58
+ customerId,
59
+ userId: null,
60
+ email: null,
61
+ firstName: null,
62
+ lastName: null,
63
+ tags: [],
64
+ status: "enabled",
65
+ profileUpdatedAt: null,
66
+ statusUpdatedAt: null,
67
+ syncedAt: new Date().toISOString(),
68
+ source: "",
69
+ };
70
+ }
71
+
72
+ export async function memberForUser(s: Stores, userId: string): Promise<MemberRecord | null> {
73
+ const { items } = await s.members.query({ where: { userId }, limit: 1 });
74
+ return items[0]?.data ?? null;
75
+ }
76
+
77
+ /** Clean a tag list the way Shopify stores them: trimmed, unique, no blanks. */
78
+ export function cleanTags(raw: unknown): string[] {
79
+ const list = typeof raw === "string" ? raw.split(",") : Array.isArray(raw) ? raw : [];
80
+ const out: string[] = [];
81
+ for (const tag of list) {
82
+ if (typeof tag !== "string") continue;
83
+ const t = tag.trim();
84
+ if (t !== "" && !out.some((o) => o.toLowerCase() === t.toLowerCase())) out.push(t);
85
+ }
86
+ return out;
87
+ }
88
+
89
+ /** "gid://shopify/Customer/123" or 123 → "123". */
90
+ export function numericId(raw: unknown): string | null {
91
+ if (typeof raw === "number" && Number.isSafeInteger(raw) && raw > 0) return String(raw);
92
+ if (typeof raw !== "string") return null;
93
+ const m = /^(?:gid:\/\/shopify\/Customer\/)?(\d+)$/.exec(raw.trim());
94
+ return m?.[1] ?? null;
95
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Shopify Storefront API: plan details and checkout carts.
3
+ *
4
+ * Server-side calls use the private token (with the buyer's IP, as Shopify
5
+ * asks), fall back to the public token, then to tokenless access, which
6
+ * covers products and carts. Prices and availability always come from
7
+ * Shopify; nothing here decides who gets access.
8
+ */
9
+
10
+ import { env, ENV, shopOrigin, type RedeemIDConfig } from "./config.js";
11
+ import { USER_AGENT } from "./shopify.js";
12
+
13
+ export const STOREFRONT_API_VERSION = "2026-10";
14
+
15
+ export interface Plan {
16
+ handle: string;
17
+ title: string;
18
+ description: string;
19
+ availableForSale: boolean;
20
+ variantId: string | null;
21
+ sellingPlanId: string | null;
22
+ price: { amount: string; currencyCode: string } | null;
23
+ }
24
+
25
+ const PLAN_TTL_MS = 5 * 60 * 1000;
26
+ const planCache = new Map<string, { at: number; value: Plan | null }>();
27
+
28
+ export function resetStorefrontCache(): void {
29
+ planCache.clear();
30
+ }
31
+
32
+ export async function storefront<T>(
33
+ config: RedeemIDConfig,
34
+ query: string,
35
+ variables: Record<string, unknown> = {},
36
+ opts: { buyerIp?: string | null; fetcher?: typeof fetch } = {},
37
+ ): Promise<T> {
38
+ const headers: Record<string, string> = {
39
+ "Content-Type": "application/json",
40
+ Accept: "application/json",
41
+ "User-Agent": USER_AGENT,
42
+ };
43
+ const priv = env(ENV.storefrontPrivateToken);
44
+ const pub = env(ENV.storefrontPublicToken);
45
+ if (priv) {
46
+ headers["Shopify-Storefront-Private-Token"] = priv;
47
+ if (opts.buyerIp) headers["Shopify-Storefront-Buyer-IP"] = opts.buyerIp;
48
+ } else if (pub) {
49
+ headers["X-Shopify-Storefront-Access-Token"] = pub;
50
+ }
51
+ const res = await (opts.fetcher ?? fetch)(`${shopOrigin(config)}/api/${STOREFRONT_API_VERSION}/graphql.json`, {
52
+ method: "POST",
53
+ headers,
54
+ body: JSON.stringify({ query, variables }),
55
+ });
56
+ const body = (await res.json().catch(() => ({}))) as { data?: T; errors?: unknown };
57
+ if (!res.ok || !body.data) throw new Error(`Storefront API request failed (${res.status}).`);
58
+ return body.data;
59
+ }
60
+
61
+ const PLAN_QUERY = `query Plan($handle: String!) {
62
+ product(handle: $handle) {
63
+ handle title description availableForSale requiresSellingPlan
64
+ variants(first: 1) { nodes { id availableForSale price { amount currencyCode } } }
65
+ sellingPlanGroups(first: 1) { nodes { sellingPlans(first: 1) { nodes { id } } } }
66
+ }
67
+ }`;
68
+
69
+ interface PlanData {
70
+ product: {
71
+ handle: string;
72
+ title: string;
73
+ description: string;
74
+ availableForSale: boolean;
75
+ requiresSellingPlan: boolean;
76
+ variants: { nodes: Array<{ id: string; availableForSale: boolean; price: { amount: string; currencyCode: string } }> };
77
+ sellingPlanGroups: { nodes: Array<{ sellingPlans: { nodes: Array<{ id: string }> } }> };
78
+ } | null;
79
+ }
80
+
81
+ /** A product by handle, cached for five minutes. Null when Shopify has no such product. */
82
+ export async function plan(config: RedeemIDConfig, handle: string, fetcher?: typeof fetch): Promise<Plan | null> {
83
+ const key = `${shopOrigin(config)}|${handle}`;
84
+ const hit = planCache.get(key);
85
+ if (hit && Date.now() - hit.at < PLAN_TTL_MS) return hit.value;
86
+
87
+ const { product: p } = await storefront<PlanData>(config, PLAN_QUERY, { handle }, { fetcher });
88
+ const variant = p?.variants.nodes[0] ?? null;
89
+ const value: Plan | null = p
90
+ ? {
91
+ handle: p.handle,
92
+ title: p.title,
93
+ description: p.description,
94
+ availableForSale: p.availableForSale && (variant?.availableForSale ?? false),
95
+ variantId: variant?.id ?? null,
96
+ // Subscription-only products need a selling plan in the cart line.
97
+ sellingPlanId: p.requiresSellingPlan ? (p.sellingPlanGroups.nodes[0]?.sellingPlans.nodes[0]?.id ?? null) : null,
98
+ price: variant?.price ?? null,
99
+ }
100
+ : null;
101
+ planCache.set(key, { at: Date.now(), value });
102
+ return value;
103
+ }
104
+
105
+ const CART_CREATE = `mutation Cart($input: CartInput!) {
106
+ cartCreate(input: $input) { cart { checkoutUrl } userErrors { message } }
107
+ }`;
108
+
109
+ /** Create a one-line cart and return its checkout URL. */
110
+ export async function checkoutUrl(
111
+ config: RedeemIDConfig,
112
+ line: { variantId: string; sellingPlanId?: string | null },
113
+ opts: { email?: string | null; buyerIp?: string | null; fetcher?: typeof fetch } = {},
114
+ ): Promise<string> {
115
+ const input: Record<string, unknown> = {
116
+ lines: [{ merchandiseId: line.variantId, quantity: 1, ...(line.sellingPlanId ? { sellingPlanId: line.sellingPlanId } : {}) }],
117
+ };
118
+ if (opts.email) input.buyerIdentity = { email: opts.email };
119
+ const data = await storefront<{ cartCreate: { cart: { checkoutUrl: string } | null; userErrors: Array<{ message: string }> } }>(
120
+ config,
121
+ CART_CREATE,
122
+ { input },
123
+ opts,
124
+ );
125
+ const url = data.cartCreate.cart?.checkoutUrl;
126
+ if (!url) throw new Error(data.cartCreate.userErrors[0]?.message ?? "Shopify returned no checkout.");
127
+ return url;
128
+ }
129
+
130
+ // ---------------------------------------------------------------- cart
131
+
132
+ export interface Cart {
133
+ id: string;
134
+ checkoutUrl: string;
135
+ totalQuantity: number;
136
+ }
137
+
138
+ const CART_FIELDS = "id checkoutUrl totalQuantity";
139
+
140
+ const CART_GET = `query Cart($id: ID!) { cart(id: $id) { ${CART_FIELDS} } }`;
141
+
142
+ const CART_NEW = `mutation CartNew($input: CartInput!) {
143
+ cartCreate(input: $input) { cart { ${CART_FIELDS} } userErrors { message } }
144
+ }`;
145
+
146
+ const CART_ADD = `mutation CartAdd($id: ID!, $lines: [CartLineInput!]!) {
147
+ cartLinesAdd(cartId: $id, lines: $lines) { cart { ${CART_FIELDS} } userErrors { message } }
148
+ }`;
149
+
150
+ type CartResult = { cart: Cart | null; userErrors: Array<{ message: string }> };
151
+
152
+ /**
153
+ * A cart by id. Null once Shopify no longer has it, which is what happens
154
+ * after its checkout completes.
155
+ */
156
+ export async function getCart(
157
+ config: RedeemIDConfig,
158
+ id: string,
159
+ opts: { buyerIp?: string | null; fetcher?: typeof fetch } = {},
160
+ ): Promise<Cart | null> {
161
+ const data = await storefront<{ cart: Cart | null }>(config, CART_GET, { id }, opts);
162
+ return data.cart;
163
+ }
164
+
165
+ /**
166
+ * Add one line to the visitor's cart, creating the cart when there is none
167
+ * (or Shopify has dropped it). A new cart carries the member's email.
168
+ */
169
+ export async function addToCart(
170
+ config: RedeemIDConfig,
171
+ cartId: string | null,
172
+ line: { variantId: string; sellingPlanId?: string | null },
173
+ opts: { email?: string | null; buyerIp?: string | null; fetcher?: typeof fetch } = {},
174
+ ): Promise<Cart> {
175
+ const lines = [{ merchandiseId: line.variantId, quantity: 1, ...(line.sellingPlanId ? { sellingPlanId: line.sellingPlanId } : {}) }];
176
+ if (cartId) {
177
+ const data = await storefront<{ cartLinesAdd: CartResult }>(config, CART_ADD, { id: cartId, lines }, opts).catch(() => null);
178
+ if (data?.cartLinesAdd.cart) return data.cartLinesAdd.cart;
179
+ }
180
+ const input: Record<string, unknown> = { lines };
181
+ if (opts.email) input.buyerIdentity = { email: opts.email };
182
+ const data = await storefront<{ cartCreate: CartResult }>(config, CART_NEW, { input }, opts);
183
+ const cart = data.cartCreate.cart;
184
+ if (!cart) throw new Error(data.cartCreate.userErrors[0]?.message ?? "Shopify returned no cart.");
185
+ return cart;
186
+ }
package/src/version.ts ADDED
@@ -0,0 +1 @@
1
+ export const VERSION = "0.0.3";
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Shopify webhooks → member mirror.
3
+ *
4
+ * Shopify is the only source of entitlements: these handlers copy what Shopify
5
+ * says and never invent access. Status comes from customers/disable|enable
6
+ * only; a profile payload without `tags` never means "tags removed".
7
+ */
8
+
9
+ import { cleanTags, emptyMember, numericId, type MemberRecord, type Stores } from "./storage.js";
10
+
11
+ export type Kind = "profile" | "delete" | "status" | "tags" | "merge" | "compliance" | "ignored";
12
+
13
+ const TOPICS: Record<string, Kind> = {
14
+ "customers/create": "profile",
15
+ "customers/update": "profile",
16
+ "customers/delete": "delete",
17
+ "customers/disable": "status",
18
+ "customers/enable": "status",
19
+ "customers/merge": "merge",
20
+ "customer.tags_added": "tags",
21
+ "customer.tags_removed": "tags",
22
+ "customers/redact": "compliance",
23
+ "customers/data_request": "compliance",
24
+ "shop/redact": "compliance",
25
+ "customer_account_settings/update": "ignored",
26
+ };
27
+
28
+ export function kindOf(topic: string): Kind | null {
29
+ return TOPICS[topic.trim().toLowerCase()] ?? null;
30
+ }
31
+
32
+ /** Shopify signs the raw body with the app's client secret (HMAC-SHA256, base64). */
33
+ export async function verifyHmac(body: Uint8Array<ArrayBuffer>, header: string, secret: string): Promise<boolean> {
34
+ if (!secret || !header) return false;
35
+ const key = await crypto.subtle.importKey(
36
+ "raw",
37
+ new TextEncoder().encode(secret),
38
+ { name: "HMAC", hash: "SHA-256" },
39
+ false,
40
+ ["verify"],
41
+ );
42
+ let sig: Uint8Array<ArrayBuffer>;
43
+ try {
44
+ sig = Uint8Array.from(atob(header.trim()), (c) => c.charCodeAt(0));
45
+ } catch {
46
+ return false;
47
+ }
48
+ // crypto.subtle.verify compares in constant time.
49
+ return crypto.subtle.verify("HMAC", key, sig, body);
50
+ }
51
+
52
+ export async function sign(body: Uint8Array<ArrayBuffer>, secret: string): Promise<string> {
53
+ const key = await crypto.subtle.importKey(
54
+ "raw",
55
+ new TextEncoder().encode(secret),
56
+ { name: "HMAC", hash: "SHA-256" },
57
+ false,
58
+ ["sign"],
59
+ );
60
+ const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, body));
61
+ let s = "";
62
+ for (const b of mac) s += String.fromCharCode(b);
63
+ return btoa(s);
64
+ }
65
+
66
+ function time(raw: unknown): number | null {
67
+ if (typeof raw !== "string") return null;
68
+ const t = Date.parse(raw);
69
+ return Number.isFinite(t) ? t : null;
70
+ }
71
+
72
+ function str(raw: unknown): string | null {
73
+ return typeof raw === "string" && raw.trim() !== "" ? raw.trim() : null;
74
+ }
75
+
76
+ function customerOf(payload: Record<string, unknown>): string | null {
77
+ const nested = (payload.customer as { id?: unknown } | undefined)?.id;
78
+ for (const c of [payload.id, payload.customerId, payload.customer_id, nested]) {
79
+ const id = numericId(c);
80
+ if (id) return id;
81
+ }
82
+ return null;
83
+ }
84
+
85
+ export type Outcome = "processed" | "skipped" | "stale" | "ignored";
86
+
87
+ /**
88
+ * Apply one webhook to the mirror. Throws on storage errors so the caller can
89
+ * answer 500 and let Shopify retry.
90
+ */
91
+ export async function apply(s: Stores, topic: string, payload: Record<string, unknown>): Promise<Outcome> {
92
+ const kind = kindOf(topic);
93
+ if (!kind || kind === "ignored") return "ignored";
94
+
95
+ if (kind === "compliance") {
96
+ // customers/redact: drop the mirror for that customer. data_request and
97
+ // shop/redact need staff action (or are covered by deleting the app).
98
+ if (topic === "customers/redact") {
99
+ const id = customerOf(payload);
100
+ if (id) await s.members.delete(id);
101
+ return "processed";
102
+ }
103
+ return "skipped";
104
+ }
105
+
106
+ if (kind === "merge") {
107
+ if (payload.status === "failed") return "skipped";
108
+ const kept = numericId(payload.admin_graphql_api_customer_kept_id);
109
+ const gone = numericId(payload.admin_graphql_api_customer_deleted_id);
110
+ if (!kept || !gone) return "skipped";
111
+ const old = await s.members.get(gone);
112
+ if (old?.userId) {
113
+ const survivor = (await s.members.get(kept)) ?? emptyMember(kept);
114
+ if (!survivor.userId) survivor.userId = old.userId;
115
+ survivor.syncedAt = new Date().toISOString();
116
+ survivor.source = topic;
117
+ await s.members.put(kept, survivor);
118
+ }
119
+ await s.members.delete(gone);
120
+ return "processed";
121
+ }
122
+
123
+ const id = customerOf(payload);
124
+ if (!id) return "skipped";
125
+
126
+ if (kind === "delete") {
127
+ await s.members.delete(id);
128
+ return "processed";
129
+ }
130
+
131
+ const current: MemberRecord = (await s.members.get(id)) ?? emptyMember(id);
132
+ const updatedAt = time(payload.updated_at);
133
+
134
+ if (kind === "profile") {
135
+ if (updatedAt !== null && current.profileUpdatedAt !== null && updatedAt < current.profileUpdatedAt) {
136
+ return "stale";
137
+ }
138
+ current.email = str(payload.email)?.toLowerCase() ?? current.email;
139
+ current.firstName = str(payload.first_name) ?? current.firstName;
140
+ current.lastName = str(payload.last_name) ?? current.lastName;
141
+ if ("tags" in payload) current.tags = cleanTags(payload.tags);
142
+ current.profileUpdatedAt = updatedAt ?? current.profileUpdatedAt;
143
+ } else if (kind === "status") {
144
+ if (updatedAt !== null && current.statusUpdatedAt !== null && updatedAt < current.statusUpdatedAt) {
145
+ return "stale";
146
+ }
147
+ current.status = topic.endsWith("/disable") ? "disabled" : "enabled";
148
+ current.statusUpdatedAt = updatedAt ?? current.statusUpdatedAt;
149
+ } else if (kind === "tags") {
150
+ const change = cleanTags(payload.tags);
151
+ const lower = new Set(change.map((t) => t.toLowerCase()));
152
+ current.tags = topic.endsWith("tags_added")
153
+ ? cleanTags([...current.tags, ...change])
154
+ : current.tags.filter((t) => !lower.has(t.toLowerCase()));
155
+ }
156
+
157
+ current.syncedAt = new Date().toISOString();
158
+ current.source = topic;
159
+ await s.members.put(id, current);
160
+ return "processed";
161
+ }