@redeemid/emdash 0.0.15 → 0.0.17

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 (46) hide show
  1. package/README.md +416 -223
  2. package/docs/shopify-setup.md +1 -1
  3. package/package.json +2 -1
  4. package/src/access-admin.ts +754 -0
  5. package/src/access.ts +84 -18
  6. package/src/admin-api.ts +9 -2
  7. package/src/agents.ts +7 -3
  8. package/src/astro-modules.d.ts +4 -0
  9. package/src/components/AccessBadge.astro +7 -5
  10. package/src/components/Account.astro +1 -1
  11. package/src/components/Cart.astro +1 -1
  12. package/src/components/CartView.astro +1 -1
  13. package/src/components/Collection.astro +1 -1
  14. package/src/components/Content.astro +7 -3
  15. package/src/components/Contribution.astro +1 -1
  16. package/src/components/Gate.astro +4 -2
  17. package/src/components/LockedPanel.astro +1 -1
  18. package/src/components/MemberCard.astro +1 -1
  19. package/src/components/Notices.astro +2 -1
  20. package/src/components/Plan.astro +1 -1
  21. package/src/components/Preferences.astro +1 -1
  22. package/src/components/Product.astro +1 -1
  23. package/src/components/YourData.astro +1 -1
  24. package/src/config.ts +17 -5
  25. package/src/descriptor.ts +7 -0
  26. package/src/events.ts +3 -0
  27. package/src/features.ts +1 -1
  28. package/src/files.ts +1 -1
  29. package/src/guard.ts +5 -3
  30. package/src/index.ts +4 -1
  31. package/src/log.ts +96 -0
  32. package/src/monitor.ts +560 -0
  33. package/src/plugin.ts +202 -86
  34. package/src/rate-limit-cloudflare.ts +20 -0
  35. package/src/rate-limit.ts +66 -0
  36. package/src/routes/callback.ts +21 -4
  37. package/src/routes/preferences.ts +2 -2
  38. package/src/routes/privacy.ts +2 -2
  39. package/src/routes/webhook.ts +24 -11
  40. package/src/settings.ts +195 -0
  41. package/src/site-config.ts +38 -5
  42. package/src/staff.ts +12 -23
  43. package/src/storage.ts +12 -0
  44. package/src/text.ts +1 -0
  45. package/src/version.ts +1 -1
  46. package/src/webhooks.ts +2 -1
package/README.md CHANGED
@@ -1,67 +1,107 @@
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.15, 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.17, 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, 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
+ - Product pages, collection grids, a cart page and pay-what-you-want from the Storefront API.
62
+ - Signed outbound events for a CRM or email tool, MCP tools for AI agents, and a `redeemid-emdash` command.
63
+ - Replacement text for every string readers see, Google's paywalled-content markup, cache safety and optional rate limits.
64
+
65
+ ## Requirements
66
+
67
+ - **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.
68
+ - **A Shopify store with the new customer accounts** (Settings → Customer accounts). Classic accounts have no Customer Account API.
69
+ - **The Headless sales channel**, for the sign-in client and Storefront API tokens.
70
+ - **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.
71
+ - **HTTPS.** Shopify only accepts `https` callback URIs, so a local site needs a tunnel to sign in for real.
72
+
73
+ ## Quick start
74
+
75
+ ### 1. Set up Shopify
76
+
77
+ Follow [docs/shopify-setup.md](docs/shopify-setup.md). It gives you a Customer Account API client, Storefront API tokens and a webhook app.
78
+
79
+ ### 2. Install
37
80
 
38
81
  ```sh
39
82
  pnpm add @redeemid/emdash
40
83
  ```
41
84
 
85
+ RedeemID is a native EmDash plugin: an npm dependency, not a registry plugin, because sign-in and page gating run in the site process.
86
+
87
+ ### 3. Configure
88
+
42
89
  ```js title="astro.config.mjs"
43
90
  import { redeemid, redeemidGuard, redeemidPlugin } from "@redeemid/emdash";
91
+ import { defineConfig } from "astro/config";
44
92
  import emdash from "emdash/astro";
45
93
 
46
94
  const config = {
47
95
  shop: "my-store.myshopify.com",
48
96
  levels: [
49
97
  { slug: "member", name: "Member", tags: ["member"] },
50
- // wall: what the wall says to readers this level would let in; joinUrl overrides join.href.
51
98
  { 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
99
  ],
55
100
  rules: [
56
101
  { collection: "posts", taxonomy: "category", term: "members", levels: ["member", "pro"] },
57
102
  { collection: "posts", taxonomy: "category", term: "pro", levels: ["pro"] },
58
103
  ],
59
- // Optional: where the wall's join link goes.
60
104
  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
105
  };
66
106
 
67
107
  export default defineConfig({
@@ -69,37 +109,108 @@ export default defineConfig({
69
109
  emdash({
70
110
  // database, storage, …
71
111
  authProviders: [redeemid(config)], // sign-in, webhooks, cart and checkout routes
72
- plugins: [redeemidPlugin(config)], // admin pages and the editor blocks
112
+ plugins: [redeemidPlugin(config)], // admin pages and editor blocks
73
113
  }),
74
- redeemidGuard(),
114
+ redeemidGuard(), // gates pages and keeps members out of EmDash's own APIs
75
115
  ],
76
116
  });
77
117
  ```
78
118
 
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:
119
+ Pass the same `config` to `redeemid()` and `redeemidPlugin()`. Every option is in the [config reference](#config).
80
120
 
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. |
121
+ 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).
122
+
123
+ ### 4. Set the secrets
91
124
 
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.
125
+ Set them as runtime secrets of the site (`.env` locally, `wrangler secret put` on Workers), not as build-time variables:
93
126
 
94
- RedeemID finds the authorization, token and logout endpoints itself from the store's discovery documents, so they need no setting.
127
+ ```sh
128
+ REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID=… # sign-in
129
+ REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_SECRET=… # verifies webhooks
130
+ REDEEMID_SHOPIFY_STOREFRONT_PRIVATE_TOKEN=… # plans, shop and checkout
131
+ ```
132
+
133
+ The [secrets reference](#secrets) lists the optional ones.
134
+
135
+ ### 5. Render content through RedeemID
136
+
137
+ ```astro
138
+ ---
139
+ import Account from "@redeemid/emdash/components/Account.astro";
140
+ import Cart from "@redeemid/emdash/components/Cart.astro";
141
+ import Content from "@redeemid/emdash/components/Content.astro";
142
+ import Notices from "@redeemid/emdash/components/Notices.astro";
143
+ ---
144
+ <header><Account /> <Cart /></header>
145
+ <Notices />
146
+ <Content value={post.data.content} collection="posts" terms={post.data.terms} id={post.id} title={post.data.title} />
147
+ ```
95
148
 
96
- In Shopify, give the Customer Account API client these settings:
149
+ Use `<Content>` in place of `<PortableText>` for every body that may be locked. Plain `<PortableText>` shows everything.
150
+
151
+ ### 6. Check it
152
+
153
+ 1. Finish EmDash setup as staff first. Store customers can't create the first account.
154
+ 2. Open **Plugins → RedeemID → Status**. Every setting should say Set. Then open **Webhooks → Setup** and press **Register**.
155
+ 3. Tag a test customer `member` in Shopify, open a locked post, and sign in as that customer. The post opens.
156
+ 4. Remove the tag in Shopify. The post locks again once the webhook arrives.
157
+
158
+ ## Content and templates
159
+
160
+ EmDash has no content filter hook, so templates decide what to render.
161
+
162
+ **`<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).
163
+
164
+ ```astro
165
+ <Content value={post.data.content} collection="posts" terms={post.data.terms} id={post.id} title={post.data.title}>
166
+ <a slot="locked" href="/join/">Become a member</a>
167
+ </Content>
168
+ ```
97
169
 
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/`
170
+ 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.
101
171
 
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`.
172
+ **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.
173
+
174
+ **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.
175
+
176
+ **`<Gate>`** locks any part of a template that isn't the body:
177
+
178
+ ```astro
179
+ <Gate levels={["pro"]}><a href="/reports/archive.zip">Download the archive</a></Gate>
180
+ ```
181
+
182
+ **`<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:
183
+
184
+ ```astro
185
+ <AccessBadge collection="posts" terms={post.data.terms} content={post.data.content} />
186
+ ```
187
+
188
+ **`<Notices />`** goes near the top of the layout. It shows the message for a `?redeemid_notice=` code (see [notices](#notices)) and nothing otherwise.
189
+
190
+ **`<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.
191
+
192
+ **`<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.
193
+
194
+ ```astro
195
+ <Plan handle="gold-membership" label="Join Gold" levels={["gold"]}>
196
+ <p>Shown if the product can't be loaded.</p>
197
+ </Plan>
198
+ ```
199
+
200
+ **In your own code** (`@redeemid/emdash/access`):
201
+
202
+ ```ts
203
+ import { canRead, entryRequirement, readEntry } from "@redeemid/emdash/access";
204
+
205
+ const verdict = await canRead(Astro, { collection: "posts", terms: post.data.terms });
206
+ // verdict.allowed; verdict.reason: open | allowed | sign_in | upgrade | disabled | unconfigured
207
+ const read = await readEntry(Astro, { collection: "posts", terms: post.data.terms, id: post.id });
208
+ // adds drip (read.opensAt) and the meter (read.meterLeft), and counts a metered read
209
+ ```
210
+
211
+ `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.
212
+
213
+ ## Guides
103
214
 
104
215
  ### Rules
105
216
 
@@ -108,30 +219,61 @@ Each rule names a collection and the levels, any of which may read. An empty `le
108
219
  | Rule | Covers |
109
220
  | --- | --- |
110
221
  | `{ 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. |
222
+ | `{ 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
223
  | `{ collection: "posts", taxonomy: "tag", term: "free-to-read", levels: [], open: true }` | Opens entries with that term to everyone, whatever else covers them |
224
+ | `{ collection: "pages", levels: [], open: true }` | Opens a whole collection to everyone, unless a term rule locks an entry |
225
+ | `{ 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
226
  | `{ collection: "*", levels: [] }` | The site default, for collections with no rule of their own |
114
-
115
227
  | `{ collection: "posts", taxonomy: "category", term: "archive", levels: ["member"], drip: 30 }` | Drip: members read these entries 30 days after their level started |
116
228
  | `{ collection: "posts", taxonomy: "category", term: "exclusive", levels: ["pro"], meter: false }` | The meter never opens these entries |
117
229
 
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.
230
+ 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
231
 
120
- ### Selling one entry on its own
232
+ 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
233
 
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:
234
+ ### Access settings in the admin
123
235
 
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.
236
+ 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**:
237
+
238
+ - **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.
239
+ - **Site rules.** The site default, how term rules combine, the meter and the wall's join link.
240
+ - **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.
241
+ - **Taxonomies.** Rules for terms in every taxonomy, for one collection or every collection the taxonomy is on.
242
+ - **Features** and **Protected files.**
243
+
244
+ 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.
245
+
246
+ 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:
247
+
248
+ | Choice | Readers need |
249
+ | --- | --- |
250
+ | Follow the site rules | What the rules say (the default) |
251
+ | Free to everyone | Nothing |
252
+ | Only the levels ticked | One of those levels (none ticked: any signed-in member) |
253
+ | The site rules, and also a level ticked | What the rules say and one of those levels |
127
254
 
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.
255
+ 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
256
 
130
257
  ### Meter and drip
131
258
 
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.
259
+ ```ts
260
+ meter: { limit: 3, period: "month", collections: ["posts"] },
261
+ teaser: { posts: 40 },
262
+ ```
263
+
264
+ 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.
265
+
266
+ 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.
133
267
 
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.
268
+ ### Selling one entry on its own
269
+
270
+ 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:
271
+
272
+ - 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.
273
+ - Locked readers see **Buy just this** on the wall with the product's price, beside the level's offer.
274
+ - `<AccessBadge>` drops the lock for buyers, and protected files in an `entries: true` folder that the entry uses reach them too.
275
+
276
+ 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
277
 
136
278
  ### Features
137
279
 
@@ -142,7 +284,7 @@ features: [
142
284
  ],
143
285
  ```
144
286
 
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.
287
+ A member holds a feature through any of its levels or tags. `<MemberCard>` lists them under the member's levels.
146
288
 
147
289
  ```astro
148
290
  ---
@@ -153,6 +295,29 @@ import { hasFeature } from "@redeemid/emdash/features";
153
295
  {!(await hasFeature(Astro, "ad-free")) && <aside class="ad">…</aside>}
154
296
  ```
155
297
 
298
+ ### Shop
299
+
300
+ Products, collections and the cart come from the Storefront API. Shopify works out prices, discounts, taxes and stock.
301
+
302
+ ```astro
303
+ <!-- /shop/ -->
304
+ <Collection handle="shop" productHref="/shop/{handle}/" showTitle />
305
+ <!-- /shop/[handle]/ -->
306
+ <Product handle={Astro.params.handle} heading="h1"><p>No such product.</p></Product>
307
+ <!-- /cart/ -->
308
+ <CartView productHref="/shop/{handle}/" />
309
+ <!-- anywhere -->
310
+ <Contribution handle="support" amounts={[5, 10, 25]} />
311
+ ```
312
+
313
+ - `<Product>`: image, title, price, description, variants (sold-out ones shown but not choosable), quantity, Add to cart and Buy now.
314
+ - `<Collection>`: up to 50 products as cards linking to `productHref`.
315
+ - `<CartView>`: each line with a quantity to change (0 removes it) and Remove, a discount code field, the totals and Checkout.
316
+ - `<Contribution>`: pay what you want. Price a product at 1.00, and the amount the reader picks becomes the quantity.
317
+ - `<Cart>`: the item count, linking to checkout.
318
+
319
+ The forms post to `/_emdash/api/auth/redeemid/cart-edit`, which reads every product and variant from Shopify again and only changes 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. Editors get the same with the Product, Collection and Contribution blocks.
320
+
156
321
  ### Preferences
157
322
 
158
323
  ```ts
@@ -162,9 +327,9 @@ preferences: [
162
327
  ],
163
328
  ```
164
329
 
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.
330
+ 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
331
 
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.
332
+ `<Preferences>` (or its block) is a form that works without JavaScript. Visitors see its `signed-out` slot.
168
333
 
169
334
  | Shopify type | Value |
170
335
  | --- | --- |
@@ -176,9 +341,7 @@ Define each field in Shopify (**Settings → Custom data → Customers**). The d
176
341
  | Decimal | A number, as a string |
177
342
  | Date | `YYYY-MM-DD` |
178
343
 
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.
344
+ 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
345
 
183
346
  ### Protected files
184
347
 
@@ -191,69 +354,78 @@ files: [
191
354
  ],
192
355
  ```
193
356
 
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:
357
+ 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
358
 
196
- - **Allowed:** the file, sent with `Cache-Control: private, no-store`.
359
+ - **Allowed:** the file, with `Cache-Control: private, no-store`.
197
360
  - **Signed out:** a 401 page with a sign-in link that comes back to the file.
198
361
  - **Signed in without access:** a 403 page.
199
362
 
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.
201
-
202
- Limits:
363
+ 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.
203
364
 
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.
365
+ 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
366
 
208
367
  ### Privacy requests
209
368
 
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.
369
+ 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
370
 
212
- | Started | What happens |
371
+ | Started by | What happens |
213
372
  | --- | --- |
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. |
373
+ | 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. |
374
+ | 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
375
  | 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
376
  | Shopify `customers/redact` | The member's record and meter are erased. |
218
377
  | Shopify `shop/redact` | Every member record and meter is erased. |
219
378
 
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.
379
+ 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.
380
+
381
+ ### Staff tools
382
+
383
+ 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**:
384
+
385
+ - **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.
386
+ - **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.
387
+ - **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.
388
+ - **Access test.** A collection, an entry, terms and a reader's tags in; the verdict out.
389
+
390
+ Resync updates email, name and tags. Enabled or disabled status comes only from the enable and disable webhooks.
391
+
392
+ ### Webhooks, app events and reports
221
393
 
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.
394
+ Members gain and lose access only when Shopify sends tag changes, so RedeemID shows what arrived and warns when deliveries stop.
223
395
 
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.
396
+ - **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).
397
+ - **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.
398
+ - **Webhooks → Setup.** The endpoint, whether signatures can be checked, and the subscriptions.
399
+ - **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.
400
+ - **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.
401
+
402
+ 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
403
 
226
404
  ### AI agents and the command line
227
405
 
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.
406
+ 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
407
 
230
408
  | MCP tool | Command | What it answers |
231
409
  | --- | --- | --- |
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:
410
+ | `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`. |
411
+ | `list_levels` | `levels` | Every level, its tags, wall text and join link |
412
+ | `get_rules` | `rules` | How rules combine, every rule, the meter, teasers, features and file rules |
413
+ | `get_member` | `member <id or email>` | One member: status, tags, levels with start dates, past levels, features, last sync |
414
+ | `site_health` | `health` | Each setting (set or not, never a value), the last webhook, open privacy requests, ignored text replacements |
415
+ | `level_changes` | `changes --since <date>` | Who joined or left a level since a date |
240
416
 
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. |
417
+ 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
418
 
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`.
419
+ **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
420
 
249
- **Command line.**
421
+ **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
422
 
251
423
  ```sh
252
424
  npx -p @redeemid/emdash redeemid-emdash access posts --id my-story --tags gold-member --url https://example.com
253
425
  npx -p @redeemid/emdash redeemid-emdash member jane@example.com
254
426
  ```
255
427
 
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.
428
+ Each command calls a staff-only route at `/_emdash/api/plugins/redeemid/<name>`, which scripts can POST to directly.
257
429
 
258
430
  ### Replacement text
259
431
 
@@ -265,11 +437,9 @@ text: {
265
437
  },
266
438
  ```
267
439
 
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).
440
+ 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.
269
441
 
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.
271
-
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.
442
+ 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
443
 
274
444
  ### Events
275
445
 
@@ -277,144 +447,167 @@ Props win over `text`: a `label` or `title` you pass, or Notices `messages`, is
277
447
  events: { url: "https://hooks.example.com/redeemid", only: ["member.signed_in", "member.levels_changed"] },
278
448
  ```
279
449
 
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.
450
+ 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
451
 
282
452
  | Event | `data` |
283
453
  | --- | --- |
284
454
  | `member.signed_in` | `customerId`, `levels`, `first` (first sign-in), `refresh` |
285
455
  | `member.levels_changed` | `customerId`, `levels`, `added`, `removed`, `source` (`sign-in`, the webhook topic, or a staff resync or tag edit) |
286
456
  | `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) |
457
+ | `meter.exhausted` | The same, sent with the last free read |
458
+ | `preferences.saved` | `customerId`, `fields` (the names changed, never the answers) |
289
459
  | `member.erased` | `customerId`, `source` (`member`, `staff` or `shopify`) |
290
460
 
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.
461
+ No event carries an email address or a name; match people by `customerId`. Leave `only` out to send every event.
323
462
 
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:
463
+ ### Caching
329
464
 
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
- ```
465
+ `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.
336
466
 
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).
467
+ 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.
338
468
 
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.
469
+ ### Rate limits
340
470
 
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:
471
+ 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:
342
472
 
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 />
473
+ ```jsonc
474
+ "ratelimits": [{ "name": "REDEEMID_RATE_LIMIT", "namespace_id": "1001", "simple": { "limit": 20, "period": 60 } }]
350
475
  ```
351
476
 
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.
477
+ 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.
353
478
 
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:
479
+ ## Reference
355
480
 
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>
363
- ```
481
+ ### Config
364
482
 
365
- ### Caching
483
+ 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.
366
484
 
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.
485
+ | Option | Type | What it does |
486
+ | --- | --- | --- |
487
+ | `shop` | `string` | The store's `*.myshopify.com` domain (a custom customer-account domain works too). Leave it out to read `REDEEMID_SHOPIFY_STORE_DOMAIN`. |
488
+ | `label` | `string` | The sign-in button's label. Default "Sign in". |
489
+ | `levels` | `{ slug, name, tags, match?, wall?, joinUrl? }[]` | Levels from customer tags. `match: "all"` needs every tag. |
490
+ | `rules` | `{ collection, taxonomy?, term?, levels, open?, drip?, meter? }[]` | What needs which levels. See [rules](#rules). |
491
+ | `combine` | `"all"` \| `"any"` | How several term rules on one entry combine. Default `"all"`. |
492
+ | `join` | `{ href, label? }` | The wall's join link. |
493
+ | `teaser` | `{ [collection]: words }` | Teaser length for locked entries with no paywall break. |
494
+ | `meter` | `{ limit, period?, collections? }` | Free reads for signed-in readers. |
495
+ | `features` | `{ slug, name, levels?, tags? }[]` | Perks held through levels or tags. |
496
+ | `preferences` | `{ key, access? }[]` | Customer metafields members see (`access: "view"`) or change. |
497
+ | `files` | `{ folder, levels?, features?, entries? }[]` | Protected media folders. |
498
+ | `text` | `{ [original]: replacement }` | Replacement text, one string at a time. |
499
+ | `events` | `{ url, only? }` | Signed outbound events. |
500
+ | `agents` | `{ actions?: boolean }` | Also offer resync and tag edits to agents and the command line. |
501
+ | `schema` | `false` | Turn off the paywalled-content JSON-LD. |
502
+ | `memberPaths` | `string[]` | Extra `/_emdash` path prefixes signed-in members may use. Everything else there is refused to them. |
503
+ | `entries` | `{ ["collection:id"]: EntryRule }` | Entries' own rules. Usually set in the editor's Access panel. |
504
+ | `allowInsecureShop` | `boolean` | Local tests only: lets `shop` be an `http://` fake store. Never set it on a live site. |
505
+
506
+ `redeemidGuard(options)` takes `{ rateLimit?: boolean }`.
507
+
508
+ ### Secrets
509
+
510
+ | Variable | Needed for | What it is |
511
+ | --- | --- | --- |
512
+ | `REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID` | Sign-in | Client ID of the Headless storefront's Customer Account API client |
513
+ | `REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_SECRET` | Sign-in, confidential clients | Its client secret. Leave unset for a public client. |
514
+ | `REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_SECRET` | Webhooks | Client secret of the app that sends the webhooks. Shopify signs each webhook with it. |
515
+ | `REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID` | Staff tools, preferences, privacy erasure requests | Client ID of that same app. Optional. |
516
+ | `REDEEMID_SHOPIFY_STOREFRONT_PRIVATE_TOKEN` | Plans, shop, checkout | Storefront API private access token, used server-side |
517
+ | `REDEEMID_SHOPIFY_STOREFRONT_PUBLIC_TOKEN` | Plans, shop, checkout | Public access token, only used without the private one |
518
+ | `REDEEMID_SHOPIFY_STORE_DOMAIN` | Discovery | The store's domain, only when `shop` isn't in the config |
519
+ | `REDEEMID_EVENTS_SECRET` | Events | Signs outbound events. Set it with `events.url`. |
368
520
 
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.
521
+ 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.
370
522
 
371
- ### Shop
523
+ ### Components
372
524
 
373
- Products, collections and the cart come from the Storefront API; Shopify works out prices, discounts, taxes and stock.
525
+ Import each from `@redeemid/emdash/components/<Name>.astro`.
374
526
 
375
- ```astro
376
- ---
377
- import Product from "@redeemid/emdash/components/Product.astro";
378
- import Collection from "@redeemid/emdash/components/Collection.astro";
379
- import CartView from "@redeemid/emdash/components/CartView.astro";
380
- import Contribution from "@redeemid/emdash/components/Contribution.astro";
381
- ---
382
- <!-- /shop/ -->
383
- <Collection handle="shop" productHref="/shop/{handle}/" showTitle />
384
- <!-- /shop/[handle]/ -->
385
- <Product handle={Astro.params.handle} heading="h1"><p>No such product.</p></Product>
386
- <!-- /cart/ -->
387
- <CartView productHref="/shop/{handle}/" />
388
- <!-- anywhere -->
389
- <Contribution handle="support" amounts={[5, 10, 25]} />
390
- ```
527
+ | Component | Props | Use |
528
+ | --- | --- | --- |
529
+ | `Content` | `value`, `collection`, `terms`, `id`, `title`; slot `locked` | An entry body with breaks, Members only parts, teaser, meter and wall |
530
+ | `Gate` | `collection`, `terms`, `id`, `levels`; slot `locked` | Locks any part of a template |
531
+ | `LockedPanel` | `reason`, `required`, … | The wall on its own |
532
+ | `AccessBadge` | `collection`, `terms`, `content`, `id`, `free`, `class` | What an entry needs, with a lock for readers kept out |
533
+ | `Account` | | Sign in, or the member's name, levels, Account and Sign out |
534
+ | `MemberCard` | `title` | Levels, past levels, meter, features, Refresh membership |
535
+ | `Preferences` | `title`, `heading`, `label`; slot `signed-out` | The member's preference form |
536
+ | `YourData` | `title`, `heading` | Download or delete my data |
537
+ | `Feature` | `slug`; slot `else` | Shows its content to members holding the feature |
538
+ | `Notices` | `messages` | The message for `?redeemid_notice=` |
539
+ | `Plan` | `handle`, `label`, `title`, `levels`, `heldLabel` | A product with a buy button |
540
+ | `Product` | `handle`, `heading`, `compact`, `addLabel`, `buyLabel` | A product page |
541
+ | `Collection` | `handle`, `limit`, `productHref`, `showTitle` | A grid of products |
542
+ | `Cart` | `label` | The cart count, linking to checkout |
543
+ | `CartView` | `productHref`, `empty` | A cart page |
544
+ | `Contribution` | `handle`, `amounts`, `initial`, `label`, `title` | Pay what you want |
545
+
546
+ ### Editor blocks
547
+
548
+ `redeemidPlugin()` adds these Portable Text blocks to the EmDash editor. `<Content>` renders them.
549
+
550
+ | Block | Type | Does |
551
+ | --- | --- | --- |
552
+ | Paywall break | `redeemid-paywall` | Members-only content starts here, for the levels picked or the entry's rule |
553
+ | Members only / Members only: end | `redeemid-members`, `redeemid-members-end` | A members-only part in the middle of a body |
554
+ | Sold on its own | `redeemid-item` | Sells the entry by itself to holders of a tag |
555
+ | Plan | `redeemid-plan` | A product with a buy button |
556
+ | Product | `redeemid-product` | A product with variants and Add to cart |
557
+ | Collection | `redeemid-collection` | A grid of products |
558
+ | Contribution | `redeemid-contribution` | Pay what you want |
559
+ | Preferences | `redeemid-preferences` | The member's preference form |
560
+
561
+ ### Modules
562
+
563
+ | Import | Gives |
564
+ | --- | --- |
565
+ | `@redeemid/emdash` | `redeemid()`, `redeemidPlugin()`, `redeemidGuard()`, config types |
566
+ | `@redeemid/emdash/access` | `canRead()`, `readEntry()`, `entryRequirement()`, and links: `signInUrl()`, `signOutUrl()`, `accountUrl()`, `cartUrl()`, `refreshUrl()` |
567
+ | `@redeemid/emdash/features` | `hasFeature()`, `viewerFeatures()` |
568
+ | `@redeemid/emdash/preferences` | `readPreferences()`, `savePreferences()` |
569
+ | `@redeemid/emdash/storefront` | `product()`, `collection()`, `plan()`, `money()` and the cart calls the shop components use |
570
+ | `@redeemid/emdash/events` | `signBody(body, secret)`, the signature a receiver checks against `X-RedeemID-Signature` |
571
+ | `@redeemid/emdash/text` | `STRINGS`, the list of every replaceable string |
391
572
 
392
- - `<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).
393
- - `<Collection>`: the collection's products (`limit`, up to 50) as cards linking to `productHref`.
394
- - `<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`.
395
- - `<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.
573
+ ### Routes
396
574
 
397
- 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.
575
+ Under `/_emdash/api/auth/redeemid/`: `login`, `callback`, `logout`, `webhook`, `buy`, `cart`, `cart-edit`, `account`, `preferences` and `privacy`. Staff-only routes for the admin, agents and the command line are under `/_emdash/api/plugins/redeemid/`.
398
576
 
399
- Or ask directly:
577
+ 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`.
400
578
 
401
- ```ts
402
- import { canRead } from "@redeemid/emdash/access";
579
+ ### Notices
403
580
 
404
- const verdict = await canRead(Astro, { collection: "posts", terms: post.data.terms });
405
- // verdict.allowed, verdict.reason: open | allowed | sign_in | upgrade | disabled
406
- // readEntry() adds drip (verdict.opensAt) and metered (verdict.meterLeft)
407
- ```
581
+ Routes send readers back with `?redeemid_notice=<code>`, and `<Notices />` shows the message.
408
582
 
409
- Pages rendered for a signed-in member are sent with `Cache-Control: private, no-store`; pages for visitors stay cacheable.
583
+ | From | Codes |
584
+ | --- | --- |
585
+ | 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` |
586
+ | Buy and cart | `plan_unavailable`, `checkout_failed`, `cart_empty`, `cart_added`, `cart_updated`, `discount_applied`, `discount_not_applicable` |
587
+ | Account | `account_unavailable`, `refreshed` |
588
+ | Preferences | `preferences_saved`, `preferences_refused`, `preferences_unavailable` |
589
+ | Privacy | `data_erased`, `erase_unconfirmed` |
590
+ | Rate limits | `too_many_requests` |
591
+
592
+ Reword one with `messages={{ cart_empty: "…" }}`, or for every page with `text`.
593
+
594
+ ## Guarantees
595
+
596
+ - Shopify is the only source of entitlements. Nothing on the site grants access.
597
+ - Levels read the mirrored tags as they are, however old. They change only when Shopify sends new ones.
598
+ - Enabled or disabled status comes only from the `customers/enable` and `customers/disable` webhooks.
599
+ - 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.
600
+ - 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.
601
+ - Customers can't create the first (admin) account on a new site.
602
+ - Webhooks are checked against the app's signature before anything changes.
410
603
 
411
- ## Rules
604
+ ## Troubleshooting
412
605
 
413
- - Shopify is the only source of entitlements. Nothing on the EmDash side grants access.
414
- - Levels read the mirrored tags as they are, however old; they change only when Shopify sends new ones.
415
- - Status (enabled or disabled) comes only from the `customers/enable` and `customers/disable` webhooks.
416
- - Staff accounts (any EmDash role above Subscriber) can't sign in through the store, even with a matching email.
417
- - Customers can't create the first (admin) account on a new site.
606
+ - **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.
607
+ - **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.
608
+ - **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.
609
+ - **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.
610
+ - **A member sees the visitor's page.** A cache is serving it. See [caching](#caching).
418
611
 
419
612
  ## Develop
420
613
 
@@ -425,9 +618,9 @@ pnpm typecheck
425
618
  pnpm e2e # boots the playground site against a fake Shopify store
426
619
  ```
427
620
 
428
- 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`.
621
+ `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.
429
622
 
430
- `playground/` is the EmDash starter template with RedeemID wired in and two locked posts in its seed.
623
+ **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.
431
624
 
432
625
  ## License
433
626