@redeemid/emdash 0.0.21 → 0.0.23
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 +50 -13
- package/docs/developer-api.md +6 -3
- package/package.json +2 -1
- package/src/access-admin.ts +83 -12
- package/src/access.ts +128 -28
- package/src/agents.ts +4 -4
- package/src/api-version.ts +1 -1
- package/src/blocks-names.ts +3 -1
- package/src/components/AccessBadge.astro +4 -1
- package/src/components/Account.astro +14 -3
- package/src/components/LockedPanel.astro +4 -5
- package/src/components/MemberCard.astro +4 -3
- package/src/components/Plan.astro +34 -15
- package/src/config.ts +54 -3
- package/src/contract.ts +2 -1
- package/src/events.ts +1 -1
- package/src/files.ts +9 -5
- package/src/guard.ts +15 -0
- package/src/log.ts +1 -0
- package/src/plugin.ts +43 -4
- package/src/privacy.ts +10 -7
- package/src/routes/buy.ts +3 -2
- package/src/routes/callback.ts +7 -3
- package/src/routes/webhook.ts +21 -1
- package/src/search-guard.ts +100 -0
- package/src/setup.ts +2 -2
- package/src/staff.ts +89 -2
- package/src/storefront.ts +8 -4
- package/src/text.ts +1 -1
- package/src/version.ts +1 -1
- package/src/webhooks.ts +10 -3
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.23, 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
|
|
|
@@ -26,7 +26,7 @@ Readers sign in with the store's own customer account. Their Shopify customer ta
|
|
|
26
26
|
|
|
27
27
|
1. **Sign in.** A reader clicks "Sign in", signs in on the store's customer account page, and comes back signed in as an EmDash Subscriber. That role only means "signed in". It never opens content.
|
|
28
28
|
2. **Mirror.** RedeemID keeps a copy of the customer: email, name, tags and whether the account is enabled. Signed Shopify webhooks keep it current.
|
|
29
|
-
3. **Levels.** You map tags to levels and name them whatever you like, such as `gold-member` to Gold. Tags come from Shopify Flow, a subscription app or staff, never from the site.
|
|
29
|
+
3. **Levels.** You map tags to levels and name them whatever you like, such as `gold-member` to Gold, or Gold for `gold` or for `silver` with `annual`. A level can include others, so Gold members read everything for Silver. Tags come from Shopify Flow, a subscription app or staff, never from the site.
|
|
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
|
|
|
@@ -181,7 +181,7 @@ Pass `id` on the entry's own page. It lets the meter count the read, and a locke
|
|
|
181
181
|
<Gate levels={["pro"]}><a href="/reports/archive.zip">Download the archive</a></Gate>
|
|
182
182
|
```
|
|
183
183
|
|
|
184
|
-
**`<AccessBadge>`** shows what an entry needs, on cards and on the entry. It shows "Free" for open entries (`free=""` hides it), adds `is-locked` and a lock for readers it keeps out, and puts the level slugs in `data-redeemid-level` for colours:
|
|
184
|
+
**`<AccessBadge>`** shows what an entry needs, on cards and on the entry. It shows "Free" for open entries (`free=""` hides it), adds `is-locked` and a lock for readers it keeps out, and puts the level slugs in `data-redeemid-level` for colours (and the first level's `badge` colour in `--redeemid-level-color`):
|
|
185
185
|
|
|
186
186
|
```astro
|
|
187
187
|
<AccessBadge collection="posts" terms={post.data.terms} content={post.data.content} />
|
|
@@ -191,7 +191,7 @@ Pass `id` on the entry's own page. It lets the meter count the read, and a locke
|
|
|
191
191
|
|
|
192
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.
|
|
193
193
|
|
|
194
|
-
**`<Plan>`** sells a membership: a Shopify product's title and price with a button that starts checkout (members check out with their email).
|
|
194
|
+
**`<Plan>`** sells a membership: a Shopify product's title and price with a button that starts checkout (members check out with their email). Its levels are `levels`, or else the levels whose `products` list the handle. Then the card fits the member: one who holds a level through its own tags sees "You're on this plan." in place of the button; one whose bigger level includes it sees "Included in your Gold membership."; one the plan would move up (its level includes theirs) gets **Upgrade** (`upgradeLabel`); and one who held it before (Shopify has since removed its tag) gets **Renew** (`renewLabel`) instead of `label`. `data-redeemid-state` is `held`, `included`, `upgrade`, `renew` or `join`. 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.
|
|
195
195
|
|
|
196
196
|
```astro
|
|
197
197
|
<Plan handle="gold-membership" label="Join Gold" levels={["gold"]}>
|
|
@@ -214,6 +214,18 @@ const needs = await entryRequirement(config, "posts", post.data.terms, post.id);
|
|
|
214
214
|
// e.g. [["gold", "pro"]]: readers pass every clause, each with any one of its levels; null when open
|
|
215
215
|
```
|
|
216
216
|
|
|
217
|
+
**Site search.** EmDash's search API (`/_emdash/api/search`, which its live search box uses) is public and answers with a snippet of the matching text. `redeemidGuard()` checks each result for visitors and members: an entry the reader can't read in full keeps its title but loses its snippet, and stays in the results only when its title holds every searched word, so a search can't show locked text or tell which locked entry contains a word. Staff get every result. A search page that calls EmDash's `search()` in its template filters the same way with `readableResults()`:
|
|
218
|
+
|
|
219
|
+
```astro
|
|
220
|
+
---
|
|
221
|
+
import { search } from "emdash";
|
|
222
|
+
import { readableResults } from "@redeemid/emdash/search";
|
|
223
|
+
|
|
224
|
+
const q = Astro.url.searchParams.get("q") ?? "";
|
|
225
|
+
const results = q ? await readableResults(Astro, q, (await search(q)).items) : [];
|
|
226
|
+
---
|
|
227
|
+
```
|
|
228
|
+
|
|
217
229
|
`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.
|
|
218
230
|
|
|
219
231
|
## Guides
|
|
@@ -235,13 +247,35 @@ Each rule names a collection and the levels, any of which may read. An empty `le
|
|
|
235
247
|
|
|
236
248
|
Term rules beat the collection's rule, which beats the site default. When several term rules cover one entry (a story in Investigations, which needs Gold, and Culture, which needs Silver), readers must pass every one of them, so only members with both read it. Set `combine: "any"` to let one be enough.
|
|
237
249
|
|
|
238
|
-
Levels
|
|
250
|
+
### Levels
|
|
251
|
+
|
|
252
|
+
A level is a name for customers who hold some Shopify tags. `tags` is one group: any one of them grants the level, or every one with `match: "all"`. `groups` adds more groups, and any one group is enough:
|
|
253
|
+
|
|
254
|
+
```js
|
|
255
|
+
levels: [
|
|
256
|
+
{ slug: "silver", name: "Silver", tags: ["silver"] },
|
|
257
|
+
// "gold", or "silver" with "annual".
|
|
258
|
+
{ slug: "gold", name: "Gold", tags: ["gold"], groups: [{ tags: ["silver", "annual"], match: "all" }], includes: ["silver"] },
|
|
259
|
+
],
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
`includes` lists levels this one also opens: Gold members read everything for Silver, and inclusion carries on (Gold includes Silver includes Members). So a rule names only the lowest level that may read, and an entry that needs Silver and Gold needs Gold once. Members hold the levels their tags grant and every level those include; the account box and member card show only the top one ("Gold", not "Gold, Silver"), and `member.levels_changed` lists them all.
|
|
263
|
+
|
|
264
|
+
The rest of a level is how it looks and where it leads:
|
|
265
|
+
|
|
266
|
+
| Key | What it does |
|
|
267
|
+
| --- | --- |
|
|
268
|
+
| `wall` | What the wall says to readers this level would let in |
|
|
269
|
+
| `joinUrl` | Where the wall's join link goes for this level (else `join.href`) |
|
|
270
|
+
| `products` | Handles of the Shopify products whose purchase leads to the level. Display only: plan cards use it (see `<Plan>`). It grants nothing. |
|
|
271
|
+
| `home` | Members' home: the same-site path a member lands on after signing in from the home page or a plain sign-in link (their top level's, in level order). Signing in from an entry returns them to it. |
|
|
272
|
+
| `badge` | A hex colour, set as `--redeemid-level-color` on badges, the account box and the member card. Badges always show the name too. |
|
|
239
273
|
|
|
240
274
|
### Access settings in the admin
|
|
241
275
|
|
|
242
276
|
Everything about who reads what can be set in the EmDash admin, so a new content type, taxonomy or level never needs a code change. Under **Plugins → RedeemID → Access**:
|
|
243
277
|
|
|
244
|
-
- **Levels.** Add, rename and remove levels and the Shopify tags that grant them. Name them anything; the slug is made from the name. A level that rules, features or folders still use can't be removed.
|
|
278
|
+
- **Levels.** Add, rename and remove levels and the Shopify tags that grant them: one group per line, commas between tags, and `all:` before a line that needs every tag on it. Tick the levels each one includes, and set its plan products, members' home and badge colour. Name them anything; the slug is made from the name. A level that rules, features or folders still use can't be removed; removing one drops it from other levels' includes.
|
|
245
279
|
- **Site rules.** The site default, how term rules combine, the meter and the wall's join link.
|
|
246
280
|
- **Content types.** Who can read each collection, with drip, the meter and the teaser. Every collection on the site is listed, including ones made after RedeemID was set up.
|
|
247
281
|
- **Taxonomies.** Rules for terms in every taxonomy, for one collection or every collection the taxonomy is on.
|
|
@@ -271,7 +305,7 @@ teaser: { posts: 40 },
|
|
|
271
305
|
|
|
272
306
|
The meter lets a signed-in reader open a locked entry anyway, `limit` entries per `period` (`"month"`, the default, or `"week"`, in UTC). Each entry counts once per period, when its own page renders through `<Content>` with an `id`. Lists, feeds, search and the content API never count. Visitors get the wall and a nudge to sign in. While it runs, `<Content>` says how many free reads are left, and when it runs out the wall says so. Rules with `meter: false` are never metered, and the meter never opens an entry a member is waiting on by drip.
|
|
273
307
|
|
|
274
|
-
Drip holds a rule's entries back from members who pass it until they've held their level for `drip` days. A level starts when the customer first had
|
|
308
|
+
Drip holds a rule's entries back from members who pass it until they've held their level for `drip` days. A level starts when the customer first had a tag of its group (for a `match: "all"` group, the last of its tags); a level held through another's `includes` starts with that one. With several levels the earliest counts, and with none known, the first sign-in. RedeemID notes when each tag first arrives and forgets it when Shopify removes it. The wall shows the date the entry opens.
|
|
275
309
|
|
|
276
310
|
### Selling one entry on its own
|
|
277
311
|
|
|
@@ -398,7 +432,7 @@ What RedeemID keeps about a member: the synced record (Shopify customer ID, emai
|
|
|
398
432
|
| Staff, on a member's page | **Export data**, **Erase site data**, and **Also request erasure in Shopify** (orders included, after Shopify's wait; **Cancel the Shopify erasure** withdraws it). The Shopify request needs the `write_customer_data_erasure` scope. |
|
|
399
433
|
| Shopify `customers/data_request` | An open request on the **Privacy** page. Press **Export and mark done**, then send the export to the store owner. |
|
|
400
434
|
| Shopify `customers/redact` | The member's record and meter are erased. |
|
|
401
|
-
| Shopify `shop/redact` | Every member record and meter is erased. |
|
|
435
|
+
| Shopify `shop/redact` | Every member record and meter is erased, a hundred at a time, so even a large shop fits in one request on the Workers Free plan. |
|
|
402
436
|
|
|
403
437
|
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.
|
|
404
438
|
|
|
@@ -407,6 +441,7 @@ The Privacy page shows each request's source and Shopify customer ID, never an e
|
|
|
407
441
|
With `REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID` set, RedeemID gets an Admin API token for the webhook app with the client credentials grant. The app must be in the same Shopify organization as the store, as Dev Dashboard apps are, and needs `read_customers` and `write_customers`. Then, under **Plugins → RedeemID**:
|
|
408
442
|
|
|
409
443
|
- **Member detail.** Levels, tags and features; resync from Shopify; add and remove tags. Tag edits are made in Shopify and read back. Nothing is granted on the site.
|
|
444
|
+
- **Site user.** Signing in links a customer to the site user with the same verified email. When the emails differ, staff link the customer to an existing Subscriber by that user's email on the member's page (never a staff account, never a user linked to another customer), or unlink them. This part needs no Admin API app.
|
|
410
445
|
- **Bulk resync.** Reads the 25 members synced longest ago, few enough for the Workers free limit of 50 database queries a request. Press it again for the next 25. It never runs on its own.
|
|
411
446
|
- **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.
|
|
412
447
|
- **Access test.** A collection, an entry, terms and a reader's tags in; the verdict out.
|
|
@@ -445,7 +480,7 @@ Staff questions as data, for AI agents and scripts. Every one needs an administr
|
|
|
445
480
|
| MCP tool | Command | What it answers |
|
|
446
481
|
| --- | --- | --- |
|
|
447
482
|
| `explain_access` | `access <collection>` | Can this reader read this entry, and why. Give the entry by `id` or `terms`, and the reader by `tags`, `customerId` or `signedIn: false`. |
|
|
448
|
-
| `list_levels` | `levels` | Every level, its
|
|
483
|
+
| `list_levels` | `levels` | Every level, its tag groups, the levels it includes, wall text and join link |
|
|
449
484
|
| `get_rules` | `rules` | How rules combine, every rule, the meter, teasers, features and file rules |
|
|
450
485
|
| `get_member` | `member <id or email>` | One member: status, tags, levels with start dates, past levels, features, last sync |
|
|
451
486
|
| `site_health` | `health` | Each setting (set or not, never a value), the last webhook, open privacy requests, ignored text replacements |
|
|
@@ -524,9 +559,9 @@ Pass one object to both `redeemid(config)` and `redeemidPlugin(config)`. `levels
|
|
|
524
559
|
|
|
525
560
|
| Option | Type | What it does |
|
|
526
561
|
| --- | --- | --- |
|
|
527
|
-
| `shop` | `string` | The store's `*.myshopify.com` domain
|
|
562
|
+
| `shop` | `string` | The store's `*.myshopify.com` domain. A custom domain works for sign-in, but the Admin API (staff tools, webhook registration, tag reads) answers only on myshopify.com, and the Status page warns when the app is set up with a custom domain. Leave it out to read `REDEEMID_SHOPIFY_STORE_DOMAIN`. |
|
|
528
563
|
| `label` | `string` | The sign-in button's label. Default "Sign in". |
|
|
529
|
-
| `levels` | `{ slug, name, tags
|
|
564
|
+
| `levels` | `{ slug, name, tags?, match?, groups?, includes?, wall?, joinUrl?, products?, home?, badge? }[]` | Levels from customer tags. See [levels](#levels). |
|
|
530
565
|
| `rules` | `{ collection, taxonomy?, term?, levels, open?, drip?, meter? }[]` | What needs which levels. See [rules](#rules). |
|
|
531
566
|
| `combine` | `"all"` \| `"any"` | How several term rules on one entry combine. Default `"all"`. |
|
|
532
567
|
| `join` | `{ href, label? }` | The wall's join link. |
|
|
@@ -578,7 +613,7 @@ Import each from `@redeemid/emdash/components/<Name>.astro`.
|
|
|
578
613
|
| `YourData` | `title`, `heading` | Download or delete my data |
|
|
579
614
|
| `Feature` | `slug`; slot `else` | Shows its content to members holding the feature |
|
|
580
615
|
| `Notices` | `messages` | The message for `?redeemid_notice=` |
|
|
581
|
-
| `Plan` | `handle`, `label`, `title`, `levels`, `heldLabel`, `renewLabel` | A product with a buy button |
|
|
616
|
+
| `Plan` | `handle`, `label`, `title`, `levels`, `heldLabel`, `renewLabel`, `upgradeLabel` | A product with a buy button that fits the member |
|
|
582
617
|
| `Product` | `handle`, `heading`, `compact`, `html`, `schema`, `addLabel`, `buyLabel` | A product page |
|
|
583
618
|
| `Collection` | `handle`, `limit`, `productHref`, `showTitle`, `browse`, `quickAdd` | A grid of products, with filters and pages |
|
|
584
619
|
| `ProductSearch` | `productHref`, `limit`, `boxOnly`, `action`, `label`, `quickAdd` | Product search |
|
|
@@ -619,6 +654,7 @@ Import each from `@redeemid/emdash/components/<Name>.astro`.
|
|
|
619
654
|
| `@redeemid/emdash/catalog` | `searchProducts()`, `collectionPage()`, `suggest()`, `recommendations()` |
|
|
620
655
|
| `@redeemid/emdash/events` | `signBody(body, secret)`, the signature a receiver checks against `X-RedeemID-Signature` |
|
|
621
656
|
| `@redeemid/emdash/text` | `STRINGS`, the list of every replaceable string |
|
|
657
|
+
| `@redeemid/emdash/search` | `readableResults()`, for a search page that calls EmDash's `search()` |
|
|
622
658
|
| `@redeemid/emdash/contract` | The stable API as a list, and `API_VERSION` |
|
|
623
659
|
|
|
624
660
|
The full developer API (stable exports, JSON routes, the browser client and its events, MCP tools) is in [docs/developer-api.md](docs/developer-api.md).
|
|
@@ -649,10 +685,11 @@ Reword one with `messages={{ cart_empty: "…" }}`, or for every page with `text
|
|
|
649
685
|
- Shopify is the only source of entitlements. Nothing on the site grants access.
|
|
650
686
|
- Levels read the mirrored tags as they are, however old. They change only when Shopify sends new ones.
|
|
651
687
|
- Enabled or disabled status comes only from the `customers/enable` and `customers/disable` webhooks.
|
|
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.
|
|
688
|
+
- Locked content never reaches the content API, site search (snippets, or which entry matched), 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.
|
|
653
689
|
- 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.
|
|
654
690
|
- Customers can't create the first (admin) account on a new site.
|
|
655
691
|
- Webhooks are checked against the app's signature before anything changes.
|
|
692
|
+
- `customers/merge` moves the sign-in link to the customer Shopify kept. With the Admin API app set up, the kept customer's tags are read straight away; without it they arrive with its next webhook or sign-in.
|
|
656
693
|
- 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.
|
|
657
694
|
|
|
658
695
|
## Troubleshooting
|
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.3.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,13 +17,16 @@ 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` | `siteConfig`, `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`, `topLevels`, `closure`, `simplify`, `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` |
|
|
24
24
|
| `@redeemid/emdash/catalog` | `searchProducts`, `collectionPage`, `suggest`, `recommendations`, `browseFromUrl`, `browseHref`, `parseFilters`, `priceFilter`, `SORTS` |
|
|
25
25
|
| `@redeemid/emdash/events` | `signBody` |
|
|
26
26
|
| `@redeemid/emdash/text` | `STRINGS`, `text`, `textParts` |
|
|
27
|
+
| `@redeemid/emdash/search` | `readableResults` |
|
|
28
|
+
|
|
29
|
+
Levels: `levelsFor(tags, levels)` answers every level the tags hold, including the ones a held level `includes` (so `levels` in `api/state`, `Viewer.levels` and `member.levels_changed` list Silver for a Gold member whose Gold includes Silver). `topLevels(held, levels)` keeps the ones no other held level includes, for showing ("Gold"). `closure(slug, levels)` is every level holding `slug` opens, itself first. `simplify(required, levels)` drops what inclusion already covers from a requirement (`[["silver"], ["gold"]]` becomes `[["gold"]]`); `entryRequirement` already does it. `membership()` marks a level held only through another with `via` (that level's slug).
|
|
27
30
|
|
|
28
31
|
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
32
|
|
|
@@ -50,7 +53,7 @@ Under `/_emdash/api/auth/redeemid/`. Same-origin only: EmDash checks `Origin` on
|
|
|
50
53
|
- `api/state` (GET): the visitor's own state.
|
|
51
54
|
|
|
52
55
|
```json
|
|
53
|
-
{ "apiVersion": "1.
|
|
56
|
+
{ "apiVersion": "1.3.0", "version": "0.0.23", "signedIn": true, "linked": true,
|
|
54
57
|
"levels": ["gold"], "features": ["ad-free"], "cart": { "count": 2 },
|
|
55
58
|
"meter": { "used": 1, "limit": 3, "period": "month" } }
|
|
56
59
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@redeemid/emdash",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.23",
|
|
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",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"exports": {
|
|
14
14
|
".": "./src/index.ts",
|
|
15
15
|
"./access": "./src/access.ts",
|
|
16
|
+
"./search": "./src/search-guard.ts",
|
|
16
17
|
"./guard.ts": "./src/guard.ts",
|
|
17
18
|
"./rate-limit-cloudflare.ts": "./src/rate-limit-cloudflare.ts",
|
|
18
19
|
"./templates.ts": "./src/templates.ts",
|
package/src/access-admin.ts
CHANGED
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
|
|
16
16
|
import type { StorageCollection } from "emdash";
|
|
17
17
|
|
|
18
|
-
import { applyEntryRule, describe, requirement, ruleSource, SIGNED_IN, termTree, type Requirement, type Terms } from "./access.js";
|
|
19
|
-
import type
|
|
18
|
+
import { applyEntryRule, badgeColor, describe, requirement, ruleSource, SIGNED_IN, termTree, type Requirement, type Terms } from "./access.js";
|
|
19
|
+
import { groupsOf, type FeatureConfig, type FileRule, type LevelConfig, type MeterConfig, type RedeemIDConfig, type RuleConfig, type TagGroup } from "./config.js";
|
|
20
20
|
import {
|
|
21
21
|
days,
|
|
22
22
|
deleteAccess,
|
|
@@ -153,6 +153,40 @@ function ruleText(rule: RuleConfig | undefined, levels: LevelConfig[]): string {
|
|
|
153
153
|
return extra ? `${who} (${extra})` : who;
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
/** A level's tag groups as the form shows them: one per line, "all:" before a line that needs every tag. */
|
|
157
|
+
function groupsText(level: LevelConfig | undefined): string {
|
|
158
|
+
if (!level) return "";
|
|
159
|
+
return groupsOf(level)
|
|
160
|
+
.map((g) => `${g.match === "all" && g.tags.length > 1 ? "all: " : ""}${g.tags.join(", ")}`)
|
|
161
|
+
.join("\n");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The level's tags in the table: "gold or silver + annual". */
|
|
165
|
+
function groupsWords(level: LevelConfig): string {
|
|
166
|
+
return groupsOf(level)
|
|
167
|
+
.map((g) => g.tags.join(g.match === "all" ? " + " : " or "))
|
|
168
|
+
.join(" or ");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Tag groups from the form: one per line, tags separated by commas; a line
|
|
173
|
+
* starting "all:" needs every tag. `firstMatch` ("all") applies to a first
|
|
174
|
+
* line with no prefix, for callers that still send the old Customer needs field.
|
|
175
|
+
*/
|
|
176
|
+
export function parseGroups(raw: unknown, firstMatch?: unknown): TagGroup[] {
|
|
177
|
+
const lines = Array.isArray(raw) ? [raw.join(",")] : String(raw ?? "").split(/\r?\n/);
|
|
178
|
+
const out: TagGroup[] = [];
|
|
179
|
+
lines.forEach((line, i) => {
|
|
180
|
+
const all = /^\s*all\s*:/i.test(line);
|
|
181
|
+
const tags = list(line.replace(/^\s*(all|any)\s*:/i, "").split(","));
|
|
182
|
+
if (tags.length === 0) return;
|
|
183
|
+
const match = all || (i === 0 && firstMatch === "all") ? "all" : "any";
|
|
184
|
+
if (out.some((g) => (g.match ?? "any") === match && g.tags.join("|").toLowerCase() === tags.join("|").toLowerCase())) return;
|
|
185
|
+
out.push({ tags, ...(match === "all" && tags.length > 1 ? { match: "all" as const } : {}) });
|
|
186
|
+
});
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
|
|
156
190
|
// ---------------------------------------------------------------- tabs
|
|
157
191
|
|
|
158
192
|
function levelsTab(env: AccessEnv, editing?: string): unknown[] {
|
|
@@ -161,8 +195,9 @@ function levelsTab(env: AccessEnv, editing?: string): unknown[] {
|
|
|
161
195
|
const rules = config.rules ?? [];
|
|
162
196
|
const used = (slug: string) => rules.filter((r) => r.levels.includes(slug)).length;
|
|
163
197
|
const current = editing ? levels.find((l) => l.slug === editing) : undefined;
|
|
198
|
+
const others = levels.filter((l) => l.slug !== current?.slug);
|
|
164
199
|
return [
|
|
165
|
-
{ type: "context", text: "A level is a name for readers who hold some Shopify customer tags. Name them anything. Tags come from Shopify (Flow, a subscription app or staff), never from this site." },
|
|
200
|
+
{ type: "context", text: "A level is a name for readers who hold some Shopify customer tags. Name them anything. Tags come from Shopify (Flow, a subscription app or staff), never from this site. A level that includes another reads everything for it too, so a rule names only the lowest level that may read." },
|
|
166
201
|
...sourceLine(env, "levels", "Levels"),
|
|
167
202
|
levels.length
|
|
168
203
|
? {
|
|
@@ -173,31 +208,44 @@ function levelsTab(env: AccessEnv, editing?: string): unknown[] {
|
|
|
173
208
|
{ key: "name", label: "Level" },
|
|
174
209
|
{ key: "slug", label: "Slug", format: "code" },
|
|
175
210
|
{ key: "tags", label: "Shopify tags" },
|
|
176
|
-
{ key: "
|
|
211
|
+
{ key: "includes", label: "Includes" },
|
|
177
212
|
{ key: "rules", label: "Rules", format: "number" },
|
|
178
213
|
{ key: "act", label: "", format: "element" },
|
|
179
214
|
],
|
|
180
215
|
rows: levels.map((l) => ({
|
|
181
216
|
name: l.name,
|
|
182
217
|
slug: l.slug,
|
|
183
|
-
tags: l
|
|
184
|
-
|
|
218
|
+
tags: groupsWords(l),
|
|
219
|
+
includes: (l.includes ?? []).map((s) => levels.find((x) => x.slug === s)?.name ?? s).join(", "),
|
|
185
220
|
rules: used(l.slug),
|
|
186
221
|
act: { type: "menu", action_id: "level_menu", label: "Edit", items: [{ label: "Edit", value: `edit:${l.slug}` }, { label: "Remove", value: `remove:${l.slug}` }] },
|
|
187
222
|
})),
|
|
188
223
|
}
|
|
189
224
|
: { type: "empty", title: "No levels yet", description: "Add one below, such as Members for the tag member.", size: "sm" },
|
|
190
225
|
{ type: "header", text: current ? `Edit ${current.name}` : "Add a level" },
|
|
226
|
+
{ type: "context", text: "Shopify tags: a line passes when the customer has any one of its tags; start a line with all: to need every tag on it. Any one line grants the level, so gold on one line and all: silver, annual on the next means Gold, or Silver with Annual." },
|
|
191
227
|
{
|
|
192
228
|
type: "form",
|
|
193
229
|
block_id: current ? `level:${current.slug}` : "level:new",
|
|
194
230
|
fields: [
|
|
195
231
|
{ type: "text_input", action_id: "name", label: "Name", placeholder: "Members", initial_value: current?.name ?? "" },
|
|
196
232
|
...(current ? [] : [{ type: "text_input", action_id: "slug", label: "Slug (optional, fixed once saved)", placeholder: "members" }]),
|
|
197
|
-
{
|
|
198
|
-
|
|
233
|
+
{
|
|
234
|
+
type: "text_input",
|
|
235
|
+
action_id: "tags",
|
|
236
|
+
label: "Shopify tags: commas between tags, one group per line",
|
|
237
|
+
placeholder: "gold\nall: silver, annual",
|
|
238
|
+
initial_value: groupsText(current),
|
|
239
|
+
multiline: true,
|
|
240
|
+
},
|
|
241
|
+
...(others.length
|
|
242
|
+
? [{ type: "checkbox", action_id: "includes", label: "Also opens what these levels read", options: others.map((l) => ({ label: l.name, value: l.slug })), initial_value: current?.includes ?? [] }]
|
|
243
|
+
: []),
|
|
199
244
|
{ type: "text_input", action_id: "wall", label: "Wall text (optional)", initial_value: current?.wall ?? "", multiline: true },
|
|
200
245
|
{ type: "text_input", action_id: "join_url", label: "Join link (optional)", placeholder: "/join/", initial_value: current?.joinUrl ?? "" },
|
|
246
|
+
{ type: "text_input", action_id: "products", label: "Shopify product handles that lead to it (optional, for plan cards)", placeholder: "gold-membership", initial_value: current?.products?.join(", ") ?? "" },
|
|
247
|
+
{ type: "text_input", action_id: "home", label: "Members' home after sign-in (optional)", placeholder: "/members/gold/", initial_value: current?.home ?? "" },
|
|
248
|
+
{ type: "text_input", action_id: "badge", label: "Badge colour (optional)", placeholder: "#c9a227", initial_value: current?.badge ?? "" },
|
|
201
249
|
],
|
|
202
250
|
submit: { label: current ? "Save level" : "Add level", action_id: "level_save" },
|
|
203
251
|
},
|
|
@@ -497,16 +545,31 @@ export async function accessAction(env: AccessEnv, i: Interaction): Promise<Bloc
|
|
|
497
545
|
const now = effective(env);
|
|
498
546
|
const used = (now.rules ?? []).filter((r) => r.levels.includes(slug!)).length + (now.files ?? []).filter((f) => f.levels?.includes(slug!)).length + (now.features ?? []).filter((f) => f.levels?.includes(slug!)).length;
|
|
499
547
|
if (used) return bad("levels", `Rules, features or folders use this level (${used}). Change them first.`);
|
|
500
|
-
await saveSite(env, (site, n) => ({
|
|
548
|
+
await saveSite(env, (site, n) => ({
|
|
549
|
+
...site,
|
|
550
|
+
levels: start(site, n, "levels")
|
|
551
|
+
.filter((l) => l.slug !== slug)
|
|
552
|
+
.map((l) => {
|
|
553
|
+
if (!l.includes?.includes(slug!)) return l;
|
|
554
|
+
const includes = l.includes.filter((s) => s !== slug);
|
|
555
|
+
const { includes: _gone, ...rest } = l;
|
|
556
|
+
return includes.length ? { ...rest, includes } : rest;
|
|
557
|
+
}),
|
|
558
|
+
}));
|
|
501
559
|
return ok("levels", "Level removed.");
|
|
502
560
|
}
|
|
503
561
|
|
|
504
562
|
case "level_save": {
|
|
505
563
|
const name = String(values.name ?? "").trim();
|
|
506
|
-
const
|
|
564
|
+
const groups = parseGroups(values.tags, values.match);
|
|
507
565
|
const editing = target !== "new" ? target : null;
|
|
508
566
|
if (!name) return bad("levels", "Give the level a name.", { level: editing ?? undefined });
|
|
509
|
-
if (
|
|
567
|
+
if (groups.length === 0) return bad("levels", "Give it at least one Shopify tag.", { level: editing ?? undefined });
|
|
568
|
+
const home = String(values.home ?? "").trim();
|
|
569
|
+
if (home && (!home.startsWith("/") || home.startsWith("//"))) return bad("levels", "The members' home must be a path on this site, such as /members/.", { level: editing ?? undefined });
|
|
570
|
+
const badge = String(values.badge ?? "").trim();
|
|
571
|
+
if (badge && !badgeColor({ slug: "x", name: "x", badge })) return bad("levels", "The badge colour must be a hex colour, such as #c9a227.", { level: editing ?? undefined });
|
|
572
|
+
const products = list(values.products).map((h) => h.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "")).filter(Boolean);
|
|
510
573
|
const fail = await saveSite(env, (site, n) => {
|
|
511
574
|
const levels = start(site, n, "levels");
|
|
512
575
|
let slug = editing;
|
|
@@ -515,11 +578,19 @@ export async function accessAction(env: AccessEnv, i: Interaction): Promise<Bloc
|
|
|
515
578
|
if (typeof made !== "string") return made.error;
|
|
516
579
|
slug = made;
|
|
517
580
|
}
|
|
518
|
-
const
|
|
581
|
+
const [first, ...more] = groups;
|
|
582
|
+
const level: LevelConfig = { slug, name, tags: first!.tags, ...(first!.match === "all" ? { match: "all" as const } : {}) };
|
|
583
|
+
if (more.length) level.groups = more;
|
|
584
|
+
const known = new Set(levels.map((l) => l.slug));
|
|
585
|
+
const includes = list(values.includes).filter((s) => s !== slug && known.has(s));
|
|
586
|
+
if (includes.length) level.includes = includes;
|
|
519
587
|
const wall = String(values.wall ?? "").trim();
|
|
520
588
|
const joinUrl = String(values.join_url ?? "").trim();
|
|
521
589
|
if (wall) level.wall = wall;
|
|
522
590
|
if (joinUrl) level.joinUrl = joinUrl;
|
|
591
|
+
if (products.length) level.products = [...new Set(products)];
|
|
592
|
+
if (home) level.home = home;
|
|
593
|
+
if (badge) level.badge = badge;
|
|
523
594
|
const at = levels.findIndex((l) => l.slug === slug);
|
|
524
595
|
if (at >= 0) levels[at] = level;
|
|
525
596
|
else levels.push(level);
|