@redeemid/emdash 0.0.18 → 0.0.20

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/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";
@@ -48,7 +50,8 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
48
50
  const state = url.searchParams.get("state");
49
51
  const code = url.searchParams.get("code");
50
52
  if (!flow || !state || state !== flow.state || !code || Date.now() - flow.at > FLOW_TTL_MS) {
51
- await refused("sign_in_expired", "warn");
53
+ // A request with no sign-in behind it (a crawler, a probe) isn't worth an entry.
54
+ if (flow) await refused("sign_in_expired", "warn");
52
55
  return fail(redirect, returnTo, "sign_in_expired");
53
56
  }
54
57
 
@@ -118,10 +121,18 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
118
121
  syncedAt: now,
119
122
  source: "sign-in",
120
123
  linkedAt: member.linkedAt ?? now,
124
+ emailVerified,
121
125
  },
122
126
  now,
123
127
  ),
124
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
+ }
125
136
 
126
137
  const levels = levelsFor(who.tags, config.levels);
127
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 });
@@ -131,7 +142,12 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
131
142
  session.regenerate?.();
132
143
  session.set("user", { id: user.id });
133
144
  session.set(SESSION_ID_TOKEN, tokens.idToken);
134
- 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);
135
151
  return flow.refresh ? fail(redirect, returnTo, "refreshed") : redirect(returnTo);
136
152
  } catch (e) {
137
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,13 +12,20 @@ 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
 
21
23
  const MAX_BYTES = 1024 * 1024;
24
+ /** A delivery still "pending" after this long never finished. */
25
+ const STALE_PENDING_MS = 60 * 1000;
26
+ /** At most one "Bad signature" entry per this long, so junk requests can't fill App events. */
27
+ const REJECT_LOG_MS = 60 * 1000;
28
+ let lastRejectLogged = 0;
22
29
 
23
30
  export const POST: APIRoute = async ({ request, locals }) => {
24
31
  const emdash = (locals as { emdash?: { db?: unknown; config?: unknown } }).emdash;
@@ -31,7 +38,10 @@ export const POST: APIRoute = async ({ request, locals }) => {
31
38
  const ok = await verifyHmac(body, request.headers.get("x-shopify-hmac-sha256") ?? "", secret);
32
39
  const topic = (request.headers.get("x-shopify-topic") ?? "").toLowerCase();
33
40
  if (!ok) {
34
- await record({ event: "webhook.rejected", label: topic || "unknown", status: "rejected", level: "warn", origin: "webhook", note: "Bad signature" });
41
+ if (Date.now() - lastRejectLogged > REJECT_LOG_MS) {
42
+ lastRejectLogged = Date.now();
43
+ await record({ event: "webhook.rejected", label: topic || "unknown", status: "rejected", level: "warn", origin: "webhook", note: "Bad signature" });
44
+ }
35
45
  return new Response("Bad signature", { status: 401 });
36
46
  }
37
47
 
@@ -51,7 +61,17 @@ export const POST: APIRoute = async ({ request, locals }) => {
51
61
  const customerId = topic === "shop/redact" ? null : customerOf(payload);
52
62
  const receivedAt = new Date().toISOString();
53
63
  if (delivery) {
54
- const claimed = await s.deliveries.compareAndSet(delivery, null, { topic, receivedAt, outcome: "pending", customerId });
64
+ const claim = () => s.deliveries.compareAndSet(delivery, null, { topic, receivedAt, outcome: "pending", customerId });
65
+ let claimed = await claim();
66
+ if (!claimed.applied) {
67
+ // A delivery left "pending" was cut off before it finished (a
68
+ // timeout, a crash): let Shopify's retry run it again.
69
+ const earlier = await s.deliveries.get(delivery);
70
+ if (earlier?.outcome === "pending" && Date.now() - Date.parse(earlier.receivedAt) > STALE_PENDING_MS) {
71
+ await s.deliveries.delete(delivery);
72
+ claimed = await claim();
73
+ }
74
+ }
55
75
  if (!claimed.applied) {
56
76
  await record({ event: "webhook.received", label: topic, status: "duplicate", origin: "webhook", customerId });
57
77
  return new Response("Duplicate", { status: 200 });
@@ -60,7 +80,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
60
80
 
61
81
  try {
62
82
  const config = await siteConfig(locals);
63
- 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));
64
84
  if (delivery) await s.deliveries.put(delivery, { topic, receivedAt, outcome, customerId }).catch(() => undefined);
65
85
  await record({ event: "webhook.received", label: topic, status: outcome, origin: "webhook", customerId, ms: Date.now() - started });
66
86
  return new Response(outcome, { status: 200 });
@@ -71,3 +91,24 @@ export const POST: APIRoute = async ({ request, locals }) => {
71
91
  return new Response("Retry", { status: 500 });
72
92
  }
73
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
+ }
package/src/settings.ts CHANGED
@@ -63,7 +63,16 @@ export interface EntryRule {
63
63
  updatedAt?: string;
64
64
  }
65
65
 
66
- export type AccessRecord = SiteAccess | EntryRule;
66
+ /**
67
+ * Bumped on every write to the collection, so a request can tell with one
68
+ * small read whether the settings it holds are still current.
69
+ */
70
+ export interface AccessStamp {
71
+ kind: "stamp";
72
+ version: string;
73
+ }
74
+
75
+ export type AccessRecord = SiteAccess | EntryRule | AccessStamp;
67
76
 
68
77
  export interface LoadedAccess {
69
78
  site: SiteAccess | null;
@@ -71,6 +80,7 @@ export interface LoadedAccess {
71
80
  }
72
81
 
73
82
  export const SITE_ID = "site";
83
+ export const STAMP_ID = "stamp";
74
84
  export const entryKey = (entryId: string) => `entry:${entryId}`;
75
85
 
76
86
  /** Read everything saved, in one query. */
@@ -118,18 +128,96 @@ export function overridden(site: SiteAccess | null): Set<ManagedKey> {
118
128
  return new Set(MANAGED.filter((k) => site && k in site));
119
129
  }
120
130
 
121
- /** Code config plus the admin's settings, read with `db`. */
131
+ /**
132
+ * A level slug no level can have (slugs can't hold a colon), so nobody
133
+ * passes a requirement that names it: what every entry needs while the
134
+ * saved settings can't be read.
135
+ */
136
+ export const UNAVAILABLE = "redeemid:unavailable";
137
+
138
+ /**
139
+ * The settings while the saved ones can't be read and none were read before:
140
+ * every entry locked (see UNAVAILABLE), no meter to open one, and files in
141
+ * any folder refused. Failing open would show what the admin locked.
142
+ */
143
+ export function lockdown(code: RedeemIDConfig): RedeemIDConfig {
144
+ const out: RedeemIDConfig = { ...code, unavailable: true };
145
+ delete out.meter;
146
+ return out;
147
+ }
148
+
149
+ /** What this server last read, and the stamp it was read at. */
150
+ interface Held {
151
+ version: string | null;
152
+ saved: LoadedAccess;
153
+ }
154
+ const HELD = Symbol.for("redeemid:access-held");
155
+ const held = () => globalThis as unknown as Record<symbol, Held | undefined>;
156
+
157
+ /** Forget the settings held in memory (tests, and after a write here). */
158
+ export function forgetAccess(): void {
159
+ held()[HELD] = undefined;
160
+ }
161
+
162
+ /** Record that the settings changed, so every server reads them again. */
163
+ export async function touchAccess(store: StorageCollection<AccessRecord>): Promise<void> {
164
+ forgetAccess();
165
+ await store.put(STAMP_ID, { kind: "stamp", version: crypto.randomUUID() });
166
+ }
167
+
168
+ /** Save one record and bump the stamp. */
169
+ export async function putAccess(store: StorageCollection<AccessRecord>, id: string, data: SiteAccess | EntryRule): Promise<void> {
170
+ await store.put(id, data);
171
+ await touchAccess(store);
172
+ }
173
+
174
+ /** Delete one record and bump the stamp. */
175
+ export async function deleteAccess(store: StorageCollection<AccessRecord>, id: string): Promise<void> {
176
+ await store.delete(id);
177
+ await touchAccess(store);
178
+ }
179
+
180
+ /**
181
+ * The saved settings, read again only when the stamp has moved: one small
182
+ * read per request instead of the whole collection.
183
+ */
184
+ async function savedAccess(store: StorageCollection<AccessRecord>): Promise<LoadedAccess> {
185
+ const stamp = (await store.get(STAMP_ID)) as AccessStamp | null;
186
+ const version = stamp?.kind === "stamp" ? stamp.version : null;
187
+ const known = held()[HELD];
188
+ if (known && version !== null && known.version === version) return known.saved;
189
+ const saved = await loadAccess(store);
190
+ if (version === null) {
191
+ // Saved before stamps existed: start one, so the next request can skip the read.
192
+ const fresh = crypto.randomUUID();
193
+ await store.put(STAMP_ID, { kind: "stamp", version: fresh }).catch(() => undefined);
194
+ held()[HELD] = { version: fresh, saved };
195
+ } else {
196
+ held()[HELD] = { version, saved };
197
+ }
198
+ return saved;
199
+ }
200
+
201
+ /**
202
+ * Code config plus the admin's settings, read with `db`. When storage can't
203
+ * be read, the settings last read stand; with none, every entry is locked
204
+ * (see lockdown). Never the code's settings alone: a rule saved only in the
205
+ * admin would open its entries for as long as the error lasts.
206
+ */
122
207
  export async function withSaved(code: RedeemIDConfig | null, db: unknown): Promise<RedeemIDConfig | null> {
123
208
  if (!code) return null;
209
+ let saved: LoadedAccess;
124
210
  try {
125
- const merged = mergeAccess(code, await loadAccess(stores(db).access));
126
- announceLevels(merged?.levels);
127
- return merged;
211
+ saved = await savedAccess(stores(db).access);
128
212
  } catch (e) {
129
- // No storage yet (or it can't be read): the code's settings stand.
130
- console.error("[redeemid] couldn't read saved access settings:", (e as Error).message);
131
- return code;
213
+ const last = held()[HELD];
214
+ console.error("[redeemid] couldn't read saved access settings:", (e as Error).message, last ? "(using the last ones read)" : "(locking entries)");
215
+ if (!last) return lockdown(code);
216
+ saved = last.saved;
132
217
  }
218
+ const merged = mergeAccess(code, saved);
219
+ announceLevels(merged?.levels);
220
+ return merged;
133
221
  }
134
222
 
135
223
  // ---------------------------------------------------------------- level pickers