@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/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 +47 -3
- package/src/guard.ts +10 -3
- package/src/index.ts +47 -1
- package/src/log.ts +14 -4
- 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 +18 -2
- 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 +46 -5
- 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,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
|
-
|
|
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 {
|
|
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 (
|
|
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 ?
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
/**
|
|
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 {
|