@redeemid/emdash 0.0.10 → 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.
- package/README.md +65 -1
- package/bin/redeemid-emdash.mjs +144 -0
- package/docs/shopify-setup.md +3 -1
- package/package.json +6 -2
- package/src/agents.ts +179 -0
- package/src/blocks/index.ts +1 -0
- package/src/blocks-names.ts +26 -0
- package/src/components/AccessBadge.astro +6 -3
- package/src/components/Content.astro +15 -6
- package/src/components/LockedPanel.astro +25 -1
- package/src/components/Notices.astro +2 -0
- package/src/components/YourData.astro +42 -0
- package/src/config.ts +6 -1
- package/src/descriptor.ts +1 -0
- package/src/events.ts +1 -1
- package/src/files.ts +8 -3
- package/src/index.ts +1 -0
- package/src/plugin.ts +135 -4
- package/src/privacy.ts +148 -0
- package/src/routes/privacy.ts +59 -0
- package/src/routes/webhook.ts +1 -1
- package/src/staff.ts +92 -0
- package/src/storage.ts +15 -0
- package/src/text.ts +11 -0
- package/src/version.ts +1 -1
- 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.
|
|
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,6 +17,7 @@ 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.
|
|
@@ -24,6 +25,8 @@ Shopify customer accounts, member levels and paywalls for [EmDash](https://githu
|
|
|
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.
|
|
25
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.
|
|
26
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.
|
|
27
30
|
- **Replacement text.** Put any string members and visitors see in your own words, one string at a time, with `text`.
|
|
28
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.
|
|
29
32
|
- **Keeps members out of EmDash's own APIs**, so the gate is the only way to read locked content.
|
|
@@ -112,6 +115,16 @@ Each rule names a collection and the levels, any of which may read. An empty `le
|
|
|
112
115
|
|
|
113
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.
|
|
114
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
|
+
|
|
115
128
|
### Meter and drip
|
|
116
129
|
|
|
117
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.
|
|
@@ -190,6 +203,56 @@ Limits:
|
|
|
190
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.
|
|
191
204
|
- Moving a file out of the folder makes it public again.
|
|
192
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
|
+
|
|
193
256
|
### Replacement text
|
|
194
257
|
|
|
195
258
|
```ts
|
|
@@ -221,6 +284,7 @@ With `REDEEMID_EVENTS_SECRET` set, RedeemID POSTs `{ id, event, at, data }` to t
|
|
|
221
284
|
| `meter.used` | `userId` (EmDash), `entry` (`collection:id`), `used`, `limit`, `period` (`2026-11`, or `2026-W45` by week) |
|
|
222
285
|
| `meter.exhausted` | the same, sent with the last free read |
|
|
223
286
|
| `preferences.saved` | `customerId`, `fields` (the `namespace.key` names changed) |
|
|
287
|
+
| `member.erased` | `customerId`, `source` (`member`, `staff` or `shopify`) |
|
|
224
288
|
|
|
225
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.
|
|
226
290
|
|
|
@@ -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
|
+
});
|
package/docs/shopify-setup.md
CHANGED
|
@@ -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) 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.
|
|
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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@redeemid/emdash",
|
|
3
|
-
"version": "0.0.
|
|
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",
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
"src",
|
|
29
29
|
"README.md",
|
|
30
30
|
"LICENSE",
|
|
31
|
-
"docs"
|
|
31
|
+
"docs",
|
|
32
|
+
"bin"
|
|
32
33
|
],
|
|
33
34
|
"keywords": [
|
|
34
35
|
"emdash",
|
|
@@ -64,5 +65,8 @@
|
|
|
64
65
|
},
|
|
65
66
|
"bugs": {
|
|
66
67
|
"url": "https://github.com/webmultipliers/redeemid-emdash/issues"
|
|
68
|
+
},
|
|
69
|
+
"bin": {
|
|
70
|
+
"redeemid-emdash": "./bin/redeemid-emdash.mjs"
|
|
67
71
|
}
|
|
68
72
|
}
|
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
|
+
}
|
package/src/blocks/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ export const blockComponents = {
|
|
|
13
13
|
"redeemid-paywall": Marker,
|
|
14
14
|
"redeemid-members": Marker,
|
|
15
15
|
"redeemid-members-end": Marker,
|
|
16
|
+
"redeemid-item": Marker,
|
|
16
17
|
"redeemid-plan": PlanBlock,
|
|
17
18
|
"redeemid-product": ProductBlock,
|
|
18
19
|
"redeemid-collection": CollectionBlock,
|
package/src/blocks-names.ts
CHANGED
|
@@ -8,6 +8,32 @@ export const PREFERENCES_BLOCK = "redeemid-preferences";
|
|
|
8
8
|
/** Starts a members-only part of the body; it runs to the next end block. */
|
|
9
9
|
export const MEMBERS_BLOCK = "redeemid-members";
|
|
10
10
|
export const MEMBERS_END_BLOCK = "redeemid-members-end";
|
|
11
|
+
/** Sells the entry on its own: readers holding its tag read all of it. */
|
|
12
|
+
export const ITEM_BLOCK = "redeemid-item";
|
|
13
|
+
|
|
14
|
+
export interface SoldItem {
|
|
15
|
+
/** The Shopify customer tag buyers get, lowercased. */
|
|
16
|
+
tag: string;
|
|
17
|
+
/** The Shopify product handle that sells it, or null. */
|
|
18
|
+
product: string | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The entry's "Sold on its own" block, if it has one with a tag. */
|
|
22
|
+
export function soldItem(value: unknown): SoldItem | null {
|
|
23
|
+
const blocks = Array.isArray(value) ? value : [];
|
|
24
|
+
const b = blocks.find((x) => (x as { _type?: unknown })?._type === ITEM_BLOCK) as { tag?: unknown; product?: unknown } | undefined;
|
|
25
|
+
const tag = typeof b?.tag === "string" ? b.tag.trim().toLowerCase() : "";
|
|
26
|
+
if (!tag) return null;
|
|
27
|
+
const raw = typeof b?.product === "string" ? b.product.trim() : "";
|
|
28
|
+
// A handle, or a product link: /products/<handle>.
|
|
29
|
+
const handle = (/\/products\/([a-z0-9][a-z0-9-]*)/i.exec(raw)?.[1] ?? raw).toLowerCase();
|
|
30
|
+
return { tag, product: /^[a-z0-9][a-z0-9-]*$/.test(handle) ? handle : null };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Does someone with these tags hold the item? */
|
|
34
|
+
export function holdsItem(item: SoldItem | null, tags: string[] | undefined): boolean {
|
|
35
|
+
return Boolean(item && tags?.some((t) => t.toLowerCase() === item.tag));
|
|
36
|
+
}
|
|
11
37
|
|
|
12
38
|
type Node = { _type?: unknown; levels?: unknown };
|
|
13
39
|
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* carries `data-redeemid-level` (the level slugs) for styling, and
|
|
10
10
|
* `is-locked` with a lock for readers who can't read the members part.
|
|
11
11
|
*/
|
|
12
|
-
import { canRead, describe, entryRequirement, type Terms } from "../access.js";
|
|
13
|
-
import { splitAtPaywall } from "../blocks-names.js";
|
|
12
|
+
import { canRead, describe, entryRequirement, viewer, type Terms } from "../access.js";
|
|
13
|
+
import { holdsItem, soldItem, splitAtPaywall } from "../blocks-names.js";
|
|
14
14
|
import { siteConfig } from "../site-config.js";
|
|
15
15
|
import { text } from "../text.js";
|
|
16
16
|
|
|
@@ -30,7 +30,10 @@ const required = split.paywall && split.levels.length
|
|
|
30
30
|
? [split.levels]
|
|
31
31
|
: await entryRequirement(config, collection, terms);
|
|
32
32
|
const label = describe(required, config?.levels, free, text(config, "Signed in"));
|
|
33
|
-
const
|
|
33
|
+
const item = soldItem(content);
|
|
34
|
+
const owner = item ? (await viewer(Astro)).member : null;
|
|
35
|
+
const owned = Boolean(owner && owner.status !== "disabled" && holdsItem(item, owner.tags));
|
|
36
|
+
const locked = required !== null && !owned && !(await canRead(Astro, { collection, terms }, { required })).allowed;
|
|
34
37
|
const slugs = required?.flat().join(" ") || (required ? "signed-in" : "free");
|
|
35
38
|
---
|
|
36
39
|
|
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
* - No break: the entry's rule covers the whole body; readers it keeps out see
|
|
15
15
|
* the collection's teaser (config `teaser`, in words), if any.
|
|
16
16
|
*
|
|
17
|
+
* - "Sold on its own": readers holding the block's Shopify tag read the whole
|
|
18
|
+
* entry, whatever its rule, breaks and drip say. Others see "Buy just this"
|
|
19
|
+
* on the wall when the block names a product.
|
|
20
|
+
*
|
|
17
21
|
* Readers kept out get the wall (LockedPanel), with the "locked" slot on top.
|
|
18
22
|
*
|
|
19
23
|
* Pass `id` on the entry's own page so drip and the meter apply: a rule's
|
|
@@ -23,8 +27,8 @@
|
|
|
23
27
|
*/
|
|
24
28
|
import { PortableText } from "emdash/ui";
|
|
25
29
|
|
|
26
|
-
import { canRead, entryRequirement, readEntry, type Terms, type Verdict } from "../access.js";
|
|
27
|
-
import { sections, splitAtPaywall, teaser } from "../blocks-names.js";
|
|
30
|
+
import { canRead, entryRequirement, readEntry, viewer, type Terms, type Verdict } from "../access.js";
|
|
31
|
+
import { holdsItem, sections, soldItem, splitAtPaywall, teaser } from "../blocks-names.js";
|
|
28
32
|
import { siteConfig } from "../site-config.js";
|
|
29
33
|
import { text } from "../text.js";
|
|
30
34
|
import LockedPanel from "./LockedPanel.astro";
|
|
@@ -41,11 +45,15 @@ const { value, collection = "", terms, id } = Astro.props;
|
|
|
41
45
|
const split = splitAtPaywall(value);
|
|
42
46
|
const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
|
|
43
47
|
const entryRule = await entryRequirement(config, collection, terms);
|
|
48
|
+
// Bought on its own: the whole entry, no meter, no drip.
|
|
49
|
+
const item = soldItem(value);
|
|
50
|
+
const reader = item ? (await viewer(Astro)).member : null;
|
|
51
|
+
const owned = Boolean(reader && reader.status !== "disabled" && holdsItem(item, reader.tags));
|
|
44
52
|
// A break with levels picked locks the rest to them; with none picked it
|
|
45
53
|
// follows the entry's rule, and does nothing when no rule covers the entry.
|
|
46
54
|
const required = split.paywall ? (split.levels.length ? [split.levels] : entryRule) : undefined;
|
|
47
55
|
const verdict: Verdict | { allowed: true; reason: "open" } =
|
|
48
|
-
split.paywall && required === null
|
|
56
|
+
owned || (split.paywall && required === null)
|
|
49
57
|
? { allowed: true, reason: "open" }
|
|
50
58
|
: id
|
|
51
59
|
? await readEntry(Astro, { collection, terms, id }, required ? { required } : undefined)
|
|
@@ -58,7 +66,7 @@ const rest = verdict.allowed ? split.locked : [];
|
|
|
58
66
|
async function parts(blocks: unknown[]) {
|
|
59
67
|
const out: Array<{ blocks: unknown[]; verdict?: Verdict }> = [];
|
|
60
68
|
for (const section of sections(blocks)) {
|
|
61
|
-
if (!section.members) {
|
|
69
|
+
if (!section.members || owned) {
|
|
62
70
|
out.push({ blocks: section.blocks });
|
|
63
71
|
continue;
|
|
64
72
|
}
|
|
@@ -89,7 +97,7 @@ const meterText =
|
|
|
89
97
|
!p.verdict || p.verdict.allowed ? (
|
|
90
98
|
p.blocks.length > 0 && <PortableText value={p.blocks} />
|
|
91
99
|
) : (
|
|
92
|
-
<LockedPanel reason={p.verdict.reason} required={p.verdict.required} held={p.verdict.viewer.levels} />
|
|
100
|
+
<LockedPanel reason={p.verdict.reason} required={p.verdict.required} held={p.verdict.viewer.levels} item={item} />
|
|
93
101
|
),
|
|
94
102
|
)}
|
|
95
103
|
{verdict.allowed ? (
|
|
@@ -97,7 +105,7 @@ const meterText =
|
|
|
97
105
|
!p.verdict || p.verdict.allowed ? (
|
|
98
106
|
p.blocks.length > 0 && <PortableText value={p.blocks} />
|
|
99
107
|
) : (
|
|
100
|
-
<LockedPanel reason={p.verdict.reason} required={p.verdict.required} held={p.verdict.viewer.levels} />
|
|
108
|
+
<LockedPanel reason={p.verdict.reason} required={p.verdict.required} held={p.verdict.viewer.levels} item={item} />
|
|
101
109
|
),
|
|
102
110
|
)
|
|
103
111
|
) : (
|
|
@@ -108,6 +116,7 @@ const meterText =
|
|
|
108
116
|
opensAt={verdict.opensAt}
|
|
109
117
|
meterLeft={verdict.meterLeft}
|
|
110
118
|
meterPeriod={verdict.meter?.period}
|
|
119
|
+
item={item}
|
|
111
120
|
>
|
|
112
121
|
<slot name="locked" />
|
|
113
122
|
</LockedPanel>
|