@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 +15 -2
- package/docs/developer-api.md +3 -2
- package/package.json +2 -1
- package/src/agents.ts +1 -1
- package/src/api-version.ts +1 -1
- package/src/blocks-names.ts +3 -1
- package/src/components/LockedPanel.astro +4 -5
- package/src/components/Plan.astro +4 -5
- package/src/contract.ts +1 -0
- package/src/events.ts +1 -1
- package/src/files.ts +9 -5
- package/src/guard.ts +15 -0
- package/src/routes/buy.ts +3 -2
- package/src/routes/callback.ts +2 -1
- package/src/search-guard.ts +100 -0
- package/src/storefront.ts +8 -4
- package/src/version.ts +1 -1
- package/src/webhooks.ts +3 -0
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.
|
|
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.
|
package/docs/developer-api.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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)))),
|
package/src/api-version.ts
CHANGED
package/src/blocks-names.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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 ===
|
|
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
|
|
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
|
|
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);
|
package/src/routes/callback.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
132
|
-
|
|
133
|
-
|
|
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.
|
|
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
|
|