@redeemid/emdash 0.0.15 → 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 +11 -1
- package/package.json +2 -1
- package/src/astro-modules.d.ts +4 -0
- package/src/components/Notices.astro +1 -0
- package/src/index.ts +4 -1
- package/src/rate-limit-cloudflare.ts +20 -0
- package/src/rate-limit.ts +66 -0
- package/src/text.ts +1 -0
- package/src/version.ts +1 -1
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.
|
|
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
|
|
|
@@ -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.
|
|
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/astro-modules.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ const DEFAULTS: Record<string, string> = {
|
|
|
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
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.",
|
|
20
21
|
site_not_ready: "This site isn't ready for sign-in yet.",
|
|
21
22
|
email_required: "Your account needs an email address to sign in.",
|
|
22
23
|
token_failed: "Sign-in didn't finish. Try again.",
|
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
|
},
|
|
@@ -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
|
+
}
|
package/src/text.ts
CHANGED
|
@@ -85,6 +85,7 @@ export const STRINGS = {
|
|
|
85
85
|
"That sign-in took too long. Try again.",
|
|
86
86
|
"This account can't sign in here. Use a different email.",
|
|
87
87
|
"Your account is turned off. Contact the store for help.",
|
|
88
|
+
"Too many tries. Wait a minute, then try again.",
|
|
88
89
|
"This site isn't ready for sign-in yet.",
|
|
89
90
|
"Your account needs an email address to sign in.",
|
|
90
91
|
"Sign-in didn't finish. Try again.",
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.0.
|
|
1
|
+
export const VERSION = "0.0.16";
|