@redeemid/emdash 0.0.24 → 0.0.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -638
- package/docs/access.md +131 -0
- package/docs/content.md +84 -0
- package/docs/developer-api.md +25 -5
- package/docs/getting-started.md +94 -0
- package/docs/production.md +52 -0
- package/docs/reference.md +128 -0
- package/docs/shop.md +94 -0
- package/docs/shopify-flow.md +1 -1
- package/docs/shopify-setup.md +4 -4
- package/docs/staff.md +81 -0
- package/package.json +11 -2
- package/src/api-version.ts +1 -1
- package/src/catalog.ts +78 -0
- package/src/components/Content.astro +7 -1
- package/src/components/Gate.astro +3 -0
- package/src/components/MemberCard.astro +3 -0
- package/src/components/Plan.astro +3 -0
- package/src/components/Preferences.astro +3 -0
- package/src/components/YourData.astro +3 -0
- package/src/contract.ts +2 -1
- package/src/guard.ts +23 -2
- package/src/plugin.ts +19 -2
- package/src/route-cache.ts +134 -0
- package/src/settings.ts +9 -0
- package/src/template-scan.ts +33 -6
- package/src/version.ts +1 -1
package/README.md
CHANGED
|
@@ -1,93 +1,47 @@
|
|
|
1
1
|
# RedeemID for EmDash
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@redeemid/emdash)
|
|
4
|
+
[](https://github.com/webmultipliers/redeemid-emdash/actions/workflows/release.yml)
|
|
4
5
|
[](LICENSE)
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
Shopify commerce for [EmDash](https://github.com/emdash-cms/emdash) sites: a storefront, a cart, Shopify checkout, customer accounts, and memberships and paywalls that Shopify decides.
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
Shopify keeps the store, checkout, orders, subscriptions and customer accounts. EmDash keeps the content. RedeemID joins them: shoppers browse products and fill a cart on your EmDash site, check out with Shopify, and sign in with the store's own customer account. What a reader can read comes from their Shopify customer tags, so there is one source of truth for who paid for what.
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+

|
|
11
12
|
|
|
12
|
-
|
|
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)
|
|
13
|
+
> **Status: early (0.0.x).** Runs on Node and Cloudflare Workers. Unit tests and an end-to-end test that boots a real EmDash site against a fake Shopify store cover every feature. Expect changes before 1.0.
|
|
24
14
|
|
|
25
15
|
## How it works
|
|
26
16
|
|
|
27
17
|
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
18
|
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
|
|
30
|
-
4. **Rules.** You say which content types or terms need which levels, in the admin
|
|
19
|
+
3. **Levels.** You map tags to levels and name them whatever you like, such as `gold-member` to Gold. A level can include others, so Gold members read everything for Silver. Tags come from Shopify Flow, a subscription app or staff, never from the site.
|
|
20
|
+
4. **Rules.** You say which content types or terms need which levels, in the admin or in code. Each entry can have its own rule in the editor's Access panel, and editors mark where that rule starts with a Paywall break.
|
|
31
21
|
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
22
|
|
|
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, when the membership renews or ends and any gift membership (from Shopify), 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
|
-
- Setup page: connecting a store step by step, with the exact values to paste into Shopify, the command for each secret, a Register button for the webhooks and a form for the first level. Each step checks the site as it is.
|
|
57
|
-
- Status page: every setting, store discovery, levels and rules, and any template that would show a locked body in full.
|
|
58
|
-
- Access test, Privacy page, and a dashboard widget.
|
|
59
|
-
|
|
60
|
-
**For developers**
|
|
61
|
-
- Astro components for every piece, plus `canRead()`, `readEntry()` and `hasFeature()` for your own code.
|
|
62
|
-
- 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.
|
|
63
|
-
- 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).
|
|
64
|
-
- Signed outbound events for a CRM or email tool, MCP tools for AI agents (members, access and the catalog), and a `redeemid-emdash` command.
|
|
65
|
-
- Replacement text for every string readers see, Google's paywalled-content markup, cache safety and optional rate limits.
|
|
66
|
-
|
|
67
|
-
## Requirements
|
|
23
|
+
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.
|
|
68
24
|
|
|
69
|
-
|
|
70
|
-
- **A Shopify store with the new customer accounts** (Settings → Customer accounts). Classic accounts have no Customer Account API.
|
|
71
|
-
- **The Headless sales channel**, for the sign-in client and Storefront API tokens.
|
|
72
|
-
- **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.
|
|
73
|
-
- **HTTPS.** Shopify only accepts `https` callback URIs, so a local site needs a tunnel to sign in for real.
|
|
25
|
+
## Features
|
|
74
26
|
|
|
75
|
-
|
|
27
|
+
- **For shoppers:** product pages with options, subscriptions and Product structured data, a product sitemap, collections with Shopify's filters and sorting, product search, recommendations, a cart page and cart drawer, discount codes, local prices by market, and a checkout that opens signed in for members. It all works without JavaScript.
|
|
28
|
+
- **For members:** sign in, Account and Sign out without ever seeing the word "Shopify"; a member card with levels, renewal and end dates; preferences saved on their customer record; download or delete what the site keeps about them.
|
|
29
|
+
- **For editors:** Paywall break, Members only, Sold on its own, Plan, Product, Collection, Product search, Contribution and Preferences blocks; an Access panel on every entry; teasers, a monthly or weekly meter, drip, badges and protected file downloads.
|
|
30
|
+
- **For staff:** Access, Members, Webhooks, App events, Reports, Setup, Status, Access test and Privacy pages in the EmDash admin. New content types and taxonomies appear on their own, so access never needs a code change.
|
|
31
|
+
- **For developers:** Astro components for every piece, `canRead()` and friends for your own code, a storefront from the Storefront API with a cart and a signed-in checkout, a browser client with analytics events, signed outbound events, MCP tools for AI agents and a `redeemid-emdash` command.
|
|
76
32
|
|
|
77
|
-
|
|
33
|
+
## Requirements
|
|
78
34
|
|
|
79
|
-
|
|
35
|
+
- EmDash 1.1 or later with Astro 6 or later, on Node or Cloudflare Workers (the Workers free plan is enough).
|
|
36
|
+
- A Shopify store with the new customer accounts, the Headless sales channel, and a Shopify app on the store to send webhooks.
|
|
37
|
+
- HTTPS. Shopify only accepts `https` callback URIs, so a local site needs a tunnel to sign in for real.
|
|
80
38
|
|
|
81
|
-
|
|
39
|
+
## Quick start
|
|
82
40
|
|
|
83
41
|
```sh
|
|
84
42
|
pnpm add @redeemid/emdash
|
|
85
43
|
```
|
|
86
44
|
|
|
87
|
-
RedeemID is a native EmDash plugin: an npm dependency, not a registry plugin, because sign-in and page gating run in the site process.
|
|
88
|
-
|
|
89
|
-
### 3. Configure
|
|
90
|
-
|
|
91
45
|
```js title="astro.config.mjs"
|
|
92
46
|
import { redeemid, redeemidGuard, redeemidPlugin } from "@redeemid/emdash";
|
|
93
47
|
import { defineConfig } from "astro/config";
|
|
@@ -95,14 +49,8 @@ import emdash from "emdash/astro";
|
|
|
95
49
|
|
|
96
50
|
const config = {
|
|
97
51
|
shop: "my-store.myshopify.com",
|
|
98
|
-
levels: [
|
|
99
|
-
|
|
100
|
-
{ slug: "pro", name: "Pro", tags: ["pro"], wall: "Pro members read every report." },
|
|
101
|
-
],
|
|
102
|
-
rules: [
|
|
103
|
-
{ collection: "posts", taxonomy: "category", term: "members", levels: ["member", "pro"] },
|
|
104
|
-
{ collection: "posts", taxonomy: "category", term: "pro", levels: ["pro"] },
|
|
105
|
-
],
|
|
52
|
+
levels: [{ slug: "member", name: "Member", tags: ["member"] }],
|
|
53
|
+
rules: [{ collection: "posts", taxonomy: "category", term: "members", levels: ["member"] }],
|
|
106
54
|
join: { href: "/join/", label: "Become a member" },
|
|
107
55
|
};
|
|
108
56
|
|
|
@@ -118,589 +66,44 @@ export default defineConfig({
|
|
|
118
66
|
});
|
|
119
67
|
```
|
|
120
68
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
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).
|
|
124
|
-
|
|
125
|
-
### 4. Set the secrets
|
|
126
|
-
|
|
127
|
-
Set them as runtime secrets of the site (`.env` locally, `wrangler secret put` on Workers), not as build-time variables:
|
|
128
|
-
|
|
129
|
-
```sh
|
|
130
|
-
REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID=… # sign-in
|
|
131
|
-
REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_SECRET=… # verifies webhooks
|
|
132
|
-
REDEEMID_SHOPIFY_STOREFRONT_PRIVATE_TOKEN=… # only to sell on the site: plans, shop and checkout
|
|
133
|
-
```
|
|
134
|
-
|
|
135
|
-
The [secrets reference](#secrets) lists the rest. The Setup page prints the command for each secret its steps need.
|
|
136
|
-
|
|
137
|
-
### 5. Render content through RedeemID
|
|
69
|
+
Then render every body that may be locked with `<Content>` in place of `<PortableText>`:
|
|
138
70
|
|
|
139
71
|
```astro
|
|
140
72
|
---
|
|
141
|
-
import Account from "@redeemid/emdash/components/Account.astro";
|
|
142
|
-
import Cart from "@redeemid/emdash/components/Cart.astro";
|
|
143
73
|
import Content from "@redeemid/emdash/components/Content.astro";
|
|
144
|
-
import Notices from "@redeemid/emdash/components/Notices.astro";
|
|
145
74
|
---
|
|
146
|
-
<header><Account /> <Cart /></header>
|
|
147
|
-
<Notices />
|
|
148
75
|
<Content value={post.data.content} collection="posts" terms={post.data.terms} id={post.id} title={post.data.title} />
|
|
149
76
|
```
|
|
150
77
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
### 6. Check it
|
|
154
|
-
|
|
155
|
-
1. Finish EmDash setup as staff first. Store customers can't create the first account.
|
|
156
|
-
2. Open **Plugins → RedeemID → Setup**. Work down the steps until it says RedeemID is set up (it registers the webhooks for you). **Status** lists every setting as Set or Missing.
|
|
157
|
-
3. Tag a test customer `member` in Shopify, open a locked post, and sign in as that customer. The post opens.
|
|
158
|
-
4. Remove the tag in Shopify. The post locks again once the webhook arrives.
|
|
159
|
-
|
|
160
|
-
## Content and templates
|
|
161
|
-
|
|
162
|
-
EmDash has no content filter hook, so templates decide what to render.
|
|
163
|
-
|
|
164
|
-
**`<Content>`** renders an entry body. Levels come from the entry's rule (its content type, its terms, or its own Access panel); a Paywall break only says where that rule starts. Everything before the first Paywall break block is shown to everyone, and what follows reaches the page only for readers the rule lets in. With 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).
|
|
165
|
-
|
|
166
|
-
```astro
|
|
167
|
-
<Content value={post.data.content} collection="posts" terms={post.data.terms} id={post.id} title={post.data.title}>
|
|
168
|
-
<a slot="locked" href="/join/">Become a member</a>
|
|
169
|
-
</Content>
|
|
170
|
-
```
|
|
171
|
-
|
|
172
|
-
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.
|
|
173
|
-
|
|
174
|
-
**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.
|
|
175
|
-
|
|
176
|
-
**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.
|
|
177
|
-
|
|
178
|
-
**`<Gate>`** locks any part of a template that isn't the body:
|
|
179
|
-
|
|
180
|
-
```astro
|
|
181
|
-
<Gate levels={["pro"]}><a href="/reports/archive.zip">Download the archive</a></Gate>
|
|
182
|
-
```
|
|
183
|
-
|
|
184
|
-
**`<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 (and the first level's `badge` colour in `--redeemid-level-color`):
|
|
185
|
-
|
|
186
|
-
```astro
|
|
187
|
-
<AccessBadge collection="posts" terms={post.data.terms} content={post.data.content} />
|
|
188
|
-
```
|
|
189
|
-
|
|
190
|
-
**`<Notices />`** goes near the top of the layout. It shows the message for a `?redeemid_notice=` code (see [notices](#notices)) and nothing otherwise.
|
|
191
|
-
|
|
192
|
-
**`<MemberCard />`** is for an account page: levels with start dates, the renewal or end date and any gift membership that Shopify keeps (see [membership dates](#membership-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.
|
|
193
|
-
|
|
194
|
-
**`<Plan>`** sells a membership: a Shopify product's title and price with a button that starts checkout (members check out with their email). Its levels are `levels`, or else the levels whose `products` list the handle. Then the card fits the member: one who holds a level through its own tags sees "You're on this plan." in place of the button; one whose bigger level includes it sees "Included in your Gold membership."; one the plan would move up (its level includes theirs) gets **Upgrade** (`upgradeLabel`); and one who held it before (Shopify has since removed its tag) gets **Renew** (`renewLabel`) instead of `label`. `data-redeemid-state` is `held`, `included`, `upgrade`, `renew` or `join`. The wall does the same: a lapsed member reads "Your Pro membership has ended." with **Renew Pro** and **Already renewed? Refresh**, not the newcomer's join link.
|
|
195
|
-
|
|
196
|
-
```astro
|
|
197
|
-
<Plan handle="gold-membership" label="Join Gold" levels={["gold"]}>
|
|
198
|
-
<p>Shown if the product can't be loaded.</p>
|
|
199
|
-
</Plan>
|
|
200
|
-
```
|
|
201
|
-
|
|
202
|
-
**In your own code** (`@redeemid/emdash/access`):
|
|
203
|
-
|
|
204
|
-
```ts
|
|
205
|
-
import { canRead, entryRequirement, readEntry, siteConfig } from "@redeemid/emdash/access";
|
|
206
|
-
|
|
207
|
-
const verdict = await canRead(Astro, { collection: "posts", terms: post.data.terms, id: post.id });
|
|
208
|
-
// verdict.allowed; verdict.reason: open | allowed | sign_in | upgrade | disabled | unconfigured
|
|
209
|
-
const read = await readEntry(Astro, { collection: "posts", terms: post.data.terms, id: post.id });
|
|
210
|
-
// adds drip (read.opensAt) and the meter (read.meterLeft), and counts a metered read
|
|
211
|
-
|
|
212
|
-
const config = await siteConfig(Astro.locals);
|
|
213
|
-
const needs = await entryRequirement(config, "posts", post.data.terms, post.id);
|
|
214
|
-
// e.g. [["gold", "pro"]]: readers pass every clause, each with any one of its levels; null when open
|
|
215
|
-
```
|
|
216
|
-
|
|
217
|
-
**Site search.** EmDash's search API (`/_emdash/api/search`, which its live search box uses) is public and answers with a snippet of the matching text. `redeemidGuard()` checks each result for visitors and members: an entry the reader can't read in full keeps its title but loses its snippet, and stays in the results only when its title holds every searched word, so a search can't show locked text or tell which locked entry contains a word. Staff get every result. A search page that calls EmDash's `search()` in its template filters the same way with `readableResults()`:
|
|
218
|
-
|
|
219
|
-
```astro
|
|
220
|
-
---
|
|
221
|
-
import { search } from "emdash";
|
|
222
|
-
import { readableResults } from "@redeemid/emdash/search";
|
|
223
|
-
|
|
224
|
-
const q = Astro.url.searchParams.get("q") ?? "";
|
|
225
|
-
const results = q ? await readableResults(Astro, q, (await search(q)).items) : [];
|
|
226
|
-
---
|
|
227
|
-
```
|
|
228
|
-
|
|
229
|
-
`siteConfig(Astro.locals)` is the config for this request: your code's config with the Access page's saved settings laid over it. Pass it to the functions that take a `config`, such as `entryRequirement()`, `readPreferences()` and the storefront and catalog calls.
|
|
230
|
-
|
|
231
|
-
## Guides
|
|
232
|
-
|
|
233
|
-
### Rules
|
|
234
|
-
|
|
235
|
-
Each rule names a collection and the levels, any of which may read. An empty `levels` list, or the built-in `signed-in` level, means any signed-in member.
|
|
236
|
-
|
|
237
|
-
| Rule | Covers |
|
|
238
|
-
| --- | --- |
|
|
239
|
-
| `{ collection: "reports", levels: ["gold"] }` | Every entry in the collection |
|
|
240
|
-
| `{ 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. |
|
|
241
|
-
| `{ collection: "posts", taxonomy: "tag", term: "free-to-read", levels: [], open: true }` | Opens entries with that term to everyone, whatever else covers them |
|
|
242
|
-
| `{ collection: "pages", levels: [], open: true }` | Opens a whole collection to everyone, unless a term rule locks an entry |
|
|
243
|
-
| `{ 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. |
|
|
244
|
-
| `{ collection: "*", levels: [] }` | The site default, for collections with no rule of their own |
|
|
245
|
-
| `{ collection: "posts", taxonomy: "category", term: "archive", levels: ["member"], drip: 30 }` | Drip: members read these entries 30 days after their level started |
|
|
246
|
-
| `{ collection: "posts", taxonomy: "category", term: "exclusive", levels: ["pro"], meter: false }` | The meter never opens these entries |
|
|
247
|
-
|
|
248
|
-
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.
|
|
249
|
-
|
|
250
|
-
### Levels
|
|
251
|
-
|
|
252
|
-
A level is a name for customers who hold some Shopify tags. `tags` is one group: any one of them grants the level, or every one with `match: "all"`. `groups` adds more groups, and any one group is enough:
|
|
253
|
-
|
|
254
|
-
```js
|
|
255
|
-
levels: [
|
|
256
|
-
{ slug: "silver", name: "Silver", tags: ["silver"] },
|
|
257
|
-
// "gold", or "silver" with "annual".
|
|
258
|
-
{ slug: "gold", name: "Gold", tags: ["gold"], groups: [{ tags: ["silver", "annual"], match: "all" }], includes: ["silver"] },
|
|
259
|
-
],
|
|
260
|
-
```
|
|
261
|
-
|
|
262
|
-
`includes` lists levels this one also opens: Gold members read everything for Silver, and inclusion carries on (Gold includes Silver includes Members). So a rule names only the lowest level that may read, and an entry that needs Silver and Gold needs Gold once. Members hold the levels their tags grant and every level those include; the account box and member card show only the top one ("Gold", not "Gold, Silver"), and `member.levels_changed` lists them all.
|
|
263
|
-
|
|
264
|
-
The rest of a level is how it looks and where it leads:
|
|
265
|
-
|
|
266
|
-
| Key | What it does |
|
|
267
|
-
| --- | --- |
|
|
268
|
-
| `wall` | What the wall says to readers this level would let in |
|
|
269
|
-
| `joinUrl` | Where the wall's join link goes for this level (else `join.href`) |
|
|
270
|
-
| `products` | Handles of the Shopify products whose purchase leads to the level. Display only: plan cards use it (see `<Plan>`). It grants nothing. |
|
|
271
|
-
| `home` | Members' home: the same-site path a member lands on after signing in from the home page or a plain sign-in link (their top level's, in level order). Signing in from an entry returns them to it. |
|
|
272
|
-
| `badge` | A hex colour, set as `--redeemid-level-color` on badges, the account box and the member card. Badges always show the name too. |
|
|
273
|
-
|
|
274
|
-
### Access settings in the admin
|
|
275
|
-
|
|
276
|
-
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**:
|
|
277
|
-
|
|
278
|
-
- **Levels.** Add, rename and remove levels and the Shopify tags that grant them: one group per line, commas between tags, and `all:` before a line that needs every tag on it. Tick the levels each one includes, and set its plan products, members' home and badge colour. Name them anything; the slug is made from the name. A level that rules, features or folders still use can't be removed; removing one drops it from other levels' includes.
|
|
279
|
-
- **Site rules.** The site default, how term rules combine, the meter and the wall's join link.
|
|
280
|
-
- **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.
|
|
281
|
-
- **Taxonomies.** Rules for terms in every taxonomy, for one collection or every collection the taxonomy is on.
|
|
282
|
-
- **Features** and **Protected files.**
|
|
283
|
-
|
|
284
|
-
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. Each server keeps the last ones it read and checks one small record per request to see whether anyone saved since, so the whole set is read again only after a save. If the database can't be read, the settings last read stand; a server that has read none yet locks every entry (and refuses files in any media folder) until it can, rather than falling back to the code's settings and opening what the admin locked.
|
|
285
|
-
|
|
286
|
-
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:
|
|
287
|
-
|
|
288
|
-
| Choice | Readers need |
|
|
289
|
-
| --- | --- |
|
|
290
|
-
| Follow the site rules | What the rules say (the default) |
|
|
291
|
-
| Free to everyone | Nothing |
|
|
292
|
-
| Only some levels | One of the levels ticked (none ticked: any signed-in member) |
|
|
293
|
-
| Site rules plus a level | What the rules say and one of the levels ticked |
|
|
294
|
-
|
|
295
|
-
The panel can also hold the entry back for some days of membership, keep the meter off it, lock it until a date and then open it, or keep it free until a date and then lock it. Pass the entry's `id` (or slug) to `<Content>`, `<AccessBadge>` and `<Gate>` so its own rule applies there.
|
|
296
|
-
|
|
297
|
-
Moving an entry to the trash keeps its rule for a restore but frees its slug, so a new entry that takes the slug doesn't inherit the rule.
|
|
298
|
-
|
|
299
|
-
### Meter and drip
|
|
300
|
-
|
|
301
|
-
```ts
|
|
302
|
-
meter: { limit: 3, period: "month", collections: ["posts"] },
|
|
303
|
-
teaser: { posts: 40 },
|
|
304
|
-
```
|
|
305
|
-
|
|
306
|
-
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.
|
|
307
|
-
|
|
308
|
-
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 a tag of its group (for a `match: "all"` group, the last of its tags); a level held through another's `includes` starts with that one. 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.
|
|
309
|
-
|
|
310
|
-
### Selling one entry on its own
|
|
311
|
-
|
|
312
|
-
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:
|
|
313
|
-
|
|
314
|
-
- 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.
|
|
315
|
-
- Locked readers see **Buy just this** on the wall with the product's price, beside the level's offer.
|
|
316
|
-
- `<AccessBadge>` drops the lock for buyers, and protected files in an `entries: true` folder that the entry uses reach them too.
|
|
317
|
-
|
|
318
|
-
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.
|
|
319
|
-
|
|
320
|
-
### Features
|
|
321
|
-
|
|
322
|
-
```ts
|
|
323
|
-
features: [
|
|
324
|
-
{ slug: "ad-free", name: "Ad-free", levels: ["gold", "premium"] },
|
|
325
|
-
{ slug: "supporter", name: "Supporter", tags: ["supporter"] },
|
|
326
|
-
],
|
|
327
|
-
```
|
|
328
|
-
|
|
329
|
-
A member holds a feature through any of its levels or tags. `<MemberCard>` lists them under the member's levels.
|
|
330
|
-
|
|
331
|
-
```astro
|
|
332
|
-
---
|
|
333
|
-
import Feature from "@redeemid/emdash/components/Feature.astro";
|
|
334
|
-
import { hasFeature } from "@redeemid/emdash/features";
|
|
335
|
-
---
|
|
336
|
-
<Feature slug="supporter"><p>Thanks for your support.</p><p slot="else">Become a supporter.</p></Feature>
|
|
337
|
-
{!(await hasFeature(Astro, "ad-free")) && <aside class="ad">…</aside>}
|
|
338
|
-
```
|
|
339
|
-
|
|
340
|
-
### Shop
|
|
341
|
-
|
|
342
|
-
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.
|
|
343
|
-
|
|
344
|
-
```astro
|
|
345
|
-
<!-- layout, once -->
|
|
346
|
-
<Cart drawer />
|
|
347
|
-
<CartDrawer />
|
|
348
|
-
<RedeemIDClient />
|
|
349
|
-
<!-- /shop/ (rendered on demand for filters and pages) -->
|
|
350
|
-
<Collection handle="shop" productHref="/shop/{handle}/" showTitle browse quickAdd />
|
|
351
|
-
<!-- /shop/[handle]/ -->
|
|
352
|
-
<Product handle={Astro.params.handle} heading="h1"><p>No such product.</p></Product>
|
|
353
|
-
<Recommendations handle={Astro.params.handle} productHref="/shop/{handle}/" />
|
|
354
|
-
<!-- /search/ -->
|
|
355
|
-
<ProductSearch productHref="/shop/{handle}/" />
|
|
356
|
-
<!-- /cart/ -->
|
|
357
|
-
<CartView productHref="/shop/{handle}/" />
|
|
358
|
-
<!-- anywhere -->
|
|
359
|
-
<Contribution handle="support" amounts={[5, 10, 25]} />
|
|
360
|
-
```
|
|
361
|
-
|
|
362
|
-
- `<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.
|
|
363
|
-
- `<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.
|
|
364
|
-
- `<ProductSearch>`: a search box with Shopify's matches, filters, sorting and pages. `boxOnly` gives just the box, for a header, sending searches to `action`.
|
|
365
|
-
- `<Recommendations>`: Shopify's "You may also like" (`intent="related"`) or "Pair it with" (`intent="complementary"`) for a product.
|
|
366
|
-
- `<CartView>`: each line with a quantity to change (0 removes it) and Remove, a discount code field, the totals and Checkout.
|
|
367
|
-
- `<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.
|
|
368
|
-
- `<Contribution>`: pay what you want. Price a product at 1.00, and the amount the reader picks becomes the quantity.
|
|
369
|
-
- `<Cart>`: the item count, linking to checkout, or to `href` (a cart page), or opening the drawer with `drawer`.
|
|
370
|
-
|
|
371
|
-
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.
|
|
372
|
-
|
|
373
|
-
**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, encrypted with a key kept in the site's database, 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; then the tokens aren't kept at all.
|
|
374
|
-
|
|
375
|
-
**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.
|
|
376
|
-
|
|
377
|
-
**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.
|
|
378
|
-
|
|
379
|
-
### Preferences
|
|
380
|
-
|
|
381
|
-
```ts
|
|
382
|
-
preferences: [
|
|
383
|
-
{ key: "preferences.topics" }, // members can change it
|
|
384
|
-
{ key: "preferences.level", access: "view" }, // members only see it
|
|
385
|
-
],
|
|
386
|
-
```
|
|
387
|
-
|
|
388
|
-
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.
|
|
389
|
-
|
|
390
|
-
`<Preferences>` (or its block) is a form that works without JavaScript. Visitors see its `signed-out` slot.
|
|
391
|
-
|
|
392
|
-
| Shopify type | Value |
|
|
393
|
-
| --- | --- |
|
|
394
|
-
| Single line text | A string; with choices, one of them |
|
|
395
|
-
| Multi-line text | A string |
|
|
396
|
-
| List of single line text | A list of strings; with choices, each one of them |
|
|
397
|
-
| True or false | `true` or `false` |
|
|
398
|
-
| Integer | A whole number |
|
|
399
|
-
| Decimal | A number, as a string |
|
|
400
|
-
| Date | `YYYY-MM-DD` |
|
|
401
|
-
|
|
402
|
-
Every value is checked against the definition first. One refusal saves nothing, and an empty value clears the field. Scripts can `GET` `/_emdash/api/auth/redeemid/preferences` for `{ fields }`, or `POST` JSON `{ values: { "namespace.key": value } }`. A refused save answers `400` with `fields` mapping each key to `unknown_field`, `read_only`, `invalid_type`, `invalid_choice`, `invalid_length` or `invalid_range`. Saves are limited to 10 a minute per member (`429`). From your own code, `readPreferences(config, customerId)` and `savePreferences(config, customerId, values)` in `@redeemid/emdash/preferences` do the same (no rate limit there; `config` comes from `siteConfig()`).
|
|
403
|
-
|
|
404
|
-
### Protected files
|
|
405
|
-
|
|
406
|
-
```ts
|
|
407
|
-
files: [
|
|
408
|
-
{ folder: "Gold downloads", levels: ["gold"] },
|
|
409
|
-
{ folder: "Member downloads", levels: [] }, // any signed-in member
|
|
410
|
-
{ folder: "Supporter extras", features: ["supporter"] },
|
|
411
|
-
{ folder: "Story files", entries: true }, // whoever can read an entry that uses it
|
|
412
|
-
],
|
|
413
|
-
```
|
|
414
|
-
|
|
415
|
-
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:
|
|
416
|
-
|
|
417
|
-
- **Allowed:** the file, with `Cache-Control: private, no-store`.
|
|
418
|
-
- **Signed out:** a 401 page with a sign-in link that comes back to the file.
|
|
419
|
-
- **Signed in without access:** a 403 page.
|
|
420
|
-
|
|
421
|
-
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.
|
|
422
|
-
|
|
423
|
-
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.
|
|
424
|
-
|
|
425
|
-
### Privacy requests
|
|
426
|
-
|
|
427
|
-
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, whether the email was verified, email and SMS marketing consent, and the membership dates) 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.
|
|
428
|
-
|
|
429
|
-
| Started by | What happens |
|
|
430
|
-
| --- | --- |
|
|
431
|
-
| 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. |
|
|
432
|
-
| 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. |
|
|
433
|
-
| Shopify `customers/data_request` | An open request on the **Privacy** page. Press **Export and mark done**, then send the export to the store owner. |
|
|
434
|
-
| Shopify `customers/redact` | The member's record and meter are erased. |
|
|
435
|
-
| Shopify `shop/redact` | Every member record and meter is erased, a hundred at a time, so even a large shop fits in one request on the Workers Free plan. |
|
|
436
|
-
|
|
437
|
-
The Privacy page shows each request's source and Shopify customer ID, never an email or a name. The daily check deletes finished requests after a year; open ones stay until staff mark them done. 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.
|
|
78
|
+
Set the secrets, open **Plugins → RedeemID → Setup** in the admin, and work down the steps. [Getting started](docs/getting-started.md) walks through all of it.
|
|
438
79
|
|
|
439
|
-
|
|
80
|
+
## Documentation
|
|
440
81
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
- **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.
|
|
444
|
-
- **Site user.** Signing in links a customer to the site user with the same verified email. When the emails differ, staff link the customer to an existing Subscriber by that user's email on the member's page (never a staff account, never a user linked to another customer), or unlink them. This part needs no Admin API app.
|
|
445
|
-
- **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.
|
|
446
|
-
- **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.
|
|
447
|
-
- **Access test.** A collection, an entry, terms and a reader's tags in; the verdict out.
|
|
448
|
-
|
|
449
|
-
Resync updates email, name, tags, email marketing consent and the membership dates. Enabled or disabled status comes only from the enable and disable webhooks. With the app set up, two more things read the customer from the Admin API as it is now: every sign-in (and Refresh membership), and every `customer.tags_added` or `customer.tags_removed` webhook. Those topics carry only the change and can arrive out of order, so reading the current tags keeps the mirror right. Without the app, or when the read fails, the webhook's change is applied as sent.
|
|
450
|
-
|
|
451
|
-
#### Membership dates
|
|
452
|
-
|
|
453
|
-
A subscription app or Shopify Flow can keep these customer metafields, as dates (`YYYY-MM-DD`):
|
|
454
|
-
|
|
455
|
-
| Metafield | Shown |
|
|
82
|
+
| Guide | What's in it |
|
|
456
83
|
| --- | --- |
|
|
457
|
-
|
|
|
458
|
-
|
|
|
459
|
-
|
|
|
460
|
-
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
- **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).
|
|
469
|
-
- **Webhooks → Health.** Once a day RedeemID registers again any subscription Shopify dropped (with the Admin API app), and warns when no webhook has come in for a number of days (3 by default, 0 turns it off), or when deliveries were turned away or failed in the last day. **Check now** runs it at once. Warnings show on the page and the dashboard widget. If you enter an address on the Health tab and the site has an EmDash email provider, they also go by email: once when problems start or change, then once a week while they last.
|
|
470
|
-
- **Webhooks → Setup.** The endpoint, whether signatures can be checked, and the subscriptions.
|
|
471
|
-
- **App events.** What RedeemID did and what came of it: webhooks received, turned away and failed, sign-ins and refusals (by reason), Admin API calls (by operation, with timings), resyncs, tag edits, webhook registrations, settings saves, outbound events that failed, metered reads and the health checks. Filter by event or show problems only. Entries hold ids, labels and outcomes, never bodies, tokens or emails. Failed outbound events also keep the event body (ids and levels, never an email or a name) so they can be sent again.
|
|
472
|
-
- **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.
|
|
473
|
-
|
|
474
|
-
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.
|
|
475
|
-
|
|
476
|
-
### AI agents and the command line
|
|
477
|
-
|
|
478
|
-
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.
|
|
479
|
-
|
|
480
|
-
| MCP tool | Command | What it answers |
|
|
481
|
-
| --- | --- | --- |
|
|
482
|
-
| `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`. |
|
|
483
|
-
| `list_levels` | `levels` | Every level, its tag groups, the levels it includes, wall text and join link |
|
|
484
|
-
| `get_rules` | `rules` | How rules combine, every rule, the meter, teasers, features and file rules |
|
|
485
|
-
| `get_member` | `member <id or email>` | One member: status, tags, levels with start dates, past levels, features, last sync |
|
|
486
|
-
| `site_health` | `health` | Each setting (set or not, never a value), the last webhook, open privacy requests, ignored text replacements |
|
|
487
|
-
| `level_changes` | `changes --since <date>` | Who joined or left a level since a date |
|
|
488
|
-
| `find_products` | `products <words>` | Search the Shopify catalog: handles, titles, prices, stock |
|
|
489
|
-
| `get_product` | `product <handle>` | One product: options, variants, prices, selling plans |
|
|
490
|
-
| `get_collection` | `collection <handle>` | One collection: its products and the filters Shopify offers |
|
|
491
|
-
|
|
492
|
-
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).
|
|
493
|
-
|
|
494
|
-
**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`.
|
|
495
|
-
|
|
496
|
-
**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.
|
|
497
|
-
|
|
498
|
-
```sh
|
|
499
|
-
npx -p @redeemid/emdash redeemid-emdash access posts --id my-story --tags gold-member --url https://example.com
|
|
500
|
-
npx -p @redeemid/emdash redeemid-emdash member jane@example.com
|
|
501
|
-
```
|
|
502
|
-
|
|
503
|
-
Each command POSTs JSON to a staff-only route under `/_emdash/api/plugins/redeemid/`, named after its tool with hyphens (`explain_access` is `explain-access`). Scripts can call those routes directly.
|
|
504
|
-
|
|
505
|
-
### Replacement text
|
|
506
|
-
|
|
507
|
-
```ts
|
|
508
|
-
text: {
|
|
509
|
-
"Sign in": "Log in",
|
|
510
|
-
"This is for members. {signIn} to read on.": "Members only. {signIn} to keep reading.",
|
|
511
|
-
"This is for {level} members.": "{level} members only.",
|
|
512
|
-
},
|
|
513
|
-
```
|
|
514
|
-
|
|
515
|
-
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.
|
|
516
|
-
|
|
517
|
-
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.
|
|
518
|
-
|
|
519
|
-
### Events
|
|
520
|
-
|
|
521
|
-
```ts
|
|
522
|
-
events: { url: "https://hooks.example.com/redeemid", only: ["member.signed_in", "member.levels_changed"] },
|
|
523
|
-
```
|
|
524
|
-
|
|
525
|
-
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. Events go out after the response (with `waitUntil` on Workers), so sign-in, webhooks and pages never wait for your receiver. Each try waits at most 3 seconds; a send that fails is tried again after 1 second and after 5 seconds. One that fails all three tries is kept under **App events → Failed events**, where staff can **Send again** (same `id`, so drop duplicates by it) or **Clear** it; failed events are deleted after 30 days.
|
|
526
|
-
|
|
527
|
-
| Event | `data` |
|
|
528
|
-
| --- | --- |
|
|
529
|
-
| `member.signed_in` | `customerId`, `levels`, `first` (first sign-in), `refresh` |
|
|
530
|
-
| `member.levels_changed` | `customerId`, `levels`, `added`, `removed`, `source` (`sign-in`, the webhook topic, or a staff resync or tag edit) |
|
|
531
|
-
| `meter.used` | `userId` (EmDash), `entry` (`collection:id`), `used`, `limit`, `period` (`2026-11`, or `2026-W45` by week) |
|
|
532
|
-
| `meter.exhausted` | The same, sent with the last free read |
|
|
533
|
-
| `preferences.saved` | `customerId`, `fields` (the names changed, never the answers) |
|
|
534
|
-
| `member.erased` | `customerId`, `source` (`member`, `staff` or `shopify`) |
|
|
535
|
-
|
|
536
|
-
No event carries an email address or a name; match people by `customerId`. Leave `only` out to send every event.
|
|
537
|
-
|
|
538
|
-
### Caching
|
|
539
|
-
|
|
540
|
-
`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.
|
|
541
|
-
|
|
542
|
-
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.
|
|
543
|
-
|
|
544
|
-
### Rate limits
|
|
545
|
-
|
|
546
|
-
On Cloudflare Workers, `redeemidGuard({ rateLimit: true })` limits how often one visitor can start Sign in, Buy, a cart change or a catalog search (`api/search`), 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:
|
|
547
|
-
|
|
548
|
-
```jsonc
|
|
549
|
-
"ratelimits": [{ "name": "REDEEMID_RATE_LIMIT", "namespace_id": "1001", "simple": { "limit": 20, "period": 60 } }]
|
|
550
|
-
```
|
|
551
|
-
|
|
552
|
-
Each action counts separately per visitor (by `CF-Connecting-IP`), and Cloudflare's count is approximate. A visitor over the limit goes back to their page with the `too_many_requests` notice; the JSON routes answer `429` with `{ "error": "too_many_requests" }`. If the binding is missing or fails, nothing is limited. Off by default; leave it off on Node.
|
|
553
|
-
|
|
554
|
-
## Reference
|
|
555
|
-
|
|
556
|
-
### Config
|
|
557
|
-
|
|
558
|
-
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.
|
|
559
|
-
|
|
560
|
-
| Option | Type | What it does |
|
|
561
|
-
| --- | --- | --- |
|
|
562
|
-
| `shop` | `string` | The store's `*.myshopify.com` domain. A custom domain works for sign-in, but the Admin API (staff tools, webhook registration, tag reads) answers only on myshopify.com, and the Status page warns when the app is set up with a custom domain. Leave it out to read `REDEEMID_SHOPIFY_STORE_DOMAIN`. |
|
|
563
|
-
| `label` | `string` | The sign-in button's label. Default "Sign in". |
|
|
564
|
-
| `levels` | `{ slug, name, tags?, match?, groups?, includes?, wall?, joinUrl?, products?, home?, badge? }[]` | Levels from customer tags. See [levels](#levels). |
|
|
565
|
-
| `rules` | `{ collection, taxonomy?, term?, levels, open?, drip?, meter? }[]` | What needs which levels. See [rules](#rules). |
|
|
566
|
-
| `combine` | `"all"` \| `"any"` | How several term rules on one entry combine. Default `"all"`. |
|
|
567
|
-
| `join` | `{ href, label? }` | The wall's join link. |
|
|
568
|
-
| `teaser` | `{ [collection]: words }` | Teaser length for locked entries with no paywall break. |
|
|
569
|
-
| `meter` | `{ limit, period?, collections? }` | Free reads for signed-in readers. |
|
|
570
|
-
| `features` | `{ slug, name, levels?, tags? }[]` | Perks held through levels or tags. |
|
|
571
|
-
| `preferences` | `{ key, access? }[]` | Customer metafields members see (`access: "view"`) or change. |
|
|
572
|
-
| `files` | `{ folder, levels?, features?, entries? }[]` | Protected media folders. |
|
|
573
|
-
| `text` | `{ [original]: replacement }` | Replacement text, one string at a time. |
|
|
574
|
-
| `events` | `{ url, only? }` | Signed outbound events. |
|
|
575
|
-
| `agents` | `{ actions?: boolean }` | Also offer resync and tag edits to agents and the command line. |
|
|
576
|
-
| `market` | `{ country?, language? }` | Local prices: a fixed two-letter `country`, or `"visitor"` for each visitor's own. |
|
|
577
|
-
| `checkout` | `{ signIn?: false }` | `signIn: false` stops passing a member's store sign-in to checkout. |
|
|
578
|
-
| `schema` | `false` | Turn off the paywalled-content JSON-LD. |
|
|
579
|
-
| `memberPaths` | `string[]` | Extra `/_emdash` path prefixes signed-in members may use. Everything else there is refused to them. |
|
|
580
|
-
| `entries` | `{ ["collection:id"]: EntryRule }` | Entries' own rules. Usually set in the editor's Access panel. |
|
|
581
|
-
| `allowInsecureShop` | `boolean` | Local tests only: lets `shop` be an `http://` fake store. Never set it on a live site. |
|
|
582
|
-
|
|
583
|
-
`redeemidGuard(options)` takes `{ rateLimit?: boolean }`.
|
|
584
|
-
|
|
585
|
-
### Secrets
|
|
586
|
-
|
|
587
|
-
| Variable | Needed for | What it is |
|
|
588
|
-
| --- | --- | --- |
|
|
589
|
-
| `REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID` | Sign-in | Client ID of the Headless storefront's Customer Account API client |
|
|
590
|
-
| `REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_SECRET` | Sign-in, confidential clients | Its client secret. Leave unset for a public client. |
|
|
591
|
-
| `REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_SECRET` | Webhooks | Client secret of the app that sends the webhooks. Shopify signs each webhook with it. |
|
|
592
|
-
| `REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID` | Webhook registration, staff tools, preferences, membership dates, Shopify erasure requests | Client ID of that same app. Optional, but most of the admin needs it. |
|
|
593
|
-
| `REDEEMID_SHOPIFY_STOREFRONT_PRIVATE_TOKEN` | Plans, shop, checkout | Storefront API private access token, used server-side. Optional if the site sells nothing. |
|
|
594
|
-
| `REDEEMID_SHOPIFY_STOREFRONT_PUBLIC_TOKEN` | Plans, shop, checkout | Public access token, only used without the private one |
|
|
595
|
-
| `REDEEMID_SHOPIFY_STORE_DOMAIN` | Discovery | The store's domain, only when `shop` isn't in the config |
|
|
596
|
-
| `REDEEMID_EVENTS_SECRET` | Events | Signs outbound events. Set it with `events.url`. |
|
|
597
|
-
|
|
598
|
-
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.
|
|
599
|
-
|
|
600
|
-
### Components
|
|
601
|
-
|
|
602
|
-
Import each from `@redeemid/emdash/components/<Name>.astro`.
|
|
603
|
-
|
|
604
|
-
| Component | Props | Use |
|
|
605
|
-
| --- | --- | --- |
|
|
606
|
-
| `Content` | `value`, `collection`, `terms`, `id`, `title`; slot `locked` | An entry body with breaks, Members only parts, teaser, meter and wall |
|
|
607
|
-
| `Gate` | `collection`, `terms`, `id`, `levels`; slot `locked` | Locks any part of a template |
|
|
608
|
-
| `LockedPanel` | `reason`, `required`, … | The wall on its own |
|
|
609
|
-
| `AccessBadge` | `collection`, `terms`, `content`, `id`, `free`, `class` | What an entry needs, with a lock for readers kept out |
|
|
610
|
-
| `Account` | | Sign in, or the member's name, levels, Account and Sign out |
|
|
611
|
-
| `MemberCard` | `title` | Levels, renewal, end and gift dates, past levels, meter, features, Refresh membership |
|
|
612
|
-
| `Preferences` | `title`, `heading`, `label`; slot `signed-out` | The member's preference form |
|
|
613
|
-
| `YourData` | `title`, `heading` | Download or delete my data |
|
|
614
|
-
| `Feature` | `slug`; slot `else` | Shows its content to members holding the feature |
|
|
615
|
-
| `Notices` | `messages` | The message for `?redeemid_notice=` |
|
|
616
|
-
| `Plan` | `handle`, `label`, `title`, `levels`, `heldLabel`, `renewLabel`, `upgradeLabel` | A product with a buy button that fits the member |
|
|
617
|
-
| `Product` | `handle`, `heading`, `compact`, `html`, `schema`, `addLabel`, `buyLabel` | A product page |
|
|
618
|
-
| `Collection` | `handle`, `limit`, `productHref`, `showTitle`, `browse`, `quickAdd` | A grid of products, with filters and pages |
|
|
619
|
-
| `ProductSearch` | `productHref`, `limit`, `boxOnly`, `action`, `label`, `quickAdd` | Product search |
|
|
620
|
-
| `Recommendations` | `handle`, `intent`, `limit`, `productHref`, `title`, `quickAdd` | Shopify's recommendations |
|
|
621
|
-
| `ProductGrid` | `products`, `productHref`, `heading`, `quickAdd` | Product cards, for your own lists |
|
|
622
|
-
| `BrowseControls` | `filters`, `pageInfo`, `id`, `search`, `query` | Sort, filter and page controls, for your own lists from `collectionPage()` or `searchProducts()` |
|
|
623
|
-
| `Cart` | `label`, `href`, `drawer` | The cart count, linking to checkout or opening the drawer |
|
|
624
|
-
| `CartView` | `productHref`, `empty` | A cart page |
|
|
625
|
-
| `CartDrawer` | `title` | The cart in a slide-in dialog |
|
|
626
|
-
| `RedeemIDClient` | | The browser client, `window.redeemid` |
|
|
627
|
-
| `Contribution` | `handle`, `amounts`, `initial`, `label`, `title` | Pay what you want |
|
|
628
|
-
|
|
629
|
-
### Editor blocks
|
|
630
|
-
|
|
631
|
-
`redeemidPlugin()` adds these Portable Text blocks to the EmDash editor. `<Content>` renders them.
|
|
632
|
-
|
|
633
|
-
| Block | Type | Does |
|
|
634
|
-
| --- | --- | --- |
|
|
635
|
-
| Paywall break | `redeemid-paywall` | Everyone reads up to here; the rest follows the entry's rule |
|
|
636
|
-
| Members only / Members only: end | `redeemid-members`, `redeemid-members-end` | A members-only part in the middle of a body |
|
|
637
|
-
| Sold on its own | `redeemid-item` | Sells the entry by itself to holders of a tag |
|
|
638
|
-
| Plan | `redeemid-plan` | A product with a buy button |
|
|
639
|
-
| Product | `redeemid-product` | A product with variants and Add to cart |
|
|
640
|
-
| Collection | `redeemid-collection` | A grid of products, optionally with filters, pages and Add to cart |
|
|
641
|
-
| Product search | `redeemid-search` | A search box with Shopify's results |
|
|
642
|
-
| Contribution | `redeemid-contribution` | Pay what you want |
|
|
643
|
-
| Preferences | `redeemid-preferences` | The member's preference form |
|
|
644
|
-
|
|
645
|
-
### Modules
|
|
646
|
-
|
|
647
|
-
| Import | Gives |
|
|
648
|
-
| --- | --- |
|
|
649
|
-
| `@redeemid/emdash` | `redeemid()`, `redeemidPlugin()`, `redeemidGuard()`, config types |
|
|
650
|
-
| `@redeemid/emdash/access` | `siteConfig()`, `canRead()`, `readEntry()`, `entryRequirement()`, `viewer()`, and links: `signInUrl()`, `signOutUrl()`, `accountUrl()`, `cartUrl()`, `refreshUrl()` |
|
|
651
|
-
| `@redeemid/emdash/features` | `hasFeature()`, `viewerFeatures()` |
|
|
652
|
-
| `@redeemid/emdash/preferences` | `readPreferences()`, `savePreferences()` |
|
|
653
|
-
| `@redeemid/emdash/storefront` | `product()`, `collection()`, `plan()`, `money()` and the cart calls |
|
|
654
|
-
| `@redeemid/emdash/catalog` | `searchProducts()`, `collectionPage()`, `suggest()`, `recommendations()` |
|
|
655
|
-
| `@redeemid/emdash/events` | `signBody(body, secret)`, the signature a receiver checks against `X-RedeemID-Signature` |
|
|
656
|
-
| `@redeemid/emdash/text` | `STRINGS`, the list of every replaceable string |
|
|
657
|
-
| `@redeemid/emdash/search` | `readableResults()`, for a search page that calls EmDash's `search()` |
|
|
658
|
-
| `@redeemid/emdash/contract` | The stable API as a list, and `API_VERSION` |
|
|
659
|
-
|
|
660
|
-
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).
|
|
661
|
-
|
|
662
|
-
### Routes
|
|
663
|
-
|
|
664
|
-
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/`.
|
|
665
|
-
|
|
666
|
-
In the Customer Account API client, set the callback URI to `https://<site>/_emdash/api/auth/redeemid/callback`, the logout URI to `https://<site>/`, and (public clients) the JavaScript origin to `https://<site>`. Point webhooks at `https://<site>/_emdash/api/auth/redeemid/webhook`: `customers/create`, `customers/update`, `customers/delete`, `customers/disable`, `customers/enable`, `customers/merge`, `customer.tags_added` and `customer.tags_removed` (the Setup page registers these for you). The compliance topics `customers/data_request`, `customers/redact` and `shop/redact` go to the same URL, set in the app's own configuration.
|
|
667
|
-
|
|
668
|
-
### Notices
|
|
669
|
-
|
|
670
|
-
Routes send readers back with `?redeemid_notice=<code>`, and `<Notices />` shows the message.
|
|
671
|
-
|
|
672
|
-
| From | Codes |
|
|
673
|
-
| --- | --- |
|
|
674
|
-
| 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` |
|
|
675
|
-
| Buy and cart | `plan_unavailable`, `checkout_failed`, `cart_empty`, `cart_added`, `cart_updated`, `discount_applied`, `discount_not_applicable` |
|
|
676
|
-
| Account | `account_unavailable`, `refreshed` |
|
|
677
|
-
| Preferences | `preferences_saved`, `preferences_refused`, `preferences_unavailable` |
|
|
678
|
-
| Privacy | `data_erased`, `erase_unconfirmed` |
|
|
679
|
-
| Rate limits | `too_many_requests` |
|
|
680
|
-
|
|
681
|
-
Reword one with `messages={{ cart_empty: "…" }}`, or for every page with `text`.
|
|
84
|
+
| [Getting started](docs/getting-started.md) | Requirements, install, config, secrets, templates and a first check |
|
|
85
|
+
| [Connect a store](docs/shopify-setup.md) | The Shopify side: sign-in client, Storefront tokens and the webhook app |
|
|
86
|
+
| [Shopify Flow recipes](docs/shopify-flow.md) | Tag on a paid order, remove on refund or cancellation, passes that end |
|
|
87
|
+
| [Content and templates](docs/content.md) | `<Content>`, the Paywall break, Members only, the wall, `<Gate>`, badges, plan cards, site search and replacement text |
|
|
88
|
+
| [Access](docs/access.md) | Rules, levels, the Access page and the entry panel, meter and drip, selling one entry, features and protected files |
|
|
89
|
+
| [Shop and preferences](docs/shop.md) | Products, collections, search, the cart drawer, signed-in checkout, local prices and member preferences |
|
|
90
|
+
| [Staff tools, webhooks and privacy](docs/staff.md) | Member tools, membership dates, webhook health, app events, reports, AI agents, the command line and privacy requests |
|
|
91
|
+
| [Running in production](docs/production.md) | Caching, rate limits and troubleshooting |
|
|
92
|
+
| [Developer API](docs/developer-api.md) | Stable exports, JSON routes, the browser client, server events and MCP tools |
|
|
93
|
+
| [Reference](docs/reference.md) | Every config option, secret, component, editor block, module, route and notice |
|
|
682
94
|
|
|
683
95
|
## Guarantees
|
|
684
96
|
|
|
685
97
|
- Shopify is the only source of entitlements. Nothing on the site grants access.
|
|
686
98
|
- Levels read the mirrored tags as they are, however old. They change only when Shopify sends new ones.
|
|
687
|
-
-
|
|
688
|
-
-
|
|
689
|
-
- 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.
|
|
690
|
-
- Customers can't create the first (admin) account on a new site.
|
|
99
|
+
- Locked content never reaches the content API, site search, feeds or the sitemap for readers kept out, nor a page that renders bodies with `<Content>`. The end-to-end test checks each of these.
|
|
100
|
+
- Staff accounts can't sign in through the store, and customers can't create the first admin account on a new site.
|
|
691
101
|
- Webhooks are checked against the app's signature before anything changes.
|
|
692
|
-
- `customers/merge` moves the sign-in link to the customer Shopify kept. With the Admin API app set up, the kept customer's tags are read straight away; without it they arrive with its next webhook or sign-in.
|
|
693
102
|
- Access fails closed: when the saved access settings can't be read, a server keeps the ones it last read, and with none it locks every entry.
|
|
694
103
|
|
|
695
|
-
##
|
|
696
|
-
|
|
697
|
-
- **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.
|
|
698
|
-
- **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.
|
|
699
|
-
- **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.
|
|
700
|
-
- **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.
|
|
701
|
-
- **A member sees the visitor's page.** A cache is serving it. See [caching](#caching).
|
|
104
|
+
## Contributing
|
|
702
105
|
|
|
703
|
-
|
|
106
|
+
Issues and pull requests are welcome. Work branches from `main`, and pull requests go back into it.
|
|
704
107
|
|
|
705
108
|
```sh
|
|
706
109
|
pnpm install
|
|
@@ -711,8 +114,14 @@ pnpm e2e # boots the playground site against a fake Shopify store
|
|
|
711
114
|
|
|
712
115
|
`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.
|
|
713
116
|
|
|
714
|
-
|
|
117
|
+
Every change keeps the guarantees above: Shopify stays the only source of access, missing or unreadable data gives readers less access rather than more, and no secret, token or personal data is logged or returned.
|
|
118
|
+
|
|
119
|
+
**Releases.** Bump `version` in package.json and merge to main. The Release workflow runs the tests, stages that version on npm and cuts a matching GitHub release, `v<version>`, with the exact tarball and a `.sha256`. A maintainer approves the npm stage before it's public.
|
|
120
|
+
|
|
121
|
+
## Security
|
|
122
|
+
|
|
123
|
+
Please report security problems privately through the repository's **Security** tab rather than in a public issue.
|
|
715
124
|
|
|
716
125
|
## License
|
|
717
126
|
|
|
718
|
-
MIT, the same as EmDash.
|
|
127
|
+
MIT, the same as EmDash. Copyright Vinny S. Green, LLC.
|