@redeemid/emdash 0.0.21 → 0.0.22

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 CHANGED
@@ -7,7 +7,7 @@ Memberships and paywalls for [EmDash](https://github.com/emdash-cms/emdash) site
7
7
 
8
8
  Readers sign in with the store's own customer account. Their Shopify customer tags decide what they can read. Shopify keeps the store, checkout, orders and subscriptions, and EmDash keeps the content. Nothing on the site grants access by itself, so there is one source of truth for who paid for what.
9
9
 
10
- **Status: 0.0.21, early.** Runs on Node and Cloudflare Workers. Unit tests and an end-to-end test that boots a real EmDash site against a fake Shopify store cover the features below. Expect changes before 1.0.
10
+ **Status: 0.0.22, early.** Runs on Node and Cloudflare Workers. Unit tests and an end-to-end test that boots a real EmDash site against a fake Shopify store cover the features below. Expect changes before 1.0.
11
11
 
12
12
  ## Contents
13
13
 
@@ -214,6 +214,18 @@ const needs = await entryRequirement(config, "posts", post.data.terms, post.id);
214
214
  // e.g. [["gold", "pro"]]: readers pass every clause, each with any one of its levels; null when open
215
215
  ```
216
216
 
217
+ **Site search.** EmDash's search API (`/_emdash/api/search`, which its live search box uses) is public and answers with a snippet of the matching text. `redeemidGuard()` checks each result for visitors and members: an entry the reader can't read in full keeps its title but loses its snippet, and stays in the results only when its title holds every searched word, so a search can't show locked text or tell which locked entry contains a word. Staff get every result. A search page that calls EmDash's `search()` in its template filters the same way with `readableResults()`:
218
+
219
+ ```astro
220
+ ---
221
+ import { search } from "emdash";
222
+ import { readableResults } from "@redeemid/emdash/search";
223
+
224
+ const q = Astro.url.searchParams.get("q") ?? "";
225
+ const results = q ? await readableResults(Astro, q, (await search(q)).items) : [];
226
+ ---
227
+ ```
228
+
217
229
  `siteConfig(Astro.locals)` is the config for this request: your code's config with the Access page's saved settings laid over it. Pass it to the functions that take a `config`, such as `entryRequirement()`, `readPreferences()` and the storefront and catalog calls.
218
230
 
219
231
  ## Guides
@@ -619,6 +631,7 @@ Import each from `@redeemid/emdash/components/<Name>.astro`.
619
631
  | `@redeemid/emdash/catalog` | `searchProducts()`, `collectionPage()`, `suggest()`, `recommendations()` |
620
632
  | `@redeemid/emdash/events` | `signBody(body, secret)`, the signature a receiver checks against `X-RedeemID-Signature` |
621
633
  | `@redeemid/emdash/text` | `STRINGS`, the list of every replaceable string |
634
+ | `@redeemid/emdash/search` | `readableResults()`, for a search page that calls EmDash's `search()` |
622
635
  | `@redeemid/emdash/contract` | The stable API as a list, and `API_VERSION` |
623
636
 
624
637
  The full developer API (stable exports, JSON routes, the browser client and its events, MCP tools) is in [docs/developer-api.md](docs/developer-api.md).
@@ -649,7 +662,7 @@ Reword one with `messages={{ cart_empty: "…" }}`, or for every page with `text
649
662
  - Shopify is the only source of entitlements. Nothing on the site grants access.
650
663
  - Levels read the mirrored tags as they are, however old. They change only when Shopify sends new ones.
651
664
  - Enabled or disabled status comes only from the `customers/enable` and `customers/disable` webhooks.
652
- - Locked content never reaches the content API, search, feeds or the sitemap for readers kept out, nor a page that renders bodies with `<Content>` (the Status page flags templates that use `<PortableText>` for a gated collection). The end-to-end test checks each of these. Members are refused EmDash's own APIs except what they need.
665
+ - Locked content never reaches the content API, site search (snippets, or which entry matched), feeds or the sitemap for readers kept out, nor a page that renders bodies with `<Content>` (the Status page flags templates that use `<PortableText>` for a gated collection). The end-to-end test checks each of these. Members are refused EmDash's own APIs except what they need.
653
666
  - Staff accounts (any EmDash role above Subscriber) can't sign in through the store, even with a matching email. When that happens, the store session ends too, so the next sign-in attempt starts fresh.
654
667
  - Customers can't create the first (admin) account on a new site.
655
668
  - Webhooks are checked against the app's signature before anything changes.
@@ -1,6 +1,6 @@
1
1
  # Developer API
2
2
 
3
- What site code, other plugins and AI agents can rely on in `@redeemid/emdash`. API version **1.1.0** (`API_VERSION` in `@redeemid/emdash/contract`, also in `api/state` and `window.redeemid.apiVersion`).
3
+ What site code, other plugins and AI agents can rely on in `@redeemid/emdash`. API version **1.2.0** (`API_VERSION` in `@redeemid/emdash/contract`, also in `api/state` and `window.redeemid.apiVersion`).
4
4
 
5
5
  Everything on this page is stable: it changes only with a changelog entry and an API version bump (minor when something is added, major when something changes or goes). Anything not listed is internal, even when a module exports it. `src/contract.ts` is the same list as code, and a test fails when the code, that list and this page disagree.
6
6
 
@@ -24,6 +24,7 @@ Import these in Astro pages, components and endpoints.
24
24
  | `@redeemid/emdash/catalog` | `searchProducts`, `collectionPage`, `suggest`, `recommendations`, `browseFromUrl`, `browseHref`, `parseFilters`, `priceFilter`, `SORTS` |
25
25
  | `@redeemid/emdash/events` | `signBody` |
26
26
  | `@redeemid/emdash/text` | `STRINGS`, `text`, `textParts` |
27
+ | `@redeemid/emdash/search` | `readableResults` |
27
28
 
28
29
  Functions that take a `config` want the site's config for the request: `await siteConfig(Astro.locals)`, which is the code's config with the admin's saved settings laid over it. Passing the object from `astro.config.mjs` instead skips what staff saved on the Access page. A few examples:
29
30
 
@@ -50,7 +51,7 @@ Under `/_emdash/api/auth/redeemid/`. Same-origin only: EmDash checks `Origin` on
50
51
  - `api/state` (GET): the visitor's own state.
51
52
 
52
53
  ```json
53
- { "apiVersion": "1.1.0", "version": "0.0.21", "signedIn": true, "linked": true,
54
+ { "apiVersion": "1.2.0", "version": "0.0.22", "signedIn": true, "linked": true,
54
55
  "levels": ["gold"], "features": ["ad-free"], "cart": { "count": 2 },
55
56
  "meter": { "used": 1, "limit": 3, "period": "month" } }
56
57
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redeemid/emdash",
3
- "version": "0.0.21",
3
+ "version": "0.0.22",
4
4
  "description": "Shopify customer accounts, member levels and paywalls for EmDash sites. Shopify runs commerce; EmDash runs content.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -13,6 +13,7 @@
13
13
  "exports": {
14
14
  ".": "./src/index.ts",
15
15
  "./access": "./src/access.ts",
16
+ "./search": "./src/search-guard.ts",
16
17
  "./guard.ts": "./src/guard.ts",
17
18
  "./rate-limit-cloudflare.ts": "./src/rate-limit-cloudflare.ts",
18
19
  "./templates.ts": "./src/templates.ts",
package/src/agents.ts CHANGED
@@ -72,7 +72,7 @@ export async function explainAccess(config: RedeemIDConfig, s: Stores, input: Ex
72
72
  const required: Requirement = applyEntryRule(requirement(config.rules, collection, terms, { combine: config.combine, tree }), own);
73
73
  const owned = !disabled && holdsItem(item, tags);
74
74
  const canReadRule = owned || required === null || (signedIn && !disabled && passes(held, required));
75
- const name = (slug: string) => levels.find((l) => l.slug === slug)?.name ?? slug;
75
+ const name = (slug: string) => (slug === "signed-in" ? "Signed in" : (levels.find((l) => l.slug === slug)?.name ?? slug));
76
76
  const partResults = parts.map((clause) => ({
77
77
  needs: clause.map(name),
78
78
  canRead: owned || (signedIn && !disabled && (clause.includes("signed-in") || clause.some((l) => held.includes(l)))),
@@ -3,4 +3,4 @@
3
3
  * when something is added, the major part when something stable changes or
4
4
  * goes. Separate from the package version.
5
5
  */
6
- export const API_VERSION = "1.1.0";
6
+ export const API_VERSION = "1.2.0";
@@ -62,7 +62,8 @@ type TextNode = { _type?: unknown; children?: Array<{ text?: unknown }> };
62
62
  /**
63
63
  * The text blocks within the first `words` words of a body:
64
64
  * a block is never cut in half, so a block that would pass the count ends the
65
- * teaser. Images and custom blocks are left out. 0 gives nothing.
65
+ * teaser. Images and custom blocks are left out. A "Members only" part ends
66
+ * it too: its text is never teaser. 0 gives nothing.
66
67
  */
67
68
  export function teaser<T>(value: T[] | null | undefined, words: number): T[] {
68
69
  if (!Array.isArray(value) || !(words > 0)) return [];
@@ -70,6 +71,7 @@ export function teaser<T>(value: T[] | null | undefined, words: number): T[] {
70
71
  let count = 0;
71
72
  for (const b of value) {
72
73
  const node = b as TextNode;
74
+ if (node?._type === MEMBERS_BLOCK) break;
73
75
  if (node?._type !== "block") continue;
74
76
  const text = (node.children ?? []).map((c) => (typeof c.text === "string" ? c.text : "")).join("");
75
77
  count += text.split(/\s+/).filter(Boolean).length;
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { membership, passes, refreshUrl, signInUrl, viewer, type Reason } from "../access.js";
12
12
  import type { SoldItem } from "../blocks-names.js";
13
- import { plan as loadPlan } from "../storefront.js";
13
+ import { money, plan as loadPlan, storeContext } from "../storefront.js";
14
14
  import { siteConfig } from "../site-config.js";
15
15
  import { text, textParts } from "../text.js";
16
16
 
@@ -48,10 +48,9 @@ const week = meterPeriod === "week";
48
48
  let single: { handle: string; price: string } | null = null;
49
49
  if (item?.product && config && (reason === "sign_in" || reason === "upgrade" || reason === "drip")) {
50
50
  try {
51
- const p = await loadPlan(config, item.product);
52
- if (p?.availableForSale && p.price) {
53
- single = { handle: p.handle, price: new Intl.NumberFormat("en-US", { style: "currency", currency: p.price.currencyCode }).format(Number(p.price.amount)) };
54
- }
51
+ const p = await loadPlan(config, item.product, undefined, storeContext(config, Astro.request));
52
+ const price = p?.availableForSale ? money(p.price) : null;
53
+ if (p && price) single = { handle: p.handle, price };
55
54
  } catch {
56
55
  single = null;
57
56
  }
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { membership, viewer } from "../access.js";
14
14
  import { siteConfig } from "../site-config.js";
15
- import { plan as loadPlan, type Plan } from "../storefront.js";
15
+ import { money, plan as loadPlan, storeContext, type Plan } from "../storefront.js";
16
16
  import { text } from "../text.js";
17
17
 
18
18
  interface Props {
@@ -33,14 +33,13 @@ const { handle, label = text(config, "Join"), title, levels = [], heldLabel = te
33
33
  let p: Plan | null = null;
34
34
  if (config) {
35
35
  try {
36
- p = await loadPlan(config, handle);
36
+ // The visitor's market, so the price is the one checkout charges.
37
+ p = await loadPlan(config, handle, undefined, storeContext(config, Astro.request));
37
38
  } catch {
38
39
  p = null;
39
40
  }
40
41
  }
41
- const price = p?.price
42
- ? new Intl.NumberFormat("en-US", { style: "currency", currency: p.price.currencyCode }).format(Number(p.price.amount))
43
- : null;
42
+ const price = money(p?.price);
44
43
  const here = Astro.url.pathname + Astro.url.search;
45
44
  const v = levels.length > 0 ? await viewer(Astro) : null;
46
45
  const held = Boolean(v?.levels.some((l) => levels.includes(l)));
package/src/contract.ts CHANGED
@@ -23,6 +23,7 @@ export const MODULES = {
23
23
  "@redeemid/emdash/catalog": ["searchProducts", "collectionPage", "suggest", "recommendations", "browseFromUrl", "browseHref", "parseFilters", "priceFilter", "SORTS"],
24
24
  "@redeemid/emdash/events": ["signBody"],
25
25
  "@redeemid/emdash/text": ["STRINGS", "text", "textParts"],
26
+ "@redeemid/emdash/search": ["readableResults"],
26
27
  } as const;
27
28
 
28
29
  /** Public JSON routes under /_emdash/api/auth/redeemid/. */
package/src/events.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * Headers: X-RedeemID-Event (the name), X-RedeemID-Delivery (a fresh id) and
8
8
  * X-RedeemID-Signature: sha256=<hex HMAC of the raw body>. Events:
9
9
  * member.signed_in, member.levels_changed, meter.used, meter.exhausted,
10
- * preferences.saved.
10
+ * preferences.saved, member.erased.
11
11
  * Sending never blocks or breaks the page: events go out after the response
12
12
  * (waitUntil on Workers), are tried three times, and one that fails every
13
13
  * try is kept under App events → Failed events, where staff can send it
package/src/files.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  * match case-insensitively. Access still comes only from Shopify tags.
17
17
  */
18
18
 
19
- import { readEntry, viewer, type AstroLike, type Terms } from "./access.js";
19
+ import { readEntry, SIGNED_IN, viewer, type AstroLike, type Terms } from "./access.js";
20
20
  import { holdsItem, ITEM_BLOCK, PAYWALL_BLOCK, sections, soldItem, splitAtPaywall } from "./blocks-names.js";
21
21
  import type { FileRule, RedeemIDConfig } from "./config.js";
22
22
  import { featuresFor } from "./features.js";
@@ -78,18 +78,22 @@ async function entriesUsing(db: unknown, mediaId: string): Promise<Array<{ colle
78
78
  .filter((e) => !seen.has(`${e.collection}:${e.id}`) && Boolean(seen.add(`${e.collection}:${e.id}`)));
79
79
  }
80
80
 
81
- /** The levels picked on an entry body's breaks and members-only parts: readers must hold one from each. */
81
+ /**
82
+ * The levels picked on an entry body's breaks and members-only parts: readers
83
+ * must hold one from each. A members-only part with none picked needs a
84
+ * signed-in reader (on top of the entry's rule, which the caller checks).
85
+ */
82
86
  export function bodyClauses(body: unknown): string[][] {
83
87
  const blocks = Array.isArray(body) ? body : [];
84
88
  const out: string[][] = [];
85
89
  const split = splitAtPaywall(blocks);
86
90
  if (split.paywall && split.levels.length) out.push(split.levels);
87
- for (const s of sections(blocks)) if (s.members && s.levels.length) out.push(s.levels);
91
+ for (const s of sections(blocks)) if (s.members) out.push(s.levels.length ? s.levels : [SIGNED_IN]);
88
92
  return out;
89
93
  }
90
94
 
91
95
  /** May this reader read the whole entry: its rule, drip, and every part with levels picked? */
92
- async function readsWhole(astro: AstroLike, entry: { collection: string; id: string }): Promise<boolean> {
96
+ export async function readsWhole(astro: AstroLike, entry: { collection: string; id: string }): Promise<boolean> {
93
97
  const { getEmDashEntry } = await import("emdash");
94
98
  const result = (await getEmDashEntry(entry.collection, entry.id)) as { entry?: { data?: Record<string, unknown> } | null } | null;
95
99
  const data = result?.entry?.data;
@@ -104,7 +108,7 @@ async function readsWhole(astro: AstroLike, entry: { collection: string; id: str
104
108
  const verdict = await readEntry(astro, { collection: entry.collection, terms, ruleId: entry.id });
105
109
  if (!verdict.allowed) return false;
106
110
  const clauses = bodyClauses(body);
107
- return clauses.every((clause) => clause.some((l) => verdict.viewer.levels.includes(l) || (l === "signed-in" && verdict.viewer.signedIn)));
111
+ return clauses.every((clause) => clause.some((l) => verdict.viewer.levels.includes(l) || (l === SIGNED_IN && verdict.viewer.signedIn)));
108
112
  }
109
113
 
110
114
  export function ruleFor(config: RedeemIDConfig | null, folder: string | null): FileRule | null {
package/src/guard.ts CHANGED
@@ -24,6 +24,7 @@ import { defineMiddleware } from "astro:middleware";
24
24
  import { signInUrl } from "./access.js";
25
25
  import { readCart } from "./cart.js";
26
26
  import { keysFromUrl, pathAccess, refusal } from "./files.js";
27
+ import { guardSearch, SEARCH_PATH, SUGGEST_PATH } from "./search-guard.js";
27
28
  import { AFTER_LOGOUT_COOKIE, readAfterLogout } from "./routes/shared.js";
28
29
  import { shareConfig, siteConfig } from "./site-config.js";
29
30
 
@@ -38,8 +39,12 @@ export const MEMBER_PATHS = [
38
39
  "/_emdash/api/plugins/",
39
40
  ];
40
41
 
42
+ /** Exact paths members may use: site search (results filtered by the guard) and its title suggestions. */
43
+ export const MEMBER_EXACT = [SEARCH_PATH, SUGGEST_PATH];
44
+
41
45
  export function memberMayUse(path: string, extra: string[] = []): boolean {
42
46
  if (!path.startsWith("/_emdash")) return true;
47
+ if (MEMBER_EXACT.includes(path)) return true;
43
48
  return [...MEMBER_PATHS, ...extra].some((p) => path === p.replace(/\/$/, "") || path.startsWith(p));
44
49
  }
45
50
 
@@ -123,6 +128,16 @@ export const onRequest = defineMiddleware(async (context, next) => {
123
128
  return own ? keepPrivate(context, await next()) : next();
124
129
  }
125
130
 
131
+ // Site search answers visitors and members only with what they may read.
132
+ if (url.pathname === SEARCH_PATH && config && (!user || (user.role ?? 0) <= SUBSCRIBER)) {
133
+ try {
134
+ return await guardSearch(context, url, await next());
135
+ } catch (e) {
136
+ console.error("[redeemid] search check failed:", (e as Error).message);
137
+ return new Response(JSON.stringify({ error: { code: "SEARCH_ERROR", message: "Search failed" } }), { status: 500, headers: { "content-type": "application/json", "cache-control": "private, no-store" } });
138
+ }
139
+ }
140
+
126
141
  if (!user || user.role !== SUBSCRIBER) return next();
127
142
 
128
143
  if (memberMayUse(url.pathname, config?.memberPaths)) return next();
package/src/routes/buy.ts CHANGED
@@ -30,7 +30,8 @@ export const POST: APIRoute = async ({ request, locals, session, redirect }) =>
30
30
  if (!/^[a-z0-9][a-z0-9-]*$/i.test(handle)) return fail(redirect, returnTo, "plan_unavailable");
31
31
 
32
32
  try {
33
- const p = await plan(config, handle);
33
+ const context = storeContext(config, request);
34
+ const p = await plan(config, handle, undefined, context);
34
35
  if (!p?.variantId || !p.availableForSale) return fail(redirect, returnTo, "plan_unavailable");
35
36
  // Provider routes are public, so read the member from the session.
36
37
  const user = await sessionUser(session, emdash?.db);
@@ -40,7 +41,7 @@ export const POST: APIRoute = async ({ request, locals, session, redirect }) =>
40
41
  config,
41
42
  current?.id ?? null,
42
43
  { variantId: p.variantId, sellingPlanId: p.sellingPlanId },
43
- { email: member?.email ?? null, attributes: { _redeemid_page: returnTo }, buyerIp: request.headers.get("cf-connecting-ip"), context: storeContext(config, request) },
44
+ { email: member?.email ?? null, attributes: { _redeemid_page: returnTo }, buyerIp: request.headers.get("cf-connecting-ip"), context },
44
45
  );
45
46
  saveCart(session, { id: cart.id, count: cart.totalQuantity });
46
47
  return redirect(await checkoutFor(config, cart, { session, request, emdashConfig: emdash?.config }), 303);
@@ -139,7 +139,8 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
139
139
  await emit(config, "member.signed_in", { customerId: who.customerId, levels, first: !member.linkedAt, refresh: Boolean(flow.refresh) });
140
140
  await levelChange(config, found ? found.tags : null, { customerId: who.customerId, tags: who.tags, status: member.status }, "sign-in");
141
141
 
142
- session.regenerate?.();
142
+ // A new session id for the signed-in session (it keeps the data so far).
143
+ await session.regenerate?.();
143
144
  session.set("user", { id: user.id });
144
145
  session.set(SESSION_ID_TOKEN, tokens.idToken);
145
146
  // Kept only for signed-in checkout, and sealed (see token-vault.ts).
@@ -0,0 +1,100 @@
1
+ /**
2
+ * EmDash's site search (/_emdash/api/search) is public. It matches the
3
+ * collection's searchable fields, entry bodies included, and answers with a
4
+ * snippet of the text around the match. Without this, anyone could search for
5
+ * a word from a locked entry and read the text around it, or learn which
6
+ * locked entries contain it.
7
+ *
8
+ * For visitors and members (never staff), each result is checked like a
9
+ * protected file's entry: the reader must be able to read the whole entry
10
+ * (its rule, drip, and every part with levels picked). A result they can't
11
+ * read keeps its title, which lists show anyway, but loses its snippet, and
12
+ * it stays in the results only when its title matches every searched word.
13
+ */
14
+
15
+ import type { AstroLike } from "./access.js";
16
+
17
+ /** Loaded when used: the access checks read the site's build-time config. */
18
+ async function readsWhole(astro: AstroLike, entry: { collection: string; id: string }): Promise<boolean> {
19
+ return (await import("./files.js")).readsWhole(astro, entry);
20
+ }
21
+
22
+ export const SEARCH_PATH = "/_emdash/api/search";
23
+ /** Title suggestions only: nothing from bodies. */
24
+ export const SUGGEST_PATH = "/_emdash/api/search/suggest";
25
+ const SUBSCRIBER = 10;
26
+
27
+ interface Result {
28
+ collection?: unknown;
29
+ id?: unknown;
30
+ title?: unknown;
31
+ snippet?: unknown;
32
+ }
33
+
34
+ /** The searched words, lowercased. */
35
+ export function words(q: string): string[] {
36
+ return q.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
37
+ }
38
+
39
+ /** Does the title hold every searched word (as a word or the start of one)? */
40
+ export function titleMatches(title: unknown, q: string): boolean {
41
+ if (typeof title !== "string") return false;
42
+ const have = words(title);
43
+ const want = words(q);
44
+ return want.length > 0 && want.every((w) => have.some((h) => h.startsWith(w)));
45
+ }
46
+
47
+ /**
48
+ * Filter one search answer for this reader. `readable` decides one result;
49
+ * a failed check counts as unreadable.
50
+ */
51
+ export async function filterResults(
52
+ items: Result[],
53
+ q: string,
54
+ readable: (collection: string, id: string) => Promise<boolean>,
55
+ ): Promise<Result[]> {
56
+ const out: Result[] = [];
57
+ for (const item of items) {
58
+ const collection = typeof item.collection === "string" ? item.collection : "";
59
+ const id = typeof item.id === "string" ? item.id : "";
60
+ const ok = collection && id ? await readable(collection, id).catch(() => false) : false;
61
+ if (ok) {
62
+ out.push(item);
63
+ continue;
64
+ }
65
+ if (!titleMatches(item.title, q)) continue;
66
+ const { snippet: _gone, ...rest } = item;
67
+ out.push(rest);
68
+ }
69
+ return out;
70
+ }
71
+
72
+ /**
73
+ * For a site's own search page that calls EmDash's `search()` in its
74
+ * template: the results this reader may see, filtered like the API's.
75
+ *
76
+ * const results = await readableResults(Astro, q, (await search(q)).items);
77
+ */
78
+ export async function readableResults<T extends Result>(astro: AstroLike, q: string, items: T[]): Promise<T[]> {
79
+ const role = (astro.locals as { user?: { role?: number } | null }).user?.role ?? 0;
80
+ if (role > SUBSCRIBER) return items;
81
+ return (await filterResults(items, q, (collection, id) => readsWhole(astro, { collection, id }))) as T[];
82
+ }
83
+
84
+ /** The search answer with locked text taken out (see above). Other answers pass as they are. */
85
+ export async function guardSearch(astro: AstroLike, url: URL, res: Response): Promise<Response> {
86
+ if (!res.ok || !(res.headers.get("content-type") ?? "").includes("json")) return res;
87
+ let body: { data?: { items?: Result[] } };
88
+ try {
89
+ body = (await res.clone().json()) as typeof body;
90
+ } catch {
91
+ return res;
92
+ }
93
+ const items = body.data?.items;
94
+ if (!Array.isArray(items)) return res;
95
+ const q = url.searchParams.get("q") ?? "";
96
+ const filtered = await filterResults(items, q, (collection, id) => readsWhole(astro, { collection, id }));
97
+ const out = new Response(JSON.stringify({ ...body, data: { ...body.data, items: filtered } }), { status: res.status, headers: res.headers });
98
+ out.headers.delete("content-length");
99
+ return out;
100
+ }
package/src/storefront.ts CHANGED
@@ -128,13 +128,17 @@ interface PlanData {
128
128
  } | null;
129
129
  }
130
130
 
131
- /** A product by handle, cached for five minutes. Null when Shopify has no such product. */
132
- export async function plan(config: RedeemIDConfig, handle: string, fetcher?: typeof fetch): Promise<Plan | null> {
133
- const key = `${shopOrigin(config)}|${handle}`;
131
+ /**
132
+ * A product by handle, cached for five minutes. Null when Shopify has no such
133
+ * product. Pass `context` (storeContext) so the price is the visitor's
134
+ * market's, the one checkout will charge.
135
+ */
136
+ export async function plan(config: RedeemIDConfig, handle: string, fetcher?: typeof fetch, context?: StoreContext): Promise<Plan | null> {
137
+ const key = `${shopOrigin(config)}|${handle}|${contextKey(context)}`;
134
138
  const hit = planCache.get(key);
135
139
  if (hit && Date.now() - hit.at < PLAN_TTL_MS) return hit.value;
136
140
 
137
- const { product: p } = await storefront<PlanData>(config, PLAN_QUERY, { handle }, { fetcher });
141
+ const { product: p } = await storefront<PlanData>(config, PLAN_QUERY, { handle }, { fetcher, context });
138
142
  const variant = p?.variants.nodes[0] ?? null;
139
143
  const value: Plan | null = p
140
144
  ? {
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.0.21";
1
+ export const VERSION = "0.0.22";
package/src/webhooks.ts CHANGED
@@ -144,7 +144,10 @@ export async function apply(
144
144
  if (!id) return "skipped";
145
145
 
146
146
  if (kind === "delete") {
147
+ const gone = await s.members.get(id);
147
148
  await s.members.delete(id);
149
+ // The levels end with the customer: receivers of member.levels_changed hear it.
150
+ if (gone) await onWrite?.([...gone.tags], { ...gone, tags: [] });
148
151
  return "processed";
149
152
  }
150
153