@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
package/src/notices.ts ADDED
@@ -0,0 +1,29 @@
1
+ /** The message for each `redeemid_notice` code (also what the browser client shows in the cart drawer). */
2
+
3
+ export const NOTICES: Record<string, string> = {
4
+ not_configured: "Sign-in isn't set up on this site yet.",
5
+ sign_in_unavailable: "Sign-in isn't available right now. Try again in a few minutes.",
6
+ sign_in_expired: "That sign-in took too long. Try again.",
7
+ staff_account: "This account can't sign in here. Use a different email.",
8
+ account_disabled: "Your account is turned off. Contact the store for help.",
9
+ too_many_requests: "Too many tries. Wait a minute, then try again.",
10
+ site_not_ready: "This site isn't ready for sign-in yet.",
11
+ email_required: "Your account needs an email address to sign in.",
12
+ token_failed: "Sign-in didn't finish. Try again.",
13
+ bad_id_token: "Sign-in didn't finish. Try again.",
14
+ identity_failed: "Sign-in didn't finish. Try again.",
15
+ plan_unavailable: "That plan isn't available right now.",
16
+ checkout_failed: "Checkout didn't open. Try again.",
17
+ cart_empty: "Your cart is empty.",
18
+ cart_added: "Added to your cart.",
19
+ cart_updated: "Your cart is updated.",
20
+ discount_applied: "Discount applied.",
21
+ discount_not_applicable: "That discount code doesn't apply to this cart.",
22
+ refreshed: "Your membership is up to date.",
23
+ preferences_saved: "Your preferences are saved.",
24
+ preferences_refused: "Some answers weren't saved. Check them and try again.",
25
+ preferences_unavailable: "Your preferences can't be saved right now. Try again in a few minutes.",
26
+ data_erased: "Your data on this site is deleted, and you're signed out.",
27
+ erase_unconfirmed: "Tick the box to confirm, then try again.",
28
+ account_unavailable: "Your account page isn't available right now. Try again in a few minutes.",
29
+ };
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,21 +25,25 @@
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
- import { AgentError, explainAccess, getMember, getRules, levelChanges, listLevels, siteHealth, type ExplainInput } from "./agents.js";
46
+ import { AgentError, explainAccess, findProducts, getCollection, getMember, getProduct, getRules, levelChanges, listLevels, siteHealth, type ExplainInput } from "./agents.js";
42
47
 
43
48
  import {
44
49
  COLLECTION_BLOCK,
@@ -50,15 +55,27 @@ import {
50
55
  PLAN_BLOCK,
51
56
  PREFERENCES_BLOCK,
52
57
  PRODUCT_BLOCK,
58
+ SEARCH_BLOCK,
53
59
  } from "./blocks-names.js";
54
60
 
55
- export { COLLECTION_BLOCK, CONTRIBUTION_BLOCK, ITEM_BLOCK, MEMBERS_BLOCK, MEMBERS_END_BLOCK, PAYWALL_BLOCK, PLAN_BLOCK, PREFERENCES_BLOCK, PRODUCT_BLOCK };
61
+ export { COLLECTION_BLOCK, CONTRIBUTION_BLOCK, ITEM_BLOCK, MEMBERS_BLOCK, MEMBERS_END_BLOCK, PAYWALL_BLOCK, PLAN_BLOCK, PREFERENCES_BLOCK, PRODUCT_BLOCK, SEARCH_BLOCK };
62
+
63
+ /**
64
+ * The editor's level pickers. Declared once when the plugin starts, then kept
65
+ * in step (in place) with the levels saved in the admin: EmDash builds the
66
+ * editor's manifest from these same arrays on each request.
67
+ */
68
+ const levelOptions: Array<{ label: string; value: string }> = [];
69
+ const planLevelOptions: Array<{ label: string; value: string }> = [];
70
+
71
+ export function setLevelOptions(levels: LevelConfig[] = []): void {
72
+ const mine = levels.map((l) => ({ label: l.name, value: l.slug }));
73
+ levelOptions.splice(0, levelOptions.length, { label: "Signed in (any member)", value: SIGNED_IN }, ...mine);
74
+ planLevelOptions.splice(0, planLevelOptions.length, ...mine);
75
+ }
56
76
 
57
77
  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
- ];
78
+ setLevelOptions(config.levels);
62
79
  return [
63
80
  {
64
81
  type: PAYWALL_BLOCK,
@@ -119,7 +136,7 @@ function blocks(config: RedeemIDConfig) {
119
136
  type: "checkbox" as const,
120
137
  action_id: "levels",
121
138
  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 })),
139
+ options: planLevelOptions,
123
140
  },
124
141
  ],
125
142
  },
@@ -141,6 +158,26 @@ function blocks(config: RedeemIDConfig) {
141
158
  { type: "text_input" as const, action_id: "handle", label: "Shopify collection handle", placeholder: "shop" },
142
159
  { type: "text_input" as const, action_id: "limit", label: "How many products (up to 50)", placeholder: "12" },
143
160
  { type: "text_input" as const, action_id: "link", label: "Product page link, with {handle}", placeholder: "/shop/{handle}/" },
161
+ {
162
+ type: "checkbox" as const,
163
+ action_id: "extras",
164
+ label: "Extras",
165
+ options: [
166
+ { label: "Filters, sorting and pages (the page must render on demand)", value: "browse" },
167
+ { label: "Add to cart on cards that need no choice", value: "quick-add" },
168
+ ],
169
+ },
170
+ ],
171
+ },
172
+ {
173
+ type: SEARCH_BLOCK,
174
+ label: "Product search",
175
+ icon: "search",
176
+ description: "A search box with Shopify's matching products, filters and sorting",
177
+ placeholder: "Product search",
178
+ fields: [
179
+ { type: "text_input" as const, action_id: "link", label: "Product page link, with {handle}", placeholder: "/shop/{handle}/" },
180
+ { type: "text_input" as const, action_id: "label", label: "Search box label", placeholder: "Search products" },
144
181
  ],
145
182
  },
146
183
  {
@@ -170,30 +207,89 @@ function blocks(config: RedeemIDConfig) {
170
207
  ];
171
208
  }
172
209
 
173
- export function createPlugin(config: RedeemIDConfig = {}): ResolvedPlugin {
210
+ export function createPlugin(code: RedeemIDConfig = {}): ResolvedPlugin {
211
+ onLevels(setLevelOptions);
212
+ const live = () => liveConfig(code);
174
213
  return definePlugin({
175
214
  id: "redeemid",
176
215
  version: VERSION,
177
- capabilities: [],
216
+ // content:read: the editor's Access panel reads the entry, and its
217
+ // rule follows the entry's slug and goes with the entry.
218
+ // email:send: the daily webhook check can warn staff by email.
219
+ capabilities: ["content:read", "email:send"],
178
220
  admin: {
179
221
  pages: PAGES,
180
222
  widgets: WIDGETS,
181
- portableTextBlocks: blocks(config),
223
+ editorPanels: EDITOR_PANELS,
224
+ portableTextBlocks: blocks(code),
225
+ },
226
+ mcp: agentTools(code),
227
+ hooks: {
228
+ "plugin:activate": async (_event, ctx) => {
229
+ await scheduleHealth(ctx);
230
+ },
231
+ cron: async (event, ctx) => {
232
+ if (event.name !== HEALTH_TASK) return;
233
+ await healthCheck(monitorEnv(await live(), await memberStores(), ctx));
234
+ },
235
+ "content:afterSave": async (event) => {
236
+ const id = typeof event.content.id === "string" ? event.content.id : null;
237
+ if (!id) return;
238
+ const slug = typeof event.content.slug === "string" ? event.content.slug : null;
239
+ await entrySlugChanged((await memberStores()).access, event.collection, id, slug).catch(() => undefined);
240
+ },
241
+ "content:afterDelete": async (event) => {
242
+ if (!event.permanent) return;
243
+ await (await memberStores()).access.delete(entryKey(event.id)).catch(() => undefined);
244
+ },
182
245
  },
183
- mcp: agentTools(config),
184
246
  routes: {
185
- ...agentRoutes(config),
247
+ ...agentRoutes(code),
248
+ "access-panel": {
249
+ permission: "content:edit_own",
250
+ request: { body: "json" as const },
251
+ handler: async (ctx: RouteContext) => {
252
+ const i = (ctx.input ?? {}) as { type?: string; action_id?: string; values?: Record<string, unknown> };
253
+ const entry = ctx.ui && "entry" in ctx.ui ? ctx.ui.entry : undefined;
254
+ if (!entry) return { blocks: [{ type: "context", text: "Save the entry first." }] };
255
+ try {
256
+ const env = await accessEnv(await live());
257
+ const panel = await panelEntry(ctx, entry.collection, entry.id);
258
+ if (i.type === "form_submit" && i.action_id === "entry_save") {
259
+ const out = await entrySave(env, panel, i.values ?? {});
260
+ await record({ event: "settings.saved", label: `entry rule (${entry.collection})`, status: "ok", origin: "staff" });
261
+ return out;
262
+ }
263
+ return await entryPanel(env, panel);
264
+ } catch (e) {
265
+ return { blocks: [{ type: "banner", variant: "error", title: "RedeemID couldn't load access", description: (e as Error).message }] };
266
+ }
267
+ },
268
+ },
186
269
  admin: {
187
270
  handler: async (ctx: RouteContext) => {
188
271
  const i = (ctx.input ?? {}) as { type?: string; page?: string; action_id?: string; value?: unknown; values?: Record<string, unknown>; block_id?: string };
189
272
  try {
190
- if (i.type === "block_action" || i.type === "form_submit") return await action(config, ctx.request, i);
273
+ const config = await live();
274
+ if ((i.type === "block_action" || i.type === "form_submit") && isAccessAction(i.action_id)) {
275
+ const out = await accessAction(await accessEnv(config), i);
276
+ if (out.toast?.type === "success") await record({ event: "settings.saved", label: `access: ${String(i.action_id).replace(/_/g, " ")}`, status: "ok", origin: "staff" });
277
+ return out;
278
+ }
279
+ if (i.type === "block_action" || i.type === "form_submit") return await action(config, ctx, i);
191
280
  if (i.type !== "page_load") return { blocks: [] };
192
281
  if (i.page === "/") return await membersPage(config);
282
+ if (i.page === "/access") return await accessPage(await accessEnv(config));
283
+ if (i.page === "/webhooks") {
284
+ await scheduleHealth(ctx);
285
+ return await webhooksPage(monitorEnv(config, await memberStores(), ctx));
286
+ }
287
+ if (i.page === "/events") return await eventsPage(await memberStores());
288
+ if (i.page === "/reports") return await reportsPage(config, await memberStores());
193
289
  if (i.page === "/status") return await statusPage(config, ctx.request);
194
290
  if (i.page === "/test") return await accessTest(config);
195
291
  if (i.page === "/privacy") return await privacyPage(await memberStores());
196
- if (i.page === "widget:status") return await statusWidget(config);
292
+ if (i.page === "widget:status") return await statusWidget(config, ctx);
197
293
  } catch (e) {
198
294
  return { blocks: [{ type: "banner", variant: "error", title: "RedeemID couldn't load this page", description: (e as Error).message }] };
199
295
  }
@@ -204,6 +300,70 @@ export function createPlugin(config: RedeemIDConfig = {}): ResolvedPlugin {
204
300
  });
205
301
  }
206
302
 
303
+ /** What the Webhooks page and the daily check work with. */
304
+ function monitorEnv(config: RedeemIDConfig, s: Stores, ctx: PluginContext): MonitorEnv {
305
+ return { config, s, kv: ctx.kv, uri: ctx.url(WEBHOOK_PATH), email: ctx.email };
306
+ }
307
+
308
+ /** Make sure the daily webhook check is scheduled (on Workers it runs from the site's Cron Trigger). */
309
+ async function scheduleHealth(ctx: PluginContext): Promise<void> {
310
+ try {
311
+ if (!ctx.cron) return;
312
+ const tasks = await ctx.cron.list();
313
+ if (!tasks.some((t) => t.name === HEALTH_TASK)) await ctx.cron.schedule(HEALTH_TASK, { schedule: "@daily" });
314
+ } catch {
315
+ // Scheduling is best effort; Check now still works.
316
+ }
317
+ }
318
+
319
+ /** The code's config with what the admin saved. */
320
+ async function liveConfig(code: RedeemIDConfig): Promise<RedeemIDConfig> {
321
+ const { getDb } = await import("emdash/runtime");
322
+ return (await withSaved(code, await getDb())) ?? code;
323
+ }
324
+
325
+ /** Everything the Access screens read: saved settings and the site's content types and taxonomies, as they are now. */
326
+ async function accessEnv(config: RedeemIDConfig): Promise<AccessEnv> {
327
+ const { getDb } = await import("emdash/runtime");
328
+ const db = await getDb();
329
+ const store = stores(db).access;
330
+ const emdash = await import("emdash");
331
+ const collections = (await new emdash.SchemaRegistry(db as never).listCollections()) as Array<{ slug: string; label: string }>;
332
+ const taxonomies = (await emdash.getTaxonomyDefs()) as Array<{ name: string; label: string; collections: string[] }>;
333
+ return {
334
+ code: config,
335
+ store,
336
+ saved: await loadAccess(store),
337
+ collections: collections.map((c) => ({ slug: c.slug, label: c.label || c.slug })),
338
+ taxonomies: taxonomies.map((t) => ({ name: t.name, label: t.label || t.name, collections: t.collections ?? [] })),
339
+ terms: async (taxonomy) => {
340
+ type Node = { slug: string; label?: string; name?: string; children?: Node[] };
341
+ const out: Array<{ slug: string; label: string }> = [];
342
+ const walk = (list: Node[], path: string) =>
343
+ list.forEach((n) => {
344
+ const label = `${path}${n.label || n.slug}`;
345
+ out.push({ slug: n.slug, label });
346
+ walk(n.children ?? [], `${label} › `);
347
+ });
348
+ walk((await emdash.getTaxonomyTerms(taxonomy)) as Node[], "");
349
+ return out;
350
+ },
351
+ };
352
+ }
353
+
354
+ /** The saved entry the editor's panel is on: its slug and terms. */
355
+ async function panelEntry(ctx: RouteContext, collection: string, id: string): Promise<PanelEntry> {
356
+ const item = await ctx.content?.get(collection, id).catch(() => null);
357
+ const emdash = await import("emdash");
358
+ const terms: Terms = {};
359
+ for (const t of (await emdash.getTaxonomyDefs()) as Array<{ name: string; collections: string[] }>) {
360
+ if (!(t.collections ?? []).includes(collection)) continue;
361
+ const list = (await emdash.getEntryTerms(collection, id, t.name)) as Array<{ slug: string; id: string; parentId?: string }>;
362
+ if (list.length) terms[t.name] = list.map((x) => ({ slug: x.slug, id: x.id, parentId: x.parentId ?? null }));
363
+ }
364
+ return { collection, id, slug: item?.slug ?? null, terms };
365
+ }
366
+
207
367
  export default createPlugin;
208
368
 
209
369
  // ---------------------------------------------------------------- agents and CLI
@@ -211,9 +371,11 @@ export default createPlugin;
211
371
  const STAFF = "users:read" as const;
212
372
 
213
373
  /** Staff-only JSON routes, also offered as MCP tools. */
214
- function agentRoutes(config: RedeemIDConfig) {
374
+ function agentRoutes(code: RedeemIDConfig) {
375
+ let config = code;
215
376
  const run = (fn: (s: Stores, input: Record<string, unknown>) => Promise<unknown> | unknown) => async (ctx: RouteContext) => {
216
377
  try {
378
+ config = await liveConfig(code);
217
379
  return { ok: true, result: await fn(await memberStores(), (ctx.input ?? {}) as Record<string, unknown>) };
218
380
  } catch (e) {
219
381
  if (e instanceof AgentError || e instanceof AdminError) return { ok: false, error: e.message };
@@ -228,8 +390,11 @@ function agentRoutes(config: RedeemIDConfig) {
228
390
  "get-member": route(MEMBER, (s, i) => getMember(config, s, i as { customerId?: string; email?: string })),
229
391
  "site-health": route(EMPTY, (s) => siteHealth(config, s)),
230
392
  "level-changes": route(CHANGES, (s, i) => levelChanges(config, s, i as { since: string; level?: string })),
393
+ "find-products": route(FIND, (_s, i) => findProducts(config, i as { query: string; first?: number })),
394
+ "get-product": route(HANDLE, (_s, i) => getProduct(config, i as { handle: string })),
395
+ "get-collection": route(COLLECTION, (_s, i) => getCollection(config, i as { handle: string; first?: number })),
231
396
  };
232
- if (!config.agents?.actions) return read;
397
+ if (!code.agents?.actions) return read;
233
398
  return {
234
399
  ...read,
235
400
  "resync-member": route(CUSTOMER, async (s, i) => {
@@ -261,6 +426,9 @@ const EXPLAIN = z.object({
261
426
  const MEMBER = z.object({ customerId: z.string().optional(), email: z.string().optional() });
262
427
  const CUSTOMER = z.object({ customerId: z.string().min(1).describe("Shopify customer id") });
263
428
  const CHANGES = z.object({ since: z.string().describe("A date, e.g. 2026-11-01"), level: z.string().optional().describe("Only this level slug") });
429
+ const FIND = z.object({ query: z.string().min(1).describe("Search words, e.g. tote"), first: z.number().int().min(1).max(25).optional().describe("How many (default 10)") });
430
+ const HANDLE = z.object({ handle: z.string().min(1).describe("Shopify product handle") });
431
+ const COLLECTION = z.object({ handle: z.string().min(1).describe("Shopify collection handle"), first: z.number().int().min(1).max(50).optional().describe("How many products (default 25)") });
264
432
  const TAGS = z.object({
265
433
  customerId: z.string().min(1).describe("Shopify customer id"),
266
434
  add: z.array(z.string()).optional().describe("Tags to add in Shopify"),
@@ -276,6 +444,9 @@ function agentTools(config: RedeemIDConfig) {
276
444
  get_member: { route: "get-member", input: MEMBER, description: "One synced member by Shopify customer id or email: status, tags, current levels with start dates, past levels, features and last sync." },
277
445
  site_health: { route: "site-health", input: EMPTY, description: "Whether each setting is in place (never the values), the last webhook, open privacy requests and ignored text replacements." },
278
446
  level_changes: { route: "level-changes", input: CHANGES, description: "Who joined or left a level since a date, from each member's tag dates." },
447
+ find_products: { route: "find-products", input: FIND, description: "Search the Shopify catalog the site sells: handles, titles, prices and availability. Use the handles in Product, Plan and Collection blocks." },
448
+ get_product: { route: "get-product", input: HANDLE, description: "One Shopify product by handle: options, variants with prices and stock, selling plans (subscriptions) and image count." },
449
+ get_collection: { route: "get-collection", input: COLLECTION, description: "One Shopify collection by handle: its products, prices and the filters Shopify offers for it." },
279
450
  };
280
451
  if (config.agents?.actions) {
281
452
  tools.resync_member = { route: "resync-member", input: CUSTOMER, description: "Read one member from Shopify now and update the site. Changes nothing in Shopify." };
@@ -287,14 +458,17 @@ function agentTools(config: RedeemIDConfig) {
287
458
 
288
459
  // ---------------------------------------------------------------- actions
289
460
 
290
- const webhookUri = (request: Request) => `${new URL(request.url).origin}/_emdash/api/auth/redeemid/webhook`;
291
-
292
461
  async function action(
293
462
  config: RedeemIDConfig,
294
- request: Request,
463
+ ctx: RouteContext,
295
464
  i: { action_id?: string; value?: unknown; values?: Record<string, unknown>; block_id?: string },
296
465
  ) {
297
466
  const s = await memberStores();
467
+ const monitor = () => monitorEnv(config, s, ctx);
468
+ const cursorOf = (v: unknown) => {
469
+ const c = (v as { cursor?: unknown } | null)?.cursor;
470
+ return typeof c === "string" && c ? c : undefined;
471
+ };
298
472
  const fail = (e: unknown) => ({ type: "error" as const, message: (e as Error).message });
299
473
  switch (i.action_id) {
300
474
  case "members_back":
@@ -342,14 +516,34 @@ async function action(
342
516
  }
343
517
  case "webhooks_register": {
344
518
  try {
345
- const { added, failed } = await registerWebhooks(config, webhookUri(request));
519
+ const { added, failed } = await registerWebhooks(config, monitor().uri);
346
520
  const done = added.length ? `Registered ${added.length} webhooks.` : failed.length ? "" : "Nothing was missing.";
347
521
  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(" ") } };
522
+ await record({ event: "staff.webhooks", label: "register", status: failed.length ? `${failed.length} refused` : `${added.length} registered`, level: failed.length ? "warn" : "ok", origin: "staff" });
523
+ return webhooksPage(monitor(), "setup", { type: failed.length ? "error" : "success", message: [done, refused].filter(Boolean).join(" ") });
349
524
  } catch (e) {
350
- return { ...(await statusPage(config, request)), toast: fail(e) };
525
+ return webhooksPage(monitor(), "setup", fail(e));
351
526
  }
352
527
  }
528
+ case "webhooks_tab": {
529
+ const tab = WEBHOOK_TABS.includes(i.value as WebhookTab) ? (i.value as WebhookTab) : "received";
530
+ return webhooksPage(monitor(), tab);
531
+ }
532
+ case "deliveries_page":
533
+ return webhooksPage(monitor(), "received", undefined, cursorOf(i.value));
534
+ case "health_check": {
535
+ const r = await healthCheck({ ...monitor(), by: "staff" });
536
+ 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.";
537
+ return webhooksPage(monitor(), "health", { type: r.problems.length ? "info" : "success", message });
538
+ }
539
+ case "monitor_save":
540
+ return webhooksPage(monitor(), "health", await saveMonitor(ctx.kv, i.values));
541
+ case "events_filter":
542
+ return eventsPage(s, { event: String(i.values?.event ?? "") || undefined, problems: i.values?.problems === true });
543
+ case "events_show":
544
+ return eventsPage(s, { event: String(i.value ?? "") || undefined });
545
+ case "events_page":
546
+ return eventsPage(s, parseFilter(i.block_id), cursorOf(i.value));
353
547
  case "access_test":
354
548
  return accessTest(config, i.values);
355
549
  case "member_export": {
@@ -417,10 +611,10 @@ async function membersPage(config: RedeemIDConfig) {
417
611
  label: l.name,
418
612
  value: String(members.filter((m) => m.status === "enabled" && levelsFor(m.tags, levels).includes(l.slug)).length),
419
613
  }));
420
- const name = (m: MemberRecord) => [m.firstName, m.lastName].filter(Boolean).join(" ") || "—";
614
+ const name = (m: MemberRecord) => [m.firstName, m.lastName].filter(Boolean).join(" ") || "No name";
421
615
  const levelNames = (m: MemberRecord) => {
422
616
  const held = levelsFor(m.tags, levels);
423
- return levels.filter((l) => held.includes(l.slug)).map((l) => l.name).join(", ") || "—";
617
+ return levels.filter((l) => held.includes(l.slug)).map((l) => l.name).join(", ");
424
618
  };
425
619
  return {
426
620
  blocks: [
@@ -466,7 +660,7 @@ async function membersPage(config: RedeemIDConfig) {
466
660
  { key: "email", label: "Email" },
467
661
  { key: "levels", label: "Levels" },
468
662
  { key: "tags", label: "Shopify tags" },
469
- { key: "status", label: "Account" },
663
+ { key: "status", label: "Account", format: "badge" },
470
664
  { key: "synced", label: "Last synced", format: "relative_time" },
471
665
  { key: "open", label: "", format: "element" },
472
666
  ],
@@ -474,9 +668,9 @@ async function membersPage(config: RedeemIDConfig) {
474
668
  .sort((a, b) => b.syncedAt.localeCompare(a.syncedAt))
475
669
  .map((m) => ({
476
670
  name: name(m),
477
- email: m.email ?? "—",
671
+ email: m.email ?? "",
478
672
  levels: levelNames(m),
479
- tags: m.tags.join(", ") || "—",
673
+ tags: m.tags.join(", "),
480
674
  status: m.status === "enabled" ? "On" : "Off",
481
675
  synced: m.syncedAt,
482
676
  open: { type: "button", action_id: "member_open", label: "Open", value: m.customerId },
@@ -521,13 +715,7 @@ async function statusPage(config: RedeemIDConfig, request: Request) {
521
715
  const c = await checks(config);
522
716
  const origin = new URL(request.url).origin;
523
717
  const yes = (v: boolean) => (v ? "Set" : "Missing");
524
- const levels = config.levels ?? [];
525
- const rules = config.rules ?? [];
526
718
  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
719
  return {
532
720
  blocks: [
533
721
  { type: "header", text: "Status" },
@@ -562,11 +750,7 @@ async function statusPage(config: RedeemIDConfig, request: Request) {
562
750
  },
563
751
  ]
564
752
  : []),
565
- { type: "divider" },
566
- { type: "header", text: "Webhooks" },
567
- ...webhooks,
568
- { type: "header", text: "Recent deliveries" },
569
- ...deliveries,
753
+ { type: "context", text: "Deliveries, their health and the webhook subscriptions are under Webhooks; everything RedeemID did is under App events." },
570
754
  { type: "divider" },
571
755
  { type: "header", text: "Shopify settings" },
572
756
  {
@@ -579,62 +763,24 @@ async function statusPage(config: RedeemIDConfig, request: Request) {
579
763
  ],
580
764
  },
581
765
  { 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
- },
766
+ { type: "context", text: "Levels, rules and the meter are under Access." },
624
767
  ],
625
768
  };
626
769
  }
627
770
 
628
- async function statusWidget(config: RedeemIDConfig) {
771
+ async function statusWidget(config: RedeemIDConfig, ctx: PluginContext) {
629
772
  const c = await checks(config);
630
773
  const members = await allMembers().catch(() => []);
774
+ const check = await lastCheck(ctx.kv).catch(() => null);
631
775
  return {
632
776
  blocks: [
777
+ ...(check?.problems.length ? [{ type: "banner", variant: "alert", title: "Webhooks need attention", description: check.problems[0]!.text }] : []),
633
778
  {
634
779
  type: "fields",
635
780
  fields: [
636
781
  { label: "Sign-in", value: c.discovery.startsWith("OK") && c.clientId ? "Ready" : "Not ready" },
637
782
  { label: "Members", value: String(members.length) },
783
+ { label: "Webhooks", value: check ? (check.problems.length ? `${check.problems.length} to look at` : "Healthy") : "Not checked yet" },
638
784
  ],
639
785
  },
640
786
  ],
package/src/rate-limit.ts CHANGED
@@ -17,6 +17,7 @@ const LIMITED: Record<string, string> = {
17
17
  [`GET ${BASE}/login`]: "login",
18
18
  [`POST ${BASE}/buy`]: "buy",
19
19
  [`POST ${BASE}/cart-edit`]: "cart",
20
+ [`POST ${BASE}/api/cart`]: "cart",
20
21
  };
21
22
 
22
23
  /** Which limited action a request is, or null. */
@@ -57,6 +58,12 @@ export async function rateLimited(limiter: RateLimiter | undefined, request: Req
57
58
  console.error("[redeemid] rate limit check failed:", (e as Error).message);
58
59
  return null;
59
60
  }
61
+ if (url.pathname.startsWith(`${BASE}/api/`)) {
62
+ return new Response(JSON.stringify({ error: "too_many_requests" }), {
63
+ status: 429,
64
+ headers: { "content-type": "application/json", "retry-after": "60", "cache-control": "private, no-store" },
65
+ });
66
+ }
60
67
  const back = backTo(url, request);
61
68
  const sep = back.includes("?") ? "&" : "?";
62
69
  return new Response(null, {
@@ -0,0 +1,84 @@
1
+ /**
2
+ * /_emdash/api/auth/redeemid/api/cart: the visitor's Shopify cart as JSON.
3
+ *
4
+ * GET answers { cart } (null when there is none).
5
+ * POST takes JSON { action, ... } with the same fields as cart-edit
6
+ * (product, variant, options: { Size: "M" }, sellingPlan, quantity, line,
7
+ * code) and answers { notice, cart, added? }. Action "checkout" answers
8
+ * { checkoutUrl }: signed in for members, re-read when asked for, as
9
+ * Shopify recommends. Same-origin only (EmDash checks Origin on POST).
10
+ */
11
+
12
+ import type { APIRoute } from "astro";
13
+
14
+ import { readCart, saveCart } from "../../cart.js";
15
+ import { editCart, type CartInput } from "../../cart-ops.js";
16
+ import { checkoutFor } from "../../checkout.js";
17
+ import { readConfig, storeDomain } from "../../config.js";
18
+ import { cartDetails, getCart, type CartDetails } from "../../storefront.js";
19
+ import { json } from "./shared.js";
20
+
21
+ export const prerender = false;
22
+
23
+ type Emdash = { config?: unknown; db?: unknown } | undefined;
24
+
25
+ async function current(config: NonNullable<ReturnType<typeof readConfig>>, session: Parameters<typeof readCart>[0], request: Request): Promise<CartDetails | null> {
26
+ const ref = await readCart(session);
27
+ if (!ref) return null;
28
+ const cart = await cartDetails(config, ref.id, { buyerIp: request.headers.get("cf-connecting-ip") });
29
+ if (!cart) saveCart(session, null);
30
+ else if (cart.totalQuantity !== ref.count) saveCart(session, { id: cart.id, count: cart.totalQuantity });
31
+ return cart;
32
+ }
33
+
34
+ export const GET: APIRoute = async ({ request, locals, session }) => {
35
+ const config = readConfig((locals as { emdash?: Emdash }).emdash?.config);
36
+ if (!config || !storeDomain(config)) return json(404, { error: "not_configured" });
37
+ try {
38
+ return json(200, { cart: await current(config, session, request) });
39
+ } catch {
40
+ return json(502, { error: "checkout_failed" });
41
+ }
42
+ };
43
+
44
+ export const POST: APIRoute = async ({ request, locals, session }) => {
45
+ const emdash = (locals as { emdash?: Emdash }).emdash;
46
+ const config = readConfig(emdash?.config);
47
+ if (!config || !storeDomain(config)) return json(404, { error: "not_configured" });
48
+ const body = (await request.json().catch(() => null)) as Record<string, unknown> | null;
49
+ if (!body || typeof body !== "object" || typeof body.action !== "string") return json(400, { error: "bad_request" });
50
+ const str = (v: unknown) => (typeof v === "string" ? v : typeof v === "number" ? String(v) : undefined);
51
+ try {
52
+ if (body.action === "checkout") {
53
+ const ref = await readCart(session);
54
+ const cart = ref ? await getCart(config, ref.id, { buyerIp: request.headers.get("cf-connecting-ip") }) : null;
55
+ if (!cart || cart.totalQuantity < 1) {
56
+ if (ref) saveCart(session, null);
57
+ return json(200, { notice: "cart_empty", checkoutUrl: null });
58
+ }
59
+ return json(200, { checkoutUrl: await checkoutFor(config, cart, { session, request, emdashConfig: emdash?.config }) });
60
+ }
61
+ const options: Record<string, string> = {};
62
+ if (body.options && typeof body.options === "object") {
63
+ for (const [k, v] of Object.entries(body.options as Record<string, unknown>)) if (typeof v === "string") options[k] = v;
64
+ }
65
+ const page = str(body.page);
66
+ const input: CartInput = {
67
+ action: body.action,
68
+ product: str(body.product),
69
+ variant: str(body.variant),
70
+ options,
71
+ sellingPlan: body.sellingPlan === undefined ? null : (str(body.sellingPlan) ?? null),
72
+ quantity: str(body.quantity) ?? "1",
73
+ line: str(body.line),
74
+ code: str(body.code),
75
+ page: page && page.startsWith("/") && !page.startsWith("//") ? page.slice(0, 300) : undefined,
76
+ };
77
+ const out = await editCart(input, { config, session, request, db: emdash?.db });
78
+ const cart = out.cart ?? (out.base ? await current(config, session, request) : null);
79
+ const status = out.notice === "plan_unavailable" ? 422 : 200;
80
+ return json(status, { notice: out.notice, cart, ...(out.added ? { added: out.added } : {}) });
81
+ } catch {
82
+ return json(502, { error: "checkout_failed" });
83
+ }
84
+ };