@redeemid/emdash 0.0.17 → 0.0.19
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 +50 -18
- package/bin/redeemid-emdash.mjs +13 -1
- package/docs/developer-api.md +126 -0
- package/package.json +3 -1
- package/src/agents.ts +55 -0
- 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/BrowseControls.astro +87 -0
- package/src/components/Cart.astro +11 -5
- package/src/components/CartDrawer.astro +59 -0
- package/src/components/CartView.astro +1 -0
- package/src/components/Collection.astro +22 -17
- package/src/components/Content.astro +3 -2
- package/src/components/LockedPanel.astro +4 -2
- package/src/components/Notices.astro +2 -27
- package/src/components/Product.astro +115 -24
- 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/config.ts +14 -0
- package/src/contract.ts +50 -0
- package/src/files.ts +39 -1
- package/src/guard.ts +10 -3
- package/src/index.ts +3 -0
- package/src/log.ts +10 -3
- package/src/notices.ts +29 -0
- package/src/plugin.ts +32 -2
- 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 +4 -2
- package/src/routes/cart-edit.ts +15 -66
- package/src/routes/cart.ts +4 -2
- package/src/routes/shared.ts +11 -0
- package/src/routes/webhook.ts +20 -2
- package/src/shopify.ts +37 -1
- package/src/storefront.ts +231 -59
- package/src/text.ts +43 -2
- package/src/version.ts +1 -1
package/src/config.ts
CHANGED
|
@@ -144,6 +144,20 @@ export interface RedeemIDConfig {
|
|
|
144
144
|
* { "Sign in": "Log in" }. One string at a time; see text.ts for the list.
|
|
145
145
|
*/
|
|
146
146
|
text?: Record<string, string>;
|
|
147
|
+
/**
|
|
148
|
+
* Local prices and currency through Shopify Markets: `country` is a fixed
|
|
149
|
+
* two-letter code, or "visitor" for the country Cloudflare reports for
|
|
150
|
+
* each request (CF-IPCountry). `language` is a Shopify language code such
|
|
151
|
+
* as "FR". Prices are always Shopify's; leave this out for the store's
|
|
152
|
+
* default market.
|
|
153
|
+
*/
|
|
154
|
+
market?: { country?: string; language?: string };
|
|
155
|
+
/**
|
|
156
|
+
* Checkout. `signIn: false` stops RedeemID passing a signed-in member's
|
|
157
|
+
* store sign-in to the cart, so they check out as a guest (their email is
|
|
158
|
+
* still filled in).
|
|
159
|
+
*/
|
|
160
|
+
checkout?: { signIn?: false };
|
|
147
161
|
/** The wall's join link: where readers become members, e.g. { href: "/join/", label: "Become a member" }. */
|
|
148
162
|
join?: { href: string; label?: string };
|
|
149
163
|
/**
|
package/src/contract.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The developer API, as a list: what site code, other plugins and agents may
|
|
3
|
+
* rely on. docs/developer-api.md is the readable version, and
|
|
4
|
+
* tests/contract.test.ts fails when the code, this list and that page
|
|
5
|
+
* disagree.
|
|
6
|
+
*
|
|
7
|
+
* - Everything here is stable: it changes only with a changelog entry and an
|
|
8
|
+
* API_VERSION bump (minor when added, major when changed or removed).
|
|
9
|
+
* Before 1.0.0 of the package a breaking change can still happen, but
|
|
10
|
+
* never silently.
|
|
11
|
+
* - Anything not listed is internal, even when a module exports it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export { API_VERSION } from "./api-version.js";
|
|
15
|
+
|
|
16
|
+
/** Stable exports, by import path. */
|
|
17
|
+
export const MODULES = {
|
|
18
|
+
"@redeemid/emdash": ["redeemid", "redeemidGuard", "redeemidPlugin"],
|
|
19
|
+
"@redeemid/emdash/access": ["viewer", "canRead", "readEntry", "entryRequirement", "meterUsage", "membership", "levelsFor", "signInUrl", "signOutUrl", "accountUrl", "cartUrl", "refreshUrl", "safeReturn"],
|
|
20
|
+
"@redeemid/emdash/features": ["hasFeature", "viewerFeatures", "featuresFor"],
|
|
21
|
+
"@redeemid/emdash/preferences": ["readPreferences", "savePreferences", "preferenceFields"],
|
|
22
|
+
"@redeemid/emdash/storefront": ["plan", "product", "collection", "money", "realOptions", "variantFor", "storeContext", "cartDetails", "addToCart", "updateLines", "removeLines", "setDiscountCodes"],
|
|
23
|
+
"@redeemid/emdash/catalog": ["searchProducts", "collectionPage", "suggest", "recommendations", "browseFromUrl", "browseHref", "parseFilters", "priceFilter", "SORTS"],
|
|
24
|
+
"@redeemid/emdash/events": ["signBody"],
|
|
25
|
+
"@redeemid/emdash/text": ["STRINGS", "text", "textParts"],
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
/** Public JSON routes under /_emdash/api/auth/redeemid/. */
|
|
29
|
+
export const JSON_ROUTES = ["api/state", "api/cart", "api/search", "preferences", "privacy"] as const;
|
|
30
|
+
|
|
31
|
+
/** Form routes under /_emdash/api/auth/redeemid/ (each redirects back with a notice). */
|
|
32
|
+
export const FORM_ROUTES = ["login", "callback", "logout", "webhook", "buy", "cart", "cart-edit", "account"] as const;
|
|
33
|
+
|
|
34
|
+
/** window.redeemid members. */
|
|
35
|
+
export const CLIENT = ["version", "apiVersion", "events", "subscribe", "emit", "state", "refresh", "signIn", "signOut", "cart", "openCart", "closeCart", "search", "suggest", "collection"] as const;
|
|
36
|
+
|
|
37
|
+
/** window.redeemid.cart members. */
|
|
38
|
+
export const CLIENT_CART = ["get", "add", "update", "remove", "discount", "checkout"] as const;
|
|
39
|
+
|
|
40
|
+
/** Browser events, in the order of window.redeemid.events. */
|
|
41
|
+
export const CLIENT_EVENTS = ["ready", "state", "wall_shown", "metered_view", "sign_in_click", "checkout_click", "cart_changed", "cart_opened", "search"] as const;
|
|
42
|
+
|
|
43
|
+
/** Signed outbound server events (events.ts). */
|
|
44
|
+
export const SERVER_EVENTS = ["member.signed_in", "member.levels_changed", "meter.used", "meter.exhausted", "preferences.saved", "member.erased"] as const;
|
|
45
|
+
|
|
46
|
+
/** MCP tools, always offered (read-only). */
|
|
47
|
+
export const MCP_TOOLS = ["explain_access", "list_levels", "get_rules", "get_member", "site_health", "level_changes", "find_products", "get_product", "get_collection"] as const;
|
|
48
|
+
|
|
49
|
+
/** MCP tools offered only with config agents.actions. */
|
|
50
|
+
export const MCP_ACTIONS = ["resync_member", "resync_batch", "edit_member_tags"] as const;
|
package/src/files.ts
CHANGED
|
@@ -118,7 +118,8 @@ export async function fileAccess(astro: AstroLike, config: RedeemIDConfig | null
|
|
|
118
118
|
if (!config?.files?.length) 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);
|
|
123
124
|
const rule = ruleFor(config, media?.folder ?? null);
|
|
124
125
|
if (!media || !rule) return { protected: false };
|
|
@@ -162,12 +163,49 @@ export function keysFromPath(pathname: string): string[] | null {
|
|
|
162
163
|
break;
|
|
163
164
|
}
|
|
164
165
|
out.push(decoded);
|
|
166
|
+
// The file route drops slashes around the key, so the check does too.
|
|
167
|
+
const trimmed = decoded.replace(/^\/+|\/+$/g, "");
|
|
168
|
+
if (trimmed && trimmed !== decoded) out.push(trimmed);
|
|
165
169
|
if (decoded === candidate) break;
|
|
166
170
|
candidate = decoded;
|
|
167
171
|
}
|
|
168
172
|
return [...new Set(out)];
|
|
169
173
|
}
|
|
170
174
|
|
|
175
|
+
/** Astro's image endpoint, which reads the file its `href` names and answers with it. */
|
|
176
|
+
export const IMAGE_PATH = "/_image";
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The storage keys a request could reach: a file URL's own, or those of the
|
|
180
|
+
* file an image-endpoint request asks for by `href`. Null when it names none.
|
|
181
|
+
*/
|
|
182
|
+
export function keysFromUrl(url: URL): string[] | null {
|
|
183
|
+
const direct = keysFromPath(url.pathname);
|
|
184
|
+
if (direct) return direct;
|
|
185
|
+
if (url.pathname.replace(/\/+$/, "") !== IMAGE_PATH) return null;
|
|
186
|
+
const out: string[] = [];
|
|
187
|
+
for (const href of url.searchParams.getAll("href")) {
|
|
188
|
+
let candidate = href;
|
|
189
|
+
for (let layer = 0; layer < 4; layer++) {
|
|
190
|
+
try {
|
|
191
|
+
const keys = keysFromPath(new URL(candidate, url.origin).pathname);
|
|
192
|
+
if (keys) out.push(...keys);
|
|
193
|
+
} catch {
|
|
194
|
+
// Not a URL: nothing to check.
|
|
195
|
+
}
|
|
196
|
+
let decoded: string;
|
|
197
|
+
try {
|
|
198
|
+
decoded = decodeURIComponent(candidate);
|
|
199
|
+
} catch {
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
if (decoded === candidate) break;
|
|
203
|
+
candidate = decoded;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return out.length ? [...new Set(out)] : null;
|
|
207
|
+
}
|
|
208
|
+
|
|
171
209
|
/** Decide a download that may name several keys: the first protected one decides. */
|
|
172
210
|
export async function pathAccess(astro: AstroLike, config: RedeemIDConfig | null, keys: string[]): Promise<FileVerdict> {
|
|
173
211
|
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 ? keysFromUrl(url) : null;
|
|
84
91
|
if (keys) {
|
|
85
92
|
let verdict;
|
|
86
93
|
try {
|
package/src/index.ts
CHANGED
|
@@ -60,6 +60,9 @@ export function redeemid(config: RedeemIDConfig): AuthProviderDescriptor {
|
|
|
60
60
|
{ pattern: `${BASE}/account`, entrypoint: "@redeemid/emdash/routes/account.ts" },
|
|
61
61
|
{ pattern: `${BASE}/preferences`, entrypoint: "@redeemid/emdash/routes/preferences.ts" },
|
|
62
62
|
{ pattern: `${BASE}/privacy`, entrypoint: "@redeemid/emdash/routes/privacy.ts" },
|
|
63
|
+
{ pattern: `${BASE}/api/state`, entrypoint: "@redeemid/emdash/routes/api/state.ts" },
|
|
64
|
+
{ pattern: `${BASE}/api/cart`, entrypoint: "@redeemid/emdash/routes/api/cart.ts" },
|
|
65
|
+
{ pattern: `${BASE}/api/search`, entrypoint: "@redeemid/emdash/routes/api/search.ts" },
|
|
63
66
|
],
|
|
64
67
|
publicRoutes: [`${BASE}/`],
|
|
65
68
|
storage: STORAGE_CONFIG,
|
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
|
|
|
@@ -90,7 +91,13 @@ export async function timed<T>(event: string, label: string, run: () => Promise<
|
|
|
90
91
|
/** Delete entries (and other dated records) older than `days`, a page at a time. */
|
|
91
92
|
export async function prune<T>(store: StorageCollection<T>, field: string, days = LOG_DAYS): Promise<number> {
|
|
92
93
|
const before = new Date(Date.now() - days * 86_400_000).toISOString();
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
94
|
+
let gone = 0;
|
|
95
|
+
// A few pages a run: enough to keep up with a busy site, few enough queries for one request.
|
|
96
|
+
for (let page = 0; page < PRUNE_PAGES; page++) {
|
|
97
|
+
const { items } = await store.query({ where: { [field]: { lt: before } }, limit: 100 });
|
|
98
|
+
if (items.length === 0) break;
|
|
99
|
+
gone += await store.deleteMany(items.map((i) => i.id));
|
|
100
|
+
if (items.length < 100) break;
|
|
101
|
+
}
|
|
102
|
+
return gone;
|
|
96
103
|
}
|
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
|
@@ -43,7 +43,7 @@ import { textProblems } from "./text.js";
|
|
|
43
43
|
import { record } from "./log.js";
|
|
44
44
|
import { eventsPage, healthCheck, HEALTH_TASK, lastCheck, parseFilter, reportsPage, saveMonitor, WEBHOOK_PATH, WEBHOOK_TABS, webhooksPage, type MonitorEnv, type WebhookTab } from "./monitor.js";
|
|
45
45
|
import { z } from "astro/zod";
|
|
46
|
-
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";
|
|
47
47
|
|
|
48
48
|
import {
|
|
49
49
|
COLLECTION_BLOCK,
|
|
@@ -55,9 +55,10 @@ import {
|
|
|
55
55
|
PLAN_BLOCK,
|
|
56
56
|
PREFERENCES_BLOCK,
|
|
57
57
|
PRODUCT_BLOCK,
|
|
58
|
+
SEARCH_BLOCK,
|
|
58
59
|
} from "./blocks-names.js";
|
|
59
60
|
|
|
60
|
-
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 };
|
|
61
62
|
|
|
62
63
|
/**
|
|
63
64
|
* The editor's level pickers. Declared once when the plugin starts, then kept
|
|
@@ -157,6 +158,26 @@ function blocks(config: RedeemIDConfig) {
|
|
|
157
158
|
{ type: "text_input" as const, action_id: "handle", label: "Shopify collection handle", placeholder: "shop" },
|
|
158
159
|
{ type: "text_input" as const, action_id: "limit", label: "How many products (up to 50)", placeholder: "12" },
|
|
159
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" },
|
|
160
181
|
],
|
|
161
182
|
},
|
|
162
183
|
{
|
|
@@ -369,6 +390,9 @@ function agentRoutes(code: RedeemIDConfig) {
|
|
|
369
390
|
"get-member": route(MEMBER, (s, i) => getMember(config, s, i as { customerId?: string; email?: string })),
|
|
370
391
|
"site-health": route(EMPTY, (s) => siteHealth(config, s)),
|
|
371
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 })),
|
|
372
396
|
};
|
|
373
397
|
if (!code.agents?.actions) return read;
|
|
374
398
|
return {
|
|
@@ -402,6 +426,9 @@ const EXPLAIN = z.object({
|
|
|
402
426
|
const MEMBER = z.object({ customerId: z.string().optional(), email: z.string().optional() });
|
|
403
427
|
const CUSTOMER = z.object({ customerId: z.string().min(1).describe("Shopify customer id") });
|
|
404
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)") });
|
|
405
432
|
const TAGS = z.object({
|
|
406
433
|
customerId: z.string().min(1).describe("Shopify customer id"),
|
|
407
434
|
add: z.array(z.string()).optional().describe("Tags to add in Shopify"),
|
|
@@ -417,6 +444,9 @@ function agentTools(config: RedeemIDConfig) {
|
|
|
417
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." },
|
|
418
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." },
|
|
419
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." },
|
|
420
450
|
};
|
|
421
451
|
if (config.agents?.actions) {
|
|
422
452
|
tools.resync_member = { route: "resync-member", input: CUSTOMER, description: "Read one member from Shopify now and update the site. Changes nothing in Shopify." };
|
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
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /_emdash/api/auth/redeemid/api/search: the shop's catalog as JSON.
|
|
3
|
+
*
|
|
4
|
+
* - ?q=tote&suggest=1 : suggestions while typing { queries, products, collections }
|
|
5
|
+
* - ?q=tote : a page of matching products { products, filters, pageInfo, total }
|
|
6
|
+
* - ?collection=shop : a page of a collection's products, with its filters
|
|
7
|
+
*
|
|
8
|
+
* Paging and filters use the same params as the components: sort, after,
|
|
9
|
+
* before, filter (Shopify's filter input, repeatable), price_min,
|
|
10
|
+
* price_max, first (1 to 50). Everything is public catalog data, so answers
|
|
11
|
+
* may be cached briefly by browsers and CDNs.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { APIRoute } from "astro";
|
|
15
|
+
|
|
16
|
+
import { browseFromUrl, collectionPage, searchProducts, suggest } from "../../catalog.js";
|
|
17
|
+
import { readConfig, storeDomain } from "../../config.js";
|
|
18
|
+
import { storeContext } from "../../storefront.js";
|
|
19
|
+
import { json } from "./shared.js";
|
|
20
|
+
|
|
21
|
+
export const prerender = false;
|
|
22
|
+
|
|
23
|
+
const PUBLIC = "public, max-age=60";
|
|
24
|
+
|
|
25
|
+
export const GET: APIRoute = async ({ request, locals }) => {
|
|
26
|
+
const config = readConfig((locals as { emdash?: { config?: unknown } }).emdash?.config);
|
|
27
|
+
if (!config || !storeDomain(config)) return json(404, { error: "not_configured" });
|
|
28
|
+
const url = new URL(request.url);
|
|
29
|
+
const first = Number.parseInt(url.searchParams.get("first") ?? "", 10);
|
|
30
|
+
const browse = browseFromUrl(url.searchParams, Number.isFinite(first) ? first : 24);
|
|
31
|
+
const context = storeContext(config, request);
|
|
32
|
+
// A visitor-country answer differs per visitor.
|
|
33
|
+
const cache = config.market?.country === "visitor" ? "private, max-age=60" : PUBLIC;
|
|
34
|
+
try {
|
|
35
|
+
const handle = (url.searchParams.get("collection") ?? "").trim();
|
|
36
|
+
if (handle) {
|
|
37
|
+
if (!/^[a-z0-9][a-z0-9-]*$/i.test(handle)) return json(400, { error: "bad_request" });
|
|
38
|
+
const page = await collectionPage(config, handle, browse, { context });
|
|
39
|
+
return page ? json(200, page, cache) : json(404, { error: "not_found" });
|
|
40
|
+
}
|
|
41
|
+
if (url.searchParams.get("suggest") === "1") {
|
|
42
|
+
const limit = Number.parseInt(url.searchParams.get("limit") ?? "6", 10);
|
|
43
|
+
return json(200, await suggest(config, browse.query, Number.isFinite(limit) ? limit : 6, { context }), cache);
|
|
44
|
+
}
|
|
45
|
+
return json(200, await searchProducts(config, browse.query, browse, { context }), cache);
|
|
46
|
+
} catch {
|
|
47
|
+
return json(502, { error: "unavailable" });
|
|
48
|
+
}
|
|
49
|
+
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Shared bits of the JSON routes under /_emdash/api/auth/redeemid/api/. */
|
|
2
|
+
|
|
3
|
+
export const json = (status: number, body: unknown, cache = "private, no-store") =>
|
|
4
|
+
new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json", "Cache-Control": cache } });
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /_emdash/api/auth/redeemid/api/state: the visitor's own state, for the
|
|
3
|
+
* browser client and other site code.
|
|
4
|
+
*
|
|
5
|
+
* { apiVersion, version, signedIn, linked, levels, features, cart: { count }, meter }
|
|
6
|
+
*
|
|
7
|
+
* `levels` and `features` are slugs. Never an email, a name, a customer id
|
|
8
|
+
* or a Shopify tag. Always private, never cached.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { APIRoute } from "astro";
|
|
12
|
+
|
|
13
|
+
import { levelsFor, periodKey } from "../../access.js";
|
|
14
|
+
import { API_VERSION } from "../../api-version.js";
|
|
15
|
+
import { readCart } from "../../cart.js";
|
|
16
|
+
import { featuresFor } from "../../features.js";
|
|
17
|
+
import { siteConfig } from "../../site-config.js";
|
|
18
|
+
import { memberForUser, stores } from "../../storage.js";
|
|
19
|
+
import { VERSION } from "../../version.js";
|
|
20
|
+
import { json } from "./shared.js";
|
|
21
|
+
|
|
22
|
+
export const prerender = false;
|
|
23
|
+
|
|
24
|
+
export interface State {
|
|
25
|
+
apiVersion: string;
|
|
26
|
+
version: string;
|
|
27
|
+
signedIn: boolean;
|
|
28
|
+
/** Signed in with a store account RedeemID has synced. */
|
|
29
|
+
linked: boolean;
|
|
30
|
+
levels: string[];
|
|
31
|
+
features: string[];
|
|
32
|
+
cart: { count: number };
|
|
33
|
+
meter: { used: number; limit: number; period: "month" | "week" } | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const GET: APIRoute = async ({ locals, session }) => {
|
|
37
|
+
const config = await siteConfig(locals);
|
|
38
|
+
const db = (locals as { emdash?: { db?: unknown } }).emdash?.db;
|
|
39
|
+
const user = (await session?.get("user")) as { id?: string } | undefined;
|
|
40
|
+
const ref = await readCart(session);
|
|
41
|
+
const state: State = {
|
|
42
|
+
apiVersion: API_VERSION,
|
|
43
|
+
version: VERSION,
|
|
44
|
+
signedIn: Boolean(user?.id),
|
|
45
|
+
linked: false,
|
|
46
|
+
levels: [],
|
|
47
|
+
features: [],
|
|
48
|
+
cart: { count: ref?.count ?? 0 },
|
|
49
|
+
meter: null,
|
|
50
|
+
};
|
|
51
|
+
if (user?.id && db && config) {
|
|
52
|
+
const s = stores(db);
|
|
53
|
+
const member = await memberForUser(s, user.id).catch(() => null);
|
|
54
|
+
if (member) {
|
|
55
|
+
state.linked = true;
|
|
56
|
+
if (member.status !== "disabled") {
|
|
57
|
+
state.levels = levelsFor(member.tags, config.levels);
|
|
58
|
+
state.features = featuresFor(member, config.features, config.levels);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const meter = config.meter;
|
|
62
|
+
if (meter && meter.limit > 0) {
|
|
63
|
+
const period = meter.period ?? "month";
|
|
64
|
+
const record = await s.meter.get(`${user.id}:${periodKey(new Date(), period)}`).catch(() => null);
|
|
65
|
+
state.meter = { used: Math.min(record?.items.length ?? 0, meter.limit), limit: meter.limit, period };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return json(200, state);
|
|
69
|
+
};
|
package/src/routes/buy.ts
CHANGED
|
@@ -14,7 +14,8 @@ import { readConfig, storeDomain } from "../config.js";
|
|
|
14
14
|
import { safeReturn } from "../access.js";
|
|
15
15
|
import { memberForUser, stores } from "../storage.js";
|
|
16
16
|
import { saveCart, readCart } from "../cart.js";
|
|
17
|
-
import {
|
|
17
|
+
import { checkoutFor } from "../checkout.js";
|
|
18
|
+
import { addToCart, plan, storeContext } from "../storefront.js";
|
|
18
19
|
import { fail } from "./shared.js";
|
|
19
20
|
|
|
20
21
|
export const prerender = false;
|
|
@@ -39,10 +40,10 @@ export const POST: APIRoute = async ({ request, locals, session, redirect }) =>
|
|
|
39
40
|
config,
|
|
40
41
|
current?.id ?? null,
|
|
41
42
|
{ variantId: p.variantId, sellingPlanId: p.sellingPlanId },
|
|
42
|
-
{ email: member?.email ?? null, attributes: { _redeemid_page: returnTo }, buyerIp: request.headers.get("cf-connecting-ip") },
|
|
43
|
+
{ email: member?.email ?? null, attributes: { _redeemid_page: returnTo }, buyerIp: request.headers.get("cf-connecting-ip"), context: storeContext(config, request) },
|
|
43
44
|
);
|
|
44
45
|
saveCart(session, { id: cart.id, count: cart.totalQuantity });
|
|
45
|
-
return redirect(cart
|
|
46
|
+
return redirect(await checkoutFor(config, cart, { session, request, emdashConfig: emdash?.config }), 303);
|
|
46
47
|
} catch {
|
|
47
48
|
return fail(redirect, returnTo, "checkout_failed");
|
|
48
49
|
}
|
package/src/routes/callback.ts
CHANGED
|
@@ -23,7 +23,7 @@ import { discover, exchangeCode, identity, logoutUrl, ShopifyError, verifyIdToke
|
|
|
23
23
|
import { emptyMember, stampTags, stores } from "../storage.js";
|
|
24
24
|
import { getPublicOrigin, OptionsRepository } from "emdash/api/route-utils";
|
|
25
25
|
|
|
26
|
-
import { afterLogoutCookie, callbackUrl, fail, FLOW_TTL_MS, SESSION_FLOW, SESSION_ID_TOKEN, type Flow } from "./shared.js";
|
|
26
|
+
import { afterLogoutCookie, callbackUrl, fail, FLOW_TTL_MS, SESSION_FLOW, SESSION_CUSTOMER_TOKEN, SESSION_ID_TOKEN, type CustomerToken, type Flow } from "./shared.js";
|
|
27
27
|
|
|
28
28
|
export const prerender = false;
|
|
29
29
|
|
|
@@ -48,7 +48,8 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
|
|
|
48
48
|
const state = url.searchParams.get("state");
|
|
49
49
|
const code = url.searchParams.get("code");
|
|
50
50
|
if (!flow || !state || state !== flow.state || !code || Date.now() - flow.at > FLOW_TTL_MS) {
|
|
51
|
-
|
|
51
|
+
// A request with no sign-in behind it (a crawler, a probe) isn't worth an entry.
|
|
52
|
+
if (flow) await refused("sign_in_expired", "warn");
|
|
52
53
|
return fail(redirect, returnTo, "sign_in_expired");
|
|
53
54
|
}
|
|
54
55
|
|
|
@@ -131,6 +132,7 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
|
|
|
131
132
|
session.regenerate?.();
|
|
132
133
|
session.set("user", { id: user.id });
|
|
133
134
|
session.set(SESSION_ID_TOKEN, tokens.idToken);
|
|
135
|
+
session.set(SESSION_CUSTOMER_TOKEN, { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken ?? null, expiresAt: tokens.expiresAt ?? null } satisfies CustomerToken);
|
|
134
136
|
return flow.refresh ? fail(redirect, returnTo, "refreshed") : redirect(returnTo);
|
|
135
137
|
} catch (e) {
|
|
136
138
|
const code = e instanceof ShopifyError ? e.code : e instanceof OAuthError ? "signup_not_allowed" : "sign_in_failed";
|