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