@redeemid/emdash 0.0.2 → 0.0.3

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  Shopify customer accounts, member levels and paywalls for [EmDash](https://github.com/emdash-cms/emdash) sites. Shopify runs the store and the customer account; EmDash runs the content.
4
4
 
5
- **Status: 0.0.2, early.** Runs on Node and Cloudflare Workers. Sign-in, the member mirror, webhooks, levels, rules, the page gate and plan checkout are covered by an end-to-end test against a fake store.
5
+ **Status: 0.0.3, early.** Runs on Node and Cloudflare Workers. Sign-in, the member mirror, webhooks, levels, rules, the page gate, the Shopify cart and checkout are covered by an end-to-end test against a fake store.
6
6
 
7
7
  ## What it does
8
8
 
@@ -10,8 +10,11 @@ Shopify customer accounts, member levels and paywalls for [EmDash](https://githu
10
10
  - **Mirrors the customer.** Email, name, tags and enabled/disabled status, kept current by signed Shopify webhooks.
11
11
  - **Levels from tags.** You map Shopify customer tags to levels. Set tags in Shopify Flow or any app; RedeemID only reads them.
12
12
  - **Rules per collection or term.** Lock a collection, or only entries in a category or tag.
13
- - **Plans and checkout.** `<Plan>` shows a Shopify product's price and starts checkout through the Storefront API.
14
- - **A page gate.** `<Gate>` shows the entry to readers who may read it and a teaser plus "Sign in" to everyone else.
13
+ - **Plans, cart and checkout.** `<Plan>` shows a Shopify product's price and adds it to the visitor's Shopify cart; `<Cart>` shows the count and opens Shopify checkout.
14
+ - **Account.** Members get an Account link to their Shopify customer account (orders, profile, addresses) and Sign out, which also ends the Shopify session.
15
+ - **Paywall break block.** Editors write one body and drop a "Paywall break" block where members-only content starts, picking which levels may read on. `<Content>` renders the body and leaves the rest off the page for everyone else.
16
+ - **Plan block.** Editors add a Shopify product to any page with the "Plan" block: title, price and a buy button.
17
+ - **In the admin.** RedeemID shows under Plugins with a Members page (Shopify customers, tags, levels), a Status page (secrets, store discovery, the URLs to give Shopify, levels and rules) and a dashboard widget.
15
18
  - **Keeps members out of EmDash's own APIs**, so the gate is the only way to read locked content.
16
19
 
17
20
  ## Install
@@ -23,28 +26,29 @@ pnpm add @redeemid/emdash
23
26
  ```
24
27
 
25
28
  ```js title="astro.config.mjs"
26
- import { redeemid, redeemidGuard } from "@redeemid/emdash";
29
+ import { redeemid, redeemidGuard, redeemidPlugin } from "@redeemid/emdash";
27
30
  import emdash from "emdash/astro";
28
31
 
32
+ const config = {
33
+ shop: "my-store.myshopify.com",
34
+ levels: [
35
+ { slug: "member", name: "Member", tags: ["member"] },
36
+ { slug: "pro", name: "Pro", tags: ["pro"] },
37
+ // match: "all" needs every tag, e.g. both memberships:
38
+ { slug: "both", name: "Member + Pro", tags: ["member", "pro"], match: "all" },
39
+ ],
40
+ rules: [
41
+ { collection: "posts", taxonomy: "category", term: "members", levels: ["member", "pro"] },
42
+ { collection: "posts", taxonomy: "category", term: "pro", levels: ["pro"] },
43
+ ],
44
+ };
45
+
29
46
  export default defineConfig({
30
47
  integrations: [
31
48
  emdash({
32
49
  // database, storage, …
33
- authProviders: [
34
- redeemid({
35
- shop: "my-store.myshopify.com",
36
- levels: [
37
- { slug: "member", name: "Member", tags: ["member"] },
38
- { slug: "pro", name: "Pro", tags: ["pro"] },
39
- // match: "all" needs every tag, e.g. both memberships:
40
- { slug: "both", name: "Member + Pro", tags: ["member", "pro"], match: "all" },
41
- ],
42
- rules: [
43
- { collection: "posts", taxonomy: "category", term: "members", levels: ["member", "pro"] },
44
- { collection: "posts", taxonomy: "category", term: "pro", levels: ["pro"] },
45
- ],
46
- }),
47
- ],
50
+ authProviders: [redeemid(config)], // sign-in, webhooks, cart and checkout routes
51
+ plugins: [redeemidPlugin(config)], // admin pages and the editor blocks
48
52
  }),
49
53
  redeemidGuard(),
50
54
  ],
@@ -74,28 +78,33 @@ Point webhooks (`customers/create`, `customers/update`, `customers/delete`, `cus
74
78
 
75
79
  ## Use it in templates
76
80
 
77
- EmDash has no content filter hook, so templates decide what to render. Wrap the entry body:
81
+ EmDash has no content filter hook, so templates decide what to render. Render the entry body with `<Content>` instead of `<PortableText>`:
78
82
 
79
83
  ```astro
80
84
  ---
81
- import Gate from "@redeemid/emdash/components/Gate.astro";
85
+ import Content from "@redeemid/emdash/components/Content.astro";
82
86
  ---
83
- <Gate collection="posts" terms={post.data.terms}>
84
- <PortableText value={post.data.content} />
85
- <p slot="locked">{post.data.excerpt}</p>
86
- </Gate>
87
+ <Content value={post.data.content} collection="posts" terms={post.data.terms}>
88
+ <a slot="locked" href="/join/">Become a member</a>
89
+ </Content>
87
90
  ```
88
91
 
89
- Add the account box to your header:
92
+ Everything before the first Paywall break block is shown to everyone. What follows reaches the page only for readers the break allows: the levels picked on it, or when none are picked the entry's rule, or else any signed-in member. A body with no break follows the entry's rule as a whole. Plain `<PortableText>` ignores the break and shows everything, so use `<Content>` for any body that may hold one.
93
+
94
+ To lock a part of a template that isn't the body, wrap it in `<Gate>` (`<Gate levels={["pro"]}>…</Gate>`).
95
+
96
+ Add the account box and cart to your header. Visitors see Sign in; members see their name, levels, Account and Sign out. The cart shows how many items are in the visitor's Shopify cart and goes to its checkout:
90
97
 
91
98
  ```astro
92
99
  ---
93
100
  import Account from "@redeemid/emdash/components/Account.astro";
101
+ import Cart from "@redeemid/emdash/components/Cart.astro";
94
102
  ---
95
103
  <Account />
104
+ <Cart />
96
105
  ```
97
106
 
98
- Sell a plan. `<Plan>` shows a Shopify product's title and price with a button that starts checkout (signed-in members check out with their email). Shopify Flow or an app tags the customer after the order, and access follows that tag:
107
+ Sell a plan. `<Plan>` shows a Shopify product's title and price with a button that adds it to the visitor's Shopify cart and opens checkout (signed-in members check out with their email). Shopify Flow or an app tags the customer after the order, and access follows that tag:
99
108
 
100
109
  ```astro
101
110
  ---
@@ -53,4 +53,4 @@ The Workers free plan is enough: RedeemID is a native plugin and doesn't need sa
53
53
  3. Click **Sign in**, sign in with that customer's email, and you come back to the post, unlocked.
54
54
  4. Add or remove the tag in Shopify; the post opens or locks again once the webhook arrives.
55
55
 
56
- If sign-in sends you back with `?redeemid_notice=…`, the code says why: `not_configured` (secrets or `shop` missing), `sign_in_unavailable` (store discovery failed), `sign_in_expired`, `staff_account`, `site_not_ready`, `email_required`, `token_failed`, `bad_id_token`, `identity_failed`. A Buy button can return `plan_unavailable` (no such product, or sold out) or `checkout_failed`.
56
+ If sign-in sends you back with `?redeemid_notice=…`, the code says why: `not_configured` (secrets or `shop` missing), `sign_in_unavailable` (store discovery failed), `sign_in_expired`, `staff_account`, `site_not_ready`, `email_required`, `token_failed`, `bad_id_token`, `identity_failed`. A Buy button can return `plan_unavailable` (no such product, or sold out) or `checkout_failed`. The Cart link returns `cart_empty` when there's nothing in the cart, and Account returns `account_unavailable` when store discovery fails.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redeemid/emdash",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "Shopify customer accounts, member levels and paywalls for EmDash sites. Shopify runs commerce; EmDash runs content.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -15,6 +15,8 @@
15
15
  "./access": "./src/access.ts",
16
16
  "./guard.ts": "./src/guard.ts",
17
17
  "./storefront": "./src/storefront.ts",
18
+ "./plugin": "./src/plugin.ts",
19
+ "./blocks": "./src/blocks/index.ts",
18
20
  "./components/*": "./src/components/*",
19
21
  "./routes/*": "./src/routes/*"
20
22
  },
package/src/access.ts CHANGED
@@ -119,6 +119,16 @@ export function signInUrl(returnTo: string): string {
119
119
  return `/_emdash/api/auth/redeemid/login?return=${encodeURIComponent(safeReturn(returnTo))}`;
120
120
  }
121
121
 
122
+ /** Where the Cart link goes: the visitor's Shopify checkout. */
123
+ export function cartUrl(returnTo = "/"): string {
124
+ return `/_emdash/api/auth/redeemid/cart?return=${encodeURIComponent(safeReturn(returnTo))}`;
125
+ }
126
+
127
+ /** Where the Account link goes: the member's Shopify customer account. */
128
+ export function accountUrl(returnTo = "/"): string {
129
+ return `/_emdash/api/auth/redeemid/account?return=${encodeURIComponent(safeReturn(returnTo))}`;
130
+ }
131
+
122
132
  export function signOutUrl(returnTo = "/"): string {
123
133
  return `/_emdash/api/auth/redeemid/logout?return=${encodeURIComponent(safeReturn(returnTo))}`;
124
134
  }
@@ -0,0 +1,4 @@
1
+ declare module "*.astro" {
2
+ const Component: (props: Record<string, unknown>) => unknown;
3
+ export default Component;
4
+ }
@@ -0,0 +1,6 @@
1
+ ---
2
+ /**
3
+ * The paywall break itself renders nothing. <Content> splits the body here
4
+ * and leaves out what follows for readers who can't read it.
5
+ */
6
+ ---
@@ -0,0 +1,13 @@
1
+ ---
2
+ /** The "Plan" block: a Shopify product with its price and a buy button. */
3
+ import Plan from "../components/Plan.astro";
4
+
5
+ interface Props {
6
+ node: { handle?: string; title?: string; label?: string };
7
+ }
8
+
9
+ const { node } = Astro.props;
10
+ const handle = (node.handle ?? "").trim();
11
+ ---
12
+
13
+ {handle && <Plan handle={handle} title={node.title?.trim() || undefined} label={node.label?.trim() || "Join"} />}
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Site-side renderers for RedeemID's Portable Text blocks, picked up by
3
+ * EmDash from the plugin's `componentsEntry`.
4
+ */
5
+ import PaywallBreak from "./PaywallBreak.astro";
6
+ import PlanBlock from "./PlanBlock.astro";
7
+
8
+ export const blockComponents = {
9
+ "redeemid-paywall": PaywallBreak,
10
+ "redeemid-plan": PlanBlock,
11
+ };
@@ -0,0 +1,24 @@
1
+ /** Portable Text block types RedeemID adds to the editor. */
2
+ export const PAYWALL_BLOCK = "redeemid-paywall";
3
+ export const PLAN_BLOCK = "redeemid-plan";
4
+
5
+ type Node = { _type?: unknown; levels?: unknown };
6
+
7
+ /**
8
+ * Split a Portable Text body at its first paywall break: the open part, the
9
+ * members part, and the levels picked on the break (empty = none picked).
10
+ * With no break, everything is in `open` and `paywall` is false.
11
+ */
12
+ export function splitAtPaywall<T>(value: T[] | null | undefined): {
13
+ open: T[];
14
+ locked: T[];
15
+ levels: string[];
16
+ paywall: boolean;
17
+ } {
18
+ const blocks = Array.isArray(value) ? value : [];
19
+ const at = blocks.findIndex((b) => (b as Node)?._type === PAYWALL_BLOCK);
20
+ if (at === -1) return { open: blocks, locked: [], levels: [], paywall: false };
21
+ const raw = (blocks[at] as Node).levels;
22
+ const levels = Array.isArray(raw) ? raw.filter((l): l is string => typeof l === "string" && l !== "") : [];
23
+ return { open: blocks.slice(0, at), locked: blocks.slice(at + 1), levels, paywall: true };
24
+ }
package/src/cart.ts ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The visitor's Shopify cart, kept in the Astro session. Only the cart id
3
+ * and its item count live here; Shopify holds the lines and the checkout.
4
+ */
5
+
6
+ export const SESSION_CART = "redeemid:cart";
7
+
8
+ export interface CartRef {
9
+ id: string;
10
+ count: number;
11
+ }
12
+
13
+ type Session = { get(key: string): unknown; set(key: string, value: unknown): void; delete?(key: string): void } | undefined;
14
+
15
+ export async function readCart(session: Session): Promise<CartRef | null> {
16
+ const v = (await session?.get(SESSION_CART)) as CartRef | undefined;
17
+ return v && typeof v.id === "string" ? v : null;
18
+ }
19
+
20
+ export function saveCart(session: Session, cart: CartRef | null): void {
21
+ if (!session) return;
22
+ if (cart) session.set(SESSION_CART, cart);
23
+ else if (session.delete) session.delete(SESSION_CART);
24
+ else session.set(SESSION_CART, undefined);
25
+ }
@@ -1,9 +1,9 @@
1
1
  ---
2
2
  /**
3
- * Sign in link for visitors; name, levels and Sign out for members.
3
+ * Sign in link for visitors; name, levels, Account and Sign out for members.
4
4
  */
5
5
  import { siteConfig } from "../site-config.js";
6
- import { signInUrl, signOutUrl, viewer } from "../access.js";
6
+ import { accountUrl, signInUrl, signOutUrl, viewer } from "../access.js";
7
7
 
8
8
  const v = await viewer(Astro);
9
9
  const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
@@ -16,6 +16,7 @@ const names = (config?.levels ?? []).filter((l) => v.levels.includes(l.slug)).ma
16
16
  <>
17
17
  <span class="redeemid-account__who">{v.member?.firstName ?? v.member?.email ?? "Signed in"}</span>
18
18
  {names.length > 0 && <span class="redeemid-account__levels">{names.join(", ")}</span>}
19
+ <a class="redeemid-account__link" href={accountUrl(here)}>Account</a>
19
20
  <form method="post" action={signOutUrl(here)}>
20
21
  <button type="submit">Sign out</button>
21
22
  </form>
@@ -0,0 +1,21 @@
1
+ ---
2
+ /**
3
+ * Cart link with the item count from the visitor's Shopify cart. It goes
4
+ * to that cart's Shopify checkout.
5
+ */
6
+ import { cartUrl } from "../access.js";
7
+ import { readCart } from "../cart.js";
8
+
9
+ interface Props {
10
+ label?: string;
11
+ }
12
+
13
+ const { label = "Cart" } = Astro.props;
14
+ const ref = await readCart(Astro.session);
15
+ const count = ref?.count ?? 0;
16
+ const here = Astro.url.pathname + Astro.url.search;
17
+ ---
18
+
19
+ <a class="redeemid-cart" href={cartUrl(here)} data-redeemid-cart-count={count}>
20
+ {label}{count > 0 && <span class="redeemid-cart__count"> ({count})</span>}
21
+ </a>
@@ -0,0 +1,53 @@
1
+ ---
2
+ /**
3
+ * An entry's body, with RedeemID's paywall break applied.
4
+ *
5
+ * <Content value={post.data.content} collection="posts" terms={post.data.terms} />
6
+ *
7
+ * Everything before the first "Paywall break" block is shown to everyone.
8
+ * What follows reaches the page only for readers who may read it: the levels
9
+ * picked on the break, or when none are picked the entry's rule, or else any
10
+ * signed-in member. Others get the "locked" slot (or a built-in notice).
11
+ * A body with no break follows the entry's rule as a whole.
12
+ */
13
+ import { PortableText } from "emdash/ui";
14
+
15
+ import { canRead, requiredLevels, signInUrl, type Terms } from "../access.js";
16
+ import { splitAtPaywall } from "../blocks-names.js";
17
+ import { siteConfig } from "../site-config.js";
18
+
19
+ interface Props {
20
+ value?: unknown[] | null;
21
+ collection?: string;
22
+ terms?: Terms;
23
+ }
24
+
25
+ const { value, collection = "", terms } = Astro.props;
26
+ const split = splitAtPaywall(value);
27
+ const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
28
+ const levels = split.paywall
29
+ ? split.levels.length
30
+ ? split.levels
31
+ : (requiredLevels(config?.rules, collection, terms) ?? [])
32
+ : undefined;
33
+ const verdict = await canRead(Astro, { collection, terms }, levels ? { levels } : undefined);
34
+ const shown = split.paywall ? split.open : verdict.allowed ? split.open : [];
35
+ const here = Astro.url.pathname + Astro.url.search;
36
+ ---
37
+
38
+ {shown.length > 0 && <PortableText value={shown} />}
39
+ {verdict.allowed ? (
40
+ split.locked.length > 0 && <PortableText value={split.locked} />
41
+ ) : (
42
+ <div class="redeemid-locked" data-redeemid-reason={verdict.reason}>
43
+ <slot name="locked" />
44
+ {verdict.reason === "sign_in" && (
45
+ <p>
46
+ This is for members. <a href={signInUrl(here)}>Sign in</a> to read on.
47
+ </p>
48
+ )}
49
+ {verdict.reason === "upgrade" && <p>Your membership doesn't include the rest of this yet.</p>}
50
+ {verdict.reason === "disabled" && <p>Your account is turned off. Contact the store for help.</p>}
51
+ {verdict.reason === "unconfigured" && <p>This is for members.</p>}
52
+ </div>
53
+ )}
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The RedeemID plugin descriptor, safe to load from astro.config.mjs (no
3
+ * runtime imports). The plugin itself is in plugin.ts.
4
+ */
5
+
6
+ import type { PluginDescriptor } from "emdash";
7
+
8
+ import type { RedeemIDConfig } from "./config.js";
9
+ import { VERSION } from "./version.js";
10
+
11
+ export const PAGES = [
12
+ { path: "/", label: "Members", icon: "users" },
13
+ { path: "/status", label: "Status", icon: "pulse" },
14
+ ];
15
+ export const WIDGETS = [{ id: "status", title: "RedeemID", size: "half" as const }];
16
+
17
+ /** The descriptor for `emdash({ plugins: [...] })`. Pass the same config as `redeemid()`. */
18
+ export function redeemidPlugin(config: RedeemIDConfig): PluginDescriptor<RedeemIDConfig> {
19
+ return {
20
+ id: "redeemid",
21
+ version: VERSION,
22
+ entrypoint: "@redeemid/emdash/plugin",
23
+ componentsEntry: "@redeemid/emdash/blocks",
24
+ options: config,
25
+ adminPages: PAGES,
26
+ adminWidgets: WIDGETS,
27
+ };
28
+ }
29
+
package/src/index.ts CHANGED
@@ -36,6 +36,7 @@ import { STORAGE_CONFIG } from "./storage.js";
36
36
 
37
37
  export type { LevelConfig, RedeemIDConfig, RuleConfig } from "./config.js";
38
38
  export type { MemberRecord } from "./storage.js";
39
+ export { redeemidPlugin } from "./descriptor.js";
39
40
  // Page-time helpers live in "@redeemid/emdash/access": they read the site's
40
41
  // build-time config module, which astro.config.mjs can't load.
41
42
 
@@ -54,6 +55,8 @@ export function redeemid(config: RedeemIDConfig): AuthProviderDescriptor {
54
55
  { pattern: `${BASE}/logout`, entrypoint: "@redeemid/emdash/routes/logout.ts" },
55
56
  { pattern: `${BASE}/webhook`, entrypoint: "@redeemid/emdash/routes/webhook.ts" },
56
57
  { pattern: `${BASE}/buy`, entrypoint: "@redeemid/emdash/routes/buy.ts" },
58
+ { pattern: `${BASE}/cart`, entrypoint: "@redeemid/emdash/routes/cart.ts" },
59
+ { pattern: `${BASE}/account`, entrypoint: "@redeemid/emdash/routes/account.ts" },
57
60
  ],
58
61
  publicRoutes: [`${BASE}/`],
59
62
  storage: STORAGE_CONFIG,
package/src/plugin.ts ADDED
@@ -0,0 +1,283 @@
1
+ /**
2
+ * RedeemID as an EmDash plugin: what editors and admins see.
3
+ *
4
+ * emdash({
5
+ * authProviders: [redeemid(config)],
6
+ * plugins: [redeemidPlugin(config)],
7
+ * })
8
+ *
9
+ * It adds two Portable Text blocks to the editor, a Members page, a Status
10
+ * page and a dashboard widget. Sign-in, webhooks and checkout stay on the
11
+ * auth provider's routes.
12
+ *
13
+ * - "Paywall break" (`redeemid-paywall`): everything after it in the body is
14
+ * for members. Pick the levels that may read on; none picked means any
15
+ * signed-in member, or the entry's rule when one covers it.
16
+ * - "Plan" (`redeemid-plan`): a Shopify product's title and price with a
17
+ * button that adds it to the cart and opens checkout.
18
+ *
19
+ * Render bodies with `<Content>` (components/Content.astro), which keeps the
20
+ * part after a paywall break off the page for readers who can't read it.
21
+ */
22
+
23
+ import type { ResolvedPlugin, RouteContext } from "emdash";
24
+ import { definePlugin } from "emdash";
25
+
26
+ import { levelsFor } from "./access.js";
27
+ import { env, ENV, storeDomain, type RedeemIDConfig } from "./config.js";
28
+ import { discover } from "./shopify.js";
29
+ import { stores, type MemberRecord } from "./storage.js";
30
+ import { PAGES, WIDGETS } from "./descriptor.js";
31
+ import { VERSION } from "./version.js";
32
+
33
+ import { PAYWALL_BLOCK, PLAN_BLOCK } from "./blocks-names.js";
34
+
35
+ export { PAYWALL_BLOCK, PLAN_BLOCK };
36
+
37
+ function blocks(config: RedeemIDConfig) {
38
+ const levelOptions = (config.levels ?? []).map((l) => ({ label: l.name, value: l.slug }));
39
+ return [
40
+ {
41
+ type: PAYWALL_BLOCK,
42
+ label: "Paywall break",
43
+ icon: "lock",
44
+ description: "Everything after this is for members",
45
+ fields: [
46
+ {
47
+ type: "checkbox" as const,
48
+ action_id: "levels",
49
+ label: "Who can read on (none = any signed-in member)",
50
+ options: levelOptions,
51
+ },
52
+ ],
53
+ },
54
+ {
55
+ type: PLAN_BLOCK,
56
+ label: "Plan",
57
+ icon: "tag",
58
+ description: "A Shopify product with its price and a buy button",
59
+ placeholder: "Shopify product handle",
60
+ fields: [
61
+ { type: "text_input" as const, action_id: "handle", label: "Shopify product handle", placeholder: "gold-membership" },
62
+ { type: "text_input" as const, action_id: "title", label: "Title (optional, Shopify's title otherwise)" },
63
+ { type: "text_input" as const, action_id: "label", label: "Button label", placeholder: "Join" },
64
+ ],
65
+ },
66
+ ];
67
+ }
68
+
69
+ export function createPlugin(config: RedeemIDConfig = {}): ResolvedPlugin {
70
+ return definePlugin({
71
+ id: "redeemid",
72
+ version: VERSION,
73
+ capabilities: [],
74
+ admin: {
75
+ pages: PAGES,
76
+ widgets: WIDGETS,
77
+ portableTextBlocks: blocks(config),
78
+ },
79
+ routes: {
80
+ admin: {
81
+ handler: async (ctx: RouteContext) => {
82
+ const i = (ctx.input ?? {}) as { type?: string; page?: string };
83
+ if (i.type !== "page_load") return { blocks: [] };
84
+ try {
85
+ if (i.page === "/") return await membersPage(config);
86
+ if (i.page === "/status") return await statusPage(config, ctx.request);
87
+ if (i.page === "widget:status") return await statusWidget(config);
88
+ } catch (e) {
89
+ return { blocks: [{ type: "banner", variant: "error", title: "RedeemID couldn't load this page", description: (e as Error).message }] };
90
+ }
91
+ return { blocks: [] };
92
+ },
93
+ },
94
+ },
95
+ });
96
+ }
97
+
98
+ export default createPlugin;
99
+
100
+ // ---------------------------------------------------------------- pages
101
+
102
+ async function memberStores() {
103
+ const { getDb } = await import("emdash/runtime");
104
+ return stores(await getDb());
105
+ }
106
+
107
+ async function allMembers(): Promise<MemberRecord[]> {
108
+ const s = await memberStores();
109
+ const out: MemberRecord[] = [];
110
+ let cursor: string | undefined;
111
+ do {
112
+ const page = await s.members.query({ limit: 100, cursor });
113
+ out.push(...page.items.map((r) => r.data));
114
+ cursor = page.cursor;
115
+ } while (cursor && out.length < 1000);
116
+ return out;
117
+ }
118
+
119
+ async function membersPage(config: RedeemIDConfig) {
120
+ const members = await allMembers();
121
+ const levels = config.levels ?? [];
122
+ const counts = levels.map((l) => ({
123
+ label: l.name,
124
+ value: String(members.filter((m) => m.status === "enabled" && levelsFor(m.tags, levels).includes(l.slug)).length),
125
+ }));
126
+ const name = (m: MemberRecord) => [m.firstName, m.lastName].filter(Boolean).join(" ") || "—";
127
+ const levelNames = (m: MemberRecord) => {
128
+ const held = levelsFor(m.tags, levels);
129
+ return levels.filter((l) => held.includes(l.slug)).map((l) => l.name).join(", ") || "—";
130
+ };
131
+ return {
132
+ blocks: [
133
+ { type: "header", text: "Members" },
134
+ {
135
+ type: "context",
136
+ text: "Shopify customers who have signed in or that Shopify has sent. Levels come only from their Shopify tags; change tags in Shopify (or with Flow) and access follows.",
137
+ },
138
+ {
139
+ type: "stats",
140
+ items: [
141
+ { label: "Members", value: String(members.length) },
142
+ { label: "Turned off", value: String(members.filter((m) => m.status === "disabled").length) },
143
+ ...counts,
144
+ ],
145
+ },
146
+ members.length === 0
147
+ ? { type: "context", text: "No members yet. They appear here after their first sign-in or a Shopify customer webhook." }
148
+ : {
149
+ type: "table",
150
+ page_action_id: "members_page",
151
+ columns: [
152
+ { key: "name", label: "Name" },
153
+ { key: "email", label: "Email" },
154
+ { key: "levels", label: "Levels" },
155
+ { key: "tags", label: "Shopify tags" },
156
+ { key: "status", label: "Account" },
157
+ { key: "synced", label: "Last synced", format: "relative_time" },
158
+ ],
159
+ rows: members
160
+ .sort((a, b) => b.syncedAt.localeCompare(a.syncedAt))
161
+ .map((m) => ({
162
+ name: name(m),
163
+ email: m.email ?? "—",
164
+ levels: levelNames(m),
165
+ tags: m.tags.join(", ") || "—",
166
+ status: m.status === "enabled" ? "On" : "Off",
167
+ synced: m.syncedAt,
168
+ })),
169
+ },
170
+ ],
171
+ };
172
+ }
173
+
174
+ async function checks(config: RedeemIDConfig) {
175
+ const domain = storeDomain(config);
176
+ let discovery = "Not checked";
177
+ if (domain) {
178
+ discovery = await discover(config)
179
+ .then((d) => `OK (${new URL(d.authorizationEndpoint).host})`)
180
+ .catch((e: Error) => `Failed: ${e.message}`);
181
+ }
182
+ return {
183
+ domain,
184
+ discovery,
185
+ clientId: Boolean(env(ENV.clientId)),
186
+ webhookSecret: Boolean(env(ENV.webhookSecret)),
187
+ storefront: env(ENV.storefrontPrivateToken) ? "Private token" : env(ENV.storefrontPublicToken) ? "Public token" : "None (tokenless)",
188
+ };
189
+ }
190
+
191
+ async function lastDelivery(): Promise<string | null> {
192
+ const s = await memberStores();
193
+ const { items } = await s.deliveries.query({ orderBy: { receivedAt: "desc" }, limit: 1 });
194
+ const d = items[0]?.data;
195
+ return d ? `${d.topic} at ${d.receivedAt}` : null;
196
+ }
197
+
198
+ async function statusPage(config: RedeemIDConfig, request: Request) {
199
+ const c = await checks(config);
200
+ const origin = new URL(request.url).origin;
201
+ const yes = (v: boolean) => (v ? "Set" : "Missing");
202
+ const levels = config.levels ?? [];
203
+ const rules = config.rules ?? [];
204
+ const delivery = await lastDelivery().catch(() => null);
205
+ return {
206
+ blocks: [
207
+ { type: "header", text: "Status" },
208
+ c.discovery.startsWith("OK") && c.clientId
209
+ ? { type: "banner", variant: "default", title: "Sign-in is ready", description: "Members can sign in with their Shopify customer account." }
210
+ : { type: "banner", variant: "alert", title: "Sign-in isn't ready", description: "Set the missing values below as environment secrets, then reload." },
211
+ {
212
+ type: "fields",
213
+ fields: [
214
+ { label: "Store domain (REDEEMID_SHOPIFY_STORE_DOMAIN or shop)", value: c.domain || "Missing" },
215
+ { label: "Store discovery", value: c.discovery },
216
+ { label: "Customer Account API client ID", value: yes(c.clientId) },
217
+ { label: "Webhook signing secret", value: yes(c.webhookSecret) },
218
+ { label: "Storefront API access", value: c.storefront },
219
+ { label: "Last webhook", value: delivery ?? "None received yet" },
220
+ ],
221
+ },
222
+ { type: "divider" },
223
+ { type: "header", text: "Shopify settings" },
224
+ {
225
+ type: "fields",
226
+ fields: [
227
+ { label: "Callback URI", value: `${origin}/_emdash/api/auth/redeemid/callback` },
228
+ { label: "JavaScript origin", value: origin },
229
+ { label: "Logout URI", value: `${origin}/` },
230
+ { label: "Webhook URL", value: `${origin}/_emdash/api/auth/redeemid/webhook` },
231
+ ],
232
+ },
233
+ { type: "divider" },
234
+ { type: "header", text: "Levels" },
235
+ { type: "context", text: "Set in astro.config.mjs." },
236
+ levels.length
237
+ ? {
238
+ type: "table",
239
+ page_action_id: "levels_page",
240
+ columns: [
241
+ { key: "name", label: "Level" },
242
+ { key: "tags", label: "Shopify tags" },
243
+ { key: "match", label: "Needs" },
244
+ ],
245
+ rows: levels.map((l) => ({ name: l.name, tags: l.tags.join(", "), match: l.match === "all" ? "All tags" : "Any tag" })),
246
+ }
247
+ : { type: "context", text: "No levels configured." },
248
+ { type: "header", text: "Rules" },
249
+ rules.length
250
+ ? {
251
+ type: "table",
252
+ page_action_id: "rules_page",
253
+ columns: [
254
+ { key: "where", label: "Covers" },
255
+ { key: "levels", label: "Who can read" },
256
+ ],
257
+ rows: rules.map((r) => ({
258
+ where: r.taxonomy && r.term ? `${r.collection}, ${r.taxonomy}: ${r.term}` : `All ${r.collection}`,
259
+ levels: r.levels.length
260
+ ? r.levels.map((s) => levels.find((l) => l.slug === s)?.name ?? s).join(" or ")
261
+ : "Any signed-in member",
262
+ })),
263
+ }
264
+ : { type: "context", text: "No rules. Entries are open unless their body has a paywall break." },
265
+ ],
266
+ };
267
+ }
268
+
269
+ async function statusWidget(config: RedeemIDConfig) {
270
+ const c = await checks(config);
271
+ const members = await allMembers().catch(() => []);
272
+ return {
273
+ blocks: [
274
+ {
275
+ type: "fields",
276
+ fields: [
277
+ { label: "Sign-in", value: c.discovery.startsWith("OK") && c.clientId ? "Ready" : "Not ready" },
278
+ { label: "Members", value: String(members.length) },
279
+ ],
280
+ },
281
+ ],
282
+ };
283
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * GET /_emdash/api/auth/redeemid/account
3
+ *
4
+ * Sends a member to their Shopify customer account (orders, profile,
5
+ * addresses), on the store's own account domain. Visitors who aren't
6
+ * signed in go through sign-in first; Shopify would ask them anyway.
7
+ */
8
+
9
+ import type { APIRoute } from "astro";
10
+
11
+ import { safeReturn, signInUrl } from "../access.js";
12
+ import { readConfig, storeDomain } from "../config.js";
13
+ import { discover } from "../shopify.js";
14
+ import { fail } from "./shared.js";
15
+
16
+ export const prerender = false;
17
+
18
+ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
19
+ const returnTo = safeReturn(new URL(request.url).searchParams.get("return"));
20
+ const config = readConfig((locals as { emdash?: { config?: unknown } }).emdash?.config);
21
+ if (!config || !storeDomain(config)) return fail(redirect, returnTo, "not_configured");
22
+
23
+ const user = (await session?.get("user")) as { id?: string } | undefined;
24
+ if (!user?.id) return redirect(signInUrl(returnTo));
25
+ try {
26
+ const d = await discover(config);
27
+ return redirect(new URL("/", d.authorizationEndpoint).href);
28
+ } catch {
29
+ return fail(redirect, returnTo, "account_unavailable");
30
+ }
31
+ };
package/src/routes/buy.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
- * POST /_emdash/api/auth/redeemid/buy: start a Shopify checkout for one plan.
2
+ * POST /_emdash/api/auth/redeemid/buy: add a plan to the cart and check out.
3
3
  *
4
4
  * Takes a product handle, looks its variant up again on Shopify (never
5
- * trusting a posted variant or price) and sends the buyer to checkout. A
5
+ * trusting a posted variant or price), adds it to the visitor's Shopify
6
+ * cart and sends them to that cart's checkout. A
6
7
  * signed-in member's email is passed along so the order lands on the same
7
8
  * customer. Tags, and so access, still only change when Shopify says so.
8
9
  */
@@ -12,7 +13,8 @@ import type { APIRoute } from "astro";
12
13
  import { readConfig, storeDomain } from "../config.js";
13
14
  import { safeReturn } from "../access.js";
14
15
  import { memberForUser, stores } from "../storage.js";
15
- import { checkoutUrl, plan } from "../storefront.js";
16
+ import { saveCart, readCart } from "../cart.js";
17
+ import { addToCart, plan } from "../storefront.js";
16
18
  import { fail } from "./shared.js";
17
19
 
18
20
  export const prerender = false;
@@ -32,12 +34,15 @@ export const POST: APIRoute = async ({ request, locals, session, redirect }) =>
32
34
  // Provider routes are public, so read the member from the session.
33
35
  const user = (await session?.get("user")) as { id?: string } | undefined;
34
36
  const member = user?.id && emdash?.db ? await memberForUser(stores(emdash.db), user.id) : null;
35
- const to = await checkoutUrl(
37
+ const current = await readCart(session);
38
+ const cart = await addToCart(
36
39
  config,
40
+ current?.id ?? null,
37
41
  { variantId: p.variantId, sellingPlanId: p.sellingPlanId },
38
42
  { email: member?.email ?? null, buyerIp: request.headers.get("cf-connecting-ip") },
39
43
  );
40
- return redirect(to, 303);
44
+ saveCart(session, { id: cart.id, count: cart.totalQuantity });
45
+ return redirect(cart.checkoutUrl, 303);
41
46
  } catch {
42
47
  return fail(redirect, returnTo, "checkout_failed");
43
48
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * GET /_emdash/api/auth/redeemid/cart?return=/path
3
+ *
4
+ * Sends the visitor to their Shopify cart's checkout. With no cart, or one
5
+ * Shopify has closed (its checkout completed), they go back with the
6
+ * `cart_empty` notice and the stored cart is cleared.
7
+ */
8
+
9
+ import type { APIRoute } from "astro";
10
+
11
+ import { safeReturn } from "../access.js";
12
+ import { readCart, saveCart } from "../cart.js";
13
+ import { readConfig, storeDomain } from "../config.js";
14
+ import { getCart } from "../storefront.js";
15
+ import { fail } from "./shared.js";
16
+
17
+ export const prerender = false;
18
+
19
+ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
20
+ const returnTo = safeReturn(new URL(request.url).searchParams.get("return"));
21
+ const config = readConfig((locals as { emdash?: { config?: unknown } }).emdash?.config);
22
+ if (!config || !storeDomain(config)) return fail(redirect, returnTo, "not_configured");
23
+
24
+ const ref = await readCart(session);
25
+ if (!ref) return fail(redirect, returnTo, "cart_empty");
26
+ try {
27
+ const cart = await getCart(config, ref.id, { buyerIp: request.headers.get("cf-connecting-ip") });
28
+ if (!cart || cart.totalQuantity < 1) {
29
+ saveCart(session, null);
30
+ return fail(redirect, returnTo, "cart_empty");
31
+ }
32
+ saveCart(session, { id: cart.id, count: cart.totalQuantity });
33
+ return redirect(cart.checkoutUrl, 303);
34
+ } catch {
35
+ return fail(redirect, returnTo, "checkout_failed");
36
+ }
37
+ };
package/src/shopify.ts CHANGED
@@ -24,6 +24,12 @@ const DISCOVERY_TTL_MS = 60 * 60 * 1000;
24
24
  const discoveryCache = new Map<string, { at: number; value: Discovery }>();
25
25
  const jwksCache = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
26
26
 
27
+ /**
28
+ * Shopify answers 403 to requests with no User-Agent, and fetch on
29
+ * Cloudflare Workers sends none, so every server-side call names itself.
30
+ */
31
+ export const USER_AGENT = "@redeemid/emdash (+https://github.com/webmultipliers/redeemid-emdash)";
32
+
27
33
  export class ShopifyError extends Error {
28
34
  constructor(
29
35
  public readonly code: string,
@@ -65,7 +71,7 @@ export async function discover(config: RedeemIDConfig, fetcher: typeof fetch = f
65
71
  }
66
72
 
67
73
  async function getJson(fetcher: typeof fetch, url: string): Promise<Record<string, unknown>> {
68
- const res = await fetcher(url, { headers: { Accept: "application/json" } });
74
+ const res = await fetcher(url, { headers: { Accept: "application/json", "User-Agent": USER_AGENT } });
69
75
  if (!res.ok) throw new ShopifyError("discovery_failed", `Discovery failed (${res.status}) for ${url}`);
70
76
  return (await res.json()) as Record<string, unknown>;
71
77
  }
@@ -128,6 +134,7 @@ export async function exchangeCode(
128
134
  const headers: Record<string, string> = {
129
135
  "Content-Type": "application/x-www-form-urlencoded",
130
136
  Accept: "application/json",
137
+ "User-Agent": USER_AGENT,
131
138
  };
132
139
  // Public clients are checked against their registered JavaScript origins.
133
140
  if (args.origin) headers.Origin = args.origin;
@@ -149,7 +156,7 @@ export async function verifyIdToken(
149
156
  ): Promise<JWTPayload> {
150
157
  let jwks = jwksCache.get(d.jwksUri);
151
158
  if (!jwks) {
152
- jwks = createRemoteJWKSet(new URL(d.jwksUri));
159
+ jwks = createRemoteJWKSet(new URL(d.jwksUri), { headers: { "User-Agent": USER_AGENT } });
153
160
  jwksCache.set(d.jwksUri, jwks);
154
161
  }
155
162
  try {
@@ -191,7 +198,7 @@ export async function identity(d: Discovery, accessToken: string, fetcher: typeo
191
198
  const res = await fetcher(d.graphqlApi, {
192
199
  method: "POST",
193
200
  // The Customer Account API takes the bare access token, without "Bearer".
194
- headers: { "Content-Type": "application/json", Authorization: accessToken },
201
+ headers: { "Content-Type": "application/json", Authorization: accessToken, "User-Agent": USER_AGENT },
195
202
  body: JSON.stringify({ query: IDENTITY_QUERY }),
196
203
  });
197
204
  const data = (await res.json().catch(() => ({}))) as {
package/src/storefront.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { env, ENV, shopOrigin, type RedeemIDConfig } from "./config.js";
11
+ import { USER_AGENT } from "./shopify.js";
11
12
 
12
13
  export const STOREFRONT_API_VERSION = "2026-10";
13
14
 
@@ -34,7 +35,11 @@ export async function storefront<T>(
34
35
  variables: Record<string, unknown> = {},
35
36
  opts: { buyerIp?: string | null; fetcher?: typeof fetch } = {},
36
37
  ): Promise<T> {
37
- const headers: Record<string, string> = { "Content-Type": "application/json", Accept: "application/json" };
38
+ const headers: Record<string, string> = {
39
+ "Content-Type": "application/json",
40
+ Accept: "application/json",
41
+ "User-Agent": USER_AGENT,
42
+ };
38
43
  const priv = env(ENV.storefrontPrivateToken);
39
44
  const pub = env(ENV.storefrontPublicToken);
40
45
  if (priv) {
@@ -121,3 +126,61 @@ export async function checkoutUrl(
121
126
  if (!url) throw new Error(data.cartCreate.userErrors[0]?.message ?? "Shopify returned no checkout.");
122
127
  return url;
123
128
  }
129
+
130
+ // ---------------------------------------------------------------- cart
131
+
132
+ export interface Cart {
133
+ id: string;
134
+ checkoutUrl: string;
135
+ totalQuantity: number;
136
+ }
137
+
138
+ const CART_FIELDS = "id checkoutUrl totalQuantity";
139
+
140
+ const CART_GET = `query Cart($id: ID!) { cart(id: $id) { ${CART_FIELDS} } }`;
141
+
142
+ const CART_NEW = `mutation CartNew($input: CartInput!) {
143
+ cartCreate(input: $input) { cart { ${CART_FIELDS} } userErrors { message } }
144
+ }`;
145
+
146
+ const CART_ADD = `mutation CartAdd($id: ID!, $lines: [CartLineInput!]!) {
147
+ cartLinesAdd(cartId: $id, lines: $lines) { cart { ${CART_FIELDS} } userErrors { message } }
148
+ }`;
149
+
150
+ type CartResult = { cart: Cart | null; userErrors: Array<{ message: string }> };
151
+
152
+ /**
153
+ * A cart by id. Null once Shopify no longer has it, which is what happens
154
+ * after its checkout completes.
155
+ */
156
+ export async function getCart(
157
+ config: RedeemIDConfig,
158
+ id: string,
159
+ opts: { buyerIp?: string | null; fetcher?: typeof fetch } = {},
160
+ ): Promise<Cart | null> {
161
+ const data = await storefront<{ cart: Cart | null }>(config, CART_GET, { id }, opts);
162
+ return data.cart;
163
+ }
164
+
165
+ /**
166
+ * Add one line to the visitor's cart, creating the cart when there is none
167
+ * (or Shopify has dropped it). A new cart carries the member's email.
168
+ */
169
+ export async function addToCart(
170
+ config: RedeemIDConfig,
171
+ cartId: string | null,
172
+ line: { variantId: string; sellingPlanId?: string | null },
173
+ opts: { email?: string | null; buyerIp?: string | null; fetcher?: typeof fetch } = {},
174
+ ): Promise<Cart> {
175
+ const lines = [{ merchandiseId: line.variantId, quantity: 1, ...(line.sellingPlanId ? { sellingPlanId: line.sellingPlanId } : {}) }];
176
+ if (cartId) {
177
+ const data = await storefront<{ cartLinesAdd: CartResult }>(config, CART_ADD, { id: cartId, lines }, opts).catch(() => null);
178
+ if (data?.cartLinesAdd.cart) return data.cartLinesAdd.cart;
179
+ }
180
+ const input: Record<string, unknown> = { lines };
181
+ if (opts.email) input.buyerIdentity = { email: opts.email };
182
+ const data = await storefront<{ cartCreate: CartResult }>(config, CART_NEW, { input }, opts);
183
+ const cart = data.cartCreate.cart;
184
+ if (!cart) throw new Error(data.cartCreate.userErrors[0]?.message ?? "Shopify returned no cart.");
185
+ return cart;
186
+ }
package/src/version.ts ADDED
@@ -0,0 +1 @@
1
+ export const VERSION = "0.0.3";