@redeemid/emdash 0.0.16 → 0.0.18

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.
Files changed (73) hide show
  1. package/README.md +447 -232
  2. package/bin/redeemid-emdash.mjs +13 -1
  3. package/docs/developer-api.md +126 -0
  4. package/docs/shopify-setup.md +1 -1
  5. package/package.json +3 -1
  6. package/src/access-admin.ts +754 -0
  7. package/src/access.ts +84 -18
  8. package/src/admin-api.ts +9 -2
  9. package/src/agents.ts +62 -3
  10. package/src/api-version.ts +6 -0
  11. package/src/blocks/CollectionBlock.astro +4 -1
  12. package/src/blocks/SearchBlock.astro +14 -0
  13. package/src/blocks/index.ts +2 -0
  14. package/src/blocks-names.ts +1 -0
  15. package/src/cart-ops.ts +164 -0
  16. package/src/catalog.ts +287 -0
  17. package/src/checkout.ts +73 -0
  18. package/src/client/redeemid.ts +563 -0
  19. package/src/components/AccessBadge.astro +7 -5
  20. package/src/components/Account.astro +1 -1
  21. package/src/components/BrowseControls.astro +87 -0
  22. package/src/components/Cart.astro +12 -6
  23. package/src/components/CartDrawer.astro +59 -0
  24. package/src/components/CartView.astro +2 -1
  25. package/src/components/Collection.astro +23 -18
  26. package/src/components/Content.astro +10 -5
  27. package/src/components/Contribution.astro +1 -1
  28. package/src/components/Gate.astro +4 -2
  29. package/src/components/LockedPanel.astro +5 -3
  30. package/src/components/MemberCard.astro +1 -1
  31. package/src/components/Notices.astro +3 -28
  32. package/src/components/Plan.astro +1 -1
  33. package/src/components/Preferences.astro +1 -1
  34. package/src/components/Product.astro +116 -25
  35. package/src/components/ProductGrid.astro +52 -0
  36. package/src/components/ProductSearch.astro +83 -0
  37. package/src/components/Recommendations.astro +45 -0
  38. package/src/components/RedeemIDClient.astro +45 -0
  39. package/src/components/YourData.astro +1 -1
  40. package/src/config.ts +31 -5
  41. package/src/contract.ts +50 -0
  42. package/src/descriptor.ts +7 -0
  43. package/src/events.ts +3 -0
  44. package/src/features.ts +1 -1
  45. package/src/files.ts +1 -1
  46. package/src/guard.ts +5 -3
  47. package/src/index.ts +3 -0
  48. package/src/log.ts +96 -0
  49. package/src/monitor.ts +560 -0
  50. package/src/notices.ts +29 -0
  51. package/src/plugin.ts +234 -88
  52. package/src/rate-limit.ts +7 -0
  53. package/src/routes/api/cart.ts +84 -0
  54. package/src/routes/api/search.ts +49 -0
  55. package/src/routes/api/shared.ts +4 -0
  56. package/src/routes/api/state.ts +69 -0
  57. package/src/routes/buy.ts +4 -3
  58. package/src/routes/callback.ts +23 -5
  59. package/src/routes/cart-edit.ts +15 -66
  60. package/src/routes/cart.ts +4 -2
  61. package/src/routes/preferences.ts +2 -2
  62. package/src/routes/privacy.ts +2 -2
  63. package/src/routes/shared.ts +11 -0
  64. package/src/routes/webhook.ts +24 -11
  65. package/src/settings.ts +195 -0
  66. package/src/shopify.ts +37 -1
  67. package/src/site-config.ts +38 -5
  68. package/src/staff.ts +12 -23
  69. package/src/storage.ts +12 -0
  70. package/src/storefront.ts +231 -59
  71. package/src/text.ts +43 -2
  72. package/src/version.ts +1 -1
  73. package/src/webhooks.ts +2 -1
@@ -0,0 +1,49 @@
1
+ /**
2
+ * GET /_emdash/api/auth/redeemid/api/search: the shop's catalog as JSON.
3
+ *
4
+ * - ?q=tote&suggest=1 : suggestions while typing { queries, products, collections }
5
+ * - ?q=tote : a page of matching products { products, filters, pageInfo, total }
6
+ * - ?collection=shop : a page of a collection's products, with its filters
7
+ *
8
+ * Paging and filters use the same params as the components: sort, after,
9
+ * before, filter (Shopify's filter input, repeatable), price_min,
10
+ * price_max, first (1 to 50). Everything is public catalog data, so answers
11
+ * may be cached briefly by browsers and CDNs.
12
+ */
13
+
14
+ import type { APIRoute } from "astro";
15
+
16
+ import { browseFromUrl, collectionPage, searchProducts, suggest } from "../../catalog.js";
17
+ import { readConfig, storeDomain } from "../../config.js";
18
+ import { storeContext } from "../../storefront.js";
19
+ import { json } from "./shared.js";
20
+
21
+ export const prerender = false;
22
+
23
+ const PUBLIC = "public, max-age=60";
24
+
25
+ export const GET: APIRoute = async ({ request, locals }) => {
26
+ const config = readConfig((locals as { emdash?: { config?: unknown } }).emdash?.config);
27
+ if (!config || !storeDomain(config)) return json(404, { error: "not_configured" });
28
+ const url = new URL(request.url);
29
+ const first = Number.parseInt(url.searchParams.get("first") ?? "", 10);
30
+ const browse = browseFromUrl(url.searchParams, Number.isFinite(first) ? first : 24);
31
+ const context = storeContext(config, request);
32
+ // A visitor-country answer differs per visitor.
33
+ const cache = config.market?.country === "visitor" ? "private, max-age=60" : PUBLIC;
34
+ try {
35
+ const handle = (url.searchParams.get("collection") ?? "").trim();
36
+ if (handle) {
37
+ if (!/^[a-z0-9][a-z0-9-]*$/i.test(handle)) return json(400, { error: "bad_request" });
38
+ const page = await collectionPage(config, handle, browse, { context });
39
+ return page ? json(200, page, cache) : json(404, { error: "not_found" });
40
+ }
41
+ if (url.searchParams.get("suggest") === "1") {
42
+ const limit = Number.parseInt(url.searchParams.get("limit") ?? "6", 10);
43
+ return json(200, await suggest(config, browse.query, Number.isFinite(limit) ? limit : 6, { context }), cache);
44
+ }
45
+ return json(200, await searchProducts(config, browse.query, browse, { context }), cache);
46
+ } catch {
47
+ return json(502, { error: "unavailable" });
48
+ }
49
+ };
@@ -0,0 +1,4 @@
1
+ /** Shared bits of the JSON routes under /_emdash/api/auth/redeemid/api/. */
2
+
3
+ export const json = (status: number, body: unknown, cache = "private, no-store") =>
4
+ new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json", "Cache-Control": cache } });
@@ -0,0 +1,69 @@
1
+ /**
2
+ * GET /_emdash/api/auth/redeemid/api/state: the visitor's own state, for the
3
+ * browser client and other site code.
4
+ *
5
+ * { apiVersion, version, signedIn, linked, levels, features, cart: { count }, meter }
6
+ *
7
+ * `levels` and `features` are slugs. Never an email, a name, a customer id
8
+ * or a Shopify tag. Always private, never cached.
9
+ */
10
+
11
+ import type { APIRoute } from "astro";
12
+
13
+ import { levelsFor, periodKey } from "../../access.js";
14
+ import { API_VERSION } from "../../api-version.js";
15
+ import { readCart } from "../../cart.js";
16
+ import { featuresFor } from "../../features.js";
17
+ import { siteConfig } from "../../site-config.js";
18
+ import { memberForUser, stores } from "../../storage.js";
19
+ import { VERSION } from "../../version.js";
20
+ import { json } from "./shared.js";
21
+
22
+ export const prerender = false;
23
+
24
+ export interface State {
25
+ apiVersion: string;
26
+ version: string;
27
+ signedIn: boolean;
28
+ /** Signed in with a store account RedeemID has synced. */
29
+ linked: boolean;
30
+ levels: string[];
31
+ features: string[];
32
+ cart: { count: number };
33
+ meter: { used: number; limit: number; period: "month" | "week" } | null;
34
+ }
35
+
36
+ export const GET: APIRoute = async ({ locals, session }) => {
37
+ const config = await siteConfig(locals);
38
+ const db = (locals as { emdash?: { db?: unknown } }).emdash?.db;
39
+ const user = (await session?.get("user")) as { id?: string } | undefined;
40
+ const ref = await readCart(session);
41
+ const state: State = {
42
+ apiVersion: API_VERSION,
43
+ version: VERSION,
44
+ signedIn: Boolean(user?.id),
45
+ linked: false,
46
+ levels: [],
47
+ features: [],
48
+ cart: { count: ref?.count ?? 0 },
49
+ meter: null,
50
+ };
51
+ if (user?.id && db && config) {
52
+ const s = stores(db);
53
+ const member = await memberForUser(s, user.id).catch(() => null);
54
+ if (member) {
55
+ state.linked = true;
56
+ if (member.status !== "disabled") {
57
+ state.levels = levelsFor(member.tags, config.levels);
58
+ state.features = featuresFor(member, config.features, config.levels);
59
+ }
60
+ }
61
+ const meter = config.meter;
62
+ if (meter && meter.limit > 0) {
63
+ const period = meter.period ?? "month";
64
+ const record = await s.meter.get(`${user.id}:${periodKey(new Date(), period)}`).catch(() => null);
65
+ state.meter = { used: Math.min(record?.items.length ?? 0, meter.limit), limit: meter.limit, period };
66
+ }
67
+ }
68
+ return json(200, state);
69
+ };
package/src/routes/buy.ts CHANGED
@@ -14,7 +14,8 @@ import { readConfig, storeDomain } from "../config.js";
14
14
  import { safeReturn } from "../access.js";
15
15
  import { memberForUser, stores } from "../storage.js";
16
16
  import { saveCart, readCart } from "../cart.js";
17
- import { addToCart, plan } from "../storefront.js";
17
+ import { checkoutFor } from "../checkout.js";
18
+ import { addToCart, plan, storeContext } from "../storefront.js";
18
19
  import { fail } from "./shared.js";
19
20
 
20
21
  export const prerender = false;
@@ -39,10 +40,10 @@ export const POST: APIRoute = async ({ request, locals, session, redirect }) =>
39
40
  config,
40
41
  current?.id ?? null,
41
42
  { variantId: p.variantId, sellingPlanId: p.sellingPlanId },
42
- { email: member?.email ?? null, attributes: { _redeemid_page: returnTo }, buyerIp: request.headers.get("cf-connecting-ip") },
43
+ { email: member?.email ?? null, attributes: { _redeemid_page: returnTo }, buyerIp: request.headers.get("cf-connecting-ip"), context: storeContext(config, request) },
43
44
  );
44
45
  saveCart(session, { id: cart.id, count: cart.totalQuantity });
45
- return redirect(cart.checkoutUrl, 303);
46
+ return redirect(await checkoutFor(config, cart, { session, request, emdashConfig: emdash?.config }), 303);
46
47
  } catch {
47
48
  return fail(redirect, returnTo, "checkout_failed");
48
49
  }
@@ -14,14 +14,16 @@ import { createKyselyAdapter, type AuthTables } from "@emdash-cms/auth/adapters/
14
14
  import type { APIRoute } from "astro";
15
15
  import type { Kysely } from "kysely";
16
16
 
17
- import { env, ENV, PROVIDER_ID, readConfig, storeDomain } from "../config.js";
17
+ import { env, ENV, PROVIDER_ID, storeDomain } from "../config.js";
18
+ import { siteConfig } from "../site-config.js";
18
19
  import { emit, levelChange } from "../events.js";
20
+ import { record } from "../log.js";
19
21
  import { levelsFor } from "../access.js";
20
22
  import { discover, exchangeCode, identity, logoutUrl, ShopifyError, verifyIdToken, type Discovery } from "../shopify.js";
21
23
  import { emptyMember, stampTags, stores } from "../storage.js";
22
24
  import { getPublicOrigin, OptionsRepository } from "emdash/api/route-utils";
23
25
 
24
- import { afterLogoutCookie, callbackUrl, fail, FLOW_TTL_MS, SESSION_FLOW, SESSION_ID_TOKEN, type Flow } from "./shared.js";
26
+ import { afterLogoutCookie, callbackUrl, fail, FLOW_TTL_MS, SESSION_FLOW, SESSION_CUSTOMER_TOKEN, SESSION_ID_TOKEN, type CustomerToken, type Flow } from "./shared.js";
25
27
 
26
28
  export const prerender = false;
27
29
 
@@ -32,19 +34,21 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
32
34
  session?.delete(SESSION_FLOW);
33
35
  const returnTo = flow?.returnTo ?? "/";
34
36
 
35
- const config = readConfig(emdash?.config);
37
+ const config = await siteConfig(locals);
36
38
  const clientId = env(ENV.clientId);
37
39
  if (!config || !storeDomain(config) || !clientId || !emdash?.db || !session) return fail(redirect, returnTo, "not_configured");
38
40
 
39
41
  if (url.searchParams.get("error")) {
40
42
  // Refresh found no open store session: sign in as usual.
41
43
  if (flow?.refresh) return redirect(`/_emdash/api/auth/redeemid/login?return=${encodeURIComponent(returnTo)}`);
44
+ await refused("sign_in_cancelled", "ok");
42
45
  return fail(redirect, returnTo, "sign_in_cancelled");
43
46
  }
44
47
 
45
48
  const state = url.searchParams.get("state");
46
49
  const code = url.searchParams.get("code");
47
50
  if (!flow || !state || state !== flow.state || !code || Date.now() - flow.at > FLOW_TTL_MS) {
51
+ await refused("sign_in_expired", "warn");
48
52
  return fail(redirect, returnTo, "sign_in_expired");
49
53
  }
50
54
 
@@ -52,7 +56,10 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
52
56
  const options = new OptionsRepository(emdash.db as ConstructorParameters<typeof OptionsRepository>[0]);
53
57
  const setup = await options.get("emdash:setup_complete");
54
58
  // Never let a customer become the first (admin) user.
55
- if (setup !== true && setup !== "true") return fail(redirect, returnTo, "site_not_ready");
59
+ if (setup !== true && setup !== "true") {
60
+ await refused("site_not_ready", "warn");
61
+ return fail(redirect, returnTo, "site_not_ready");
62
+ }
56
63
 
57
64
  const d = await discover(config);
58
65
  const tokens = await exchangeCode(d, {
@@ -66,8 +73,11 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
66
73
  const claims = await verifyIdToken(d, tokens.idToken, { clientId, nonce: flow.nonce });
67
74
  // Refusals from here on also end the store session, so the next Sign in
68
75
  // asks for an email again instead of reusing this account.
69
- const refuse = (code: string) => signedOut(d, tokens.idToken, url, emdash.config, returnTo, code) ?? fail(redirect, returnTo, code);
70
76
  const who = await identity(d, tokens.accessToken);
77
+ const refuse = async (code: string) => {
78
+ await refused(code, "warn", who.customerId);
79
+ return signedOut(d, tokens.idToken, url, emdash.config, returnTo, code) ?? fail(redirect, returnTo, code);
80
+ };
71
81
 
72
82
  const email = (typeof claims.email === "string" ? claims.email : who.email)?.toLowerCase() ?? null;
73
83
  if (!email) return refuse("email_required");
@@ -114,20 +124,28 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
114
124
  );
115
125
 
116
126
  const levels = levelsFor(who.tags, config.levels);
127
+ await record({ event: "member.signed_in", label: flow.refresh ? "refresh" : member.linkedAt ? "sign-in" : "first sign-in", status: "ok", origin: "member", customerId: who.customerId });
117
128
  await emit(config, "member.signed_in", { customerId: who.customerId, levels, first: !member.linkedAt, refresh: Boolean(flow.refresh) });
118
129
  await levelChange(config, found ? found.tags : null, { customerId: who.customerId, tags: who.tags, status: member.status }, "sign-in");
119
130
 
120
131
  session.regenerate?.();
121
132
  session.set("user", { id: user.id });
122
133
  session.set(SESSION_ID_TOKEN, tokens.idToken);
134
+ session.set(SESSION_CUSTOMER_TOKEN, { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken ?? null, expiresAt: tokens.expiresAt ?? null } satisfies CustomerToken);
123
135
  return flow.refresh ? fail(redirect, returnTo, "refreshed") : redirect(returnTo);
124
136
  } catch (e) {
125
137
  const code = e instanceof ShopifyError ? e.code : e instanceof OAuthError ? "signup_not_allowed" : "sign_in_failed";
126
138
  console.error("[redeemid] sign-in failed:", (e as Error).message);
139
+ await refused(code, "error", null, String((e as Error).message).slice(0, 200));
127
140
  return fail(redirect, returnTo, code);
128
141
  }
129
142
  };
130
143
 
144
+ /** A sign-in that didn't finish, for App events: the reason code, never the email. */
145
+ function refused(code: string, level: "ok" | "warn" | "error", customerId: string | null = null, note?: string): Promise<void> {
146
+ return record({ event: "member.refused", label: code, status: "refused", level, origin: "member", customerId, ...(note ? { note } : {}) });
147
+ }
148
+
131
149
  /** Ends the store session, then shows `code` on `returnTo` (see AFTER_LOGOUT_COOKIE). */
132
150
  function signedOut(d: Discovery, idToken: string, url: URL, emdashConfig: unknown, returnTo: string, code: string): Response | null {
133
151
  const origin = getPublicOrigin(url, emdashConfig as Parameters<typeof getPublicOrigin>[1]);
@@ -2,95 +2,44 @@
2
2
  * POST /_emdash/api/auth/redeemid/cart-edit: change the visitor's Shopify cart.
3
3
  *
4
4
  * Fields: `action` (add | update | remove | discount), `return`, and
5
- * - add: `product` (handle), optional `variant` (must be one of that
6
- * product's variants, read again from Shopify), `quantity` (1 to 1000),
5
+ * - add: `product` (handle); the variant by `variant` (an id of that
6
+ * product's, read again from Shopify) or by one `option.<Name>` field per
7
+ * option (e.g. option.Size=M); `selling_plan` (one of the product's
8
+ * selling plans, or empty for one-time); `quantity` (1 to 1000);
7
9
  * `then` = "checkout" to go straight to checkout;
8
10
  * - update: `line` and `quantity` (0 removes the line);
9
11
  * - remove: `line`;
10
12
  * - discount: `code` (empty clears the codes).
11
13
  *
12
- * Prices, discounts and availability come from Shopify; nothing posted is
13
- * trusted beyond the ids it names. Back to `return` with a notice:
14
- * cart_added, cart_updated, discount_applied, discount_not_applicable,
15
- * plan_unavailable, cart_empty or checkout_failed.
14
+ * Back to `return` with a notice: cart_added, cart_updated,
15
+ * discount_applied, discount_not_applicable, plan_unavailable, cart_empty or
16
+ * checkout_failed. The JSON twin is api/cart.
16
17
  */
17
18
 
18
19
  import type { APIRoute } from "astro";
19
20
 
20
21
  import { safeReturn } from "../access.js";
21
- import { readCart, saveCart } from "../cart.js";
22
+ import { cartInputFromForm, editCart } from "../cart-ops.js";
23
+ import { checkoutFor } from "../checkout.js";
22
24
  import { readConfig, storeDomain } from "../config.js";
23
- import { memberForUser, stores } from "../storage.js";
24
- import { addToCart, cartDetails, product, removeLines, setDiscountCodes, updateLines } from "../storefront.js";
25
25
  import { fail } from "./shared.js";
26
26
 
27
27
  export const prerender = false;
28
28
 
29
- const MAX_QUANTITY = 1000;
30
-
31
29
  export const POST: APIRoute = async ({ request, locals, session, redirect }) => {
32
30
  const form = await request.formData().catch(() => null);
33
- const field = (k: string) => String(form?.get(k) ?? "").trim();
34
- const returnTo = safeReturn(field("return") || "/");
31
+ const returnTo = safeReturn(String(form?.get("return") ?? "").trim() || "/");
35
32
  const emdash = (locals as { emdash?: { config?: unknown; db?: unknown } }).emdash;
36
33
  const config = readConfig(emdash?.config);
37
34
  if (!config || !storeDomain(config)) return fail(redirect, returnTo, "not_configured");
38
- const buyerIp = request.headers.get("cf-connecting-ip");
39
- const action = field("action");
40
35
 
41
36
  try {
42
- if (action === "add") {
43
- const handle = field("product");
44
- const quantity = Number.parseInt(field("quantity") || "1", 10);
45
- if (!/^[a-z0-9][a-z0-9-]*$/i.test(handle) || !Number.isFinite(quantity) || quantity < 1 || quantity > MAX_QUANTITY) {
46
- return fail(redirect, returnTo, "plan_unavailable");
47
- }
48
- const p = await product(config, handle);
49
- const wanted = field("variant");
50
- const variant = wanted ? p?.variants.find((v) => v.id === wanted) : p?.variants.find((v) => v.availableForSale);
51
- if (!p || !variant?.availableForSale) return fail(redirect, returnTo, "plan_unavailable");
52
- const user = (await session?.get("user")) as { id?: string } | undefined;
53
- const member = user?.id && emdash?.db ? await memberForUser(stores(emdash.db), user.id) : null;
54
- const current = await readCart(session);
55
- const cart = await addToCart(
56
- config,
57
- current?.id ?? null,
58
- { variantId: variant.id, sellingPlanId: p.sellingPlanId, quantity },
59
- { email: member?.email ?? null, attributes: { _redeemid_page: returnTo }, buyerIp },
60
- );
61
- saveCart(session, { id: cart.id, count: cart.totalQuantity });
62
- return field("then") === "checkout" ? redirect(cart.checkoutUrl, 303) : fail(redirect, returnTo, "cart_added");
63
- }
64
-
65
- const ref = await readCart(session);
66
- if (!ref) return fail(redirect, returnTo, "cart_empty");
67
- const cart = await cartDetails(config, ref.id, { buyerIp });
68
- if (!cart) {
69
- saveCart(session, null);
70
- return fail(redirect, returnTo, "cart_empty");
71
- }
72
- const line = field("line");
73
- const owns = cart.lines.some((l) => l.id === line);
74
-
75
- if (action === "update" || action === "remove") {
76
- if (!owns) return fail(redirect, returnTo, "cart_updated");
77
- const quantity = action === "remove" ? 0 : Number.parseInt(field("quantity"), 10);
78
- if (!Number.isFinite(quantity) || quantity < 0 || quantity > MAX_QUANTITY) return fail(redirect, returnTo, "cart_updated");
79
- const next = quantity === 0 ? await removeLines(config, cart.id, [line], { buyerIp }) : await updateLines(config, cart.id, [{ id: line, quantity }], { buyerIp });
80
- saveCart(session, { id: next.id, count: next.totalQuantity });
81
- return fail(redirect, returnTo, "cart_updated");
82
- }
83
-
84
- if (action === "discount") {
85
- const code = field("code").slice(0, 100);
86
- const next = await setDiscountCodes(config, cart.id, code ? [code] : [], { buyerIp });
87
- saveCart(session, { id: next.id, count: next.totalQuantity });
88
- if (!code) return fail(redirect, returnTo, "cart_updated");
89
- const applied = next.discountCodes.find((d) => d.code.toLowerCase() === code.toLowerCase());
90
- return fail(redirect, returnTo, applied?.applicable ? "discount_applied" : "discount_not_applicable");
37
+ const input = { ...cartInputFromForm(form), page: returnTo };
38
+ const out = await editCart(input, { config, session, request, db: emdash?.db });
39
+ if (out.base && String(form?.get("then") ?? "") === "checkout") {
40
+ return redirect(await checkoutFor(config, out.base, { session, request, emdashConfig: emdash?.config }), 303);
91
41
  }
92
-
93
- return fail(redirect, returnTo, "cart_updated");
42
+ return fail(redirect, returnTo, out.notice);
94
43
  } catch {
95
44
  return fail(redirect, returnTo, "checkout_failed");
96
45
  }
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * GET /_emdash/api/auth/redeemid/cart?return=/path
3
3
  *
4
- * Sends the visitor to their Shopify cart's checkout. With no cart, or one
4
+ * Sends the visitor to their Shopify cart's checkout, signed in for members
5
+ * (see checkout.ts). With no cart, or one
5
6
  * Shopify has closed (its checkout completed), they go back with the
6
7
  * `cart_empty` notice and the stored cart is cleared.
7
8
  */
@@ -11,6 +12,7 @@ import type { APIRoute } from "astro";
11
12
  import { safeReturn } from "../access.js";
12
13
  import { readCart, saveCart } from "../cart.js";
13
14
  import { readConfig, storeDomain } from "../config.js";
15
+ import { checkoutFor } from "../checkout.js";
14
16
  import { getCart } from "../storefront.js";
15
17
  import { fail } from "./shared.js";
16
18
 
@@ -30,7 +32,7 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
30
32
  return fail(redirect, returnTo, "cart_empty");
31
33
  }
32
34
  saveCart(session, { id: cart.id, count: cart.totalQuantity });
33
- return redirect(cart.checkoutUrl, 303);
35
+ return redirect(await checkoutFor(config, cart, { session, request, emdashConfig: (locals as { emdash?: { config?: unknown } }).emdash?.config }), 303);
34
36
  } catch {
35
37
  return fail(redirect, returnTo, "checkout_failed");
36
38
  }
@@ -13,7 +13,7 @@
13
13
  import type { APIRoute } from "astro";
14
14
 
15
15
  import { safeReturn, signInUrl } from "../access.js";
16
- import { readConfig } from "../config.js";
16
+ import { siteConfig } from "../site-config.js";
17
17
  import { emit } from "../events.js";
18
18
  import { allowSave, formValues, PreferenceError, preferenceFields, preferencesConfigured, readPreferences, savePreferences } from "../preferences.js";
19
19
  import { memberForUser, stores } from "../storage.js";
@@ -26,7 +26,7 @@ const json = (status: number, body: unknown) =>
26
26
 
27
27
  async function context(locals: unknown, session: { get(k: string): Promise<unknown> } | undefined) {
28
28
  const emdash = (locals as { emdash?: { config?: unknown; db?: unknown } }).emdash;
29
- const config = readConfig(emdash?.config);
29
+ const config = await siteConfig(locals);
30
30
  if (!config || !preferencesConfigured(config) || !emdash?.db) return { error: "unavailable" as const };
31
31
  const user = (await session?.get("user")) as { id?: string } | undefined;
32
32
  if (!user?.id) return { error: "sign_in" as const, config };
@@ -12,7 +12,7 @@ import type { APIRoute } from "astro";
12
12
  import { getPublicOrigin } from "emdash/api/route-utils";
13
13
 
14
14
  import { safeReturn, signInUrl } from "../access.js";
15
- import { readConfig } from "../config.js";
15
+ import { siteConfig } from "../site-config.js";
16
16
  import { eraseMember, exportRequest } from "../privacy.js";
17
17
  import { memberForUser, stores } from "../storage.js";
18
18
  import { fail } from "./shared.js";
@@ -26,7 +26,7 @@ async function context(locals: unknown, session: { get(k: string): Promise<unkno
26
26
  if (!user?.id) return null;
27
27
  const s = stores(emdash.db);
28
28
  const member = await memberForUser(s, user.id);
29
- return member ? { s, member, config: readConfig(emdash.config), emdash } : null;
29
+ return member ? { s, member, config: await siteConfig(locals), emdash } : null;
30
30
  }
31
31
 
32
32
  export const GET: APIRoute = async ({ locals, session, redirect, url }) => {
@@ -2,6 +2,17 @@ import { getPublicOrigin } from "emdash/api/route-utils";
2
2
 
3
3
  export const SESSION_FLOW = "redeemid:flow";
4
4
  export const SESSION_ID_TOKEN = "redeemid:id_token";
5
+ /**
6
+ * The member's Customer Account API tokens, kept only in the server-side
7
+ * session, to open Shopify's checkout signed in. Never sent to the browser.
8
+ */
9
+ export const SESSION_CUSTOMER_TOKEN = "redeemid:customer_token";
10
+
11
+ export interface CustomerToken {
12
+ accessToken: string;
13
+ refreshToken: string | null;
14
+ expiresAt: number | null;
15
+ }
5
16
  /** A sign-in started more than this long ago is refused. */
6
17
  export const FLOW_TTL_MS = 10 * 60 * 1000;
7
18
 
@@ -3,15 +3,18 @@
3
3
  *
4
4
  * Shopify webhooks. Verifies the HMAC over the raw body, drops duplicate
5
5
  * deliveries, and updates the member mirror. Storage errors answer 500 so
6
- * Shopify retries; a failed delivery is never recorded as seen.
6
+ * Shopify retries; a failed delivery is never recorded as seen. Each delivery,
7
+ * rejection and failure goes to App events (topic, outcome, customer id).
7
8
  */
8
9
 
9
10
  import type { APIRoute } from "astro";
10
11
 
11
- import { env, ENV, readConfig } from "../config.js";
12
+ import { env, ENV } from "../config.js";
13
+ import { siteConfig } from "../site-config.js";
12
14
  import { levelChange } from "../events.js";
13
15
  import { stores } from "../storage.js";
14
- import { apply, kindOf, verifyHmac } from "../webhooks.js";
16
+ import { record } from "../log.js";
17
+ import { apply, customerOf, kindOf, verifyHmac } from "../webhooks.js";
15
18
 
16
19
  export const prerender = false;
17
20
 
@@ -26,9 +29,12 @@ export const POST: APIRoute = async ({ request, locals }) => {
26
29
  if (body.byteLength > MAX_BYTES) return new Response("Too large", { status: 413 });
27
30
 
28
31
  const ok = await verifyHmac(body, request.headers.get("x-shopify-hmac-sha256") ?? "", secret);
29
- if (!ok) return new Response("Bad signature", { status: 401 });
30
-
31
32
  const topic = (request.headers.get("x-shopify-topic") ?? "").toLowerCase();
33
+ if (!ok) {
34
+ await record({ event: "webhook.rejected", label: topic || "unknown", status: "rejected", level: "warn", origin: "webhook", note: "Bad signature" });
35
+ return new Response("Bad signature", { status: 401 });
36
+ }
37
+
32
38
  const delivery = request.headers.get("x-shopify-event-id") || request.headers.get("x-shopify-webhook-id");
33
39
  if (!kindOf(topic)) return new Response("Ignored", { status: 200 });
34
40
 
@@ -36,25 +42,32 @@ export const POST: APIRoute = async ({ request, locals }) => {
36
42
  try {
37
43
  payload = JSON.parse(new TextDecoder().decode(body)) as Record<string, unknown>;
38
44
  } catch {
45
+ await record({ event: "webhook.rejected", label: topic, status: "bad JSON", level: "warn", origin: "webhook" });
39
46
  return new Response("Bad JSON", { status: 400 });
40
47
  }
41
48
 
42
49
  const s = stores(emdash.db);
50
+ const started = Date.now();
51
+ const customerId = topic === "shop/redact" ? null : customerOf(payload);
52
+ const receivedAt = new Date().toISOString();
43
53
  if (delivery) {
44
- const claimed = await s.deliveries.compareAndSet(delivery, null, {
45
- topic,
46
- receivedAt: new Date().toISOString(),
47
- });
48
- if (!claimed.applied) return new Response("Duplicate", { status: 200 });
54
+ const claimed = await s.deliveries.compareAndSet(delivery, null, { topic, receivedAt, outcome: "pending", customerId });
55
+ if (!claimed.applied) {
56
+ await record({ event: "webhook.received", label: topic, status: "duplicate", origin: "webhook", customerId });
57
+ return new Response("Duplicate", { status: 200 });
58
+ }
49
59
  }
50
60
 
51
61
  try {
52
- const config = readConfig(emdash.config);
62
+ const config = await siteConfig(locals);
53
63
  const outcome = await apply(s, topic, payload, (before, after) => levelChange(config, before, after, topic), config);
64
+ if (delivery) await s.deliveries.put(delivery, { topic, receivedAt, outcome, customerId }).catch(() => undefined);
65
+ await record({ event: "webhook.received", label: topic, status: outcome, origin: "webhook", customerId, ms: Date.now() - started });
54
66
  return new Response(outcome, { status: 200 });
55
67
  } catch (e) {
56
68
  console.error("[redeemid] webhook failed:", topic, (e as Error).message);
57
69
  if (delivery) await s.deliveries.delete(delivery).catch(() => undefined);
70
+ await record({ event: "webhook.failed", label: topic, status: "error", level: "error", origin: "webhook", customerId, ms: Date.now() - started, note: String((e as Error).message).slice(0, 200) });
58
71
  return new Response("Retry", { status: 500 });
59
72
  }
60
73
  };