@redeemid/emdash 0.0.19 → 0.0.21
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 +58 -36
- package/docs/developer-api.md +13 -10
- package/docs/shopify-flow.md +70 -0
- package/docs/shopify-setup.md +13 -7
- package/package.json +2 -1
- package/src/access-admin.ts +12 -6
- package/src/access.ts +33 -9
- package/src/admin-api.ts +26 -4
- package/src/api-version.ts +1 -1
- package/src/cart-ops.ts +3 -2
- package/src/checkout.ts +5 -4
- package/src/components/LockedPanel.astro +12 -2
- package/src/components/MemberCard.astro +16 -2
- package/src/components/Plan.astro +12 -6
- package/src/config.ts +6 -0
- package/src/contract.ts +1 -1
- package/src/descriptor.ts +1 -0
- package/src/events.ts +122 -16
- package/src/files.ts +8 -2
- package/src/guard.ts +1 -1
- package/src/index.ts +47 -1
- package/src/log.ts +5 -2
- package/src/monitor.ts +63 -1
- package/src/plugin.ts +91 -4
- package/src/rate-limit.ts +3 -1
- package/src/routes/account.ts +2 -2
- package/src/routes/api/state.ts +2 -1
- package/src/routes/buy.ts +3 -3
- package/src/routes/callback.ts +16 -1
- package/src/routes/preferences.ts +2 -2
- package/src/routes/privacy.ts +2 -2
- package/src/routes/shared.ts +19 -0
- package/src/routes/webhook.ts +26 -3
- package/src/settings.ts +96 -8
- package/src/setup.ts +280 -0
- package/src/site-config.ts +5 -1
- package/src/staff.ts +13 -0
- package/src/storage.ts +33 -0
- package/src/template-scan.ts +77 -0
- package/src/templates.ts +15 -0
- package/src/text.ts +8 -1
- package/src/token-vault.ts +83 -0
- package/src/version.ts +1 -1
- package/src/webhooks.ts +4 -0
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Memberships and paywalls for [EmDash](https://github.com/emdash-cms/emdash) site
|
|
|
7
7
|
|
|
8
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
9
|
|
|
10
|
-
**Status: 0.0.
|
|
10
|
+
**Status: 0.0.21, early.** Runs on Node and Cloudflare Workers. Unit tests and an end-to-end test that boots a real EmDash site against a fake Shopify store cover the features below. Expect changes before 1.0.
|
|
11
11
|
|
|
12
12
|
## Contents
|
|
13
13
|
|
|
@@ -30,13 +30,13 @@ Readers sign in with the store's own customer account. Their Shopify customer ta
|
|
|
30
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
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
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.
|
|
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. [docs/shopify-flow.md](docs/shopify-flow.md) has the workflows: paid order, refund, cancelled subscription, and a pass that ends.
|
|
34
34
|
|
|
35
35
|
## What you get
|
|
36
36
|
|
|
37
37
|
**For members**
|
|
38
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.
|
|
39
|
+
- A member card with their levels and when each started, when the membership renews or ends and any gift membership (from Shopify), past levels, the meter, and Refresh membership to read their tags again after a purchase.
|
|
40
40
|
- Preferences they can change, such as topics or a newsletter choice, saved on the Shopify customer.
|
|
41
41
|
- Download or delete what the site keeps about them.
|
|
42
42
|
|
|
@@ -53,7 +53,8 @@ Selling a membership is ordinary Shopify: a product, a checkout, and a Flow work
|
|
|
53
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
54
|
- App events: webhooks, sign-ins and refusals, Admin API calls, staff actions and settings saves, filterable, kept 30 days.
|
|
55
55
|
- Reports: members by level, who joined and left by week, sign-ins and metered reads.
|
|
56
|
-
-
|
|
56
|
+
- Setup page: connecting a store step by step, with the exact values to paste into Shopify, the command for each secret, a Register button for the webhooks and a form for the first level. Each step checks the site as it is.
|
|
57
|
+
- Status page: every setting, store discovery, levels and rules, and any template that would show a locked body in full.
|
|
57
58
|
- Access test, Privacy page, and a dashboard widget.
|
|
58
59
|
|
|
59
60
|
**For developers**
|
|
@@ -75,7 +76,7 @@ Selling a membership is ordinary Shopify: a product, a checkout, and a Flow work
|
|
|
75
76
|
|
|
76
77
|
### 1. Set up Shopify
|
|
77
78
|
|
|
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
|
+
Follow [docs/shopify-setup.md](docs/shopify-setup.md). It gives you a Customer Account API client, Storefront API tokens and a webhook app. Once the package is installed, **Plugins → RedeemID → Setup** walks through the same steps for your site, with its own callback URI filled in, and says which are done.
|
|
79
80
|
|
|
80
81
|
### 2. Install
|
|
81
82
|
|
|
@@ -128,10 +129,10 @@ Set them as runtime secrets of the site (`.env` locally, `wrangler secret put` o
|
|
|
128
129
|
```sh
|
|
129
130
|
REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID=… # sign-in
|
|
130
131
|
REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_SECRET=… # verifies webhooks
|
|
131
|
-
REDEEMID_SHOPIFY_STOREFRONT_PRIVATE_TOKEN=… # plans, shop and checkout
|
|
132
|
+
REDEEMID_SHOPIFY_STOREFRONT_PRIVATE_TOKEN=… # only to sell on the site: plans, shop and checkout
|
|
132
133
|
```
|
|
133
134
|
|
|
134
|
-
The [secrets reference](#secrets) lists the
|
|
135
|
+
The [secrets reference](#secrets) lists the rest. The Setup page prints the command for each secret its steps need.
|
|
135
136
|
|
|
136
137
|
### 5. Render content through RedeemID
|
|
137
138
|
|
|
@@ -147,12 +148,12 @@ import Notices from "@redeemid/emdash/components/Notices.astro";
|
|
|
147
148
|
<Content value={post.data.content} collection="posts" terms={post.data.terms} id={post.id} title={post.data.title} />
|
|
148
149
|
```
|
|
149
150
|
|
|
150
|
-
Use `<Content>` in place of `<PortableText>` for every body that may be locked
|
|
151
|
+
Use `<Content>` in place of `<PortableText>` for every body that may be locked, because plain `<PortableText>` shows everything. You don't have to find them by hand: `redeemidGuard()` scans the site's `.astro` files at build time, and the Status page names each template, with its line, that renders a gated collection's body with `<PortableText>`. While any rule is on, it also lists templates whose collection it can't tell (such as a component given the body as a prop), so you can check them.
|
|
151
152
|
|
|
152
153
|
### 6. Check it
|
|
153
154
|
|
|
154
155
|
1. Finish EmDash setup as staff first. Store customers can't create the first account.
|
|
155
|
-
2. Open **Plugins → RedeemID →
|
|
156
|
+
2. Open **Plugins → RedeemID → Setup**. Work down the steps until it says RedeemID is set up (it registers the webhooks for you). **Status** lists every setting as Set or Missing.
|
|
156
157
|
3. Tag a test customer `member` in Shopify, open a locked post, and sign in as that customer. The post opens.
|
|
157
158
|
4. Remove the tag in Shopify. The post locks again once the webhook arrives.
|
|
158
159
|
|
|
@@ -188,9 +189,9 @@ Pass `id` on the entry's own page. It lets the meter count the read, and a locke
|
|
|
188
189
|
|
|
189
190
|
**`<Notices />`** goes near the top of the layout. It shows the message for a `?redeemid_notice=` code (see [notices](#notices)) and nothing otherwise.
|
|
190
191
|
|
|
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
|
+
**`<MemberCard />`** is for an account page: levels with start dates, the renewal or end date and any gift membership that Shopify keeps (see [membership dates](#membership-dates)), past levels, the meter, and links to the store account, Refresh membership and Sign out. Refresh membership sends a member to the store with no sign-in form; one still signed in there comes straight back with their tags read again. Add `<Preferences />` and `<YourData />` beside it.
|
|
192
193
|
|
|
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
|
+
**`<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, and a member who held one before (Shopify has since removed its tag) gets **Renew** (`renewLabel`) instead of `label`. The wall does the same: a lapsed member reads "Your Pro membership has ended." with **Renew Pro** and **Already renewed? Refresh**, not the newcomer's join link.
|
|
194
195
|
|
|
195
196
|
```astro
|
|
196
197
|
<Plan handle="gold-membership" label="Join Gold" levels={["gold"]}>
|
|
@@ -201,15 +202,19 @@ Pass `id` on the entry's own page. It lets the meter count the read, and a locke
|
|
|
201
202
|
**In your own code** (`@redeemid/emdash/access`):
|
|
202
203
|
|
|
203
204
|
```ts
|
|
204
|
-
import { canRead, entryRequirement, readEntry } from "@redeemid/emdash/access";
|
|
205
|
+
import { canRead, entryRequirement, readEntry, siteConfig } from "@redeemid/emdash/access";
|
|
205
206
|
|
|
206
|
-
const verdict = await canRead(Astro, { collection: "posts", terms: post.data.terms });
|
|
207
|
+
const verdict = await canRead(Astro, { collection: "posts", terms: post.data.terms, id: post.id });
|
|
207
208
|
// verdict.allowed; verdict.reason: open | allowed | sign_in | upgrade | disabled | unconfigured
|
|
208
209
|
const read = await readEntry(Astro, { collection: "posts", terms: post.data.terms, id: post.id });
|
|
209
210
|
// adds drip (read.opensAt) and the meter (read.meterLeft), and counts a metered read
|
|
211
|
+
|
|
212
|
+
const config = await siteConfig(Astro.locals);
|
|
213
|
+
const needs = await entryRequirement(config, "posts", post.data.terms, post.id);
|
|
214
|
+
// e.g. [["gold", "pro"]]: readers pass every clause, each with any one of its levels; null when open
|
|
210
215
|
```
|
|
211
216
|
|
|
212
|
-
`
|
|
217
|
+
`siteConfig(Astro.locals)` is the config for this request: your code's config with the Access page's saved settings laid over it. Pass it to the functions that take a `config`, such as `entryRequirement()`, `readPreferences()` and the storefront and catalog calls.
|
|
213
218
|
|
|
214
219
|
## Guides
|
|
215
220
|
|
|
@@ -242,7 +247,7 @@ Everything about who reads what can be set in the EmDash admin, so a new content
|
|
|
242
247
|
- **Taxonomies.** Rules for terms in every taxonomy, for one collection or every collection the taxonomy is on.
|
|
243
248
|
- **Features** and **Protected files.**
|
|
244
249
|
|
|
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
|
|
250
|
+
The first save of a setting copies its value from your code config, so nothing is lost, and from then on the admin's value is used. **Use the code's setting** removes the admin's copy. Saved settings are kept in the site's database. Each server keeps the last ones it read and checks one small record per request to see whether anyone saved since, so the whole set is read again only after a save. If the database can't be read, the settings last read stand; a server that has read none yet locks every entry (and refuses files in any media folder) until it can, rather than falling back to the code's settings and opening what the admin locked.
|
|
246
251
|
|
|
247
252
|
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
253
|
|
|
@@ -250,10 +255,12 @@ Each saved entry also gets an **Access** panel in the editor. It shows who can r
|
|
|
250
255
|
| --- | --- |
|
|
251
256
|
| Follow the site rules | What the rules say (the default) |
|
|
252
257
|
| Free to everyone | Nothing |
|
|
253
|
-
| Only
|
|
254
|
-
|
|
|
258
|
+
| Only some levels | One of the levels ticked (none ticked: any signed-in member) |
|
|
259
|
+
| Site rules plus a level | What the rules say and one of the levels ticked |
|
|
260
|
+
|
|
261
|
+
The panel can also hold the entry back for some days of membership, keep the meter off it, lock it until a date and then open it, or keep it free until a date and then lock it. Pass the entry's `id` (or slug) to `<Content>`, `<AccessBadge>` and `<Gate>` so its own rule applies there.
|
|
255
262
|
|
|
256
|
-
|
|
263
|
+
Moving an entry to the trash keeps its rule for a restore but frees its slug, so a new entry that takes the slug doesn't inherit the rule.
|
|
257
264
|
|
|
258
265
|
### Meter and drip
|
|
259
266
|
|
|
@@ -329,7 +336,7 @@ Products, collections, search and the cart come from the Storefront API. Shopify
|
|
|
329
336
|
|
|
330
337
|
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
338
|
|
|
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.
|
|
339
|
+
**Signed-in checkout.** When a signed-in member checks out, RedeemID puts their store sign-in (the Customer Account API access token, kept only in the server-side session, encrypted with a key kept in the site's database, and renewed when it's about to expire) on the cart, so Shopify's checkout opens signed in and the order lands on their customer record, where your Flow or apps tag it. If that can't be done, they check out as a guest with their email filled in. Set `checkout: { signIn: false }` to always use a guest checkout; then the tokens aren't kept at all.
|
|
333
340
|
|
|
334
341
|
**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
342
|
|
|
@@ -358,7 +365,7 @@ Define each field in Shopify (**Settings → Custom data → Customers**); the d
|
|
|
358
365
|
| Decimal | A number, as a string |
|
|
359
366
|
| Date | `YYYY-MM-DD` |
|
|
360
367
|
|
|
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
|
|
368
|
+
Every value is checked against the definition first. One refusal saves nothing, and an empty value clears the field. Scripts can `GET` `/_emdash/api/auth/redeemid/preferences` for `{ fields }`, or `POST` JSON `{ values: { "namespace.key": value } }`. A refused save answers `400` with `fields` mapping each key to `unknown_field`, `read_only`, `invalid_type`, `invalid_choice`, `invalid_length` or `invalid_range`. Saves are limited to 10 a minute per member (`429`). From your own code, `readPreferences(config, customerId)` and `savePreferences(config, customerId, values)` in `@redeemid/emdash/preferences` do the same (no rate limit there; `config` comes from `siteConfig()`).
|
|
362
369
|
|
|
363
370
|
### Protected files
|
|
364
371
|
|
|
@@ -383,7 +390,7 @@ Limits: only EmDash's file route is checked, so don't give the storage bucket a
|
|
|
383
390
|
|
|
384
391
|
### Privacy requests
|
|
385
392
|
|
|
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.
|
|
393
|
+
What RedeemID keeps about a member: the synced record (Shopify customer ID, email, name, tags and when each started, account status, first sign-in, last sync, whether the email was verified, email and SMS marketing consent, and the membership dates) and their meter. Preference answers stay on the Shopify customer, and webhook deliveries keep only a topic and a time. Erasing on the site never erases Shopify data; it can only ask Shopify to.
|
|
387
394
|
|
|
388
395
|
| Started by | What happens |
|
|
389
396
|
| --- | --- |
|
|
@@ -393,7 +400,7 @@ What RedeemID keeps about a member: the synced record (Shopify customer ID, emai
|
|
|
393
400
|
| Shopify `customers/redact` | The member's record and meter are erased. |
|
|
394
401
|
| Shopify `shop/redact` | Every member record and meter is erased. |
|
|
395
402
|
|
|
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.
|
|
403
|
+
The Privacy page shows each request's source and Shopify customer ID, never an email or a name. The daily check deletes finished requests after a year; open ones stay until staff mark them done. An erase sends the `member.erased` event so a CRM can delete its copy too. The site's own user account stays; delete it under Users if the person asks. The JSON route is `/_emdash/api/auth/redeemid/privacy`: `GET` downloads, `POST` with `confirm=yes` erases, only for the signed-in member and only from the site's own origin.
|
|
397
404
|
|
|
398
405
|
### Staff tools
|
|
399
406
|
|
|
@@ -404,16 +411,29 @@ With `REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID` set, RedeemID gets an Admin API toke
|
|
|
404
411
|
- **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
412
|
- **Access test.** A collection, an entry, terms and a reader's tags in; the verdict out.
|
|
406
413
|
|
|
407
|
-
Resync updates email, name and
|
|
414
|
+
Resync updates email, name, tags, email marketing consent and the membership dates. Enabled or disabled status comes only from the enable and disable webhooks. With the app set up, two more things read the customer from the Admin API as it is now: every sign-in (and Refresh membership), and every `customer.tags_added` or `customer.tags_removed` webhook. Those topics carry only the change and can arrive out of order, so reading the current tags keeps the mirror right. Without the app, or when the read fails, the webhook's change is applied as sent.
|
|
415
|
+
|
|
416
|
+
#### Membership dates
|
|
417
|
+
|
|
418
|
+
A subscription app or Shopify Flow can keep these customer metafields, as dates (`YYYY-MM-DD`):
|
|
419
|
+
|
|
420
|
+
| Metafield | Shown |
|
|
421
|
+
| --- | --- |
|
|
422
|
+
| `membership.member_since` | As the start date on the member card, in place of when the tag arrived |
|
|
423
|
+
| `membership.renews_on` | "Renews on ..." |
|
|
424
|
+
| `membership.ends_on` | "Ends on ...", for a pass that doesn't renew |
|
|
425
|
+
| `membership.gift_until` | "Gift membership until ...", while it's in the future |
|
|
426
|
+
|
|
427
|
+
RedeemID reads them with the Admin API (resync, sign-in, tag webhooks), so they need the Admin API app. They're for display only: access still comes from tags alone, and nothing on the site ends access on these dates. Time-boxed access is a Flow that waits and removes the tag (see [Shopify Flow recipes](docs/shopify-flow.md)).
|
|
408
428
|
|
|
409
429
|
### Webhooks, app events and reports
|
|
410
430
|
|
|
411
431
|
Members gain and lose access only when Shopify sends tag changes, so RedeemID shows what arrived and warns when deliveries stop.
|
|
412
432
|
|
|
413
433
|
- **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
|
|
434
|
+
- **Webhooks → Health.** Once a day RedeemID registers again any subscription Shopify dropped (with the Admin API app), and warns when no webhook has come in for a number of days (3 by default, 0 turns it off), or when deliveries were turned away or failed in the last day. **Check now** runs it at once. Warnings show on the page and the dashboard widget. If you enter an address on the Health tab and the site has an EmDash email provider, they also go by email: once when problems start or change, then once a week while they last.
|
|
415
435
|
- **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.
|
|
436
|
+
- **App events.** What RedeemID did and what came of it: webhooks received, turned away and failed, sign-ins and refusals (by reason), Admin API calls (by operation, with timings), resyncs, tag edits, webhook registrations, settings saves, outbound events that failed, metered reads and the health checks. Filter by event or show problems only. Entries hold ids, labels and outcomes, never bodies, tokens or emails. Failed outbound events also keep the event body (ids and levels, never an email or a name) so they can be sent again.
|
|
417
437
|
- **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
438
|
|
|
419
439
|
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.
|
|
@@ -445,7 +465,7 @@ npx -p @redeemid/emdash redeemid-emdash access posts --id my-story --tags gold-m
|
|
|
445
465
|
npx -p @redeemid/emdash redeemid-emdash member jane@example.com
|
|
446
466
|
```
|
|
447
467
|
|
|
448
|
-
Each command
|
|
468
|
+
Each command POSTs JSON to a staff-only route under `/_emdash/api/plugins/redeemid/`, named after its tool with hyphens (`explain_access` is `explain-access`). Scripts can call those routes directly.
|
|
449
469
|
|
|
450
470
|
### Replacement text
|
|
451
471
|
|
|
@@ -467,7 +487,7 @@ A replacement may use its original's placeholders (such as `{level}`, or `{signI
|
|
|
467
487
|
events: { url: "https://hooks.example.com/redeemid", only: ["member.signed_in", "member.levels_changed"] },
|
|
468
488
|
```
|
|
469
489
|
|
|
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.
|
|
490
|
+
With `REDEEMID_EVENTS_SECRET` set, RedeemID POSTs `{ id, event, at, data }` to the URL with the headers `X-RedeemID-Event`, `X-RedeemID-Delivery` (the `id`) and `X-RedeemID-Signature: sha256=<hex HMAC-SHA256 of the raw body>`. Check the signature before you trust a body. Events go out after the response (with `waitUntil` on Workers), so sign-in, webhooks and pages never wait for your receiver. Each try waits at most 3 seconds; a send that fails is tried again after 1 second and after 5 seconds. One that fails all three tries is kept under **App events → Failed events**, where staff can **Send again** (same `id`, so drop duplicates by it) or **Clear** it; failed events are deleted after 30 days.
|
|
471
491
|
|
|
472
492
|
| Event | `data` |
|
|
473
493
|
| --- | --- |
|
|
@@ -488,13 +508,13 @@ A route cache keys pages by URL alone and serves stored pages without running an
|
|
|
488
508
|
|
|
489
509
|
### Rate limits
|
|
490
510
|
|
|
491
|
-
On Cloudflare Workers, `redeemidGuard({ rateLimit: true })` limits how often one visitor can start Sign in, Buy or a
|
|
511
|
+
On Cloudflare Workers, `redeemidGuard({ rateLimit: true })` limits how often one visitor can start Sign in, Buy, a cart change or a catalog search (`api/search`), the routes that call Shopify. Add a [Rate Limiting binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/) named `REDEEMID_RATE_LIMIT`, which sets the limit and period:
|
|
492
512
|
|
|
493
513
|
```jsonc
|
|
494
514
|
"ratelimits": [{ "name": "REDEEMID_RATE_LIMIT", "namespace_id": "1001", "simple": { "limit": 20, "period": 60 } }]
|
|
495
515
|
```
|
|
496
516
|
|
|
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
|
|
517
|
+
Each action counts separately per visitor (by `CF-Connecting-IP`), and Cloudflare's count is approximate. A visitor over the limit goes back to their page with the `too_many_requests` notice; the JSON routes answer `429` with `{ "error": "too_many_requests" }`. If the binding is missing or fails, nothing is limited. Off by default; leave it off on Node.
|
|
498
518
|
|
|
499
519
|
## Reference
|
|
500
520
|
|
|
@@ -534,8 +554,8 @@ Pass one object to both `redeemid(config)` and `redeemidPlugin(config)`. `levels
|
|
|
534
554
|
| `REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID` | Sign-in | Client ID of the Headless storefront's Customer Account API client |
|
|
535
555
|
| `REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_SECRET` | Sign-in, confidential clients | Its client secret. Leave unset for a public client. |
|
|
536
556
|
| `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` |
|
|
538
|
-
| `REDEEMID_SHOPIFY_STOREFRONT_PRIVATE_TOKEN` | Plans, shop, checkout | Storefront API private access token, used server-side |
|
|
557
|
+
| `REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID` | Webhook registration, staff tools, preferences, membership dates, Shopify erasure requests | Client ID of that same app. Optional, but most of the admin needs it. |
|
|
558
|
+
| `REDEEMID_SHOPIFY_STOREFRONT_PRIVATE_TOKEN` | Plans, shop, checkout | Storefront API private access token, used server-side. Optional if the site sells nothing. |
|
|
539
559
|
| `REDEEMID_SHOPIFY_STOREFRONT_PUBLIC_TOKEN` | Plans, shop, checkout | Public access token, only used without the private one |
|
|
540
560
|
| `REDEEMID_SHOPIFY_STORE_DOMAIN` | Discovery | The store's domain, only when `shop` isn't in the config |
|
|
541
561
|
| `REDEEMID_EVENTS_SECRET` | Events | Signs outbound events. Set it with `events.url`. |
|
|
@@ -553,17 +573,18 @@ Import each from `@redeemid/emdash/components/<Name>.astro`.
|
|
|
553
573
|
| `LockedPanel` | `reason`, `required`, … | The wall on its own |
|
|
554
574
|
| `AccessBadge` | `collection`, `terms`, `content`, `id`, `free`, `class` | What an entry needs, with a lock for readers kept out |
|
|
555
575
|
| `Account` | | Sign in, or the member's name, levels, Account and Sign out |
|
|
556
|
-
| `MemberCard` | `title` | Levels, past levels, meter, features, Refresh membership |
|
|
576
|
+
| `MemberCard` | `title` | Levels, renewal, end and gift dates, past levels, meter, features, Refresh membership |
|
|
557
577
|
| `Preferences` | `title`, `heading`, `label`; slot `signed-out` | The member's preference form |
|
|
558
578
|
| `YourData` | `title`, `heading` | Download or delete my data |
|
|
559
579
|
| `Feature` | `slug`; slot `else` | Shows its content to members holding the feature |
|
|
560
580
|
| `Notices` | `messages` | The message for `?redeemid_notice=` |
|
|
561
|
-
| `Plan` | `handle`, `label`, `title`, `levels`, `heldLabel` | A product with a buy button |
|
|
581
|
+
| `Plan` | `handle`, `label`, `title`, `levels`, `heldLabel`, `renewLabel` | A product with a buy button |
|
|
562
582
|
| `Product` | `handle`, `heading`, `compact`, `html`, `schema`, `addLabel`, `buyLabel` | A product page |
|
|
563
583
|
| `Collection` | `handle`, `limit`, `productHref`, `showTitle`, `browse`, `quickAdd` | A grid of products, with filters and pages |
|
|
564
584
|
| `ProductSearch` | `productHref`, `limit`, `boxOnly`, `action`, `label`, `quickAdd` | Product search |
|
|
565
585
|
| `Recommendations` | `handle`, `intent`, `limit`, `productHref`, `title`, `quickAdd` | Shopify's recommendations |
|
|
566
586
|
| `ProductGrid` | `products`, `productHref`, `heading`, `quickAdd` | Product cards, for your own lists |
|
|
587
|
+
| `BrowseControls` | `filters`, `pageInfo`, `id`, `search`, `query` | Sort, filter and page controls, for your own lists from `collectionPage()` or `searchProducts()` |
|
|
567
588
|
| `Cart` | `label`, `href`, `drawer` | The cart count, linking to checkout or opening the drawer |
|
|
568
589
|
| `CartView` | `productHref`, `empty` | A cart page |
|
|
569
590
|
| `CartDrawer` | `title` | The cart in a slide-in dialog |
|
|
@@ -591,7 +612,7 @@ Import each from `@redeemid/emdash/components/<Name>.astro`.
|
|
|
591
612
|
| Import | Gives |
|
|
592
613
|
| --- | --- |
|
|
593
614
|
| `@redeemid/emdash` | `redeemid()`, `redeemidPlugin()`, `redeemidGuard()`, config types |
|
|
594
|
-
| `@redeemid/emdash/access` | `canRead()`, `readEntry()`, `entryRequirement()`, `viewer()`, and links: `signInUrl()`, `signOutUrl()`, `accountUrl()`, `cartUrl()`, `refreshUrl()` |
|
|
615
|
+
| `@redeemid/emdash/access` | `siteConfig()`, `canRead()`, `readEntry()`, `entryRequirement()`, `viewer()`, and links: `signInUrl()`, `signOutUrl()`, `accountUrl()`, `cartUrl()`, `refreshUrl()` |
|
|
595
616
|
| `@redeemid/emdash/features` | `hasFeature()`, `viewerFeatures()` |
|
|
596
617
|
| `@redeemid/emdash/preferences` | `readPreferences()`, `savePreferences()` |
|
|
597
618
|
| `@redeemid/emdash/storefront` | `product()`, `collection()`, `plan()`, `money()` and the cart calls |
|
|
@@ -606,7 +627,7 @@ The full developer API (stable exports, JSON routes, the browser client and its
|
|
|
606
627
|
|
|
607
628
|
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/`.
|
|
608
629
|
|
|
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
|
|
630
|
+
In the Customer Account API client, set the callback URI to `https://<site>/_emdash/api/auth/redeemid/callback`, the logout URI to `https://<site>/`, and (public clients) the JavaScript origin to `https://<site>`. Point webhooks at `https://<site>/_emdash/api/auth/redeemid/webhook`: `customers/create`, `customers/update`, `customers/delete`, `customers/disable`, `customers/enable`, `customers/merge`, `customer.tags_added` and `customer.tags_removed` (the Setup page registers these for you). The compliance topics `customers/data_request`, `customers/redact` and `shop/redact` go to the same URL, set in the app's own configuration.
|
|
610
631
|
|
|
611
632
|
### Notices
|
|
612
633
|
|
|
@@ -628,10 +649,11 @@ Reword one with `messages={{ cart_empty: "…" }}`, or for every page with `text
|
|
|
628
649
|
- Shopify is the only source of entitlements. Nothing on the site grants access.
|
|
629
650
|
- Levels read the mirrored tags as they are, however old. They change only when Shopify sends new ones.
|
|
630
651
|
- Enabled or disabled status comes only from the `customers/enable` and `customers/disable` webhooks.
|
|
631
|
-
- Locked content never reaches the
|
|
652
|
+
- Locked content never reaches the content API, search, feeds or the sitemap for readers kept out, nor a page that renders bodies with `<Content>` (the Status page flags templates that use `<PortableText>` for a gated collection). The end-to-end test checks each of these. Members are refused EmDash's own APIs except what they need.
|
|
632
653
|
- 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
654
|
- Customers can't create the first (admin) account on a new site.
|
|
634
655
|
- Webhooks are checked against the app's signature before anything changes.
|
|
656
|
+
- Access fails closed: when the saved access settings can't be read, a server keeps the ones it last read, and with none it locks every entry.
|
|
635
657
|
|
|
636
658
|
## Troubleshooting
|
|
637
659
|
|
package/docs/developer-api.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Developer API
|
|
2
2
|
|
|
3
|
-
What site code, other plugins and AI agents can rely on in `@redeemid/emdash`. API version **1.
|
|
3
|
+
What site code, other plugins and AI agents can rely on in `@redeemid/emdash`. API version **1.1.0** (`API_VERSION` in `@redeemid/emdash/contract`, also in `api/state` and `window.redeemid.apiVersion`).
|
|
4
4
|
|
|
5
5
|
Everything on this page is stable: it changes only with a changelog entry and an API version bump (minor when something is added, major when something changes or goes). Anything not listed is internal, even when a module exports it. `src/contract.ts` is the same list as code, and a test fails when the code, that list and this page disagree.
|
|
6
6
|
|
|
@@ -17,7 +17,7 @@ Import these in Astro pages, components and endpoints.
|
|
|
17
17
|
| Import | Stable exports |
|
|
18
18
|
| --- | --- |
|
|
19
19
|
| `@redeemid/emdash` | `redeemid`, `redeemidGuard`, `redeemidPlugin` |
|
|
20
|
-
| `@redeemid/emdash/access` | `viewer`, `canRead`, `readEntry`, `entryRequirement`, `meterUsage`, `membership`, `levelsFor`, `signInUrl`, `signOutUrl`, `accountUrl`, `cartUrl`, `refreshUrl`, `safeReturn` |
|
|
20
|
+
| `@redeemid/emdash/access` | `siteConfig`, `viewer`, `canRead`, `readEntry`, `entryRequirement`, `meterUsage`, `membership`, `levelsFor`, `signInUrl`, `signOutUrl`, `accountUrl`, `cartUrl`, `refreshUrl`, `safeReturn` |
|
|
21
21
|
| `@redeemid/emdash/features` | `hasFeature`, `viewerFeatures`, `featuresFor` |
|
|
22
22
|
| `@redeemid/emdash/preferences` | `readPreferences`, `savePreferences`, `preferenceFields` |
|
|
23
23
|
| `@redeemid/emdash/storefront` | `plan`, `product`, `collection`, `money`, `realOptions`, `variantFor`, `storeContext`, `cartDetails`, `addToCart`, `updateLines`, `removeLines`, `setDiscountCodes` |
|
|
@@ -25,16 +25,19 @@ Import these in Astro pages, components and endpoints.
|
|
|
25
25
|
| `@redeemid/emdash/events` | `signBody` |
|
|
26
26
|
| `@redeemid/emdash/text` | `STRINGS`, `text`, `textParts` |
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
Functions that take a `config` want the site's config for the request: `await siteConfig(Astro.locals)`, which is the code's config with the admin's saved settings laid over it. Passing the object from `astro.config.mjs` instead skips what staff saved on the Access page. A few examples:
|
|
29
29
|
|
|
30
30
|
```astro
|
|
31
31
|
---
|
|
32
|
-
import { canRead,
|
|
32
|
+
import { canRead, siteConfig } from "@redeemid/emdash/access";
|
|
33
33
|
import { hasFeature } from "@redeemid/emdash/features";
|
|
34
34
|
import { searchProducts } from "@redeemid/emdash/catalog";
|
|
35
|
+
import { storeContext } from "@redeemid/emdash/storefront";
|
|
35
36
|
|
|
36
37
|
const verdict = await canRead(Astro, { collection: "posts", terms: post.data.terms, id: post.id });
|
|
37
38
|
const adFree = await hasFeature(Astro, "ad-free");
|
|
39
|
+
const config = await siteConfig(Astro.locals);
|
|
40
|
+
const totes = config && (await searchProducts(config, "tote", {}, { context: storeContext(config, Astro.request) }));
|
|
38
41
|
---
|
|
39
42
|
```
|
|
40
43
|
|
|
@@ -47,7 +50,7 @@ Under `/_emdash/api/auth/redeemid/`. Same-origin only: EmDash checks `Origin` on
|
|
|
47
50
|
- `api/state` (GET): the visitor's own state.
|
|
48
51
|
|
|
49
52
|
```json
|
|
50
|
-
{ "apiVersion": "1.
|
|
53
|
+
{ "apiVersion": "1.1.0", "version": "0.0.21", "signedIn": true, "linked": true,
|
|
51
54
|
"levels": ["gold"], "features": ["ad-free"], "cart": { "count": 2 },
|
|
52
55
|
"meter": { "used": 1, "limit": 3, "period": "month" } }
|
|
53
56
|
```
|
|
@@ -57,16 +60,16 @@ Under `/_emdash/api/auth/redeemid/`. Same-origin only: EmDash checks `Origin` on
|
|
|
57
60
|
- `update`: `line`, `quantity` (0 removes); `remove`: `line`; `discount`: `code` (`""` clears);
|
|
58
61
|
- `checkout`: answers `{ checkoutUrl }`, signed in for members.
|
|
59
62
|
|
|
60
|
-
Other actions answer `{ notice, cart, added? }`. An add Shopify can't fill answers 422 with `notice: "plan_unavailable"`. Over the rate limit (with `redeemidGuard({ rateLimit: true })`) POST answers 429
|
|
63
|
+
Other actions answer `{ notice, cart, added? }`. An add Shopify can't fill answers 422 with `notice: "plan_unavailable"`. Over the rate limit (with `redeemidGuard({ rateLimit: true })`) POST answers 429 with `{ "error": "too_many_requests" }`.
|
|
61
64
|
- `api/search` (GET): public catalog data, `Cache-Control: public, max-age=60` (private when `market.country` is `"visitor"`).
|
|
62
65
|
- `?q=tote&suggest=1[&limit=6]`: `{ queries, products, collections }`, for search-as-you-type;
|
|
63
66
|
- `?q=tote`: `{ products, filters, pageInfo, total }`;
|
|
64
67
|
- `?collection=shop`: the collection's page, `{ handle, title, description, products, filters, pageInfo }`.
|
|
65
68
|
|
|
66
|
-
Both take `first` (1 to 50), `sort`, `after` or `before` (cursors from `pageInfo`), `filter` (repeatable: a filter value's `input` exactly as Shopify gave it), `price_min` and `price_max`. Sorts are `relevance`, `best-selling`, `newest`, `price-asc`, `price-desc`, `title-asc`, `title-desc` (search: relevance and price only).
|
|
69
|
+
Both take `first` (1 to 50), `sort`, `after` or `before` (cursors from `pageInfo`), `filter` (repeatable: a filter value's `input` exactly as Shopify gave it), `price_min` and `price_max`. Sorts are `relevance`, `best-selling`, `newest`, `price-asc`, `price-desc`, `title-asc`, `title-desc` (search: relevance and price only). Over the rate limit a search answers 429, as `api/cart` does.
|
|
67
70
|
- `preferences` (GET, POST) and `privacy` (GET export, POST erase): see the README.
|
|
68
71
|
|
|
69
|
-
Form routes, which redirect back with `?redeemid_notice=<code
|
|
72
|
+
Form routes, which redirect back with `?redeemid_notice=<code>` when something needs saying: `login`, `callback`, `logout`, `buy`, `cart`, `cart-edit`, `account`. `webhook` is for Shopify only and answers with a status code.
|
|
70
73
|
|
|
71
74
|
## Browser client
|
|
72
75
|
|
|
@@ -114,13 +117,13 @@ Signed outbound webhooks to config `events.url` (see the README): `member.signed
|
|
|
114
117
|
|
|
115
118
|
## MCP and the command line
|
|
116
119
|
|
|
117
|
-
RedeemID's tools join EmDash's MCP server once staff turn them on for RedeemID under Plugins. They need
|
|
120
|
+
RedeemID's tools join EmDash's MCP server once staff turn them on for RedeemID under Plugins. They need an administrator's API token (the routes ask for `users:read`, which only Admins hold) with the `mcp:tools` or `mcp:tools:redeemid` scope. Always offered, read-only:
|
|
118
121
|
|
|
119
122
|
- `explain_access`, `list_levels`, `get_rules`, `get_member`, `site_health`, `level_changes`
|
|
120
123
|
- `find_products`, `get_product`, `get_collection`: the Shopify catalog the site sells, so an agent writing content can find the right product and collection handles.
|
|
121
124
|
|
|
122
125
|
Only with config `agents: { actions: true }`: `resync_member`, `resync_batch`, `edit_member_tags`.
|
|
123
126
|
|
|
124
|
-
The `redeemid-emdash` command line calls the same routes (`npx redeemid-emdash --help`).
|
|
127
|
+
The `redeemid-emdash` command line calls the same routes (`npx -p @redeemid/emdash redeemid-emdash --help`).
|
|
125
128
|
|
|
126
129
|
For shoppers' own agents (browsing products, building a cart, checking out), point them at Shopify's Storefront MCP server for the store, `https://<store domain>/api/mcp`. It runs on Shopify, where checkout and prices already live, so RedeemID doesn't repeat it.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Shopify Flow recipes
|
|
2
|
+
|
|
3
|
+
RedeemID never grants access itself. A member's levels come from tags on their Shopify customer, and something in Shopify has to put those tags on and take them off. That is usually [Shopify Flow](https://help.shopify.com/manual/shopify-flow), which comes free with every Shopify plan, or a subscription app.
|
|
4
|
+
|
|
5
|
+
When a tag changes, Shopify sends a `customer.tags_added` or `customer.tags_removed` webhook. With the Admin API app's client ID set, RedeemID reads the customer's tags as they are now; without it, it applies the change the webhook carries. Access follows within seconds, and nobody has to sign in again.
|
|
6
|
+
|
|
7
|
+
Each recipe below is one Flow workflow. In Flow, open **Create workflow**, pick the trigger, add the condition, then add the action. The trigger and action names here are the ones Flow shows in English. The field paths in conditions can read slightly differently in Flow's picker. Subscription triggers come from your subscription app, so their names depend on the app.
|
|
8
|
+
|
|
9
|
+
The examples use a product called "Membership" and the tag `member`. Use your own product and the tag your level maps to, which you can see under **Plugins → RedeemID → Access**.
|
|
10
|
+
|
|
11
|
+
## 1. A paid order adds the tag
|
|
12
|
+
|
|
13
|
+
- **Trigger:** Order paid
|
|
14
|
+
- **Condition:** at least one of `order.lineItems` has `product.title` equal to `Membership`. If you sell several plans, match the product ID or a product tag such as `plan-member` instead, which keeps working after a rename.
|
|
15
|
+
- **Action:** Add customer tags, with `member`.
|
|
16
|
+
|
|
17
|
+
Use **Order paid** rather than Order created, so an unpaid order (for example, a payment that's still pending) doesn't open anything. Also add `order.customer` exists to the condition. A checkout always has an email, but a draft order may not have a customer.
|
|
18
|
+
|
|
19
|
+
If the plan is meant to show as a gift, also add an **Update customer metafield** action that sets `membership.gift_until` (type date). See [membership dates](../README.md#membership-dates).
|
|
20
|
+
|
|
21
|
+
## 2. A refund removes it
|
|
22
|
+
|
|
23
|
+
- **Trigger:** Refund created
|
|
24
|
+
- **Condition:** at least one of `refund.refundLineItems` has `lineItem.product.title` equal to `Membership`.
|
|
25
|
+
- **Action:** Remove customer tags, with `member`.
|
|
26
|
+
|
|
27
|
+
A partial refund of something else in the same order matches nothing, so the tag stays. Shipping-only refunds match nothing either.
|
|
28
|
+
|
|
29
|
+
## 3. A cancelled subscription removes it
|
|
30
|
+
|
|
31
|
+
Subscription apps that work with Flow add their own triggers, such as "Subscription contract cancelled" or "Subscription contract status updated". The names vary by app, so check your app's Flow listing.
|
|
32
|
+
|
|
33
|
+
- **Trigger:** your app's cancel trigger. If the app only offers a status-changed trigger, add the condition status is `CANCELLED`, and treat `EXPIRED` the same way.
|
|
34
|
+
- **Action:** Remove customer tags, with `member`.
|
|
35
|
+
|
|
36
|
+
Many apps can tag customers themselves (for example, "tag active subscribers"). If you use that, don't also run recipes 1 and 3 for the same tag, or one will undo the other.
|
|
37
|
+
|
|
38
|
+
To show the next billing date, add **Update customer metafield** to the app's "billing attempt succeeded" (or renewal) trigger, setting `membership.renews_on` to the next billing date.
|
|
39
|
+
|
|
40
|
+
## 4. A pass that ends: wait, then remove
|
|
41
|
+
|
|
42
|
+
For a fixed-term pass that doesn't renew, such as a 30-day pass:
|
|
43
|
+
|
|
44
|
+
- **Trigger:** Order paid
|
|
45
|
+
- **Condition:** the order contains the pass product, as in recipe 1.
|
|
46
|
+
- **Actions:**
|
|
47
|
+
1. Add customer tags, with `pass`.
|
|
48
|
+
2. Update customer metafield `membership.ends_on`, set to the order date plus 30 days. This step is optional and is only used for display.
|
|
49
|
+
3. Wait 30 days.
|
|
50
|
+
4. Remove customer tags, with `pass`.
|
|
51
|
+
|
|
52
|
+
The wait lives in Flow, not on the site. RedeemID doesn't end access on the `ends_on` date. It only shows that date, so the tag is still what decides access.
|
|
53
|
+
|
|
54
|
+
If a member buys a second pass while the first is still running, the first workflow's removal takes the tag away early. Give each length its own tag (`pass-30`, `pass-90`), or make the last step check the newest order before it removes the tag.
|
|
55
|
+
|
|
56
|
+
## 5. A member's own Shopify account
|
|
57
|
+
|
|
58
|
+
None of these recipes need anything from RedeemID. A customer who buys while signed out is tagged on their customer record by email. The next time they sign in on the site, the tag is already there.
|
|
59
|
+
|
|
60
|
+
When a member buys while signed in, RedeemID opens the checkout signed in as them, so the order goes on the same customer record.
|
|
61
|
+
|
|
62
|
+
After a purchase, **Refresh membership** on the member card reads their tags again straight away. That helps if Flow ran before the member got back to the site.
|
|
63
|
+
|
|
64
|
+
## Check a recipe
|
|
65
|
+
|
|
66
|
+
1. Under **Plugins → RedeemID → Webhooks → Setup**, make sure `customer.tags_added` and `customer.tags_removed` show as Registered. The Setup page registers them for you.
|
|
67
|
+
2. Buy the product with a test customer, using Shopify's Bogus Gateway or a 100% discount code.
|
|
68
|
+
3. In Flow, open the workflow's **Recent runs**. The run should show the tag added.
|
|
69
|
+
4. Open **Plugins → RedeemID → Members**, then the customer. The tag and level should be there.
|
|
70
|
+
5. Refund the order. The tag and the level should both go.
|
package/docs/shopify-setup.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
What to set up in Shopify, and on the site, to run RedeemID on an EmDash site. One store can serve several sites: each site gets its own Headless storefront for sign-in, and each receives webhooks.
|
|
4
4
|
|
|
5
|
+
Once the package is installed, **Plugins → RedeemID → Setup** in the EmDash admin walks through these same steps with your site's own addresses filled in, checks each one, and registers the webhooks for you.
|
|
6
|
+
|
|
5
7
|
## 1. Sign-in: a Headless storefront
|
|
6
8
|
|
|
7
9
|
1. In Shopify admin, open **Sales channels → Headless** (add the channel if it isn't there) and **Create storefront**. Name it after the site.
|
|
@@ -23,15 +25,17 @@ The store must use the new customer accounts (Settings → Customer accounts). C
|
|
|
23
25
|
|
|
24
26
|
## 2. Webhooks
|
|
25
27
|
|
|
26
|
-
RedeemID keeps tags and status current from webhooks.
|
|
28
|
+
RedeemID keeps tags and status current from webhooks sent by a Shopify app on the store (for example a Dev Dashboard app). Shopify signs every webhook with that app's **client secret**, which is the site's webhook secret.
|
|
29
|
+
|
|
30
|
+
Give the app the `read_customers` and `write_customers` Admin API scopes, plus `read_customer_merge` for `customers/merge`, and set its **client ID** on the site as well. Then the Setup page (or **Webhooks → Setup**) registers these topics at `https://<site>/_emdash/api/auth/redeemid/webhook` with one button:
|
|
27
31
|
|
|
28
|
-
`customers/create`, `customers/update`, `customers/delete`, `customers/disable`, `customers/enable`, `customers/merge`, `customer.tags_added`, `customer.tags_removed
|
|
32
|
+
`customers/create`, `customers/update`, `customers/delete`, `customers/disable`, `customers/enable`, `customers/merge`, `customer.tags_added`, `customer.tags_removed`
|
|
29
33
|
|
|
30
|
-
|
|
34
|
+
Without the client ID, subscribe them to that URL yourself. If the app already sends webhooks to another site, add this URL as extra subscriptions.
|
|
31
35
|
|
|
32
|
-
|
|
36
|
+
The client ID and those scopes also run the staff tools (member resync and tag edits written to Shopify), member preferences, and the reads that keep tags in order (see the README). 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
37
|
|
|
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.
|
|
38
|
+
Shopify's compliance topics (`customers/data_request`, `customers/redact`, `shop/redact`) can't be registered by the button: they 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 and acts on it (see the README's privacy requests).
|
|
35
39
|
|
|
36
40
|
## 3. The site
|
|
37
41
|
|
|
@@ -46,12 +50,14 @@ npx wrangler secret put REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_SECRET # confid
|
|
|
46
50
|
npx wrangler secret put REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_SECRET # the webhook app's client secret
|
|
47
51
|
npx wrangler secret put REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID # optional; the webhook app's client ID, for the staff tools
|
|
48
52
|
npx wrangler secret put REDEEMID_EVENTS_SECRET # optional; signs outbound events (events.url)
|
|
49
|
-
npx wrangler secret put REDEEMID_SHOPIFY_STOREFRONT_PRIVATE_TOKEN #
|
|
53
|
+
npx wrangler secret put REDEEMID_SHOPIFY_STOREFRONT_PRIVATE_TOKEN # optional; the Storefront API private token, to sell on the site (plans, shop, checkout)
|
|
50
54
|
npx wrangler secret put REDEEMID_SHOPIFY_STOREFRONT_PUBLIC_TOKEN # optional; used only without the private token
|
|
51
55
|
```
|
|
52
56
|
|
|
53
57
|
The Workers free plan is enough: RedeemID is a native plugin and doesn't need sandboxing. Worker secrets reach the code through `nodejs_compat`, which EmDash's Cloudflare templates turn on.
|
|
54
58
|
|
|
59
|
+
On Workers, also add a Cron Trigger to the site's Worker, as EmDash's deployment guide describes, so EmDash's scheduler runs RedeemID's daily webhook health check.
|
|
60
|
+
|
|
55
61
|
## 4. Try it
|
|
56
62
|
|
|
57
63
|
1. Finish EmDash setup as staff first. Store customers can't create the first account.
|
|
@@ -59,4 +65,4 @@ The Workers free plan is enough: RedeemID is a native plugin and doesn't need sa
|
|
|
59
65
|
3. Click **Sign in**, sign in with that customer's email, and you come back to the post, unlocked.
|
|
60
66
|
4. Add or remove the tag in Shopify; the post opens or locks again once the webhook arrives.
|
|
61
67
|
|
|
62
|
-
If sign-in sends you back with `?redeemid_notice=…`, the code says why: `not_configured`
|
|
68
|
+
If sign-in sends you back with `?redeemid_notice=…`, the code says why: `not_configured` means the client ID or `shop` is missing, and `sign_in_unavailable` means store discovery failed, usually a wrong `shop`. The README lists [every notice](../README.md#notices).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@redeemid/emdash",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.21",
|
|
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",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"./access": "./src/access.ts",
|
|
16
16
|
"./guard.ts": "./src/guard.ts",
|
|
17
17
|
"./rate-limit-cloudflare.ts": "./src/rate-limit-cloudflare.ts",
|
|
18
|
+
"./templates.ts": "./src/templates.ts",
|
|
18
19
|
"./storefront": "./src/storefront.ts",
|
|
19
20
|
"./catalog": "./src/catalog.ts",
|
|
20
21
|
"./features": "./src/features.ts",
|
package/src/access-admin.ts
CHANGED
|
@@ -19,11 +19,13 @@ import { applyEntryRule, describe, requirement, ruleSource, SIGNED_IN, termTree,
|
|
|
19
19
|
import type { FeatureConfig, FileRule, LevelConfig, MeterConfig, RedeemIDConfig, RuleConfig } from "./config.js";
|
|
20
20
|
import {
|
|
21
21
|
days,
|
|
22
|
+
deleteAccess,
|
|
22
23
|
entryKey,
|
|
23
24
|
list,
|
|
24
25
|
MANAGED,
|
|
25
26
|
mergeAccess,
|
|
26
27
|
overridden,
|
|
28
|
+
putAccess,
|
|
27
29
|
SITE_ID,
|
|
28
30
|
slugify,
|
|
29
31
|
validSlug,
|
|
@@ -435,7 +437,7 @@ async function saveSite(env: AccessEnv, change: (site: SiteAccess, now: RedeemID
|
|
|
435
437
|
const out = change(site, now);
|
|
436
438
|
if (typeof out === "string") return out;
|
|
437
439
|
out.updatedAt = new Date().toISOString();
|
|
438
|
-
await env.store
|
|
440
|
+
await putAccess(env.store, SITE_ID, out);
|
|
439
441
|
env.saved = { ...env.saved, site: out };
|
|
440
442
|
return null;
|
|
441
443
|
}
|
|
@@ -741,14 +743,18 @@ export async function entrySave(env: AccessEnv, entry: PanelEntry, values: Recor
|
|
|
741
743
|
rule.lockedUntil = isoDay(values.locked_until);
|
|
742
744
|
rule.openUntil = isoDay(values.open_until);
|
|
743
745
|
const empty = mode === "inherit" && !rule.drip && rule.meter !== false && !rule.lockedUntil && !rule.openUntil;
|
|
744
|
-
if (empty) await env.store
|
|
745
|
-
else await env.store
|
|
746
|
+
if (empty) await deleteAccess(env.store, entryKey(entry.id));
|
|
747
|
+
else await putAccess(env.store, entryKey(entry.id), rule);
|
|
746
748
|
env.saved = { ...env.saved, entries: [...env.saved.entries.filter((e) => e.entryId !== entry.id), ...(empty ? [] : [rule])] };
|
|
747
749
|
return entryPanel(env, entry, { type: "success", message: empty ? "This entry follows the site rules." : "Access saved." });
|
|
748
750
|
}
|
|
749
751
|
|
|
750
|
-
/**
|
|
751
|
-
|
|
752
|
+
/**
|
|
753
|
+
* Keep an entry's own rule in step with its slug. A trashed entry passes a
|
|
754
|
+
* null slug: its rule stays (by id) for a restore, but gives up the slug, so
|
|
755
|
+
* a new entry that takes the slug doesn't inherit the rule.
|
|
756
|
+
*/
|
|
757
|
+
export async function entrySlugChanged(store: StorageCollection<AccessRecord>, collection: string | null, id: string, slug: string | null): Promise<void> {
|
|
752
758
|
const rule = (await store.get(entryKey(id))) as EntryRule | null;
|
|
753
|
-
if (rule?.kind === "entry" && rule.collection === collection && rule.slug !== slug) await store
|
|
759
|
+
if (rule?.kind === "entry" && (collection === null || rule.collection === collection) && (rule.slug ?? null) !== slug) await putAccess(store, entryKey(id), { ...rule, slug });
|
|
754
760
|
}
|