@redeemid/emdash 0.0.20 → 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 +39 -19
- package/docs/developer-api.md +14 -10
- package/docs/shopify-flow.md +3 -3
- package/docs/shopify-setup.md +13 -7
- package/package.json +2 -1
- package/src/access.ts +3 -0
- 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 +2 -1
- 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
|
|
|
@@ -129,10 +129,10 @@ Set them as runtime secrets of the site (`.env` locally, `wrangler secret put` o
|
|
|
129
129
|
```sh
|
|
130
130
|
REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID=… # sign-in
|
|
131
131
|
REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_SECRET=… # verifies webhooks
|
|
132
|
-
REDEEMID_SHOPIFY_STOREFRONT_PRIVATE_TOKEN=… # plans, shop and checkout
|
|
132
|
+
REDEEMID_SHOPIFY_STOREFRONT_PRIVATE_TOKEN=… # only to sell on the site: plans, shop and checkout
|
|
133
133
|
```
|
|
134
134
|
|
|
135
|
-
The [secrets reference](#secrets) lists the
|
|
135
|
+
The [secrets reference](#secrets) lists the rest. The Setup page prints the command for each secret its steps need.
|
|
136
136
|
|
|
137
137
|
### 5. Render content through RedeemID
|
|
138
138
|
|
|
@@ -148,7 +148,7 @@ import Notices from "@redeemid/emdash/components/Notices.astro";
|
|
|
148
148
|
<Content value={post.data.content} collection="posts" terms={post.data.terms} id={post.id} title={post.data.title} />
|
|
149
149
|
```
|
|
150
150
|
|
|
151
|
-
Use `<Content>` in place of `<PortableText>` for every body that may be locked
|
|
151
|
+
Use `<Content>` in place of `<PortableText>` for every body that may be locked, because plain `<PortableText>` shows everything. You don't have to find them by hand: `redeemidGuard()` scans the site's `.astro` files at build time, and the Status page names each template, with its line, that renders a gated collection's body with `<PortableText>`. While any rule is on, it also lists templates whose collection it can't tell (such as a component given the body as a prop), so you can check them.
|
|
152
152
|
|
|
153
153
|
### 6. Check it
|
|
154
154
|
|
|
@@ -202,15 +202,31 @@ Pass `id` on the entry's own page. It lets the meter count the read, and a locke
|
|
|
202
202
|
**In your own code** (`@redeemid/emdash/access`):
|
|
203
203
|
|
|
204
204
|
```ts
|
|
205
|
-
import { canRead, entryRequirement, readEntry } from "@redeemid/emdash/access";
|
|
205
|
+
import { canRead, entryRequirement, readEntry, siteConfig } from "@redeemid/emdash/access";
|
|
206
206
|
|
|
207
|
-
const verdict = await canRead(Astro, { collection: "posts", terms: post.data.terms });
|
|
207
|
+
const verdict = await canRead(Astro, { collection: "posts", terms: post.data.terms, id: post.id });
|
|
208
208
|
// verdict.allowed; verdict.reason: open | allowed | sign_in | upgrade | disabled | unconfigured
|
|
209
209
|
const read = await readEntry(Astro, { collection: "posts", terms: post.data.terms, id: post.id });
|
|
210
210
|
// adds drip (read.opensAt) and the meter (read.meterLeft), and counts a metered read
|
|
211
|
+
|
|
212
|
+
const config = await siteConfig(Astro.locals);
|
|
213
|
+
const needs = await entryRequirement(config, "posts", post.data.terms, post.id);
|
|
214
|
+
// e.g. [["gold", "pro"]]: readers pass every clause, each with any one of its levels; null when open
|
|
211
215
|
```
|
|
212
216
|
|
|
213
|
-
|
|
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
|
+
|
|
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.
|
|
214
230
|
|
|
215
231
|
## Guides
|
|
216
232
|
|
|
@@ -251,10 +267,12 @@ Each saved entry also gets an **Access** panel in the editor. It shows who can r
|
|
|
251
267
|
| --- | --- |
|
|
252
268
|
| Follow the site rules | What the rules say (the default) |
|
|
253
269
|
| Free to everyone | Nothing |
|
|
254
|
-
| Only
|
|
255
|
-
|
|
|
270
|
+
| Only some levels | One of the levels ticked (none ticked: any signed-in member) |
|
|
271
|
+
| Site rules plus a level | What the rules say and one of the levels ticked |
|
|
272
|
+
|
|
273
|
+
The panel can also hold the entry back for some days of membership, keep the meter off it, lock it until a date and then open it, or keep it free until a date and then lock it. Pass the entry's `id` (or slug) to `<Content>`, `<AccessBadge>` and `<Gate>` so its own rule applies there.
|
|
256
274
|
|
|
257
|
-
Moving an entry to the trash keeps its rule for a restore but frees its slug, so a new entry that takes the slug doesn't inherit the rule.
|
|
275
|
+
Moving an entry to the trash keeps its rule for a restore but frees its slug, so a new entry that takes the slug doesn't inherit the rule.
|
|
258
276
|
|
|
259
277
|
### Meter and drip
|
|
260
278
|
|
|
@@ -359,7 +377,7 @@ Define each field in Shopify (**Settings → Custom data → Customers**); the d
|
|
|
359
377
|
| Decimal | A number, as a string |
|
|
360
378
|
| Date | `YYYY-MM-DD` |
|
|
361
379
|
|
|
362
|
-
Every value is checked against the definition first. One refusal saves nothing, and an empty value clears the field. Scripts can `GET` `/_emdash/api/auth/redeemid/preferences` for `{ fields }`, or `POST` JSON `{ values: { "namespace.key": value } }`. A refused save answers `400` with `fields` mapping each key to `unknown_field`, `read_only`, `invalid_type`, `invalid_choice`, `invalid_length` or `invalid_range`. Saves are limited to 10 a minute per member (`429`). `readPreferences()` and `savePreferences()` in `@redeemid/emdash/preferences` do the same from
|
|
380
|
+
Every value is checked against the definition first. One refusal saves nothing, and an empty value clears the field. Scripts can `GET` `/_emdash/api/auth/redeemid/preferences` for `{ fields }`, or `POST` JSON `{ values: { "namespace.key": value } }`. A refused save answers `400` with `fields` mapping each key to `unknown_field`, `read_only`, `invalid_type`, `invalid_choice`, `invalid_length` or `invalid_range`. Saves are limited to 10 a minute per member (`429`). From your own code, `readPreferences(config, customerId)` and `savePreferences(config, customerId, values)` in `@redeemid/emdash/preferences` do the same (no rate limit there; `config` comes from `siteConfig()`).
|
|
363
381
|
|
|
364
382
|
### Protected files
|
|
365
383
|
|
|
@@ -425,7 +443,7 @@ RedeemID reads them with the Admin API (resync, sign-in, tag webhooks), so they
|
|
|
425
443
|
Members gain and lose access only when Shopify sends tag changes, so RedeemID shows what arrived and warns when deliveries stop.
|
|
426
444
|
|
|
427
445
|
- **Webhooks → Received.** Each delivery's topic, what became of it (processed, skipped, older than what's stored, not used) and the customer ID, with counts of deliveries turned away (bad signature) and failed (storage errors; Shopify sends those again).
|
|
428
|
-
- **Webhooks → Health.** Once a day RedeemID registers again any subscription Shopify dropped (with the Admin API app), and warns when no webhook has come in for a number of days (3 by default, 0 turns it off), or when deliveries were turned away or failed in the last day. **Check now** runs it at once. Warnings show on the page and the dashboard widget
|
|
446
|
+
- **Webhooks → Health.** Once a day RedeemID registers again any subscription Shopify dropped (with the Admin API app), and warns when no webhook has come in for a number of days (3 by default, 0 turns it off), or when deliveries were turned away or failed in the last day. **Check now** runs it at once. Warnings show on the page and the dashboard widget. If you enter an address on the Health tab and the site has an EmDash email provider, they also go by email: once when problems start or change, then once a week while they last.
|
|
429
447
|
- **Webhooks → Setup.** The endpoint, whether signatures can be checked, and the subscriptions.
|
|
430
448
|
- **App events.** What RedeemID did and what came of it: webhooks received, turned away and failed, sign-ins and refusals (by reason), Admin API calls (by operation, with timings), resyncs, tag edits, webhook registrations, settings saves, outbound events that failed, metered reads and the health checks. Filter by event or show problems only. Entries hold ids, labels and outcomes, never bodies, tokens or emails. Failed outbound events also keep the event body (ids and levels, never an email or a name) so they can be sent again.
|
|
431
449
|
- **Reports.** Members on each level, who joined and left in the last 30 days and by week for 12 weeks (from each member's tag dates), sign-ins and metered reads.
|
|
@@ -459,7 +477,7 @@ npx -p @redeemid/emdash redeemid-emdash access posts --id my-story --tags gold-m
|
|
|
459
477
|
npx -p @redeemid/emdash redeemid-emdash member jane@example.com
|
|
460
478
|
```
|
|
461
479
|
|
|
462
|
-
Each command
|
|
480
|
+
Each command POSTs JSON to a staff-only route under `/_emdash/api/plugins/redeemid/`, named after its tool with hyphens (`explain_access` is `explain-access`). Scripts can call those routes directly.
|
|
463
481
|
|
|
464
482
|
### Replacement text
|
|
465
483
|
|
|
@@ -508,7 +526,7 @@ On Cloudflare Workers, `redeemidGuard({ rateLimit: true })` limits how often one
|
|
|
508
526
|
"ratelimits": [{ "name": "REDEEMID_RATE_LIMIT", "namespace_id": "1001", "simple": { "limit": 20, "period": 60 } }]
|
|
509
527
|
```
|
|
510
528
|
|
|
511
|
-
Each action counts separately per visitor (by `CF-Connecting-IP`), and Cloudflare's count is approximate. A visitor over the limit goes back to their page with the `too_many_requests` notice
|
|
529
|
+
Each action counts separately per visitor (by `CF-Connecting-IP`), and Cloudflare's count is approximate. A visitor over the limit goes back to their page with the `too_many_requests` notice; the JSON routes answer `429` with `{ "error": "too_many_requests" }`. If the binding is missing or fails, nothing is limited. Off by default; leave it off on Node.
|
|
512
530
|
|
|
513
531
|
## Reference
|
|
514
532
|
|
|
@@ -548,8 +566,8 @@ Pass one object to both `redeemid(config)` and `redeemidPlugin(config)`. `levels
|
|
|
548
566
|
| `REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID` | Sign-in | Client ID of the Headless storefront's Customer Account API client |
|
|
549
567
|
| `REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_SECRET` | Sign-in, confidential clients | Its client secret. Leave unset for a public client. |
|
|
550
568
|
| `REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_SECRET` | Webhooks | Client secret of the app that sends the webhooks. Shopify signs each webhook with it. |
|
|
551
|
-
| `REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID` |
|
|
552
|
-
| `REDEEMID_SHOPIFY_STOREFRONT_PRIVATE_TOKEN` | Plans, shop, checkout | Storefront API private access token, used server-side |
|
|
569
|
+
| `REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID` | Webhook registration, staff tools, preferences, membership dates, Shopify erasure requests | Client ID of that same app. Optional, but most of the admin needs it. |
|
|
570
|
+
| `REDEEMID_SHOPIFY_STOREFRONT_PRIVATE_TOKEN` | Plans, shop, checkout | Storefront API private access token, used server-side. Optional if the site sells nothing. |
|
|
553
571
|
| `REDEEMID_SHOPIFY_STOREFRONT_PUBLIC_TOKEN` | Plans, shop, checkout | Public access token, only used without the private one |
|
|
554
572
|
| `REDEEMID_SHOPIFY_STORE_DOMAIN` | Discovery | The store's domain, only when `shop` isn't in the config |
|
|
555
573
|
| `REDEEMID_EVENTS_SECRET` | Events | Signs outbound events. Set it with `events.url`. |
|
|
@@ -578,6 +596,7 @@ Import each from `@redeemid/emdash/components/<Name>.astro`.
|
|
|
578
596
|
| `ProductSearch` | `productHref`, `limit`, `boxOnly`, `action`, `label`, `quickAdd` | Product search |
|
|
579
597
|
| `Recommendations` | `handle`, `intent`, `limit`, `productHref`, `title`, `quickAdd` | Shopify's recommendations |
|
|
580
598
|
| `ProductGrid` | `products`, `productHref`, `heading`, `quickAdd` | Product cards, for your own lists |
|
|
599
|
+
| `BrowseControls` | `filters`, `pageInfo`, `id`, `search`, `query` | Sort, filter and page controls, for your own lists from `collectionPage()` or `searchProducts()` |
|
|
581
600
|
| `Cart` | `label`, `href`, `drawer` | The cart count, linking to checkout or opening the drawer |
|
|
582
601
|
| `CartView` | `productHref`, `empty` | A cart page |
|
|
583
602
|
| `CartDrawer` | `title` | The cart in a slide-in dialog |
|
|
@@ -605,13 +624,14 @@ Import each from `@redeemid/emdash/components/<Name>.astro`.
|
|
|
605
624
|
| Import | Gives |
|
|
606
625
|
| --- | --- |
|
|
607
626
|
| `@redeemid/emdash` | `redeemid()`, `redeemidPlugin()`, `redeemidGuard()`, config types |
|
|
608
|
-
| `@redeemid/emdash/access` | `canRead()`, `readEntry()`, `entryRequirement()`, `viewer()`, and links: `signInUrl()`, `signOutUrl()`, `accountUrl()`, `cartUrl()`, `refreshUrl()` |
|
|
627
|
+
| `@redeemid/emdash/access` | `siteConfig()`, `canRead()`, `readEntry()`, `entryRequirement()`, `viewer()`, and links: `signInUrl()`, `signOutUrl()`, `accountUrl()`, `cartUrl()`, `refreshUrl()` |
|
|
609
628
|
| `@redeemid/emdash/features` | `hasFeature()`, `viewerFeatures()` |
|
|
610
629
|
| `@redeemid/emdash/preferences` | `readPreferences()`, `savePreferences()` |
|
|
611
630
|
| `@redeemid/emdash/storefront` | `product()`, `collection()`, `plan()`, `money()` and the cart calls |
|
|
612
631
|
| `@redeemid/emdash/catalog` | `searchProducts()`, `collectionPage()`, `suggest()`, `recommendations()` |
|
|
613
632
|
| `@redeemid/emdash/events` | `signBody(body, secret)`, the signature a receiver checks against `X-RedeemID-Signature` |
|
|
614
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()` |
|
|
615
635
|
| `@redeemid/emdash/contract` | The stable API as a list, and `API_VERSION` |
|
|
616
636
|
|
|
617
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).
|
|
@@ -620,7 +640,7 @@ The full developer API (stable exports, JSON routes, the browser client and its
|
|
|
620
640
|
|
|
621
641
|
Under `/_emdash/api/auth/redeemid/`: `login`, `callback`, `logout`, `webhook`, `buy`, `cart`, `cart-edit`, `account`, `preferences` and `privacy`, and the JSON routes `api/state`, `api/cart` and `api/search`. Staff-only routes for the admin, agents and the command line are under `/_emdash/api/plugins/redeemid/`.
|
|
622
642
|
|
|
623
|
-
In the Customer Account API client, set the callback URI to `https://<site>/_emdash/api/auth/redeemid/callback`, the logout URI to `https://<site>/`, and (public clients) the JavaScript origin to `https://<site>`. Point webhooks at `https://<site>/_emdash/api/auth/redeemid/webhook`: `customers/create`, `customers/update`, `customers/delete`, `customers/disable`, `customers/enable`, `customers/merge`, `customer.tags_added
|
|
643
|
+
In the Customer Account API client, set the callback URI to `https://<site>/_emdash/api/auth/redeemid/callback`, the logout URI to `https://<site>/`, and (public clients) the JavaScript origin to `https://<site>`. Point webhooks at `https://<site>/_emdash/api/auth/redeemid/webhook`: `customers/create`, `customers/update`, `customers/delete`, `customers/disable`, `customers/enable`, `customers/merge`, `customer.tags_added` and `customer.tags_removed` (the Setup page registers these for you). The compliance topics `customers/data_request`, `customers/redact` and `shop/redact` go to the same URL, set in the app's own configuration.
|
|
624
644
|
|
|
625
645
|
### Notices
|
|
626
646
|
|
|
@@ -642,7 +662,7 @@ Reword one with `messages={{ cart_empty: "…" }}`, or for every page with `text
|
|
|
642
662
|
- Shopify is the only source of entitlements. Nothing on the site grants access.
|
|
643
663
|
- Levels read the mirrored tags as they are, however old. They change only when Shopify sends new ones.
|
|
644
664
|
- Enabled or disabled status comes only from the `customers/enable` and `customers/disable` webhooks.
|
|
645
|
-
- 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.
|
|
646
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.
|
|
647
667
|
- Customers can't create the first (admin) account on a new site.
|
|
648
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
|
|
|
@@ -17,24 +17,28 @@ Import these in Astro pages, components and endpoints.
|
|
|
17
17
|
| Import | Stable exports |
|
|
18
18
|
| --- | --- |
|
|
19
19
|
| `@redeemid/emdash` | `redeemid`, `redeemidGuard`, `redeemidPlugin` |
|
|
20
|
-
| `@redeemid/emdash/access` | `viewer`, `canRead`, `readEntry`, `entryRequirement`, `meterUsage`, `membership`, `levelsFor`, `signInUrl`, `signOutUrl`, `accountUrl`, `cartUrl`, `refreshUrl`, `safeReturn` |
|
|
20
|
+
| `@redeemid/emdash/access` | `siteConfig`, `viewer`, `canRead`, `readEntry`, `entryRequirement`, `meterUsage`, `membership`, `levelsFor`, `signInUrl`, `signOutUrl`, `accountUrl`, `cartUrl`, `refreshUrl`, `safeReturn` |
|
|
21
21
|
| `@redeemid/emdash/features` | `hasFeature`, `viewerFeatures`, `featuresFor` |
|
|
22
22
|
| `@redeemid/emdash/preferences` | `readPreferences`, `savePreferences`, `preferenceFields` |
|
|
23
23
|
| `@redeemid/emdash/storefront` | `plan`, `product`, `collection`, `money`, `realOptions`, `variantFor`, `storeContext`, `cartDetails`, `addToCart`, `updateLines`, `removeLines`, `setDiscountCodes` |
|
|
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
|
|
|
30
31
|
```astro
|
|
31
32
|
---
|
|
32
|
-
import { canRead,
|
|
33
|
+
import { canRead, siteConfig } from "@redeemid/emdash/access";
|
|
33
34
|
import { hasFeature } from "@redeemid/emdash/features";
|
|
34
35
|
import { searchProducts } from "@redeemid/emdash/catalog";
|
|
36
|
+
import { storeContext } from "@redeemid/emdash/storefront";
|
|
35
37
|
|
|
36
38
|
const verdict = await canRead(Astro, { collection: "posts", terms: post.data.terms, id: post.id });
|
|
37
39
|
const adFree = await hasFeature(Astro, "ad-free");
|
|
40
|
+
const config = await siteConfig(Astro.locals);
|
|
41
|
+
const totes = config && (await searchProducts(config, "tote", {}, { context: storeContext(config, Astro.request) }));
|
|
38
42
|
---
|
|
39
43
|
```
|
|
40
44
|
|
|
@@ -47,7 +51,7 @@ Under `/_emdash/api/auth/redeemid/`. Same-origin only: EmDash checks `Origin` on
|
|
|
47
51
|
- `api/state` (GET): the visitor's own state.
|
|
48
52
|
|
|
49
53
|
```json
|
|
50
|
-
{ "apiVersion": "1.
|
|
54
|
+
{ "apiVersion": "1.2.0", "version": "0.0.22", "signedIn": true, "linked": true,
|
|
51
55
|
"levels": ["gold"], "features": ["ad-free"], "cart": { "count": 2 },
|
|
52
56
|
"meter": { "used": 1, "limit": 3, "period": "month" } }
|
|
53
57
|
```
|
|
@@ -57,16 +61,16 @@ Under `/_emdash/api/auth/redeemid/`. Same-origin only: EmDash checks `Origin` on
|
|
|
57
61
|
- `update`: `line`, `quantity` (0 removes); `remove`: `line`; `discount`: `code` (`""` clears);
|
|
58
62
|
- `checkout`: answers `{ checkoutUrl }`, signed in for members.
|
|
59
63
|
|
|
60
|
-
Other actions answer `{ notice, cart, added? }`. An add Shopify can't fill answers 422 with `notice: "plan_unavailable"`. Over the rate limit (with `redeemidGuard({ rateLimit: true })`) POST answers 429
|
|
64
|
+
Other actions answer `{ notice, cart, added? }`. An add Shopify can't fill answers 422 with `notice: "plan_unavailable"`. Over the rate limit (with `redeemidGuard({ rateLimit: true })`) POST answers 429 with `{ "error": "too_many_requests" }`.
|
|
61
65
|
- `api/search` (GET): public catalog data, `Cache-Control: public, max-age=60` (private when `market.country` is `"visitor"`).
|
|
62
66
|
- `?q=tote&suggest=1[&limit=6]`: `{ queries, products, collections }`, for search-as-you-type;
|
|
63
67
|
- `?q=tote`: `{ products, filters, pageInfo, total }`;
|
|
64
68
|
- `?collection=shop`: the collection's page, `{ handle, title, description, products, filters, pageInfo }`.
|
|
65
69
|
|
|
66
|
-
Both take `first` (1 to 50), `sort`, `after` or `before` (cursors from `pageInfo`), `filter` (repeatable: a filter value's `input` exactly as Shopify gave it), `price_min` and `price_max`. Sorts are `relevance`, `best-selling`, `newest`, `price-asc`, `price-desc`, `title-asc`, `title-desc` (search: relevance and price only).
|
|
70
|
+
Both take `first` (1 to 50), `sort`, `after` or `before` (cursors from `pageInfo`), `filter` (repeatable: a filter value's `input` exactly as Shopify gave it), `price_min` and `price_max`. Sorts are `relevance`, `best-selling`, `newest`, `price-asc`, `price-desc`, `title-asc`, `title-desc` (search: relevance and price only). Over the rate limit a search answers 429, as `api/cart` does.
|
|
67
71
|
- `preferences` (GET, POST) and `privacy` (GET export, POST erase): see the README.
|
|
68
72
|
|
|
69
|
-
Form routes, which redirect back with `?redeemid_notice=<code
|
|
73
|
+
Form routes, which redirect back with `?redeemid_notice=<code>` when something needs saying: `login`, `callback`, `logout`, `buy`, `cart`, `cart-edit`, `account`. `webhook` is for Shopify only and answers with a status code.
|
|
70
74
|
|
|
71
75
|
## Browser client
|
|
72
76
|
|
|
@@ -114,13 +118,13 @@ Signed outbound webhooks to config `events.url` (see the README): `member.signed
|
|
|
114
118
|
|
|
115
119
|
## MCP and the command line
|
|
116
120
|
|
|
117
|
-
RedeemID's tools join EmDash's MCP server once staff turn them on for RedeemID under Plugins. They need
|
|
121
|
+
RedeemID's tools join EmDash's MCP server once staff turn them on for RedeemID under Plugins. They need an administrator's API token (the routes ask for `users:read`, which only Admins hold) with the `mcp:tools` or `mcp:tools:redeemid` scope. Always offered, read-only:
|
|
118
122
|
|
|
119
123
|
- `explain_access`, `list_levels`, `get_rules`, `get_member`, `site_health`, `level_changes`
|
|
120
124
|
- `find_products`, `get_product`, `get_collection`: the Shopify catalog the site sells, so an agent writing content can find the right product and collection handles.
|
|
121
125
|
|
|
122
126
|
Only with config `agents: { actions: true }`: `resync_member`, `resync_batch`, `edit_member_tags`.
|
|
123
127
|
|
|
124
|
-
The `redeemid-emdash` command line calls the same routes (`npx redeemid-emdash --help`).
|
|
128
|
+
The `redeemid-emdash` command line calls the same routes (`npx -p @redeemid/emdash redeemid-emdash --help`).
|
|
125
129
|
|
|
126
130
|
For shoppers' own agents (browsing products, building a cart, checking out), point them at Shopify's Storefront MCP server for the store, `https://<store domain>/api/mcp`. It runs on Shopify, where checkout and prices already live, so RedeemID doesn't repeat it.
|
package/docs/shopify-flow.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
RedeemID never grants access itself. A member's levels come from tags on their Shopify customer, and something in Shopify has to put those tags on and take them off. That is usually [Shopify Flow](https://help.shopify.com/manual/shopify-flow), which comes free with every Shopify plan, or a subscription app.
|
|
4
4
|
|
|
5
|
-
When a tag changes, Shopify sends `
|
|
5
|
+
When a tag changes, Shopify sends a `customer.tags_added` or `customer.tags_removed` webhook. With the Admin API app's client ID set, RedeemID reads the customer's tags as they are now; without it, it applies the change the webhook carries. Access follows within seconds, and nobody has to sign in again.
|
|
6
6
|
|
|
7
7
|
Each recipe below is one Flow workflow. In Flow, open **Create workflow**, pick the trigger, add the condition, then add the action. The trigger and action names here are the ones Flow shows in English. The field paths in conditions can read slightly differently in Flow's picker. Subscription triggers come from your subscription app, so their names depend on the app.
|
|
8
8
|
|
|
@@ -28,7 +28,7 @@ A partial refund of something else in the same order matches nothing, so the tag
|
|
|
28
28
|
|
|
29
29
|
## 3. A cancelled subscription removes it
|
|
30
30
|
|
|
31
|
-
Subscription apps that work with Flow add their own triggers, such as "Subscription contract cancelled" or "Subscription contract status updated".
|
|
31
|
+
Subscription apps that work with Flow add their own triggers, such as "Subscription contract cancelled" or "Subscription contract status updated". The names vary by app, so check your app's Flow listing.
|
|
32
32
|
|
|
33
33
|
- **Trigger:** your app's cancel trigger. If the app only offers a status-changed trigger, add the condition status is `CANCELLED`, and treat `EXPIRED` the same way.
|
|
34
34
|
- **Action:** Remove customer tags, with `member`.
|
|
@@ -63,7 +63,7 @@ After a purchase, **Refresh membership** on the member card reads their tags aga
|
|
|
63
63
|
|
|
64
64
|
## Check a recipe
|
|
65
65
|
|
|
66
|
-
1. Under **Plugins → RedeemID → Webhooks**, make sure `
|
|
66
|
+
1. Under **Plugins → RedeemID → Webhooks → Setup**, make sure `customer.tags_added` and `customer.tags_removed` show as Registered. The Setup page registers them for you.
|
|
67
67
|
2. Buy the product with a test customer, using Shopify's Bogus Gateway or a 100% discount code.
|
|
68
68
|
3. In Flow, open the workflow's **Recent runs**. The run should show the tag added.
|
|
69
69
|
4. Open **Plugins → RedeemID → Members**, then the customer. The tag and level should be there.
|
package/docs/shopify-setup.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
What to set up in Shopify, and on the site, to run RedeemID on an EmDash site. One store can serve several sites: each site gets its own Headless storefront for sign-in, and each receives webhooks.
|
|
4
4
|
|
|
5
|
+
Once the package is installed, **Plugins → RedeemID → Setup** in the EmDash admin walks through these same steps with your site's own addresses filled in, checks each one, and registers the webhooks for you.
|
|
6
|
+
|
|
5
7
|
## 1. Sign-in: a Headless storefront
|
|
6
8
|
|
|
7
9
|
1. In Shopify admin, open **Sales channels → Headless** (add the channel if it isn't there) and **Create storefront**. Name it after the site.
|
|
@@ -23,15 +25,17 @@ The store must use the new customer accounts (Settings → Customer accounts). C
|
|
|
23
25
|
|
|
24
26
|
## 2. Webhooks
|
|
25
27
|
|
|
26
|
-
RedeemID keeps tags and status current from webhooks.
|
|
28
|
+
RedeemID keeps tags and status current from webhooks sent by a Shopify app on the store (for example a Dev Dashboard app). Shopify signs every webhook with that app's **client secret**, which is the site's webhook secret.
|
|
29
|
+
|
|
30
|
+
Give the app the `read_customers` and `write_customers` Admin API scopes, plus `read_customer_merge` for `customers/merge`, and set its **client ID** on the site as well. Then the Setup page (or **Webhooks → Setup**) registers these topics at `https://<site>/_emdash/api/auth/redeemid/webhook` with one button:
|
|
27
31
|
|
|
28
|
-
`customers/create`, `customers/update`, `customers/delete`, `customers/disable`, `customers/enable`, `customers/merge`, `customer.tags_added`, `customer.tags_removed
|
|
32
|
+
`customers/create`, `customers/update`, `customers/delete`, `customers/disable`, `customers/enable`, `customers/merge`, `customer.tags_added`, `customer.tags_removed`
|
|
29
33
|
|
|
30
|
-
|
|
34
|
+
Without the client ID, subscribe them to that URL yourself. If the app already sends webhooks to another site, add this URL as extra subscriptions.
|
|
31
35
|
|
|
32
|
-
|
|
36
|
+
The client ID and those scopes also run the staff tools (member resync and tag edits written to Shopify), member preferences, and the reads that keep tags in order (see the README). RedeemID uses the client credentials grant, which works when the app and the store are in the same Shopify organization. For the **Also request erasure in Shopify** button on a member's page, add the optional `write_customer_data_erasure` scope.
|
|
33
37
|
|
|
34
|
-
Shopify's compliance topics (`customers/data_request`, `customers/redact`, `shop/redact`) reach only apps that declare them in the app's configuration, under `[webhooks.privacy_compliance]`. Point all three at the same webhook URL. RedeemID logs each one on the Privacy page.
|
|
38
|
+
Shopify's compliance topics (`customers/data_request`, `customers/redact`, `shop/redact`) can't be registered by the button: they reach only apps that declare them in the app's configuration, under `[webhooks.privacy_compliance]`. Point all three at the same webhook URL. RedeemID logs each one on the Privacy page and acts on it (see the README's privacy requests).
|
|
35
39
|
|
|
36
40
|
## 3. The site
|
|
37
41
|
|
|
@@ -46,12 +50,14 @@ npx wrangler secret put REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_SECRET # confid
|
|
|
46
50
|
npx wrangler secret put REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_SECRET # the webhook app's client secret
|
|
47
51
|
npx wrangler secret put REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID # optional; the webhook app's client ID, for the staff tools
|
|
48
52
|
npx wrangler secret put REDEEMID_EVENTS_SECRET # optional; signs outbound events (events.url)
|
|
49
|
-
npx wrangler secret put REDEEMID_SHOPIFY_STOREFRONT_PRIVATE_TOKEN #
|
|
53
|
+
npx wrangler secret put REDEEMID_SHOPIFY_STOREFRONT_PRIVATE_TOKEN # optional; the Storefront API private token, to sell on the site (plans, shop, checkout)
|
|
50
54
|
npx wrangler secret put REDEEMID_SHOPIFY_STOREFRONT_PUBLIC_TOKEN # optional; used only without the private token
|
|
51
55
|
```
|
|
52
56
|
|
|
53
57
|
The Workers free plan is enough: RedeemID is a native plugin and doesn't need sandboxing. Worker secrets reach the code through `nodejs_compat`, which EmDash's Cloudflare templates turn on.
|
|
54
58
|
|
|
59
|
+
On Workers, also add a Cron Trigger to the site's Worker, as EmDash's deployment guide describes, so EmDash's scheduler runs RedeemID's daily webhook health check.
|
|
60
|
+
|
|
55
61
|
## 4. Try it
|
|
56
62
|
|
|
57
63
|
1. Finish EmDash setup as staff first. Store customers can't create the first account.
|
|
@@ -59,4 +65,4 @@ The Workers free plan is enough: RedeemID is a native plugin and doesn't need sa
|
|
|
59
65
|
3. Click **Sign in**, sign in with that customer's email, and you come back to the post, unlocked.
|
|
60
66
|
4. Add or remove the tag in Shopify; the post opens or locks again once the webhook arrives.
|
|
61
67
|
|
|
62
|
-
If sign-in sends you back with `?redeemid_notice=…`, the code says why: `not_configured`
|
|
68
|
+
If sign-in sends you back with `?redeemid_notice=…`, the code says why: `not_configured` means the client ID or `shop` is missing, and `sign_in_unavailable` means store discovery failed, usually a wrong `shop`. The README lists [every notice](../README.md#notices).
|
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/access.ts
CHANGED
|
@@ -15,6 +15,9 @@ import { UNAVAILABLE, type EntryRule } from "./settings.js";
|
|
|
15
15
|
import { siteConfig } from "./site-config.js";
|
|
16
16
|
import { memberForUser, stores, type MemberRecord, type MeterRecord } from "./storage.js";
|
|
17
17
|
|
|
18
|
+
/** The site's config for a request: the code's config with the admin's saved settings laid over it. */
|
|
19
|
+
export { siteConfig };
|
|
20
|
+
|
|
18
21
|
/** The parts of Astro's global that access checks read. */
|
|
19
22
|
export interface AstroLike {
|
|
20
23
|
locals: unknown;
|
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
|
@@ -16,13 +16,14 @@ export { API_VERSION } from "./api-version.js";
|
|
|
16
16
|
/** Stable exports, by import path. */
|
|
17
17
|
export const MODULES = {
|
|
18
18
|
"@redeemid/emdash": ["redeemid", "redeemidGuard", "redeemidPlugin"],
|
|
19
|
-
"@redeemid/emdash/access": ["viewer", "canRead", "readEntry", "entryRequirement", "meterUsage", "membership", "levelsFor", "signInUrl", "signOutUrl", "accountUrl", "cartUrl", "refreshUrl", "safeReturn"],
|
|
19
|
+
"@redeemid/emdash/access": ["siteConfig", "viewer", "canRead", "readEntry", "entryRequirement", "meterUsage", "membership", "levelsFor", "signInUrl", "signOutUrl", "accountUrl", "cartUrl", "refreshUrl", "safeReturn"],
|
|
20
20
|
"@redeemid/emdash/features": ["hasFeature", "viewerFeatures", "featuresFor"],
|
|
21
21
|
"@redeemid/emdash/preferences": ["readPreferences", "savePreferences", "preferenceFields"],
|
|
22
22
|
"@redeemid/emdash/storefront": ["plan", "product", "collection", "money", "realOptions", "variantFor", "storeContext", "cartDetails", "addToCart", "updateLines", "removeLines", "setDiscountCodes"],
|
|
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
|
|