@redeemid/emdash 0.0.19 → 0.0.20

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 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.19, early.** Runs on Node and Cloudflare Workers. Every feature below is covered by unit tests and an end-to-end test that boots a real EmDash site against a fake Shopify store. Expect changes before 1.0.
10
+ **Status: 0.0.20, early.** Runs on Node and Cloudflare Workers. Every feature below is covered by unit tests and an end-to-end test that boots a real EmDash site against a fake Shopify store. Expect changes before 1.0.
11
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
- - Status page: every setting, store discovery, levels and rules.
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
 
@@ -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. Plain `<PortableText>` shows everything.
151
+ Use `<Content>` in place of `<PortableText>` for every body that may be locked. Plain `<PortableText>` shows everything. The Status page checks: `redeemidGuard()` scans the site's `.astro` files at build time, and Status names any template that renders a gated collection's body with EmDash's `<PortableText>`, with its line. A template whose collection it can't see (a component given the body as a prop) is listed to check while any rule is on.
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 → Status**. Every setting should say Set. Then open **Webhooks → Setup** and press **Register**.
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"]}>
@@ -242,7 +243,7 @@ Everything about who reads what can be set in the EmDash admin, so a new content
242
243
  - **Taxonomies.** Rules for terms in every taxonomy, for one collection or every collection the taxonomy is on.
243
244
  - **Features** and **Protected files.**
244
245
 
245
- The first save of a setting copies its value from your code config, so nothing is lost, and from then on the admin's value is used. **Use the code's setting** removes the admin's copy. Saved settings are kept in the site's database and read once per request.
246
+ 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
247
 
247
248
  Each saved entry also gets an **Access** panel in the editor. It shows who can read the entry right now and which rule says so, and sets the entry's own rule:
248
249
 
@@ -253,7 +254,7 @@ Each saved entry also gets an **Access** panel in the editor. It shows who can r
253
254
  | Only the levels ticked | One of those levels (none ticked: any signed-in member) |
254
255
  | The site rules, and also a level ticked | What the rules say and one of those levels |
255
256
 
256
- It can also hold the entry back for some days of membership, keep the meter off it, lock it until a date and then open it, or keep it free until a date and then lock it. Pass the entry's `id` (or slug) to `<Content>`, `<AccessBadge>` and `<Gate>` so its own rule applies there.
257
+ 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. It can also hold the entry back for some days of membership, keep the meter off it, lock it until a date and then open it, or keep it free until a date and then lock it. Pass the entry's `id` (or slug) to `<Content>`, `<AccessBadge>` and `<Gate>` so its own rule applies there.
257
258
 
258
259
  ### Meter and drip
259
260
 
@@ -329,7 +330,7 @@ Products, collections, search and the cart come from the Storefront API. Shopify
329
330
 
330
331
  Everything works without JavaScript: the forms post to `/_emdash/api/auth/redeemid/cart-edit`, which reads every product, variant, option and selling plan from Shopify again and only changes the visitor's own cart. `<RedeemIDClient />` adds the drawer, option pickers that follow the choice, and search suggestions. A new cart carries the `_redeemid_page` attribute (the page the first item was added on), which shows on the order in Shopify admin. Editors get the same with the Product, Collection, Product search and Contribution blocks.
331
332
 
332
- **Signed-in checkout.** When a signed-in member checks out, RedeemID puts their store sign-in (the Customer Account API access token, kept only in the server-side session and renewed when it's about to expire) on the cart, so Shopify's checkout opens signed in and the order lands on their customer record, where your Flow or apps tag it. If that can't be done, they check out as a guest with their email filled in. Set `checkout: { signIn: false }` to always use a guest checkout.
333
+ **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
334
 
334
335
  **Local prices.** Set `market: { country: "visitor" }` to show each visitor prices in their own market (from Cloudflare's `CF-IPCountry`), or a fixed `country` and `language`. Markets are set up in Shopify.
335
336
 
@@ -383,7 +384,7 @@ Limits: only EmDash's file route is checked, so don't give the storage bucket a
383
384
 
384
385
  ### Privacy requests
385
386
 
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.
387
+ 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
388
 
388
389
  | Started by | What happens |
389
390
  | --- | --- |
@@ -393,7 +394,7 @@ What RedeemID keeps about a member: the synced record (Shopify customer ID, emai
393
394
  | Shopify `customers/redact` | The member's record and meter are erased. |
394
395
  | Shopify `shop/redact` | Every member record and meter is erased. |
395
396
 
396
- The Privacy page shows each request's source and Shopify customer ID, never an email or a name. An erase sends the `member.erased` event so a CRM can delete its copy too. The site's own user account stays; delete it under Users if the person asks. The JSON route is `/_emdash/api/auth/redeemid/privacy`: `GET` downloads, `POST` with `confirm=yes` erases, only for the signed-in member and only from the site's own origin.
397
+ 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
398
 
398
399
  ### Staff tools
399
400
 
@@ -404,7 +405,20 @@ With `REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID` set, RedeemID gets an Admin API toke
404
405
  - **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
406
  - **Access test.** A collection, an entry, terms and a reader's tags in; the verdict out.
406
407
 
407
- Resync updates email, name and tags. Enabled or disabled status comes only from the enable and disable webhooks.
408
+ 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.
409
+
410
+ #### Membership dates
411
+
412
+ A subscription app or Shopify Flow can keep these customer metafields, as dates (`YYYY-MM-DD`):
413
+
414
+ | Metafield | Shown |
415
+ | --- | --- |
416
+ | `membership.member_since` | As the start date on the member card, in place of when the tag arrived |
417
+ | `membership.renews_on` | "Renews on ..." |
418
+ | `membership.ends_on` | "Ends on ...", for a pass that doesn't renew |
419
+ | `membership.gift_until` | "Gift membership until ...", while it's in the future |
420
+
421
+ 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
422
 
409
423
  ### Webhooks, app events and reports
410
424
 
@@ -413,7 +427,7 @@ Members gain and lose access only when Shopify sends tag changes, so RedeemID sh
413
427
  - **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
428
  - **Webhooks → Health.** Once a day RedeemID registers again any subscription Shopify dropped (with the Admin API app), and warns when no webhook has come in for a number of days (3 by default, 0 turns it off), or when deliveries were turned away or failed in the last day. **Check now** runs it at once. Warnings show on the page and the dashboard widget, and go by email to the address you set when the site has an EmDash email provider: once when problems start or change, then once a week while they last.
415
429
  - **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.
430
+ - **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
431
  - **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
432
 
419
433
  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.
@@ -467,7 +481,7 @@ A replacement may use its original's placeholders (such as `{level}`, or `{signI
467
481
  events: { url: "https://hooks.example.com/redeemid", only: ["member.signed_in", "member.levels_changed"] },
468
482
  ```
469
483
 
470
- With `REDEEMID_EVENTS_SECRET` set, RedeemID POSTs `{ id, event, at, data }` to the URL with the headers `X-RedeemID-Event`, `X-RedeemID-Delivery` (the `id`) and `X-RedeemID-Signature: sha256=<hex HMAC-SHA256 of the raw body>`. Check the signature before you trust a body. A send waits at most 3 seconds and never breaks the page; a failed send is logged, not retried.
484
+ 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
485
 
472
486
  | Event | `data` |
473
487
  | --- | --- |
@@ -488,7 +502,7 @@ A route cache keys pages by URL alone and serves stored pages without running an
488
502
 
489
503
  ### Rate limits
490
504
 
491
- On Cloudflare Workers, `redeemidGuard({ rateLimit: true })` limits how often one visitor can start Sign in, Buy or a cart change, the routes that call Shopify. Add a [Rate Limiting binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/) named `REDEEMID_RATE_LIMIT`, which sets the limit and period:
505
+ 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
506
 
493
507
  ```jsonc
494
508
  "ratelimits": [{ "name": "REDEEMID_RATE_LIMIT", "namespace_id": "1001", "simple": { "limit": 20, "period": 60 } }]
@@ -553,12 +567,12 @@ Import each from `@redeemid/emdash/components/<Name>.astro`.
553
567
  | `LockedPanel` | `reason`, `required`, … | The wall on its own |
554
568
  | `AccessBadge` | `collection`, `terms`, `content`, `id`, `free`, `class` | What an entry needs, with a lock for readers kept out |
555
569
  | `Account` | | Sign in, or the member's name, levels, Account and Sign out |
556
- | `MemberCard` | `title` | Levels, past levels, meter, features, Refresh membership |
570
+ | `MemberCard` | `title` | Levels, renewal, end and gift dates, past levels, meter, features, Refresh membership |
557
571
  | `Preferences` | `title`, `heading`, `label`; slot `signed-out` | The member's preference form |
558
572
  | `YourData` | `title`, `heading` | Download or delete my data |
559
573
  | `Feature` | `slug`; slot `else` | Shows its content to members holding the feature |
560
574
  | `Notices` | `messages` | The message for `?redeemid_notice=` |
561
- | `Plan` | `handle`, `label`, `title`, `levels`, `heldLabel` | A product with a buy button |
575
+ | `Plan` | `handle`, `label`, `title`, `levels`, `heldLabel`, `renewLabel` | A product with a buy button |
562
576
  | `Product` | `handle`, `heading`, `compact`, `html`, `schema`, `addLabel`, `buyLabel` | A product page |
563
577
  | `Collection` | `handle`, `limit`, `productHref`, `showTitle`, `browse`, `quickAdd` | A grid of products, with filters and pages |
564
578
  | `ProductSearch` | `productHref`, `limit`, `boxOnly`, `action`, `label`, `quickAdd` | Product search |
@@ -628,10 +642,11 @@ Reword one with `messages={{ cart_empty: "…" }}`, or for every page with `text
628
642
  - Shopify is the only source of entitlements. Nothing on the site grants access.
629
643
  - Levels read the mirrored tags as they are, however old. They change only when Shopify sends new ones.
630
644
  - Enabled or disabled status comes only from the `customers/enable` and `customers/disable` webhooks.
631
- - Locked content never reaches the page, the content API, search, feeds or the sitemap for readers kept out. Members are refused EmDash's own APIs except what they need.
645
+ - 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
646
  - 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
647
  - Customers can't create the first (admin) account on a new site.
634
648
  - Webhooks are checked against the app's signature before anything changes.
649
+ - 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
650
 
636
651
  ## Troubleshooting
637
652
 
@@ -47,7 +47,7 @@ Under `/_emdash/api/auth/redeemid/`. Same-origin only: EmDash checks `Origin` on
47
47
  - `api/state` (GET): the visitor's own state.
48
48
 
49
49
  ```json
50
- { "apiVersion": "1.0.0", "version": "0.0.19", "signedIn": true, "linked": true,
50
+ { "apiVersion": "1.0.0", "version": "0.0.20", "signedIn": true, "linked": true,
51
51
  "levels": ["gold"], "features": ["ad-free"], "cart": { "count": 2 },
52
52
  "meter": { "used": 1, "limit": 3, "period": "month" } }
53
53
  ```
@@ -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 `customers/update` and RedeemID reads the customer again. 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". Most subscription apps add triggers like these, each with its own names. 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**, make sure `customers/update` is registered. The Setup page does this 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redeemid/emdash",
3
- "version": "0.0.19",
3
+ "version": "0.0.20",
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",
@@ -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.put(SITE_ID, out);
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.delete(entryKey(entry.id));
745
- else await env.store.put(entryKey(entry.id), rule);
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
- /** Keep an entry's own rule in step with its slug, and drop it with the entry. */
751
- export async function entrySlugChanged(store: StorageCollection<AccessRecord>, collection: string, id: string, slug: string | null): Promise<void> {
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.put(entryKey(id), { ...rule, slug });
759
+ if (rule?.kind === "entry" && (collection === null || rule.collection === collection) && (rule.slug ?? null) !== slug) await putAccess(store, entryKey(id), { ...rule, slug });
754
760
  }
package/src/access.ts CHANGED
@@ -11,9 +11,9 @@
11
11
  */
12
12
 
13
13
  import type { LevelConfig, RedeemIDConfig, RuleConfig } from "./config.js";
14
- import type { EntryRule } from "./settings.js";
14
+ import { UNAVAILABLE, type EntryRule } from "./settings.js";
15
15
  import { siteConfig } from "./site-config.js";
16
- import { memberForUser, stores, type MemberRecord } from "./storage.js";
16
+ import { memberForUser, stores, type MemberRecord, type MeterRecord } from "./storage.js";
17
17
 
18
18
  /** The parts of Astro's global that access checks read. */
19
19
  export interface AstroLike {
@@ -336,7 +336,7 @@ export function passes(held: string[], required: string[][]): boolean {
336
336
  */
337
337
  export function describe(required: Requirement, levels: LevelConfig[] = [], open = "Free", signedIn = "Signed in"): string {
338
338
  if (required === null) return open;
339
- const name = (slug: string) => levels.find((l) => l.slug === slug)?.name ?? slug;
339
+ const name = (slug: string) => (slug === UNAVAILABLE ? "Members" : (levels.find((l) => l.slug === slug)?.name ?? slug));
340
340
  const parts = reduce(required).filter((c) => c.length > 0).map((c) => c.map(name).join(" or "));
341
341
  return parts.length ? parts.join(" + ") : signedIn;
342
342
  }
@@ -384,6 +384,8 @@ export async function entryRequirement(
384
384
  id?: string | null,
385
385
  ): Promise<Requirement> {
386
386
  if (!config) return null;
387
+ // Saved settings can't be read: nobody passes this.
388
+ if (config.unavailable) return [[UNAVAILABLE]];
387
389
  const own = entryRuleFor(config, collection, id);
388
390
  if (own && (own.mode === "open" || own.mode === "levels") && !own.openUntil && !own.lockedUntil) return applyEntryRule(null, own);
389
391
  const tree = await termTree(config.rules, collection, terms);
@@ -445,7 +447,7 @@ export async function canRead(
445
447
  const v = await viewer(astro);
446
448
 
447
449
  if (required === null) return { allowed: true, reason: "open", required: [], viewer: v };
448
- if (!config) return { allowed: false, reason: "unconfigured", required, viewer: v };
450
+ if (!config || config.unavailable) return { allowed: false, reason: "unconfigured", required, viewer: v };
449
451
  if (!v.signedIn) return { allowed: false, reason: "sign_in", required, viewer: v };
450
452
  if (v.member?.status === "disabled") return { allowed: false, reason: "disabled", required, viewer: v };
451
453
  if (!v.member) return { allowed: false, reason: "upgrade", required, viewer: v };
@@ -529,12 +531,31 @@ export async function readEntry(
529
531
  const key = `${user.id}:${periodKey(new Date(), period)}`;
530
532
  try {
531
533
  const s = stores(db);
532
- const record = (await s.meter.get(key)) ?? { userId: user.id, period: periodKey(new Date(), period), items: [] };
533
- const used = consume(record.items, `${entry.collection}:${entry.id}`, meter.limit);
534
534
  const info = { limit: meter.limit, period };
535
- if (!used.allowed) return { ...verdict, meterLeft: 0, meter: info };
536
- if (used.items !== record.items) {
537
- await s.meter.put(key, { ...record, items: used.items });
535
+ // Read, count and write with compare-and-set, so parallel requests
536
+ // can't each take the last free entry.
537
+ let record: MeterRecord | null = null;
538
+ let used: ReturnType<typeof consume> | null = null;
539
+ let wrote = false;
540
+ for (let attempt = 0; attempt < 3 && !used; attempt++) {
541
+ const current = await s.meter.getVersioned(key);
542
+ const r = current?.value ?? { userId: user.id, period: periodKey(new Date(), period), items: [] };
543
+ const next = consume(r.items, `${entry.collection}:${entry.id}`, meter.limit);
544
+ if (!next.allowed || next.items === r.items) {
545
+ record = r;
546
+ used = next;
547
+ break;
548
+ }
549
+ const write = await s.meter.compareAndSet(key, current?.revision ?? null, { ...r, items: next.items });
550
+ if (write.applied) {
551
+ record = r;
552
+ used = next;
553
+ wrote = true;
554
+ }
555
+ }
556
+ // Lost every race: count it as used up rather than open one too many.
557
+ if (!used || !record || !used.allowed) return { ...verdict, meterLeft: 0, meter: info };
558
+ if (wrote) {
538
559
  const { emit } = await import("./events.js");
539
560
  const data = { userId: user.id, entry: `${entry.collection}:${entry.id}`, used: used.items.length, limit: meter.limit, period: record.period };
540
561
  const { record: log } = await import("./log.js");
package/src/admin-api.ts CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { env, ENV, shopOrigin, type RedeemIDConfig } from "./config.js";
13
13
  import { USER_AGENT } from "./shopify.js";
14
- import { cleanTags, emptyMember, stampTags, type MemberRecord, type Stores } from "./storage.js";
14
+ import { cleanTags, emptyMember, metaDate, stampTags, type MemberRecord, type MembershipDates, type Stores } from "./storage.js";
15
15
  import { timed } from "./log.js";
16
16
 
17
17
  export const ADMIN_API_VERSION = "2026-10";
@@ -100,15 +100,32 @@ export interface AdminCustomer {
100
100
  lastName: string | null;
101
101
  tags: string[];
102
102
  updatedAt: number | null;
103
+ emailMarketing: string | null;
104
+ dates: MembershipDates;
103
105
  }
104
106
 
107
+ /** The membership.* customer metafields read with each customer (see MemberRecord.dates). */
108
+ const DATE_FIELDS = `since: metafield(namespace: "membership", key: "member_since") { value } renews: metafield(namespace: "membership", key: "renews_on") { value } ends: metafield(namespace: "membership", key: "ends_on") { value } gift: metafield(namespace: "membership", key: "gift_until") { value }`;
109
+
105
110
  /** Up to 100 customers by numeric id. Customers Shopify no longer has are left out. */
106
111
  export async function customers(config: RedeemIDConfig, ids: string[], fetcher: typeof fetch = fetch): Promise<AdminCustomer[]> {
107
112
  if (ids.length === 0) return [];
108
- type Node = { id: string; firstName: string | null; lastName: string | null; tags: string[]; updatedAt: string; defaultEmailAddress: { emailAddress: string | null } | null } | null;
113
+ type Meta = { value: string | null } | null;
114
+ type Node = {
115
+ id: string;
116
+ firstName: string | null;
117
+ lastName: string | null;
118
+ tags: string[];
119
+ updatedAt: string;
120
+ defaultEmailAddress: { emailAddress: string | null; marketingState?: string | null } | null;
121
+ since?: Meta;
122
+ renews?: Meta;
123
+ ends?: Meta;
124
+ gift?: Meta;
125
+ } | null;
109
126
  const data = await admin<{ nodes: Node[] }>(
110
127
  config,
111
- `query RedeemIDCustomers($ids: [ID!]!) { nodes(ids: $ids) { ... on Customer { id firstName lastName tags updatedAt defaultEmailAddress { emailAddress } } } }`,
128
+ `query RedeemIDCustomers($ids: [ID!]!) { nodes(ids: $ids) { ... on Customer { id firstName lastName tags updatedAt defaultEmailAddress { emailAddress marketingState } ${DATE_FIELDS} } } }`,
112
129
  { ids: ids.slice(0, 100).map(gid) },
113
130
  fetcher,
114
131
  );
@@ -121,11 +138,14 @@ export async function customers(config: RedeemIDConfig, ids: string[], fetcher:
121
138
  lastName: n.lastName || null,
122
139
  tags: cleanTags(n.tags),
123
140
  updatedAt: Number.isFinite(Date.parse(n.updatedAt)) ? Date.parse(n.updatedAt) : null,
141
+ emailMarketing: n.defaultEmailAddress?.marketingState ?? null,
142
+ dates: { since: metaDate(n.since?.value), renews: metaDate(n.renews?.value), ends: metaDate(n.ends?.value), gift: metaDate(n.gift?.value) },
124
143
  }));
125
144
  }
126
145
 
127
146
  /**
128
- * Read members from Shopify again and update the mirror: profile and tags.
147
+ * Read members from Shopify again and update the mirror: profile, tags,
148
+ * email marketing state and the membership dates.
129
149
  * Status stays with the enable/disable webhooks. Members Shopify no longer
130
150
  * has are reported in `missing` and left alone.
131
151
  */
@@ -142,6 +162,8 @@ export async function resync(config: RedeemIDConfig, s: Stores, ids: string[], s
142
162
  m.lastName = c.lastName ?? m.lastName;
143
163
  m.tags = c.tags;
144
164
  m.profileUpdatedAt = c.updatedAt ?? m.profileUpdatedAt;
165
+ m.emailMarketing = c.emailMarketing ?? m.emailMarketing ?? null;
166
+ m.dates = c.dates;
145
167
  m.syncedAt = new Date().toISOString();
146
168
  m.source = source;
147
169
  await s.members.put(c.customerId, stampTags(m));
package/src/cart-ops.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  import { readCart, saveCart } from "./cart.js";
12
12
  import type { RedeemIDConfig } from "./config.js";
13
13
  import { memberForUser, stores } from "./storage.js";
14
+ import { sessionUser } from "./routes/shared.js";
14
15
  import { addToCart, cartDetails, product, realOptions, removeLines, setDiscountCodes, storeContext, updateLines, variantFor, type Cart, type CartDetails, type Product, type Variant } from "./storefront.js";
15
16
 
16
17
  export const MAX_QUANTITY = 1000;
@@ -100,8 +101,8 @@ export async function editCart(input: CartInput, ctx: CartContext): Promise<Cart
100
101
  const variant = p ? pickVariant(p, input) : undefined;
101
102
  const plan = p ? pickSellingPlan(p, input.sellingPlan) : undefined;
102
103
  if (!p || !variant?.availableForSale || plan === undefined) return { notice: "plan_unavailable", cart: null };
103
- const user = (await session?.get("user")) as { id?: string } | undefined;
104
- const member = user?.id && ctx.db ? await memberForUser(stores(ctx.db), user.id) : null;
104
+ const user = await sessionUser(session, ctx.db);
105
+ const member = user && ctx.db ? await memberForUser(stores(ctx.db), user.id) : null;
105
106
  const current = await readCart(session);
106
107
  const cart = await addToCart(
107
108
  config,
package/src/checkout.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * A signed-in member's cart gets their Customer Account API access token
5
5
  * (cartBuyerIdentityUpdate), so checkout opens signed in and the order lands
6
6
  * on their Shopify customer, where the shop's Flow or apps tag it. The token
7
- * lives only in the server-side session and is renewed with its refresh
7
+ * lives only in the server-side session, sealed (token-vault.ts), and is renewed with its refresh
8
8
  * token when it's about to expire. Anything that fails falls back to a guest
9
9
  * checkout: buying never breaks because sign-in couldn't be carried over.
10
10
  */
@@ -13,6 +13,7 @@ import { env, ENV, type RedeemIDConfig } from "./config.js";
13
13
  import { SESSION_CUSTOMER_TOKEN, callbackUrl, type CustomerToken } from "./routes/shared.js";
14
14
  import { discover, refreshTokens } from "./shopify.js";
15
15
  import { signInCart } from "./storefront.js";
16
+ import { seal, unseal } from "./token-vault.js";
16
17
 
17
18
  type Session = { get(key: string): unknown; set(key: string, value: unknown): void; delete?(key: string): void } | undefined;
18
19
 
@@ -23,9 +24,9 @@ const RENEW_MS = 2 * 60 * 1000;
23
24
  export async function customerToken(
24
25
  config: RedeemIDConfig,
25
26
  session: Session,
26
- opts: { url: URL; emdashConfig?: unknown; fetcher?: typeof fetch; now?: number },
27
+ opts: { url: URL; emdashConfig?: unknown; fetcher?: typeof fetch; now?: number; db?: unknown },
27
28
  ): Promise<string | null> {
28
- const t = (await session?.get(SESSION_CUSTOMER_TOKEN)) as CustomerToken | undefined;
29
+ const t = await unseal(await session?.get(SESSION_CUSTOMER_TOKEN), opts.db);
29
30
  if (!t?.accessToken) return null;
30
31
  const now = opts.now ?? Date.now();
31
32
  if (!t.expiresAt || t.expiresAt - now > RENEW_MS) return t.accessToken;
@@ -45,7 +46,7 @@ export async function customerToken(
45
46
  { clientId, clientSecret: env(ENV.clientSecret), origin: new URL(redirect).origin, refreshToken: t.refreshToken },
46
47
  opts.fetcher,
47
48
  );
48
- session?.set(SESSION_CUSTOMER_TOKEN, next satisfies CustomerToken);
49
+ session?.set(SESSION_CUSTOMER_TOKEN, await seal(next satisfies CustomerToken, opts.db));
49
50
  return next.accessToken;
50
51
  } catch {
51
52
  forget();
@@ -8,7 +8,7 @@
8
8
  * and `joinUrl` come from the level's config, the join link from `join`.
9
9
  * Anything in the default slot is shown above the built-in text.
10
10
  */
11
- import { passes, signInUrl, type Reason } from "../access.js";
11
+ import { membership, passes, refreshUrl, signInUrl, viewer, type Reason } from "../access.js";
12
12
  import type { SoldItem } from "../blocks-names.js";
13
13
  import { plan as loadPlan } from "../storefront.js";
14
14
  import { siteConfig } from "../site-config.js";
@@ -39,6 +39,9 @@ const config = await siteConfig(Astro.locals);
39
39
  const missing = required.find((clause) => clause.length > 0 && !passes(held, [clause]));
40
40
  const level = missing ? config?.levels?.find((l) => l.slug === missing[0]) : undefined;
41
41
  const joinHref = level?.joinUrl ?? config?.join?.href;
42
+ // A member who held the level before is offered it back ("Renew"), not "Join" as if new.
43
+ const member = reason === "upgrade" && level ? (await viewer(Astro)).member : null;
44
+ const lapsed = Boolean(member && member.status !== "disabled" && membership(member, config?.levels).past.some((p) => p.slug === level?.slug));
42
45
  const joinLabel = config?.join?.label ?? text(config, "Become a member");
43
46
  const week = meterPeriod === "week";
44
47
  // "Buy just this": the item's product and price, read from Shopify.
@@ -73,7 +76,14 @@ const here = Astro.url.pathname + Astro.url.search;
73
76
  {opens ? textParts(config, "This opens for you on {date}.", { date: <time datetime={opensAt?.toISOString()}>{opens}</time> }) : text(config, "This opens for you after you've been a member a little longer.")}
74
77
  </p>
75
78
  )}
76
- {reason === "upgrade" && (
79
+ {reason === "upgrade" && lapsed && level && (
80
+ <p class="redeemid-locked__renew">
81
+ {text(config, "Your {level} membership has ended.", { level: level.name })}
82
+ {joinHref && <> <a href={joinHref}>{text(config, "Renew {level}", { level: level.name })}</a></>}
83
+ {" "}{textParts(config, "Already renewed? {refresh}", { refresh: <a href={refreshUrl(here)}>{text(config, "Refresh")}</a> })}
84
+ </p>
85
+ )}
86
+ {reason === "upgrade" && !lapsed && (
77
87
  <p>
78
88
  {level ? text(config, "This is for {level} members.", { level: level.name }) : text(config, "Your membership doesn't include this yet.")}
79
89
  {joinHref && <> <a href={joinHref}>{joinLabel}</a></>}