@redeemid/emdash 0.0.18 → 0.0.19

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
@@ -7,7 +7,7 @@ Memberships and paywalls for [EmDash](https://github.com/emdash-cms/emdash) site
7
7
 
8
8
  Readers sign in with the store's own customer account. Their Shopify customer tags decide what they can read. Shopify keeps the store, checkout, orders and subscriptions, and EmDash keeps the content. Nothing on the site grants access by itself, so there is one source of truth for who paid for what.
9
9
 
10
- **Status: 0.0.18, early.** Runs on Node and Cloudflare Workers. Every feature below is covered by unit tests and an end-to-end test that boots a real EmDash site against a fake Shopify store. Expect changes before 1.0.
10
+ **Status: 0.0.19, early.** Runs on Node and Cloudflare Workers. Every feature below is covered by unit tests and an end-to-end test that boots a real EmDash site against a fake Shopify store. Expect changes before 1.0.
11
11
 
12
12
  ## Contents
13
13
 
@@ -47,7 +47,7 @@ Under `/_emdash/api/auth/redeemid/`. Same-origin only: EmDash checks `Origin` on
47
47
  - `api/state` (GET): the visitor's own state.
48
48
 
49
49
  ```json
50
- { "apiVersion": "1.0.0", "version": "0.0.18", "signedIn": true, "linked": true,
50
+ { "apiVersion": "1.0.0", "version": "0.0.19", "signedIn": true, "linked": true,
51
51
  "levels": ["gold"], "features": ["ad-free"], "cart": { "count": 2 },
52
52
  "meter": { "used": 1, "limit": 3, "period": "month" } }
53
53
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redeemid/emdash",
3
- "version": "0.0.18",
3
+ "version": "0.0.19",
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",
package/src/files.ts CHANGED
@@ -118,7 +118,8 @@ export async function fileAccess(astro: AstroLike, config: RedeemIDConfig | null
118
118
  if (!config?.files?.length) return { protected: false };
119
119
  const locals = astro.locals as { user?: { role?: number } | null; emdash?: { db?: unknown } | null };
120
120
  const db = locals.emdash?.db;
121
- if (!db) return { protected: false };
121
+ // No database to look the file up in: the caller fails closed.
122
+ if (!db) throw new Error("no database for the file check");
122
123
  const media = await mediaByKey(db, key);
123
124
  const rule = ruleFor(config, media?.folder ?? null);
124
125
  if (!media || !rule) return { protected: false };
@@ -162,12 +163,49 @@ export function keysFromPath(pathname: string): string[] | null {
162
163
  break;
163
164
  }
164
165
  out.push(decoded);
166
+ // The file route drops slashes around the key, so the check does too.
167
+ const trimmed = decoded.replace(/^\/+|\/+$/g, "");
168
+ if (trimmed && trimmed !== decoded) out.push(trimmed);
165
169
  if (decoded === candidate) break;
166
170
  candidate = decoded;
167
171
  }
168
172
  return [...new Set(out)];
169
173
  }
170
174
 
175
+ /** Astro's image endpoint, which reads the file its `href` names and answers with it. */
176
+ export const IMAGE_PATH = "/_image";
177
+
178
+ /**
179
+ * The storage keys a request could reach: a file URL's own, or those of the
180
+ * file an image-endpoint request asks for by `href`. Null when it names none.
181
+ */
182
+ export function keysFromUrl(url: URL): string[] | null {
183
+ const direct = keysFromPath(url.pathname);
184
+ if (direct) return direct;
185
+ if (url.pathname.replace(/\/+$/, "") !== IMAGE_PATH) return null;
186
+ const out: string[] = [];
187
+ for (const href of url.searchParams.getAll("href")) {
188
+ let candidate = href;
189
+ for (let layer = 0; layer < 4; layer++) {
190
+ try {
191
+ const keys = keysFromPath(new URL(candidate, url.origin).pathname);
192
+ if (keys) out.push(...keys);
193
+ } catch {
194
+ // Not a URL: nothing to check.
195
+ }
196
+ let decoded: string;
197
+ try {
198
+ decoded = decodeURIComponent(candidate);
199
+ } catch {
200
+ break;
201
+ }
202
+ if (decoded === candidate) break;
203
+ candidate = decoded;
204
+ }
205
+ }
206
+ return out.length ? [...new Set(out)] : null;
207
+ }
208
+
171
209
  /** Decide a download that may name several keys: the first protected one decides. */
172
210
  export async function pathAccess(astro: AstroLike, config: RedeemIDConfig | null, keys: string[]): Promise<FileVerdict> {
173
211
  for (const key of keys) {
package/src/guard.ts CHANGED
@@ -23,7 +23,7 @@ import { defineMiddleware } from "astro:middleware";
23
23
 
24
24
  import { signInUrl } from "./access.js";
25
25
  import { readCart } from "./cart.js";
26
- import { keysFromPath, pathAccess, refusal } from "./files.js";
26
+ import { keysFromUrl, pathAccess, refusal } from "./files.js";
27
27
  import { AFTER_LOGOUT_COOKIE, readAfterLogout } from "./routes/shared.js";
28
28
  import { shareConfig, siteConfig } from "./site-config.js";
29
29
 
@@ -49,7 +49,14 @@ export function memberMayUse(path: string, extra: string[] = []): boolean {
49
49
  */
50
50
  async function withUser(context: { locals: unknown; session?: { get(k: string): Promise<unknown> } }) {
51
51
  const locals = context.locals as { user?: { id: string; role?: number } | null; emdash?: { db?: unknown } | null };
52
- if (locals.user || !locals.emdash?.db) return context;
52
+ if (!locals.emdash?.db) {
53
+ // EmDash leaves the database off some public requests: fetch it, so the check can run.
54
+ const { getDb } = await import("emdash/runtime");
55
+ const next = { ...locals, emdash: { ...(locals.emdash ?? {}), db: await getDb() } };
56
+ shareConfig(locals, next);
57
+ return withUser({ ...context, locals: next });
58
+ }
59
+ if (locals.user) return context;
53
60
  const sessionUser = (await context.session?.get("user")) as { id?: string } | undefined;
54
61
  if (!sessionUser?.id) return context;
55
62
  const { createKyselyAdapter } = await import("@emdash-cms/auth/adapters/kysely");
@@ -80,7 +87,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
80
87
  const config = await siteConfig(locals);
81
88
 
82
89
  // Protected files: checked for everyone, on every download.
83
- const keys = config?.files?.length ? keysFromPath(url.pathname) : null;
90
+ const keys = config?.files?.length ? keysFromUrl(url) : null;
84
91
  if (keys) {
85
92
  let verdict;
86
93
  try {
package/src/log.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  import type { StorageCollection } from "emdash";
11
11
 
12
12
  export const LOG_DAYS = 30;
13
+ const PRUNE_PAGES = 5;
13
14
 
14
15
  export type LogOrigin = "webhook" | "member" | "staff" | "agent" | "cron" | "site";
15
16
 
@@ -90,7 +91,13 @@ export async function timed<T>(event: string, label: string, run: () => Promise<
90
91
  /** Delete entries (and other dated records) older than `days`, a page at a time. */
91
92
  export async function prune<T>(store: StorageCollection<T>, field: string, days = LOG_DAYS): Promise<number> {
92
93
  const before = new Date(Date.now() - days * 86_400_000).toISOString();
93
- const { items } = await store.query({ where: { [field]: { lt: before } }, limit: 100 });
94
- if (items.length === 0) return 0;
95
- return store.deleteMany(items.map((i) => i.id));
94
+ let gone = 0;
95
+ // A few pages a run: enough to keep up with a busy site, few enough queries for one request.
96
+ for (let page = 0; page < PRUNE_PAGES; page++) {
97
+ const { items } = await store.query({ where: { [field]: { lt: before } }, limit: 100 });
98
+ if (items.length === 0) break;
99
+ gone += await store.deleteMany(items.map((i) => i.id));
100
+ if (items.length < 100) break;
101
+ }
102
+ return gone;
96
103
  }
@@ -48,7 +48,8 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
48
48
  const state = url.searchParams.get("state");
49
49
  const code = url.searchParams.get("code");
50
50
  if (!flow || !state || state !== flow.state || !code || Date.now() - flow.at > FLOW_TTL_MS) {
51
- await refused("sign_in_expired", "warn");
51
+ // A request with no sign-in behind it (a crawler, a probe) isn't worth an entry.
52
+ if (flow) await refused("sign_in_expired", "warn");
52
53
  return fail(redirect, returnTo, "sign_in_expired");
53
54
  }
54
55
 
@@ -19,6 +19,11 @@ import { apply, customerOf, kindOf, verifyHmac } from "../webhooks.js";
19
19
  export const prerender = false;
20
20
 
21
21
  const MAX_BYTES = 1024 * 1024;
22
+ /** A delivery still "pending" after this long never finished. */
23
+ const STALE_PENDING_MS = 60 * 1000;
24
+ /** At most one "Bad signature" entry per this long, so junk requests can't fill App events. */
25
+ const REJECT_LOG_MS = 60 * 1000;
26
+ let lastRejectLogged = 0;
22
27
 
23
28
  export const POST: APIRoute = async ({ request, locals }) => {
24
29
  const emdash = (locals as { emdash?: { db?: unknown; config?: unknown } }).emdash;
@@ -31,7 +36,10 @@ export const POST: APIRoute = async ({ request, locals }) => {
31
36
  const ok = await verifyHmac(body, request.headers.get("x-shopify-hmac-sha256") ?? "", secret);
32
37
  const topic = (request.headers.get("x-shopify-topic") ?? "").toLowerCase();
33
38
  if (!ok) {
34
- await record({ event: "webhook.rejected", label: topic || "unknown", status: "rejected", level: "warn", origin: "webhook", note: "Bad signature" });
39
+ if (Date.now() - lastRejectLogged > REJECT_LOG_MS) {
40
+ lastRejectLogged = Date.now();
41
+ await record({ event: "webhook.rejected", label: topic || "unknown", status: "rejected", level: "warn", origin: "webhook", note: "Bad signature" });
42
+ }
35
43
  return new Response("Bad signature", { status: 401 });
36
44
  }
37
45
 
@@ -51,7 +59,17 @@ export const POST: APIRoute = async ({ request, locals }) => {
51
59
  const customerId = topic === "shop/redact" ? null : customerOf(payload);
52
60
  const receivedAt = new Date().toISOString();
53
61
  if (delivery) {
54
- const claimed = await s.deliveries.compareAndSet(delivery, null, { topic, receivedAt, outcome: "pending", customerId });
62
+ const claim = () => s.deliveries.compareAndSet(delivery, null, { topic, receivedAt, outcome: "pending", customerId });
63
+ let claimed = await claim();
64
+ if (!claimed.applied) {
65
+ // A delivery left "pending" was cut off before it finished (a
66
+ // timeout, a crash): let Shopify's retry run it again.
67
+ const earlier = await s.deliveries.get(delivery);
68
+ if (earlier?.outcome === "pending" && Date.now() - Date.parse(earlier.receivedAt) > STALE_PENDING_MS) {
69
+ await s.deliveries.delete(delivery);
70
+ claimed = await claim();
71
+ }
72
+ }
55
73
  if (!claimed.applied) {
56
74
  await record({ event: "webhook.received", label: topic, status: "duplicate", origin: "webhook", customerId });
57
75
  return new Response("Duplicate", { status: 200 });
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.0.18";
1
+ export const VERSION = "0.0.19";