@redeemid/emdash 0.0.22 → 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 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.22, 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.
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). 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
+ **`<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"]}>
@@ -247,13 +247,35 @@ Each rule names a collection and the levels, any of which may read. An empty `le
247
247
 
248
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.
249
249
 
250
- Levels can need several tags: `{ slug: "both", name: "Member + Pro", tags: ["member", "pro"], match: "all" }`. A level's `wall` is what the wall says to readers it would let in, and its `joinUrl` overrides `join.href`.
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. |
251
273
 
252
274
  ### Access settings in the admin
253
275
 
254
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**:
255
277
 
256
- - **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.
257
279
  - **Site rules.** The site default, how term rules combine, the meter and the wall's join link.
258
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.
259
281
  - **Taxonomies.** Rules for terms in every taxonomy, for one collection or every collection the taxonomy is on.
@@ -283,7 +305,7 @@ teaser: { posts: 40 },
283
305
 
284
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.
285
307
 
286
- 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 its tag (for a `match: "all"` level, the last of its tags). 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.
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.
287
309
 
288
310
  ### Selling one entry on its own
289
311
 
@@ -410,7 +432,7 @@ What RedeemID keeps about a member: the synced record (Shopify customer ID, emai
410
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. |
411
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. |
412
434
  | Shopify `customers/redact` | The member's record and meter are erased. |
413
- | 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. |
414
436
 
415
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.
416
438
 
@@ -419,6 +441,7 @@ The Privacy page shows each request's source and Shopify customer ID, never an e
419
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**:
420
442
 
421
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.
422
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.
423
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.
424
447
  - **Access test.** A collection, an entry, terms and a reader's tags in; the verdict out.
@@ -457,7 +480,7 @@ Staff questions as data, for AI agents and scripts. Every one needs an administr
457
480
  | MCP tool | Command | What it answers |
458
481
  | --- | --- | --- |
459
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`. |
460
- | `list_levels` | `levels` | Every level, its tags, wall text and join link |
483
+ | `list_levels` | `levels` | Every level, its tag groups, the levels it includes, wall text and join link |
461
484
  | `get_rules` | `rules` | How rules combine, every rule, the meter, teasers, features and file rules |
462
485
  | `get_member` | `member <id or email>` | One member: status, tags, levels with start dates, past levels, features, last sync |
463
486
  | `site_health` | `health` | Each setting (set or not, never a value), the last webhook, open privacy requests, ignored text replacements |
@@ -536,9 +559,9 @@ Pass one object to both `redeemid(config)` and `redeemidPlugin(config)`. `levels
536
559
 
537
560
  | Option | Type | What it does |
538
561
  | --- | --- | --- |
539
- | `shop` | `string` | The store's `*.myshopify.com` domain (a custom customer-account domain works too). Leave it out to read `REDEEMID_SHOPIFY_STORE_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`. |
540
563
  | `label` | `string` | The sign-in button's label. Default "Sign in". |
541
- | `levels` | `{ slug, name, tags, match?, wall?, joinUrl? }[]` | Levels from customer tags. `match: "all"` needs every tag. |
564
+ | `levels` | `{ slug, name, tags?, match?, groups?, includes?, wall?, joinUrl?, products?, home?, badge? }[]` | Levels from customer tags. See [levels](#levels). |
542
565
  | `rules` | `{ collection, taxonomy?, term?, levels, open?, drip?, meter? }[]` | What needs which levels. See [rules](#rules). |
543
566
  | `combine` | `"all"` \| `"any"` | How several term rules on one entry combine. Default `"all"`. |
544
567
  | `join` | `{ href, label? }` | The wall's join link. |
@@ -590,7 +613,7 @@ Import each from `@redeemid/emdash/components/<Name>.astro`.
590
613
  | `YourData` | `title`, `heading` | Download or delete my data |
591
614
  | `Feature` | `slug`; slot `else` | Shows its content to members holding the feature |
592
615
  | `Notices` | `messages` | The message for `?redeemid_notice=` |
593
- | `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 |
594
617
  | `Product` | `handle`, `heading`, `compact`, `html`, `schema`, `addLabel`, `buyLabel` | A product page |
595
618
  | `Collection` | `handle`, `limit`, `productHref`, `showTitle`, `browse`, `quickAdd` | A grid of products, with filters and pages |
596
619
  | `ProductSearch` | `productHref`, `limit`, `boxOnly`, `action`, `label`, `quickAdd` | Product search |
@@ -666,6 +689,7 @@ Reword one with `messages={{ cart_empty: "…" }}`, or for every page with `text
666
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.
667
690
  - Customers can't create the first (admin) account on a new site.
668
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.
669
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.
670
694
 
671
695
  ## Troubleshooting
@@ -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.2.0** (`API_VERSION` in `@redeemid/emdash/contract`, also in `api/state` and `window.redeemid.apiVersion`).
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,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` | `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` |
@@ -26,6 +26,8 @@ Import these in Astro pages, components and endpoints.
26
26
  | `@redeemid/emdash/text` | `STRINGS`, `text`, `textParts` |
27
27
  | `@redeemid/emdash/search` | `readableResults` |
28
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).
30
+
29
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:
30
32
 
31
33
  ```astro
@@ -51,7 +53,7 @@ Under `/_emdash/api/auth/redeemid/`. Same-origin only: EmDash checks `Origin` on
51
53
  - `api/state` (GET): the visitor's own state.
52
54
 
53
55
  ```json
54
- { "apiVersion": "1.2.0", "version": "0.0.22", "signedIn": true, "linked": true,
56
+ { "apiVersion": "1.3.0", "version": "0.0.23", "signedIn": true, "linked": true,
55
57
  "levels": ["gold"], "features": ["ad-free"], "cart": { "count": 2 },
56
58
  "meter": { "used": 1, "limit": 3, "period": "month" } }
57
59
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redeemid/emdash",
3
- "version": "0.0.22",
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",
@@ -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 { FeatureConfig, FileRule, LevelConfig, MeterConfig, RedeemIDConfig, RuleConfig } from "./config.js";
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: "match", label: "Needs" },
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.tags.join(", "),
184
- match: l.tags.length < 2 ? "The tag" : l.match === "all" ? "Every tag" : "Any tag",
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
- { type: "text_input", action_id: "tags", label: "Shopify tags, comma separated", placeholder: "member", initial_value: current?.tags.join(", ") ?? "" },
198
- { type: "select", action_id: "match", label: "Customer needs", options: [{ label: "Any one tag", value: "any" }, { label: "Every tag", value: "all" }], initial_value: current?.match ?? "any" },
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) => ({ ...site, levels: start(site, n, "levels").filter((l) => l.slug !== slug) }));
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 tags = list(values.tags);
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 (tags.length === 0) return bad("levels", "Give it at least one Shopify tag.", { level: editing ?? undefined });
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 level: LevelConfig = { slug, name, tags, ...(values.match === "all" ? { match: "all" as 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);
package/src/access.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  * "entitled".
11
11
  */
12
12
 
13
- import type { LevelConfig, RedeemIDConfig, RuleConfig } from "./config.js";
13
+ import { groupsOf, levelTags, type LevelConfig, type RedeemIDConfig, type RuleConfig, type TagGroup } from "./config.js";
14
14
  import { UNAVAILABLE, type EntryRule } from "./settings.js";
15
15
  import { siteConfig } from "./site-config.js";
16
16
  import { memberForUser, stores, type MemberRecord, type MeterRecord } from "./storage.js";
@@ -72,15 +72,78 @@ export type Terms = Record<string, TermRef[] | undefined>;
72
72
  /** Parent slug by term slug, per taxonomy, for walking up a hierarchy. */
73
73
  export type TermTree = Record<string, Record<string, string | null>>;
74
74
 
75
- export function levelsFor(tags: string[], levels: LevelConfig[] = []): string[] {
75
+ export { groupsOf, levelTags, type TagGroup };
76
+
77
+ function groupPasses(g: TagGroup, held: Set<string>): boolean {
78
+ const has = (t: string) => held.has(t.toLowerCase());
79
+ return g.match === "all" ? g.tags.every(has) : g.tags.some(has);
80
+ }
81
+
82
+ /** Levels the tags grant directly, through one of their own groups, in config order. */
83
+ export function directLevels(tags: string[], levels: LevelConfig[] = []): string[] {
76
84
  const held = new Set(tags.map((t) => t.toLowerCase()));
77
- return levels
78
- .filter((l) => {
79
- if (l.tags.length === 0) return false;
80
- const has = (t: string) => held.has(t.toLowerCase());
81
- return l.match === "all" ? l.tags.every(has) : l.tags.some(has);
82
- })
83
- .map((l) => l.slug);
85
+ return levels.filter((l) => groupsOf(l).some((g) => groupPasses(g, held))).map((l) => l.slug);
86
+ }
87
+
88
+ /**
89
+ * Every level holding `slug` opens, itself first: the levels it includes, and
90
+ * theirs (Gold includes Silver includes Members). Unknown slugs are skipped and
91
+ * loops stop, so a bad config can't hang.
92
+ */
93
+ export function closure(slug: string, levels: LevelConfig[] = []): string[] {
94
+ const bySlug = new Map(levels.map((l) => [l.slug, l]));
95
+ const seen: string[] = [];
96
+ const queue = [slug];
97
+ while (queue.length) {
98
+ const next = queue.shift()!;
99
+ if (seen.includes(next) || !bySlug.has(next)) continue;
100
+ seen.push(next);
101
+ queue.push(...(bySlug.get(next)!.includes ?? []));
102
+ }
103
+ return seen;
104
+ }
105
+
106
+ /** Does holding `holder` open what needs `needed`? */
107
+ export function covers(holder: string, needed: string, levels: LevelConfig[] = []): boolean {
108
+ return holder === needed || closure(holder, levels).includes(needed);
109
+ }
110
+
111
+ /**
112
+ * The levels a member with these tags holds: the ones their tags grant, and
113
+ * every level those include. In config order.
114
+ */
115
+ export function levelsFor(tags: string[], levels: LevelConfig[] = []): string[] {
116
+ const held = new Set(directLevels(tags, levels).flatMap((s) => closure(s, levels)));
117
+ return levels.filter((l) => held.has(l.slug)).map((l) => l.slug);
118
+ }
119
+
120
+ /**
121
+ * The held levels no other held level includes, for showing a member's
122
+ * levels: "Gold", not "Gold, Silver", when Gold includes Silver.
123
+ */
124
+ export function topLevels(held: string[], levels: LevelConfig[] = []): string[] {
125
+ return held.filter((slug) => !held.some((o) => o !== slug && covers(o, slug, levels) && !covers(slug, o, levels)));
126
+ }
127
+
128
+ /**
129
+ * Where a member lands after signing in from the home page or a plain
130
+ * sign-in link: the `home` of their first top level (in level order) that has
131
+ * one, or null. Only same-site paths count.
132
+ */
133
+ export function memberHome(tags: string[], levels: LevelConfig[] = []): string | null {
134
+ const top = topLevels(levelsFor(tags, levels), levels);
135
+ for (const level of levels) {
136
+ if (!top.includes(level.slug) || !level.home) continue;
137
+ const home = safeReturn(level.home.trim());
138
+ if (home !== "/") return home;
139
+ }
140
+ return null;
141
+ }
142
+
143
+ /** A level's badge colour when it is a hex colour, else null. */
144
+ export function badgeColor(level: LevelConfig | undefined): string | null {
145
+ const c = level?.badge?.trim() ?? "";
146
+ return /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(c) ? c : null;
84
147
  }
85
148
 
86
149
  const slugOf = (t: TermRef) => (typeof t === "string" ? t : t.slug);
@@ -89,12 +152,16 @@ const slugOf = (t: TermRef) => (typeof t === "string" ? t : t.slug);
89
152
  * The clauses, simplified: duplicates go, a clause that contains another
90
153
  * clause goes (needing Pro already passes "Member or Pro"), and an empty
91
154
  * clause goes when a level clause is there (any level implies signed in).
155
+ *
156
+ * With `levels`, inclusion counts too: in "Silver or Gold" Gold goes when it
157
+ * includes Silver, and "Silver + Gold" needs only Gold.
92
158
  */
93
- function reduce(clauses: string[][]): string[][] {
159
+ function reduce(clauses: string[][], levels?: LevelConfig[]): string[][] {
94
160
  const seen = new Set<string>();
95
161
  const out: string[][] = [];
96
162
  for (const c of clauses) {
97
- const sorted = c.includes(SIGNED_IN) ? [] : [...new Set(c)].sort();
163
+ let sorted = c.includes(SIGNED_IN) ? [] : [...new Set(c)].sort();
164
+ if (levels?.length) sorted = sorted.filter((l) => !sorted.some((o) => o !== l && covers(l, o, levels) && !covers(o, l, levels)));
98
165
  const key = sorted.join("|");
99
166
  if (seen.has(key)) continue;
100
167
  seen.add(key);
@@ -102,7 +169,14 @@ function reduce(clauses: string[][]): string[][] {
102
169
  }
103
170
  const levelled = out.filter((c) => c.length > 0);
104
171
  if (levelled.length === 0) return out;
105
- return levelled.filter((c) => !levelled.some((o) => o !== c && o.length < c.length && o.every((l) => c.includes(l))));
172
+ // `o` implies `c` when every level in `o` opens some level in `c`.
173
+ const implies = (o: string[], c: string[]) => o.every((l) => c.some((x) => (levels?.length ? covers(l, x, levels) : l === x)));
174
+ return levelled.filter((c) => !levelled.some((o) => o !== c && implies(o, c) && !(implies(c, o) && levelled.indexOf(c) < levelled.indexOf(o))));
175
+ }
176
+
177
+ /** A requirement simplified with the levels' includes (see reduce). */
178
+ export function simplify(required: Requirement, levels: LevelConfig[] = []): Requirement {
179
+ return required === null ? null : reduce(required, levels);
106
180
  }
107
181
 
108
182
  function combined(rules: RuleConfig[], mode: "all" | "any"): string[][] {
@@ -229,29 +303,52 @@ export function meterable(rules: RuleConfig[] = [], collection: string, terms: T
229
303
  }
230
304
 
231
305
  /**
232
- * When a member's membership started, for drip: the earliest start among the
233
- * levels they hold now (a level starts when its last needed tag arrived),
234
- * else when they first signed in. Null when neither is known.
306
+ * When each held level started, by slug (ISO, or null when unknown). A group
307
+ * starts when its first tag arrived ("any") or its last one ("all"); a level
308
+ * starts with its earliest passing group, and a level held only because
309
+ * another includes it starts with the earliest of those.
235
310
  */
236
- export function memberSince(member: MemberRecord, levels: LevelConfig[] = []): string | null {
311
+ export function levelStarts(member: MemberRecord, levels: LevelConfig[] = []): Map<string, string | null> {
237
312
  const since = member.tagsSince ?? {};
238
- const starts: string[] = [];
313
+ const heldTags = new Set(member.tags.map((t) => t.toLowerCase()));
314
+ const direct = new Map<string, string | null>();
315
+ for (const level of levels) {
316
+ const starts: string[] = [];
317
+ let passing = false;
318
+ for (const g of groupsOf(level)) {
319
+ if (!groupPasses(g, heldTags)) continue;
320
+ passing = true;
321
+ const dates = g.tags.filter((t) => heldTags.has(t.toLowerCase())).map((t) => since[t.toLowerCase()]).filter((d): d is string => Boolean(d)).sort();
322
+ const pick = g.match === "all" ? dates.at(-1) : dates[0];
323
+ if (pick) starts.push(pick);
324
+ }
325
+ if (passing) direct.set(level.slug, starts.sort()[0] ?? null);
326
+ }
327
+ const out = new Map<string, string | null>();
239
328
  for (const slug of levelsFor(member.tags, levels)) {
240
- const level = levels.find((l) => l.slug === slug);
241
- const dates = (level?.tags ?? []).map((t) => since[t.toLowerCase()]).filter((d): d is string => Boolean(d)).sort();
242
- if (dates.length === 0) continue;
243
- const pick = level?.match === "all" ? dates[dates.length - 1] : dates[0];
244
- if (pick) starts.push(pick);
329
+ const dates = [...direct.entries()].filter(([d]) => closure(d, levels).includes(slug)).map(([, at]) => at).filter((d): d is string => Boolean(d)).sort();
330
+ out.set(slug, dates[0] ?? null);
245
331
  }
246
- starts.sort();
332
+ return out;
333
+ }
334
+
335
+ /**
336
+ * When a member's membership started, for drip: the earliest start among the
337
+ * levels they hold now (see levelStarts), else when they first signed in.
338
+ * Null when neither is known.
339
+ */
340
+ export function memberSince(member: MemberRecord, levels: LevelConfig[] = []): string | null {
341
+ const starts = [...levelStarts(member, levels).values()].filter((d): d is string => Boolean(d)).sort();
247
342
  return starts[0] ?? member.linkedAt ?? null;
248
343
  }
249
344
 
250
345
  export interface HeldLevel {
251
346
  slug: string;
252
347
  name: string;
253
- /** When the level started (see memberSince), ISO, or null when unknown. */
348
+ /** When the level started (see levelStarts), ISO, or null when unknown. */
254
349
  since: string | null;
350
+ /** Set when the member holds it only because a level they hold includes it: that level's slug. */
351
+ via?: string;
255
352
  }
256
353
 
257
354
  export interface PastLevel {
@@ -267,15 +364,18 @@ export interface PastLevel {
267
364
  */
268
365
  export function membership(member: MemberRecord, levels: LevelConfig[] = []): { current: HeldLevel[]; past: PastLevel[] } {
269
366
  const held = levelsFor(member.tags, levels);
367
+ const direct = directLevels(member.tags, levels);
368
+ const starts = levelStarts(member, levels);
270
369
  const current = held.map((slug) => {
271
370
  const level = levels.find((l) => l.slug === slug)!;
272
- return { slug, name: level.name, since: memberSince({ ...member, linkedAt: null }, [level]) };
371
+ const via = direct.includes(slug) ? undefined : direct.find((d) => closure(d, levels).includes(slug));
372
+ return { slug, name: level.name, since: starts.get(slug) ?? null, ...(via ? { via } : {}) };
273
373
  });
274
374
  const pastTags = member.pastTags ?? [];
275
375
  const past: PastLevel[] = [];
276
376
  for (const level of levels) {
277
377
  if (held.includes(level.slug)) continue;
278
- const wanted = new Set(level.tags.map((t) => t.toLowerCase()));
378
+ const wanted = new Set(levelTags(level));
279
379
  const spans = pastTags.filter((p) => wanted.has(p.tag));
280
380
  if (spans.length === 0) continue;
281
381
  const froms = spans.map((p) => p.from).filter((d): d is string => Boolean(d)).sort();
@@ -340,7 +440,7 @@ export function passes(held: string[], required: string[][]): boolean {
340
440
  export function describe(required: Requirement, levels: LevelConfig[] = [], open = "Free", signedIn = "Signed in"): string {
341
441
  if (required === null) return open;
342
442
  const name = (slug: string) => (slug === UNAVAILABLE ? "Members" : (levels.find((l) => l.slug === slug)?.name ?? slug));
343
- const parts = reduce(required).filter((c) => c.length > 0).map((c) => c.map(name).join(" or "));
443
+ const parts = reduce(required, levels).filter((c) => c.length > 0).map((c) => c.map(name).join(" or "));
344
444
  return parts.length ? parts.join(" + ") : signedIn;
345
445
  }
346
446
 
@@ -392,7 +492,7 @@ export async function entryRequirement(
392
492
  const own = entryRuleFor(config, collection, id);
393
493
  if (own && (own.mode === "open" || own.mode === "levels") && !own.openUntil && !own.lockedUntil) return applyEntryRule(null, own);
394
494
  const tree = await termTree(config.rules, collection, terms);
395
- return applyEntryRule(requirement(config.rules, collection, terms, { combine: config.combine, tree }), own);
495
+ return simplify(applyEntryRule(requirement(config.rules, collection, terms, { combine: config.combine, tree }), own), config.levels);
396
496
  }
397
497
 
398
498
  const viewers = new WeakMap<object, Promise<Viewer>>();
package/src/agents.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import { adminConfigured } from "./admin-api.js";
13
- import { applyEntryRule, entryRuleFor, describe as describeRequirement, dripDays, levelsFor, membership, passes, requirement, termTree, type Requirement, type Terms } from "./access.js";
13
+ import { applyEntryRule, entryRuleFor, groupsOf, simplify, describe as describeRequirement, dripDays, levelsFor, membership, passes, requirement, termTree, type Requirement, type Terms } from "./access.js";
14
14
  import { ENV, env, storeDomain, unknownEnvNames, type RedeemIDConfig } from "./config.js";
15
15
  import { featuresFor } from "./features.js";
16
16
  import { bodyClauses } from "./files.js";
@@ -69,7 +69,7 @@ export async function explainAccess(config: RedeemIDConfig, s: Stores, input: Ex
69
69
  const disabled = member?.status === "disabled";
70
70
  const held = disabled ? [] : levelsFor(tags, levels);
71
71
  const tree = await termTree(config.rules, collection, terms);
72
- const required: Requirement = applyEntryRule(requirement(config.rules, collection, terms, { combine: config.combine, tree }), own);
72
+ const required: Requirement = simplify(applyEntryRule(requirement(config.rules, collection, terms, { combine: config.combine, tree }), own), levels);
73
73
  const owned = !disabled && holdsItem(item, tags);
74
74
  const canReadRule = owned || required === null || (signedIn && !disabled && passes(held, required));
75
75
  const name = (slug: string) => (slug === "signed-in" ? "Signed in" : (levels.find((l) => l.slug === slug)?.name ?? slug));
@@ -101,7 +101,7 @@ export async function explainAccess(config: RedeemIDConfig, s: Stores, input: Ex
101
101
  }
102
102
 
103
103
  export function listLevels(config: RedeemIDConfig): Array<Record<string, unknown>> {
104
- return (config.levels ?? []).map((l) => ({ slug: l.slug, name: l.name, tags: l.tags, match: l.match ?? "any", wall: l.wall ?? null, joinUrl: l.joinUrl ?? null }));
104
+ return (config.levels ?? []).map((l) => ({ slug: l.slug, name: l.name, groups: groupsOf(l).map((g) => ({ tags: g.tags, match: g.match ?? "any" })), includes: l.includes ?? [], wall: l.wall ?? null, joinUrl: l.joinUrl ?? null }));
105
105
  }
106
106
 
107
107
  export function getRules(config: RedeemIDConfig): Record<string, unknown> {
@@ -3,4 +3,4 @@
3
3
  * when something is added, the major part when something stable changes or
4
4
  * goes. Separate from the package version.
5
5
  */
6
- export const API_VERSION = "1.2.0";
6
+ export const API_VERSION = "1.3.0";