@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 +36 -27
- package/docs/shopify-setup.md +1 -1
- package/package.json +3 -1
- package/src/access.ts +10 -0
- package/src/astro-modules.d.ts +4 -0
- package/src/blocks/PaywallBreak.astro +6 -0
- package/src/blocks/PlanBlock.astro +13 -0
- package/src/blocks/index.ts +11 -0
- package/src/blocks-names.ts +24 -0
- package/src/cart.ts +25 -0
- package/src/components/Account.astro +3 -2
- package/src/components/Cart.astro +21 -0
- package/src/components/Content.astro +53 -0
- package/src/descriptor.ts +29 -0
- package/src/index.ts +3 -0
- package/src/plugin.ts +283 -0
- package/src/routes/account.ts +31 -0
- package/src/routes/buy.ts +10 -5
- package/src/routes/cart.ts +37 -0
- package/src/shopify.ts +10 -3
- package/src/storefront.ts +64 -1
- package/src/version.ts +1 -0
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.
|
|
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
|
|
14
|
-
- **
|
|
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
|
-
|
|
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.
|
|
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
|
|
85
|
+
import Content from "@redeemid/emdash/components/Content.astro";
|
|
82
86
|
---
|
|
83
|
-
<
|
|
84
|
-
<
|
|
85
|
-
|
|
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
|
-
|
|
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
|
|
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
|
---
|
package/docs/shopify-setup.md
CHANGED
|
@@ -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.
|
|
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,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:
|
|
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)
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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> = {
|
|
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";
|