@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.
@@ -1,7 +1,9 @@
1
1
  ---
2
2
  /**
3
3
  * The member's own account card, for an account page: their levels with
4
- * when each started, levels they held before, the meter, and links to the
4
+ * when each started, when the membership renews or ends and any gift
5
+ * membership (from the shop's membership.* customer metafields), levels
6
+ * they held before, the meter, and links to the
5
7
  * store account, Refresh membership and Sign out. Visitors get a sign-in link.
6
8
  *
7
9
  * <MemberCard />
@@ -35,6 +37,11 @@ const date = (iso: string | null) => {
35
37
  return Number.isNaN(t) ? null : new Intl.DateTimeFormat("en-US", { dateStyle: "long", timeZone: "UTC" }).format(new Date(t));
36
38
  };
37
39
  const name = [m?.firstName, m?.lastName].filter(Boolean).join(" ");
40
+ // Dates the shop keeps in Shopify (Flow or a subscription app). Display only.
41
+ const dates = m && !disabled ? (m.dates ?? {}) : {};
42
+ const today = new Date().toISOString().slice(0, 10);
43
+ const gift = dates.gift && dates.gift >= today ? dates.gift : null;
44
+ const day = (ymd: string | null | undefined) => (ymd ? date(`${ymd}T12:00:00Z`) : null);
38
45
  const join = config?.join;
39
46
  ---
40
47
 
@@ -55,7 +62,7 @@ const join = config?.join;
55
62
  {current.map((l) => (
56
63
  <li data-redeemid-level={l.slug}>
57
64
  <strong>{l.name}</strong>
58
- {date(l.since) && <> {textParts(config, "since {date}", { date: <time datetime={l.since ?? undefined}>{date(l.since)}</time> })}</>}
65
+ {(dates.since ? day(dates.since) : date(l.since)) && <> {textParts(config, "since {date}", { date: <time datetime={dates.since ?? l.since ?? undefined}>{dates.since ? day(dates.since) : date(l.since)}</time> })}</>}
59
66
  </li>
60
67
  ))}
61
68
  </ul>
@@ -64,6 +71,13 @@ const join = config?.join;
64
71
  {text(config, "You don't have a membership yet.")}{join && <> <a href={join.href}>{join.label ?? text(config, "Become a member")}</a></>}
65
72
  </p>
66
73
  )}
74
+ {current.length > 0 && (dates.renews || dates.ends || gift) && (
75
+ <ul class="redeemid-member__dates">
76
+ {dates.renews && <li data-redeemid-date="renews">{textParts(config, "Renews on {date}", { date: <time datetime={dates.renews}>{day(dates.renews)}</time> })}</li>}
77
+ {dates.ends && <li data-redeemid-date="ends">{textParts(config, "Ends on {date}", { date: <time datetime={dates.ends}>{day(dates.ends)}</time> })}</li>}
78
+ {gift && <li data-redeemid-date="gift">{textParts(config, "Gift membership until {date}", { date: <time datetime={gift}>{day(gift)}</time> })}</li>}
79
+ </ul>
80
+ )}
67
81
  {features.length > 0 && (
68
82
  <p class="redeemid-member__features">
69
83
  {text(config, "Includes: {features}", { features: features.map((f) => f.name).join(", ") })}
@@ -7,9 +7,10 @@
7
7
  * <Plan handle="gold-membership" levels={["gold"]} />
8
8
  *
9
9
  * With `levels`, members who hold one of them see "You're on this plan"
10
- * in place of the button.
10
+ * in place of the button, and a member who held one before (Shopify removed
11
+ * its tag) sees "Renew" instead of "Join".
11
12
  */
12
- import { viewer } from "../access.js";
13
+ import { membership, viewer } from "../access.js";
13
14
  import { siteConfig } from "../site-config.js";
14
15
  import { plan as loadPlan, type Plan } from "../storefront.js";
15
16
  import { text } from "../text.js";
@@ -23,10 +24,12 @@ interface Props {
23
24
  /** The levels this plan gives. Members holding one see `heldLabel`, not the button. */
24
25
  levels?: string[];
25
26
  heldLabel?: string;
27
+ /** Button text for a member whose plan lapsed. */
28
+ renewLabel?: string;
26
29
  }
27
30
 
28
31
  const config = await siteConfig(Astro.locals);
29
- const { handle, label = text(config, "Join"), title, levels = [], heldLabel = text(config, "You're on this plan.") } = Astro.props;
32
+ const { handle, label = text(config, "Join"), title, levels = [], heldLabel = text(config, "You're on this plan."), renewLabel = text(config, "Renew") } = Astro.props;
30
33
  let p: Plan | null = null;
31
34
  if (config) {
32
35
  try {
@@ -39,11 +42,14 @@ const price = p?.price
39
42
  ? new Intl.NumberFormat("en-US", { style: "currency", currency: p.price.currencyCode }).format(Number(p.price.amount))
40
43
  : null;
41
44
  const here = Astro.url.pathname + Astro.url.search;
42
- const held = levels.length > 0 && (await viewer(Astro)).levels.some((l) => levels.includes(l));
45
+ const v = levels.length > 0 ? await viewer(Astro) : null;
46
+ const held = Boolean(v?.levels.some((l) => levels.includes(l)));
47
+ // Lapsed: held one of these levels before. Offered their plan back, not "Join" as if new.
48
+ const lapsed = Boolean(!held && v?.member && v.member.status !== "disabled" && membership(v.member, config?.levels).past.some((p) => levels.includes(p.slug)));
43
49
  ---
44
50
 
45
51
  {p ? (
46
- <div class:list={["redeemid-plan", { "is-held": held }]} data-redeemid-plan={p.handle}>
52
+ <div class:list={["redeemid-plan", { "is-held": held, "is-lapsed": lapsed }]} data-redeemid-plan={p.handle} data-redeemid-state={held ? "held" : lapsed ? "renew" : "join"}>
47
53
  <h2 class="redeemid-plan__title">{title ?? p.title}</h2>
48
54
  {price && <p class="redeemid-plan__price">{price}</p>}
49
55
  {p.description && <p class="redeemid-plan__description">{p.description}</p>}
@@ -53,7 +59,7 @@ const held = levels.length > 0 && (await viewer(Astro)).levels.some((l) => level
53
59
  <form method="post" action="/_emdash/api/auth/redeemid/buy">
54
60
  <input type="hidden" name="plan" value={p.handle} />
55
61
  <input type="hidden" name="return" value={here} />
56
- <button type="submit">{label}</button>
62
+ <button type="submit">{lapsed ? renewLabel : label}</button>
57
63
  </form>
58
64
  ) : (
59
65
  <p class="redeemid-plan__unavailable">{text(config, "Not available right now.")}</p>
package/src/config.ts CHANGED
@@ -187,6 +187,12 @@ export interface RedeemIDConfig {
187
187
  * in the editor's Access panel rather than here.
188
188
  */
189
189
  entries?: Record<string, EntryRule>;
190
+ /**
191
+ * Set by RedeemID, never in astro.config.mjs: the settings saved in the
192
+ * admin couldn't be read and none were read before, so every entry is
193
+ * locked until they can be (see lockdown in settings.ts).
194
+ */
195
+ unavailable?: boolean;
190
196
  /**
191
197
  * Only for local tests: lets `shop` be a full http:// origin of a fake store.
192
198
  * Never set this on a live site.
package/src/descriptor.ts CHANGED
@@ -15,6 +15,7 @@ export const PAGES = [
15
15
  { path: "/events", label: "App events", icon: "list" },
16
16
  { path: "/reports", label: "Reports", icon: "chart" },
17
17
  { path: "/status", label: "Status", icon: "pulse" },
18
+ { path: "/setup", label: "Setup", icon: "settings" },
18
19
  { path: "/test", label: "Access test", icon: "search" },
19
20
  { path: "/privacy", label: "Privacy", icon: "lock" },
20
21
  ];
package/src/events.ts CHANGED
@@ -8,7 +8,10 @@
8
8
  * X-RedeemID-Signature: sha256=<hex HMAC of the raw body>. Events:
9
9
  * member.signed_in, member.levels_changed, meter.used, meter.exhausted,
10
10
  * preferences.saved.
11
- * Sending never blocks or breaks the page: failures are logged and dropped.
11
+ * Sending never blocks or breaks the page: events go out after the response
12
+ * (waitUntil on Workers), are tried three times, and one that fails every
13
+ * try is kept under App events → Failed events, where staff can send it
14
+ * again or clear it.
12
15
  * No event carries an email address or a name; match people by `customerId`.
13
16
  */
14
17
 
@@ -19,6 +22,8 @@ import { record } from "./log.js";
19
22
  export type EventName = "member.signed_in" | "member.levels_changed" | "meter.used" | "meter.exhausted" | "preferences.saved" | "member.erased";
20
23
 
21
24
  const TIMEOUT_MS = 3000;
25
+ /** Waits before the second and third tries. */
26
+ export const RETRY_DELAYS_MS = [1000, 5000];
22
27
 
23
28
  export async function signBody(body: string, secret: string): Promise<string> {
24
29
  const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
@@ -26,14 +31,20 @@ export async function signBody(body: string, secret: string): Promise<string> {
26
31
  return [...mac].map((b) => b.toString(16).padStart(2, "0")).join("");
27
32
  }
28
33
 
29
- /** Send one event. Returns whether the receiver answered 2xx; never throws. */
30
- export async function emit(config: RedeemIDConfig | null, name: EventName, data: Record<string, unknown>, fetcher: typeof fetch = fetch): Promise<boolean> {
31
- const url = config?.events?.url;
32
- const secret = env(ENV.eventsSecret);
33
- if (!url || !secret) return false;
34
- if (config.events?.only && !config.events.only.includes(name)) return false;
35
- const id = crypto.randomUUID();
36
- const body = JSON.stringify({ id, event: name, at: new Date().toISOString(), data });
34
+ /** An event that failed every try, kept so staff can send it again or clear it (App events). */
35
+ export interface FailedEvent {
36
+ /** The delivery id, also the record id: a resend keeps it, so receivers can drop duplicates. */
37
+ id: string;
38
+ event: EventName;
39
+ /** The JSON body as first sent (ids and levels, never an email or a name). */
40
+ body: string;
41
+ at: string;
42
+ attempts: number;
43
+ error: string;
44
+ }
45
+
46
+ /** One POST. Resolves to null when the receiver answered 2xx, else what went wrong. */
47
+ async function post(url: string, name: string, id: string, body: string, secret: string, fetcher: typeof fetch): Promise<string | null> {
37
48
  try {
38
49
  const res = await fetcher(url, {
39
50
  method: "POST",
@@ -47,22 +58,117 @@ export async function emit(config: RedeemIDConfig | null, name: EventName, data:
47
58
  body,
48
59
  signal: AbortSignal.timeout(TIMEOUT_MS),
49
60
  });
50
- if (!res.ok) await record({ event: "events.failed", label: name, status: `HTTP ${res.status}`, level: "warn", origin: "site" });
51
- return res.ok;
61
+ return res.ok ? null : `HTTP ${res.status}`;
52
62
  } catch (e) {
53
- console.error("[redeemid] event not sent:", name, (e as Error).message);
54
- await record({ event: "events.failed", label: name, status: "error", level: "error", origin: "site", note: String((e as Error).message).slice(0, 200) });
55
- return false;
63
+ return String((e as Error).message || "error").slice(0, 200);
56
64
  }
57
65
  }
58
66
 
67
+ type Sleep = (ms: number) => Promise<void>;
68
+ const sleep: Sleep = (ms) => new Promise((r) => setTimeout(r, ms));
69
+
70
+ /**
71
+ * Try a delivery up to three times (now, after 1 s, after 5 s). One that
72
+ * fails every try goes to the failed list (App events) for staff. True when
73
+ * the receiver took it.
74
+ */
75
+ export async function deliver(
76
+ url: string,
77
+ secret: string,
78
+ name: EventName,
79
+ id: string,
80
+ body: string,
81
+ opts: { fetcher?: typeof fetch; wait?: Sleep; failed?: (f: FailedEvent) => Promise<void> } = {},
82
+ ): Promise<boolean> {
83
+ const fetcher = opts.fetcher ?? fetch;
84
+ let error: string | null = null;
85
+ let attempts = 0;
86
+ for (const delay of [0, ...RETRY_DELAYS_MS]) {
87
+ if (delay) await (opts.wait ?? sleep)(delay);
88
+ attempts++;
89
+ error = await post(url, name, id, body, secret, fetcher);
90
+ if (error === null) return true;
91
+ }
92
+ console.error("[redeemid] event not sent:", name, error);
93
+ const failed: FailedEvent = { id, event: name, body, at: new Date().toISOString(), attempts, error: error ?? "error" };
94
+ await record({ event: "events.failed", label: name, status: error ?? "error", level: "error", origin: "site", note: `${attempts} tries` });
95
+ await (opts.failed ?? keepFailed)(failed).catch(() => undefined);
96
+ return false;
97
+ }
98
+
99
+ async function keepFailed(f: FailedEvent): Promise<void> {
100
+ const { getDb } = await import("emdash/runtime");
101
+ const { stores } = await import("./storage.js");
102
+ await stores(await getDb()).failedEvents.put(f.id, f);
103
+ }
104
+
105
+ /** Deliveries started and not finished yet: tests and the CLI can wait for them. */
106
+ const pending = new Set<Promise<boolean>>();
107
+
108
+ /** Wait until every event started so far has been delivered or given up on. */
109
+ export async function flushEvents(): Promise<void> {
110
+ while (pending.size) await Promise.allSettled([...pending]);
111
+ }
112
+
113
+ /** EmDash's after(), loaded once; null outside an EmDash site. */
114
+ let afterFn: Promise<((fn: () => Promise<void>) => void) | null> | null = null;
115
+
116
+ /** Keep the work alive after the response (waitUntil on Workers); plain Node needs nothing. Never waits. */
117
+ function keepAlive(work: Promise<unknown>): void {
118
+ afterFn ??= import("emdash").then(
119
+ (m) => m.after,
120
+ () => null,
121
+ );
122
+ void afterFn.then((after) =>
123
+ after?.(async () => {
124
+ await work;
125
+ }),
126
+ );
127
+ }
128
+
129
+ /**
130
+ * Send one event, after the response: the page, sign-in or webhook never
131
+ * waits for the receiver. Returns at once; false when events are off.
132
+ */
133
+ export async function emit(
134
+ config: RedeemIDConfig | null,
135
+ name: EventName,
136
+ data: Record<string, unknown>,
137
+ opts: { fetcher?: typeof fetch; wait?: Sleep; failed?: (f: FailedEvent) => Promise<void> } = {},
138
+ ): Promise<boolean> {
139
+ const url = config?.events?.url;
140
+ const secret = env(ENV.eventsSecret);
141
+ if (!url || !secret) return false;
142
+ if (config.events?.only && !config.events.only.includes(name)) return false;
143
+ const id = crypto.randomUUID();
144
+ const body = JSON.stringify({ id, event: name, at: new Date().toISOString(), data });
145
+ const work = deliver(url, secret, name, id, body, opts).catch(() => false);
146
+ pending.add(work);
147
+ void work.finally(() => pending.delete(work));
148
+ keepAlive(work);
149
+ return true;
150
+ }
151
+
152
+ /** Send a failed event again (staff). On success it leaves the failed list. */
153
+ export async function resend(config: RedeemIDConfig, s: { failedEvents: { get(id: string): Promise<FailedEvent | null>; delete(id: string): Promise<boolean> } }, id: string, fetcher: typeof fetch = fetch): Promise<boolean> {
154
+ const f = await s.failedEvents.get(id);
155
+ const url = config.events?.url;
156
+ const secret = env(ENV.eventsSecret);
157
+ if (!f || !url || !secret) return false;
158
+ const error = await post(url, f.event, f.id, f.body, secret, fetcher);
159
+ await record({ event: "events.resent", label: f.event, status: error ?? "ok", level: error ? "warn" : "ok", origin: "staff" });
160
+ if (error) return false;
161
+ await s.failedEvents.delete(id);
162
+ return true;
163
+ }
164
+
59
165
  /** Send member.levels_changed when a mirror write changed the member's levels. */
60
166
  export async function levelChange(
61
167
  config: RedeemIDConfig | null,
62
168
  beforeTags: string[] | null,
63
169
  after: { customerId: string; tags: string[]; status: string },
64
170
  source: string,
65
- fetcher: typeof fetch = fetch,
171
+ fetcher?: typeof fetch,
66
172
  ): Promise<void> {
67
173
  if (!config?.events?.url) return;
68
174
  const { levelsFor } = await import("./access.js");
@@ -73,6 +179,6 @@ export async function levelChange(
73
179
  config,
74
180
  "member.levels_changed",
75
181
  { customerId: after.customerId, levels: now, added: now.filter((l) => !was.includes(l)), removed: was.filter((l) => !now.includes(l)), source },
76
- fetcher,
182
+ fetcher ? { fetcher } : {},
77
183
  );
78
184
  }
package/src/files.ts CHANGED
@@ -115,15 +115,22 @@ export function ruleFor(config: RedeemIDConfig | null, folder: string | null): F
115
115
 
116
116
  /** Decide one download. `key` is the storage key from the file URL. */
117
117
  export async function fileAccess(astro: AstroLike, config: RedeemIDConfig | null, key: string): Promise<FileVerdict> {
118
- if (!config?.files?.length) return { protected: false };
118
+ if (!config?.files?.length && !config?.unavailable) return { protected: false };
119
119
  const locals = astro.locals as { user?: { role?: number } | null; emdash?: { db?: unknown } | null };
120
120
  const db = locals.emdash?.db;
121
- if (!db) return { protected: false };
121
+ // No database to look the file up in: the caller fails closed.
122
+ if (!db) throw new Error("no database for the file check");
122
123
  const media = await mediaByKey(db, key);
124
+ const role = locals.user?.role ?? 0;
125
+ // Saved settings can't be read: any file in a folder might be protected.
126
+ if (config.unavailable) {
127
+ if (!media?.folder) return { protected: false };
128
+ if (role > SUBSCRIBER) return { protected: true, allowed: true };
129
+ return { protected: true, allowed: false, reason: (await viewer(astro)).signedIn ? "denied" : "sign_in" };
130
+ }
123
131
  const rule = ruleFor(config, media?.folder ?? null);
124
132
  if (!media || !rule) return { protected: false };
125
133
 
126
- const role = locals.user?.role ?? 0;
127
134
  if (role > SUBSCRIBER) return { protected: true, allowed: true };
128
135
  const v = await viewer(astro);
129
136
  if (!v.signedIn) return { protected: true, allowed: false, reason: "sign_in" };
@@ -162,12 +169,49 @@ export function keysFromPath(pathname: string): string[] | null {
162
169
  break;
163
170
  }
164
171
  out.push(decoded);
172
+ // The file route drops slashes around the key, so the check does too.
173
+ const trimmed = decoded.replace(/^\/+|\/+$/g, "");
174
+ if (trimmed && trimmed !== decoded) out.push(trimmed);
165
175
  if (decoded === candidate) break;
166
176
  candidate = decoded;
167
177
  }
168
178
  return [...new Set(out)];
169
179
  }
170
180
 
181
+ /** Astro's image endpoint, which reads the file its `href` names and answers with it. */
182
+ export const IMAGE_PATH = "/_image";
183
+
184
+ /**
185
+ * The storage keys a request could reach: a file URL's own, or those of the
186
+ * file an image-endpoint request asks for by `href`. Null when it names none.
187
+ */
188
+ export function keysFromUrl(url: URL): string[] | null {
189
+ const direct = keysFromPath(url.pathname);
190
+ if (direct) return direct;
191
+ if (url.pathname.replace(/\/+$/, "") !== IMAGE_PATH) return null;
192
+ const out: string[] = [];
193
+ for (const href of url.searchParams.getAll("href")) {
194
+ let candidate = href;
195
+ for (let layer = 0; layer < 4; layer++) {
196
+ try {
197
+ const keys = keysFromPath(new URL(candidate, url.origin).pathname);
198
+ if (keys) out.push(...keys);
199
+ } catch {
200
+ // Not a URL: nothing to check.
201
+ }
202
+ let decoded: string;
203
+ try {
204
+ decoded = decodeURIComponent(candidate);
205
+ } catch {
206
+ break;
207
+ }
208
+ if (decoded === candidate) break;
209
+ candidate = decoded;
210
+ }
211
+ }
212
+ return out.length ? [...new Set(out)] : null;
213
+ }
214
+
171
215
  /** Decide a download that may name several keys: the first protected one decides. */
172
216
  export async function pathAccess(astro: AstroLike, config: RedeemIDConfig | null, keys: string[]): Promise<FileVerdict> {
173
217
  for (const key of keys) {
package/src/guard.ts CHANGED
@@ -23,7 +23,7 @@ import { defineMiddleware } from "astro:middleware";
23
23
 
24
24
  import { signInUrl } from "./access.js";
25
25
  import { readCart } from "./cart.js";
26
- import { keysFromPath, pathAccess, refusal } from "./files.js";
26
+ import { keysFromUrl, pathAccess, refusal } from "./files.js";
27
27
  import { AFTER_LOGOUT_COOKIE, readAfterLogout } from "./routes/shared.js";
28
28
  import { shareConfig, siteConfig } from "./site-config.js";
29
29
 
@@ -49,7 +49,14 @@ export function memberMayUse(path: string, extra: string[] = []): boolean {
49
49
  */
50
50
  async function withUser(context: { locals: unknown; session?: { get(k: string): Promise<unknown> } }) {
51
51
  const locals = context.locals as { user?: { id: string; role?: number } | null; emdash?: { db?: unknown } | null };
52
- if (locals.user || !locals.emdash?.db) return context;
52
+ if (!locals.emdash?.db) {
53
+ // EmDash leaves the database off some public requests: fetch it, so the check can run.
54
+ const { getDb } = await import("emdash/runtime");
55
+ const next = { ...locals, emdash: { ...(locals.emdash ?? {}), db: await getDb() } };
56
+ shareConfig(locals, next);
57
+ return withUser({ ...context, locals: next });
58
+ }
59
+ if (locals.user) return context;
53
60
  const sessionUser = (await context.session?.get("user")) as { id?: string } | undefined;
54
61
  if (!sessionUser?.id) return context;
55
62
  const { createKyselyAdapter } = await import("@emdash-cms/auth/adapters/kysely");
@@ -80,7 +87,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
80
87
  const config = await siteConfig(locals);
81
88
 
82
89
  // Protected files: checked for everyone, on every download.
83
- const keys = config?.files?.length ? keysFromPath(url.pathname) : null;
90
+ const keys = config?.files?.length || config?.unavailable ? keysFromUrl(url) : null;
84
91
  if (keys) {
85
92
  let verdict;
86
93
  try {
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
@@ -10,6 +10,7 @@
10
10
  import type { StorageCollection } from "emdash";
11
11
 
12
12
  export const LOG_DAYS = 30;
13
+ const PRUNE_PAGES = 5;
13
14
 
14
15
  export type LogOrigin = "webhook" | "member" | "staff" | "agent" | "cron" | "site";
15
16
 
@@ -44,6 +45,7 @@ export const LOG_EVENTS: Record<string, string> = {
44
45
  "staff.webhooks": "Webhook registration",
45
46
  "settings.saved": "Settings saved",
46
47
  "events.failed": "Outbound event failed",
48
+ "events.resent": "Outbound event sent again",
47
49
  "health.check": "Health check",
48
50
  "meter.used": "Metered read",
49
51
  "meter.exhausted": "Metered read, meter used up",
@@ -87,10 +89,18 @@ export async function timed<T>(event: string, label: string, run: () => Promise<
87
89
  }
88
90
  }
89
91
 
92
+ type Where<T> = NonNullable<NonNullable<Parameters<StorageCollection<T>["query"]>[0]>["where"]>;
93
+
90
94
  /** Delete entries (and other dated records) older than `days`, a page at a time. */
91
- 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> {
92
96
  const before = new Date(Date.now() - days * 86_400_000).toISOString();
93
- const { items } = await store.query({ where: { [field]: { lt: before } }, limit: 100 });
94
- if (items.length === 0) return 0;
95
- return store.deleteMany(items.map((i) => i.id));
97
+ let gone = 0;
98
+ // A few pages a run: enough to keep up with a busy site, few enough queries for one request.
99
+ for (let page = 0; page < PRUNE_PAGES; page++) {
100
+ const { items } = await store.query({ where: { ...where, [field]: { lt: before } } as Where<T>, limit: 100 });
101
+ if (items.length === 0) break;
102
+ gone += await store.deleteMany(items.map((i) => i.id));
103
+ if (items.length < 100) break;
104
+ }
105
+ return gone;
96
106
  }
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 {