@redeemid/emdash 0.0.14 → 0.0.16

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Shopify customer accounts, member levels and paywalls for [EmDash](https://github.com/emdash-cms/emdash) sites. Shopify runs the store and the customer account; EmDash runs the content.
4
4
 
5
- **Status: 0.0.14, early.** Runs on Node and Cloudflare Workers. Sign-in, the member mirror, webhooks, levels, rules, the meter and drip, teasers, paywall markup, badges, the page gate, the Shopify cart and checkout, the staff tools, preferences, protected files, privacy requests, AI agent tools, the command line, replacement text and outbound events are covered by an end-to-end test against a fake store.
5
+ **Status: 0.0.16, early.** Runs on Node and Cloudflare Workers. Sign-in, the member mirror, webhooks, levels, rules, the meter and drip, teasers, paywall markup, badges, the page gate, the Shopify cart and checkout, the staff tools, preferences, protected files, privacy requests, AI agent tools, the command line, replacement text and outbound events are covered by an end-to-end test against a fake store.
6
6
 
7
7
  ## What it does
8
8
 
@@ -296,7 +296,7 @@ With `REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID` set (it also runs member preferences
296
296
 
297
297
  - **Member detail.** Open a member from the Members page to see their levels, tags and features, resync them from Shopify, or add and remove tags. Tag edits are made in Shopify, then read back; nothing is granted on the EmDash side.
298
298
  - **Bulk resync.** Reads the 25 members synced longest ago from Shopify in one request, few enough for the Workers Free limit of 50 database queries a request. Run it again for the next 25. It never runs on its own.
299
- - **Webhooks.** The Status page lists the store's subscriptions to this site and registers any that are missing (`customers/redact` stays in the app's own settings).
299
+ - **Webhooks.** The Status page lists the store's subscriptions to this site and registers any that are missing (`customers/redact` stays in the app's own settings). A topic Shopify refuses, such as `customers/merge` without the `read_customer_merge` scope, is named with the reason and doesn't stop the others.
300
300
  - **Recent deliveries.** The last webhooks the site took, with their topics and times.
301
301
  - **Access test.** Enter a collection, terms and a reader's tags to see what the entry needs and whether that reader gets in.
302
302
 
@@ -368,6 +368,16 @@ import Plan from "@redeemid/emdash/components/Plan.astro";
368
368
 
369
369
  A route cache keys pages by URL alone and serves a stored page without running any code. So if the site turns on Astro's route cache, don't cache the pages that render `<Content>` or `<Gate>` for locked entries: a member would get the visitor's stored copy, wall included. Call `Astro.cache.set(hint)` only on pages whose entries no rule covers.
370
370
 
371
+ ### Rate limits
372
+
373
+ On Cloudflare Workers, `redeemidGuard({ rateLimit: true })` limits how often one visitor can start Sign in, Buy or a cart change, the routes that make calls to Shopify. Add a [Rate Limiting binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/) named `REDEEMID_RATE_LIMIT` to your wrangler config; the limit and period are set there:
374
+
375
+ ```jsonc
376
+ "ratelimits": [{ "name": "REDEEMID_RATE_LIMIT", "namespace_id": "1001", "simple": { "limit": 20, "period": 60 } }]
377
+ ```
378
+
379
+ Each action counts separately per visitor (by `CF-Connecting-IP`). A visitor over the limit goes back to the page they came from with the `too_many_requests` notice. If the binding is missing or fails, nothing is limited. Off by default; leave it off on Node.
380
+
371
381
  ### Shop
372
382
 
373
383
  Products, collections and the cart come from the Storefront API; Shopify works out prices, discounts, taxes and stock.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redeemid/emdash",
3
- "version": "0.0.14",
3
+ "version": "0.0.16",
4
4
  "description": "Shopify customer accounts, member levels and paywalls for EmDash sites. Shopify runs commerce; EmDash runs content.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -14,6 +14,7 @@
14
14
  ".": "./src/index.ts",
15
15
  "./access": "./src/access.ts",
16
16
  "./guard.ts": "./src/guard.ts",
17
+ "./rate-limit-cloudflare.ts": "./src/rate-limit-cloudflare.ts",
17
18
  "./storefront": "./src/storefront.ts",
18
19
  "./features": "./src/features.ts",
19
20
  "./events": "./src/events.ts",
package/src/admin-api.ts CHANGED
@@ -176,10 +176,22 @@ export async function webhookSubscriptions(config: RedeemIDConfig, fetcher: type
176
176
  return data.webhookSubscriptions.nodes.map((n) => ({ id: n.id, topic: n.topic, uri: n.uri ?? "" }));
177
177
  }
178
178
 
179
- /** Register the topics RedeemID needs that no subscription sends to `uri` yet. Returns the topics it added. */
180
- export async function registerWebhooks(config: RedeemIDConfig, uri: string, fetcher: typeof fetch = fetch): Promise<string[]> {
179
+ /** Scopes a topic needs beyond read_customers, to explain a refusal. */
180
+ const TOPIC_SCOPES: Partial<Record<(typeof WEBHOOK_TOPICS)[number], string>> = { CUSTOMERS_MERGE: "read_customer_merge" };
181
+
182
+ /**
183
+ * Register the topics RedeemID needs that no subscription sends to `uri` yet.
184
+ * A topic Shopify refuses doesn't stop the others: it comes back in `failed`.
185
+ */
186
+ export async function registerWebhooks(
187
+ config: RedeemIDConfig,
188
+ uri: string,
189
+ fetcher: typeof fetch = fetch,
190
+ ): Promise<{ added: string[]; failed: Array<{ topic: string; message: string }> }> {
181
191
  const have = await webhookSubscriptions(config, fetcher);
182
192
  const missing = WEBHOOK_TOPICS.filter((t) => !have.some((h) => h.topic === t && h.uri === uri));
193
+ const added: string[] = [];
194
+ const failed: Array<{ topic: string; message: string }> = [];
183
195
  for (const topic of missing) {
184
196
  const data = await admin<{ webhookSubscriptionCreate: { userErrors: Array<{ message: string }> } }>(
185
197
  config,
@@ -188,7 +200,8 @@ export async function registerWebhooks(config: RedeemIDConfig, uri: string, fetc
188
200
  fetcher,
189
201
  );
190
202
  const err = data.webhookSubscriptionCreate.userErrors[0];
191
- if (err) throw new AdminError(`${topic}: ${err.message}`);
203
+ if (!err) added.push(topic);
204
+ else failed.push({ topic, message: TOPIC_SCOPES[topic] ? `${err.message} (the app needs the ${TOPIC_SCOPES[topic]} scope)` : err.message });
192
205
  }
193
- return [...missing];
206
+ return { added, failed };
194
207
  }
@@ -2,3 +2,7 @@ declare module "*.astro" {
2
2
  const Component: (props: Record<string, unknown>) => unknown;
3
3
  export default Component;
4
4
  }
5
+
6
+ declare module "cloudflare:workers" {
7
+ export const env: unknown;
8
+ }
@@ -16,6 +16,8 @@ const DEFAULTS: Record<string, string> = {
16
16
  sign_in_unavailable: "Sign-in isn't available right now. Try again in a few minutes.",
17
17
  sign_in_expired: "That sign-in took too long. Try again.",
18
18
  staff_account: "This account can't sign in here. Use a different email.",
19
+ account_disabled: "Your account is turned off. Contact the store for help.",
20
+ too_many_requests: "Too many tries. Wait a minute, then try again.",
19
21
  site_not_ready: "This site isn't ready for sign-in yet.",
20
22
  email_required: "Your account needs an email address to sign in.",
21
23
  token_failed: "Sign-in didn't finish. Try again.",
package/src/guard.ts CHANGED
@@ -24,6 +24,7 @@ import { defineMiddleware } from "astro:middleware";
24
24
  import { signInUrl } from "./access.js";
25
25
  import { readCart } from "./cart.js";
26
26
  import { keysFromPath, pathAccess, refusal } from "./files.js";
27
+ import { AFTER_LOGOUT_COOKIE, readAfterLogout } from "./routes/shared.js";
27
28
  import { siteConfig } from "./site-config.js";
28
29
 
29
30
  const SUBSCRIBER = 10;
@@ -98,6 +99,16 @@ export const onRequest = defineMiddleware(async (context, next) => {
98
99
  }
99
100
  }
100
101
 
102
+ // Back from ending the store session after a refused sign-in.
103
+ if (url.pathname === "/" && !url.searchParams.has("redeemid_notice")) {
104
+ const after = readAfterLogout(context.cookies.get(AFTER_LOGOUT_COOKIE)?.value);
105
+ if (after) {
106
+ const sep = after.returnTo.includes("?") ? "&" : "?";
107
+ const location = `${after.returnTo}${sep}redeemid_notice=${encodeURIComponent(after.code)}`;
108
+ return new Response(null, { status: 302, headers: { location, "cache-control": "private, no-store", "set-cookie": `${AFTER_LOGOUT_COOKIE}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax` } });
109
+ }
110
+ }
111
+
101
112
  if (!url.pathname.startsWith("/_emdash")) {
102
113
  const own = Boolean(user) || Boolean(await readCart(context.session as never).catch(() => null));
103
114
  return own ? keepPrivate(context, await next()) : next();
package/src/index.ts CHANGED
@@ -70,11 +70,14 @@ export function redeemid(config: RedeemIDConfig): AuthProviderDescriptor {
70
70
  * Astro integration that keeps members (EmDash Subscribers) out of EmDash's
71
71
  * content API and admin, so only RedeemID's checks decide who reads what.
72
72
  */
73
- export function redeemidGuard(): AstroIntegration {
73
+ export function redeemidGuard(options: { rateLimit?: boolean } = {}): AstroIntegration {
74
74
  return {
75
75
  name: "@redeemid/emdash/guard",
76
76
  hooks: {
77
77
  "astro:config:setup": ({ addMiddleware }) => {
78
+ // Cloudflare Workers only: limits Sign in, Buy and cart changes per
79
+ // visitor with the REDEEMID_RATE_LIMIT Rate Limiting binding.
80
+ if (options.rateLimit) addMiddleware({ entrypoint: "@redeemid/emdash/rate-limit-cloudflare.ts", order: "pre" });
78
81
  addMiddleware({ entrypoint: "@redeemid/emdash/guard.ts", order: "post" });
79
82
  },
80
83
  },
package/src/plugin.ts CHANGED
@@ -342,8 +342,10 @@ async function action(
342
342
  }
343
343
  case "webhooks_register": {
344
344
  try {
345
- const added = await registerWebhooks(config, webhookUri(request));
346
- return { ...(await statusPage(config, request)), toast: { type: "success", message: added.length ? `Registered ${added.length} webhooks.` : "Nothing was missing." } };
345
+ const { added, failed } = await registerWebhooks(config, webhookUri(request));
346
+ const done = added.length ? `Registered ${added.length} webhooks.` : failed.length ? "" : "Nothing was missing.";
347
+ const refused = failed.map((f) => `Shopify refused ${f.topic}: ${f.message}`).join(" ");
348
+ return { ...(await statusPage(config, request)), toast: { type: failed.length ? "error" : "success", message: [done, refused].filter(Boolean).join(" ") } };
347
349
  } catch (e) {
348
350
  return { ...(await statusPage(config, request)), toast: fail(e) };
349
351
  }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Rate limits on Cloudflare Workers, added by `redeemidGuard({ rateLimit: true })`.
3
+ * Reads the REDEEMID_RATE_LIMIT Rate Limiting binding from wrangler config.
4
+ */
5
+
6
+ import { defineMiddleware } from "astro:middleware";
7
+ import { env } from "cloudflare:workers";
8
+
9
+ import { RATE_LIMIT_BINDING, rateLimited, type RateLimiter } from "./rate-limit.js";
10
+
11
+ let warned = false;
12
+
13
+ export const onRequest = defineMiddleware(async (context, next) => {
14
+ const limiter = (env as Record<string, unknown>)[RATE_LIMIT_BINDING] as RateLimiter | undefined;
15
+ if (!limiter && !warned) {
16
+ warned = true;
17
+ console.warn(`[redeemid] rateLimit is on but there is no ${RATE_LIMIT_BINDING} binding; nothing is limited.`);
18
+ }
19
+ return (await rateLimited(limiter, context.request)) ?? next();
20
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Optional per-visitor rate limits on the routes that start work at Shopify:
3
+ * Sign in, Buy and cart changes. On Cloudflare Workers they use a Rate
4
+ * Limiting binding named REDEEMID_RATE_LIMIT (see rate-limit-cloudflare.ts);
5
+ * nothing is limited without one.
6
+ */
7
+
8
+ export const RATE_LIMIT_BINDING = "REDEEMID_RATE_LIMIT";
9
+
10
+ /** The shape of a Workers Rate Limiting binding. */
11
+ export interface RateLimiter {
12
+ limit(options: { key: string }): Promise<{ success: boolean }>;
13
+ }
14
+
15
+ const BASE = "/_emdash/api/auth/redeemid";
16
+ const LIMITED: Record<string, string> = {
17
+ [`GET ${BASE}/login`]: "login",
18
+ [`POST ${BASE}/buy`]: "buy",
19
+ [`POST ${BASE}/cart-edit`]: "cart",
20
+ };
21
+
22
+ /** Which limited action a request is, or null. */
23
+ export function limitedAction(method: string, path: string): string | null {
24
+ return LIMITED[`${method.toUpperCase()} ${path.replace(/\/+$/, "")}`] ?? null;
25
+ }
26
+
27
+ /** Where to send a reader who was limited: the page they came from, on this site. */
28
+ function backTo(url: URL, request: Request): string {
29
+ const ret = url.searchParams.get("return");
30
+ if (ret && ret.startsWith("/") && !ret.startsWith("//") && !ret.startsWith("/\\")) return ret;
31
+ const referer = request.headers.get("referer");
32
+ if (referer) {
33
+ try {
34
+ const r = new URL(referer);
35
+ if (r.origin === url.origin && !r.pathname.startsWith("/_emdash")) return `${r.pathname}${r.search}`;
36
+ } catch {
37
+ // ignore
38
+ }
39
+ }
40
+ return "/";
41
+ }
42
+
43
+ /**
44
+ * A response for a request over its limit, or null to let it through. Fails
45
+ * open: a limiter that errors never blocks a reader.
46
+ */
47
+ export async function rateLimited(limiter: RateLimiter | undefined, request: Request): Promise<Response | null> {
48
+ if (!limiter || typeof limiter.limit !== "function") return null;
49
+ const url = new URL(request.url);
50
+ const action = limitedAction(request.method, url.pathname);
51
+ if (!action) return null;
52
+ const ip = request.headers.get("cf-connecting-ip") ?? request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
53
+ try {
54
+ const { success } = await limiter.limit({ key: `${action}:${ip}` });
55
+ if (success) return null;
56
+ } catch (e) {
57
+ console.error("[redeemid] rate limit check failed:", (e as Error).message);
58
+ return null;
59
+ }
60
+ const back = backTo(url, request);
61
+ const sep = back.includes("?") ? "&" : "?";
62
+ return new Response(null, {
63
+ status: 303,
64
+ headers: { location: `${back}${sep}redeemid_notice=too_many_requests`, "retry-after": "60", "cache-control": "private, no-store" },
65
+ });
66
+ }
@@ -12,15 +12,16 @@
12
12
  import { findOrCreateOAuthUser, OAuthError, Role } from "@emdash-cms/auth";
13
13
  import { createKyselyAdapter, type AuthTables } from "@emdash-cms/auth/adapters/kysely";
14
14
  import type { APIRoute } from "astro";
15
- import { OptionsRepository } from "emdash/api/route-utils";
16
15
  import type { Kysely } from "kysely";
17
16
 
18
17
  import { env, ENV, PROVIDER_ID, readConfig, storeDomain } from "../config.js";
19
18
  import { emit, levelChange } from "../events.js";
20
19
  import { levelsFor } from "../access.js";
21
- import { discover, exchangeCode, identity, ShopifyError, verifyIdToken } from "../shopify.js";
20
+ import { discover, exchangeCode, identity, logoutUrl, ShopifyError, verifyIdToken, type Discovery } from "../shopify.js";
22
21
  import { emptyMember, stampTags, stores } from "../storage.js";
23
- import { callbackUrl, fail, FLOW_TTL_MS, SESSION_FLOW, SESSION_ID_TOKEN, type Flow } from "./shared.js";
22
+ import { getPublicOrigin, OptionsRepository } from "emdash/api/route-utils";
23
+
24
+ import { afterLogoutCookie, callbackUrl, fail, FLOW_TTL_MS, SESSION_FLOW, SESSION_ID_TOKEN, type Flow } from "./shared.js";
24
25
 
25
26
  export const prerender = false;
26
27
 
@@ -63,10 +64,13 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
63
64
  verifier: flow.verifier,
64
65
  });
65
66
  const claims = await verifyIdToken(d, tokens.idToken, { clientId, nonce: flow.nonce });
67
+ // Refusals from here on also end the store session, so the next Sign in
68
+ // 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);
66
70
  const who = await identity(d, tokens.accessToken);
67
71
 
68
72
  const email = (typeof claims.email === "string" ? claims.email : who.email)?.toLowerCase() ?? null;
69
- if (!email) return fail(redirect, returnTo, "email_required");
73
+ if (!email) return refuse("email_required");
70
74
  const emailVerified = claims.email_verified === true;
71
75
 
72
76
  const adapter = createKyselyAdapter(emdash.db as Kysely<AuthTables>);
@@ -74,7 +78,7 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
74
78
  // Staff accounts never sign in through the store, even with the same email.
75
79
  const linked = await adapter.getOAuthAccount(PROVIDER_ID, who.customerId);
76
80
  const existing = linked ? await adapter.getUserById(linked.userId) : await adapter.getUserByEmail(email);
77
- if (existing && existing.role > Role.SUBSCRIBER) return fail(redirect, returnTo, "staff_account");
81
+ if (existing && existing.role > Role.SUBSCRIBER) return refuse("staff_account");
78
82
 
79
83
  const name = [who.firstName, who.lastName].filter(Boolean).join(" ") || null;
80
84
  const user = await findOrCreateOAuthUser(
@@ -84,8 +88,8 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
84
88
  async () => ({ allowed: true, role: Role.SUBSCRIBER }),
85
89
  { requireVerifiedEmail: true },
86
90
  );
87
- if (user.disabled) return fail(redirect, returnTo, "account_disabled");
88
- if (user.role > Role.SUBSCRIBER) return fail(redirect, returnTo, "staff_account");
91
+ if (user.disabled) return refuse("account_disabled");
92
+ if (user.role > Role.SUBSCRIBER) return refuse("staff_account");
89
93
 
90
94
  const s = stores(emdash.db);
91
95
  const found = await s.members.get(who.customerId);
@@ -123,3 +127,11 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
123
127
  return fail(redirect, returnTo, code);
124
128
  }
125
129
  };
130
+
131
+ /** Ends the store session, then shows `code` on `returnTo` (see AFTER_LOGOUT_COOKIE). */
132
+ function signedOut(d: Discovery, idToken: string, url: URL, emdashConfig: unknown, returnTo: string, code: string): Response | null {
133
+ const origin = getPublicOrigin(url, emdashConfig as Parameters<typeof getPublicOrigin>[1]);
134
+ const to = logoutUrl(d, idToken, `${origin}/`);
135
+ if (!to) return null;
136
+ return new Response(null, { status: 302, headers: { location: to, "set-cookie": afterLogoutCookie(code, returnTo), "cache-control": "private, no-store" } });
137
+ }
@@ -26,3 +26,29 @@ export function fail(redirect: (to: string) => Response, returnTo: string, code:
26
26
  const sep = returnTo.includes("?") ? "&" : "?";
27
27
  return redirect(`${returnTo}${sep}redeemid_notice=${encodeURIComponent(code)}`);
28
28
  }
29
+
30
+ /**
31
+ * A refused sign-in also ends the store session, or the next Sign in would
32
+ * pass straight through with the same account. Shopify only returns to the
33
+ * site root after that, so this short cookie carries the notice and the page
34
+ * back; the guard middleware turns it into the usual `?redeemid_notice=`.
35
+ */
36
+ export const AFTER_LOGOUT_COOKIE = "redeemid_after_logout";
37
+ export const SIGNED_OUT_NOTICES = ["staff_account", "account_disabled", "email_required"] as const;
38
+
39
+ export function afterLogoutCookie(code: string, returnTo: string): string {
40
+ return `${AFTER_LOGOUT_COOKIE}=${encodeURIComponent(JSON.stringify([code, returnTo]))}; Path=/; Max-Age=600; HttpOnly; Secure; SameSite=Lax`;
41
+ }
42
+
43
+ /** The notice and page a refused sign-in left, or null. */
44
+ export function readAfterLogout(raw: string | undefined): { code: string; returnTo: string } | null {
45
+ if (!raw) return null;
46
+ try {
47
+ const [code, returnTo] = JSON.parse(decodeURIComponent(raw)) as [unknown, unknown];
48
+ if (typeof code !== "string" || !(SIGNED_OUT_NOTICES as readonly string[]).includes(code)) return null;
49
+ const to = typeof returnTo === "string" && returnTo.startsWith("/") && !returnTo.startsWith("//") && !returnTo.startsWith("/\\") ? returnTo : "/";
50
+ return { code, returnTo: to };
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
package/src/text.ts CHANGED
@@ -84,6 +84,8 @@ export const STRINGS = {
84
84
  "Sign-in isn't available right now. Try again in a few minutes.",
85
85
  "That sign-in took too long. Try again.",
86
86
  "This account can't sign in here. Use a different email.",
87
+ "Your account is turned off. Contact the store for help.",
88
+ "Too many tries. Wait a minute, then try again.",
87
89
  "This site isn't ready for sign-in yet.",
88
90
  "Your account needs an email address to sign in.",
89
91
  "Sign-in didn't finish. Try again.",
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.0.14";
1
+ export const VERSION = "0.0.16";