@redeemid/emdash 0.0.16 → 0.0.18
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 +447 -232
- package/bin/redeemid-emdash.mjs +13 -1
- package/docs/developer-api.md +126 -0
- package/docs/shopify-setup.md +1 -1
- package/package.json +3 -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 +62 -3
- package/src/api-version.ts +6 -0
- package/src/blocks/CollectionBlock.astro +4 -1
- package/src/blocks/SearchBlock.astro +14 -0
- package/src/blocks/index.ts +2 -0
- package/src/blocks-names.ts +1 -0
- package/src/cart-ops.ts +164 -0
- package/src/catalog.ts +287 -0
- package/src/checkout.ts +73 -0
- package/src/client/redeemid.ts +563 -0
- package/src/components/AccessBadge.astro +7 -5
- package/src/components/Account.astro +1 -1
- package/src/components/BrowseControls.astro +87 -0
- package/src/components/Cart.astro +12 -6
- package/src/components/CartDrawer.astro +59 -0
- package/src/components/CartView.astro +2 -1
- package/src/components/Collection.astro +23 -18
- package/src/components/Content.astro +10 -5
- package/src/components/Contribution.astro +1 -1
- package/src/components/Gate.astro +4 -2
- package/src/components/LockedPanel.astro +5 -3
- package/src/components/MemberCard.astro +1 -1
- package/src/components/Notices.astro +3 -28
- package/src/components/Plan.astro +1 -1
- package/src/components/Preferences.astro +1 -1
- package/src/components/Product.astro +116 -25
- package/src/components/ProductGrid.astro +52 -0
- package/src/components/ProductSearch.astro +83 -0
- package/src/components/Recommendations.astro +45 -0
- package/src/components/RedeemIDClient.astro +45 -0
- package/src/components/YourData.astro +1 -1
- package/src/config.ts +31 -5
- package/src/contract.ts +50 -0
- 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 +3 -0
- package/src/log.ts +96 -0
- package/src/monitor.ts +560 -0
- package/src/notices.ts +29 -0
- package/src/plugin.ts +234 -88
- package/src/rate-limit.ts +7 -0
- package/src/routes/api/cart.ts +84 -0
- package/src/routes/api/search.ts +49 -0
- package/src/routes/api/shared.ts +4 -0
- package/src/routes/api/state.ts +69 -0
- package/src/routes/buy.ts +4 -3
- package/src/routes/callback.ts +23 -5
- package/src/routes/cart-edit.ts +15 -66
- package/src/routes/cart.ts +4 -2
- package/src/routes/preferences.ts +2 -2
- package/src/routes/privacy.ts +2 -2
- package/src/routes/shared.ts +11 -0
- package/src/routes/webhook.ts +24 -11
- package/src/settings.ts +195 -0
- package/src/shopify.ts +37 -1
- package/src/site-config.ts +38 -5
- package/src/staff.ts +12 -23
- package/src/storage.ts +12 -0
- package/src/storefront.ts +231 -59
- package/src/text.ts +43 -2
- package/src/version.ts +1 -1
- package/src/webhooks.ts +2 -1
package/src/notices.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** The message for each `redeemid_notice` code (also what the browser client shows in the cart drawer). */
|
|
2
|
+
|
|
3
|
+
export const NOTICES: Record<string, string> = {
|
|
4
|
+
not_configured: "Sign-in isn't set up on this site yet.",
|
|
5
|
+
sign_in_unavailable: "Sign-in isn't available right now. Try again in a few minutes.",
|
|
6
|
+
sign_in_expired: "That sign-in took too long. Try again.",
|
|
7
|
+
staff_account: "This account can't sign in here. Use a different email.",
|
|
8
|
+
account_disabled: "Your account is turned off. Contact the store for help.",
|
|
9
|
+
too_many_requests: "Too many tries. Wait a minute, then try again.",
|
|
10
|
+
site_not_ready: "This site isn't ready for sign-in yet.",
|
|
11
|
+
email_required: "Your account needs an email address to sign in.",
|
|
12
|
+
token_failed: "Sign-in didn't finish. Try again.",
|
|
13
|
+
bad_id_token: "Sign-in didn't finish. Try again.",
|
|
14
|
+
identity_failed: "Sign-in didn't finish. Try again.",
|
|
15
|
+
plan_unavailable: "That plan isn't available right now.",
|
|
16
|
+
checkout_failed: "Checkout didn't open. Try again.",
|
|
17
|
+
cart_empty: "Your cart is empty.",
|
|
18
|
+
cart_added: "Added to your cart.",
|
|
19
|
+
cart_updated: "Your cart is updated.",
|
|
20
|
+
discount_applied: "Discount applied.",
|
|
21
|
+
discount_not_applicable: "That discount code doesn't apply to this cart.",
|
|
22
|
+
refreshed: "Your membership is up to date.",
|
|
23
|
+
preferences_saved: "Your preferences are saved.",
|
|
24
|
+
preferences_refused: "Some answers weren't saved. Check them and try again.",
|
|
25
|
+
preferences_unavailable: "Your preferences can't be saved right now. Try again in a few minutes.",
|
|
26
|
+
data_erased: "Your data on this site is deleted, and you're signed out.",
|
|
27
|
+
erase_unconfirmed: "Tick the box to confirm, then try again.",
|
|
28
|
+
account_unavailable: "Your account page isn't available right now. Try again in a few minutes.",
|
|
29
|
+
};
|
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,21 +25,25 @@
|
|
|
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
|
-
import { AgentError, explainAccess, getMember, getRules, levelChanges, listLevels, siteHealth, type ExplainInput } from "./agents.js";
|
|
46
|
+
import { AgentError, explainAccess, findProducts, getCollection, getMember, getProduct, getRules, levelChanges, listLevels, siteHealth, type ExplainInput } from "./agents.js";
|
|
42
47
|
|
|
43
48
|
import {
|
|
44
49
|
COLLECTION_BLOCK,
|
|
@@ -50,15 +55,27 @@ import {
|
|
|
50
55
|
PLAN_BLOCK,
|
|
51
56
|
PREFERENCES_BLOCK,
|
|
52
57
|
PRODUCT_BLOCK,
|
|
58
|
+
SEARCH_BLOCK,
|
|
53
59
|
} from "./blocks-names.js";
|
|
54
60
|
|
|
55
|
-
export { COLLECTION_BLOCK, CONTRIBUTION_BLOCK, ITEM_BLOCK, MEMBERS_BLOCK, MEMBERS_END_BLOCK, PAYWALL_BLOCK, PLAN_BLOCK, PREFERENCES_BLOCK, PRODUCT_BLOCK };
|
|
61
|
+
export { COLLECTION_BLOCK, CONTRIBUTION_BLOCK, ITEM_BLOCK, MEMBERS_BLOCK, MEMBERS_END_BLOCK, PAYWALL_BLOCK, PLAN_BLOCK, PREFERENCES_BLOCK, PRODUCT_BLOCK, SEARCH_BLOCK };
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The editor's level pickers. Declared once when the plugin starts, then kept
|
|
65
|
+
* in step (in place) with the levels saved in the admin: EmDash builds the
|
|
66
|
+
* editor's manifest from these same arrays on each request.
|
|
67
|
+
*/
|
|
68
|
+
const levelOptions: Array<{ label: string; value: string }> = [];
|
|
69
|
+
const planLevelOptions: Array<{ label: string; value: string }> = [];
|
|
70
|
+
|
|
71
|
+
export function setLevelOptions(levels: LevelConfig[] = []): void {
|
|
72
|
+
const mine = levels.map((l) => ({ label: l.name, value: l.slug }));
|
|
73
|
+
levelOptions.splice(0, levelOptions.length, { label: "Signed in (any member)", value: SIGNED_IN }, ...mine);
|
|
74
|
+
planLevelOptions.splice(0, planLevelOptions.length, ...mine);
|
|
75
|
+
}
|
|
56
76
|
|
|
57
77
|
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
|
-
];
|
|
78
|
+
setLevelOptions(config.levels);
|
|
62
79
|
return [
|
|
63
80
|
{
|
|
64
81
|
type: PAYWALL_BLOCK,
|
|
@@ -119,7 +136,7 @@ function blocks(config: RedeemIDConfig) {
|
|
|
119
136
|
type: "checkbox" as const,
|
|
120
137
|
action_id: "levels",
|
|
121
138
|
label: "Levels this plan gives (members on them see \"You're on this plan\")",
|
|
122
|
-
options:
|
|
139
|
+
options: planLevelOptions,
|
|
123
140
|
},
|
|
124
141
|
],
|
|
125
142
|
},
|
|
@@ -141,6 +158,26 @@ function blocks(config: RedeemIDConfig) {
|
|
|
141
158
|
{ type: "text_input" as const, action_id: "handle", label: "Shopify collection handle", placeholder: "shop" },
|
|
142
159
|
{ type: "text_input" as const, action_id: "limit", label: "How many products (up to 50)", placeholder: "12" },
|
|
143
160
|
{ type: "text_input" as const, action_id: "link", label: "Product page link, with {handle}", placeholder: "/shop/{handle}/" },
|
|
161
|
+
{
|
|
162
|
+
type: "checkbox" as const,
|
|
163
|
+
action_id: "extras",
|
|
164
|
+
label: "Extras",
|
|
165
|
+
options: [
|
|
166
|
+
{ label: "Filters, sorting and pages (the page must render on demand)", value: "browse" },
|
|
167
|
+
{ label: "Add to cart on cards that need no choice", value: "quick-add" },
|
|
168
|
+
],
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
type: SEARCH_BLOCK,
|
|
174
|
+
label: "Product search",
|
|
175
|
+
icon: "search",
|
|
176
|
+
description: "A search box with Shopify's matching products, filters and sorting",
|
|
177
|
+
placeholder: "Product search",
|
|
178
|
+
fields: [
|
|
179
|
+
{ type: "text_input" as const, action_id: "link", label: "Product page link, with {handle}", placeholder: "/shop/{handle}/" },
|
|
180
|
+
{ type: "text_input" as const, action_id: "label", label: "Search box label", placeholder: "Search products" },
|
|
144
181
|
],
|
|
145
182
|
},
|
|
146
183
|
{
|
|
@@ -170,30 +207,89 @@ function blocks(config: RedeemIDConfig) {
|
|
|
170
207
|
];
|
|
171
208
|
}
|
|
172
209
|
|
|
173
|
-
export function createPlugin(
|
|
210
|
+
export function createPlugin(code: RedeemIDConfig = {}): ResolvedPlugin {
|
|
211
|
+
onLevels(setLevelOptions);
|
|
212
|
+
const live = () => liveConfig(code);
|
|
174
213
|
return definePlugin({
|
|
175
214
|
id: "redeemid",
|
|
176
215
|
version: VERSION,
|
|
177
|
-
|
|
216
|
+
// content:read: the editor's Access panel reads the entry, and its
|
|
217
|
+
// rule follows the entry's slug and goes with the entry.
|
|
218
|
+
// email:send: the daily webhook check can warn staff by email.
|
|
219
|
+
capabilities: ["content:read", "email:send"],
|
|
178
220
|
admin: {
|
|
179
221
|
pages: PAGES,
|
|
180
222
|
widgets: WIDGETS,
|
|
181
|
-
|
|
223
|
+
editorPanels: EDITOR_PANELS,
|
|
224
|
+
portableTextBlocks: blocks(code),
|
|
225
|
+
},
|
|
226
|
+
mcp: agentTools(code),
|
|
227
|
+
hooks: {
|
|
228
|
+
"plugin:activate": async (_event, ctx) => {
|
|
229
|
+
await scheduleHealth(ctx);
|
|
230
|
+
},
|
|
231
|
+
cron: async (event, ctx) => {
|
|
232
|
+
if (event.name !== HEALTH_TASK) return;
|
|
233
|
+
await healthCheck(monitorEnv(await live(), await memberStores(), ctx));
|
|
234
|
+
},
|
|
235
|
+
"content:afterSave": async (event) => {
|
|
236
|
+
const id = typeof event.content.id === "string" ? event.content.id : null;
|
|
237
|
+
if (!id) return;
|
|
238
|
+
const slug = typeof event.content.slug === "string" ? event.content.slug : null;
|
|
239
|
+
await entrySlugChanged((await memberStores()).access, event.collection, id, slug).catch(() => undefined);
|
|
240
|
+
},
|
|
241
|
+
"content:afterDelete": async (event) => {
|
|
242
|
+
if (!event.permanent) return;
|
|
243
|
+
await (await memberStores()).access.delete(entryKey(event.id)).catch(() => undefined);
|
|
244
|
+
},
|
|
182
245
|
},
|
|
183
|
-
mcp: agentTools(config),
|
|
184
246
|
routes: {
|
|
185
|
-
...agentRoutes(
|
|
247
|
+
...agentRoutes(code),
|
|
248
|
+
"access-panel": {
|
|
249
|
+
permission: "content:edit_own",
|
|
250
|
+
request: { body: "json" as const },
|
|
251
|
+
handler: async (ctx: RouteContext) => {
|
|
252
|
+
const i = (ctx.input ?? {}) as { type?: string; action_id?: string; values?: Record<string, unknown> };
|
|
253
|
+
const entry = ctx.ui && "entry" in ctx.ui ? ctx.ui.entry : undefined;
|
|
254
|
+
if (!entry) return { blocks: [{ type: "context", text: "Save the entry first." }] };
|
|
255
|
+
try {
|
|
256
|
+
const env = await accessEnv(await live());
|
|
257
|
+
const panel = await panelEntry(ctx, entry.collection, entry.id);
|
|
258
|
+
if (i.type === "form_submit" && i.action_id === "entry_save") {
|
|
259
|
+
const out = await entrySave(env, panel, i.values ?? {});
|
|
260
|
+
await record({ event: "settings.saved", label: `entry rule (${entry.collection})`, status: "ok", origin: "staff" });
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
return await entryPanel(env, panel);
|
|
264
|
+
} catch (e) {
|
|
265
|
+
return { blocks: [{ type: "banner", variant: "error", title: "RedeemID couldn't load access", description: (e as Error).message }] };
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
},
|
|
186
269
|
admin: {
|
|
187
270
|
handler: async (ctx: RouteContext) => {
|
|
188
271
|
const i = (ctx.input ?? {}) as { type?: string; page?: string; action_id?: string; value?: unknown; values?: Record<string, unknown>; block_id?: string };
|
|
189
272
|
try {
|
|
190
|
-
|
|
273
|
+
const config = await live();
|
|
274
|
+
if ((i.type === "block_action" || i.type === "form_submit") && isAccessAction(i.action_id)) {
|
|
275
|
+
const out = await accessAction(await accessEnv(config), i);
|
|
276
|
+
if (out.toast?.type === "success") await record({ event: "settings.saved", label: `access: ${String(i.action_id).replace(/_/g, " ")}`, status: "ok", origin: "staff" });
|
|
277
|
+
return out;
|
|
278
|
+
}
|
|
279
|
+
if (i.type === "block_action" || i.type === "form_submit") return await action(config, ctx, i);
|
|
191
280
|
if (i.type !== "page_load") return { blocks: [] };
|
|
192
281
|
if (i.page === "/") return await membersPage(config);
|
|
282
|
+
if (i.page === "/access") return await accessPage(await accessEnv(config));
|
|
283
|
+
if (i.page === "/webhooks") {
|
|
284
|
+
await scheduleHealth(ctx);
|
|
285
|
+
return await webhooksPage(monitorEnv(config, await memberStores(), ctx));
|
|
286
|
+
}
|
|
287
|
+
if (i.page === "/events") return await eventsPage(await memberStores());
|
|
288
|
+
if (i.page === "/reports") return await reportsPage(config, await memberStores());
|
|
193
289
|
if (i.page === "/status") return await statusPage(config, ctx.request);
|
|
194
290
|
if (i.page === "/test") return await accessTest(config);
|
|
195
291
|
if (i.page === "/privacy") return await privacyPage(await memberStores());
|
|
196
|
-
if (i.page === "widget:status") return await statusWidget(config);
|
|
292
|
+
if (i.page === "widget:status") return await statusWidget(config, ctx);
|
|
197
293
|
} catch (e) {
|
|
198
294
|
return { blocks: [{ type: "banner", variant: "error", title: "RedeemID couldn't load this page", description: (e as Error).message }] };
|
|
199
295
|
}
|
|
@@ -204,6 +300,70 @@ export function createPlugin(config: RedeemIDConfig = {}): ResolvedPlugin {
|
|
|
204
300
|
});
|
|
205
301
|
}
|
|
206
302
|
|
|
303
|
+
/** What the Webhooks page and the daily check work with. */
|
|
304
|
+
function monitorEnv(config: RedeemIDConfig, s: Stores, ctx: PluginContext): MonitorEnv {
|
|
305
|
+
return { config, s, kv: ctx.kv, uri: ctx.url(WEBHOOK_PATH), email: ctx.email };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Make sure the daily webhook check is scheduled (on Workers it runs from the site's Cron Trigger). */
|
|
309
|
+
async function scheduleHealth(ctx: PluginContext): Promise<void> {
|
|
310
|
+
try {
|
|
311
|
+
if (!ctx.cron) return;
|
|
312
|
+
const tasks = await ctx.cron.list();
|
|
313
|
+
if (!tasks.some((t) => t.name === HEALTH_TASK)) await ctx.cron.schedule(HEALTH_TASK, { schedule: "@daily" });
|
|
314
|
+
} catch {
|
|
315
|
+
// Scheduling is best effort; Check now still works.
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** The code's config with what the admin saved. */
|
|
320
|
+
async function liveConfig(code: RedeemIDConfig): Promise<RedeemIDConfig> {
|
|
321
|
+
const { getDb } = await import("emdash/runtime");
|
|
322
|
+
return (await withSaved(code, await getDb())) ?? code;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Everything the Access screens read: saved settings and the site's content types and taxonomies, as they are now. */
|
|
326
|
+
async function accessEnv(config: RedeemIDConfig): Promise<AccessEnv> {
|
|
327
|
+
const { getDb } = await import("emdash/runtime");
|
|
328
|
+
const db = await getDb();
|
|
329
|
+
const store = stores(db).access;
|
|
330
|
+
const emdash = await import("emdash");
|
|
331
|
+
const collections = (await new emdash.SchemaRegistry(db as never).listCollections()) as Array<{ slug: string; label: string }>;
|
|
332
|
+
const taxonomies = (await emdash.getTaxonomyDefs()) as Array<{ name: string; label: string; collections: string[] }>;
|
|
333
|
+
return {
|
|
334
|
+
code: config,
|
|
335
|
+
store,
|
|
336
|
+
saved: await loadAccess(store),
|
|
337
|
+
collections: collections.map((c) => ({ slug: c.slug, label: c.label || c.slug })),
|
|
338
|
+
taxonomies: taxonomies.map((t) => ({ name: t.name, label: t.label || t.name, collections: t.collections ?? [] })),
|
|
339
|
+
terms: async (taxonomy) => {
|
|
340
|
+
type Node = { slug: string; label?: string; name?: string; children?: Node[] };
|
|
341
|
+
const out: Array<{ slug: string; label: string }> = [];
|
|
342
|
+
const walk = (list: Node[], path: string) =>
|
|
343
|
+
list.forEach((n) => {
|
|
344
|
+
const label = `${path}${n.label || n.slug}`;
|
|
345
|
+
out.push({ slug: n.slug, label });
|
|
346
|
+
walk(n.children ?? [], `${label} › `);
|
|
347
|
+
});
|
|
348
|
+
walk((await emdash.getTaxonomyTerms(taxonomy)) as Node[], "");
|
|
349
|
+
return out;
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** The saved entry the editor's panel is on: its slug and terms. */
|
|
355
|
+
async function panelEntry(ctx: RouteContext, collection: string, id: string): Promise<PanelEntry> {
|
|
356
|
+
const item = await ctx.content?.get(collection, id).catch(() => null);
|
|
357
|
+
const emdash = await import("emdash");
|
|
358
|
+
const terms: Terms = {};
|
|
359
|
+
for (const t of (await emdash.getTaxonomyDefs()) as Array<{ name: string; collections: string[] }>) {
|
|
360
|
+
if (!(t.collections ?? []).includes(collection)) continue;
|
|
361
|
+
const list = (await emdash.getEntryTerms(collection, id, t.name)) as Array<{ slug: string; id: string; parentId?: string }>;
|
|
362
|
+
if (list.length) terms[t.name] = list.map((x) => ({ slug: x.slug, id: x.id, parentId: x.parentId ?? null }));
|
|
363
|
+
}
|
|
364
|
+
return { collection, id, slug: item?.slug ?? null, terms };
|
|
365
|
+
}
|
|
366
|
+
|
|
207
367
|
export default createPlugin;
|
|
208
368
|
|
|
209
369
|
// ---------------------------------------------------------------- agents and CLI
|
|
@@ -211,9 +371,11 @@ export default createPlugin;
|
|
|
211
371
|
const STAFF = "users:read" as const;
|
|
212
372
|
|
|
213
373
|
/** Staff-only JSON routes, also offered as MCP tools. */
|
|
214
|
-
function agentRoutes(
|
|
374
|
+
function agentRoutes(code: RedeemIDConfig) {
|
|
375
|
+
let config = code;
|
|
215
376
|
const run = (fn: (s: Stores, input: Record<string, unknown>) => Promise<unknown> | unknown) => async (ctx: RouteContext) => {
|
|
216
377
|
try {
|
|
378
|
+
config = await liveConfig(code);
|
|
217
379
|
return { ok: true, result: await fn(await memberStores(), (ctx.input ?? {}) as Record<string, unknown>) };
|
|
218
380
|
} catch (e) {
|
|
219
381
|
if (e instanceof AgentError || e instanceof AdminError) return { ok: false, error: e.message };
|
|
@@ -228,8 +390,11 @@ function agentRoutes(config: RedeemIDConfig) {
|
|
|
228
390
|
"get-member": route(MEMBER, (s, i) => getMember(config, s, i as { customerId?: string; email?: string })),
|
|
229
391
|
"site-health": route(EMPTY, (s) => siteHealth(config, s)),
|
|
230
392
|
"level-changes": route(CHANGES, (s, i) => levelChanges(config, s, i as { since: string; level?: string })),
|
|
393
|
+
"find-products": route(FIND, (_s, i) => findProducts(config, i as { query: string; first?: number })),
|
|
394
|
+
"get-product": route(HANDLE, (_s, i) => getProduct(config, i as { handle: string })),
|
|
395
|
+
"get-collection": route(COLLECTION, (_s, i) => getCollection(config, i as { handle: string; first?: number })),
|
|
231
396
|
};
|
|
232
|
-
if (!
|
|
397
|
+
if (!code.agents?.actions) return read;
|
|
233
398
|
return {
|
|
234
399
|
...read,
|
|
235
400
|
"resync-member": route(CUSTOMER, async (s, i) => {
|
|
@@ -261,6 +426,9 @@ const EXPLAIN = z.object({
|
|
|
261
426
|
const MEMBER = z.object({ customerId: z.string().optional(), email: z.string().optional() });
|
|
262
427
|
const CUSTOMER = z.object({ customerId: z.string().min(1).describe("Shopify customer id") });
|
|
263
428
|
const CHANGES = z.object({ since: z.string().describe("A date, e.g. 2026-11-01"), level: z.string().optional().describe("Only this level slug") });
|
|
429
|
+
const FIND = z.object({ query: z.string().min(1).describe("Search words, e.g. tote"), first: z.number().int().min(1).max(25).optional().describe("How many (default 10)") });
|
|
430
|
+
const HANDLE = z.object({ handle: z.string().min(1).describe("Shopify product handle") });
|
|
431
|
+
const COLLECTION = z.object({ handle: z.string().min(1).describe("Shopify collection handle"), first: z.number().int().min(1).max(50).optional().describe("How many products (default 25)") });
|
|
264
432
|
const TAGS = z.object({
|
|
265
433
|
customerId: z.string().min(1).describe("Shopify customer id"),
|
|
266
434
|
add: z.array(z.string()).optional().describe("Tags to add in Shopify"),
|
|
@@ -276,6 +444,9 @@ function agentTools(config: RedeemIDConfig) {
|
|
|
276
444
|
get_member: { route: "get-member", input: MEMBER, description: "One synced member by Shopify customer id or email: status, tags, current levels with start dates, past levels, features and last sync." },
|
|
277
445
|
site_health: { route: "site-health", input: EMPTY, description: "Whether each setting is in place (never the values), the last webhook, open privacy requests and ignored text replacements." },
|
|
278
446
|
level_changes: { route: "level-changes", input: CHANGES, description: "Who joined or left a level since a date, from each member's tag dates." },
|
|
447
|
+
find_products: { route: "find-products", input: FIND, description: "Search the Shopify catalog the site sells: handles, titles, prices and availability. Use the handles in Product, Plan and Collection blocks." },
|
|
448
|
+
get_product: { route: "get-product", input: HANDLE, description: "One Shopify product by handle: options, variants with prices and stock, selling plans (subscriptions) and image count." },
|
|
449
|
+
get_collection: { route: "get-collection", input: COLLECTION, description: "One Shopify collection by handle: its products, prices and the filters Shopify offers for it." },
|
|
279
450
|
};
|
|
280
451
|
if (config.agents?.actions) {
|
|
281
452
|
tools.resync_member = { route: "resync-member", input: CUSTOMER, description: "Read one member from Shopify now and update the site. Changes nothing in Shopify." };
|
|
@@ -287,14 +458,17 @@ function agentTools(config: RedeemIDConfig) {
|
|
|
287
458
|
|
|
288
459
|
// ---------------------------------------------------------------- actions
|
|
289
460
|
|
|
290
|
-
const webhookUri = (request: Request) => `${new URL(request.url).origin}/_emdash/api/auth/redeemid/webhook`;
|
|
291
|
-
|
|
292
461
|
async function action(
|
|
293
462
|
config: RedeemIDConfig,
|
|
294
|
-
|
|
463
|
+
ctx: RouteContext,
|
|
295
464
|
i: { action_id?: string; value?: unknown; values?: Record<string, unknown>; block_id?: string },
|
|
296
465
|
) {
|
|
297
466
|
const s = await memberStores();
|
|
467
|
+
const monitor = () => monitorEnv(config, s, ctx);
|
|
468
|
+
const cursorOf = (v: unknown) => {
|
|
469
|
+
const c = (v as { cursor?: unknown } | null)?.cursor;
|
|
470
|
+
return typeof c === "string" && c ? c : undefined;
|
|
471
|
+
};
|
|
298
472
|
const fail = (e: unknown) => ({ type: "error" as const, message: (e as Error).message });
|
|
299
473
|
switch (i.action_id) {
|
|
300
474
|
case "members_back":
|
|
@@ -342,14 +516,34 @@ async function action(
|
|
|
342
516
|
}
|
|
343
517
|
case "webhooks_register": {
|
|
344
518
|
try {
|
|
345
|
-
const { added, failed } = await registerWebhooks(config,
|
|
519
|
+
const { added, failed } = await registerWebhooks(config, monitor().uri);
|
|
346
520
|
const done = added.length ? `Registered ${added.length} webhooks.` : failed.length ? "" : "Nothing was missing.";
|
|
347
521
|
const refused = failed.map((f) => `Shopify refused ${f.topic}: ${f.message}`).join(" ");
|
|
348
|
-
|
|
522
|
+
await record({ event: "staff.webhooks", label: "register", status: failed.length ? `${failed.length} refused` : `${added.length} registered`, level: failed.length ? "warn" : "ok", origin: "staff" });
|
|
523
|
+
return webhooksPage(monitor(), "setup", { type: failed.length ? "error" : "success", message: [done, refused].filter(Boolean).join(" ") });
|
|
349
524
|
} catch (e) {
|
|
350
|
-
return
|
|
525
|
+
return webhooksPage(monitor(), "setup", fail(e));
|
|
351
526
|
}
|
|
352
527
|
}
|
|
528
|
+
case "webhooks_tab": {
|
|
529
|
+
const tab = WEBHOOK_TABS.includes(i.value as WebhookTab) ? (i.value as WebhookTab) : "received";
|
|
530
|
+
return webhooksPage(monitor(), tab);
|
|
531
|
+
}
|
|
532
|
+
case "deliveries_page":
|
|
533
|
+
return webhooksPage(monitor(), "received", undefined, cursorOf(i.value));
|
|
534
|
+
case "health_check": {
|
|
535
|
+
const r = await healthCheck({ ...monitor(), by: "staff" });
|
|
536
|
+
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.";
|
|
537
|
+
return webhooksPage(monitor(), "health", { type: r.problems.length ? "info" : "success", message });
|
|
538
|
+
}
|
|
539
|
+
case "monitor_save":
|
|
540
|
+
return webhooksPage(monitor(), "health", await saveMonitor(ctx.kv, i.values));
|
|
541
|
+
case "events_filter":
|
|
542
|
+
return eventsPage(s, { event: String(i.values?.event ?? "") || undefined, problems: i.values?.problems === true });
|
|
543
|
+
case "events_show":
|
|
544
|
+
return eventsPage(s, { event: String(i.value ?? "") || undefined });
|
|
545
|
+
case "events_page":
|
|
546
|
+
return eventsPage(s, parseFilter(i.block_id), cursorOf(i.value));
|
|
353
547
|
case "access_test":
|
|
354
548
|
return accessTest(config, i.values);
|
|
355
549
|
case "member_export": {
|
|
@@ -417,10 +611,10 @@ async function membersPage(config: RedeemIDConfig) {
|
|
|
417
611
|
label: l.name,
|
|
418
612
|
value: String(members.filter((m) => m.status === "enabled" && levelsFor(m.tags, levels).includes(l.slug)).length),
|
|
419
613
|
}));
|
|
420
|
-
const name = (m: MemberRecord) => [m.firstName, m.lastName].filter(Boolean).join(" ") || "
|
|
614
|
+
const name = (m: MemberRecord) => [m.firstName, m.lastName].filter(Boolean).join(" ") || "No name";
|
|
421
615
|
const levelNames = (m: MemberRecord) => {
|
|
422
616
|
const held = levelsFor(m.tags, levels);
|
|
423
|
-
return levels.filter((l) => held.includes(l.slug)).map((l) => l.name).join(", ")
|
|
617
|
+
return levels.filter((l) => held.includes(l.slug)).map((l) => l.name).join(", ");
|
|
424
618
|
};
|
|
425
619
|
return {
|
|
426
620
|
blocks: [
|
|
@@ -466,7 +660,7 @@ async function membersPage(config: RedeemIDConfig) {
|
|
|
466
660
|
{ key: "email", label: "Email" },
|
|
467
661
|
{ key: "levels", label: "Levels" },
|
|
468
662
|
{ key: "tags", label: "Shopify tags" },
|
|
469
|
-
{ key: "status", label: "Account" },
|
|
663
|
+
{ key: "status", label: "Account", format: "badge" },
|
|
470
664
|
{ key: "synced", label: "Last synced", format: "relative_time" },
|
|
471
665
|
{ key: "open", label: "", format: "element" },
|
|
472
666
|
],
|
|
@@ -474,9 +668,9 @@ async function membersPage(config: RedeemIDConfig) {
|
|
|
474
668
|
.sort((a, b) => b.syncedAt.localeCompare(a.syncedAt))
|
|
475
669
|
.map((m) => ({
|
|
476
670
|
name: name(m),
|
|
477
|
-
email: m.email ?? "
|
|
671
|
+
email: m.email ?? "",
|
|
478
672
|
levels: levelNames(m),
|
|
479
|
-
tags: m.tags.join(", ")
|
|
673
|
+
tags: m.tags.join(", "),
|
|
480
674
|
status: m.status === "enabled" ? "On" : "Off",
|
|
481
675
|
synced: m.syncedAt,
|
|
482
676
|
open: { type: "button", action_id: "member_open", label: "Open", value: m.customerId },
|
|
@@ -521,13 +715,7 @@ async function statusPage(config: RedeemIDConfig, request: Request) {
|
|
|
521
715
|
const c = await checks(config);
|
|
522
716
|
const origin = new URL(request.url).origin;
|
|
523
717
|
const yes = (v: boolean) => (v ? "Set" : "Missing");
|
|
524
|
-
const levels = config.levels ?? [];
|
|
525
|
-
const rules = config.rules ?? [];
|
|
526
718
|
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
719
|
return {
|
|
532
720
|
blocks: [
|
|
533
721
|
{ type: "header", text: "Status" },
|
|
@@ -562,11 +750,7 @@ async function statusPage(config: RedeemIDConfig, request: Request) {
|
|
|
562
750
|
},
|
|
563
751
|
]
|
|
564
752
|
: []),
|
|
565
|
-
{ type: "
|
|
566
|
-
{ type: "header", text: "Webhooks" },
|
|
567
|
-
...webhooks,
|
|
568
|
-
{ type: "header", text: "Recent deliveries" },
|
|
569
|
-
...deliveries,
|
|
753
|
+
{ type: "context", text: "Deliveries, their health and the webhook subscriptions are under Webhooks; everything RedeemID did is under App events." },
|
|
570
754
|
{ type: "divider" },
|
|
571
755
|
{ type: "header", text: "Shopify settings" },
|
|
572
756
|
{
|
|
@@ -579,62 +763,24 @@ async function statusPage(config: RedeemIDConfig, request: Request) {
|
|
|
579
763
|
],
|
|
580
764
|
},
|
|
581
765
|
{ 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
|
-
},
|
|
766
|
+
{ type: "context", text: "Levels, rules and the meter are under Access." },
|
|
624
767
|
],
|
|
625
768
|
};
|
|
626
769
|
}
|
|
627
770
|
|
|
628
|
-
async function statusWidget(config: RedeemIDConfig) {
|
|
771
|
+
async function statusWidget(config: RedeemIDConfig, ctx: PluginContext) {
|
|
629
772
|
const c = await checks(config);
|
|
630
773
|
const members = await allMembers().catch(() => []);
|
|
774
|
+
const check = await lastCheck(ctx.kv).catch(() => null);
|
|
631
775
|
return {
|
|
632
776
|
blocks: [
|
|
777
|
+
...(check?.problems.length ? [{ type: "banner", variant: "alert", title: "Webhooks need attention", description: check.problems[0]!.text }] : []),
|
|
633
778
|
{
|
|
634
779
|
type: "fields",
|
|
635
780
|
fields: [
|
|
636
781
|
{ label: "Sign-in", value: c.discovery.startsWith("OK") && c.clientId ? "Ready" : "Not ready" },
|
|
637
782
|
{ label: "Members", value: String(members.length) },
|
|
783
|
+
{ label: "Webhooks", value: check ? (check.problems.length ? `${check.problems.length} to look at` : "Healthy") : "Not checked yet" },
|
|
638
784
|
],
|
|
639
785
|
},
|
|
640
786
|
],
|
package/src/rate-limit.ts
CHANGED
|
@@ -17,6 +17,7 @@ const LIMITED: Record<string, string> = {
|
|
|
17
17
|
[`GET ${BASE}/login`]: "login",
|
|
18
18
|
[`POST ${BASE}/buy`]: "buy",
|
|
19
19
|
[`POST ${BASE}/cart-edit`]: "cart",
|
|
20
|
+
[`POST ${BASE}/api/cart`]: "cart",
|
|
20
21
|
};
|
|
21
22
|
|
|
22
23
|
/** Which limited action a request is, or null. */
|
|
@@ -57,6 +58,12 @@ export async function rateLimited(limiter: RateLimiter | undefined, request: Req
|
|
|
57
58
|
console.error("[redeemid] rate limit check failed:", (e as Error).message);
|
|
58
59
|
return null;
|
|
59
60
|
}
|
|
61
|
+
if (url.pathname.startsWith(`${BASE}/api/`)) {
|
|
62
|
+
return new Response(JSON.stringify({ error: "too_many_requests" }), {
|
|
63
|
+
status: 429,
|
|
64
|
+
headers: { "content-type": "application/json", "retry-after": "60", "cache-control": "private, no-store" },
|
|
65
|
+
});
|
|
66
|
+
}
|
|
60
67
|
const back = backTo(url, request);
|
|
61
68
|
const sep = back.includes("?") ? "&" : "?";
|
|
62
69
|
return new Response(null, {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /_emdash/api/auth/redeemid/api/cart: the visitor's Shopify cart as JSON.
|
|
3
|
+
*
|
|
4
|
+
* GET answers { cart } (null when there is none).
|
|
5
|
+
* POST takes JSON { action, ... } with the same fields as cart-edit
|
|
6
|
+
* (product, variant, options: { Size: "M" }, sellingPlan, quantity, line,
|
|
7
|
+
* code) and answers { notice, cart, added? }. Action "checkout" answers
|
|
8
|
+
* { checkoutUrl }: signed in for members, re-read when asked for, as
|
|
9
|
+
* Shopify recommends. Same-origin only (EmDash checks Origin on POST).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { APIRoute } from "astro";
|
|
13
|
+
|
|
14
|
+
import { readCart, saveCart } from "../../cart.js";
|
|
15
|
+
import { editCart, type CartInput } from "../../cart-ops.js";
|
|
16
|
+
import { checkoutFor } from "../../checkout.js";
|
|
17
|
+
import { readConfig, storeDomain } from "../../config.js";
|
|
18
|
+
import { cartDetails, getCart, type CartDetails } from "../../storefront.js";
|
|
19
|
+
import { json } from "./shared.js";
|
|
20
|
+
|
|
21
|
+
export const prerender = false;
|
|
22
|
+
|
|
23
|
+
type Emdash = { config?: unknown; db?: unknown } | undefined;
|
|
24
|
+
|
|
25
|
+
async function current(config: NonNullable<ReturnType<typeof readConfig>>, session: Parameters<typeof readCart>[0], request: Request): Promise<CartDetails | null> {
|
|
26
|
+
const ref = await readCart(session);
|
|
27
|
+
if (!ref) return null;
|
|
28
|
+
const cart = await cartDetails(config, ref.id, { buyerIp: request.headers.get("cf-connecting-ip") });
|
|
29
|
+
if (!cart) saveCart(session, null);
|
|
30
|
+
else if (cart.totalQuantity !== ref.count) saveCart(session, { id: cart.id, count: cart.totalQuantity });
|
|
31
|
+
return cart;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const GET: APIRoute = async ({ request, locals, session }) => {
|
|
35
|
+
const config = readConfig((locals as { emdash?: Emdash }).emdash?.config);
|
|
36
|
+
if (!config || !storeDomain(config)) return json(404, { error: "not_configured" });
|
|
37
|
+
try {
|
|
38
|
+
return json(200, { cart: await current(config, session, request) });
|
|
39
|
+
} catch {
|
|
40
|
+
return json(502, { error: "checkout_failed" });
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const POST: APIRoute = async ({ request, locals, session }) => {
|
|
45
|
+
const emdash = (locals as { emdash?: Emdash }).emdash;
|
|
46
|
+
const config = readConfig(emdash?.config);
|
|
47
|
+
if (!config || !storeDomain(config)) return json(404, { error: "not_configured" });
|
|
48
|
+
const body = (await request.json().catch(() => null)) as Record<string, unknown> | null;
|
|
49
|
+
if (!body || typeof body !== "object" || typeof body.action !== "string") return json(400, { error: "bad_request" });
|
|
50
|
+
const str = (v: unknown) => (typeof v === "string" ? v : typeof v === "number" ? String(v) : undefined);
|
|
51
|
+
try {
|
|
52
|
+
if (body.action === "checkout") {
|
|
53
|
+
const ref = await readCart(session);
|
|
54
|
+
const cart = ref ? await getCart(config, ref.id, { buyerIp: request.headers.get("cf-connecting-ip") }) : null;
|
|
55
|
+
if (!cart || cart.totalQuantity < 1) {
|
|
56
|
+
if (ref) saveCart(session, null);
|
|
57
|
+
return json(200, { notice: "cart_empty", checkoutUrl: null });
|
|
58
|
+
}
|
|
59
|
+
return json(200, { checkoutUrl: await checkoutFor(config, cart, { session, request, emdashConfig: emdash?.config }) });
|
|
60
|
+
}
|
|
61
|
+
const options: Record<string, string> = {};
|
|
62
|
+
if (body.options && typeof body.options === "object") {
|
|
63
|
+
for (const [k, v] of Object.entries(body.options as Record<string, unknown>)) if (typeof v === "string") options[k] = v;
|
|
64
|
+
}
|
|
65
|
+
const page = str(body.page);
|
|
66
|
+
const input: CartInput = {
|
|
67
|
+
action: body.action,
|
|
68
|
+
product: str(body.product),
|
|
69
|
+
variant: str(body.variant),
|
|
70
|
+
options,
|
|
71
|
+
sellingPlan: body.sellingPlan === undefined ? null : (str(body.sellingPlan) ?? null),
|
|
72
|
+
quantity: str(body.quantity) ?? "1",
|
|
73
|
+
line: str(body.line),
|
|
74
|
+
code: str(body.code),
|
|
75
|
+
page: page && page.startsWith("/") && !page.startsWith("//") ? page.slice(0, 300) : undefined,
|
|
76
|
+
};
|
|
77
|
+
const out = await editCart(input, { config, session, request, db: emdash?.db });
|
|
78
|
+
const cart = out.cart ?? (out.base ? await current(config, session, request) : null);
|
|
79
|
+
const status = out.notice === "plan_unavailable" ? 422 : 200;
|
|
80
|
+
return json(status, { notice: out.notice, cart, ...(out.added ? { added: out.added } : {}) });
|
|
81
|
+
} catch {
|
|
82
|
+
return json(502, { error: "checkout_failed" });
|
|
83
|
+
}
|
|
84
|
+
};
|