@redeemid/emdash 0.0.19 → 0.0.21

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/src/index.ts CHANGED
@@ -28,11 +28,16 @@
28
28
  * (the client secret of the Shopify app whose webhooks the site receives).
29
29
  */
30
30
 
31
+ import { readdirSync, readFileSync, type Dirent } from "node:fs";
32
+ import { join, relative } from "node:path";
33
+ import { fileURLToPath } from "node:url";
34
+
31
35
  import type { AstroIntegration } from "astro";
32
36
  import type { AuthProviderDescriptor } from "emdash";
33
37
 
34
38
  import { PROVIDER_ID, type RedeemIDConfig } from "./config.js";
35
39
  import { STORAGE_CONFIG } from "./storage.js";
40
+ import { scanTemplate, type TemplateFinding } from "./template-scan.js";
36
41
 
37
42
  export type { LevelConfig, RedeemIDConfig, RuleConfig } from "./config.js";
38
43
  export type { MemberRecord } from "./storage.js";
@@ -77,7 +82,11 @@ export function redeemidGuard(options: { rateLimit?: boolean } = {}): AstroInteg
77
82
  return {
78
83
  name: "@redeemid/emdash/guard",
79
84
  hooks: {
80
- "astro:config:setup": ({ addMiddleware }) => {
85
+ "astro:config:setup": ({ addMiddleware, updateConfig, config }) => {
86
+ // Finds templates that render a body with EmDash's <PortableText>
87
+ // instead of <Content>, for the Status page.
88
+ updateConfig({ vite: { plugins: [templateScan(config.root, config.srcDir)] } });
89
+ addMiddleware({ entrypoint: "@redeemid/emdash/templates.ts", order: "pre" });
81
90
  // Cloudflare Workers only: limits Sign in, Buy and cart changes per
82
91
  // visitor with the REDEEMID_RATE_LIMIT Rate Limiting binding.
83
92
  if (options.rateLimit) addMiddleware({ entrypoint: "@redeemid/emdash/rate-limit-cloudflare.ts", order: "pre" });
@@ -87,4 +96,41 @@ export function redeemidGuard(options: { rateLimit?: boolean } = {}): AstroInteg
87
96
  };
88
97
  }
89
98
 
99
+ const TEMPLATES_MARK = "const FINDINGS: TemplateFinding[] | null = null; // redeemid:templates";
100
+
101
+ /** Vite plugin: fills templates.ts with the scan of the site's .astro files. */
102
+ function templateScan(root: URL, srcDir: URL) {
103
+ return {
104
+ name: "redeemid:templates",
105
+ // Before TypeScript is stripped, so the marker line is still there.
106
+ enforce: "pre" as const,
107
+ transform(code: string, id: string) {
108
+ if (!id.split("?")[0]!.endsWith("/templates.ts") || !code.includes(TEMPLATES_MARK)) return undefined;
109
+ const base = fileURLToPath(root);
110
+ const out: TemplateFinding[] = [];
111
+ // Static node: imports only: astro.config's module runner is closed by
112
+ // the time this runs, so a dynamic import() here fails.
113
+ const walk = (dir: string): void => {
114
+ let items: Dirent[] = [];
115
+ try {
116
+ items = readdirSync(dir, { withFileTypes: true });
117
+ } catch {
118
+ return;
119
+ }
120
+ for (const d of items) {
121
+ const path = join(dir, d.name);
122
+ if (d.isDirectory() && d.name !== "node_modules") walk(path);
123
+ else if (d.isFile() && d.name.endsWith(".astro")) {
124
+ const found = scanTemplate(relative(base, path).split("\\").join("/"), readFileSync(path, "utf8"));
125
+ if (found) out.push(found);
126
+ }
127
+ }
128
+ };
129
+ walk(fileURLToPath(srcDir));
130
+ out.sort((a, b) => a.file.localeCompare(b.file));
131
+ return { code: code.replace(TEMPLATES_MARK, `const FINDINGS = ${JSON.stringify(out)};`), map: null };
132
+ },
133
+ };
134
+ }
135
+
90
136
  export default redeemid;
package/src/log.ts CHANGED
@@ -45,6 +45,7 @@ export const LOG_EVENTS: Record<string, string> = {
45
45
  "staff.webhooks": "Webhook registration",
46
46
  "settings.saved": "Settings saved",
47
47
  "events.failed": "Outbound event failed",
48
+ "events.resent": "Outbound event sent again",
48
49
  "health.check": "Health check",
49
50
  "meter.used": "Metered read",
50
51
  "meter.exhausted": "Metered read, meter used up",
@@ -88,13 +89,15 @@ export async function timed<T>(event: string, label: string, run: () => Promise<
88
89
  }
89
90
  }
90
91
 
92
+ type Where<T> = NonNullable<NonNullable<Parameters<StorageCollection<T>["query"]>[0]>["where"]>;
93
+
91
94
  /** Delete entries (and other dated records) older than `days`, a page at a time. */
92
- export async function prune<T>(store: StorageCollection<T>, field: string, days = LOG_DAYS): Promise<number> {
95
+ export async function prune<T>(store: StorageCollection<T>, field: string, days = LOG_DAYS, where: Record<string, string | number | boolean> = {}): Promise<number> {
93
96
  const before = new Date(Date.now() - days * 86_400_000).toISOString();
94
97
  let gone = 0;
95
98
  // A few pages a run: enough to keep up with a busy site, few enough queries for one request.
96
99
  for (let page = 0; page < PRUNE_PAGES; page++) {
97
- const { items } = await store.query({ where: { [field]: { lt: before } }, limit: 100 });
100
+ const { items } = await store.query({ where: { ...where, [field]: { lt: before } } as Where<T>, limit: 100 });
98
101
  if (items.length === 0) break;
99
102
  gone += await store.deleteMany(items.map((i) => i.id));
100
103
  if (items.length < 100) break;
package/src/monitor.ts CHANGED
@@ -16,6 +16,7 @@ import { levelsFor, membership } from "./access.js";
16
16
  import { adminConfigured, registerWebhooks } from "./admin-api.js";
17
17
  import { env, ENV, type RedeemIDConfig } from "./config.js";
18
18
  import { LOG_DAYS, LOG_EVENTS, prune, record, type LogEntry } from "./log.js";
19
+ import { RETRY_DELAYS_MS, type FailedEvent } from "./events.js";
19
20
  import type { DeliveryRecord, MemberRecord, Stores } from "./storage.js";
20
21
  import { webhooksSection } from "./staff.js";
21
22
 
@@ -181,10 +182,15 @@ async function alert(env_: MonitorEnv, settings: MonitorSettings, result: Health
181
182
  }
182
183
  }
183
184
 
184
- /** Drop app events and delivery records older than LOG_DAYS, a page of each. */
185
+ /** Finished privacy requests are kept this long, as the record that they were answered. Open ones stay until staff close them. */
186
+ export const REQUEST_DAYS = 365;
187
+
188
+ /** Drop app events and delivery records older than LOG_DAYS, and finished privacy requests older than REQUEST_DAYS. */
185
189
  export async function tidy(s: Stores): Promise<void> {
186
190
  await prune(s.log, "at").catch(() => 0);
187
191
  await prune(s.deliveries, "receivedAt").catch(() => 0);
192
+ await prune(s.failedEvents, "at").catch(() => 0);
193
+ await prune(s.requests, "at", REQUEST_DAYS, { status: "done" }).catch(() => 0);
188
194
  }
189
195
 
190
196
  /** WEBHOOK_TOPICS name → the topic Shopify shows: CUSTOMER_TAGS_ADDED → customer.tags_added. */
@@ -379,6 +385,7 @@ export async function eventsPage(s: Stores, filter: EventFilter = {}, cursor?: s
379
385
  if (filter.event && filter.event in LOG_EVENTS) where.event = filter.event;
380
386
  if (filter.problems) where.level = { in: ["warn", "error"] };
381
387
  const page = await s.log.query({ where: where as never, orderBy: { at: "desc" }, limit: 50, ...(cursor ? { cursor } : {}) });
388
+ const failed = cursor ? null : await s.failedEvents.query({ orderBy: { at: "desc" }, limit: 50 }).catch(() => null);
382
389
  return {
383
390
  blocks: [
384
391
  { type: "header", text: "App events" },
@@ -386,6 +393,7 @@ export async function eventsPage(s: Stores, filter: EventFilter = {}, cursor?: s
386
393
  type: "context",
387
394
  text: `What RedeemID did and what came of it: webhooks, sign-ins, calls to Shopify, staff actions, settings saves and outbound events. Kept ${LOG_DAYS} days. Never bodies, tokens or emails.`,
388
395
  },
396
+ ...(failed?.items.length ? failedBlocks(failed.items.map((r) => r.data)) : []),
389
397
  {
390
398
  type: "form",
391
399
  block_id: "events_filter",
@@ -424,6 +432,60 @@ export async function eventsPage(s: Stores, filter: EventFilter = {}, cursor?: s
424
432
  };
425
433
  }
426
434
 
435
+ /** Outbound events that failed every try: send again or clear. */
436
+ function failedBlocks(list: FailedEvent[]): unknown[] {
437
+ return [
438
+ { type: "header", text: "Failed events" },
439
+ {
440
+ type: "context",
441
+ text: `Outbound events your receiver didn't take after ${RETRY_DELAYS_MS.length + 1} tries. Send again keeps each one's delivery id, so a receiver that got it after all can drop the copy. Cleared after ${LOG_DAYS} days.`,
442
+ },
443
+ {
444
+ type: "table",
445
+ page_action_id: "failed_page",
446
+ columns: [
447
+ { key: "event", label: "Event" },
448
+ { key: "customer", label: "Customer" },
449
+ { key: "error", label: "Last error" },
450
+ { key: "tries", label: "Tries" },
451
+ { key: "at", label: "When", format: "relative_time" },
452
+ { key: "resend", label: "", format: "element" },
453
+ { key: "clear", label: "", format: "element" },
454
+ ],
455
+ rows: list.map((f) => ({
456
+ event: f.event,
457
+ customer: customerOfBody(f.body),
458
+ error: f.error,
459
+ tries: String(f.attempts),
460
+ at: f.at,
461
+ resend: { type: "button", action_id: "failed_resend", label: "Send again", style: "primary", value: f.id },
462
+ clear: { type: "button", action_id: "failed_clear", label: "Clear", style: "secondary", value: f.id },
463
+ })),
464
+ },
465
+ {
466
+ type: "actions",
467
+ elements: [
468
+ {
469
+ type: "button",
470
+ action_id: "failed_clear_all",
471
+ label: "Clear all",
472
+ style: "danger",
473
+ confirm: { title: "Clear every failed event?", text: "They won't be sent. App events keeps the record that they failed.", confirm: "Clear all", deny: "Cancel", style: "danger" },
474
+ },
475
+ ],
476
+ },
477
+ ];
478
+ }
479
+
480
+ function customerOfBody(body: string): string {
481
+ try {
482
+ const id = (JSON.parse(body) as { data?: { customerId?: unknown } }).data?.customerId;
483
+ return typeof id === "string" ? id : "";
484
+ } catch {
485
+ return "";
486
+ }
487
+ }
488
+
427
489
  function eventRow(e: LogEntry) {
428
490
  const status = e.status === "ok" ? "OK" : cap(e.status);
429
491
  return {
package/src/plugin.ts CHANGED
@@ -30,7 +30,7 @@ import { definePlugin } from "emdash";
30
30
 
31
31
  import { levelsFor, SIGNED_IN, type Terms } from "./access.js";
32
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";
33
+ import { deleteAccess, entryKey, loadAccess, onLevels, withSaved } from "./settings.js";
34
34
  import { env, ENV, storeDomain, unknownEnvNames, type LevelConfig, type RedeemIDConfig } from "./config.js";
35
35
  import { discover } from "./shopify.js";
36
36
  import { stores, type MemberRecord, type Stores } from "./storage.js";
@@ -40,7 +40,10 @@ import { AdminError, adminConfigured } from "./admin-api.js";
40
40
  import { cancelShopifyErasure, closeRequest, eraseMember, exportMember, exportRequest, requestShopifyErasure } from "./privacy.js";
41
41
  import { accessTest, bulkResync, BULK_BATCH, exportBlocks, memberDetail, privacyPage, registerWebhooks, resyncOne, tagEdit } from "./staff.js";
42
42
  import { textProblems } from "./text.js";
43
+ import { gatedFindings, templateFindings } from "./template-scan.js";
44
+ import { setupFacts, setupPage, WEBHOOK } from "./setup.js";
43
45
  import { record } from "./log.js";
46
+ import { resend } from "./events.js";
44
47
  import { eventsPage, healthCheck, HEALTH_TASK, lastCheck, parseFilter, reportsPage, saveMonitor, WEBHOOK_PATH, WEBHOOK_TABS, webhooksPage, type MonitorEnv, type WebhookTab } from "./monitor.js";
45
48
  import { z } from "astro/zod";
46
49
  import { AgentError, explainAccess, findProducts, getCollection, getMember, getProduct, getRules, levelChanges, listLevels, siteHealth, type ExplainInput } from "./agents.js";
@@ -239,8 +242,15 @@ export function createPlugin(code: RedeemIDConfig = {}): ResolvedPlugin {
239
242
  await entrySlugChanged((await memberStores()).access, event.collection, id, slug).catch(() => undefined);
240
243
  },
241
244
  "content:afterDelete": async (event) => {
242
- if (!event.permanent) return;
243
- await (await memberStores()).access.delete(entryKey(event.id)).catch(() => undefined);
245
+ const store = (await memberStores()).access;
246
+ // Trashed: the rule waits (by id) for a restore, without the slug.
247
+ if (!event.permanent) return entrySlugChanged(store, null, event.id, null).catch(() => undefined);
248
+ if (await store.get(entryKey(event.id)).catch(() => null)) await deleteAccess(store, entryKey(event.id)).catch(() => undefined);
249
+ },
250
+ "content:afterRestore": async (event) => {
251
+ const id = typeof event.content.id === "string" ? event.content.id : null;
252
+ const slug = typeof event.content.slug === "string" ? event.content.slug : null;
253
+ if (id) await entrySlugChanged((await memberStores()).access, event.collection, id, slug).catch(() => undefined);
244
254
  },
245
255
  },
246
256
  routes: {
@@ -276,6 +286,7 @@ export function createPlugin(code: RedeemIDConfig = {}): ResolvedPlugin {
276
286
  if (out.toast?.type === "success") await record({ event: "settings.saved", label: `access: ${String(i.action_id).replace(/_/g, " ")}`, status: "ok", origin: "staff" });
277
287
  return out;
278
288
  }
289
+ if ((i.type === "block_action" || i.type === "form_submit") && isSetupAction(i.action_id)) return await setupAction(config, live, ctx, i);
279
290
  if (i.type === "block_action" || i.type === "form_submit") return await action(config, ctx, i);
280
291
  if (i.type !== "page_load") return { blocks: [] };
281
292
  if (i.page === "/") return await membersPage(config);
@@ -286,6 +297,7 @@ export function createPlugin(code: RedeemIDConfig = {}): ResolvedPlugin {
286
297
  }
287
298
  if (i.page === "/events") return await eventsPage(await memberStores());
288
299
  if (i.page === "/reports") return await reportsPage(config, await memberStores());
300
+ if (i.page === "/setup") return setupPage(await setupFacts(config, await memberStores(), siteOrigin(ctx)));
289
301
  if (i.page === "/status") return await statusPage(config, ctx.request);
290
302
  if (i.page === "/test") return await accessTest(config);
291
303
  if (i.page === "/privacy") return await privacyPage(await memberStores());
@@ -456,6 +468,44 @@ function agentTools(config: RedeemIDConfig) {
456
468
  return { tools };
457
469
  }
458
470
 
471
+ // ---------------------------------------------------------------- setup
472
+
473
+ const SETUP_ACTIONS = new Set(["setup_check", "setup_register", "setup_level"]);
474
+ const isSetupAction = (id: unknown) => typeof id === "string" && SETUP_ACTIONS.has(id);
475
+
476
+ /** The site's own origin, as visitors reach it. */
477
+ function siteOrigin(ctx: PluginContext): string {
478
+ return new URL(ctx.url("/")).origin;
479
+ }
480
+
481
+ /** Setup's buttons: Check again, Register, Add level. Each answers the page as it is after the step. */
482
+ async function setupAction(
483
+ config: RedeemIDConfig,
484
+ live: () => Promise<RedeemIDConfig>,
485
+ ctx: PluginContext,
486
+ i: { action_id?: string; values?: Record<string, unknown> },
487
+ ) {
488
+ const s = await memberStores();
489
+ const origin = siteOrigin(ctx);
490
+ const page = async (toast?: { type: "success" | "error" | "info"; message: string }) => setupPage(await setupFacts(await live(), s, origin), toast);
491
+ if (i.action_id === "setup_register") {
492
+ try {
493
+ const { added, failed } = await registerWebhooks(config, origin + WEBHOOK);
494
+ await record({ event: "staff.webhooks", label: "register (setup)", status: failed.length ? `${failed.length} refused` : `${added.length} registered`, level: failed.length ? "warn" : "ok", origin: "staff" });
495
+ const refused = failed.map((f) => `Shopify refused ${f.topic}: ${f.message}`).join(" ");
496
+ return page({ type: failed.length ? "error" : "success", message: [added.length ? `Registered ${added.length} webhooks.` : failed.length ? "" : "Nothing was missing.", refused].filter(Boolean).join(" ") });
497
+ } catch (e) {
498
+ return page({ type: "error", message: (e as Error).message });
499
+ }
500
+ }
501
+ if (i.action_id === "setup_level") {
502
+ const out = await accessAction(await accessEnv(config), { action_id: "level_save", block_id: "level:new", values: { name: i.values?.name, tags: i.values?.tags } });
503
+ if (out.toast?.type === "success") await record({ event: "settings.saved", label: "access: level save (setup)", status: "ok", origin: "staff" });
504
+ return page(out.toast?.type === "success" ? { type: "success", message: "Level added. Change it any time under Access." } : out.toast);
505
+ }
506
+ return page({ type: "info", message: "Checked." });
507
+ }
508
+
459
509
  // ---------------------------------------------------------------- actions
460
510
 
461
511
  async function action(
@@ -544,6 +594,18 @@ async function action(
544
594
  return eventsPage(s, { event: String(i.value ?? "") || undefined });
545
595
  case "events_page":
546
596
  return eventsPage(s, parseFilter(i.block_id), cursorOf(i.value));
597
+ case "failed_resend": {
598
+ const sent = await resend(config, s, String(i.value ?? "")).catch(() => false);
599
+ return eventsPage(s, {}, undefined, sent ? { type: "success", message: "Sent. It's off the failed list." } : { type: "error", message: "The receiver still didn't take it. It stays on the list." });
600
+ }
601
+ case "failed_clear":
602
+ await s.failedEvents.delete(String(i.value ?? ""));
603
+ return eventsPage(s, {}, undefined, { type: "success", message: "Cleared." });
604
+ case "failed_clear_all": {
605
+ const { items } = await s.failedEvents.query({ limit: 100 });
606
+ await s.failedEvents.deleteMany(items.map((r) => r.id));
607
+ return eventsPage(s, {}, undefined, { type: "success", message: "Failed events cleared." });
608
+ }
547
609
  case "access_test":
548
610
  return accessTest(config, i.values);
549
611
  case "member_export": {
@@ -750,7 +812,8 @@ async function statusPage(config: RedeemIDConfig, request: Request) {
750
812
  },
751
813
  ]
752
814
  : []),
753
- { type: "context", text: "Deliveries, their health and the webhook subscriptions are under Webhooks; everything RedeemID did is under App events." },
815
+ ...templateBlocks(config),
816
+ { type: "context", text: "Setting up a store step by step is under Setup. Deliveries, their health and the webhook subscriptions are under Webhooks; everything RedeemID did is under App events." },
754
817
  { type: "divider" },
755
818
  { type: "header", text: "Shopify settings" },
756
819
  {
@@ -768,6 +831,30 @@ async function statusPage(config: RedeemIDConfig, request: Request) {
768
831
  };
769
832
  }
770
833
 
834
+ /** Status: templates that would show a gated body in full (see template-scan.ts). */
835
+ function templateBlocks(config: RedeemIDConfig): unknown[] {
836
+ const found = templateFindings();
837
+ if (!found) return [{ type: "banner", variant: "alert", title: "Templates weren't checked", description: "Add redeemidGuard() to the integrations in astro.config.mjs. It gates pages and keeps members out of EmDash's own APIs, and checks the templates." }];
838
+ const risky = gatedFindings(found, config);
839
+ if (!risky.length) return [{ type: "context", text: "Templates: none renders a gated collection's body with EmDash's PortableText. Bodies go through RedeemID's Content." }];
840
+ const sure = risky.filter((f) => f.gated.length);
841
+ return [
842
+ sure.length
843
+ ? { type: "banner", variant: "error", title: "A template shows locked bodies in full", description: "These templates render a gated collection's body with EmDash's <PortableText>, which skips every rule, Paywall break and Members only part. Use RedeemID's <Content value={entry.data.content} collection=\"…\" terms={entry.data.terms} id={entry.id} /> instead." }
844
+ : { type: "banner", variant: "alert", title: "Check these templates", description: "They render a body with EmDash's <PortableText> and RedeemID couldn't see which collection. If it's one with a rule, use RedeemID's <Content> instead." },
845
+ {
846
+ type: "table",
847
+ page_action_id: "template_findings",
848
+ columns: [
849
+ { key: "file", label: "Template" },
850
+ { key: "lines", label: "Line" },
851
+ { key: "collections", label: "Gated collection" },
852
+ ],
853
+ rows: risky.map((f) => ({ file: f.file, lines: f.lines.join(", "), collections: f.gated.join(", ") || "Not found in the file" })),
854
+ },
855
+ ];
856
+ }
857
+
771
858
  async function statusWidget(config: RedeemIDConfig, ctx: PluginContext) {
772
859
  const c = await checks(config);
773
860
  const members = await allMembers().catch(() => []);
package/src/rate-limit.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
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
3
+ * Sign in, Buy, cart changes and catalog search. On Cloudflare Workers they use a Rate
4
4
  * Limiting binding named REDEEMID_RATE_LIMIT (see rate-limit-cloudflare.ts);
5
5
  * nothing is limited without one.
6
6
  */
@@ -18,6 +18,8 @@ const LIMITED: Record<string, string> = {
18
18
  [`POST ${BASE}/buy`]: "buy",
19
19
  [`POST ${BASE}/cart-edit`]: "cart",
20
20
  [`POST ${BASE}/api/cart`]: "cart",
21
+ // Each new query is a Storefront API call.
22
+ [`GET ${BASE}/api/search`]: "search",
21
23
  };
22
24
 
23
25
  /** Which limited action a request is, or null. */
@@ -11,7 +11,7 @@ import type { APIRoute } from "astro";
11
11
  import { safeReturn, signInUrl } from "../access.js";
12
12
  import { readConfig, storeDomain } from "../config.js";
13
13
  import { discover } from "../shopify.js";
14
- import { fail } from "./shared.js";
14
+ import { fail, sessionUser } from "./shared.js";
15
15
 
16
16
  export const prerender = false;
17
17
 
@@ -20,7 +20,7 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
20
20
  const config = readConfig((locals as { emdash?: { config?: unknown } }).emdash?.config);
21
21
  if (!config || !storeDomain(config)) return fail(redirect, returnTo, "not_configured");
22
22
 
23
- const user = (await session?.get("user")) as { id?: string } | undefined;
23
+ const user = await sessionUser(session, (locals as { emdash?: { db?: unknown } }).emdash?.db);
24
24
  if (!user?.id) return redirect(signInUrl(returnTo));
25
25
  try {
26
26
  const d = await discover(config);
@@ -18,6 +18,7 @@ import { siteConfig } from "../../site-config.js";
18
18
  import { memberForUser, stores } from "../../storage.js";
19
19
  import { VERSION } from "../../version.js";
20
20
  import { json } from "./shared.js";
21
+ import { sessionUser } from "../shared.js";
21
22
 
22
23
  export const prerender = false;
23
24
 
@@ -36,7 +37,7 @@ export interface State {
36
37
  export const GET: APIRoute = async ({ locals, session }) => {
37
38
  const config = await siteConfig(locals);
38
39
  const db = (locals as { emdash?: { db?: unknown } }).emdash?.db;
39
- const user = (await session?.get("user")) as { id?: string } | undefined;
40
+ const user = await sessionUser(session, db);
40
41
  const ref = await readCart(session);
41
42
  const state: State = {
42
43
  apiVersion: API_VERSION,
package/src/routes/buy.ts CHANGED
@@ -16,7 +16,7 @@ import { memberForUser, stores } from "../storage.js";
16
16
  import { saveCart, readCart } from "../cart.js";
17
17
  import { checkoutFor } from "../checkout.js";
18
18
  import { addToCart, plan, storeContext } from "../storefront.js";
19
- import { fail } from "./shared.js";
19
+ import { fail, sessionUser } from "./shared.js";
20
20
 
21
21
  export const prerender = false;
22
22
 
@@ -33,8 +33,8 @@ export const POST: APIRoute = async ({ request, locals, session, redirect }) =>
33
33
  const p = await plan(config, handle);
34
34
  if (!p?.variantId || !p.availableForSale) return fail(redirect, returnTo, "plan_unavailable");
35
35
  // Provider routes are public, so read the member from the session.
36
- const user = (await session?.get("user")) as { id?: string } | undefined;
37
- const member = user?.id && emdash?.db ? await memberForUser(stores(emdash.db), user.id) : null;
36
+ const user = await sessionUser(session, emdash?.db);
37
+ const member = user && emdash?.db ? await memberForUser(stores(emdash.db), user.id) : null;
38
38
  const current = await readCart(session);
39
39
  const cart = await addToCart(
40
40
  config,
@@ -21,6 +21,8 @@ import { record } from "../log.js";
21
21
  import { levelsFor } from "../access.js";
22
22
  import { discover, exchangeCode, identity, logoutUrl, ShopifyError, verifyIdToken, type Discovery } from "../shopify.js";
23
23
  import { emptyMember, stampTags, stores } from "../storage.js";
24
+ import { adminConfigured, resync } from "../admin-api.js";
25
+ import { seal } from "../token-vault.js";
24
26
  import { getPublicOrigin, OptionsRepository } from "emdash/api/route-utils";
25
27
 
26
28
  import { afterLogoutCookie, callbackUrl, fail, FLOW_TTL_MS, SESSION_FLOW, SESSION_CUSTOMER_TOKEN, SESSION_ID_TOKEN, type CustomerToken, type Flow } from "./shared.js";
@@ -119,10 +121,18 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
119
121
  syncedAt: now,
120
122
  source: "sign-in",
121
123
  linkedAt: member.linkedAt ?? now,
124
+ emailVerified,
122
125
  },
123
126
  now,
124
127
  ),
125
128
  );
129
+ // With the Admin API app, signing in is a full sync: the membership
130
+ // dates and marketing consent too. A failure never blocks sign-in.
131
+ if (adminConfigured()) {
132
+ await resync(config, s, [who.customerId], "sign-in").catch(async (e: Error) => {
133
+ await record({ event: "staff.resync", label: "sign-in", status: "error", level: "warn", origin: "member", customerId: who.customerId, note: String(e.message).slice(0, 200) });
134
+ });
135
+ }
126
136
 
127
137
  const levels = levelsFor(who.tags, config.levels);
128
138
  await record({ event: "member.signed_in", label: flow.refresh ? "refresh" : member.linkedAt ? "sign-in" : "first sign-in", status: "ok", origin: "member", customerId: who.customerId });
@@ -132,7 +142,12 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
132
142
  session.regenerate?.();
133
143
  session.set("user", { id: user.id });
134
144
  session.set(SESSION_ID_TOKEN, tokens.idToken);
135
- session.set(SESSION_CUSTOMER_TOKEN, { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken ?? null, expiresAt: tokens.expiresAt ?? null } satisfies CustomerToken);
145
+ // Kept only for signed-in checkout, and sealed (see token-vault.ts).
146
+ if (config.checkout?.signIn !== false) {
147
+ const sealed = await seal({ accessToken: tokens.accessToken, refreshToken: tokens.refreshToken ?? null, expiresAt: tokens.expiresAt ?? null } satisfies CustomerToken, emdash.db).catch(() => null);
148
+ if (sealed) session.set(SESSION_CUSTOMER_TOKEN, sealed);
149
+ else session.delete?.(SESSION_CUSTOMER_TOKEN);
150
+ } else session.delete?.(SESSION_CUSTOMER_TOKEN);
136
151
  return flow.refresh ? fail(redirect, returnTo, "refreshed") : redirect(returnTo);
137
152
  } catch (e) {
138
153
  const code = e instanceof ShopifyError ? e.code : e instanceof OAuthError ? "signup_not_allowed" : "sign_in_failed";
@@ -17,7 +17,7 @@ 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";
20
- import { fail } from "./shared.js";
20
+ import { fail, sessionUser } from "./shared.js";
21
21
 
22
22
  export const prerender = false;
23
23
 
@@ -28,7 +28,7 @@ async function context(locals: unknown, session: { get(k: string): Promise<unkno
28
28
  const emdash = (locals as { emdash?: { config?: unknown; db?: unknown } }).emdash;
29
29
  const config = await siteConfig(locals);
30
30
  if (!config || !preferencesConfigured(config) || !emdash?.db) return { error: "unavailable" as const };
31
- const user = (await session?.get("user")) as { id?: string } | undefined;
31
+ const user = await sessionUser(session, emdash.db);
32
32
  if (!user?.id) return { error: "sign_in" as const, config };
33
33
  const s = stores(emdash.db);
34
34
  const member = await memberForUser(s, user.id);
@@ -15,14 +15,14 @@ import { safeReturn, signInUrl } from "../access.js";
15
15
  import { siteConfig } from "../site-config.js";
16
16
  import { eraseMember, exportRequest } from "../privacy.js";
17
17
  import { memberForUser, stores } from "../storage.js";
18
- import { fail } from "./shared.js";
18
+ import { fail, sessionUser } from "./shared.js";
19
19
 
20
20
  export const prerender = false;
21
21
 
22
22
  async function context(locals: unknown, session: { get(k: string): Promise<unknown> } | undefined) {
23
23
  const emdash = (locals as { emdash?: { config?: unknown; db?: unknown } }).emdash;
24
24
  if (!emdash?.db) return null;
25
- const user = (await session?.get("user")) as { id?: string } | undefined;
25
+ const user = await sessionUser(session, emdash.db);
26
26
  if (!user?.id) return null;
27
27
  const s = stores(emdash.db);
28
28
  const member = await memberForUser(s, user.id);
@@ -63,3 +63,22 @@ export function readAfterLogout(raw: string | undefined): { code: string; return
63
63
  return null;
64
64
  }
65
65
  }
66
+
67
+ type SessionLike = { get(key: string): unknown } | undefined;
68
+
69
+ /**
70
+ * The signed-in EmDash user behind this session, or null: none, gone, or
71
+ * disabled in EmDash since they signed in. Every member route asks this
72
+ * instead of trusting the session's user id.
73
+ */
74
+ export async function sessionUser(session: SessionLike, db: unknown): Promise<{ id: string } | null> {
75
+ const held = (await session?.get("user")) as { id?: string } | undefined;
76
+ if (!held?.id || !db) return null;
77
+ try {
78
+ const { createKyselyAdapter } = await import("@emdash-cms/auth/adapters/kysely");
79
+ const user = await createKyselyAdapter(db as never).getUserById(held.id);
80
+ return user && !user.disabled ? { id: user.id } : null;
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
@@ -12,9 +12,11 @@ import type { APIRoute } from "astro";
12
12
  import { env, ENV } from "../config.js";
13
13
  import { siteConfig } from "../site-config.js";
14
14
  import { levelChange } from "../events.js";
15
- import { stores } from "../storage.js";
15
+ import { stores, type Stores } from "../storage.js";
16
+ import { adminConfigured, resync } from "../admin-api.js";
17
+ import type { RedeemIDConfig } from "../config.js";
16
18
  import { record } from "../log.js";
17
- import { apply, customerOf, kindOf, verifyHmac } from "../webhooks.js";
19
+ import { apply, customerOf, kindOf, verifyHmac, type Outcome } from "../webhooks.js";
18
20
 
19
21
  export const prerender = false;
20
22
 
@@ -78,7 +80,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
78
80
 
79
81
  try {
80
82
  const config = await siteConfig(locals);
81
- const outcome = await apply(s, topic, payload, (before, after) => levelChange(config, before, after, topic), config);
83
+ const outcome = (await currentTags(config, s, topic, customerId)) ?? (await apply(s, topic, payload, (before, after) => levelChange(config, before, after, topic), config));
82
84
  if (delivery) await s.deliveries.put(delivery, { topic, receivedAt, outcome, customerId }).catch(() => undefined);
83
85
  await record({ event: "webhook.received", label: topic, status: outcome, origin: "webhook", customerId, ms: Date.now() - started });
84
86
  return new Response(outcome, { status: 200 });
@@ -89,3 +91,24 @@ export const POST: APIRoute = async ({ request, locals }) => {
89
91
  return new Response("Retry", { status: 500 });
90
92
  }
91
93
  };
94
+
95
+ /**
96
+ * Tag webhooks carry only the change, and can arrive out of order. With the
97
+ * Admin API app set up, read the customer's tags as they are now instead of
98
+ * applying the change. Null (apply the change) without the app, or when the
99
+ * read fails.
100
+ */
101
+ async function currentTags(config: RedeemIDConfig | null, s: Stores, topic: string, customerId: string | null): Promise<Outcome | null> {
102
+ if (!config || !customerId || kindOf(topic) !== "tags" || !adminConfigured()) return null;
103
+ try {
104
+ const before = await s.members.get(customerId);
105
+ const { updated } = await resync(config, s, [customerId], topic);
106
+ const m = updated[0];
107
+ if (!m) return "skipped";
108
+ await levelChange(config, before?.tags ?? null, m, topic);
109
+ return "processed";
110
+ } catch (e) {
111
+ console.error("[redeemid] tag webhook resync failed, applying the change:", (e as Error).message);
112
+ return null;
113
+ }
114
+ }