@redeemid/emdash 0.0.24 → 0.0.25

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/docs/shop.md ADDED
@@ -0,0 +1,65 @@
1
+ # Shop and preferences
2
+
3
+ ## Shop
4
+
5
+ Products, collections, search and the cart come from the Storefront API. Shopify works out prices, discounts, taxes and stock, and runs checkout. EmDash has no Shopify plugin underneath, so these components are the storefront.
6
+
7
+ ```astro
8
+ <!-- layout, once -->
9
+ <Cart drawer />
10
+ <CartDrawer />
11
+ <RedeemIDClient />
12
+ <!-- /shop/ (rendered on demand for filters and pages) -->
13
+ <Collection handle="shop" productHref="/shop/{handle}/" showTitle browse quickAdd />
14
+ <!-- /shop/[handle]/ -->
15
+ <Product handle={Astro.params.handle} heading="h1"><p>No such product.</p></Product>
16
+ <Recommendations handle={Astro.params.handle} productHref="/shop/{handle}/" />
17
+ <!-- /search/ -->
18
+ <ProductSearch productHref="/shop/{handle}/" />
19
+ <!-- /cart/ -->
20
+ <CartView productHref="/shop/{handle}/" />
21
+ <!-- anywhere -->
22
+ <Contribution handle="support" amounts={[5, 10, 25]} />
23
+ ```
24
+
25
+ - `<Product>`: images (with thumbnails), title, price and sale price, description (`html` for Shopify's formatted one), a picker per option (Size, Color…), one-time or subscription when the product has selling plans, quantity, Add to cart and Buy now. On a product's own page (`heading="h1"`) it prints Product structured data.
26
+ - `<Collection>`: up to 50 products as cards linking to `productHref`. `browse` adds Shopify's filters (set up in the Search & Discovery app), sort orders and pages, read from the page URL. `quickAdd` adds Add to cart on cards that need no choice.
27
+ - `<ProductSearch>`: a search box with Shopify's matches, filters, sorting and pages. `boxOnly` gives just the box, for a header, sending searches to `action`.
28
+ - `<Recommendations>`: Shopify's "You may also like" (`intent="related"`) or "Pair it with" (`intent="complementary"`) for a product.
29
+ - `<CartView>`: each line with a quantity to change (0 removes it) and Remove, a discount code field, the totals and Checkout.
30
+ - `<CartDrawer>`: the same cart in a modal dialog that slides in. With `<RedeemIDClient />` on the page, Add to cart opens it in place and `<Cart drawer />` opens it instead of going to checkout.
31
+ - `<Contribution>`: pay what you want. Price a product at 1.00, and the amount the reader picks becomes the quantity.
32
+ - `<Cart>`: the item count, linking to checkout, or to `href` (a cart page), or opening the drawer with `drawer`.
33
+
34
+ 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.
35
+
36
+ **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.
37
+
38
+ **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.
39
+
40
+ **Shoppers' agents.** Shopify runs a Storefront MCP server for every store at `https://<store domain>/api/mcp`, for AI agents that browse, build a cart and check out. Point shopping agents there; RedeemID's own tools are for staff.
41
+
42
+ ## Preferences
43
+
44
+ ```ts
45
+ preferences: [
46
+ { key: "preferences.topics" }, // members can change it
47
+ { key: "preferences.level", access: "view" }, // members only see it
48
+ ],
49
+ ```
50
+
51
+ Define each field in Shopify (**Settings → Custom data → Customers**); the definition sets its name, type and limits. List the ones members get, in the order to show them. Reading and saving use the Admin API app (`REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID` with `read_customers` and `write_customers`), always on the signed-in member's own customer. Fields in the `membership` namespace are never offered, and preferences never open content.
52
+
53
+ `<Preferences>` (or its block) is a form that works without JavaScript. Visitors see its `signed-out` slot.
54
+
55
+ | Shopify type | Value |
56
+ | --- | --- |
57
+ | Single line text | A string; with choices, one of them |
58
+ | Multi-line text | A string |
59
+ | List of single line text | A list of strings; with choices, each one of them |
60
+ | True or false | `true` or `false` |
61
+ | Integer | A whole number |
62
+ | Decimal | A number, as a string |
63
+ | Date | `YYYY-MM-DD` |
64
+
65
+ Every value is checked against the definition first. One refusal saves nothing, and an empty value clears the field. Scripts can `GET` `/_emdash/api/auth/redeemid/preferences` for `{ fields }`, or `POST` JSON `{ values: { "namespace.key": value } }`. A refused save answers `400` with `fields` mapping each key to `unknown_field`, `read_only`, `invalid_type`, `invalid_choice`, `invalid_length` or `invalid_range`. Saves are limited to 10 a minute per member (`429`). From your own code, `readPreferences(config, customerId)` and `savePreferences(config, customerId, values)` in `@redeemid/emdash/preferences` do the same (no rate limit there; `config` comes from `siteConfig()`).
@@ -16,7 +16,7 @@ The examples use a product called "Membership" and the tag `member`. Use your ow
16
16
 
17
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
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).
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](staff.md#membership-dates).
20
20
 
21
21
  ## 2. A refund removes it
22
22
 
@@ -33,13 +33,13 @@ Give the app the `read_customers` and `write_customers` Admin API scopes, plus `
33
33
 
34
34
  Without the client ID, subscribe them to that URL yourself. If the app already sends webhooks to another site, add this URL as extra subscriptions.
35
35
 
36
- The client ID and those scopes also run the staff tools (member resync and tag edits written to Shopify), member preferences, and the reads that keep tags in order (see the README). RedeemID uses the client credentials grant, which works when the app and the store are in the same Shopify organization. For the **Also request erasure in Shopify** button on a member's page, add the optional `write_customer_data_erasure` scope.
36
+ The client ID and those scopes also run the staff tools (member resync and tag edits written to Shopify), member preferences, and the reads that keep tags in order (see [staff tools](staff.md#staff-tools)). RedeemID uses the client credentials grant, which works when the app and the store are in the same Shopify organization. For the **Also request erasure in Shopify** button on a member's page, add the optional `write_customer_data_erasure` scope.
37
37
 
38
- Shopify's compliance topics (`customers/data_request`, `customers/redact`, `shop/redact`) can't be registered by the button: they reach only apps that declare them in the app's configuration, under `[webhooks.privacy_compliance]`. Point all three at the same webhook URL. RedeemID logs each one on the Privacy page and acts on it (see the README's privacy requests).
38
+ Shopify's compliance topics (`customers/data_request`, `customers/redact`, `shop/redact`) can't be registered by the button: they reach only apps that declare them in the app's configuration, under `[webhooks.privacy_compliance]`. Point all three at the same webhook URL. RedeemID logs each one on the Privacy page and acts on it (see [privacy requests](staff.md#privacy-requests)).
39
39
 
40
40
  ## 3. The site
41
41
 
42
- Add the package and config as in the [README](../README.md#quick-start), with `shop` set to the store's domain. A custom customer-account domain works as well as `*.myshopify.com`.
42
+ Add the package and config as in [getting started](getting-started.md#3-configure), with `shop` set to the store's domain. A custom customer-account domain works as well as `*.myshopify.com`.
43
43
 
44
44
  Set the secrets. On Cloudflare Workers:
45
45
 
@@ -65,4 +65,4 @@ On Workers, also add a Cron Trigger to the site's Worker, as EmDash's deployment
65
65
  3. Click **Sign in**, sign in with that customer's email, and you come back to the post, unlocked.
66
66
  4. Add or remove the tag in Shopify; the post opens or locks again once the webhook arrives.
67
67
 
68
- If sign-in sends you back with `?redeemid_notice=…`, the code says why: `not_configured` means the client ID or `shop` is missing, and `sign_in_unavailable` means store discovery failed, usually a wrong `shop`. The README lists [every notice](../README.md#notices).
68
+ If sign-in sends you back with `?redeemid_notice=…`, the code says why: `not_configured` means the client ID or `shop` is missing, and `sign_in_unavailable` means store discovery failed, usually a wrong `shop`. The reference lists [every notice](reference.md#notices).
package/docs/staff.md ADDED
@@ -0,0 +1,81 @@
1
+ # Staff tools, webhooks and privacy
2
+
3
+ ## Staff tools
4
+
5
+ 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**:
6
+
7
+ - **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.
8
+ - **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.
9
+ - **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.
10
+ - **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.
11
+ - **Access test.** A collection, an entry, terms and a reader's tags in; the verdict out.
12
+
13
+ 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.
14
+
15
+ ### Membership dates
16
+
17
+ A subscription app or Shopify Flow can keep these customer metafields, as dates (`YYYY-MM-DD`):
18
+
19
+ | Metafield | Shown |
20
+ | --- | --- |
21
+ | `membership.member_since` | As the start date on the member card, in place of when the tag arrived |
22
+ | `membership.renews_on` | "Renews on ..." |
23
+ | `membership.ends_on` | "Ends on ...", for a pass that doesn't renew |
24
+ | `membership.gift_until` | "Gift membership until ...", while it's in the future |
25
+
26
+ 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](shopify-flow.md)).
27
+
28
+ ## Webhooks, app events and reports
29
+
30
+ Members gain and lose access only when Shopify sends tag changes, so RedeemID shows what arrived and warns when deliveries stop.
31
+
32
+ - **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).
33
+ - **Webhooks → Health.** Once a day RedeemID registers again any subscription Shopify dropped (with the Admin API app), and warns when no webhook has come in for a number of days (3 by default, 0 turns it off), or when deliveries were turned away or failed in the last day. **Check now** runs it at once. Warnings show on the page and the dashboard widget. If you enter an address on the Health tab and the site has an EmDash email provider, they also go by email: once when problems start or change, then once a week while they last.
34
+ - **Webhooks → Setup.** The endpoint, whether signatures can be checked, and the subscriptions.
35
+ - **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.
36
+ - **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.
37
+
38
+ 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.
39
+
40
+ ## AI agents and the command line
41
+
42
+ Staff questions as data, for AI agents and scripts. Every one needs an administrator, reads what the site has synced, and never grants access, edits a rule or returns a secret.
43
+
44
+ | MCP tool | Command | What it answers |
45
+ | --- | --- | --- |
46
+ | `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`. |
47
+ | `list_levels` | `levels` | Every level, its tag groups, the levels it includes, wall text and join link |
48
+ | `get_rules` | `rules` | How rules combine, every rule, the meter, teasers, features and file rules |
49
+ | `get_member` | `member <id or email>` | One member: status, tags, levels with start dates, past levels, features, last sync |
50
+ | `site_health` | `health` | Each setting (set or not, never a value), the last webhook, open privacy requests, ignored text replacements |
51
+ | `level_changes` | `changes --since <date>` | Who joined or left a level since a date |
52
+ | `find_products` | `products <words>` | Search the Shopify catalog: handles, titles, prices, stock |
53
+ | `get_product` | `product <handle>` | One product: options, variants, prices, selling plans |
54
+ | `get_collection` | `collection <handle>` | One collection: its products and the filters Shopify offers |
55
+
56
+ With `agents: { actions: true }`, three actions are offered too: `resync_member` (`resync <customer id>`), `resync_batch` (`resync-batch`, 25 members) and `edit_member_tags` (`tags <customer id> --add a,b --remove c`, marked destructive for agents).
57
+
58
+ **Agents** use EmDash's MCP server at `/_emdash/api/mcp` with an API token that has the `mcp:tools` or `mcp:tools:redeemid` scope. The tools stay off until an administrator turns them on for RedeemID under **Plugins**, and a release that changes the list needs approving again. Names carry the prefix, such as `redeemid__explain_access`.
59
+
60
+ **The command line** signs in the way EmDash's CLI does: `--token`, `EMDASH_TOKEN`, or the token `emdash login` saved. The site comes from `--url` or `EMDASH_URL`. Output is JSON.
61
+
62
+ ```sh
63
+ npx -p @redeemid/emdash redeemid-emdash access posts --id my-story --tags gold-member --url https://example.com
64
+ npx -p @redeemid/emdash redeemid-emdash member jane@example.com
65
+ ```
66
+
67
+ Each command POSTs JSON to a staff-only route under `/_emdash/api/plugins/redeemid/`, named after its tool with hyphens (`explain_access` is `explain-access`). Scripts can call those routes directly.
68
+
69
+ ## Privacy requests
70
+
71
+ 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.
72
+
73
+ | Started by | What happens |
74
+ | --- | --- |
75
+ | A member, with `<YourData>` | **Download my data** returns their record and meter as JSON. **Delete my data** needs a ticked box, erases both, and signs them out. |
76
+ | 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. |
77
+ | Shopify `customers/data_request` | An open request on the **Privacy** page. Press **Export and mark done**, then send the export to the store owner. |
78
+ | Shopify `customers/redact` | The member's record and meter are erased. |
79
+ | 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. |
80
+
81
+ 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redeemid/emdash",
3
- "version": "0.0.24",
3
+ "version": "0.0.25",
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",
@@ -14,6 +14,7 @@
14
14
  ".": "./src/index.ts",
15
15
  "./access": "./src/access.ts",
16
16
  "./search": "./src/search-guard.ts",
17
+ "./cache": "./src/route-cache.ts",
17
18
  "./guard.ts": "./src/guard.ts",
18
19
  "./rate-limit-cloudflare.ts": "./src/rate-limit-cloudflare.ts",
19
20
  "./templates.ts": "./src/templates.ts",
@@ -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.3.0";
6
+ export const API_VERSION = "1.4.0";
@@ -36,7 +36,8 @@
36
36
  */
37
37
  import { PortableText } from "emdash/ui";
38
38
 
39
- import { canRead, entryRequirement, readEntry, viewer, type Terms, type Verdict } from "../access.js";
39
+ import { canRead, entryRequirement, entryRuleFor, readEntry, viewer, type Terms, type Verdict } from "../access.js";
40
+ import { noRouteCache } from "../route-cache.js";
40
41
  import { holdsItem, sections, soldItem, splitAtPaywall, teaser } from "../blocks-names.js";
41
42
  import { siteConfig } from "../site-config.js";
42
43
  import { text } from "../text.js";
@@ -101,6 +102,11 @@ const schema =
101
102
  hasPart: { "@type": "WebPageElement", isAccessibleForFree: false, cssSelector: ".redeemid-locked" },
102
103
  }).replace(/</g, "\\u003c")
103
104
  : null;
105
+ // A body that reads differently for members, or is free only until a date,
106
+ // never goes into the route cache, whatever the page's hint or the site's
107
+ // route rules say.
108
+ const freeUntil = entryRuleFor(config, collection, id)?.openUntil;
109
+ if (lockedEntry || item || (freeUntil && Date.now() < Date.parse(freeUntil))) noRouteCache(Astro);
104
110
  const metered = verdict.reason === "metered" ? (verdict as Verdict) : null;
105
111
  const week = metered?.meter?.period === "week";
106
112
  const left = metered?.meterLeft ?? 0;
@@ -11,6 +11,7 @@
11
11
  * Pass `levels` to lock a part of a page to specific levels instead.
12
12
  */
13
13
  import { canRead, type Terms } from "../access.js";
14
+ import { noRouteCache } from "../route-cache.js";
14
15
  import LockedPanel from "./LockedPanel.astro";
15
16
 
16
17
  interface Props {
@@ -23,6 +24,8 @@ interface Props {
23
24
 
24
25
  const { collection = "", terms, levels, id } = Astro.props;
25
26
  const verdict = await canRead(Astro, { collection, terms, id }, levels ? { levels } : undefined);
27
+ // What shows depends on the reader: never in the route cache.
28
+ if (verdict.reason !== "open") noRouteCache(Astro);
26
29
  ---
27
30
 
28
31
  {verdict.allowed ? (
@@ -12,6 +12,7 @@
12
12
  * membership reads it from the store again.
13
13
  */
14
14
  import { accountUrl, badgeColor, membership, meterUsage, refreshUrl, signInUrl, signOutUrl, viewer } from "../access.js";
15
+ import { noRouteCache } from "../route-cache.js";
15
16
  import { featuresFor } from "../features.js";
16
17
  import { siteConfig } from "../site-config.js";
17
18
  import { text, textParts } from "../text.js";
@@ -24,6 +25,8 @@ interface Props {
24
25
  const config = await siteConfig(Astro.locals);
25
26
  const { title = text(config, "Your membership") } = Astro.props;
26
27
  const v = await viewer(Astro);
28
+ // One member's own details: never in the route cache.
29
+ noRouteCache(Astro);
27
30
  const here = Astro.url.pathname + Astro.url.search;
28
31
  const m = v.member;
29
32
  const disabled = m?.status === "disabled";
@@ -16,6 +16,7 @@
16
16
  * - join: anyone else.
17
17
  */
18
18
  import { closure, directLevels, membership, topLevels, viewer } from "../access.js";
19
+ import { noRouteCache } from "../route-cache.js";
19
20
  import { siteConfig } from "../site-config.js";
20
21
  import { money, plan as loadPlan, storeContext, type Plan } from "../storefront.js";
21
22
  import { text } from "../text.js";
@@ -51,6 +52,8 @@ if (config) {
51
52
  const price = money(p?.price);
52
53
  const here = Astro.url.pathname + Astro.url.search;
53
54
  const v = levels.length > 0 ? await viewer(Astro) : null;
55
+ // Held, upgrade, renew or join depends on the reader: never in the route cache.
56
+ if (levels.length > 0) noRouteCache(Astro);
54
57
  const live = v?.member && v.member.status !== "disabled" ? v.member : null;
55
58
  const direct = live ? directLevels(live.tags, all) : [];
56
59
  const top = topLevels(v?.levels ?? [], all);
@@ -9,6 +9,7 @@
9
9
  * <Preferences title="Your preferences" />
10
10
  */
11
11
  import { viewer } from "../access.js";
12
+ import { noRouteCache } from "../route-cache.js";
12
13
  import { preferencesConfigured, readPreferences, type PreferenceField } from "../preferences.js";
13
14
  import { siteConfig } from "../site-config.js";
14
15
  import { text } from "../text.js";
@@ -23,6 +24,8 @@ interface Props {
23
24
  const config = await siteConfig(Astro.locals);
24
25
  const { title = text(config, "Your preferences"), heading = "h2", label = text(config, "Save preferences") } = Astro.props;
25
26
  const v = await viewer(Astro);
27
+ // One member's own details: never in the route cache.
28
+ noRouteCache(Astro);
26
29
  const m = v.member;
27
30
  let fields: PreferenceField[] = [];
28
31
  let failed = false;
@@ -8,6 +8,7 @@
8
8
  * <YourData />
9
9
  */
10
10
  import { viewer } from "../access.js";
11
+ import { noRouteCache } from "../route-cache.js";
11
12
  import { siteConfig } from "../site-config.js";
12
13
  import { text } from "../text.js";
13
14
 
@@ -19,6 +20,8 @@ interface Props {
19
20
  const config = await siteConfig(Astro.locals);
20
21
  const { title = text(config, "Your data"), heading = "h2" } = Astro.props;
21
22
  const v = await viewer(Astro);
23
+ // One member's own details: never in the route cache.
24
+ noRouteCache(Astro);
22
25
  const route = "/_emdash/api/auth/redeemid/privacy";
23
26
  const here = Astro.url.pathname;
24
27
  const H = heading;
package/src/contract.ts CHANGED
@@ -24,6 +24,7 @@ export const MODULES = {
24
24
  "@redeemid/emdash/events": ["signBody"],
25
25
  "@redeemid/emdash/text": ["STRINGS", "text", "textParts"],
26
26
  "@redeemid/emdash/search": ["readableResults"],
27
+ "@redeemid/emdash/cache": ["cacheEntry", "cachePage", "noRouteCache", "CACHE_TAG"],
27
28
  } as const;
28
29
 
29
30
  /** Public JSON routes under /_emdash/api/auth/redeemid/. */
package/src/guard.ts CHANGED
@@ -17,6 +17,12 @@
17
17
  * text included) could be stored and served to anyone. A later
18
18
  * `Astro.cache.set(hint)` turns caching back on, and the route cache reads its
19
19
  * options only after the page has rendered, so the opt-out runs after `next()`.
20
+ * Pages that called noRouteCache() or cacheEntry() for a gated entry get the
21
+ * same opt-out, for every visitor.
22
+ *
23
+ * A request that saves access settings (the Access page, an entry's Access
24
+ * panel) purges the pages cacheEntry() and cachePage() let in, by their
25
+ * `redeemid` tag, so they follow the new rules.
20
26
  */
21
27
 
22
28
  import { defineMiddleware } from "astro:middleware";
@@ -24,7 +30,9 @@ import { defineMiddleware } from "astro:middleware";
24
30
  import { signInUrl } from "./access.js";
25
31
  import { readCart } from "./cart.js";
26
32
  import { keysFromUrl, pathAccess, refusal } from "./files.js";
33
+ import { keptOut, purgeRouteCache } from "./route-cache.js";
27
34
  import { guardSearch, SEARCH_PATH, SUGGEST_PATH } from "./search-guard.js";
35
+ import { accessWrites } from "./settings.js";
28
36
  import { AFTER_LOGOUT_COOKIE, readAfterLogout } from "./routes/shared.js";
29
37
  import { shareConfig, siteConfig } from "./site-config.js";
30
38
 
@@ -86,7 +94,17 @@ export function keepPrivate(context: { cache?: { set(options: false): void } },
86
94
  }
87
95
  }
88
96
 
97
+ type Next = Parameters<Parameters<typeof defineMiddleware>[0]>[1];
98
+ type Context = Parameters<Parameters<typeof defineMiddleware>[0]>[0];
99
+
89
100
  export const onRequest = defineMiddleware(async (context, next) => {
101
+ const writes = accessWrites();
102
+ const res = await guard(context, next);
103
+ if (accessWrites() !== writes) await purgeRouteCache(context.cache);
104
+ return res;
105
+ });
106
+
107
+ async function guard(context: Context, next: Next): Promise<Response> {
90
108
  const { locals, url } = context;
91
109
  const user = (locals as { user?: { role?: number } | null }).user;
92
110
  const config = await siteConfig(locals);
@@ -125,7 +143,10 @@ export const onRequest = defineMiddleware(async (context, next) => {
125
143
 
126
144
  if (!url.pathname.startsWith("/_emdash")) {
127
145
  const own = Boolean(user) || Boolean(await readCart(context.session as never).catch(() => null));
128
- return own ? keepPrivate(context, await next()) : next();
146
+ if (own) return keepPrivate(context, await next());
147
+ const res = await next();
148
+ if (keptOut(locals)) context.cache?.set(false);
149
+ return res;
129
150
  }
130
151
 
131
152
  // Site search answers visitors and members only with what they may read.
@@ -149,4 +170,4 @@ export const onRequest = defineMiddleware(async (context, next) => {
149
170
  status: 403,
150
171
  headers: { "content-type": "application/json", "cache-control": "private, no-store" },
151
172
  });
152
- });
173
+ }
package/src/plugin.ts CHANGED
@@ -39,7 +39,7 @@ import { AdminError, adminConfigured } from "./admin-api.js";
39
39
  import { cancelShopifyErasure, closeRequest, eraseMember, exportMember, exportRequest, requestShopifyErasure } from "./privacy.js";
40
40
  import { accessTest, bulkResync, BULK_BATCH, exportBlocks, memberDetail, privacyPage, registerWebhooks, resyncOne, staffLink, staffUnlink, tagEdit } from "./staff.js";
41
41
  import { textProblems } from "./text.js";
42
- import { gatedFindings, templateFindings } from "./template-scan.js";
42
+ import { cacheFindings, gatedFindings, templateFindings } from "./template-scan.js";
43
43
  import { setupFacts, setupPage, WEBHOOK } from "./setup.js";
44
44
  import { record } from "./log.js";
45
45
  import { resend } from "./events.js";
@@ -867,7 +867,23 @@ function templateBlocks(config: RedeemIDConfig): unknown[] {
867
867
  const found = templateFindings();
868
868
  if (!found) return [{ type: "banner", variant: "alert", title: "Templates weren't checked", description: "Add redeemidGuard() to the integrations in astro.config.mjs. It gates pages and keeps members out of EmDash's own APIs, and checks the templates." }];
869
869
  const risky = gatedFindings(found, config);
870
- if (!risky.length) return [{ type: "context", text: "Templates: none renders a gated collection's body with EmDash's PortableText. Bodies go through RedeemID's Content." }];
870
+ const caching = cacheFindings(found, config);
871
+ const cacheRows = caching.length
872
+ ? [
873
+ { type: "banner", variant: "alert", title: "A template caches gated pages itself", description: "These templates call Astro.cache.set() on pages of a gated collection. With the route cache on, one reader's copy then reaches everyone, and pages keep old rules after an Access save. Use cacheEntry(Astro, collection, entry, cacheHint) on entry pages and cachePage(Astro, cacheHint) on lists, from @redeemid/emdash/cache." },
874
+ {
875
+ type: "table",
876
+ page_action_id: "template_cache_findings",
877
+ columns: [
878
+ { key: "file", label: "Template" },
879
+ { key: "lines", label: "Line" },
880
+ { key: "collections", label: "Gated collection" },
881
+ ],
882
+ rows: caching.map((f) => ({ file: f.file, lines: (f.cache ?? []).join(", "), collections: f.gated.join(", ") })),
883
+ },
884
+ ]
885
+ : [];
886
+ if (!risky.length) return [{ type: "context", text: "Templates: none renders a gated collection's body with EmDash's PortableText. Bodies go through RedeemID's Content." }, ...cacheRows];
871
887
  const sure = risky.filter((f) => f.gated.length);
872
888
  return [
873
889
  sure.length
@@ -883,6 +899,7 @@ function templateBlocks(config: RedeemIDConfig): unknown[] {
883
899
  ],
884
900
  rows: risky.map((f) => ({ file: f.file, lines: f.lines.join(", "), collections: f.gated.join(", ") || "Not found in the file" })),
885
901
  },
902
+ ...cacheRows,
886
903
  ];
887
904
  }
888
905
 
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Astro's route cache, made safe for gated content.
3
+ *
4
+ * The route cache keys pages by URL and serves a stored page without running
5
+ * any code, middleware included. So a page cached for one reader reaches the
6
+ * next one whoever they are: a visitor's copy (wall included) reaches a
7
+ * member, and a page cached while its entry was free keeps showing all of it
8
+ * after a rule locks it. Use these in page frontmatter instead of calling
9
+ * `Astro.cache.set()` yourself:
10
+ *
11
+ * const { entry: post, cacheHint } = await getEmDashEntry("posts", slug);
12
+ * await cacheEntry(Astro, "posts", post, cacheHint);
13
+ *
14
+ * const { entries, cacheHint } = await getEmDashCollection("posts");
15
+ * cachePage(Astro, cacheHint);
16
+ *
17
+ * cacheEntry caches the page only when it reads the same for everyone: no
18
+ * rule covers the entry, no open window is running out, and the body has no
19
+ * Members only part, single sale, Plan or Preferences block. Otherwise the
20
+ * page stays out of the route cache, even when a layout sets a hint later:
21
+ * redeemidGuard() opts it out again after the page renders.
22
+ *
23
+ * RedeemID's member-aware components (Content for a locked body, Gate, Plan,
24
+ * MemberCard, Preferences, YourData) call noRouteCache() themselves as a
25
+ * safety net, for pages that set hints by hand or sites with route rules.
26
+ *
27
+ * Both tag the page `redeemid`. Saving on the Access page or an entry's
28
+ * Access panel purges that tag, so cached pages follow the new rules.
29
+ *
30
+ * The cache still serves its copy to signed-in members (no code runs on a
31
+ * hit), so keep member-aware chrome on cached pages, such as the header's
32
+ * Account link, in the browser (window.redeemid) or out of cached pages.
33
+ */
34
+
35
+ import { entryRequirement, entryRuleFor, type AstroLike, type Terms } from "./access.js";
36
+ import { ITEM_BLOCK, MEMBERS_BLOCK, PLAN_BLOCK, PREFERENCES_BLOCK } from "./blocks-names.js";
37
+ import { siteConfig } from "./site-config.js";
38
+
39
+ /** The cache tag RedeemID puts on pages it lets into the route cache. */
40
+ export const CACHE_TAG = "redeemid";
41
+
42
+ /** Blocks whose output depends on who reads them. */
43
+ export const MEMBER_BLOCKS: readonly string[] = [MEMBERS_BLOCK, ITEM_BLOCK, PLAN_BLOCK, PREFERENCES_BLOCK];
44
+
45
+ type Hint = { tags?: string[]; maxAge?: number; swr?: number; lastModified?: Date; etag?: string };
46
+ interface RouteCache {
47
+ enabled?: boolean;
48
+ set(options: Hint | false): void;
49
+ }
50
+ type CacheAstro = AstroLike & { cache?: RouteCache };
51
+
52
+ const KEEP_OUT = Symbol.for("redeemid:keep-out-of-route-cache");
53
+
54
+ /** Did page code ask to keep this request out of the route cache? (Read by redeemidGuard.) */
55
+ export function keptOut(locals: unknown): boolean {
56
+ return Boolean(locals && typeof locals === "object" && (locals as Record<symbol, unknown>)[KEEP_OUT]);
57
+ }
58
+
59
+ /**
60
+ * Keep this page out of the route cache, whatever sets a hint later. Its
61
+ * Cache-Control stays as it was: a visitor's copy is the same for every
62
+ * visitor, and redeemidGuard() already makes signed-in pages private.
63
+ */
64
+ export function noRouteCache(astro: CacheAstro): void {
65
+ if (astro.locals && typeof astro.locals === "object") (astro.locals as Record<symbol, unknown>)[KEEP_OUT] = true;
66
+ astro.cache?.set(false);
67
+ }
68
+
69
+ /** Cache a page that shows no gated body (a list, a page RedeemID doesn't touch), tagged so Access saves purge it. */
70
+ export function cachePage(astro: CacheAstro, hint?: Hint | null): void {
71
+ if (keptOut(astro.locals) || !astro.cache?.enabled) return;
72
+ astro.cache.set({ ...(hint ?? {}), tags: [...(hint?.tags ?? []), CACHE_TAG] });
73
+ }
74
+
75
+ /** Does a body hold a block that reads differently for members? Looks at every field of the entry's data. */
76
+ export function hasMemberBlocks(data: Record<string, unknown> | null | undefined): boolean {
77
+ for (const v of Object.values(data ?? {})) {
78
+ if (Array.isArray(v) && v.some((b) => MEMBER_BLOCKS.includes((b as { _type?: string })?._type ?? ""))) return true;
79
+ }
80
+ return false;
81
+ }
82
+
83
+ /**
84
+ * May this entry's page be stored and served to everyone? Only when it reads
85
+ * the same for every visitor, now and until the cache could expire.
86
+ */
87
+ export async function cacheable(
88
+ astro: AstroLike,
89
+ collection: string,
90
+ entry: { id?: string; data?: Record<string, unknown> | null } | null | undefined,
91
+ ): Promise<boolean> {
92
+ if (!entry) return false;
93
+ const config = await siteConfig(astro.locals);
94
+ if (!config) return !hasMemberBlocks(entry.data);
95
+ if (config.unavailable) return false;
96
+ const terms = entry.data?.terms as Terms | undefined;
97
+ if ((await entryRequirement(config, collection, terms, entry.id)) !== null) return false;
98
+ // Free until a date: a stored copy would outlive the window.
99
+ const own = entryRuleFor(config, collection, entry.id);
100
+ if (own?.openUntil && Date.now() < Date.parse(own.openUntil)) return false;
101
+ return !hasMemberBlocks(entry.data);
102
+ }
103
+
104
+ /**
105
+ * Cache an entry's page with its hint when it reads the same for everyone;
106
+ * otherwise keep it out of the route cache. Returns whether it was cached.
107
+ */
108
+ export async function cacheEntry(
109
+ astro: CacheAstro,
110
+ collection: string,
111
+ entry: { id?: string; data?: Record<string, unknown> | null } | null | undefined,
112
+ hint?: Hint | null,
113
+ ): Promise<boolean> {
114
+ if (keptOut(astro.locals)) return false;
115
+ if (!(await cacheable(astro, collection, entry))) {
116
+ noRouteCache(astro);
117
+ return false;
118
+ }
119
+ if (!astro.cache?.enabled) return false;
120
+ cachePage(astro, hint);
121
+ return true;
122
+ }
123
+
124
+ // ------------------------------------------------------------------ purging
125
+
126
+ /** Purge RedeemID's cached pages. Never throws: the save it follows already happened. */
127
+ export async function purgeRouteCache(cache: { enabled?: boolean; invalidate?(o: { tags: string[] }): Promise<unknown> } | undefined): Promise<void> {
128
+ if (!cache?.enabled || !cache.invalidate) return;
129
+ try {
130
+ await cache.invalidate({ tags: [CACHE_TAG] });
131
+ } catch (e) {
132
+ console.error("[redeemid] couldn't purge the route cache:", (e as Error).message);
133
+ }
134
+ }
package/src/settings.ts CHANGED
@@ -159,9 +159,18 @@ export function forgetAccess(): void {
159
159
  held()[HELD] = undefined;
160
160
  }
161
161
 
162
+ const WRITES = Symbol.for("redeemid:access-writes");
163
+
164
+ /** How many times this server has saved access settings. redeemidGuard() purges the route cache when a request moves it. */
165
+ export function accessWrites(): number {
166
+ return (globalThis as unknown as Record<symbol, number | undefined>)[WRITES] ?? 0;
167
+ }
168
+
162
169
  /** Record that the settings changed, so every server reads them again. */
163
170
  export async function touchAccess(store: StorageCollection<AccessRecord>): Promise<void> {
164
171
  forgetAccess();
172
+ const g = globalThis as unknown as Record<symbol, number | undefined>;
173
+ g[WRITES] = (g[WRITES] ?? 0) + 1;
165
174
  await store.put(STAMP_ID, { kind: "stamp", version: crypto.randomUUID() });
166
175
  }
167
176