@redeemid/emdash 0.0.16 → 0.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +447 -232
  2. package/bin/redeemid-emdash.mjs +13 -1
  3. package/docs/developer-api.md +126 -0
  4. package/docs/shopify-setup.md +1 -1
  5. package/package.json +3 -1
  6. package/src/access-admin.ts +754 -0
  7. package/src/access.ts +84 -18
  8. package/src/admin-api.ts +9 -2
  9. package/src/agents.ts +62 -3
  10. package/src/api-version.ts +6 -0
  11. package/src/blocks/CollectionBlock.astro +4 -1
  12. package/src/blocks/SearchBlock.astro +14 -0
  13. package/src/blocks/index.ts +2 -0
  14. package/src/blocks-names.ts +1 -0
  15. package/src/cart-ops.ts +164 -0
  16. package/src/catalog.ts +287 -0
  17. package/src/checkout.ts +73 -0
  18. package/src/client/redeemid.ts +563 -0
  19. package/src/components/AccessBadge.astro +7 -5
  20. package/src/components/Account.astro +1 -1
  21. package/src/components/BrowseControls.astro +87 -0
  22. package/src/components/Cart.astro +12 -6
  23. package/src/components/CartDrawer.astro +59 -0
  24. package/src/components/CartView.astro +2 -1
  25. package/src/components/Collection.astro +23 -18
  26. package/src/components/Content.astro +10 -5
  27. package/src/components/Contribution.astro +1 -1
  28. package/src/components/Gate.astro +4 -2
  29. package/src/components/LockedPanel.astro +5 -3
  30. package/src/components/MemberCard.astro +1 -1
  31. package/src/components/Notices.astro +3 -28
  32. package/src/components/Plan.astro +1 -1
  33. package/src/components/Preferences.astro +1 -1
  34. package/src/components/Product.astro +116 -25
  35. package/src/components/ProductGrid.astro +52 -0
  36. package/src/components/ProductSearch.astro +83 -0
  37. package/src/components/Recommendations.astro +45 -0
  38. package/src/components/RedeemIDClient.astro +45 -0
  39. package/src/components/YourData.astro +1 -1
  40. package/src/config.ts +31 -5
  41. package/src/contract.ts +50 -0
  42. package/src/descriptor.ts +7 -0
  43. package/src/events.ts +3 -0
  44. package/src/features.ts +1 -1
  45. package/src/files.ts +1 -1
  46. package/src/guard.ts +5 -3
  47. package/src/index.ts +3 -0
  48. package/src/log.ts +96 -0
  49. package/src/monitor.ts +560 -0
  50. package/src/notices.ts +29 -0
  51. package/src/plugin.ts +234 -88
  52. package/src/rate-limit.ts +7 -0
  53. package/src/routes/api/cart.ts +84 -0
  54. package/src/routes/api/search.ts +49 -0
  55. package/src/routes/api/shared.ts +4 -0
  56. package/src/routes/api/state.ts +69 -0
  57. package/src/routes/buy.ts +4 -3
  58. package/src/routes/callback.ts +23 -5
  59. package/src/routes/cart-edit.ts +15 -66
  60. package/src/routes/cart.ts +4 -2
  61. package/src/routes/preferences.ts +2 -2
  62. package/src/routes/privacy.ts +2 -2
  63. package/src/routes/shared.ts +11 -0
  64. package/src/routes/webhook.ts +24 -11
  65. package/src/settings.ts +195 -0
  66. package/src/shopify.ts +37 -1
  67. package/src/site-config.ts +38 -5
  68. package/src/staff.ts +12 -23
  69. package/src/storage.ts +12 -0
  70. package/src/storefront.ts +231 -59
  71. package/src/text.ts +43 -2
  72. package/src/version.ts +1 -1
  73. package/src/webhooks.ts +2 -1
package/README.md CHANGED
@@ -1,67 +1,108 @@
1
1
  # RedeemID for EmDash
2
2
 
3
- Shopify customer accounts, member levels and paywalls for [EmDash](https://github.com/emdash-cms/emdash) sites. Shopify runs the store and the customer account; EmDash runs the content.
4
-
5
- **Status: 0.0.16, early.** Runs on Node and Cloudflare Workers. Sign-in, the member mirror, webhooks, levels, rules, the meter and drip, teasers, paywall markup, badges, the page gate, the Shopify cart and checkout, the staff tools, preferences, protected files, privacy requests, AI agent tools, the command line, replacement text and outbound events are covered by an end-to-end test against a fake store.
6
-
7
- ## What it does
8
-
9
- - **Sign in with the store's customer account.** Members click "Sign in" on your site, sign in on the store's account page and come back signed in. They become EmDash Subscribers. That role only means "signed in"; it never grants content.
10
- - **Mirrors the customer.** Email, name, tags and enabled/disabled status, kept current by signed Shopify webhooks.
11
- - **Levels from tags.** You map Shopify customer tags to levels. Set tags in Shopify Flow or any app; RedeemID only reads them.
12
- - **Rules.** Lock a collection, entries with a term (and its child terms), or set a site default; a term can also open its entries to everyone. When several term rules cover an entry, readers must pass all of them.
13
- - **Meter and drip.** Signed-in readers may open a few locked entries free each month or week. A rule can also drip: members read its entries once they've held their level for so many days.
14
- - **Teasers and badges.** Locked entries can show their first paragraphs, up to a word count. `<AccessBadge>` names the level an entry needs and shows a lock to readers it keeps out.
15
- - **Plans, cart and checkout.** `<Plan>` shows a Shopify product's price and adds it to the visitor's Shopify cart; `<Cart>` shows the count and opens Shopify checkout.
16
- - **Member account card.** `<MemberCard>` shows members their levels with when each started, the levels they held before, the meter, and Refresh membership, which reads their tags from the store again.
17
- - **Account.** Members get an Account link to their Shopify customer account (orders, profile, addresses) and Sign out, which also ends the Shopify session.
18
- - **Paywall break block.** Editors write one body and drop a "Paywall break" block where members-only content starts, picking which levels may read on, or leaving it to the entry's rule. `<Content>` renders the body and leaves the rest off the page for everyone else.
19
- - **Members only block.** For a members part in the middle of a body: "Members only" (pick the levels) and "Members only: end". Everyone else sees the wall in its place and reads on after it.
20
- - **Sold on its own block.** Sell one entry by itself: buyers get a Shopify tag that opens the whole entry whatever its levels say, and locked readers see "Buy just this" with the price.
21
- - **The wall.** Readers kept out see why and what to do next: sign in, or join, with each level's own wall text. `<Notices>` shows the messages sign-in, Buy, Cart and Account send readers back with.
22
- - **Plan block.** Editors add a Shopify product to any page with the "Plan" block: title, price and a buy button.
23
- - **Shop.** `<Product>`, `<Collection>` and `<CartView>` (with the Product and Collection blocks) give the site product pages, collection grids and a cart page with quantities and discount codes. `<Contribution>` (and its block) takes pay-what-you-want amounts. Carts note the page each sale started on.
24
- - **Features.** Name perks members hold through levels or tags (ad-free, a supporter thank-you) and show them with `<Feature>` or `hasFeature()`.
25
- - **In the admin.** RedeemID shows under Plugins with a Members page (Shopify customers, tags, levels; open a member to resync them or add and remove tags, which are written to Shopify), a bulk resync you start yourself, a Status page (secrets, store discovery, webhook subscriptions with a Register button, recent deliveries, levels and rules), an Access test and a dashboard widget.
26
- - **Member preferences.** Members see and change customer fields you define in Shopify, such as topics or a newsletter choice, with `<Preferences>` or its block. Answers are saved on the Shopify customer, ready for Flow, segments and email; the site keeps no copy.
27
- - **Protected files.** Put uploads in a media folder named in `files`, and every download checks the reader's levels, features or the entries that use the file. Copied links are no use to anyone else.
28
- - **Privacy requests.** Members download or delete what the site keeps about them with `<YourData>`. Staff export or erase any member, and can ask Shopify to erase the customer too. Shopify's compliance webhooks are handled and logged on a Privacy page.
29
- - **AI agents and a command line.** Staff questions (can this reader read this entry, who is this member, what changed) as MCP tools for EmDash's MCP server, and as the `redeemid-emdash` command.
30
- - **Replacement text.** Put any string members and visitors see in your own words, one string at a time, with `text`.
31
- - **Outbound events.** Signed JSON POSTs to a URL of yours when a member signs in, their levels change, or they use the meter, for a CRM, an email tool or your own code.
32
- - **Keeps members out of EmDash's own APIs**, so the gate is the only way to read locked content.
33
-
34
- ## Install
35
-
36
- The package is a native EmDash plugin (an npm dependency, not a registry plugin), because sign-in and page gating run in the site process.
3
+ [![npm](https://img.shields.io/npm/v/@redeemid/emdash)](https://www.npmjs.com/package/@redeemid/emdash)
4
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
5
+
6
+ Memberships and paywalls for [EmDash](https://github.com/emdash-cms/emdash) sites, run by Shopify.
7
+
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
+
10
+ **Status: 0.0.18, early.** Runs on Node and Cloudflare Workers. Every feature below is covered by unit tests and an end-to-end test that boots a real EmDash site against a fake Shopify store. Expect changes before 1.0.
11
+
12
+ ## Contents
13
+
14
+ - [How it works](#how-it-works)
15
+ - [What you get](#what-you-get)
16
+ - [Requirements](#requirements)
17
+ - [Quick start](#quick-start)
18
+ - [Content and templates](#content-and-templates)
19
+ - [Guides](#guides): [rules](#rules), [access settings in the admin](#access-settings-in-the-admin), [meter and drip](#meter-and-drip), [selling one entry](#selling-one-entry-on-its-own), [features](#features), [shop](#shop), [preferences](#preferences), [protected files](#protected-files), [privacy requests](#privacy-requests), [staff tools](#staff-tools), [webhooks, app events and reports](#webhooks-app-events-and-reports), [AI agents and the command line](#ai-agents-and-the-command-line), [replacement text](#replacement-text), [events](#events), [caching](#caching), [rate limits](#rate-limits)
20
+ - [Reference](#reference): [config](#config), [secrets](#secrets), [components](#components), [editor blocks](#editor-blocks), [modules](#modules), [routes](#routes), [notices](#notices)
21
+ - [Guarantees](#guarantees)
22
+ - [Troubleshooting](#troubleshooting)
23
+ - [Develop](#develop)
24
+
25
+ ## How it works
26
+
27
+ 1. **Sign in.** A reader clicks "Sign in", signs in on the store's customer account page, and comes back signed in as an EmDash Subscriber. That role only means "signed in". It never opens content.
28
+ 2. **Mirror.** RedeemID keeps a copy of the customer: email, name, tags and whether the account is enabled. Signed Shopify webhooks keep it current.
29
+ 3. **Levels.** You map tags to levels and name them whatever you like, such as `gold-member` to Gold. Tags come from Shopify Flow, a subscription app or staff, never from the site.
30
+ 4. **Rules.** You say which content types or terms need which levels, in the admin (Plugins → RedeemID → Access) or in code. Each entry can have its own rule in the editor's Access panel, and editors can drop a Paywall break or a Members only block into any body.
31
+ 5. **Render.** Templates render bodies with `<Content>`. Readers who pass see everything. Everyone else gets a teaser and the wall, and the locked part never reaches the page.
32
+
33
+ Selling a membership is ordinary Shopify: a product, a checkout, and a Flow workflow that tags the customer when the order is paid. RedeemID shows the product and starts checkout, and access follows the tag.
34
+
35
+ ## What you get
36
+
37
+ **For members**
38
+ - Sign in, Account (orders, profile and addresses on the store) and Sign out, which ends the store session too. Members never see the word "Shopify".
39
+ - A member card with their levels and when each started, past levels, the meter, and Refresh membership to read their tags again after a purchase.
40
+ - Preferences they can change, such as topics or a newsletter choice, saved on the Shopify customer.
41
+ - Download or delete what the site keeps about them.
42
+
43
+ **For editors**
44
+ - Paywall break, Members only, Sold on its own, Plan, Product, Collection, Product search, Contribution and Preferences blocks in the EmDash editor.
45
+ - An Access panel on every entry: follow the site rules, free to everyone, only some levels, or the site rules plus a level, with drip, meter and date windows.
46
+ - Rules by content type, term (with child terms) or site default, plus terms that open entries to everyone.
47
+ - Teasers, a monthly or weekly meter, drip by days of membership, and badges that show what an entry needs.
48
+ - Protected files: downloads in chosen media folders check the reader's access.
49
+
50
+ **For staff, in the EmDash admin**
51
+ - Access page: levels, site rules, the meter, every content type and taxonomy (new ones appear on their own), features and protected folders. No code change needed.
52
+ - Members page, member detail (resync, add and remove tags in Shopify), a bulk resync you start yourself.
53
+ - Webhooks page: what Shopify sent and what became of it, delivery health with a daily check and email warnings, and the subscriptions with a Register button.
54
+ - App events: webhooks, sign-ins and refusals, Admin API calls, staff actions and settings saves, filterable, kept 30 days.
55
+ - Reports: members by level, who joined and left by week, sign-ins and metered reads.
56
+ - Status page: every setting, store discovery, levels and rules.
57
+ - Access test, Privacy page, and a dashboard widget.
58
+
59
+ **For developers**
60
+ - Astro components for every piece, plus `canRead()`, `readEntry()` and `hasFeature()` for your own code.
61
+ - A storefront from the Storefront API: product pages with options, subscriptions and galleries, collection grids with Shopify's filters, sorting and pages, product search, recommendations, a cart page and a cart drawer, pay-what-you-want, local prices, and a checkout that opens signed in for members.
62
+ - A browser client, `window.redeemid`, with `subscribe()` events for any analytics or CRM tool, cart methods and search, plus JSON routes for state, the cart and the catalog. See [docs/developer-api.md](docs/developer-api.md).
63
+ - Signed outbound events for a CRM or email tool, MCP tools for AI agents (members, access and the catalog), and a `redeemid-emdash` command.
64
+ - Replacement text for every string readers see, Google's paywalled-content markup, cache safety and optional rate limits.
65
+
66
+ ## Requirements
67
+
68
+ - **EmDash 1.1 or later** with Astro 6 or later, on Node or Cloudflare Workers (the Workers free plan is enough). The MCP tools need an EmDash version with plugin MCP tools.
69
+ - **A Shopify store with the new customer accounts** (Settings → Customer accounts). Classic accounts have no Customer Account API.
70
+ - **The Headless sales channel**, for the sign-in client and Storefront API tokens.
71
+ - **A Shopify app on the store**, such as a Dev Dashboard app, to send webhooks. With its client ID too, it runs the staff tools and preferences.
72
+ - **HTTPS.** Shopify only accepts `https` callback URIs, so a local site needs a tunnel to sign in for real.
73
+
74
+ ## Quick start
75
+
76
+ ### 1. Set up Shopify
77
+
78
+ Follow [docs/shopify-setup.md](docs/shopify-setup.md). It gives you a Customer Account API client, Storefront API tokens and a webhook app.
79
+
80
+ ### 2. Install
37
81
 
38
82
  ```sh
39
83
  pnpm add @redeemid/emdash
40
84
  ```
41
85
 
86
+ RedeemID is a native EmDash plugin: an npm dependency, not a registry plugin, because sign-in and page gating run in the site process.
87
+
88
+ ### 3. Configure
89
+
42
90
  ```js title="astro.config.mjs"
43
91
  import { redeemid, redeemidGuard, redeemidPlugin } from "@redeemid/emdash";
92
+ import { defineConfig } from "astro/config";
44
93
  import emdash from "emdash/astro";
45
94
 
46
95
  const config = {
47
96
  shop: "my-store.myshopify.com",
48
97
  levels: [
49
98
  { slug: "member", name: "Member", tags: ["member"] },
50
- // wall: what the wall says to readers this level would let in; joinUrl overrides join.href.
51
99
  { slug: "pro", name: "Pro", tags: ["pro"], wall: "Pro members read every report." },
52
- // match: "all" needs every tag, e.g. both memberships:
53
- { slug: "both", name: "Member + Pro", tags: ["member", "pro"], match: "all" },
54
100
  ],
55
101
  rules: [
56
102
  { collection: "posts", taxonomy: "category", term: "members", levels: ["member", "pro"] },
57
103
  { collection: "posts", taxonomy: "category", term: "pro", levels: ["pro"] },
58
104
  ],
59
- // Optional: where the wall's join link goes.
60
105
  join: { href: "/join/", label: "Become a member" },
61
- // Optional: locked posts with no paywall break show the paragraphs within their first 40 words.
62
- teaser: { posts: 40 },
63
- // Optional: signed-in readers may open 3 locked posts a month.
64
- meter: { limit: 3, period: "month", collections: ["posts"] },
65
106
  };
66
107
 
67
108
  export default defineConfig({
@@ -69,37 +110,108 @@ export default defineConfig({
69
110
  emdash({
70
111
  // database, storage, …
71
112
  authProviders: [redeemid(config)], // sign-in, webhooks, cart and checkout routes
72
- plugins: [redeemidPlugin(config)], // admin pages and the editor blocks
113
+ plugins: [redeemidPlugin(config)], // admin pages and editor blocks
73
114
  }),
74
- redeemidGuard(),
115
+ redeemidGuard(), // gates pages and keeps members out of EmDash's own APIs
75
116
  ],
76
117
  });
77
118
  ```
78
119
 
79
- Set these secrets in the environment (`.env` locally, `wrangler secret put` on Workers). [docs/shopify-setup.md](docs/shopify-setup.md) walks through the Shopify side:
120
+ Pass the same `config` to `redeemid()` and `redeemidPlugin()`. Every option is in the [config reference](#config).
80
121
 
81
- | Variable | Shopify API | What it is |
82
- | --- | --- | --- |
83
- | `REDEEMID_SHOPIFY_STORE_DOMAIN` | Customer Account API discovery | The store's `*.myshopify.com` domain. Only needed when `shop` isn't set in the config. |
84
- | `REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID` | Customer Account API | Client ID of the Headless storefront's Customer Account API client |
85
- | `REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_SECRET` | Customer Account API | Its client secret. Confidential clients only; leave unset for a public client. |
86
- | `REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_SECRET` | Webhooks (Admin API app) | Client secret of the Shopify app that sends the webhooks. Shopify signs each webhook with it. |
87
- | `REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID` | Admin API (staff tools) | Client ID of that same app. Optional; with it, staff can resync members, edit tags in Shopify and register webhooks. |
88
- | `REDEEMID_EVENTS_SECRET` | None | Optional. Signs outbound events; set it with `events.url`. |
89
- | `REDEEMID_SHOPIFY_STOREFRONT_PRIVATE_TOKEN` | Storefront API | Private access token, for the plan cards and checkout (server-side). |
90
- | `REDEEMID_SHOPIFY_STOREFRONT_PUBLIC_TOKEN` | Storefront API | Public access token. Only used when the private token isn't set. |
122
+ Levels, rules, the meter, features, protected folders, teasers and the join link in this config are the site's defaults. You can leave them all out and set them under **Plugins → RedeemID → Access** instead; see [access settings in the admin](#access-settings-in-the-admin).
123
+
124
+ ### 4. Set the secrets
125
+
126
+ Set them as runtime secrets of the site (`.env` locally, `wrangler secret put` on Workers), not as build-time variables:
127
+
128
+ ```sh
129
+ REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID=… # sign-in
130
+ REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_SECRET=… # verifies webhooks
131
+ REDEEMID_SHOPIFY_STOREFRONT_PRIVATE_TOKEN=… # plans, shop and checkout
132
+ ```
133
+
134
+ The [secrets reference](#secrets) lists the optional ones.
135
+
136
+ ### 5. Render content through RedeemID
137
+
138
+ ```astro
139
+ ---
140
+ import Account from "@redeemid/emdash/components/Account.astro";
141
+ import Cart from "@redeemid/emdash/components/Cart.astro";
142
+ import Content from "@redeemid/emdash/components/Content.astro";
143
+ import Notices from "@redeemid/emdash/components/Notices.astro";
144
+ ---
145
+ <header><Account /> <Cart /></header>
146
+ <Notices />
147
+ <Content value={post.data.content} collection="posts" terms={post.data.terms} id={post.id} title={post.data.title} />
148
+ ```
149
+
150
+ Use `<Content>` in place of `<PortableText>` for every body that may be locked. Plain `<PortableText>` shows everything.
151
+
152
+ ### 6. Check it
153
+
154
+ 1. Finish EmDash setup as staff first. Store customers can't create the first account.
155
+ 2. Open **Plugins → RedeemID → Status**. Every setting should say Set. Then open **Webhooks → Setup** and press **Register**.
156
+ 3. Tag a test customer `member` in Shopify, open a locked post, and sign in as that customer. The post opens.
157
+ 4. Remove the tag in Shopify. The post locks again once the webhook arrives.
158
+
159
+ ## Content and templates
160
+
161
+ EmDash has no content filter hook, so templates decide what to render.
162
+
163
+ **`<Content>`** renders an entry body. Everything before the first Paywall break block is shown to everyone. What follows reaches the page only for readers the break allows: the levels picked on it ("Signed in" for any member) or, with none picked, the entry's rule. With none picked and no rule covering the entry, the break changes nothing. A body with no break follows the entry's rule as a whole, and readers it keeps out see the collection's `teaser` if one is set (whole paragraphs, never half of one).
164
+
165
+ ```astro
166
+ <Content value={post.data.content} collection="posts" terms={post.data.terms} id={post.id} title={post.data.title}>
167
+ <a slot="locked" href="/join/">Become a member</a>
168
+ </Content>
169
+ ```
170
+
171
+ Pass `id` on the entry's own page. It lets the meter count the read, and a locked entry gets Google's paywalled-content markup: JSON-LD with `isAccessibleForFree: false` and the wall (`.redeemid-locked`) as the part that isn't free, so search engines can tell a paywall from cloaking. `title` is its headline. Set `schema: false` if an SEO plugin already adds this markup.
172
+
173
+ **Members only.** A "Members only" block starts a part for the levels picked on it (none picked: the entry's rule, or any signed-in member) that runs to the next "Members only: end" block. Readers who can't read it see the wall in its place and read on after it.
174
+
175
+ **The wall** is `<LockedPanel>`. It says why the reader is kept out and offers sign-in or the join link, with the wall text of the first level they're missing. `<Content>` and `<Gate>` show it, and their `locked` slot goes on top.
176
+
177
+ **`<Gate>`** locks any part of a template that isn't the body:
178
+
179
+ ```astro
180
+ <Gate levels={["pro"]}><a href="/reports/archive.zip">Download the archive</a></Gate>
181
+ ```
182
+
183
+ **`<AccessBadge>`** shows what an entry needs, on cards and on the entry. It shows "Free" for open entries (`free=""` hides it), adds `is-locked` and a lock for readers it keeps out, and puts the level slugs in `data-redeemid-level` for colours:
184
+
185
+ ```astro
186
+ <AccessBadge collection="posts" terms={post.data.terms} content={post.data.content} />
187
+ ```
188
+
189
+ **`<Notices />`** goes near the top of the layout. It shows the message for a `?redeemid_notice=` code (see [notices](#notices)) and nothing otherwise.
190
+
191
+ **`<MemberCard />`** is for an account page: levels with start dates, past levels, the meter, and links to the store account, Refresh membership and Sign out. Refresh membership sends a member to the store with no sign-in form; one still signed in there comes straight back with their tags read again. Add `<Preferences />` and `<YourData />` beside it.
192
+
193
+ **`<Plan>`** sells a membership: a Shopify product's title and price with a button that starts checkout (members check out with their email). With `levels`, members who already hold one see "You're on this plan." in place of the button.
194
+
195
+ ```astro
196
+ <Plan handle="gold-membership" label="Join Gold" levels={["gold"]}>
197
+ <p>Shown if the product can't be loaded.</p>
198
+ </Plan>
199
+ ```
91
200
 
92
- Set them as runtime variables or secrets of the site itself, not as build-time variables. If a value shows as Missing on the Status page, check its name: the Status page also lists any `REDEEMID_` variable that is set but that RedeemID doesn't read, by name only.
201
+ **In your own code** (`@redeemid/emdash/access`):
93
202
 
94
- RedeemID finds the authorization, token and logout endpoints itself from the store's discovery documents, so they need no setting.
203
+ ```ts
204
+ import { canRead, entryRequirement, readEntry } from "@redeemid/emdash/access";
95
205
 
96
- In Shopify, give the Customer Account API client these settings:
206
+ const verdict = await canRead(Astro, { collection: "posts", terms: post.data.terms });
207
+ // verdict.allowed; verdict.reason: open | allowed | sign_in | upgrade | disabled | unconfigured
208
+ const read = await readEntry(Astro, { collection: "posts", terms: post.data.terms, id: post.id });
209
+ // adds drip (read.opensAt) and the meter (read.meterLeft), and counts a metered read
210
+ ```
97
211
 
98
- - Callback URI: `https://your-site.example/_emdash/api/auth/redeemid/callback`
99
- - JavaScript origin (public clients): `https://your-site.example`
100
- - Logout URI: `https://your-site.example/`
212
+ `entryRequirement(config, collection, terms)` gives what an entry needs: a list of clauses, each passed by any of its levels, or `null` when it's open.
101
213
 
102
- Point webhooks (`customers/create`, `customers/update`, `customers/delete`, `customers/disable`, `customers/enable`, `customers/merge`, `customer.tags_added`, `customer.tags_removed`, `customers/redact`) at `https://your-site.example/_emdash/api/auth/redeemid/webhook`.
214
+ ## Guides
103
215
 
104
216
  ### Rules
105
217
 
@@ -108,30 +220,61 @@ Each rule names a collection and the levels, any of which may read. An empty `le
108
220
  | Rule | Covers |
109
221
  | --- | --- |
110
222
  | `{ collection: "reports", levels: ["gold"] }` | Every entry in the collection |
111
- | `{ collection: "posts", taxonomy: "category", term: "investigations", levels: ["gold"] }` | Entries with that term, or with a child of it in a hierarchical taxonomy. A child's own rule wins over its parent's. |
223
+ | `{ collection: "posts", taxonomy: "category", term: "investigations", levels: ["gold"] }` | Entries with that term, or a child of it in a hierarchical taxonomy. A child's own rule wins over its parent's. |
112
224
  | `{ collection: "posts", taxonomy: "tag", term: "free-to-read", levels: [], open: true }` | Opens entries with that term to everyone, whatever else covers them |
225
+ | `{ collection: "pages", levels: [], open: true }` | Opens a whole collection to everyone, unless a term rule locks an entry |
226
+ | `{ collection: "*", taxonomy: "category", term: "members", levels: ["member"] }` | That term in every collection, new ones included. A rule for the same term and a named collection wins there. |
113
227
  | `{ collection: "*", levels: [] }` | The site default, for collections with no rule of their own |
114
-
115
228
  | `{ collection: "posts", taxonomy: "category", term: "archive", levels: ["member"], drip: 30 }` | Drip: members read these entries 30 days after their level started |
116
229
  | `{ collection: "posts", taxonomy: "category", term: "exclusive", levels: ["pro"], meter: false }` | The meter never opens these entries |
117
230
 
118
- Term rules beat the collection's rule, which beats the site default. When several term rules cover one entry (a story in Investigations, Gold, and Culture, Silver), readers must pass every one of them, so only Gold + Silver members read it. Set `combine: "any"` to let one of them be enough.
231
+ Term rules beat the collection's rule, which beats the site default. When several term rules cover one entry (a story in Investigations, which needs Gold, and Culture, which needs Silver), readers must pass every one of them, so only members with both read it. Set `combine: "any"` to let one be enough.
119
232
 
120
- ### Selling one entry on its own
233
+ Levels can need several tags: `{ slug: "both", name: "Member + Pro", tags: ["member", "pro"], match: "all" }`. A level's `wall` is what the wall says to readers it would let in, and its `joinUrl` overrides `join.href`.
121
234
 
122
- Add the **Sold on its own** block anywhere in an entry's body. Give it the Shopify customer tag buyers get, such as `item-big-story`, and the Shopify product that sells it (its handle, or a link to it). Then:
235
+ ### Access settings in the admin
123
236
 
124
- - A reader whose customer holds that tag reads the whole entry: its rule, Paywall break, Members only parts and drip don't apply, and the meter isn't used.
125
- - Locked readers see **Buy just this** on the wall, with the product's price from Shopify, beside the level's offer. It starts checkout for that product.
126
- - `<AccessBadge>` drops the lock for buyers, and protected files in an `entries: true` folder that the entry uses reach them too.
237
+ Everything about who reads what can be set in the EmDash admin, so a new content type, taxonomy or level never needs a code change. Under **Plugins → RedeemID → Access**:
238
+
239
+ - **Levels.** Add, rename and remove levels and the Shopify tags that grant them. Name them anything; the slug is made from the name. A level that rules, features or folders still use can't be removed.
240
+ - **Site rules.** The site default, how term rules combine, the meter and the wall's join link.
241
+ - **Content types.** Who can read each collection, with drip, the meter and the teaser. Every collection on the site is listed, including ones made after RedeemID was set up.
242
+ - **Taxonomies.** Rules for terms in every taxonomy, for one collection or every collection the taxonomy is on.
243
+ - **Features** and **Protected files.**
244
+
245
+ The first save of a setting copies its value from your code config, so nothing is lost, and from then on the admin's value is used. **Use the code's setting** removes the admin's copy. Saved settings are kept in the site's database and read once per request.
127
246
 
128
- Shopify adds the tag when the order is paid, usually with a Flow workflow (Order paid → the line item is that product → add the customer tag). The site never grants it, so a refund workflow that removes the tag takes the entry away again. A tag on an entry that's open to everyone changes nothing.
247
+ Each saved entry also gets an **Access** panel in the editor. It shows who can read the entry right now and which rule says so, and sets the entry's own rule:
248
+
249
+ | Choice | Readers need |
250
+ | --- | --- |
251
+ | Follow the site rules | What the rules say (the default) |
252
+ | Free to everyone | Nothing |
253
+ | Only the levels ticked | One of those levels (none ticked: any signed-in member) |
254
+ | The site rules, and also a level ticked | What the rules say and one of those levels |
255
+
256
+ It 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.
129
257
 
130
258
  ### Meter and drip
131
259
 
132
- The meter lets a signed-in reader who can't read an entry open it anyway, `limit` entries per `period` (`"month"`, the default, or `"week"`, in UTC). Each entry counts once per period, when its own page renders through `<Content>` with an `id`; lists, feeds, search and the content API never count. Visitors get the wall and a nudge to sign in. While it runs, `<Content>` says how many free reads are left; when it runs out, the wall says so. Rules with `meter: false` are never metered, and the meter never opens an entry a member is waiting on by drip.
260
+ ```ts
261
+ meter: { limit: 3, period: "month", collections: ["posts"] },
262
+ teaser: { posts: 40 },
263
+ ```
264
+
265
+ The meter lets a signed-in reader open a locked entry anyway, `limit` entries per `period` (`"month"`, the default, or `"week"`, in UTC). Each entry counts once per period, when its own page renders through `<Content>` with an `id`. Lists, feeds, search and the content API never count. Visitors get the wall and a nudge to sign in. While it runs, `<Content>` says how many free reads are left, and when it runs out the wall says so. Rules with `meter: false` are never metered, and the meter never opens an entry a member is waiting on by drip.
133
266
 
134
- Drip holds a rule's entries back from members who pass it until they've held their level for `drip` days. A level starts when the customer first had its tag (for a `match: "all"` level, the last of its tags); with several levels the earliest counts, and with none known, the first sign-in. RedeemID notes when each tag first arrives from sign-in and webhooks, and forgets a tag when Shopify removes it. The wall shows the date the entry opens.
267
+ Drip holds a rule's entries back from members who pass it until they've held their level for `drip` days. A level starts when the customer first had its tag (for a `match: "all"` level, the last of its tags). With several levels the earliest counts, and with none known, the first sign-in. RedeemID notes when each tag first arrives and forgets it when Shopify removes it. The wall shows the date the entry opens.
268
+
269
+ ### Selling one entry on its own
270
+
271
+ Add the **Sold on its own** block anywhere in an entry's body. Give it the Shopify customer tag buyers get, such as `item-big-story`, and the product that sells it (its handle, or a link to it). Then:
272
+
273
+ - A reader holding that tag reads the whole entry. Its rule, Paywall break, Members only parts and drip don't apply, and the meter isn't used.
274
+ - Locked readers see **Buy just this** on the wall with the product's price, beside the level's offer.
275
+ - `<AccessBadge>` drops the lock for buyers, and protected files in an `entries: true` folder that the entry uses reach them too.
276
+
277
+ Shopify adds the tag when the order is paid, usually with a Flow workflow (Order paid → the line item is that product → add the customer tag). A refund workflow that removes the tag takes the entry away again.
135
278
 
136
279
  ### Features
137
280
 
@@ -142,7 +285,7 @@ features: [
142
285
  ],
143
286
  ```
144
287
 
145
- A member holds a feature through any of its levels or tags; like levels, features come only from the mirrored tags. `<MemberCard>` lists them under the member's levels.
288
+ A member holds a feature through any of its levels or tags. `<MemberCard>` lists them under the member's levels.
146
289
 
147
290
  ```astro
148
291
  ---
@@ -153,6 +296,45 @@ import { hasFeature } from "@redeemid/emdash/features";
153
296
  {!(await hasFeature(Astro, "ad-free")) && <aside class="ad">…</aside>}
154
297
  ```
155
298
 
299
+ ### Shop
300
+
301
+ Products, collections, search and the cart come from the Storefront API. Shopify works out prices, discounts, taxes and stock, and runs checkout. EmDash has no Shopify plugin underneath, so these components are the storefront.
302
+
303
+ ```astro
304
+ <!-- layout, once -->
305
+ <Cart drawer />
306
+ <CartDrawer />
307
+ <RedeemIDClient />
308
+ <!-- /shop/ (rendered on demand for filters and pages) -->
309
+ <Collection handle="shop" productHref="/shop/{handle}/" showTitle browse quickAdd />
310
+ <!-- /shop/[handle]/ -->
311
+ <Product handle={Astro.params.handle} heading="h1"><p>No such product.</p></Product>
312
+ <Recommendations handle={Astro.params.handle} productHref="/shop/{handle}/" />
313
+ <!-- /search/ -->
314
+ <ProductSearch productHref="/shop/{handle}/" />
315
+ <!-- /cart/ -->
316
+ <CartView productHref="/shop/{handle}/" />
317
+ <!-- anywhere -->
318
+ <Contribution handle="support" amounts={[5, 10, 25]} />
319
+ ```
320
+
321
+ - `<Product>`: images (with thumbnails), title, price and sale price, description (`html` for Shopify's formatted one), a picker per option (Size, Color…), one-time or subscription when the product has selling plans, quantity, Add to cart and Buy now. On a product's own page (`heading="h1"`) it prints Product structured data.
322
+ - `<Collection>`: up to 50 products as cards linking to `productHref`. `browse` adds Shopify's filters (set up in the Search & Discovery app), sort orders and pages, read from the page URL. `quickAdd` adds Add to cart on cards that need no choice.
323
+ - `<ProductSearch>`: a search box with Shopify's matches, filters, sorting and pages. `boxOnly` gives just the box, for a header, sending searches to `action`.
324
+ - `<Recommendations>`: Shopify's "You may also like" (`intent="related"`) or "Pair it with" (`intent="complementary"`) for a product.
325
+ - `<CartView>`: each line with a quantity to change (0 removes it) and Remove, a discount code field, the totals and Checkout.
326
+ - `<CartDrawer>`: the same cart in a modal dialog that slides in. With `<RedeemIDClient />` on the page, Add to cart opens it in place and `<Cart drawer />` opens it instead of going to checkout.
327
+ - `<Contribution>`: pay what you want. Price a product at 1.00, and the amount the reader picks becomes the quantity.
328
+ - `<Cart>`: the item count, linking to checkout, or to `href` (a cart page), or opening the drawer with `drawer`.
329
+
330
+ Everything works without JavaScript: the forms post to `/_emdash/api/auth/redeemid/cart-edit`, which reads every product, variant, option and selling plan from Shopify again and only changes the visitor's own cart. `<RedeemIDClient />` adds the drawer, option pickers that follow the choice, and search suggestions. A new cart carries the `_redeemid_page` attribute (the page the first item was added on), which shows on the order in Shopify admin. Editors get the same with the Product, Collection, Product search and Contribution blocks.
331
+
332
+ **Signed-in checkout.** When a signed-in member checks out, RedeemID puts their store sign-in (the Customer Account API access token, kept only in the server-side session and renewed when it's about to expire) on the cart, so Shopify's checkout opens signed in and the order lands on their customer record, where your Flow or apps tag it. If that can't be done, they check out as a guest with their email filled in. Set `checkout: { signIn: false }` to always use a guest checkout.
333
+
334
+ **Local prices.** Set `market: { country: "visitor" }` to show each visitor prices in their own market (from Cloudflare's `CF-IPCountry`), or a fixed `country` and `language`. Markets are set up in Shopify.
335
+
336
+ **Shoppers' agents.** Shopify runs a Storefront MCP server for every store at `https://<store domain>/api/mcp`, for AI agents that browse, build a cart and check out. Point shopping agents there; RedeemID's own tools are for staff.
337
+
156
338
  ### Preferences
157
339
 
158
340
  ```ts
@@ -162,9 +344,9 @@ preferences: [
162
344
  ],
163
345
  ```
164
346
 
165
- Define each field in Shopify (**Settings → Custom data → Customers**). The definition sets its name, type and limits, such as allowed choices or a minimum and maximum. List the ones members get in `preferences`, in the order to show them. Reading and saving use the Admin API app (`REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID`, with `read_customers` and `write_customers`), always on the signed-in member's own customer. Fields in the `membership` namespace are never offered. Preferences never open content.
347
+ Define each field in Shopify (**Settings → Custom data → Customers**); the definition sets its name, type and limits. List the ones members get, in the order to show them. Reading and saving use the Admin API app (`REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID` with `read_customers` and `write_customers`), always on the signed-in member's own customer. Fields in the `membership` namespace are never offered, and preferences never open content.
166
348
 
167
- `<Preferences>` (or the Preferences block) is a form with a control for each field. It works without JavaScript and comes back with the `preferences_saved`, `preferences_refused` or `preferences_unavailable` notice. Visitors see its `signed-out` slot.
349
+ `<Preferences>` (or its block) is a form that works without JavaScript. Visitors see its `signed-out` slot.
168
350
 
169
351
  | Shopify type | Value |
170
352
  | --- | --- |
@@ -176,9 +358,7 @@ Define each field in Shopify (**Settings → Custom data → Customers**). The d
176
358
  | Decimal | A number, as a string |
177
359
  | Date | `YYYY-MM-DD` |
178
360
 
179
- Every value is checked against the definition before it goes to Shopify; one refusal saves nothing, and an empty value clears the field. Scripts can use `/_emdash/api/auth/redeemid/preferences` too: `GET` returns `{ fields }` with values, `POST` with JSON `{ values: { "namespace.key": value } }` saves. 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()` and `savePreferences()` in `@redeemid/emdash/preferences` do the same.
180
-
181
- A save sends the `preferences.saved` event with the field names changed, never the answers.
361
+ 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 your code.
182
362
 
183
363
  ### Protected files
184
364
 
@@ -191,69 +371,81 @@ files: [
191
371
  ],
192
372
  ```
193
373
 
194
- Upload files into a folder in the EmDash media library. Folder names match without regard to case. Every request for a file in a protected folder at `/_emdash/api/media/file/…` is checked before EmDash serves it:
374
+ Upload files into a folder in the EmDash media library (names match without regard to case). Every request for one at `/_emdash/api/media/file/…` is checked before EmDash serves it:
195
375
 
196
- - **Allowed:** the file, sent with `Cache-Control: private, no-store`.
376
+ - **Allowed:** the file, with `Cache-Control: private, no-store`.
197
377
  - **Signed out:** a 401 page with a sign-in link that comes back to the file.
198
378
  - **Signed in without access:** a 403 page.
199
379
 
200
- A rule lets in members who hold any of its `levels` (an empty list means any signed-in member) or `features`. With `entries: true`, it also lets in whoever can read a published entry that uses the file: its rule, its drip, and the levels picked on its Paywall break or Members only parts. Staff can always download. Disabled members can't. If a file can't be checked, it isn't served.
380
+ A rule lets in members who hold any of its `levels` or `features`. With `entries: true` it also lets in whoever can read a published entry that uses the file. Staff can always download, disabled members can't, and a file that can't be checked isn't served.
201
381
 
202
- Limits:
203
-
204
- - Only EmDash's file route is checked. Don't give the storage bucket a public URL of its own.
205
- - Image services that fetch the file without the reader's cookies, such as a CDN image resizer, get refused, so keep protected images out of resized `<Image>` output.
206
- - Moving a file out of the folder makes it public again.
382
+ Limits: only EmDash's file route is checked, so don't give the storage bucket a public URL. Image services that fetch without the reader's cookies, such as a CDN resizer, are refused. Moving a file out of the folder makes it public again.
207
383
 
208
384
  ### Privacy requests
209
385
 
210
- What RedeemID keeps about a member: the synced record (Shopify customer ID, email, name, tags and when each started, account status, when they first signed in and were last synced) and their meter. Preference answers stay on the Shopify customer. Webhook deliveries keep only a topic and a time. Shopify stays the record of the customer, so erasing on the site never erases Shopify data. It can only ask Shopify to.
386
+ What RedeemID keeps about a member: the synced record (Shopify customer ID, email, name, tags and when each started, account status, first sign-in, last sync) and their meter. Preference answers stay on the Shopify customer, and webhook deliveries keep only a topic and a time. Erasing on the site never erases Shopify data; it can only ask Shopify to.
211
387
 
212
- | Started | What happens |
388
+ | Started by | What happens |
213
389
  | --- | --- |
214
- | A member, with `<YourData>` on an account page | **Download my data** returns their record and meter as JSON. **Delete my data** needs a ticked box, erases both, and signs them out. |
215
- | Staff, on a member's page in the admin | **Export data** shows the same JSON. **Erase site data** erases it. **Also request erasure in Shopify** asks Shopify to erase the customer across the store, orders included. Shopify waits before it does, and **Cancel the Shopify erasure** withdraws it. That needs the `write_customer_data_erasure` scope on the Admin API app. |
390
+ | A member, with `<YourData>` | **Download my data** returns their record and meter as JSON. **Delete my data** needs a ticked box, erases both, and signs them out. |
391
+ | Staff, on a member's page | **Export data**, **Erase site data**, and **Also request erasure in Shopify** (orders included, after Shopify's wait; **Cancel the Shopify erasure** withdraws it). The Shopify request needs the `write_customer_data_erasure` scope. |
216
392
  | Shopify `customers/data_request` | An open request on the **Privacy** page. Press **Export and mark done**, then send the export to the store owner. |
217
393
  | Shopify `customers/redact` | The member's record and meter are erased. |
218
394
  | Shopify `shop/redact` | Every member record and meter is erased. |
219
395
 
220
- The Privacy page lists every request with where it came from and the Shopify customer ID. It never shows an email or a name. An erase sends the `member.erased` event with `customerId` and `source`, so a CRM that took earlier events can delete its copy too.
396
+ The Privacy page shows each request's source and Shopify customer ID, never an email or a name. An erase sends the `member.erased` event so a CRM can delete its copy too. The site's own user account stays; delete it under Users if the person asks. The JSON route is `/_emdash/api/auth/redeemid/privacy`: `GET` downloads, `POST` with `confirm=yes` erases, only for the signed-in member and only from the site's own origin.
397
+
398
+ ### Staff tools
399
+
400
+ With `REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID` set, RedeemID gets an Admin API token for the webhook app with the client credentials grant. The app must be in the same Shopify organization as the store, as Dev Dashboard apps are, and needs `read_customers` and `write_customers`. Then, under **Plugins → RedeemID**:
401
+
402
+ - **Member detail.** Levels, tags and features; resync from Shopify; add and remove tags. Tag edits are made in Shopify and read back. Nothing is granted on the site.
403
+ - **Bulk resync.** Reads the 25 members synced longest ago, few enough for the Workers free limit of 50 database queries a request. Press it again for the next 25. It never runs on its own.
404
+ - **Webhook subscriptions.** Webhooks → Setup lists the store's subscriptions to this site and registers any that are missing. A topic Shopify refuses, such as `customers/merge` without `read_customer_merge`, is named with the reason and doesn't stop the others. `customers/redact` stays in the app's own settings.
405
+ - **Access test.** A collection, an entry, terms and a reader's tags in; the verdict out.
406
+
407
+ Resync updates email, name and tags. Enabled or disabled status comes only from the enable and disable webhooks.
221
408
 
222
- Erasing leaves the site's own user account (EmDash's users table) in place; delete it in the admin's Users if the person asks. If they sign in again, they're linked again and get back whatever Shopify still grants.
409
+ ### Webhooks, app events and reports
223
410
 
224
- The JSON route is `/_emdash/api/auth/redeemid/privacy`: `GET` downloads, and `POST` with `confirm=yes` erases. It only acts for the signed-in member and refuses a POST from another origin.
411
+ Members gain and lose access only when Shopify sends tag changes, so RedeemID shows what arrived and warns when deliveries stop.
412
+
413
+ - **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).
414
+ - **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, and go by email to the address you set when the site has an EmDash email provider: once when problems start or change, then once a week while they last.
415
+ - **Webhooks → Setup.** The endpoint, whether signatures can be checked, and the subscriptions.
416
+ - **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.
417
+ - **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.
418
+
419
+ App events and delivery records are kept 30 days. The daily check runs from EmDash's scheduler: on Node it runs by itself; on Cloudflare Workers, add a Cron Trigger to the site's Worker as EmDash's deployment guide describes. The plugin asks for `email:send` only to send these warnings.
225
420
 
226
421
  ### AI agents and the command line
227
422
 
228
- RedeemID answers the questions staff ask in the admin as data, for AI agents and for scripts. All of them need an administrator. They read what the site has synced. None of them grants access, edits a level or a rule, or returns a secret.
423
+ Staff questions as data, for AI agents and scripts. Every one needs an administrator, reads what the site has synced, and never grants access, edits a rule or returns a secret.
229
424
 
230
425
  | MCP tool | Command | What it answers |
231
426
  | --- | --- | --- |
232
- | `explain_access` | `access <collection>` | Can this reader read this entry, and why. Give the entry by `id` (its terms and body are read from the site) or by `terms`. Give the reader by Shopify `tags`, a member's `customerId`, or `signedIn: false`. The answer covers what the entry needs, the reader's levels, the result and reason, the level a locked reader is offered, drip days, and each members-only part. |
233
- | `list_levels` | `levels` | Every level, the tags that grant it, its wall text and join link. |
234
- | `get_rules` | `rules` | How rules combine, every rule, the meter, teasers, features and protected-file rules. |
235
- | `get_member` | `member <id or email>` | One synced member: status, tags, levels with start dates, past levels, features, last sync. |
236
- | `site_health` | `health` | Whether each setting is in place (yes or no, never a value), the last webhook, open privacy requests and ignored text replacements. |
237
- | `level_changes` | `changes --since <date>` | Who joined or left a level since a date. |
238
-
239
- With `agents: { actions: true }` in the config, three actions are offered too:
427
+ | `explain_access` | `access <collection>` | Can this reader read this entry, and why. Give the entry by `id` or `terms`, and the reader by `tags`, `customerId` or `signedIn: false`. |
428
+ | `list_levels` | `levels` | Every level, its tags, wall text and join link |
429
+ | `get_rules` | `rules` | How rules combine, every rule, the meter, teasers, features and file rules |
430
+ | `get_member` | `member <id or email>` | One member: status, tags, levels with start dates, past levels, features, last sync |
431
+ | `site_health` | `health` | Each setting (set or not, never a value), the last webhook, open privacy requests, ignored text replacements |
432
+ | `level_changes` | `changes --since <date>` | Who joined or left a level since a date |
433
+ | `find_products` | `products <words>` | Search the Shopify catalog: handles, titles, prices, stock |
434
+ | `get_product` | `product <handle>` | One product: options, variants, prices, selling plans |
435
+ | `get_collection` | `collection <handle>` | One collection: its products and the filters Shopify offers |
240
436
 
241
- | MCP tool | Command | What it does |
242
- | --- | --- | --- |
243
- | `resync_member` | `resync <customer id>` | Reads one member from Shopify and updates the site. Changes nothing in Shopify. |
244
- | `resync_batch` | `resync-batch` | Reads the 25 members synced longest ago. |
245
- | `edit_member_tags` | `tags <customer id> --add a,b --remove c` | Adds or removes tags on the Shopify customer, then reads back what Shopify holds. It's marked destructive for agents. |
437
+ With `agents: { actions: true }`, three actions are offered too: `resync_member` (`resync <customer id>`), `resync_batch` (`resync-batch`, 25 members) and `edit_member_tags` (`tags <customer id> --add a,b --remove c`, marked destructive for agents).
246
438
 
247
- **Agents.** EmDash's MCP server is at `/_emdash/api/mcp`, on EmDash versions with plugin MCP tools. RedeemID's tools stay off until an administrator turns them on for RedeemID under **Plugins**. Turning them on approves the list as it stands, and a release that changes the list needs approving again. The agent uses an EmDash API token with the `mcp:tools` or `mcp:tools:redeemid` scope, made by an administrator. Tool names carry the plugin's prefix, for example `redeemid__explain_access`.
439
+ **Agents** use EmDash's MCP server at `/_emdash/api/mcp` with an API token that has the `mcp:tools` or `mcp:tools:redeemid` scope. The tools stay off until an administrator turns them on for RedeemID under **Plugins**, and a release that changes the list needs approving again. Names carry the prefix, such as `redeemid__explain_access`.
248
440
 
249
- **Command line.**
441
+ **The command line** signs in the way EmDash's CLI does: `--token`, `EMDASH_TOKEN`, or the token `emdash login` saved. The site comes from `--url` or `EMDASH_URL`. Output is JSON.
250
442
 
251
443
  ```sh
252
444
  npx -p @redeemid/emdash redeemid-emdash access posts --id my-story --tags gold-member --url https://example.com
253
445
  npx -p @redeemid/emdash redeemid-emdash member jane@example.com
254
446
  ```
255
447
 
256
- It signs in the way EmDash's own CLI does: `--token`, or `EMDASH_TOKEN`, or the token `emdash login` saved for the site. The site comes from `--url` or `EMDASH_URL`. Output is JSON. Each command calls a staff-only route at `/_emdash/api/plugins/redeemid/<name>`, so scripts can call the same routes directly with a POST and a JSON body.
448
+ Each command calls a staff-only route at `/_emdash/api/plugins/redeemid/<name>`, which scripts can POST to directly.
257
449
 
258
450
  ### Replacement text
259
451
 
@@ -265,11 +457,9 @@ text: {
265
457
  },
266
458
  ```
267
459
 
268
- Each key is a string's original English; its value is the new text. Only the strings you name change. There is no find-and-replace across strings, so replacing "Sign in" leaves "Sign in to download this file." alone: to change a word everywhere, replace each string that uses it. `STRINGS` in `@redeemid/emdash/text` lists every string, grouped by where it shows (account, badge, cart, wall, meter, member card, notices, plan, preferences, product, files).
269
-
270
- Some strings have placeholders such as `{level}` or `{signIn}` (a link), filled in when the text shows. A replacement may use its original's placeholders, in any order or not at all, but no others. Text is plain: HTML in a replacement shows as typed. A replacement that uses another placeholder, or names a string RedeemID doesn't show, is ignored, so the original shows, and the Status page lists it with the reason.
460
+ Each key is a string's original English and its value is the new text. Only the strings you name change: there is no find-and-replace, so replacing "Sign in" leaves "Sign in to download this file." alone. `STRINGS` in `@redeemid/emdash/text` lists every string, grouped by where it shows.
271
461
 
272
- Props win over `text`: a `label` or `title` you pass, or Notices `messages`, is used as given. Staff screens in the admin aren't covered.
462
+ A replacement may use its original's placeholders (such as `{level}`, or `{signIn}` for a link) in any order, or not at all, but no others. Text is plain, so HTML shows as typed. A replacement with an unknown placeholder, or for a string RedeemID doesn't show, is ignored and listed on the Status page with the reason. Props you pass (`label`, `title`, Notices `messages`) win over `text`. Staff screens in the admin aren't covered.
273
463
 
274
464
  ### Events
275
465
 
@@ -277,154 +467,179 @@ Props win over `text`: a `label` or `title` you pass, or Notices `messages`, is
277
467
  events: { url: "https://hooks.example.com/redeemid", only: ["member.signed_in", "member.levels_changed"] },
278
468
  ```
279
469
 
280
- With `REDEEMID_EVENTS_SECRET` set, RedeemID POSTs `{ id, event, at, data }` to the URL. Headers: `X-RedeemID-Event`, `X-RedeemID-Delivery` (the `id`) and `X-RedeemID-Signature: sha256=<hex HMAC-SHA256 of the raw body with the secret>`. Check the signature before you trust a body. Sending waits at most 3 seconds and never breaks the page; a failed send is logged and dropped, not retried.
470
+ With `REDEEMID_EVENTS_SECRET` set, RedeemID POSTs `{ id, event, at, data }` to the URL with the headers `X-RedeemID-Event`, `X-RedeemID-Delivery` (the `id`) and `X-RedeemID-Signature: sha256=<hex HMAC-SHA256 of the raw body>`. Check the signature before you trust a body. A send waits at most 3 seconds and never breaks the page; a failed send is logged, not retried.
281
471
 
282
472
  | Event | `data` |
283
473
  | --- | --- |
284
474
  | `member.signed_in` | `customerId`, `levels`, `first` (first sign-in), `refresh` |
285
475
  | `member.levels_changed` | `customerId`, `levels`, `added`, `removed`, `source` (`sign-in`, the webhook topic, or a staff resync or tag edit) |
286
476
  | `meter.used` | `userId` (EmDash), `entry` (`collection:id`), `used`, `limit`, `period` (`2026-11`, or `2026-W45` by week) |
287
- | `meter.exhausted` | the same, sent with the last free read |
288
- | `preferences.saved` | `customerId`, `fields` (the `namespace.key` names changed) |
477
+ | `meter.exhausted` | The same, sent with the last free read |
478
+ | `preferences.saved` | `customerId`, `fields` (the names changed, never the answers) |
289
479
  | `member.erased` | `customerId`, `source` (`member`, `staff` or `shopify`) |
290
480
 
291
- No event carries an email address or a name: match people by the Shopify `customerId`. `only` limits which events are sent; leave it out for all of them.
292
-
293
- ### Staff tools
294
-
295
- With `REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID` set (it also runs member preferences), RedeemID gets an Admin API token for the webhook app with the client credentials grant (the app must belong to the same Shopify organization as the store, as Dev Dashboard apps do) and needs the `read_customers` and `write_customers` scopes. Then, in the admin:
296
-
297
- - **Member detail.** Open a member from the Members page to see their levels, tags and features, resync them from Shopify, or add and remove tags. Tag edits are made in Shopify, then read back; nothing is granted on the EmDash side.
298
- - **Bulk resync.** Reads the 25 members synced longest ago from Shopify in one request, few enough for the Workers Free limit of 50 database queries a request. Run it again for the next 25. It never runs on its own.
299
- - **Webhooks.** The Status page lists the store's subscriptions to this site and registers any that are missing (`customers/redact` stays in the app's own settings). A topic Shopify refuses, such as `customers/merge` without the `read_customer_merge` scope, is named with the reason and doesn't stop the others.
300
- - **Recent deliveries.** The last webhooks the site took, with their topics and times.
301
- - **Access test.** Enter a collection, terms and a reader's tags to see what the entry needs and whether that reader gets in.
302
-
303
- Resync updates email, name and tags. Status stays with the enable and disable webhooks.
304
-
305
- ## Use it in templates
306
-
307
- EmDash has no content filter hook, so templates decide what to render. Render the entry body with `<Content>` instead of `<PortableText>`:
308
-
309
- ```astro
310
- ---
311
- import Content from "@redeemid/emdash/components/Content.astro";
312
- ---
313
- <Content value={post.data.content} collection="posts" terms={post.data.terms} id={post.id} title={post.data.title}>
314
- <a slot="locked" href="/join/">Become a member</a>
315
- </Content>
316
- ```
317
-
318
- Everything before the first Paywall break block is shown to everyone. What follows reaches the page only for readers the break allows: the levels picked on it ("Signed in" for any member), or when none are picked the entry's rule. With none picked and no rule covering the entry, the break changes nothing. A body with no break follows the entry's rule as a whole; readers it keeps out see the collection's `teaser` (the paragraphs within that many words, never half a paragraph), if one is set. Plain `<PortableText>` ignores the break and shows everything, so use `<Content>` for any body that may hold one.
319
-
320
- On the entry's own page (with `id`), a locked entry also gets Google's paywalled-content markup: JSON-LD saying `isAccessibleForFree: false`, with the wall (`.redeemid-locked`) as the part that isn't free. Search engines can then tell a paywall from cloaking. Pass `title` for its headline. Set `schema: false` in the config if an SEO plugin already adds this markup.
321
-
322
- A "Members only" block starts a part for the levels picked on it (none picked: the entry's rule, or any signed-in member) that runs to the next "Members only: end" block. Readers who can't read it see the wall there, and the body goes on after the end block.
481
+ No event carries an email address or a name; match people by `customerId`. Leave `only` out to send every event.
323
482
 
324
- The wall is `<LockedPanel>`: it says why the reader is kept out and offers sign-in or the join link, with the wall text of the first level they are missing. `<Content>` and `<Gate>` show it; its default slot (and `<Content>`'s "locked" slot) goes on top.
325
-
326
- To lock a part of a template that isn't the body, wrap it in `<Gate>` (`<Gate levels={["pro"]}>…</Gate>`).
327
-
328
- Show what an entry needs with `<AccessBadge>`, on cards and on the entry. It reads the entry's rules (and a paywall break's levels when you pass `content`), shows "Free" for open entries (`free=""` hides it), and adds `is-locked` and a lock for readers it keeps out. `data-redeemid-level` holds the level slugs, for colours:
329
-
330
- ```astro
331
- ---
332
- import AccessBadge from "@redeemid/emdash/components/AccessBadge.astro";
333
- ---
334
- <AccessBadge collection="posts" terms={post.data.terms} content={post.data.content} />
335
- ```
336
-
337
- In your own code (`@redeemid/emdash/access`), `entryRequirement(config, collection, terms)` gives what an entry needs (a list of clauses, each passed by any of its levels, or `null` when open), `canRead(Astro, entry)` gives the verdict for the visitor, and `readEntry(Astro, { collection, terms, id })` gives it with drip and the meter applied (and counts a metered read).
338
-
339
- Put `<Notices />` near the top of your layout. It shows the message for a `?redeemid_notice=` code (see [docs/shopify-setup.md](docs/shopify-setup.md)) and nothing otherwise; pass `messages={{ cart_empty: "…" }}` to reword one.
483
+ ### Caching
340
484
 
341
- Add the account box and cart to your header. Visitors see Sign in; members see their name, levels, Account and Sign out. The cart shows how many items are in the visitor's Shopify cart and goes to its checkout:
485
+ `redeemidGuard()` keeps every page private for a signed-in visitor or a visitor with a cart: `Cache-Control: private, no-store`, and out of Astro's route cache (it calls `cache.set(false)` after the page sets its own hint). Other visitors all get the same page, so their pages stay cacheable.
342
486
 
343
- ```astro
344
- ---
345
- import Account from "@redeemid/emdash/components/Account.astro";
346
- import Cart from "@redeemid/emdash/components/Cart.astro";
347
- ---
348
- <Account />
349
- <Cart />
350
- ```
487
+ A route cache keys pages by URL alone and serves stored pages without running any code. If you turn on Astro's route cache, don't cache pages that render `<Content>` or `<Gate>` for locked entries, or a member would get the visitor's copy, wall included. Call `Astro.cache.set(hint)` only on pages whose entries no rule covers. Saving on the Access page or an entry's Access panel doesn't purge the route cache either, so a page cached before the change keeps its old copy until it expires.
351
488
 
352
- For an account page, `<MemberCard />` shows the member's levels (with when each started), levels they held before, how much of the meter they've used, and links to their store account, Refresh membership and Sign out. Visitors get a sign-in link. Refresh membership (`refreshUrl(returnTo)` in `@redeemid/emdash/access`) sends the member to the store with no sign-in form; a member still signed in there comes straight back with their tags read again and the `refreshed` notice, and anyone else goes through the usual sign-in. Use it after a purchase, when Shopify's tag webhook hasn't arrived yet.
489
+ ### Rate limits
353
490
 
354
- Sell a plan. `<Plan>` shows a Shopify product's title and price with a button that adds it to the visitor's Shopify cart and opens checkout (signed-in members check out with their email). Shopify Flow or an app tags the customer after the order, and access follows that tag. With `levels`, members who already hold one of them see "You're on this plan." (`heldLabel`) in place of the button; the Plan block has the same choice:
491
+ On Cloudflare Workers, `redeemidGuard({ rateLimit: true })` limits how often one visitor can start Sign in, Buy or a cart change, the routes that call Shopify. Add a [Rate Limiting binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/) named `REDEEMID_RATE_LIMIT`, which sets the limit and period:
355
492
 
356
- ```astro
357
- ---
358
- import Plan from "@redeemid/emdash/components/Plan.astro";
359
- ---
360
- <Plan handle="gold-membership" label="Join Gold" levels={["gold"]}>
361
- <p>Shown if the product can't be loaded.</p>
362
- </Plan>
493
+ ```jsonc
494
+ "ratelimits": [{ "name": "REDEEMID_RATE_LIMIT", "namespace_id": "1001", "simple": { "limit": 20, "period": 60 } }]
363
495
  ```
364
496
 
365
- ### Caching
497
+ 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. If the binding is missing or fails, nothing is limited. Off by default; leave it off on Node.
366
498
 
367
- `redeemidGuard()` keeps every site page private for a signed-in visitor or a visitor with a cart: `Cache-Control: private, no-store`, and out of Astro's route cache (it calls `cache.set(false)` after the page has set its own cache hint). Visitors with neither get the same page, so their pages stay cacheable.
499
+ ## Reference
368
500
 
369
- A route cache keys pages by URL alone and serves a stored page without running any code. So if the site turns on Astro's route cache, don't cache the pages that render `<Content>` or `<Gate>` for locked entries: a member would get the visitor's stored copy, wall included. Call `Astro.cache.set(hint)` only on pages whose entries no rule covers.
501
+ ### Config
370
502
 
371
- ### Rate limits
503
+ Pass one object to both `redeemid(config)` and `redeemidPlugin(config)`. `levels`, `rules`, `meter`, `features`, `files`, `combine`, `teaser` and `join` are defaults that the Access page can replace.
372
504
 
373
- On Cloudflare Workers, `redeemidGuard({ rateLimit: true })` limits how often one visitor can start Sign in, Buy or a cart change, the routes that make calls to Shopify. Add a [Rate Limiting binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/) named `REDEEMID_RATE_LIMIT` to your wrangler config; the limit and period are set there:
505
+ | Option | Type | What it does |
506
+ | --- | --- | --- |
507
+ | `shop` | `string` | The store's `*.myshopify.com` domain (a custom customer-account domain works too). Leave it out to read `REDEEMID_SHOPIFY_STORE_DOMAIN`. |
508
+ | `label` | `string` | The sign-in button's label. Default "Sign in". |
509
+ | `levels` | `{ slug, name, tags, match?, wall?, joinUrl? }[]` | Levels from customer tags. `match: "all"` needs every tag. |
510
+ | `rules` | `{ collection, taxonomy?, term?, levels, open?, drip?, meter? }[]` | What needs which levels. See [rules](#rules). |
511
+ | `combine` | `"all"` \| `"any"` | How several term rules on one entry combine. Default `"all"`. |
512
+ | `join` | `{ href, label? }` | The wall's join link. |
513
+ | `teaser` | `{ [collection]: words }` | Teaser length for locked entries with no paywall break. |
514
+ | `meter` | `{ limit, period?, collections? }` | Free reads for signed-in readers. |
515
+ | `features` | `{ slug, name, levels?, tags? }[]` | Perks held through levels or tags. |
516
+ | `preferences` | `{ key, access? }[]` | Customer metafields members see (`access: "view"`) or change. |
517
+ | `files` | `{ folder, levels?, features?, entries? }[]` | Protected media folders. |
518
+ | `text` | `{ [original]: replacement }` | Replacement text, one string at a time. |
519
+ | `events` | `{ url, only? }` | Signed outbound events. |
520
+ | `agents` | `{ actions?: boolean }` | Also offer resync and tag edits to agents and the command line. |
521
+ | `market` | `{ country?, language? }` | Local prices: a fixed two-letter `country`, or `"visitor"` for each visitor's own. |
522
+ | `checkout` | `{ signIn?: false }` | `signIn: false` stops passing a member's store sign-in to checkout. |
523
+ | `schema` | `false` | Turn off the paywalled-content JSON-LD. |
524
+ | `memberPaths` | `string[]` | Extra `/_emdash` path prefixes signed-in members may use. Everything else there is refused to them. |
525
+ | `entries` | `{ ["collection:id"]: EntryRule }` | Entries' own rules. Usually set in the editor's Access panel. |
526
+ | `allowInsecureShop` | `boolean` | Local tests only: lets `shop` be an `http://` fake store. Never set it on a live site. |
527
+
528
+ `redeemidGuard(options)` takes `{ rateLimit?: boolean }`.
529
+
530
+ ### Secrets
531
+
532
+ | Variable | Needed for | What it is |
533
+ | --- | --- | --- |
534
+ | `REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID` | Sign-in | Client ID of the Headless storefront's Customer Account API client |
535
+ | `REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_SECRET` | Sign-in, confidential clients | Its client secret. Leave unset for a public client. |
536
+ | `REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_SECRET` | Webhooks | Client secret of the app that sends the webhooks. Shopify signs each webhook with it. |
537
+ | `REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID` | Staff tools, preferences, privacy erasure requests | Client ID of that same app. Optional. |
538
+ | `REDEEMID_SHOPIFY_STOREFRONT_PRIVATE_TOKEN` | Plans, shop, checkout | Storefront API private access token, used server-side |
539
+ | `REDEEMID_SHOPIFY_STOREFRONT_PUBLIC_TOKEN` | Plans, shop, checkout | Public access token, only used without the private one |
540
+ | `REDEEMID_SHOPIFY_STORE_DOMAIN` | Discovery | The store's domain, only when `shop` isn't in the config |
541
+ | `REDEEMID_EVENTS_SECRET` | Events | Signs outbound events. Set it with `events.url`. |
374
542
 
375
- ```jsonc
376
- "ratelimits": [{ "name": "REDEEMID_RATE_LIMIT", "namespace_id": "1001", "simple": { "limit": 20, "period": 60 } }]
377
- ```
543
+ The Status page shows each one as Set or Missing, and lists any `REDEEMID_` variable that is set but not read, by name only, so a misspelt name stands out. Authorization, token and logout endpoints need no setting: RedeemID reads them from the store's discovery documents.
378
544
 
379
- Each action counts separately per visitor (by `CF-Connecting-IP`). A visitor over the limit goes back to the page they came from with the `too_many_requests` notice. If the binding is missing or fails, nothing is limited. Off by default; leave it off on Node.
545
+ ### Components
380
546
 
381
- ### Shop
547
+ Import each from `@redeemid/emdash/components/<Name>.astro`.
382
548
 
383
- Products, collections and the cart come from the Storefront API; Shopify works out prices, discounts, taxes and stock.
549
+ | Component | Props | Use |
550
+ | --- | --- | --- |
551
+ | `Content` | `value`, `collection`, `terms`, `id`, `title`; slot `locked` | An entry body with breaks, Members only parts, teaser, meter and wall |
552
+ | `Gate` | `collection`, `terms`, `id`, `levels`; slot `locked` | Locks any part of a template |
553
+ | `LockedPanel` | `reason`, `required`, … | The wall on its own |
554
+ | `AccessBadge` | `collection`, `terms`, `content`, `id`, `free`, `class` | What an entry needs, with a lock for readers kept out |
555
+ | `Account` | | Sign in, or the member's name, levels, Account and Sign out |
556
+ | `MemberCard` | `title` | Levels, past levels, meter, features, Refresh membership |
557
+ | `Preferences` | `title`, `heading`, `label`; slot `signed-out` | The member's preference form |
558
+ | `YourData` | `title`, `heading` | Download or delete my data |
559
+ | `Feature` | `slug`; slot `else` | Shows its content to members holding the feature |
560
+ | `Notices` | `messages` | The message for `?redeemid_notice=` |
561
+ | `Plan` | `handle`, `label`, `title`, `levels`, `heldLabel` | A product with a buy button |
562
+ | `Product` | `handle`, `heading`, `compact`, `html`, `schema`, `addLabel`, `buyLabel` | A product page |
563
+ | `Collection` | `handle`, `limit`, `productHref`, `showTitle`, `browse`, `quickAdd` | A grid of products, with filters and pages |
564
+ | `ProductSearch` | `productHref`, `limit`, `boxOnly`, `action`, `label`, `quickAdd` | Product search |
565
+ | `Recommendations` | `handle`, `intent`, `limit`, `productHref`, `title`, `quickAdd` | Shopify's recommendations |
566
+ | `ProductGrid` | `products`, `productHref`, `heading`, `quickAdd` | Product cards, for your own lists |
567
+ | `Cart` | `label`, `href`, `drawer` | The cart count, linking to checkout or opening the drawer |
568
+ | `CartView` | `productHref`, `empty` | A cart page |
569
+ | `CartDrawer` | `title` | The cart in a slide-in dialog |
570
+ | `RedeemIDClient` | | The browser client, `window.redeemid` |
571
+ | `Contribution` | `handle`, `amounts`, `initial`, `label`, `title` | Pay what you want |
572
+
573
+ ### Editor blocks
574
+
575
+ `redeemidPlugin()` adds these Portable Text blocks to the EmDash editor. `<Content>` renders them.
576
+
577
+ | Block | Type | Does |
578
+ | --- | --- | --- |
579
+ | Paywall break | `redeemid-paywall` | Members-only content starts here, for the levels picked or the entry's rule |
580
+ | Members only / Members only: end | `redeemid-members`, `redeemid-members-end` | A members-only part in the middle of a body |
581
+ | Sold on its own | `redeemid-item` | Sells the entry by itself to holders of a tag |
582
+ | Plan | `redeemid-plan` | A product with a buy button |
583
+ | Product | `redeemid-product` | A product with variants and Add to cart |
584
+ | Collection | `redeemid-collection` | A grid of products, optionally with filters, pages and Add to cart |
585
+ | Product search | `redeemid-search` | A search box with Shopify's results |
586
+ | Contribution | `redeemid-contribution` | Pay what you want |
587
+ | Preferences | `redeemid-preferences` | The member's preference form |
588
+
589
+ ### Modules
590
+
591
+ | Import | Gives |
592
+ | --- | --- |
593
+ | `@redeemid/emdash` | `redeemid()`, `redeemidPlugin()`, `redeemidGuard()`, config types |
594
+ | `@redeemid/emdash/access` | `canRead()`, `readEntry()`, `entryRequirement()`, `viewer()`, and links: `signInUrl()`, `signOutUrl()`, `accountUrl()`, `cartUrl()`, `refreshUrl()` |
595
+ | `@redeemid/emdash/features` | `hasFeature()`, `viewerFeatures()` |
596
+ | `@redeemid/emdash/preferences` | `readPreferences()`, `savePreferences()` |
597
+ | `@redeemid/emdash/storefront` | `product()`, `collection()`, `plan()`, `money()` and the cart calls |
598
+ | `@redeemid/emdash/catalog` | `searchProducts()`, `collectionPage()`, `suggest()`, `recommendations()` |
599
+ | `@redeemid/emdash/events` | `signBody(body, secret)`, the signature a receiver checks against `X-RedeemID-Signature` |
600
+ | `@redeemid/emdash/text` | `STRINGS`, the list of every replaceable string |
601
+ | `@redeemid/emdash/contract` | The stable API as a list, and `API_VERSION` |
384
602
 
385
- ```astro
386
- ---
387
- import Product from "@redeemid/emdash/components/Product.astro";
388
- import Collection from "@redeemid/emdash/components/Collection.astro";
389
- import CartView from "@redeemid/emdash/components/CartView.astro";
390
- import Contribution from "@redeemid/emdash/components/Contribution.astro";
391
- ---
392
- <!-- /shop/ -->
393
- <Collection handle="shop" productHref="/shop/{handle}/" showTitle />
394
- <!-- /shop/[handle]/ -->
395
- <Product handle={Astro.params.handle} heading="h1"><p>No such product.</p></Product>
396
- <!-- /cart/ -->
397
- <CartView productHref="/shop/{handle}/" />
398
- <!-- anywhere -->
399
- <Contribution handle="support" amounts={[5, 10, 25]} />
400
- ```
603
+ 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).
401
604
 
402
- - `<Product>`: image, title, price, description, the variants (sold-out ones shown but not choosable), a quantity, Add to cart (back to the page with the `cart_added` notice) and Buy now (straight to checkout).
403
- - `<Collection>`: the collection's products (`limit`, up to 50) as cards linking to `productHref`.
404
- - `<CartView>`: each line with its price, a quantity to change (0 removes it) and Remove, a discount code field, the subtotal and total, and Checkout. Pages with it are sent `Cache-Control: private, no-store`.
405
- - `<Contribution>`: pay what you want. Make a Shopify product priced at 1.00; the amount the reader picks becomes the quantity, so Shopify charges exactly that.
605
+ ### Routes
406
606
 
407
- The forms post to `/_emdash/api/auth/redeemid/cart-edit`, which reads every product and variant from Shopify again and only changes lines in the visitor's own cart. A new cart carries the `_redeemid_page` attribute (the page the first item was added on), which shows on the order in Shopify admin; Shopify hides attributes starting with `_` at checkout. Editors get the same with the Product, Collection and Contribution blocks.
607
+ 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/`.
408
608
 
409
- Or ask directly:
609
+ 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`, `customer.tags_removed` and `customers/redact`.
410
610
 
411
- ```ts
412
- import { canRead } from "@redeemid/emdash/access";
611
+ ### Notices
413
612
 
414
- const verdict = await canRead(Astro, { collection: "posts", terms: post.data.terms });
415
- // verdict.allowed, verdict.reason: open | allowed | sign_in | upgrade | disabled
416
- // readEntry() adds drip (verdict.opensAt) and metered (verdict.meterLeft)
417
- ```
613
+ Routes send readers back with `?redeemid_notice=<code>`, and `<Notices />` shows the message.
418
614
 
419
- Pages rendered for a signed-in member are sent with `Cache-Control: private, no-store`; pages for visitors stay cacheable.
615
+ | From | Codes |
616
+ | --- | --- |
617
+ | Sign in | `not_configured`, `sign_in_unavailable`, `sign_in_expired`, `staff_account`, `account_disabled`, `site_not_ready`, `email_required`, `token_failed`, `bad_id_token`, `identity_failed` |
618
+ | Buy and cart | `plan_unavailable`, `checkout_failed`, `cart_empty`, `cart_added`, `cart_updated`, `discount_applied`, `discount_not_applicable` |
619
+ | Account | `account_unavailable`, `refreshed` |
620
+ | Preferences | `preferences_saved`, `preferences_refused`, `preferences_unavailable` |
621
+ | Privacy | `data_erased`, `erase_unconfirmed` |
622
+ | Rate limits | `too_many_requests` |
623
+
624
+ Reword one with `messages={{ cart_empty: "…" }}`, or for every page with `text`.
625
+
626
+ ## Guarantees
627
+
628
+ - Shopify is the only source of entitlements. Nothing on the site grants access.
629
+ - Levels read the mirrored tags as they are, however old. They change only when Shopify sends new ones.
630
+ - Enabled or disabled status comes only from the `customers/enable` and `customers/disable` webhooks.
631
+ - Locked content never reaches the page, the content API, search, feeds or the sitemap for readers kept out. Members are refused EmDash's own APIs except what they need.
632
+ - 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.
633
+ - Customers can't create the first (admin) account on a new site.
634
+ - Webhooks are checked against the app's signature before anything changes.
420
635
 
421
- ## Rules
636
+ ## Troubleshooting
422
637
 
423
- - Shopify is the only source of entitlements. Nothing on the EmDash side grants access.
424
- - Levels read the mirrored tags as they are, however old; they change only when Shopify sends new ones.
425
- - Status (enabled or disabled) comes only from the `customers/enable` and `customers/disable` webhooks.
426
- - Staff accounts (any EmDash role above Subscriber) can't sign in through the store, even with a matching email.
427
- - Customers can't create the first (admin) account on a new site.
638
+ - **A setting shows Missing on the Status page.** Check the name: the Status page lists any `REDEEMID_` variable it doesn't read. Set it as a runtime secret, not a build variable. On Workers, a deploy can remove plain-text dashboard variables, so use encrypted secrets.
639
+ - **Sign-in comes back with a notice.** The code says why; see [notices](#notices). `sign_in_unavailable` means store discovery failed, usually a wrong `shop`. `not_configured` means the client ID or `shop` is missing.
640
+ - **Register webhooks names a refused topic.** The app lacks a scope, such as `read_customer_merge` for `customers/merge`. Add it to the app, have the store accept the new version, and press Register again.
641
+ - **Tags changed in Shopify but the site didn't follow.** Check Webhooks → Received and Health, and App events with Problems only. Resync the member from their page, or ask them to press Refresh membership.
642
+ - **A member sees the visitor's page.** A cache is serving it. See [caching](#caching).
428
643
 
429
644
  ## Develop
430
645
 
@@ -435,9 +650,9 @@ pnpm typecheck
435
650
  pnpm e2e # boots the playground site against a fake Shopify store
436
651
  ```
437
652
 
438
- Releases: bump `version` in package.json and merge to main. The Release workflow runs the tests, then stages that version on npm with a stage-only token. A maintainer approves it on npmjs.com or with `npm stage approve <id>` (2FA), and only then is it public. The same run cuts a matching GitHub release, `v<version>`, with the exact tarball it staged and a `.sha256`.
653
+ `playground/` is the EmDash starter template with RedeemID wired in and locked posts in its seed. The package ships TypeScript source; there is no build step.
439
654
 
440
- `playground/` is the EmDash starter template with RedeemID wired in and two locked posts in its seed.
655
+ **Releases.** Bump `version` in package.json and merge to main. The Release workflow runs the tests, stages that version on npm with a stage-only token and cuts a matching GitHub release, `v<version>`, with the exact tarball and a `.sha256`. A maintainer approves the npm stage (2FA) before it's public.
441
656
 
442
657
  ## License
443
658