@redeemid/emdash 0.0.19 → 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/README.md +36 -21
- package/docs/developer-api.md +1 -1
- package/docs/shopify-flow.md +70 -0
- package/package.json +2 -1
- package/src/access-admin.ts +12 -6
- package/src/access.ts +30 -9
- package/src/admin-api.ts +26 -4
- package/src/cart-ops.ts +3 -2
- package/src/checkout.ts +5 -4
- package/src/components/LockedPanel.astro +12 -2
- package/src/components/MemberCard.astro +16 -2
- package/src/components/Plan.astro +12 -6
- package/src/config.ts +6 -0
- package/src/descriptor.ts +1 -0
- package/src/events.ts +122 -16
- package/src/files.ts +8 -2
- package/src/guard.ts +1 -1
- package/src/index.ts +47 -1
- package/src/log.ts +5 -2
- package/src/monitor.ts +63 -1
- package/src/plugin.ts +91 -4
- package/src/rate-limit.ts +3 -1
- package/src/routes/account.ts +2 -2
- package/src/routes/api/state.ts +2 -1
- package/src/routes/buy.ts +3 -3
- package/src/routes/callback.ts +16 -1
- package/src/routes/preferences.ts +2 -2
- package/src/routes/privacy.ts +2 -2
- package/src/routes/shared.ts +19 -0
- package/src/routes/webhook.ts +26 -3
- package/src/settings.ts +96 -8
- package/src/setup.ts +280 -0
- package/src/site-config.ts +5 -1
- package/src/staff.ts +13 -0
- package/src/storage.ts +33 -0
- package/src/template-scan.ts +77 -0
- package/src/templates.ts +15 -0
- package/src/text.ts +8 -1
- package/src/token-vault.ts +83 -0
- package/src/version.ts +1 -1
- package/src/webhooks.ts +4 -0
|
@@ -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,
|
|
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
|
|
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:
|
|
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
|
-
/**
|
|
30
|
-
export
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
51
|
-
return res.ok;
|
|
61
|
+
return res.ok ? null : `HTTP ${res.status}`;
|
|
52
62
|
} catch (e) {
|
|
53
|
-
|
|
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
|
|
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,16 +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
121
|
// No database to look the file up in: the caller fails closed.
|
|
122
122
|
if (!db) throw new Error("no database for the file check");
|
|
123
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
|
+
}
|
|
124
131
|
const rule = ruleFor(config, media?.folder ?? null);
|
|
125
132
|
if (!media || !rule) return { protected: false };
|
|
126
133
|
|
|
127
|
-
const role = locals.user?.role ?? 0;
|
|
128
134
|
if (role > SUBSCRIBER) return { protected: true, allowed: true };
|
|
129
135
|
const v = await viewer(astro);
|
|
130
136
|
if (!v.signedIn) return { protected: true, allowed: false, reason: "sign_in" };
|
package/src/guard.ts
CHANGED
|
@@ -87,7 +87,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
87
87
|
const config = await siteConfig(locals);
|
|
88
88
|
|
|
89
89
|
// Protected files: checked for everyone, on every download.
|
|
90
|
-
const keys = config?.files?.length ? keysFromUrl(url) : null;
|
|
90
|
+
const keys = config?.files?.length || config?.unavailable ? keysFromUrl(url) : null;
|
|
91
91
|
if (keys) {
|
|
92
92
|
let verdict;
|
|
93
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
|
@@ -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 } }
|
|
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
|
-
/**
|
|
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 {
|