@redeemid/emdash 0.0.9 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +138 -2
  2. package/bin/redeemid-emdash.mjs +144 -0
  3. package/docs/shopify-setup.md +4 -2
  4. package/package.json +8 -2
  5. package/src/access.ts +2 -2
  6. package/src/agents.ts +179 -0
  7. package/src/blocks/ContributionBlock.astro +1 -1
  8. package/src/blocks/PlanBlock.astro +1 -1
  9. package/src/blocks/PreferencesBlock.astro +12 -0
  10. package/src/blocks/index.ts +3 -0
  11. package/src/blocks-names.ts +27 -0
  12. package/src/components/AccessBadge.astro +10 -6
  13. package/src/components/Account.astro +5 -4
  14. package/src/components/Cart.astro +4 -1
  15. package/src/components/CartView.astro +14 -13
  16. package/src/components/Collection.astro +2 -1
  17. package/src/components/Content.astro +25 -10
  18. package/src/components/Contribution.astro +4 -3
  19. package/src/components/LockedPanel.astro +35 -9
  20. package/src/components/MemberCard.astro +15 -14
  21. package/src/components/Notices.astro +12 -2
  22. package/src/components/Plan.astro +3 -2
  23. package/src/components/Preferences.astro +102 -0
  24. package/src/components/Product.astro +6 -5
  25. package/src/components/YourData.astro +42 -0
  26. package/src/config.ts +38 -1
  27. package/src/descriptor.ts +1 -0
  28. package/src/events.ts +3 -2
  29. package/src/files.ts +196 -0
  30. package/src/guard.ts +43 -2
  31. package/src/index.ts +2 -0
  32. package/src/plugin.ts +169 -4
  33. package/src/preferences.ts +298 -0
  34. package/src/privacy.ts +148 -0
  35. package/src/routes/preferences.ts +82 -0
  36. package/src/routes/privacy.ts +59 -0
  37. package/src/routes/webhook.ts +1 -1
  38. package/src/staff.ts +92 -0
  39. package/src/storage.ts +17 -0
  40. package/src/text.ts +185 -0
  41. package/src/version.ts +1 -1
  42. package/src/webhooks.ts +17 -4
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Shopify customer accounts, member levels and paywalls for [EmDash](https://github.com/emdash-cms/emdash) sites. Shopify runs the store and the customer account; EmDash runs the content.
4
4
 
5
- **Status: 0.0.9, early.** Runs on Node and Cloudflare Workers. Sign-in, the member mirror, webhooks, levels, rules, the meter and drip, teasers, badges, the page gate, the Shopify cart and checkout, the staff tools and outbound events are covered by an end-to-end test against a fake store.
5
+ **Status: 0.0.11, early.** Runs on Node and Cloudflare Workers. Sign-in, the member mirror, webhooks, levels, rules, the meter and drip, teasers, badges, the page gate, the Shopify cart and checkout, the staff tools, preferences, protected files, privacy requests, AI agent tools, the command line, replacement text and outbound events are covered by an end-to-end test against a fake store.
6
6
 
7
7
  ## What it does
8
8
 
@@ -17,11 +17,17 @@ Shopify customer accounts, member levels and paywalls for [EmDash](https://githu
17
17
  - **Account.** Members get an Account link to their Shopify customer account (orders, profile, addresses) and Sign out, which also ends the Shopify session.
18
18
  - **Paywall break block.** Editors write one body and drop a "Paywall break" block where members-only content starts, picking which levels may read on, or leaving it to the entry's rule. `<Content>` renders the body and leaves the rest off the page for everyone else.
19
19
  - **Members only block.** For a members part in the middle of a body: "Members only" (pick the levels) and "Members only: end". Everyone else sees the wall in its place and reads on after it.
20
+ - **Sold on its own block.** Sell one entry by itself: buyers get a Shopify tag that opens the whole entry whatever its levels say, and locked readers see "Buy just this" with the price.
20
21
  - **The wall.** Readers kept out see why and what to do next: sign in, or join, with each level's own wall text. `<Notices>` shows the messages sign-in, Buy, Cart and Account send readers back with.
21
22
  - **Plan block.** Editors add a Shopify product to any page with the "Plan" block: title, price and a buy button.
22
23
  - **Shop.** `<Product>`, `<Collection>` and `<CartView>` (with the Product and Collection blocks) give the site product pages, collection grids and a cart page with quantities and discount codes. `<Contribution>` (and its block) takes pay-what-you-want amounts. Carts note the page each sale started on.
23
24
  - **Features.** Name perks members hold through levels or tags (ad-free, a supporter thank-you) and show them with `<Feature>` or `hasFeature()`.
24
25
  - **In the admin.** RedeemID shows under Plugins with a Members page (Shopify customers, tags, levels; open a member to resync them or add and remove tags, which are written to Shopify), a bulk resync you start yourself, a Status page (secrets, store discovery, webhook subscriptions with a Register button, recent deliveries, levels and rules), an Access test and a dashboard widget.
26
+ - **Member preferences.** Members see and change customer fields you define in Shopify, such as topics or a newsletter choice, with `<Preferences>` or its block. Answers are saved on the Shopify customer, ready for Flow, segments and email; the site keeps no copy.
27
+ - **Protected files.** Put uploads in a media folder named in `files`, and every download checks the reader's levels, features or the entries that use the file. Copied links are no use to anyone else.
28
+ - **Privacy requests.** Members download or delete what the site keeps about them with `<YourData>`. Staff export or erase any member, and can ask Shopify to erase the customer too. Shopify's compliance webhooks are handled and logged on a Privacy page.
29
+ - **AI agents and a command line.** Staff questions (can this reader read this entry, who is this member, what changed) as MCP tools for EmDash's MCP server, and as the `redeemid-emdash` command.
30
+ - **Replacement text.** Put any string members and visitors see in your own words, one string at a time, with `text`.
25
31
  - **Outbound events.** Signed JSON POSTs to a URL of yours when a member signs in, their levels change, or they use the meter, for a CRM, an email tool or your own code.
26
32
  - **Keeps members out of EmDash's own APIs**, so the gate is the only way to read locked content.
27
33
 
@@ -109,6 +115,16 @@ Each rule names a collection and the levels, any of which may read. An empty `le
109
115
 
110
116
  Term rules beat the collection's rule, which beats the site default. When several term rules cover one entry (a story in Investigations, Gold, and Culture, Silver), readers must pass every one of them, so only Gold + Silver members read it. Set `combine: "any"` to let one of them be enough.
111
117
 
118
+ ### Selling one entry on its own
119
+
120
+ Add the **Sold on its own** block anywhere in an entry's body. Give it the Shopify customer tag buyers get, such as `item-big-story`, and the Shopify product that sells it (its handle, or a link to it). Then:
121
+
122
+ - A reader whose customer holds that tag reads the whole entry: its rule, Paywall break, Members only parts and drip don't apply, and the meter isn't used.
123
+ - Locked readers see **Buy just this** on the wall, with the product's price from Shopify, beside the level's offer. It starts checkout for that product.
124
+ - `<AccessBadge>` drops the lock for buyers, and protected files in an `entries: true` folder that the entry uses reach them too.
125
+
126
+ Shopify adds the tag when the order is paid, usually with a Flow workflow (Order paid → the line item is that product → add the customer tag). The site never grants it, so a refund workflow that removes the tag takes the entry away again. A tag on an entry that's open to everyone changes nothing.
127
+
112
128
  ### Meter and drip
113
129
 
114
130
  The meter lets a signed-in reader who can't read an entry open it anyway, `limit` entries per `period` (`"month"`, the default, or `"week"`, in UTC). Each entry counts once per period, when its own page renders through `<Content>` with an `id`; lists, feeds, search and the content API never count. Visitors get the wall and a nudge to sign in. While it runs, `<Content>` says how many free reads are left; when it runs out, the wall says so. Rules with `meter: false` are never metered, and the meter never opens an entry a member is waiting on by drip.
@@ -135,6 +151,124 @@ import { hasFeature } from "@redeemid/emdash/features";
135
151
  {!(await hasFeature(Astro, "ad-free")) && <aside class="ad">…</aside>}
136
152
  ```
137
153
 
154
+ ### Preferences
155
+
156
+ ```ts
157
+ preferences: [
158
+ { key: "preferences.topics" }, // members can change it
159
+ { key: "preferences.level", access: "view" }, // members only see it
160
+ ],
161
+ ```
162
+
163
+ Define each field in Shopify (**Settings → Custom data → Customers**). The definition sets its name, type and limits, such as allowed choices or a minimum and maximum. List the ones members get in `preferences`, in the order to show them. Reading and saving use the Admin API app (`REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID`, with `read_customers` and `write_customers`), always on the signed-in member's own customer. Fields in the `membership` namespace are never offered. Preferences never open content.
164
+
165
+ `<Preferences>` (or the Preferences block) is a form with a control for each field. It works without JavaScript and comes back with the `preferences_saved`, `preferences_refused` or `preferences_unavailable` notice. Visitors see its `signed-out` slot.
166
+
167
+ | Shopify type | Value |
168
+ | --- | --- |
169
+ | Single line text | A string; with choices, one of them |
170
+ | Multi-line text | A string |
171
+ | List of single line text | A list of strings; with choices, each one of them |
172
+ | True or false | `true` or `false` |
173
+ | Integer | A whole number |
174
+ | Decimal | A number, as a string |
175
+ | Date | `YYYY-MM-DD` |
176
+
177
+ Every value is checked against the definition before it goes to Shopify; one refusal saves nothing, and an empty value clears the field. Scripts can use `/_emdash/api/auth/redeemid/preferences` too: `GET` returns `{ fields }` with values, `POST` with JSON `{ values: { "namespace.key": value } }` saves. A refused save answers `400` with `fields` mapping each key to `unknown_field`, `read_only`, `invalid_type`, `invalid_choice`, `invalid_length` or `invalid_range`. Saves are limited to 10 a minute per member (`429`). From your own code, `readPreferences()` and `savePreferences()` in `@redeemid/emdash/preferences` do the same.
178
+
179
+ A save sends the `preferences.saved` event with the field names changed, never the answers.
180
+
181
+ ### Protected files
182
+
183
+ ```ts
184
+ files: [
185
+ { folder: "Gold downloads", levels: ["gold"] },
186
+ { folder: "Member downloads", levels: [] }, // any signed-in member
187
+ { folder: "Supporter extras", features: ["supporter"] },
188
+ { folder: "Story files", entries: true }, // whoever can read an entry that uses it
189
+ ],
190
+ ```
191
+
192
+ Upload files into a folder in the EmDash media library. Folder names match without regard to case. Every request for a file in a protected folder at `/_emdash/api/media/file/…` is checked before EmDash serves it:
193
+
194
+ - **Allowed:** the file, sent with `Cache-Control: private, no-store`.
195
+ - **Signed out:** a 401 page with a sign-in link that comes back to the file.
196
+ - **Signed in without access:** a 403 page.
197
+
198
+ A rule lets in members who hold any of its `levels` (an empty list means any signed-in member) or `features`. With `entries: true`, it also lets in whoever can read a published entry that uses the file: its rule, its drip, and the levels picked on its Paywall break or Members only parts. Staff can always download. Disabled members can't. If a file can't be checked, it isn't served.
199
+
200
+ Limits:
201
+
202
+ - Only EmDash's file route is checked. Don't give the storage bucket a public URL of its own.
203
+ - 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.
204
+ - Moving a file out of the folder makes it public again.
205
+
206
+ ### Privacy requests
207
+
208
+ What RedeemID keeps about a member: the synced record (Shopify customer ID, email, name, tags and when each started, account status, when they first signed in and were last synced) and their meter. Preference answers stay on the Shopify customer. Webhook deliveries keep only a topic and a time. Shopify stays the record of the customer, so erasing on the site never erases Shopify data. It can only ask Shopify to.
209
+
210
+ | Started | What happens |
211
+ | --- | --- |
212
+ | A member, with `<YourData>` on an account page | **Download my data** returns their record and meter as JSON. **Delete my data** needs a ticked box, erases both, and signs them out. |
213
+ | Staff, on a member's page in the admin | **Export data** shows the same JSON. **Erase site data** erases it. **Also request erasure in Shopify** asks Shopify to erase the customer across the store, orders included. Shopify waits before it does, and **Cancel the Shopify erasure** withdraws it. That needs the `write_customer_data_erasure` scope on the Admin API app. |
214
+ | Shopify `customers/data_request` | An open request on the **Privacy** page. Press **Export and mark done**, then send the export to the store owner. |
215
+ | Shopify `customers/redact` | The member's record and meter are erased. |
216
+ | Shopify `shop/redact` | Every member record and meter is erased. |
217
+
218
+ The Privacy page lists every request with where it came from and the Shopify customer ID. It never shows an email or a name. An erase sends the `member.erased` event with `customerId` and `source`, so a CRM that took earlier events can delete its copy too.
219
+
220
+ Erasing leaves the site's own user account (EmDash's users table) in place; delete it in the admin's Users if the person asks. If they sign in again, they're linked again and get back whatever Shopify still grants.
221
+
222
+ The JSON route is `/_emdash/api/auth/redeemid/privacy`: `GET` downloads, and `POST` with `confirm=yes` erases. It only acts for the signed-in member and refuses a POST from another origin.
223
+
224
+ ### AI agents and the command line
225
+
226
+ RedeemID answers the questions staff ask in the admin as data, for AI agents and for scripts. All of them need an administrator. They read what the site has synced. None of them grants access, edits a level or a rule, or returns a secret.
227
+
228
+ | MCP tool | Command | What it answers |
229
+ | --- | --- | --- |
230
+ | `explain_access` | `access <collection>` | Can this reader read this entry, and why. Give the entry by `id` (its terms and body are read from the site) or by `terms`. Give the reader by Shopify `tags`, a member's `customerId`, or `signedIn: false`. The answer covers what the entry needs, the reader's levels, the result and reason, the level a locked reader is offered, drip days, and each members-only part. |
231
+ | `list_levels` | `levels` | Every level, the tags that grant it, its wall text and join link. |
232
+ | `get_rules` | `rules` | How rules combine, every rule, the meter, teasers, features and protected-file rules. |
233
+ | `get_member` | `member <id or email>` | One synced member: status, tags, levels with start dates, past levels, features, last sync. |
234
+ | `site_health` | `health` | Whether each setting is in place (yes or no, never a value), the last webhook, open privacy requests and ignored text replacements. |
235
+ | `level_changes` | `changes --since <date>` | Who joined or left a level since a date. |
236
+
237
+ With `agents: { actions: true }` in the config, three actions are offered too:
238
+
239
+ | MCP tool | Command | What it does |
240
+ | --- | --- | --- |
241
+ | `resync_member` | `resync <customer id>` | Reads one member from Shopify and updates the site. Changes nothing in Shopify. |
242
+ | `resync_batch` | `resync-batch` | Reads the 50 members synced longest ago. |
243
+ | `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. |
244
+
245
+ **Agents.** EmDash's MCP server is at `/_emdash/api/mcp`, on EmDash versions with plugin MCP tools. RedeemID's tools stay off until an administrator turns them on for RedeemID under **Plugins**. Turning them on approves the list as it stands, and a release that changes the list needs approving again. The agent uses an EmDash API token with the `mcp:tools` or `mcp:tools:redeemid` scope, made by an administrator. Tool names carry the plugin's prefix, for example `redeemid__explain_access`.
246
+
247
+ **Command line.**
248
+
249
+ ```sh
250
+ npx -p @redeemid/emdash redeemid-emdash access posts --id my-story --tags gold-member --url https://example.com
251
+ npx -p @redeemid/emdash redeemid-emdash member jane@example.com
252
+ ```
253
+
254
+ It signs in the way EmDash's own CLI does: `--token`, or `EMDASH_TOKEN`, or the token `emdash login` saved for the site. The site comes from `--url` or `EMDASH_URL`. Output is JSON. Each command calls a staff-only route at `/_emdash/api/plugins/redeemid/<name>`, so scripts can call the same routes directly with a POST and a JSON body.
255
+
256
+ ### Replacement text
257
+
258
+ ```ts
259
+ text: {
260
+ "Sign in": "Log in",
261
+ "This is for members. {signIn} to read on.": "Members only. {signIn} to keep reading.",
262
+ "This is for {level} members.": "{level} members only.",
263
+ },
264
+ ```
265
+
266
+ Each key is a string's original English; its value is the new text. Only the strings you name change. There is no find-and-replace across strings, so replacing "Sign in" leaves "Sign in to download this file." alone: to change a word everywhere, replace each string that uses it. `STRINGS` in `@redeemid/emdash/text` lists every string, grouped by where it shows (account, badge, cart, wall, meter, member card, notices, plan, preferences, product, files).
267
+
268
+ 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.
269
+
270
+ Props win over `text`: a `label` or `title` you pass, or Notices `messages`, is used as given. Staff screens in the admin aren't covered.
271
+
138
272
  ### Events
139
273
 
140
274
  ```ts
@@ -149,12 +283,14 @@ With `REDEEMID_EVENTS_SECRET` set, RedeemID POSTs `{ id, event, at, data }` to t
149
283
  | `member.levels_changed` | `customerId`, `levels`, `added`, `removed`, `source` (`sign-in`, the webhook topic, or a staff resync or tag edit) |
150
284
  | `meter.used` | `userId` (EmDash), `entry` (`collection:id`), `used`, `limit`, `period` (`2026-11`, or `2026-W45` by week) |
151
285
  | `meter.exhausted` | the same, sent with the last free read |
286
+ | `preferences.saved` | `customerId`, `fields` (the `namespace.key` names changed) |
287
+ | `member.erased` | `customerId`, `source` (`member`, `staff` or `shopify`) |
152
288
 
153
289
  No event carries an email address or a name: match people by the Shopify `customerId`. `only` limits which events are sent; leave it out for all of them.
154
290
 
155
291
  ### Staff tools
156
292
 
157
- 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 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:
293
+ 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:
158
294
 
159
295
  - **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.
160
296
  - **Bulk resync.** Reads the 50 members synced longest ago from Shopify in one request. Run it again for the next 50. It never runs on its own.
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * redeemid-emdash: ask a RedeemID site the questions staff ask in the admin.
4
+ *
5
+ * npx redeemid-emdash access posts --id my-story --tags gold-member
6
+ * npx redeemid-emdash member jane@example.com
7
+ *
8
+ * Signs in like EmDash's own CLI: --token or EMDASH_TOKEN, else the token
9
+ * `emdash login` saved for the site. The site comes from --url or EMDASH_URL.
10
+ * The token's user must be an administrator.
11
+ */
12
+
13
+ import { readFileSync } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { join } from "node:path";
16
+ import { parseArgs } from "node:util";
17
+
18
+ const HELP = `Usage: redeemid-emdash <command> [options]
19
+
20
+ Commands:
21
+ access <collection> Can a reader read an entry, and why
22
+ --id <id or slug> the entry (or --terms taxonomy:slug,…)
23
+ --tags a,b the reader's Shopify tags (or --customer <id>)
24
+ --signed-out a signed-out visitor
25
+ levels Every level and the tags that grant it
26
+ rules The paywall rules, meter, features and file rules
27
+ member <id or email> One synced member
28
+ health What's set up (never the values)
29
+ changes --since <date> Who joined or left a level [--level <slug>]
30
+ resync <customer id> Read one member from Shopify again *
31
+ resync-batch Read the 50 members synced longest ago *
32
+ tags <customer id> Edit tags in Shopify: --add a,b --remove c *
33
+
34
+ * only when the site sets agents: { actions: true }
35
+
36
+ Options:
37
+ --url <site> the site (or EMDASH_URL)
38
+ --token <token> an EmDash API token (or EMDASH_TOKEN, or emdash login)
39
+ -h, --help`;
40
+
41
+ const { values: o, positionals } = parseArgs({
42
+ allowPositionals: true,
43
+ options: {
44
+ url: { type: "string" },
45
+ token: { type: "string" },
46
+ id: { type: "string" },
47
+ terms: { type: "string" },
48
+ tags: { type: "string" },
49
+ customer: { type: "string" },
50
+ "signed-out": { type: "boolean" },
51
+ since: { type: "string" },
52
+ level: { type: "string" },
53
+ add: { type: "string" },
54
+ remove: { type: "string" },
55
+ help: { type: "boolean", short: "h" },
56
+ },
57
+ });
58
+
59
+ const list = (s) => (s ? s.split(",").map((x) => x.trim()).filter(Boolean) : undefined);
60
+ const [command, arg] = positionals;
61
+
62
+ function request() {
63
+ switch (command) {
64
+ case "access": {
65
+ if (!arg) throw new Error("access needs a collection");
66
+ const terms = {};
67
+ for (const pair of list(o.terms) ?? []) {
68
+ const [tax, slug] = pair.split(":");
69
+ if (tax && slug) (terms[tax] ??= []).push(slug);
70
+ }
71
+ return ["explain-access", { collection: arg, id: o.id, terms: o.terms ? terms : undefined, tags: list(o.tags), customerId: o.customer, signedIn: o["signed-out"] ? false : undefined }];
72
+ }
73
+ case "levels":
74
+ return ["list-levels", {}];
75
+ case "rules":
76
+ return ["get-rules", {}];
77
+ case "member":
78
+ if (!arg) throw new Error("member needs a customer id or an email");
79
+ return ["get-member", arg.includes("@") ? { email: arg } : { customerId: arg }];
80
+ case "health":
81
+ return ["site-health", {}];
82
+ case "changes":
83
+ if (!o.since) throw new Error("changes needs --since <date>");
84
+ return ["level-changes", { since: o.since, level: o.level }];
85
+ case "resync":
86
+ if (!arg) throw new Error("resync needs a customer id");
87
+ return ["resync-member", { customerId: arg }];
88
+ case "resync-batch":
89
+ return ["resync-batch", {}];
90
+ case "tags":
91
+ if (!arg || (!o.add && !o.remove)) throw new Error("tags needs a customer id and --add or --remove");
92
+ return ["edit-member-tags", { customerId: arg, add: list(o.add), remove: list(o.remove) }];
93
+ default:
94
+ return null;
95
+ }
96
+ }
97
+
98
+ /** The token: --token, EMDASH_TOKEN, or what `emdash login` saved for this site. */
99
+ function token(site) {
100
+ if (o.token) return o.token;
101
+ if (process.env.EMDASH_TOKEN) return process.env.EMDASH_TOKEN;
102
+ try {
103
+ const dir = process.env.XDG_CONFIG_HOME ? join(process.env.XDG_CONFIG_HOME, "emdash") : join(homedir(), ".config", "emdash");
104
+ const saved = JSON.parse(readFileSync(join(dir, "auth.json"), "utf8"))[new URL(site).origin];
105
+ if (saved?.accessToken && new Date(saved.expiresAt) > new Date()) return saved.accessToken;
106
+ if (saved?.accessToken) throw new Error("The saved sign-in has expired. Run `npx emdash whoami` (or `emdash login`) for this site, then try again.");
107
+ } catch (e) {
108
+ if (e instanceof Error && e.message.startsWith("The saved")) throw e;
109
+ }
110
+ throw new Error("No token. Pass --token, set EMDASH_TOKEN, or run `npx emdash login --url <site>`.");
111
+ }
112
+
113
+ async function main() {
114
+ const req = o.help || !command ? null : request();
115
+ if (!req) {
116
+ console.log(HELP);
117
+ process.exit(command && !o.help ? 2 : 0);
118
+ }
119
+ const site = (o.url ?? process.env.EMDASH_URL ?? "").replace(/\/+$/, "");
120
+ if (!site) throw new Error("No site. Pass --url or set EMDASH_URL.");
121
+ const [route, body] = req;
122
+ const res = await fetch(`${site}/_emdash/api/plugins/redeemid/${route}`, {
123
+ method: "POST",
124
+ headers: { authorization: `Bearer ${token(site)}`, "content-type": "application/json", "x-emdash-request": "1" },
125
+ body: JSON.stringify(body),
126
+ });
127
+ const text = await res.text();
128
+ let json;
129
+ try {
130
+ json = JSON.parse(text);
131
+ } catch {
132
+ throw new Error(`${res.status} from the site: ${text.slice(0, 200)}`);
133
+ }
134
+ if (res.status === 404) throw new Error(`The site has no "${route}" route. Actions need agents: { actions: true }, and every command needs @redeemid/emdash 0.0.11 or later.`);
135
+ if (!res.ok) throw new Error(json?.error?.message ?? `${res.status} from the site`);
136
+ const out = json?.data ?? json;
137
+ if (out && out.ok === false) throw new Error(out.error);
138
+ console.log(JSON.stringify(out?.result ?? out, null, 2));
139
+ }
140
+
141
+ main().catch((e) => {
142
+ console.error(`redeemid-emdash: ${e.message}`);
143
+ process.exit(1);
144
+ });
@@ -29,7 +29,9 @@ RedeemID keeps tags and status current from webhooks. Point these topics at `htt
29
29
 
30
30
  Subscribe them from a Shopify app on the store (for example a Dev Dashboard app). If the app already sends webhooks to another site, add this URL as extra subscriptions. `customers/merge` needs the `read_customer_merge` scope. Shopify signs every webhook with that app's **client secret**, which is the site's webhook secret.
31
31
 
32
- For the staff tools (member resync, tag edits written to Shopify, the Register webhooks button), give the same app the `read_customers` and `write_customers` Admin API scopes and set its **client ID** as well. RedeemID uses the client credentials grant, which works when the app and the store are in the same Shopify organization.
32
+ For the staff tools (member resync, tag edits written to Shopify, the Register webhooks button) and member preferences, give the same app the `read_customers` and `write_customers` Admin API scopes and set its **client ID** as well. RedeemID uses the client credentials grant, which works when the app and the store are in the same Shopify organization. For the **Also request erasure in Shopify** button on a member's page, add the optional `write_customer_data_erasure` scope.
33
+
34
+ Shopify's compliance topics (`customers/data_request`, `customers/redact`, `shop/redact`) reach only apps that declare them in the app's configuration, under `[webhooks.privacy_compliance]`. Point all three at the same webhook URL. RedeemID logs each one on the Privacy page.
33
35
 
34
36
  ## 3. The site
35
37
 
@@ -57,4 +59,4 @@ The Workers free plan is enough: RedeemID is a native plugin and doesn't need sa
57
59
  3. Click **Sign in**, sign in with that customer's email, and you come back to the post, unlocked.
58
60
  4. Add or remove the tag in Shopify; the post opens or locks again once the webhook arrives.
59
61
 
60
- If sign-in sends you back with `?redeemid_notice=…`, the code says why: `not_configured` (secrets or `shop` missing), `sign_in_unavailable` (store discovery failed), `sign_in_expired`, `staff_account`, `site_not_ready`, `email_required`, `token_failed`, `bad_id_token`, `identity_failed`. A Buy button can return `plan_unavailable` (no such product, or sold out) or `checkout_failed`. The Cart link returns `cart_empty` when there's nothing in the cart, and Account returns `account_unavailable` when store discovery fails. Cart changes return `cart_added`, `cart_updated`, `discount_applied` or `discount_not_applicable`. Refresh membership returns `refreshed` once it has read the member's tags again.
62
+ If sign-in sends you back with `?redeemid_notice=…`, the code says why: `not_configured` (secrets or `shop` missing), `sign_in_unavailable` (store discovery failed), `sign_in_expired`, `staff_account`, `site_not_ready`, `email_required`, `token_failed`, `bad_id_token`, `identity_failed`. A Buy button can return `plan_unavailable` (no such product, or sold out) or `checkout_failed`. The Cart link returns `cart_empty` when there's nothing in the cart, and Account returns `account_unavailable` when store discovery fails. Cart changes return `cart_added`, `cart_updated`, `discount_applied` or `discount_not_applicable`. Refresh membership returns `refreshed` once it has read the member's tags again. Preferences return `preferences_saved`, `preferences_refused` or `preferences_unavailable`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redeemid/emdash",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
4
4
  "description": "Shopify customer accounts, member levels and paywalls for EmDash sites. Shopify runs commerce; EmDash runs content.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -17,6 +17,8 @@
17
17
  "./storefront": "./src/storefront.ts",
18
18
  "./features": "./src/features.ts",
19
19
  "./events": "./src/events.ts",
20
+ "./preferences": "./src/preferences.ts",
21
+ "./text": "./src/text.ts",
20
22
  "./plugin": "./src/plugin.ts",
21
23
  "./blocks": "./src/blocks/index.ts",
22
24
  "./components/*": "./src/components/*",
@@ -26,7 +28,8 @@
26
28
  "src",
27
29
  "README.md",
28
30
  "LICENSE",
29
- "docs"
31
+ "docs",
32
+ "bin"
30
33
  ],
31
34
  "keywords": [
32
35
  "emdash",
@@ -62,5 +65,8 @@
62
65
  },
63
66
  "bugs": {
64
67
  "url": "https://github.com/webmultipliers/redeemid-emdash/issues"
68
+ },
69
+ "bin": {
70
+ "redeemid-emdash": "./bin/redeemid-emdash.mjs"
65
71
  }
66
72
  }
package/src/access.ts CHANGED
@@ -277,11 +277,11 @@ export function passes(held: string[], required: string[][]): boolean {
277
277
  * The requirement in words, for badges and admin pages: "Gold + Silver",
278
278
  * "Silver or Gold", "Signed in". `null` gives `open`.
279
279
  */
280
- export function describe(required: Requirement, levels: LevelConfig[] = [], open = "Free"): string {
280
+ export function describe(required: Requirement, levels: LevelConfig[] = [], open = "Free", signedIn = "Signed in"): string {
281
281
  if (required === null) return open;
282
282
  const name = (slug: string) => levels.find((l) => l.slug === slug)?.name ?? slug;
283
283
  const parts = reduce(required).filter((c) => c.length > 0).map((c) => c.map(name).join(" or "));
284
- return parts.length ? parts.join(" + ") : "Signed in";
284
+ return parts.length ? parts.join(" + ") : signedIn;
285
285
  }
286
286
 
287
287
  /**
package/src/agents.ts ADDED
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Answers for AI agents (EmDash's MCP server) and the command line: the same
3
+ * questions the admin answers, as plain data. Staff only. Read-only by
4
+ * default; the three actions (resync one member, resync a batch, edit a
5
+ * member's tags in Shopify) are offered only with config `agents.actions`.
6
+ *
7
+ * Nothing here grants access on the site, edits a level or rule, or returns a
8
+ * secret: Shopify still decides who is entitled, and settings show only
9
+ * whether each value is set.
10
+ */
11
+
12
+ import { adminConfigured } from "./admin-api.js";
13
+ import { describe as describeRequirement, dripDays, levelsFor, membership, passes, requirement, termTree, type Requirement, type Terms } from "./access.js";
14
+ import { ENV, env, storeDomain, type RedeemIDConfig } from "./config.js";
15
+ import { featuresFor } from "./features.js";
16
+ import { bodyClauses } from "./files.js";
17
+ import { holdsItem, soldItem, type SoldItem } from "./blocks-names.js";
18
+ import { cleanTags, numericId, type MemberRecord, type Stores } from "./storage.js";
19
+ import { textProblems } from "./text.js";
20
+
21
+ export class AgentError extends Error {}
22
+
23
+ export interface ExplainInput {
24
+ collection: string;
25
+ /** An entry's id or slug: its terms and body are read from the site. */
26
+ id?: string;
27
+ /** Terms by taxonomy, when no `id` is given: { category: ["investigations"] }. */
28
+ terms?: Record<string, string[]>;
29
+ /** A reader by their Shopify tags… */
30
+ tags?: string[];
31
+ /** …or a real member, by Shopify customer id. */
32
+ customerId?: string;
33
+ /** A signed-out visitor when false. Defaults to true. */
34
+ signedIn?: boolean;
35
+ }
36
+
37
+ /** Can this reader read this entry, and why. */
38
+ export async function explainAccess(config: RedeemIDConfig, s: Stores, input: ExplainInput): Promise<Record<string, unknown>> {
39
+ const levels = config.levels ?? [];
40
+ const collection = input.collection.trim();
41
+ if (!collection) throw new AgentError("collection is required");
42
+ let terms: Terms = input.terms ?? {};
43
+ let parts: string[][] = [];
44
+ let entry: { id: string; title: string | null } | null = null;
45
+ let item: SoldItem | null = null;
46
+ if (input.id) {
47
+ const { getEmDashEntry } = await import("emdash");
48
+ const result = (await getEmDashEntry(collection, input.id)) as { entry?: { id?: string; data?: Record<string, unknown> } | null } | null;
49
+ const data = result?.entry?.data;
50
+ if (!data) throw new AgentError(`No published ${collection} entry "${input.id}"`);
51
+ terms = (data.terms as Terms | undefined) ?? {};
52
+ const body = Object.values(data).find((v) => Array.isArray(v) && v.some((b) => typeof (b as { _type?: unknown })?._type === "string" && String((b as { _type: string })._type).startsWith("redeemid-")));
53
+ parts = bodyClauses(body);
54
+ item = soldItem(body);
55
+ entry = { id: String(result?.entry?.id ?? input.id), title: typeof data.title === "string" ? data.title : null };
56
+ }
57
+
58
+ let tags = cleanTags(input.tags ?? []);
59
+ let member: MemberRecord | null = null;
60
+ if (input.customerId) {
61
+ const id = numericId(input.customerId);
62
+ member = id ? await s.members.get(id) : null;
63
+ if (!member) throw new AgentError(`No synced member with customer id ${input.customerId}`);
64
+ tags = member.tags;
65
+ }
66
+ const signedIn = input.signedIn !== false || Boolean(member);
67
+ const disabled = member?.status === "disabled";
68
+ const held = disabled ? [] : levelsFor(tags, levels);
69
+ const tree = await termTree(config.rules, collection, terms);
70
+ const required: Requirement = requirement(config.rules, collection, terms, { combine: config.combine, tree });
71
+ const owned = !disabled && holdsItem(item, tags);
72
+ const canReadRule = owned || required === null || (signedIn && !disabled && passes(held, required));
73
+ const name = (slug: string) => levels.find((l) => l.slug === slug)?.name ?? slug;
74
+ const partResults = parts.map((clause) => ({
75
+ needs: clause.map(name),
76
+ canRead: owned || (signedIn && !disabled && (clause.includes("signed-in") || clause.some((l) => held.includes(l)))),
77
+ }));
78
+ const missing = required?.find((clause) => clause.length > 0 && !passes(held, [clause]));
79
+ return {
80
+ entry,
81
+ collection,
82
+ terms,
83
+ reader: {
84
+ signedIn,
85
+ ...(member ? { customerId: member.customerId, status: member.status } : {}),
86
+ tags,
87
+ levels: held.map(name),
88
+ },
89
+ needs: describeRequirement(required, levels),
90
+ canRead: canReadRule,
91
+ reason: canReadRule ? "allowed" : !signedIn ? "sign_in" : disabled ? "disabled" : "upgrade",
92
+ offered: !canReadRule && missing ? name(missing[0] as string) : null,
93
+ dripDays: owned ? 0 : dripDays(config.rules, collection, terms, tree),
94
+ soldOnItsOwn: item ? { tag: item.tag, product: item.product, held: owned } : null,
95
+ parts: partResults,
96
+ note: "Drip and the meter depend on the member's dates and use, so they can still change what the page shows.",
97
+ };
98
+ }
99
+
100
+ export function listLevels(config: RedeemIDConfig): Array<Record<string, unknown>> {
101
+ return (config.levels ?? []).map((l) => ({ slug: l.slug, name: l.name, tags: l.tags, match: l.match ?? "any", wall: l.wall ?? null, joinUrl: l.joinUrl ?? null }));
102
+ }
103
+
104
+ export function getRules(config: RedeemIDConfig): Record<string, unknown> {
105
+ return {
106
+ combine: config.combine ?? "all",
107
+ rules: config.rules ?? [],
108
+ meter: config.meter ?? null,
109
+ teaser: config.teaser ?? {},
110
+ features: config.features ?? [],
111
+ files: config.files ?? [],
112
+ };
113
+ }
114
+
115
+ /** One synced member, by customer id or email. */
116
+ export async function getMember(config: RedeemIDConfig, s: Stores, input: { customerId?: string; email?: string }): Promise<Record<string, unknown>> {
117
+ let m: MemberRecord | null = null;
118
+ const id = input.customerId ? numericId(input.customerId) : null;
119
+ if (id) m = await s.members.get(id);
120
+ else if (input.email) {
121
+ const { items } = await s.members.query({ where: { email: input.email.trim().toLowerCase() }, limit: 1 });
122
+ m = items[0]?.data ?? null;
123
+ } else throw new AgentError("Give customerId or email");
124
+ if (!m) throw new AgentError("No synced member matches");
125
+ const levels = config.levels ?? [];
126
+ const { current, past } = membership(m, levels);
127
+ const held = featuresFor(m, config.features, levels);
128
+ return {
129
+ customerId: m.customerId,
130
+ email: m.email,
131
+ name: [m.firstName, m.lastName].filter(Boolean).join(" ") || null,
132
+ status: m.status,
133
+ signedInHere: Boolean(m.userId),
134
+ linkedAt: m.linkedAt ?? null,
135
+ tags: m.tags,
136
+ levels: current.map((l) => ({ slug: l.slug, name: l.name, since: l.since })),
137
+ pastLevels: past.map((l) => ({ slug: l.slug, name: l.name, from: l.from, to: l.to })),
138
+ features: (config.features ?? []).filter((f) => held.includes(f.slug)).map((f) => f.slug),
139
+ syncedAt: m.syncedAt,
140
+ source: m.source,
141
+ };
142
+ }
143
+
144
+ /** What's set up, as yes or no: never a value. */
145
+ export async function siteHealth(config: RedeemIDConfig, s: Stores): Promise<Record<string, unknown>> {
146
+ const { items } = await s.deliveries.query({ orderBy: { receivedAt: "desc" }, limit: 1 });
147
+ const open = await s.requests.query({ where: { status: "open" }, limit: 50 });
148
+ return {
149
+ storeDomain: Boolean(storeDomain(config)),
150
+ customerAccountClientId: Boolean(env(ENV.clientId)),
151
+ webhookSecret: Boolean(env(ENV.webhookSecret)),
152
+ adminApi: adminConfigured(),
153
+ events: Boolean(config.events?.url) && Boolean(env(ENV.eventsSecret)),
154
+ storefront: env(ENV.storefrontPrivateToken) ? "private token" : env(ENV.storefrontPublicToken) ? "public token" : "tokenless",
155
+ lastWebhook: items[0]?.data ?? null,
156
+ openPrivacyRequests: open.items.length,
157
+ ignoredTextReplacements: textProblems(config).length,
158
+ };
159
+ }
160
+
161
+ /** Who started or ended a level since a date, from each member's tag dates. */
162
+ export async function levelChanges(config: RedeemIDConfig, s: Stores, input: { since: string; level?: string }): Promise<Array<Record<string, unknown>>> {
163
+ const since = Date.parse(input.since);
164
+ if (Number.isNaN(since)) throw new AgentError("since must be a date, e.g. 2026-11-01");
165
+ const levels = (config.levels ?? []).filter((l) => !input.level || l.slug === input.level);
166
+ if (input.level && levels.length === 0) throw new AgentError(`No level "${input.level}"`);
167
+ const out: Array<Record<string, unknown>> = [];
168
+ let cursor: string | undefined;
169
+ do {
170
+ const page = await s.members.query({ limit: 100, cursor });
171
+ for (const { data: m } of page.items) {
172
+ const { current, past } = membership(m, config.levels ?? []);
173
+ for (const l of current) if (levels.some((x) => x.slug === l.slug) && l.since && Date.parse(l.since) >= since) out.push({ customerId: m.customerId, email: m.email, level: l.slug, change: "joined", at: l.since });
174
+ for (const l of past) if (levels.some((x) => x.slug === l.slug) && Date.parse(l.to) >= since) out.push({ customerId: m.customerId, email: m.email, level: l.slug, change: "left", at: l.to });
175
+ }
176
+ cursor = page.cursor;
177
+ } while (cursor);
178
+ return out.sort((a, b) => String(a.at).localeCompare(String(b.at)));
179
+ }
@@ -18,7 +18,7 @@ const amounts = (node.amounts ?? "")
18
18
  <Contribution
19
19
  handle={handle}
20
20
  title={node.title?.trim() || undefined}
21
- label={node.label?.trim() || "Give"}
21
+ label={node.label?.trim() || undefined}
22
22
  amounts={amounts.length ? amounts : undefined}
23
23
  />
24
24
  )}
@@ -10,4 +10,4 @@ const { node } = Astro.props;
10
10
  const handle = (node.handle ?? "").trim();
11
11
  ---
12
12
 
13
- {handle && <Plan handle={handle} title={node.title?.trim() || undefined} label={node.label?.trim() || "Join"} levels={Array.isArray(node.levels) ? node.levels : []} />}
13
+ {handle && <Plan handle={handle} title={node.title?.trim() || undefined} label={node.label?.trim() || undefined} levels={Array.isArray(node.levels) ? node.levels : []} />}
@@ -0,0 +1,12 @@
1
+ ---
2
+ /** The "Preferences" block: the signed-in member's preferences form. */
3
+ import Preferences from "../components/Preferences.astro";
4
+
5
+ interface Props {
6
+ node: { title?: string; label?: string };
7
+ }
8
+
9
+ const { node } = Astro.props;
10
+ ---
11
+
12
+ <Preferences title={node.title?.trim() || undefined} label={node.label?.trim() || undefined} />
@@ -6,14 +6,17 @@ import Marker from "./PaywallBreak.astro";
6
6
  import CollectionBlock from "./CollectionBlock.astro";
7
7
  import ContributionBlock from "./ContributionBlock.astro";
8
8
  import PlanBlock from "./PlanBlock.astro";
9
+ import PreferencesBlock from "./PreferencesBlock.astro";
9
10
  import ProductBlock from "./ProductBlock.astro";
10
11
 
11
12
  export const blockComponents = {
12
13
  "redeemid-paywall": Marker,
13
14
  "redeemid-members": Marker,
14
15
  "redeemid-members-end": Marker,
16
+ "redeemid-item": Marker,
15
17
  "redeemid-plan": PlanBlock,
16
18
  "redeemid-product": ProductBlock,
17
19
  "redeemid-collection": CollectionBlock,
18
20
  "redeemid-contribution": ContributionBlock,
21
+ "redeemid-preferences": PreferencesBlock,
19
22
  };