@redeemid/emdash 0.0.15 → 0.0.17

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 (46) hide show
  1. package/README.md +416 -223
  2. package/docs/shopify-setup.md +1 -1
  3. package/package.json +2 -1
  4. package/src/access-admin.ts +754 -0
  5. package/src/access.ts +84 -18
  6. package/src/admin-api.ts +9 -2
  7. package/src/agents.ts +7 -3
  8. package/src/astro-modules.d.ts +4 -0
  9. package/src/components/AccessBadge.astro +7 -5
  10. package/src/components/Account.astro +1 -1
  11. package/src/components/Cart.astro +1 -1
  12. package/src/components/CartView.astro +1 -1
  13. package/src/components/Collection.astro +1 -1
  14. package/src/components/Content.astro +7 -3
  15. package/src/components/Contribution.astro +1 -1
  16. package/src/components/Gate.astro +4 -2
  17. package/src/components/LockedPanel.astro +1 -1
  18. package/src/components/MemberCard.astro +1 -1
  19. package/src/components/Notices.astro +2 -1
  20. package/src/components/Plan.astro +1 -1
  21. package/src/components/Preferences.astro +1 -1
  22. package/src/components/Product.astro +1 -1
  23. package/src/components/YourData.astro +1 -1
  24. package/src/config.ts +17 -5
  25. package/src/descriptor.ts +7 -0
  26. package/src/events.ts +3 -0
  27. package/src/features.ts +1 -1
  28. package/src/files.ts +1 -1
  29. package/src/guard.ts +5 -3
  30. package/src/index.ts +4 -1
  31. package/src/log.ts +96 -0
  32. package/src/monitor.ts +560 -0
  33. package/src/plugin.ts +202 -86
  34. package/src/rate-limit-cloudflare.ts +20 -0
  35. package/src/rate-limit.ts +66 -0
  36. package/src/routes/callback.ts +21 -4
  37. package/src/routes/preferences.ts +2 -2
  38. package/src/routes/privacy.ts +2 -2
  39. package/src/routes/webhook.ts +24 -11
  40. package/src/settings.ts +195 -0
  41. package/src/site-config.ts +38 -5
  42. package/src/staff.ts +12 -23
  43. package/src/storage.ts +12 -0
  44. package/src/text.ts +1 -0
  45. package/src/version.ts +1 -1
  46. package/src/webhooks.ts +2 -1
package/src/plugin.ts CHANGED
@@ -6,8 +6,9 @@
6
6
  * plugins: [redeemidPlugin(config)],
7
7
  * })
8
8
  *
9
- * It adds four Portable Text blocks to the editor, a Members page, a Status
10
- * page and a dashboard widget. Sign-in, webhooks and checkout stay on the
9
+ * It adds Portable Text blocks to the editor, an Access panel on every entry,
10
+ * admin pages (Members, Access, Webhooks, App events, Reports, Status, Access
11
+ * test, Privacy), a daily webhook health check and a dashboard widget. Sign-in, webhooks and checkout stay on the
11
12
  * auth provider's routes.
12
13
  *
13
14
  * - "Paywall break" (`redeemid-paywall`): everything after it in the body is
@@ -24,19 +25,23 @@
24
25
  * part after a paywall break off the page for readers who can't read it.
25
26
  */
26
27
 
27
- import type { ResolvedPlugin, RouteContext } from "emdash";
28
+ import type { PluginContext, ResolvedPlugin, RouteContext } from "emdash";
28
29
  import { definePlugin } from "emdash";
29
30
 
30
- import { describe, levelsFor, SIGNED_IN } from "./access.js";
31
- import { env, ENV, storeDomain, unknownEnvNames, type RedeemIDConfig } from "./config.js";
31
+ import { levelsFor, SIGNED_IN, type Terms } from "./access.js";
32
+ import { accessAction, accessPage, entryPanel, entrySave, entrySlugChanged, isAccessAction, type AccessEnv, type PanelEntry } from "./access-admin.js";
33
+ import { entryKey, loadAccess, onLevels, withSaved } from "./settings.js";
34
+ import { env, ENV, storeDomain, unknownEnvNames, type LevelConfig, type RedeemIDConfig } from "./config.js";
32
35
  import { discover } from "./shopify.js";
33
36
  import { stores, type MemberRecord, type Stores } from "./storage.js";
34
- import { PAGES, WIDGETS } from "./descriptor.js";
37
+ import { EDITOR_PANELS, PAGES, WIDGETS } from "./descriptor.js";
35
38
  import { VERSION } from "./version.js";
36
39
  import { AdminError, adminConfigured } from "./admin-api.js";
37
40
  import { cancelShopifyErasure, closeRequest, eraseMember, exportMember, exportRequest, requestShopifyErasure } from "./privacy.js";
38
- import { accessTest, bulkResync, BULK_BATCH, deliveriesSection, exportBlocks, memberDetail, privacyPage, registerWebhooks, resyncOne, tagEdit, webhooksSection } from "./staff.js";
41
+ import { accessTest, bulkResync, BULK_BATCH, exportBlocks, memberDetail, privacyPage, registerWebhooks, resyncOne, tagEdit } from "./staff.js";
39
42
  import { textProblems } from "./text.js";
43
+ import { record } from "./log.js";
44
+ import { eventsPage, healthCheck, HEALTH_TASK, lastCheck, parseFilter, reportsPage, saveMonitor, WEBHOOK_PATH, WEBHOOK_TABS, webhooksPage, type MonitorEnv, type WebhookTab } from "./monitor.js";
40
45
  import { z } from "astro/zod";
41
46
  import { AgentError, explainAccess, getMember, getRules, levelChanges, listLevels, siteHealth, type ExplainInput } from "./agents.js";
42
47
 
@@ -54,11 +59,22 @@ import {
54
59
 
55
60
  export { COLLECTION_BLOCK, CONTRIBUTION_BLOCK, ITEM_BLOCK, MEMBERS_BLOCK, MEMBERS_END_BLOCK, PAYWALL_BLOCK, PLAN_BLOCK, PREFERENCES_BLOCK, PRODUCT_BLOCK };
56
61
 
62
+ /**
63
+ * The editor's level pickers. Declared once when the plugin starts, then kept
64
+ * in step (in place) with the levels saved in the admin: EmDash builds the
65
+ * editor's manifest from these same arrays on each request.
66
+ */
67
+ const levelOptions: Array<{ label: string; value: string }> = [];
68
+ const planLevelOptions: Array<{ label: string; value: string }> = [];
69
+
70
+ export function setLevelOptions(levels: LevelConfig[] = []): void {
71
+ const mine = levels.map((l) => ({ label: l.name, value: l.slug }));
72
+ levelOptions.splice(0, levelOptions.length, { label: "Signed in (any member)", value: SIGNED_IN }, ...mine);
73
+ planLevelOptions.splice(0, planLevelOptions.length, ...mine);
74
+ }
75
+
57
76
  function blocks(config: RedeemIDConfig) {
58
- const levelOptions = [
59
- { label: "Signed in (any member)", value: SIGNED_IN },
60
- ...(config.levels ?? []).map((l) => ({ label: l.name, value: l.slug })),
61
- ];
77
+ setLevelOptions(config.levels);
62
78
  return [
63
79
  {
64
80
  type: PAYWALL_BLOCK,
@@ -119,7 +135,7 @@ function blocks(config: RedeemIDConfig) {
119
135
  type: "checkbox" as const,
120
136
  action_id: "levels",
121
137
  label: "Levels this plan gives (members on them see \"You're on this plan\")",
122
- options: (config.levels ?? []).map((l) => ({ label: l.name, value: l.slug })),
138
+ options: planLevelOptions,
123
139
  },
124
140
  ],
125
141
  },
@@ -170,30 +186,89 @@ function blocks(config: RedeemIDConfig) {
170
186
  ];
171
187
  }
172
188
 
173
- export function createPlugin(config: RedeemIDConfig = {}): ResolvedPlugin {
189
+ export function createPlugin(code: RedeemIDConfig = {}): ResolvedPlugin {
190
+ onLevels(setLevelOptions);
191
+ const live = () => liveConfig(code);
174
192
  return definePlugin({
175
193
  id: "redeemid",
176
194
  version: VERSION,
177
- capabilities: [],
195
+ // content:read: the editor's Access panel reads the entry, and its
196
+ // rule follows the entry's slug and goes with the entry.
197
+ // email:send: the daily webhook check can warn staff by email.
198
+ capabilities: ["content:read", "email:send"],
178
199
  admin: {
179
200
  pages: PAGES,
180
201
  widgets: WIDGETS,
181
- portableTextBlocks: blocks(config),
202
+ editorPanels: EDITOR_PANELS,
203
+ portableTextBlocks: blocks(code),
204
+ },
205
+ mcp: agentTools(code),
206
+ hooks: {
207
+ "plugin:activate": async (_event, ctx) => {
208
+ await scheduleHealth(ctx);
209
+ },
210
+ cron: async (event, ctx) => {
211
+ if (event.name !== HEALTH_TASK) return;
212
+ await healthCheck(monitorEnv(await live(), await memberStores(), ctx));
213
+ },
214
+ "content:afterSave": async (event) => {
215
+ const id = typeof event.content.id === "string" ? event.content.id : null;
216
+ if (!id) return;
217
+ const slug = typeof event.content.slug === "string" ? event.content.slug : null;
218
+ await entrySlugChanged((await memberStores()).access, event.collection, id, slug).catch(() => undefined);
219
+ },
220
+ "content:afterDelete": async (event) => {
221
+ if (!event.permanent) return;
222
+ await (await memberStores()).access.delete(entryKey(event.id)).catch(() => undefined);
223
+ },
182
224
  },
183
- mcp: agentTools(config),
184
225
  routes: {
185
- ...agentRoutes(config),
226
+ ...agentRoutes(code),
227
+ "access-panel": {
228
+ permission: "content:edit_own",
229
+ request: { body: "json" as const },
230
+ handler: async (ctx: RouteContext) => {
231
+ const i = (ctx.input ?? {}) as { type?: string; action_id?: string; values?: Record<string, unknown> };
232
+ const entry = ctx.ui && "entry" in ctx.ui ? ctx.ui.entry : undefined;
233
+ if (!entry) return { blocks: [{ type: "context", text: "Save the entry first." }] };
234
+ try {
235
+ const env = await accessEnv(await live());
236
+ const panel = await panelEntry(ctx, entry.collection, entry.id);
237
+ if (i.type === "form_submit" && i.action_id === "entry_save") {
238
+ const out = await entrySave(env, panel, i.values ?? {});
239
+ await record({ event: "settings.saved", label: `entry rule (${entry.collection})`, status: "ok", origin: "staff" });
240
+ return out;
241
+ }
242
+ return await entryPanel(env, panel);
243
+ } catch (e) {
244
+ return { blocks: [{ type: "banner", variant: "error", title: "RedeemID couldn't load access", description: (e as Error).message }] };
245
+ }
246
+ },
247
+ },
186
248
  admin: {
187
249
  handler: async (ctx: RouteContext) => {
188
250
  const i = (ctx.input ?? {}) as { type?: string; page?: string; action_id?: string; value?: unknown; values?: Record<string, unknown>; block_id?: string };
189
251
  try {
190
- if (i.type === "block_action" || i.type === "form_submit") return await action(config, ctx.request, i);
252
+ const config = await live();
253
+ if ((i.type === "block_action" || i.type === "form_submit") && isAccessAction(i.action_id)) {
254
+ const out = await accessAction(await accessEnv(config), i);
255
+ if (out.toast?.type === "success") await record({ event: "settings.saved", label: `access: ${String(i.action_id).replace(/_/g, " ")}`, status: "ok", origin: "staff" });
256
+ return out;
257
+ }
258
+ if (i.type === "block_action" || i.type === "form_submit") return await action(config, ctx, i);
191
259
  if (i.type !== "page_load") return { blocks: [] };
192
260
  if (i.page === "/") return await membersPage(config);
261
+ if (i.page === "/access") return await accessPage(await accessEnv(config));
262
+ if (i.page === "/webhooks") {
263
+ await scheduleHealth(ctx);
264
+ return await webhooksPage(monitorEnv(config, await memberStores(), ctx));
265
+ }
266
+ if (i.page === "/events") return await eventsPage(await memberStores());
267
+ if (i.page === "/reports") return await reportsPage(config, await memberStores());
193
268
  if (i.page === "/status") return await statusPage(config, ctx.request);
194
269
  if (i.page === "/test") return await accessTest(config);
195
270
  if (i.page === "/privacy") return await privacyPage(await memberStores());
196
- if (i.page === "widget:status") return await statusWidget(config);
271
+ if (i.page === "widget:status") return await statusWidget(config, ctx);
197
272
  } catch (e) {
198
273
  return { blocks: [{ type: "banner", variant: "error", title: "RedeemID couldn't load this page", description: (e as Error).message }] };
199
274
  }
@@ -204,6 +279,70 @@ export function createPlugin(config: RedeemIDConfig = {}): ResolvedPlugin {
204
279
  });
205
280
  }
206
281
 
282
+ /** What the Webhooks page and the daily check work with. */
283
+ function monitorEnv(config: RedeemIDConfig, s: Stores, ctx: PluginContext): MonitorEnv {
284
+ return { config, s, kv: ctx.kv, uri: ctx.url(WEBHOOK_PATH), email: ctx.email };
285
+ }
286
+
287
+ /** Make sure the daily webhook check is scheduled (on Workers it runs from the site's Cron Trigger). */
288
+ async function scheduleHealth(ctx: PluginContext): Promise<void> {
289
+ try {
290
+ if (!ctx.cron) return;
291
+ const tasks = await ctx.cron.list();
292
+ if (!tasks.some((t) => t.name === HEALTH_TASK)) await ctx.cron.schedule(HEALTH_TASK, { schedule: "@daily" });
293
+ } catch {
294
+ // Scheduling is best effort; Check now still works.
295
+ }
296
+ }
297
+
298
+ /** The code's config with what the admin saved. */
299
+ async function liveConfig(code: RedeemIDConfig): Promise<RedeemIDConfig> {
300
+ const { getDb } = await import("emdash/runtime");
301
+ return (await withSaved(code, await getDb())) ?? code;
302
+ }
303
+
304
+ /** Everything the Access screens read: saved settings and the site's content types and taxonomies, as they are now. */
305
+ async function accessEnv(config: RedeemIDConfig): Promise<AccessEnv> {
306
+ const { getDb } = await import("emdash/runtime");
307
+ const db = await getDb();
308
+ const store = stores(db).access;
309
+ const emdash = await import("emdash");
310
+ const collections = (await new emdash.SchemaRegistry(db as never).listCollections()) as Array<{ slug: string; label: string }>;
311
+ const taxonomies = (await emdash.getTaxonomyDefs()) as Array<{ name: string; label: string; collections: string[] }>;
312
+ return {
313
+ code: config,
314
+ store,
315
+ saved: await loadAccess(store),
316
+ collections: collections.map((c) => ({ slug: c.slug, label: c.label || c.slug })),
317
+ taxonomies: taxonomies.map((t) => ({ name: t.name, label: t.label || t.name, collections: t.collections ?? [] })),
318
+ terms: async (taxonomy) => {
319
+ type Node = { slug: string; label?: string; name?: string; children?: Node[] };
320
+ const out: Array<{ slug: string; label: string }> = [];
321
+ const walk = (list: Node[], path: string) =>
322
+ list.forEach((n) => {
323
+ const label = `${path}${n.label || n.slug}`;
324
+ out.push({ slug: n.slug, label });
325
+ walk(n.children ?? [], `${label} › `);
326
+ });
327
+ walk((await emdash.getTaxonomyTerms(taxonomy)) as Node[], "");
328
+ return out;
329
+ },
330
+ };
331
+ }
332
+
333
+ /** The saved entry the editor's panel is on: its slug and terms. */
334
+ async function panelEntry(ctx: RouteContext, collection: string, id: string): Promise<PanelEntry> {
335
+ const item = await ctx.content?.get(collection, id).catch(() => null);
336
+ const emdash = await import("emdash");
337
+ const terms: Terms = {};
338
+ for (const t of (await emdash.getTaxonomyDefs()) as Array<{ name: string; collections: string[] }>) {
339
+ if (!(t.collections ?? []).includes(collection)) continue;
340
+ const list = (await emdash.getEntryTerms(collection, id, t.name)) as Array<{ slug: string; id: string; parentId?: string }>;
341
+ if (list.length) terms[t.name] = list.map((x) => ({ slug: x.slug, id: x.id, parentId: x.parentId ?? null }));
342
+ }
343
+ return { collection, id, slug: item?.slug ?? null, terms };
344
+ }
345
+
207
346
  export default createPlugin;
208
347
 
209
348
  // ---------------------------------------------------------------- agents and CLI
@@ -211,9 +350,11 @@ export default createPlugin;
211
350
  const STAFF = "users:read" as const;
212
351
 
213
352
  /** Staff-only JSON routes, also offered as MCP tools. */
214
- function agentRoutes(config: RedeemIDConfig) {
353
+ function agentRoutes(code: RedeemIDConfig) {
354
+ let config = code;
215
355
  const run = (fn: (s: Stores, input: Record<string, unknown>) => Promise<unknown> | unknown) => async (ctx: RouteContext) => {
216
356
  try {
357
+ config = await liveConfig(code);
217
358
  return { ok: true, result: await fn(await memberStores(), (ctx.input ?? {}) as Record<string, unknown>) };
218
359
  } catch (e) {
219
360
  if (e instanceof AgentError || e instanceof AdminError) return { ok: false, error: e.message };
@@ -229,7 +370,7 @@ function agentRoutes(config: RedeemIDConfig) {
229
370
  "site-health": route(EMPTY, (s) => siteHealth(config, s)),
230
371
  "level-changes": route(CHANGES, (s, i) => levelChanges(config, s, i as { since: string; level?: string })),
231
372
  };
232
- if (!config.agents?.actions) return read;
373
+ if (!code.agents?.actions) return read;
233
374
  return {
234
375
  ...read,
235
376
  "resync-member": route(CUSTOMER, async (s, i) => {
@@ -287,14 +428,17 @@ function agentTools(config: RedeemIDConfig) {
287
428
 
288
429
  // ---------------------------------------------------------------- actions
289
430
 
290
- const webhookUri = (request: Request) => `${new URL(request.url).origin}/_emdash/api/auth/redeemid/webhook`;
291
-
292
431
  async function action(
293
432
  config: RedeemIDConfig,
294
- request: Request,
433
+ ctx: RouteContext,
295
434
  i: { action_id?: string; value?: unknown; values?: Record<string, unknown>; block_id?: string },
296
435
  ) {
297
436
  const s = await memberStores();
437
+ const monitor = () => monitorEnv(config, s, ctx);
438
+ const cursorOf = (v: unknown) => {
439
+ const c = (v as { cursor?: unknown } | null)?.cursor;
440
+ return typeof c === "string" && c ? c : undefined;
441
+ };
298
442
  const fail = (e: unknown) => ({ type: "error" as const, message: (e as Error).message });
299
443
  switch (i.action_id) {
300
444
  case "members_back":
@@ -342,14 +486,34 @@ async function action(
342
486
  }
343
487
  case "webhooks_register": {
344
488
  try {
345
- const { added, failed } = await registerWebhooks(config, webhookUri(request));
489
+ const { added, failed } = await registerWebhooks(config, monitor().uri);
346
490
  const done = added.length ? `Registered ${added.length} webhooks.` : failed.length ? "" : "Nothing was missing.";
347
491
  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(" ") } };
492
+ await record({ event: "staff.webhooks", label: "register", status: failed.length ? `${failed.length} refused` : `${added.length} registered`, level: failed.length ? "warn" : "ok", origin: "staff" });
493
+ return webhooksPage(monitor(), "setup", { type: failed.length ? "error" : "success", message: [done, refused].filter(Boolean).join(" ") });
349
494
  } catch (e) {
350
- return { ...(await statusPage(config, request)), toast: fail(e) };
495
+ return webhooksPage(monitor(), "setup", fail(e));
351
496
  }
352
497
  }
498
+ case "webhooks_tab": {
499
+ const tab = WEBHOOK_TABS.includes(i.value as WebhookTab) ? (i.value as WebhookTab) : "received";
500
+ return webhooksPage(monitor(), tab);
501
+ }
502
+ case "deliveries_page":
503
+ return webhooksPage(monitor(), "received", undefined, cursorOf(i.value));
504
+ case "health_check": {
505
+ const r = await healthCheck({ ...monitor(), by: "staff" });
506
+ const message = r.problems.length ? `Checked: ${r.problems.length === 1 ? "1 problem" : `${r.problems.length} problems`}.` : "Checked: subscriptions are in place and deliveries look healthy.";
507
+ return webhooksPage(monitor(), "health", { type: r.problems.length ? "info" : "success", message });
508
+ }
509
+ case "monitor_save":
510
+ return webhooksPage(monitor(), "health", await saveMonitor(ctx.kv, i.values));
511
+ case "events_filter":
512
+ return eventsPage(s, { event: String(i.values?.event ?? "") || undefined, problems: i.values?.problems === true });
513
+ case "events_show":
514
+ return eventsPage(s, { event: String(i.value ?? "") || undefined });
515
+ case "events_page":
516
+ return eventsPage(s, parseFilter(i.block_id), cursorOf(i.value));
353
517
  case "access_test":
354
518
  return accessTest(config, i.values);
355
519
  case "member_export": {
@@ -417,10 +581,10 @@ async function membersPage(config: RedeemIDConfig) {
417
581
  label: l.name,
418
582
  value: String(members.filter((m) => m.status === "enabled" && levelsFor(m.tags, levels).includes(l.slug)).length),
419
583
  }));
420
- const name = (m: MemberRecord) => [m.firstName, m.lastName].filter(Boolean).join(" ") || "—";
584
+ const name = (m: MemberRecord) => [m.firstName, m.lastName].filter(Boolean).join(" ") || "No name";
421
585
  const levelNames = (m: MemberRecord) => {
422
586
  const held = levelsFor(m.tags, levels);
423
- return levels.filter((l) => held.includes(l.slug)).map((l) => l.name).join(", ") || "—";
587
+ return levels.filter((l) => held.includes(l.slug)).map((l) => l.name).join(", ");
424
588
  };
425
589
  return {
426
590
  blocks: [
@@ -466,7 +630,7 @@ async function membersPage(config: RedeemIDConfig) {
466
630
  { key: "email", label: "Email" },
467
631
  { key: "levels", label: "Levels" },
468
632
  { key: "tags", label: "Shopify tags" },
469
- { key: "status", label: "Account" },
633
+ { key: "status", label: "Account", format: "badge" },
470
634
  { key: "synced", label: "Last synced", format: "relative_time" },
471
635
  { key: "open", label: "", format: "element" },
472
636
  ],
@@ -474,9 +638,9 @@ async function membersPage(config: RedeemIDConfig) {
474
638
  .sort((a, b) => b.syncedAt.localeCompare(a.syncedAt))
475
639
  .map((m) => ({
476
640
  name: name(m),
477
- email: m.email ?? "—",
641
+ email: m.email ?? "",
478
642
  levels: levelNames(m),
479
- tags: m.tags.join(", ") || "—",
643
+ tags: m.tags.join(", "),
480
644
  status: m.status === "enabled" ? "On" : "Off",
481
645
  synced: m.syncedAt,
482
646
  open: { type: "button", action_id: "member_open", label: "Open", value: m.customerId },
@@ -521,13 +685,7 @@ async function statusPage(config: RedeemIDConfig, request: Request) {
521
685
  const c = await checks(config);
522
686
  const origin = new URL(request.url).origin;
523
687
  const yes = (v: boolean) => (v ? "Set" : "Missing");
524
- const levels = config.levels ?? [];
525
- const rules = config.rules ?? [];
526
688
  const delivery = await lastDelivery().catch(() => null);
527
- const webhooks = await webhooksSection(config, webhookUri(request));
528
- const deliveries = await memberStores()
529
- .then((s) => deliveriesSection(s))
530
- .catch(() => [{ type: "context", text: "Couldn't read deliveries." }]);
531
689
  return {
532
690
  blocks: [
533
691
  { type: "header", text: "Status" },
@@ -562,11 +720,7 @@ async function statusPage(config: RedeemIDConfig, request: Request) {
562
720
  },
563
721
  ]
564
722
  : []),
565
- { type: "divider" },
566
- { type: "header", text: "Webhooks" },
567
- ...webhooks,
568
- { type: "header", text: "Recent deliveries" },
569
- ...deliveries,
723
+ { type: "context", text: "Deliveries, their health and the webhook subscriptions are under Webhooks; everything RedeemID did is under App events." },
570
724
  { type: "divider" },
571
725
  { type: "header", text: "Shopify settings" },
572
726
  {
@@ -579,62 +733,24 @@ async function statusPage(config: RedeemIDConfig, request: Request) {
579
733
  ],
580
734
  },
581
735
  { type: "divider" },
582
- { type: "header", text: "Levels" },
583
- { type: "context", text: "Set in astro.config.mjs." },
584
- levels.length
585
- ? {
586
- type: "table",
587
- page_action_id: "levels_page",
588
- columns: [
589
- { key: "name", label: "Level" },
590
- { key: "tags", label: "Shopify tags" },
591
- { key: "match", label: "Needs" },
592
- ],
593
- rows: levels.map((l) => ({ name: l.name, tags: l.tags.join(", "), match: l.match === "all" ? "All tags" : "Any tag" })),
594
- }
595
- : { type: "context", text: "No levels configured." },
596
- { type: "header", text: "Rules" },
597
- rules.length
598
- ? {
599
- type: "table",
600
- page_action_id: "rules_page",
601
- columns: [
602
- { key: "where", label: "Covers" },
603
- { key: "levels", label: "Who can read" },
604
- { key: "extra", label: "Drip and meter" },
605
- ],
606
- rows: rules.map((r) => ({
607
- where:
608
- r.taxonomy && r.term
609
- ? `${r.collection === "*" ? "Every collection" : r.collection}, ${r.taxonomy}: ${r.term} (and its child terms)`
610
- : r.collection === "*"
611
- ? "Site default (collections with no rule)"
612
- : `All ${r.collection}`,
613
- levels: r.open ? "Everyone (opens the entry)" : r.levels.length ? describe([r.levels], levels) : "Any signed-in member",
614
- extra: [r.drip ? `Opens ${r.drip} days into membership` : "", r.meter === false ? "Never metered" : ""].filter(Boolean).join("; "),
615
- })),
616
- }
617
- : { type: "context", text: "No rules. Entries are open unless their body has a paywall break." },
618
- {
619
- type: "context",
620
- text: config.meter?.limit
621
- ? `Meter: signed-in readers may open ${config.meter.limit} locked ${config.meter.limit === 1 ? "entry" : "entries"} a ${config.meter.period ?? "month"}${config.meter.collections?.length ? ` (${config.meter.collections.join(", ")})` : ""}.`
622
- : "Meter: off.",
623
- },
736
+ { type: "context", text: "Levels, rules and the meter are under Access." },
624
737
  ],
625
738
  };
626
739
  }
627
740
 
628
- async function statusWidget(config: RedeemIDConfig) {
741
+ async function statusWidget(config: RedeemIDConfig, ctx: PluginContext) {
629
742
  const c = await checks(config);
630
743
  const members = await allMembers().catch(() => []);
744
+ const check = await lastCheck(ctx.kv).catch(() => null);
631
745
  return {
632
746
  blocks: [
747
+ ...(check?.problems.length ? [{ type: "banner", variant: "alert", title: "Webhooks need attention", description: check.problems[0]!.text }] : []),
633
748
  {
634
749
  type: "fields",
635
750
  fields: [
636
751
  { label: "Sign-in", value: c.discovery.startsWith("OK") && c.clientId ? "Ready" : "Not ready" },
637
752
  { label: "Members", value: String(members.length) },
753
+ { label: "Webhooks", value: check ? (check.problems.length ? `${check.problems.length} to look at` : "Healthy") : "Not checked yet" },
638
754
  ],
639
755
  },
640
756
  ],
@@ -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
+ }
@@ -14,8 +14,10 @@ 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";
@@ -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,6 +124,7 @@ 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
 
@@ -124,10 +135,16 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
124
135
  } catch (e) {
125
136
  const code = e instanceof ShopifyError ? e.code : e instanceof OAuthError ? "signup_not_allowed" : "sign_in_failed";
126
137
  console.error("[redeemid] sign-in failed:", (e as Error).message);
138
+ await refused(code, "error", null, String((e as Error).message).slice(0, 200));
127
139
  return fail(redirect, returnTo, code);
128
140
  }
129
141
  };
130
142
 
143
+ /** A sign-in that didn't finish, for App events: the reason code, never the email. */
144
+ function refused(code: string, level: "ok" | "warn" | "error", customerId: string | null = null, note?: string): Promise<void> {
145
+ return record({ event: "member.refused", label: code, status: "refused", level, origin: "member", customerId, ...(note ? { note } : {}) });
146
+ }
147
+
131
148
  /** Ends the store session, then shows `code` on `returnTo` (see AFTER_LOGOUT_COOKIE). */
132
149
  function signedOut(d: Discovery, idToken: string, url: URL, emdashConfig: unknown, returnTo: string, code: string): Response | null {
133
150
  const origin = getPublicOrigin(url, emdashConfig as Parameters<typeof getPublicOrigin>[1]);
@@ -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 }) => {