@redeemid/emdash 0.0.24 → 0.0.26
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 +47 -638
- package/docs/access.md +131 -0
- package/docs/content.md +84 -0
- package/docs/developer-api.md +25 -5
- package/docs/getting-started.md +94 -0
- package/docs/production.md +52 -0
- package/docs/reference.md +128 -0
- package/docs/shop.md +94 -0
- package/docs/shopify-flow.md +1 -1
- package/docs/shopify-setup.md +4 -4
- package/docs/staff.md +81 -0
- package/package.json +11 -2
- package/src/api-version.ts +1 -1
- package/src/catalog.ts +78 -0
- package/src/components/Content.astro +7 -1
- package/src/components/Gate.astro +3 -0
- package/src/components/MemberCard.astro +3 -0
- package/src/components/Plan.astro +3 -0
- package/src/components/Preferences.astro +3 -0
- package/src/components/YourData.astro +3 -0
- package/src/contract.ts +2 -1
- package/src/guard.ts +23 -2
- package/src/plugin.ts +19 -2
- package/src/route-cache.ts +134 -0
- package/src/settings.ts +9 -0
- package/src/template-scan.ts +33 -6
- package/src/version.ts +1 -1
package/docs/shop.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
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
|
+
**Product pages in search.** `<Product heading="h1">` prints Product structured data. Give the page the product's own title and description, and answer 404 for a handle Shopify doesn't have, by reading the product in the page (the same cached read `<Product>` makes):
|
|
41
|
+
|
|
42
|
+
```astro
|
|
43
|
+
---
|
|
44
|
+
// src/pages/shop/[handle].astro
|
|
45
|
+
import { siteConfig } from "@redeemid/emdash/access";
|
|
46
|
+
import { product, storeContext } from "@redeemid/emdash/storefront";
|
|
47
|
+
|
|
48
|
+
const config = await siteConfig(Astro.locals);
|
|
49
|
+
const p = config ? await product(config, Astro.params.handle, undefined, storeContext(config, Astro.request)).catch(() => null) : null;
|
|
50
|
+
if (!p) Astro.response.status = 404;
|
|
51
|
+
const title = p?.seo?.title || p?.title;
|
|
52
|
+
const description = p?.seo?.description || p?.description.slice(0, 160);
|
|
53
|
+
---
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
EmDash's sitemap lists content entries only, so add one for products. `productSitemap()` lists every product the Headless channel sells (up to 5,000), newest change first, linked with the same pattern as the cards:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
// src/pages/sitemap-products.xml.ts
|
|
60
|
+
import { siteConfig } from "@redeemid/emdash/access";
|
|
61
|
+
import { productSitemap } from "@redeemid/emdash/catalog";
|
|
62
|
+
import type { APIRoute } from "astro";
|
|
63
|
+
|
|
64
|
+
export const GET: APIRoute = async ({ locals, url }) => productSitemap(await siteConfig(locals), url, { productHref: "/shop/{handle}/" });
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Then submit it to search engines, or list it in robots.txt. A custom robots.txt in EmDash's SEO settings replaces the generated one, so keep both lines in it: `Sitemap: https://<your site>/sitemap.xml` and `Sitemap: https://<your site>/sitemap-products.xml`. If the store's Online Store channel is open too, search engines find two pages for each product; headless stores usually password-protect the Online Store or redirect it to the site.
|
|
68
|
+
|
|
69
|
+
**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.
|
|
70
|
+
|
|
71
|
+
## Preferences
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
preferences: [
|
|
75
|
+
{ key: "preferences.topics" }, // members can change it
|
|
76
|
+
{ key: "preferences.level", access: "view" }, // members only see it
|
|
77
|
+
],
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
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.
|
|
81
|
+
|
|
82
|
+
`<Preferences>` (or its block) is a form that works without JavaScript. Visitors see its `signed-out` slot.
|
|
83
|
+
|
|
84
|
+
| Shopify type | Value |
|
|
85
|
+
| --- | --- |
|
|
86
|
+
| Single line text | A string; with choices, one of them |
|
|
87
|
+
| Multi-line text | A string |
|
|
88
|
+
| List of single line text | A list of strings; with choices, each one of them |
|
|
89
|
+
| True or false | `true` or `false` |
|
|
90
|
+
| Integer | A whole number |
|
|
91
|
+
| Decimal | A number, as a string |
|
|
92
|
+
| Date | `YYYY-MM-DD` |
|
|
93
|
+
|
|
94
|
+
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()`).
|
package/docs/shopify-flow.md
CHANGED
|
@@ -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](
|
|
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
|
|
package/docs/shopify-setup.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@redeemid/emdash",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "Shopify customer accounts,
|
|
3
|
+
"version": "0.0.26",
|
|
4
|
+
"description": "Shopify commerce for EmDash sites: storefront, cart, checkout, customer accounts, memberships and paywalls. Shopify runs commerce; EmDash runs content.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "Vinny S. Green, LLC",
|
|
@@ -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",
|
|
@@ -38,7 +39,15 @@
|
|
|
38
39
|
],
|
|
39
40
|
"keywords": [
|
|
40
41
|
"emdash",
|
|
42
|
+
"emdash-plugin",
|
|
41
43
|
"shopify",
|
|
44
|
+
"headless-commerce",
|
|
45
|
+
"ecommerce",
|
|
46
|
+
"storefront",
|
|
47
|
+
"cart",
|
|
48
|
+
"checkout",
|
|
49
|
+
"astro",
|
|
50
|
+
"cloudflare-workers",
|
|
42
51
|
"paywall",
|
|
43
52
|
"membership",
|
|
44
53
|
"customer-accounts"
|
package/src/api-version.ts
CHANGED
package/src/catalog.ts
CHANGED
|
@@ -285,3 +285,81 @@ export function browseHref(current: URL, change: Record<string, string | null>):
|
|
|
285
285
|
}
|
|
286
286
|
|
|
287
287
|
export type { Money };
|
|
288
|
+
|
|
289
|
+
const SITEMAP_QUERY = `query SitemapProducts($first: Int!, $after: String) {
|
|
290
|
+
products(first: $first, after: $after, sortKey: UPDATED_AT, reverse: true) {
|
|
291
|
+
pageInfo { hasNextPage endCursor }
|
|
292
|
+
nodes { handle updatedAt }
|
|
293
|
+
}
|
|
294
|
+
}`;
|
|
295
|
+
|
|
296
|
+
/** One product in the sitemap: its handle and when Shopify last changed it. */
|
|
297
|
+
export interface SitemapProduct {
|
|
298
|
+
handle: string;
|
|
299
|
+
updatedAt: string;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** The most products one sitemap lists (20 Storefront API pages of 250). */
|
|
303
|
+
export const SITEMAP_LIMIT = 5000;
|
|
304
|
+
|
|
305
|
+
function escapeXml(s: string): string {
|
|
306
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Every product the site's sales channel can sell, newest change first, up
|
|
311
|
+
* to SITEMAP_LIMIT. Cached for five minutes.
|
|
312
|
+
*/
|
|
313
|
+
export async function sitemapProducts(config: RedeemIDConfig, opts: { fetcher?: typeof fetch } = {}): Promise<SitemapProduct[]> {
|
|
314
|
+
return cachedRead(`${shopOrigin(config)}|sitemap`, async () => {
|
|
315
|
+
const out: SitemapProduct[] = [];
|
|
316
|
+
let after: string | null = null;
|
|
317
|
+
while (out.length < SITEMAP_LIMIT) {
|
|
318
|
+
type Data = { products: { pageInfo: { hasNextPage: boolean; endCursor: string | null }; nodes: SitemapProduct[] } };
|
|
319
|
+
const data: Data = await storefront<Data>(config, SITEMAP_QUERY, { first: 250, after }, opts);
|
|
320
|
+
out.push(...data.products.nodes.filter((n) => typeof n.handle === "string" && n.handle));
|
|
321
|
+
if (!data.products.pageInfo.hasNextPage || !data.products.pageInfo.endCursor) break;
|
|
322
|
+
after = data.products.pageInfo.endCursor;
|
|
323
|
+
}
|
|
324
|
+
return out.slice(0, SITEMAP_LIMIT);
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* A sitemap of the site's product pages, for search engines. EmDash's own
|
|
330
|
+
* sitemap lists content entries only, so products need this one:
|
|
331
|
+
*
|
|
332
|
+
* // src/pages/sitemap-products.xml.ts
|
|
333
|
+
* export const GET: APIRoute = async ({ locals, url }) =>
|
|
334
|
+
* productSitemap(await siteConfig(locals), url, { productHref: "/shop/{handle}/" });
|
|
335
|
+
*
|
|
336
|
+
* `productHref` is the same pattern the product cards link with. Answers an
|
|
337
|
+
* empty sitemap (not an error) when the store can't be read, so a crawler
|
|
338
|
+
* never caches a broken page.
|
|
339
|
+
*/
|
|
340
|
+
export async function productSitemap(
|
|
341
|
+
config: RedeemIDConfig | null,
|
|
342
|
+
url: URL,
|
|
343
|
+
opts: { productHref: string; fetcher?: typeof fetch },
|
|
344
|
+
): Promise<Response> {
|
|
345
|
+
let products: SitemapProduct[] = [];
|
|
346
|
+
if (config) {
|
|
347
|
+
try {
|
|
348
|
+
products = await sitemapProducts(config, { fetcher: opts.fetcher });
|
|
349
|
+
} catch {
|
|
350
|
+
products = [];
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const lines = ['<?xml version="1.0" encoding="UTF-8"?>', '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'];
|
|
354
|
+
for (const p of products) {
|
|
355
|
+
const loc = new URL(opts.productHref.replace("{handle}", encodeURIComponent(p.handle)), url).href;
|
|
356
|
+
lines.push(" <url>", ` <loc>${escapeXml(loc)}</loc>`);
|
|
357
|
+
if (p.updatedAt) lines.push(` <lastmod>${escapeXml(p.updatedAt)}</lastmod>`);
|
|
358
|
+
lines.push(" </url>");
|
|
359
|
+
}
|
|
360
|
+
lines.push("</urlset>");
|
|
361
|
+
return new Response(lines.join("\n"), {
|
|
362
|
+
status: 200,
|
|
363
|
+
headers: { "Content-Type": "application/xml; charset=utf-8", "Cache-Control": "public, max-age=3600" },
|
|
364
|
+
});
|
|
365
|
+
}
|
|
@@ -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
|
@@ -20,10 +20,11 @@ export const MODULES = {
|
|
|
20
20
|
"@redeemid/emdash/features": ["hasFeature", "viewerFeatures", "featuresFor"],
|
|
21
21
|
"@redeemid/emdash/preferences": ["readPreferences", "savePreferences", "preferenceFields"],
|
|
22
22
|
"@redeemid/emdash/storefront": ["plan", "product", "collection", "money", "realOptions", "variantFor", "storeContext", "cartDetails", "addToCart", "updateLines", "removeLines", "setDiscountCodes"],
|
|
23
|
-
"@redeemid/emdash/catalog": ["searchProducts", "collectionPage", "suggest", "recommendations", "browseFromUrl", "browseHref", "parseFilters", "priceFilter", "SORTS"],
|
|
23
|
+
"@redeemid/emdash/catalog": ["searchProducts", "collectionPage", "suggest", "recommendations", "browseFromUrl", "browseHref", "parseFilters", "priceFilter", "SORTS", "productSitemap"],
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|