@redeemid/emdash 0.0.15 → 0.0.17
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 +416 -223
- package/docs/shopify-setup.md +1 -1
- package/package.json +2 -1
- package/src/access-admin.ts +754 -0
- package/src/access.ts +84 -18
- package/src/admin-api.ts +9 -2
- package/src/agents.ts +7 -3
- package/src/astro-modules.d.ts +4 -0
- package/src/components/AccessBadge.astro +7 -5
- package/src/components/Account.astro +1 -1
- package/src/components/Cart.astro +1 -1
- package/src/components/CartView.astro +1 -1
- package/src/components/Collection.astro +1 -1
- package/src/components/Content.astro +7 -3
- package/src/components/Contribution.astro +1 -1
- package/src/components/Gate.astro +4 -2
- package/src/components/LockedPanel.astro +1 -1
- package/src/components/MemberCard.astro +1 -1
- package/src/components/Notices.astro +2 -1
- package/src/components/Plan.astro +1 -1
- package/src/components/Preferences.astro +1 -1
- package/src/components/Product.astro +1 -1
- package/src/components/YourData.astro +1 -1
- package/src/config.ts +17 -5
- package/src/descriptor.ts +7 -0
- package/src/events.ts +3 -0
- package/src/features.ts +1 -1
- package/src/files.ts +1 -1
- package/src/guard.ts +5 -3
- package/src/index.ts +4 -1
- package/src/log.ts +96 -0
- package/src/monitor.ts +560 -0
- package/src/plugin.ts +202 -86
- package/src/rate-limit-cloudflare.ts +20 -0
- package/src/rate-limit.ts +66 -0
- package/src/routes/callback.ts +21 -4
- package/src/routes/preferences.ts +2 -2
- package/src/routes/privacy.ts +2 -2
- package/src/routes/webhook.ts +24 -11
- package/src/settings.ts +195 -0
- package/src/site-config.ts +38 -5
- package/src/staff.ts +12 -23
- package/src/storage.ts +12 -0
- package/src/text.ts +1 -0
- package/src/version.ts +1 -1
- package/src/webhooks.ts +2 -1
package/src/plugin.ts
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* plugins: [redeemidPlugin(config)],
|
|
7
7
|
* })
|
|
8
8
|
*
|
|
9
|
-
* It adds
|
|
10
|
-
*
|
|
9
|
+
* It adds Portable Text blocks to the editor, an Access panel on every entry,
|
|
10
|
+
* admin pages (Members, Access, Webhooks, App events, Reports, Status, Access
|
|
11
|
+
* test, Privacy), a daily webhook health check and a dashboard widget. Sign-in, webhooks and checkout stay on the
|
|
11
12
|
* auth provider's routes.
|
|
12
13
|
*
|
|
13
14
|
* - "Paywall break" (`redeemid-paywall`): everything after it in the body is
|
|
@@ -24,19 +25,23 @@
|
|
|
24
25
|
* part after a paywall break off the page for readers who can't read it.
|
|
25
26
|
*/
|
|
26
27
|
|
|
27
|
-
import type { ResolvedPlugin, RouteContext } from "emdash";
|
|
28
|
+
import type { PluginContext, ResolvedPlugin, RouteContext } from "emdash";
|
|
28
29
|
import { definePlugin } from "emdash";
|
|
29
30
|
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
31
|
+
import { levelsFor, SIGNED_IN, type Terms } from "./access.js";
|
|
32
|
+
import { accessAction, accessPage, entryPanel, entrySave, entrySlugChanged, isAccessAction, type AccessEnv, type PanelEntry } from "./access-admin.js";
|
|
33
|
+
import { entryKey, loadAccess, onLevels, withSaved } from "./settings.js";
|
|
34
|
+
import { env, ENV, storeDomain, unknownEnvNames, type LevelConfig, type RedeemIDConfig } from "./config.js";
|
|
32
35
|
import { discover } from "./shopify.js";
|
|
33
36
|
import { stores, type MemberRecord, type Stores } from "./storage.js";
|
|
34
|
-
import { PAGES, WIDGETS } from "./descriptor.js";
|
|
37
|
+
import { EDITOR_PANELS, PAGES, WIDGETS } from "./descriptor.js";
|
|
35
38
|
import { VERSION } from "./version.js";
|
|
36
39
|
import { AdminError, adminConfigured } from "./admin-api.js";
|
|
37
40
|
import { cancelShopifyErasure, closeRequest, eraseMember, exportMember, exportRequest, requestShopifyErasure } from "./privacy.js";
|
|
38
|
-
import { accessTest, bulkResync, BULK_BATCH,
|
|
41
|
+
import { accessTest, bulkResync, BULK_BATCH, exportBlocks, memberDetail, privacyPage, registerWebhooks, resyncOne, tagEdit } from "./staff.js";
|
|
39
42
|
import { textProblems } from "./text.js";
|
|
43
|
+
import { record } from "./log.js";
|
|
44
|
+
import { eventsPage, healthCheck, HEALTH_TASK, lastCheck, parseFilter, reportsPage, saveMonitor, WEBHOOK_PATH, WEBHOOK_TABS, webhooksPage, type MonitorEnv, type WebhookTab } from "./monitor.js";
|
|
40
45
|
import { z } from "astro/zod";
|
|
41
46
|
import { AgentError, explainAccess, getMember, getRules, levelChanges, listLevels, siteHealth, type ExplainInput } from "./agents.js";
|
|
42
47
|
|
|
@@ -54,11 +59,22 @@ import {
|
|
|
54
59
|
|
|
55
60
|
export { COLLECTION_BLOCK, CONTRIBUTION_BLOCK, ITEM_BLOCK, MEMBERS_BLOCK, MEMBERS_END_BLOCK, PAYWALL_BLOCK, PLAN_BLOCK, PREFERENCES_BLOCK, PRODUCT_BLOCK };
|
|
56
61
|
|
|
62
|
+
/**
|
|
63
|
+
* The editor's level pickers. Declared once when the plugin starts, then kept
|
|
64
|
+
* in step (in place) with the levels saved in the admin: EmDash builds the
|
|
65
|
+
* editor's manifest from these same arrays on each request.
|
|
66
|
+
*/
|
|
67
|
+
const levelOptions: Array<{ label: string; value: string }> = [];
|
|
68
|
+
const planLevelOptions: Array<{ label: string; value: string }> = [];
|
|
69
|
+
|
|
70
|
+
export function setLevelOptions(levels: LevelConfig[] = []): void {
|
|
71
|
+
const mine = levels.map((l) => ({ label: l.name, value: l.slug }));
|
|
72
|
+
levelOptions.splice(0, levelOptions.length, { label: "Signed in (any member)", value: SIGNED_IN }, ...mine);
|
|
73
|
+
planLevelOptions.splice(0, planLevelOptions.length, ...mine);
|
|
74
|
+
}
|
|
75
|
+
|
|
57
76
|
function blocks(config: RedeemIDConfig) {
|
|
58
|
-
|
|
59
|
-
{ label: "Signed in (any member)", value: SIGNED_IN },
|
|
60
|
-
...(config.levels ?? []).map((l) => ({ label: l.name, value: l.slug })),
|
|
61
|
-
];
|
|
77
|
+
setLevelOptions(config.levels);
|
|
62
78
|
return [
|
|
63
79
|
{
|
|
64
80
|
type: PAYWALL_BLOCK,
|
|
@@ -119,7 +135,7 @@ function blocks(config: RedeemIDConfig) {
|
|
|
119
135
|
type: "checkbox" as const,
|
|
120
136
|
action_id: "levels",
|
|
121
137
|
label: "Levels this plan gives (members on them see \"You're on this plan\")",
|
|
122
|
-
options:
|
|
138
|
+
options: planLevelOptions,
|
|
123
139
|
},
|
|
124
140
|
],
|
|
125
141
|
},
|
|
@@ -170,30 +186,89 @@ function blocks(config: RedeemIDConfig) {
|
|
|
170
186
|
];
|
|
171
187
|
}
|
|
172
188
|
|
|
173
|
-
export function createPlugin(
|
|
189
|
+
export function createPlugin(code: RedeemIDConfig = {}): ResolvedPlugin {
|
|
190
|
+
onLevels(setLevelOptions);
|
|
191
|
+
const live = () => liveConfig(code);
|
|
174
192
|
return definePlugin({
|
|
175
193
|
id: "redeemid",
|
|
176
194
|
version: VERSION,
|
|
177
|
-
|
|
195
|
+
// content:read: the editor's Access panel reads the entry, and its
|
|
196
|
+
// rule follows the entry's slug and goes with the entry.
|
|
197
|
+
// email:send: the daily webhook check can warn staff by email.
|
|
198
|
+
capabilities: ["content:read", "email:send"],
|
|
178
199
|
admin: {
|
|
179
200
|
pages: PAGES,
|
|
180
201
|
widgets: WIDGETS,
|
|
181
|
-
|
|
202
|
+
editorPanels: EDITOR_PANELS,
|
|
203
|
+
portableTextBlocks: blocks(code),
|
|
204
|
+
},
|
|
205
|
+
mcp: agentTools(code),
|
|
206
|
+
hooks: {
|
|
207
|
+
"plugin:activate": async (_event, ctx) => {
|
|
208
|
+
await scheduleHealth(ctx);
|
|
209
|
+
},
|
|
210
|
+
cron: async (event, ctx) => {
|
|
211
|
+
if (event.name !== HEALTH_TASK) return;
|
|
212
|
+
await healthCheck(monitorEnv(await live(), await memberStores(), ctx));
|
|
213
|
+
},
|
|
214
|
+
"content:afterSave": async (event) => {
|
|
215
|
+
const id = typeof event.content.id === "string" ? event.content.id : null;
|
|
216
|
+
if (!id) return;
|
|
217
|
+
const slug = typeof event.content.slug === "string" ? event.content.slug : null;
|
|
218
|
+
await entrySlugChanged((await memberStores()).access, event.collection, id, slug).catch(() => undefined);
|
|
219
|
+
},
|
|
220
|
+
"content:afterDelete": async (event) => {
|
|
221
|
+
if (!event.permanent) return;
|
|
222
|
+
await (await memberStores()).access.delete(entryKey(event.id)).catch(() => undefined);
|
|
223
|
+
},
|
|
182
224
|
},
|
|
183
|
-
mcp: agentTools(config),
|
|
184
225
|
routes: {
|
|
185
|
-
...agentRoutes(
|
|
226
|
+
...agentRoutes(code),
|
|
227
|
+
"access-panel": {
|
|
228
|
+
permission: "content:edit_own",
|
|
229
|
+
request: { body: "json" as const },
|
|
230
|
+
handler: async (ctx: RouteContext) => {
|
|
231
|
+
const i = (ctx.input ?? {}) as { type?: string; action_id?: string; values?: Record<string, unknown> };
|
|
232
|
+
const entry = ctx.ui && "entry" in ctx.ui ? ctx.ui.entry : undefined;
|
|
233
|
+
if (!entry) return { blocks: [{ type: "context", text: "Save the entry first." }] };
|
|
234
|
+
try {
|
|
235
|
+
const env = await accessEnv(await live());
|
|
236
|
+
const panel = await panelEntry(ctx, entry.collection, entry.id);
|
|
237
|
+
if (i.type === "form_submit" && i.action_id === "entry_save") {
|
|
238
|
+
const out = await entrySave(env, panel, i.values ?? {});
|
|
239
|
+
await record({ event: "settings.saved", label: `entry rule (${entry.collection})`, status: "ok", origin: "staff" });
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
242
|
+
return await entryPanel(env, panel);
|
|
243
|
+
} catch (e) {
|
|
244
|
+
return { blocks: [{ type: "banner", variant: "error", title: "RedeemID couldn't load access", description: (e as Error).message }] };
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
},
|
|
186
248
|
admin: {
|
|
187
249
|
handler: async (ctx: RouteContext) => {
|
|
188
250
|
const i = (ctx.input ?? {}) as { type?: string; page?: string; action_id?: string; value?: unknown; values?: Record<string, unknown>; block_id?: string };
|
|
189
251
|
try {
|
|
190
|
-
|
|
252
|
+
const config = await live();
|
|
253
|
+
if ((i.type === "block_action" || i.type === "form_submit") && isAccessAction(i.action_id)) {
|
|
254
|
+
const out = await accessAction(await accessEnv(config), i);
|
|
255
|
+
if (out.toast?.type === "success") await record({ event: "settings.saved", label: `access: ${String(i.action_id).replace(/_/g, " ")}`, status: "ok", origin: "staff" });
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
if (i.type === "block_action" || i.type === "form_submit") return await action(config, ctx, i);
|
|
191
259
|
if (i.type !== "page_load") return { blocks: [] };
|
|
192
260
|
if (i.page === "/") return await membersPage(config);
|
|
261
|
+
if (i.page === "/access") return await accessPage(await accessEnv(config));
|
|
262
|
+
if (i.page === "/webhooks") {
|
|
263
|
+
await scheduleHealth(ctx);
|
|
264
|
+
return await webhooksPage(monitorEnv(config, await memberStores(), ctx));
|
|
265
|
+
}
|
|
266
|
+
if (i.page === "/events") return await eventsPage(await memberStores());
|
|
267
|
+
if (i.page === "/reports") return await reportsPage(config, await memberStores());
|
|
193
268
|
if (i.page === "/status") return await statusPage(config, ctx.request);
|
|
194
269
|
if (i.page === "/test") return await accessTest(config);
|
|
195
270
|
if (i.page === "/privacy") return await privacyPage(await memberStores());
|
|
196
|
-
if (i.page === "widget:status") return await statusWidget(config);
|
|
271
|
+
if (i.page === "widget:status") return await statusWidget(config, ctx);
|
|
197
272
|
} catch (e) {
|
|
198
273
|
return { blocks: [{ type: "banner", variant: "error", title: "RedeemID couldn't load this page", description: (e as Error).message }] };
|
|
199
274
|
}
|
|
@@ -204,6 +279,70 @@ export function createPlugin(config: RedeemIDConfig = {}): ResolvedPlugin {
|
|
|
204
279
|
});
|
|
205
280
|
}
|
|
206
281
|
|
|
282
|
+
/** What the Webhooks page and the daily check work with. */
|
|
283
|
+
function monitorEnv(config: RedeemIDConfig, s: Stores, ctx: PluginContext): MonitorEnv {
|
|
284
|
+
return { config, s, kv: ctx.kv, uri: ctx.url(WEBHOOK_PATH), email: ctx.email };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Make sure the daily webhook check is scheduled (on Workers it runs from the site's Cron Trigger). */
|
|
288
|
+
async function scheduleHealth(ctx: PluginContext): Promise<void> {
|
|
289
|
+
try {
|
|
290
|
+
if (!ctx.cron) return;
|
|
291
|
+
const tasks = await ctx.cron.list();
|
|
292
|
+
if (!tasks.some((t) => t.name === HEALTH_TASK)) await ctx.cron.schedule(HEALTH_TASK, { schedule: "@daily" });
|
|
293
|
+
} catch {
|
|
294
|
+
// Scheduling is best effort; Check now still works.
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** The code's config with what the admin saved. */
|
|
299
|
+
async function liveConfig(code: RedeemIDConfig): Promise<RedeemIDConfig> {
|
|
300
|
+
const { getDb } = await import("emdash/runtime");
|
|
301
|
+
return (await withSaved(code, await getDb())) ?? code;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Everything the Access screens read: saved settings and the site's content types and taxonomies, as they are now. */
|
|
305
|
+
async function accessEnv(config: RedeemIDConfig): Promise<AccessEnv> {
|
|
306
|
+
const { getDb } = await import("emdash/runtime");
|
|
307
|
+
const db = await getDb();
|
|
308
|
+
const store = stores(db).access;
|
|
309
|
+
const emdash = await import("emdash");
|
|
310
|
+
const collections = (await new emdash.SchemaRegistry(db as never).listCollections()) as Array<{ slug: string; label: string }>;
|
|
311
|
+
const taxonomies = (await emdash.getTaxonomyDefs()) as Array<{ name: string; label: string; collections: string[] }>;
|
|
312
|
+
return {
|
|
313
|
+
code: config,
|
|
314
|
+
store,
|
|
315
|
+
saved: await loadAccess(store),
|
|
316
|
+
collections: collections.map((c) => ({ slug: c.slug, label: c.label || c.slug })),
|
|
317
|
+
taxonomies: taxonomies.map((t) => ({ name: t.name, label: t.label || t.name, collections: t.collections ?? [] })),
|
|
318
|
+
terms: async (taxonomy) => {
|
|
319
|
+
type Node = { slug: string; label?: string; name?: string; children?: Node[] };
|
|
320
|
+
const out: Array<{ slug: string; label: string }> = [];
|
|
321
|
+
const walk = (list: Node[], path: string) =>
|
|
322
|
+
list.forEach((n) => {
|
|
323
|
+
const label = `${path}${n.label || n.slug}`;
|
|
324
|
+
out.push({ slug: n.slug, label });
|
|
325
|
+
walk(n.children ?? [], `${label} › `);
|
|
326
|
+
});
|
|
327
|
+
walk((await emdash.getTaxonomyTerms(taxonomy)) as Node[], "");
|
|
328
|
+
return out;
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** The saved entry the editor's panel is on: its slug and terms. */
|
|
334
|
+
async function panelEntry(ctx: RouteContext, collection: string, id: string): Promise<PanelEntry> {
|
|
335
|
+
const item = await ctx.content?.get(collection, id).catch(() => null);
|
|
336
|
+
const emdash = await import("emdash");
|
|
337
|
+
const terms: Terms = {};
|
|
338
|
+
for (const t of (await emdash.getTaxonomyDefs()) as Array<{ name: string; collections: string[] }>) {
|
|
339
|
+
if (!(t.collections ?? []).includes(collection)) continue;
|
|
340
|
+
const list = (await emdash.getEntryTerms(collection, id, t.name)) as Array<{ slug: string; id: string; parentId?: string }>;
|
|
341
|
+
if (list.length) terms[t.name] = list.map((x) => ({ slug: x.slug, id: x.id, parentId: x.parentId ?? null }));
|
|
342
|
+
}
|
|
343
|
+
return { collection, id, slug: item?.slug ?? null, terms };
|
|
344
|
+
}
|
|
345
|
+
|
|
207
346
|
export default createPlugin;
|
|
208
347
|
|
|
209
348
|
// ---------------------------------------------------------------- agents and CLI
|
|
@@ -211,9 +350,11 @@ export default createPlugin;
|
|
|
211
350
|
const STAFF = "users:read" as const;
|
|
212
351
|
|
|
213
352
|
/** Staff-only JSON routes, also offered as MCP tools. */
|
|
214
|
-
function agentRoutes(
|
|
353
|
+
function agentRoutes(code: RedeemIDConfig) {
|
|
354
|
+
let config = code;
|
|
215
355
|
const run = (fn: (s: Stores, input: Record<string, unknown>) => Promise<unknown> | unknown) => async (ctx: RouteContext) => {
|
|
216
356
|
try {
|
|
357
|
+
config = await liveConfig(code);
|
|
217
358
|
return { ok: true, result: await fn(await memberStores(), (ctx.input ?? {}) as Record<string, unknown>) };
|
|
218
359
|
} catch (e) {
|
|
219
360
|
if (e instanceof AgentError || e instanceof AdminError) return { ok: false, error: e.message };
|
|
@@ -229,7 +370,7 @@ function agentRoutes(config: RedeemIDConfig) {
|
|
|
229
370
|
"site-health": route(EMPTY, (s) => siteHealth(config, s)),
|
|
230
371
|
"level-changes": route(CHANGES, (s, i) => levelChanges(config, s, i as { since: string; level?: string })),
|
|
231
372
|
};
|
|
232
|
-
if (!
|
|
373
|
+
if (!code.agents?.actions) return read;
|
|
233
374
|
return {
|
|
234
375
|
...read,
|
|
235
376
|
"resync-member": route(CUSTOMER, async (s, i) => {
|
|
@@ -287,14 +428,17 @@ function agentTools(config: RedeemIDConfig) {
|
|
|
287
428
|
|
|
288
429
|
// ---------------------------------------------------------------- actions
|
|
289
430
|
|
|
290
|
-
const webhookUri = (request: Request) => `${new URL(request.url).origin}/_emdash/api/auth/redeemid/webhook`;
|
|
291
|
-
|
|
292
431
|
async function action(
|
|
293
432
|
config: RedeemIDConfig,
|
|
294
|
-
|
|
433
|
+
ctx: RouteContext,
|
|
295
434
|
i: { action_id?: string; value?: unknown; values?: Record<string, unknown>; block_id?: string },
|
|
296
435
|
) {
|
|
297
436
|
const s = await memberStores();
|
|
437
|
+
const monitor = () => monitorEnv(config, s, ctx);
|
|
438
|
+
const cursorOf = (v: unknown) => {
|
|
439
|
+
const c = (v as { cursor?: unknown } | null)?.cursor;
|
|
440
|
+
return typeof c === "string" && c ? c : undefined;
|
|
441
|
+
};
|
|
298
442
|
const fail = (e: unknown) => ({ type: "error" as const, message: (e as Error).message });
|
|
299
443
|
switch (i.action_id) {
|
|
300
444
|
case "members_back":
|
|
@@ -342,14 +486,34 @@ async function action(
|
|
|
342
486
|
}
|
|
343
487
|
case "webhooks_register": {
|
|
344
488
|
try {
|
|
345
|
-
const { added, failed } = await registerWebhooks(config,
|
|
489
|
+
const { added, failed } = await registerWebhooks(config, monitor().uri);
|
|
346
490
|
const done = added.length ? `Registered ${added.length} webhooks.` : failed.length ? "" : "Nothing was missing.";
|
|
347
491
|
const refused = failed.map((f) => `Shopify refused ${f.topic}: ${f.message}`).join(" ");
|
|
348
|
-
|
|
492
|
+
await record({ event: "staff.webhooks", label: "register", status: failed.length ? `${failed.length} refused` : `${added.length} registered`, level: failed.length ? "warn" : "ok", origin: "staff" });
|
|
493
|
+
return webhooksPage(monitor(), "setup", { type: failed.length ? "error" : "success", message: [done, refused].filter(Boolean).join(" ") });
|
|
349
494
|
} catch (e) {
|
|
350
|
-
return
|
|
495
|
+
return webhooksPage(monitor(), "setup", fail(e));
|
|
351
496
|
}
|
|
352
497
|
}
|
|
498
|
+
case "webhooks_tab": {
|
|
499
|
+
const tab = WEBHOOK_TABS.includes(i.value as WebhookTab) ? (i.value as WebhookTab) : "received";
|
|
500
|
+
return webhooksPage(monitor(), tab);
|
|
501
|
+
}
|
|
502
|
+
case "deliveries_page":
|
|
503
|
+
return webhooksPage(monitor(), "received", undefined, cursorOf(i.value));
|
|
504
|
+
case "health_check": {
|
|
505
|
+
const r = await healthCheck({ ...monitor(), by: "staff" });
|
|
506
|
+
const message = r.problems.length ? `Checked: ${r.problems.length === 1 ? "1 problem" : `${r.problems.length} problems`}.` : "Checked: subscriptions are in place and deliveries look healthy.";
|
|
507
|
+
return webhooksPage(monitor(), "health", { type: r.problems.length ? "info" : "success", message });
|
|
508
|
+
}
|
|
509
|
+
case "monitor_save":
|
|
510
|
+
return webhooksPage(monitor(), "health", await saveMonitor(ctx.kv, i.values));
|
|
511
|
+
case "events_filter":
|
|
512
|
+
return eventsPage(s, { event: String(i.values?.event ?? "") || undefined, problems: i.values?.problems === true });
|
|
513
|
+
case "events_show":
|
|
514
|
+
return eventsPage(s, { event: String(i.value ?? "") || undefined });
|
|
515
|
+
case "events_page":
|
|
516
|
+
return eventsPage(s, parseFilter(i.block_id), cursorOf(i.value));
|
|
353
517
|
case "access_test":
|
|
354
518
|
return accessTest(config, i.values);
|
|
355
519
|
case "member_export": {
|
|
@@ -417,10 +581,10 @@ async function membersPage(config: RedeemIDConfig) {
|
|
|
417
581
|
label: l.name,
|
|
418
582
|
value: String(members.filter((m) => m.status === "enabled" && levelsFor(m.tags, levels).includes(l.slug)).length),
|
|
419
583
|
}));
|
|
420
|
-
const name = (m: MemberRecord) => [m.firstName, m.lastName].filter(Boolean).join(" ") || "
|
|
584
|
+
const name = (m: MemberRecord) => [m.firstName, m.lastName].filter(Boolean).join(" ") || "No name";
|
|
421
585
|
const levelNames = (m: MemberRecord) => {
|
|
422
586
|
const held = levelsFor(m.tags, levels);
|
|
423
|
-
return levels.filter((l) => held.includes(l.slug)).map((l) => l.name).join(", ")
|
|
587
|
+
return levels.filter((l) => held.includes(l.slug)).map((l) => l.name).join(", ");
|
|
424
588
|
};
|
|
425
589
|
return {
|
|
426
590
|
blocks: [
|
|
@@ -466,7 +630,7 @@ async function membersPage(config: RedeemIDConfig) {
|
|
|
466
630
|
{ key: "email", label: "Email" },
|
|
467
631
|
{ key: "levels", label: "Levels" },
|
|
468
632
|
{ key: "tags", label: "Shopify tags" },
|
|
469
|
-
{ key: "status", label: "Account" },
|
|
633
|
+
{ key: "status", label: "Account", format: "badge" },
|
|
470
634
|
{ key: "synced", label: "Last synced", format: "relative_time" },
|
|
471
635
|
{ key: "open", label: "", format: "element" },
|
|
472
636
|
],
|
|
@@ -474,9 +638,9 @@ async function membersPage(config: RedeemIDConfig) {
|
|
|
474
638
|
.sort((a, b) => b.syncedAt.localeCompare(a.syncedAt))
|
|
475
639
|
.map((m) => ({
|
|
476
640
|
name: name(m),
|
|
477
|
-
email: m.email ?? "
|
|
641
|
+
email: m.email ?? "",
|
|
478
642
|
levels: levelNames(m),
|
|
479
|
-
tags: m.tags.join(", ")
|
|
643
|
+
tags: m.tags.join(", "),
|
|
480
644
|
status: m.status === "enabled" ? "On" : "Off",
|
|
481
645
|
synced: m.syncedAt,
|
|
482
646
|
open: { type: "button", action_id: "member_open", label: "Open", value: m.customerId },
|
|
@@ -521,13 +685,7 @@ async function statusPage(config: RedeemIDConfig, request: Request) {
|
|
|
521
685
|
const c = await checks(config);
|
|
522
686
|
const origin = new URL(request.url).origin;
|
|
523
687
|
const yes = (v: boolean) => (v ? "Set" : "Missing");
|
|
524
|
-
const levels = config.levels ?? [];
|
|
525
|
-
const rules = config.rules ?? [];
|
|
526
688
|
const delivery = await lastDelivery().catch(() => null);
|
|
527
|
-
const webhooks = await webhooksSection(config, webhookUri(request));
|
|
528
|
-
const deliveries = await memberStores()
|
|
529
|
-
.then((s) => deliveriesSection(s))
|
|
530
|
-
.catch(() => [{ type: "context", text: "Couldn't read deliveries." }]);
|
|
531
689
|
return {
|
|
532
690
|
blocks: [
|
|
533
691
|
{ type: "header", text: "Status" },
|
|
@@ -562,11 +720,7 @@ async function statusPage(config: RedeemIDConfig, request: Request) {
|
|
|
562
720
|
},
|
|
563
721
|
]
|
|
564
722
|
: []),
|
|
565
|
-
{ type: "
|
|
566
|
-
{ type: "header", text: "Webhooks" },
|
|
567
|
-
...webhooks,
|
|
568
|
-
{ type: "header", text: "Recent deliveries" },
|
|
569
|
-
...deliveries,
|
|
723
|
+
{ type: "context", text: "Deliveries, their health and the webhook subscriptions are under Webhooks; everything RedeemID did is under App events." },
|
|
570
724
|
{ type: "divider" },
|
|
571
725
|
{ type: "header", text: "Shopify settings" },
|
|
572
726
|
{
|
|
@@ -579,62 +733,24 @@ async function statusPage(config: RedeemIDConfig, request: Request) {
|
|
|
579
733
|
],
|
|
580
734
|
},
|
|
581
735
|
{ type: "divider" },
|
|
582
|
-
{ type: "
|
|
583
|
-
{ type: "context", text: "Set in astro.config.mjs." },
|
|
584
|
-
levels.length
|
|
585
|
-
? {
|
|
586
|
-
type: "table",
|
|
587
|
-
page_action_id: "levels_page",
|
|
588
|
-
columns: [
|
|
589
|
-
{ key: "name", label: "Level" },
|
|
590
|
-
{ key: "tags", label: "Shopify tags" },
|
|
591
|
-
{ key: "match", label: "Needs" },
|
|
592
|
-
],
|
|
593
|
-
rows: levels.map((l) => ({ name: l.name, tags: l.tags.join(", "), match: l.match === "all" ? "All tags" : "Any tag" })),
|
|
594
|
-
}
|
|
595
|
-
: { type: "context", text: "No levels configured." },
|
|
596
|
-
{ type: "header", text: "Rules" },
|
|
597
|
-
rules.length
|
|
598
|
-
? {
|
|
599
|
-
type: "table",
|
|
600
|
-
page_action_id: "rules_page",
|
|
601
|
-
columns: [
|
|
602
|
-
{ key: "where", label: "Covers" },
|
|
603
|
-
{ key: "levels", label: "Who can read" },
|
|
604
|
-
{ key: "extra", label: "Drip and meter" },
|
|
605
|
-
],
|
|
606
|
-
rows: rules.map((r) => ({
|
|
607
|
-
where:
|
|
608
|
-
r.taxonomy && r.term
|
|
609
|
-
? `${r.collection === "*" ? "Every collection" : r.collection}, ${r.taxonomy}: ${r.term} (and its child terms)`
|
|
610
|
-
: r.collection === "*"
|
|
611
|
-
? "Site default (collections with no rule)"
|
|
612
|
-
: `All ${r.collection}`,
|
|
613
|
-
levels: r.open ? "Everyone (opens the entry)" : r.levels.length ? describe([r.levels], levels) : "Any signed-in member",
|
|
614
|
-
extra: [r.drip ? `Opens ${r.drip} days into membership` : "", r.meter === false ? "Never metered" : ""].filter(Boolean).join("; "),
|
|
615
|
-
})),
|
|
616
|
-
}
|
|
617
|
-
: { type: "context", text: "No rules. Entries are open unless their body has a paywall break." },
|
|
618
|
-
{
|
|
619
|
-
type: "context",
|
|
620
|
-
text: config.meter?.limit
|
|
621
|
-
? `Meter: signed-in readers may open ${config.meter.limit} locked ${config.meter.limit === 1 ? "entry" : "entries"} a ${config.meter.period ?? "month"}${config.meter.collections?.length ? ` (${config.meter.collections.join(", ")})` : ""}.`
|
|
622
|
-
: "Meter: off.",
|
|
623
|
-
},
|
|
736
|
+
{ type: "context", text: "Levels, rules and the meter are under Access." },
|
|
624
737
|
],
|
|
625
738
|
};
|
|
626
739
|
}
|
|
627
740
|
|
|
628
|
-
async function statusWidget(config: RedeemIDConfig) {
|
|
741
|
+
async function statusWidget(config: RedeemIDConfig, ctx: PluginContext) {
|
|
629
742
|
const c = await checks(config);
|
|
630
743
|
const members = await allMembers().catch(() => []);
|
|
744
|
+
const check = await lastCheck(ctx.kv).catch(() => null);
|
|
631
745
|
return {
|
|
632
746
|
blocks: [
|
|
747
|
+
...(check?.problems.length ? [{ type: "banner", variant: "alert", title: "Webhooks need attention", description: check.problems[0]!.text }] : []),
|
|
633
748
|
{
|
|
634
749
|
type: "fields",
|
|
635
750
|
fields: [
|
|
636
751
|
{ label: "Sign-in", value: c.discovery.startsWith("OK") && c.clientId ? "Ready" : "Not ready" },
|
|
637
752
|
{ label: "Members", value: String(members.length) },
|
|
753
|
+
{ label: "Webhooks", value: check ? (check.problems.length ? `${check.problems.length} to look at` : "Healthy") : "Not checked yet" },
|
|
638
754
|
],
|
|
639
755
|
},
|
|
640
756
|
],
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate limits on Cloudflare Workers, added by `redeemidGuard({ rateLimit: true })`.
|
|
3
|
+
* Reads the REDEEMID_RATE_LIMIT Rate Limiting binding from wrangler config.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { defineMiddleware } from "astro:middleware";
|
|
7
|
+
import { env } from "cloudflare:workers";
|
|
8
|
+
|
|
9
|
+
import { RATE_LIMIT_BINDING, rateLimited, type RateLimiter } from "./rate-limit.js";
|
|
10
|
+
|
|
11
|
+
let warned = false;
|
|
12
|
+
|
|
13
|
+
export const onRequest = defineMiddleware(async (context, next) => {
|
|
14
|
+
const limiter = (env as Record<string, unknown>)[RATE_LIMIT_BINDING] as RateLimiter | undefined;
|
|
15
|
+
if (!limiter && !warned) {
|
|
16
|
+
warned = true;
|
|
17
|
+
console.warn(`[redeemid] rateLimit is on but there is no ${RATE_LIMIT_BINDING} binding; nothing is limited.`);
|
|
18
|
+
}
|
|
19
|
+
return (await rateLimited(limiter, context.request)) ?? next();
|
|
20
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional per-visitor rate limits on the routes that start work at Shopify:
|
|
3
|
+
* Sign in, Buy and cart changes. On Cloudflare Workers they use a Rate
|
|
4
|
+
* Limiting binding named REDEEMID_RATE_LIMIT (see rate-limit-cloudflare.ts);
|
|
5
|
+
* nothing is limited without one.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const RATE_LIMIT_BINDING = "REDEEMID_RATE_LIMIT";
|
|
9
|
+
|
|
10
|
+
/** The shape of a Workers Rate Limiting binding. */
|
|
11
|
+
export interface RateLimiter {
|
|
12
|
+
limit(options: { key: string }): Promise<{ success: boolean }>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const BASE = "/_emdash/api/auth/redeemid";
|
|
16
|
+
const LIMITED: Record<string, string> = {
|
|
17
|
+
[`GET ${BASE}/login`]: "login",
|
|
18
|
+
[`POST ${BASE}/buy`]: "buy",
|
|
19
|
+
[`POST ${BASE}/cart-edit`]: "cart",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Which limited action a request is, or null. */
|
|
23
|
+
export function limitedAction(method: string, path: string): string | null {
|
|
24
|
+
return LIMITED[`${method.toUpperCase()} ${path.replace(/\/+$/, "")}`] ?? null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Where to send a reader who was limited: the page they came from, on this site. */
|
|
28
|
+
function backTo(url: URL, request: Request): string {
|
|
29
|
+
const ret = url.searchParams.get("return");
|
|
30
|
+
if (ret && ret.startsWith("/") && !ret.startsWith("//") && !ret.startsWith("/\\")) return ret;
|
|
31
|
+
const referer = request.headers.get("referer");
|
|
32
|
+
if (referer) {
|
|
33
|
+
try {
|
|
34
|
+
const r = new URL(referer);
|
|
35
|
+
if (r.origin === url.origin && !r.pathname.startsWith("/_emdash")) return `${r.pathname}${r.search}`;
|
|
36
|
+
} catch {
|
|
37
|
+
// ignore
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return "/";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A response for a request over its limit, or null to let it through. Fails
|
|
45
|
+
* open: a limiter that errors never blocks a reader.
|
|
46
|
+
*/
|
|
47
|
+
export async function rateLimited(limiter: RateLimiter | undefined, request: Request): Promise<Response | null> {
|
|
48
|
+
if (!limiter || typeof limiter.limit !== "function") return null;
|
|
49
|
+
const url = new URL(request.url);
|
|
50
|
+
const action = limitedAction(request.method, url.pathname);
|
|
51
|
+
if (!action) return null;
|
|
52
|
+
const ip = request.headers.get("cf-connecting-ip") ?? request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
|
53
|
+
try {
|
|
54
|
+
const { success } = await limiter.limit({ key: `${action}:${ip}` });
|
|
55
|
+
if (success) return null;
|
|
56
|
+
} catch (e) {
|
|
57
|
+
console.error("[redeemid] rate limit check failed:", (e as Error).message);
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const back = backTo(url, request);
|
|
61
|
+
const sep = back.includes("?") ? "&" : "?";
|
|
62
|
+
return new Response(null, {
|
|
63
|
+
status: 303,
|
|
64
|
+
headers: { location: `${back}${sep}redeemid_notice=too_many_requests`, "retry-after": "60", "cache-control": "private, no-store" },
|
|
65
|
+
});
|
|
66
|
+
}
|
package/src/routes/callback.ts
CHANGED
|
@@ -14,8 +14,10 @@ import { createKyselyAdapter, type AuthTables } from "@emdash-cms/auth/adapters/
|
|
|
14
14
|
import type { APIRoute } from "astro";
|
|
15
15
|
import type { Kysely } from "kysely";
|
|
16
16
|
|
|
17
|
-
import { env, ENV, PROVIDER_ID,
|
|
17
|
+
import { env, ENV, PROVIDER_ID, storeDomain } from "../config.js";
|
|
18
|
+
import { siteConfig } from "../site-config.js";
|
|
18
19
|
import { emit, levelChange } from "../events.js";
|
|
20
|
+
import { record } from "../log.js";
|
|
19
21
|
import { levelsFor } from "../access.js";
|
|
20
22
|
import { discover, exchangeCode, identity, logoutUrl, ShopifyError, verifyIdToken, type Discovery } from "../shopify.js";
|
|
21
23
|
import { emptyMember, stampTags, stores } from "../storage.js";
|
|
@@ -32,19 +34,21 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
|
|
|
32
34
|
session?.delete(SESSION_FLOW);
|
|
33
35
|
const returnTo = flow?.returnTo ?? "/";
|
|
34
36
|
|
|
35
|
-
const config =
|
|
37
|
+
const config = await siteConfig(locals);
|
|
36
38
|
const clientId = env(ENV.clientId);
|
|
37
39
|
if (!config || !storeDomain(config) || !clientId || !emdash?.db || !session) return fail(redirect, returnTo, "not_configured");
|
|
38
40
|
|
|
39
41
|
if (url.searchParams.get("error")) {
|
|
40
42
|
// Refresh found no open store session: sign in as usual.
|
|
41
43
|
if (flow?.refresh) return redirect(`/_emdash/api/auth/redeemid/login?return=${encodeURIComponent(returnTo)}`);
|
|
44
|
+
await refused("sign_in_cancelled", "ok");
|
|
42
45
|
return fail(redirect, returnTo, "sign_in_cancelled");
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
const state = url.searchParams.get("state");
|
|
46
49
|
const code = url.searchParams.get("code");
|
|
47
50
|
if (!flow || !state || state !== flow.state || !code || Date.now() - flow.at > FLOW_TTL_MS) {
|
|
51
|
+
await refused("sign_in_expired", "warn");
|
|
48
52
|
return fail(redirect, returnTo, "sign_in_expired");
|
|
49
53
|
}
|
|
50
54
|
|
|
@@ -52,7 +56,10 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
|
|
|
52
56
|
const options = new OptionsRepository(emdash.db as ConstructorParameters<typeof OptionsRepository>[0]);
|
|
53
57
|
const setup = await options.get("emdash:setup_complete");
|
|
54
58
|
// Never let a customer become the first (admin) user.
|
|
55
|
-
if (setup !== true && setup !== "true")
|
|
59
|
+
if (setup !== true && setup !== "true") {
|
|
60
|
+
await refused("site_not_ready", "warn");
|
|
61
|
+
return fail(redirect, returnTo, "site_not_ready");
|
|
62
|
+
}
|
|
56
63
|
|
|
57
64
|
const d = await discover(config);
|
|
58
65
|
const tokens = await exchangeCode(d, {
|
|
@@ -66,8 +73,11 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
|
|
|
66
73
|
const claims = await verifyIdToken(d, tokens.idToken, { clientId, nonce: flow.nonce });
|
|
67
74
|
// Refusals from here on also end the store session, so the next Sign in
|
|
68
75
|
// asks for an email again instead of reusing this account.
|
|
69
|
-
const refuse = (code: string) => signedOut(d, tokens.idToken, url, emdash.config, returnTo, code) ?? fail(redirect, returnTo, code);
|
|
70
76
|
const who = await identity(d, tokens.accessToken);
|
|
77
|
+
const refuse = async (code: string) => {
|
|
78
|
+
await refused(code, "warn", who.customerId);
|
|
79
|
+
return signedOut(d, tokens.idToken, url, emdash.config, returnTo, code) ?? fail(redirect, returnTo, code);
|
|
80
|
+
};
|
|
71
81
|
|
|
72
82
|
const email = (typeof claims.email === "string" ? claims.email : who.email)?.toLowerCase() ?? null;
|
|
73
83
|
if (!email) return refuse("email_required");
|
|
@@ -114,6 +124,7 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
|
|
|
114
124
|
);
|
|
115
125
|
|
|
116
126
|
const levels = levelsFor(who.tags, config.levels);
|
|
127
|
+
await record({ event: "member.signed_in", label: flow.refresh ? "refresh" : member.linkedAt ? "sign-in" : "first sign-in", status: "ok", origin: "member", customerId: who.customerId });
|
|
117
128
|
await emit(config, "member.signed_in", { customerId: who.customerId, levels, first: !member.linkedAt, refresh: Boolean(flow.refresh) });
|
|
118
129
|
await levelChange(config, found ? found.tags : null, { customerId: who.customerId, tags: who.tags, status: member.status }, "sign-in");
|
|
119
130
|
|
|
@@ -124,10 +135,16 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
|
|
|
124
135
|
} catch (e) {
|
|
125
136
|
const code = e instanceof ShopifyError ? e.code : e instanceof OAuthError ? "signup_not_allowed" : "sign_in_failed";
|
|
126
137
|
console.error("[redeemid] sign-in failed:", (e as Error).message);
|
|
138
|
+
await refused(code, "error", null, String((e as Error).message).slice(0, 200));
|
|
127
139
|
return fail(redirect, returnTo, code);
|
|
128
140
|
}
|
|
129
141
|
};
|
|
130
142
|
|
|
143
|
+
/** A sign-in that didn't finish, for App events: the reason code, never the email. */
|
|
144
|
+
function refused(code: string, level: "ok" | "warn" | "error", customerId: string | null = null, note?: string): Promise<void> {
|
|
145
|
+
return record({ event: "member.refused", label: code, status: "refused", level, origin: "member", customerId, ...(note ? { note } : {}) });
|
|
146
|
+
}
|
|
147
|
+
|
|
131
148
|
/** Ends the store session, then shows `code` on `returnTo` (see AFTER_LOGOUT_COOKIE). */
|
|
132
149
|
function signedOut(d: Discovery, idToken: string, url: URL, emdashConfig: unknown, returnTo: string, code: string): Response | null {
|
|
133
150
|
const origin = getPublicOrigin(url, emdashConfig as Parameters<typeof getPublicOrigin>[1]);
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import type { APIRoute } from "astro";
|
|
14
14
|
|
|
15
15
|
import { safeReturn, signInUrl } from "../access.js";
|
|
16
|
-
import {
|
|
16
|
+
import { siteConfig } from "../site-config.js";
|
|
17
17
|
import { emit } from "../events.js";
|
|
18
18
|
import { allowSave, formValues, PreferenceError, preferenceFields, preferencesConfigured, readPreferences, savePreferences } from "../preferences.js";
|
|
19
19
|
import { memberForUser, stores } from "../storage.js";
|
|
@@ -26,7 +26,7 @@ const json = (status: number, body: unknown) =>
|
|
|
26
26
|
|
|
27
27
|
async function context(locals: unknown, session: { get(k: string): Promise<unknown> } | undefined) {
|
|
28
28
|
const emdash = (locals as { emdash?: { config?: unknown; db?: unknown } }).emdash;
|
|
29
|
-
const config =
|
|
29
|
+
const config = await siteConfig(locals);
|
|
30
30
|
if (!config || !preferencesConfigured(config) || !emdash?.db) return { error: "unavailable" as const };
|
|
31
31
|
const user = (await session?.get("user")) as { id?: string } | undefined;
|
|
32
32
|
if (!user?.id) return { error: "sign_in" as const, config };
|
package/src/routes/privacy.ts
CHANGED
|
@@ -12,7 +12,7 @@ import type { APIRoute } from "astro";
|
|
|
12
12
|
import { getPublicOrigin } from "emdash/api/route-utils";
|
|
13
13
|
|
|
14
14
|
import { safeReturn, signInUrl } from "../access.js";
|
|
15
|
-
import {
|
|
15
|
+
import { siteConfig } from "../site-config.js";
|
|
16
16
|
import { eraseMember, exportRequest } from "../privacy.js";
|
|
17
17
|
import { memberForUser, stores } from "../storage.js";
|
|
18
18
|
import { fail } from "./shared.js";
|
|
@@ -26,7 +26,7 @@ async function context(locals: unknown, session: { get(k: string): Promise<unkno
|
|
|
26
26
|
if (!user?.id) return null;
|
|
27
27
|
const s = stores(emdash.db);
|
|
28
28
|
const member = await memberForUser(s, user.id);
|
|
29
|
-
return member ? { s, member, config:
|
|
29
|
+
return member ? { s, member, config: await siteConfig(locals), emdash } : null;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
export const GET: APIRoute = async ({ locals, session, redirect, url }) => {
|