@redeemid/emdash 0.0.0-stage → 0.0.2
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/LICENSE +20 -0
- package/README.md +142 -2
- package/docs/shopify-setup.md +56 -0
- package/package.json +61 -5
- package/src/access.ts +124 -0
- package/src/components/Account.astro +26 -0
- package/src/components/Gate.astro +40 -0
- package/src/components/Plan.astro +53 -0
- package/src/config.ts +112 -0
- package/src/guard.ts +45 -0
- package/src/index.ts +78 -0
- package/src/routes/buy.ts +44 -0
- package/src/routes/callback.ts +106 -0
- package/src/routes/login.ts +49 -0
- package/src/routes/logout.ts +41 -0
- package/src/routes/shared.ts +26 -0
- package/src/routes/webhook.ts +58 -0
- package/src/shopify.ts +227 -0
- package/src/site-config.ts +15 -0
- package/src/storage.ts +95 -0
- package/src/storefront.ts +123 -0
- package/src/webhooks.ts +161 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
The MIT License
|
|
2
|
+
|
|
3
|
+
Copyright 2026 Vinny S. Green, LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
6
|
+
this software and associated documentation files (the "Software"), to deal in
|
|
7
|
+
the Software without restriction, including without limitation the rights to
|
|
8
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
|
9
|
+
the Software, and to permit persons to whom the Software is furnished to do so,
|
|
10
|
+
subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
17
|
+
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
18
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
19
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
20
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,143 @@
|
|
|
1
|
-
#
|
|
1
|
+
# RedeemID for EmDash
|
|
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
|
+
|
|
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.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
- **Sign in with the store's customer account.** Members click "Sign in" on your site, sign in on the store's account page and come back signed in. They become EmDash Subscribers. That role only means "signed in"; it never grants content.
|
|
10
|
+
- **Mirrors the customer.** Email, name, tags and enabled/disabled status, kept current by signed Shopify webhooks.
|
|
11
|
+
- **Levels from tags.** You map Shopify customer tags to levels. Set tags in Shopify Flow or any app; RedeemID only reads them.
|
|
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.
|
|
15
|
+
- **Keeps members out of EmDash's own APIs**, so the gate is the only way to read locked content.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
The package is a native EmDash plugin (an npm dependency, not a registry plugin), because sign-in and page gating run in the site process.
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
pnpm add @redeemid/emdash
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```js title="astro.config.mjs"
|
|
26
|
+
import { redeemid, redeemidGuard } from "@redeemid/emdash";
|
|
27
|
+
import emdash from "emdash/astro";
|
|
28
|
+
|
|
29
|
+
export default defineConfig({
|
|
30
|
+
integrations: [
|
|
31
|
+
emdash({
|
|
32
|
+
// 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
|
+
],
|
|
48
|
+
}),
|
|
49
|
+
redeemidGuard(),
|
|
50
|
+
],
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Set these secrets in the environment (`.env` locally, `wrangler secret put` on Workers). [docs/shopify-setup.md](docs/shopify-setup.md) walks through the Shopify side:
|
|
55
|
+
|
|
56
|
+
| Variable | Shopify API | What it is |
|
|
57
|
+
| --- | --- | --- |
|
|
58
|
+
| `REDEEMID_SHOPIFY_STORE_DOMAIN` | Customer Account API discovery | The store's `*.myshopify.com` domain. Only needed when `shop` isn't set in the config. |
|
|
59
|
+
| `REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID` | Customer Account API | Client ID of the Headless storefront's Customer Account API client |
|
|
60
|
+
| `REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_SECRET` | Customer Account API | Its client secret. Confidential clients only; leave unset for a public client. |
|
|
61
|
+
| `REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_SECRET` | Webhooks (Admin API app) | Client secret of the Shopify app that sends the webhooks. Shopify signs each webhook with it. |
|
|
62
|
+
| `REDEEMID_SHOPIFY_STOREFRONT_PRIVATE_TOKEN` | Storefront API | Private access token, for the plan cards and checkout (server-side). |
|
|
63
|
+
| `REDEEMID_SHOPIFY_STOREFRONT_PUBLIC_TOKEN` | Storefront API | Public access token. Only used when the private token isn't set. |
|
|
64
|
+
|
|
65
|
+
RedeemID finds the authorization, token and logout endpoints itself from the store's discovery documents, so they need no setting.
|
|
66
|
+
|
|
67
|
+
In Shopify, give the Customer Account API client these settings:
|
|
68
|
+
|
|
69
|
+
- Callback URI: `https://your-site.example/_emdash/api/auth/redeemid/callback`
|
|
70
|
+
- JavaScript origin (public clients): `https://your-site.example`
|
|
71
|
+
- Logout URI: `https://your-site.example/`
|
|
72
|
+
|
|
73
|
+
Point webhooks (`customers/create`, `customers/update`, `customers/delete`, `customers/disable`, `customers/enable`, `customers/merge`, `customer.tags_added`, `customer.tags_removed`, `customers/redact`) at `https://your-site.example/_emdash/api/auth/redeemid/webhook`.
|
|
74
|
+
|
|
75
|
+
## Use it in templates
|
|
76
|
+
|
|
77
|
+
EmDash has no content filter hook, so templates decide what to render. Wrap the entry body:
|
|
78
|
+
|
|
79
|
+
```astro
|
|
80
|
+
---
|
|
81
|
+
import Gate from "@redeemid/emdash/components/Gate.astro";
|
|
82
|
+
---
|
|
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
|
+
```
|
|
88
|
+
|
|
89
|
+
Add the account box to your header:
|
|
90
|
+
|
|
91
|
+
```astro
|
|
92
|
+
---
|
|
93
|
+
import Account from "@redeemid/emdash/components/Account.astro";
|
|
94
|
+
---
|
|
95
|
+
<Account />
|
|
96
|
+
```
|
|
97
|
+
|
|
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:
|
|
99
|
+
|
|
100
|
+
```astro
|
|
101
|
+
---
|
|
102
|
+
import Plan from "@redeemid/emdash/components/Plan.astro";
|
|
103
|
+
---
|
|
104
|
+
<Plan handle="gold-membership" label="Join Gold">
|
|
105
|
+
<p>Shown if the product can't be loaded.</p>
|
|
106
|
+
</Plan>
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Or ask directly:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
import { canRead } from "@redeemid/emdash/access";
|
|
113
|
+
|
|
114
|
+
const verdict = await canRead(Astro, { collection: "posts", terms: post.data.terms });
|
|
115
|
+
// verdict.allowed, verdict.reason: open | allowed | sign_in | upgrade | disabled
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Pages rendered for a signed-in member are sent with `Cache-Control: private, no-store`; pages for visitors stay cacheable.
|
|
119
|
+
|
|
120
|
+
## Rules
|
|
121
|
+
|
|
122
|
+
- Shopify is the only source of entitlements. Nothing on the EmDash side grants access.
|
|
123
|
+
- Levels read the mirrored tags as they are, however old; they change only when Shopify sends new ones.
|
|
124
|
+
- Status (enabled or disabled) comes only from the `customers/enable` and `customers/disable` webhooks.
|
|
125
|
+
- Staff accounts (any EmDash role above Subscriber) can't sign in through the store, even with a matching email.
|
|
126
|
+
- Customers can't create the first (admin) account on a new site.
|
|
127
|
+
|
|
128
|
+
## Develop
|
|
129
|
+
|
|
130
|
+
```sh
|
|
131
|
+
pnpm install
|
|
132
|
+
pnpm test # unit tests
|
|
133
|
+
pnpm typecheck
|
|
134
|
+
pnpm e2e # boots the playground site against a fake Shopify store
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Releases: bump `version` in package.json and merge to main. The Release workflow runs the tests, then stages that version on npm with a stage-only token. A maintainer approves it on npmjs.com or with `npm stage approve <id>` (2FA), and only then is it public. The same run cuts a matching GitHub release, `v<version>`, with the exact tarball it staged and a `.sha256`.
|
|
138
|
+
|
|
139
|
+
`playground/` is the EmDash starter template with RedeemID wired in and two locked posts in its seed.
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
MIT, the same as EmDash.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Connect a store
|
|
2
|
+
|
|
3
|
+
What to set up in Shopify, and on the site, to run RedeemID on an EmDash site. One store can serve several sites: each site gets its own Headless storefront for sign-in, and each receives webhooks.
|
|
4
|
+
|
|
5
|
+
## 1. Sign-in: a Headless storefront
|
|
6
|
+
|
|
7
|
+
1. In Shopify admin, open **Sales channels → Headless** (add the channel if it isn't there) and **Create storefront**. Name it after the site.
|
|
8
|
+
2. Open the storefront's **Customer Account API** settings.
|
|
9
|
+
3. Pick a **Client type**. Both work, because sign-in runs on the server with PKCE either way:
|
|
10
|
+
- **Confidential** gives a client secret, which the site sends with the token request.
|
|
11
|
+
- **Public** has no secret. Shopify checks the request's origin instead, so register the site as a JavaScript origin.
|
|
12
|
+
4. Under **Application setup**, add:
|
|
13
|
+
- **Callback URI:** `https://<site>/_emdash/api/auth/redeemid/callback`
|
|
14
|
+
- **JavaScript origin:** `https://<site>` (public clients)
|
|
15
|
+
- **Logout URI:** `https://<site>/` (members land here after signing out)
|
|
16
|
+
5. Copy the **Client ID**, and the **Client secret** if the client is confidential.
|
|
17
|
+
|
|
18
|
+
The Authorization, Token and Logout endpoints Shopify lists need no setting: RedeemID reads them from the store's discovery documents, and they already follow a custom customer-account domain.
|
|
19
|
+
|
|
20
|
+
Shopify only accepts `https` callback URIs, so a local `astro dev` site needs a tunnel (for example `cloudflared tunnel`) to sign in for real. The end-to-end test uses a fake store instead.
|
|
21
|
+
|
|
22
|
+
The store must use the new customer accounts (Settings → Customer accounts). Classic accounts have no Customer Account API.
|
|
23
|
+
|
|
24
|
+
## 2. Webhooks
|
|
25
|
+
|
|
26
|
+
RedeemID keeps tags and status current from webhooks. Point these topics at `https://<site>/_emdash/api/auth/redeemid/webhook`:
|
|
27
|
+
|
|
28
|
+
`customers/create`, `customers/update`, `customers/delete`, `customers/disable`, `customers/enable`, `customers/merge`, `customer.tags_added`, `customer.tags_removed`, `customers/redact`
|
|
29
|
+
|
|
30
|
+
Subscribe them from a Shopify app on the store (for example a Dev Dashboard app). If the app already sends webhooks to another site, add this URL as extra subscriptions. `customers/merge` needs the `read_customer_merge` scope. Shopify signs every webhook with that app's **client secret**, which is the site's webhook secret.
|
|
31
|
+
|
|
32
|
+
## 3. The site
|
|
33
|
+
|
|
34
|
+
Add the package and config as in the [README](../README.md#install), with `shop` set to the store's domain. A custom customer-account domain works as well as `*.myshopify.com`.
|
|
35
|
+
|
|
36
|
+
Set the secrets. On Cloudflare Workers:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
npx wrangler secret put REDEEMID_SHOPIFY_STORE_DOMAIN # e.g. my-store.myshopify.com, unless `shop` is in the config
|
|
40
|
+
npx wrangler secret put REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID # Customer Account API client ID
|
|
41
|
+
npx wrangler secret put REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_SECRET # confidential clients only
|
|
42
|
+
npx wrangler secret put REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_SECRET # the webhook app's client secret
|
|
43
|
+
npx wrangler secret put REDEEMID_SHOPIFY_STOREFRONT_PRIVATE_TOKEN # Headless storefront's Storefront API private token (plans, checkout)
|
|
44
|
+
npx wrangler secret put REDEEMID_SHOPIFY_STOREFRONT_PUBLIC_TOKEN # optional; used only without the private token
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The Workers free plan is enough: RedeemID is a native plugin and doesn't need sandboxing. Worker secrets reach the code through `nodejs_compat`, which EmDash's Cloudflare templates turn on.
|
|
48
|
+
|
|
49
|
+
## 4. Try it
|
|
50
|
+
|
|
51
|
+
1. Finish EmDash setup as staff first. Store customers can't create the first account.
|
|
52
|
+
2. Tag a test customer in Shopify (for example `member`) and visit a locked post.
|
|
53
|
+
3. Click **Sign in**, sign in with that customer's email, and you come back to the post, unlocked.
|
|
54
|
+
4. Add or remove the tag in Shopify; the post opens or locks again once the webhook arrives.
|
|
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`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,62 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@redeemid/emdash",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "Shopify customer accounts, member levels and paywalls for EmDash sites. Shopify runs commerce; EmDash runs content.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Vinny S. Green, LLC",
|
|
8
|
+
"homepage": "https://www.redeemid.com/",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/webmultipliers/redeemid-emdash.git"
|
|
12
|
+
},
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./src/index.ts",
|
|
15
|
+
"./access": "./src/access.ts",
|
|
16
|
+
"./guard.ts": "./src/guard.ts",
|
|
17
|
+
"./storefront": "./src/storefront.ts",
|
|
18
|
+
"./components/*": "./src/components/*",
|
|
19
|
+
"./routes/*": "./src/routes/*"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"src",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE",
|
|
25
|
+
"docs"
|
|
26
|
+
],
|
|
27
|
+
"keywords": [
|
|
28
|
+
"emdash",
|
|
29
|
+
"shopify",
|
|
30
|
+
"paywall",
|
|
31
|
+
"membership",
|
|
32
|
+
"customer-accounts"
|
|
33
|
+
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"test": "vitest run",
|
|
36
|
+
"typecheck": "tsc --noEmit",
|
|
37
|
+
"e2e": "node tests/e2e/run.mjs"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"@emdash-cms/auth": ">=1.1.0",
|
|
41
|
+
"astro": ">=6",
|
|
42
|
+
"emdash": ">=1.1.0"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"jose": "^6.1.3"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@emdash-cms/auth": "^1.4.0",
|
|
49
|
+
"astro": "^7.3.2",
|
|
50
|
+
"emdash": "^1.4.0",
|
|
51
|
+
"kysely": "^0.29.0",
|
|
52
|
+
"typescript": "^6.0.3",
|
|
53
|
+
"vitest": "4.1.10"
|
|
54
|
+
},
|
|
55
|
+
"packageManager": "pnpm@10.28.0",
|
|
56
|
+
"publishConfig": {
|
|
57
|
+
"access": "public"
|
|
58
|
+
},
|
|
59
|
+
"bugs": {
|
|
60
|
+
"url": "https://github.com/webmultipliers/redeemid-emdash/issues"
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/access.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who may read what, for page code.
|
|
3
|
+
*
|
|
4
|
+
* EmDash has no content filter hook, so templates ask before they render:
|
|
5
|
+
*
|
|
6
|
+
* const access = await canRead(Astro, { collection: "posts", terms: post.data.terms });
|
|
7
|
+
*
|
|
8
|
+
* or wrap the body in <Gate>. The answer comes from the member's mirrored
|
|
9
|
+
* Shopify tags only. The EmDash "Subscriber" role means "signed in", never
|
|
10
|
+
* "entitled".
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { LevelConfig, RedeemIDConfig, RuleConfig } from "./config.js";
|
|
14
|
+
import { siteConfig } from "./site-config.js";
|
|
15
|
+
import { memberForUser, stores, type MemberRecord } from "./storage.js";
|
|
16
|
+
|
|
17
|
+
/** The parts of Astro's global that access checks read. */
|
|
18
|
+
export interface AstroLike {
|
|
19
|
+
locals: unknown;
|
|
20
|
+
response?: { headers: Headers };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface Locals {
|
|
24
|
+
user?: { id: string; disabled?: boolean | number } | null;
|
|
25
|
+
emdash?: { db?: unknown; config?: unknown } | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface Viewer {
|
|
29
|
+
signedIn: boolean;
|
|
30
|
+
member: MemberRecord | null;
|
|
31
|
+
/** Level slugs held right now. Empty when the Shopify account is disabled. */
|
|
32
|
+
levels: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type Reason = "open" | "allowed" | "sign_in" | "upgrade" | "disabled" | "unconfigured";
|
|
36
|
+
|
|
37
|
+
export interface Verdict {
|
|
38
|
+
allowed: boolean;
|
|
39
|
+
reason: Reason;
|
|
40
|
+
/** Levels any of which would unlock the entry. Empty = any signed-in member. */
|
|
41
|
+
required: string[];
|
|
42
|
+
viewer: Viewer;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type Terms = Record<string, Array<{ slug: string } | string> | undefined>;
|
|
46
|
+
|
|
47
|
+
export function levelsFor(tags: string[], levels: LevelConfig[] = []): string[] {
|
|
48
|
+
const held = new Set(tags.map((t) => t.toLowerCase()));
|
|
49
|
+
return levels
|
|
50
|
+
.filter((l) => {
|
|
51
|
+
if (l.tags.length === 0) return false;
|
|
52
|
+
const has = (t: string) => held.has(t.toLowerCase());
|
|
53
|
+
return l.match === "all" ? l.tags.every(has) : l.tags.some(has);
|
|
54
|
+
})
|
|
55
|
+
.map((l) => l.slug);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Required levels for an entry, or null when no rule covers it.
|
|
60
|
+
* Term rules beat collection rules; several matching term rules combine.
|
|
61
|
+
*/
|
|
62
|
+
export function requiredLevels(rules: RuleConfig[] = [], collection: string, terms: Terms = {}): string[] | null {
|
|
63
|
+
const forCollection = rules.filter((r) => r.collection === collection);
|
|
64
|
+
const termRules = forCollection.filter((r) => {
|
|
65
|
+
if (!r.taxonomy || !r.term) return false;
|
|
66
|
+
const list = terms[r.taxonomy] ?? [];
|
|
67
|
+
return list.some((t) => (typeof t === "string" ? t : t.slug) === r.term);
|
|
68
|
+
});
|
|
69
|
+
const chosen = termRules.length > 0 ? termRules : forCollection.filter((r) => !r.taxonomy && !r.term);
|
|
70
|
+
if (chosen.length === 0) return null;
|
|
71
|
+
// Any rule that admits every member wins.
|
|
72
|
+
if (chosen.some((r) => r.levels.length === 0)) return [];
|
|
73
|
+
return [...new Set(chosen.flatMap((r) => r.levels))];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function viewer(astro: AstroLike): Promise<Viewer> {
|
|
77
|
+
const locals = astro.locals as Locals;
|
|
78
|
+
const user = locals.user;
|
|
79
|
+
if (!user?.id || user.disabled) return { signedIn: false, member: null, levels: [] };
|
|
80
|
+
|
|
81
|
+
// Member-specific output must never be shared by a cache.
|
|
82
|
+
astro.response?.headers.set("Cache-Control", "private, no-store");
|
|
83
|
+
|
|
84
|
+
const config = siteConfig(locals.emdash?.config);
|
|
85
|
+
const db = locals.emdash?.db;
|
|
86
|
+
if (!config || !db) return { signedIn: true, member: null, levels: [] };
|
|
87
|
+
|
|
88
|
+
const member = await memberForUser(stores(db), user.id);
|
|
89
|
+
const levels = member && member.status !== "disabled" ? levelsFor(member.tags, config.levels) : [];
|
|
90
|
+
return { signedIn: true, member, levels };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function canRead(
|
|
94
|
+
astro: AstroLike,
|
|
95
|
+
entry: { collection: string; terms?: Terms },
|
|
96
|
+
override?: { levels: string[] },
|
|
97
|
+
): Promise<Verdict> {
|
|
98
|
+
const locals = astro.locals as Locals;
|
|
99
|
+
const config: RedeemIDConfig | null = siteConfig(locals.emdash?.config);
|
|
100
|
+
const required = override ? override.levels : requiredLevels(config?.rules, entry.collection, entry.terms);
|
|
101
|
+
const v = await viewer(astro);
|
|
102
|
+
|
|
103
|
+
if (required === null) return { allowed: true, reason: "open", required: [], viewer: v };
|
|
104
|
+
if (!config) return { allowed: false, reason: "unconfigured", required, viewer: v };
|
|
105
|
+
if (!v.signedIn) return { allowed: false, reason: "sign_in", required, viewer: v };
|
|
106
|
+
if (v.member?.status === "disabled") return { allowed: false, reason: "disabled", required, viewer: v };
|
|
107
|
+
if (!v.member) return { allowed: false, reason: "upgrade", required, viewer: v };
|
|
108
|
+
const ok = required.length === 0 || required.some((l) => v.levels.includes(l));
|
|
109
|
+
return { allowed: ok, reason: ok ? "allowed" : "upgrade", required, viewer: v };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Same-site path to return to after sign-in, or "/". */
|
|
113
|
+
export function safeReturn(raw: string | null | undefined): string {
|
|
114
|
+
if (!raw || !raw.startsWith("/") || raw.startsWith("//") || raw.startsWith("/\\")) return "/";
|
|
115
|
+
return raw;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function signInUrl(returnTo: string): string {
|
|
119
|
+
return `/_emdash/api/auth/redeemid/login?return=${encodeURIComponent(safeReturn(returnTo))}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function signOutUrl(returnTo = "/"): string {
|
|
123
|
+
return `/_emdash/api/auth/redeemid/logout?return=${encodeURIComponent(safeReturn(returnTo))}`;
|
|
124
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Sign in link for visitors; name, levels and Sign out for members.
|
|
4
|
+
*/
|
|
5
|
+
import { siteConfig } from "../site-config.js";
|
|
6
|
+
import { signInUrl, signOutUrl, viewer } from "../access.js";
|
|
7
|
+
|
|
8
|
+
const v = await viewer(Astro);
|
|
9
|
+
const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
|
|
10
|
+
const here = Astro.url.pathname + Astro.url.search;
|
|
11
|
+
const names = (config?.levels ?? []).filter((l) => v.levels.includes(l.slug)).map((l) => l.name);
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
<div class="redeemid-account" data-redeemid-signed-in={v.signedIn ? "true" : "false"}>
|
|
15
|
+
{v.signedIn ? (
|
|
16
|
+
<>
|
|
17
|
+
<span class="redeemid-account__who">{v.member?.firstName ?? v.member?.email ?? "Signed in"}</span>
|
|
18
|
+
{names.length > 0 && <span class="redeemid-account__levels">{names.join(", ")}</span>}
|
|
19
|
+
<form method="post" action={signOutUrl(here)}>
|
|
20
|
+
<button type="submit">Sign out</button>
|
|
21
|
+
</form>
|
|
22
|
+
</>
|
|
23
|
+
) : (
|
|
24
|
+
<a href={signInUrl(here)}>{config?.label ?? "Sign in"}</a>
|
|
25
|
+
)}
|
|
26
|
+
</div>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Show the default slot to readers who may read the entry, the "locked" slot
|
|
4
|
+
* (or a built-in notice) to everyone else.
|
|
5
|
+
*
|
|
6
|
+
* <Gate collection="posts" terms={post.data.terms}>
|
|
7
|
+
* <PortableText value={post.data.content} />
|
|
8
|
+
* <p slot="locked">…teaser…</p>
|
|
9
|
+
* </Gate>
|
|
10
|
+
*
|
|
11
|
+
* Pass `levels` to lock a part of a page to specific levels instead.
|
|
12
|
+
*/
|
|
13
|
+
import { canRead, signInUrl, type Terms } from "../access.js";
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
collection?: string;
|
|
17
|
+
terms?: Terms;
|
|
18
|
+
levels?: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const { collection = "", terms, levels } = Astro.props;
|
|
22
|
+
const verdict = await canRead(Astro, { collection, terms }, levels ? { levels } : undefined);
|
|
23
|
+
const here = Astro.url.pathname + Astro.url.search;
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
{verdict.allowed ? (
|
|
27
|
+
<slot />
|
|
28
|
+
) : (
|
|
29
|
+
<div class="redeemid-locked" data-redeemid-reason={verdict.reason}>
|
|
30
|
+
<slot name="locked" />
|
|
31
|
+
{verdict.reason === "sign_in" && (
|
|
32
|
+
<p>
|
|
33
|
+
This is for members. <a href={signInUrl(here)}>Sign in</a> to read it.
|
|
34
|
+
</p>
|
|
35
|
+
)}
|
|
36
|
+
{verdict.reason === "upgrade" && <p>Your membership doesn't include this yet.</p>}
|
|
37
|
+
{verdict.reason === "disabled" && <p>Your account is turned off. Contact the store for help.</p>}
|
|
38
|
+
{verdict.reason === "unconfigured" && <p>This is for members.</p>}
|
|
39
|
+
</div>
|
|
40
|
+
)}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* A membership plan from Shopify: name, price and a Buy button that starts
|
|
4
|
+
* checkout. Prices come from the Storefront API; the default slot shows if
|
|
5
|
+
* the product can't be loaded.
|
|
6
|
+
*
|
|
7
|
+
* <Plan handle="gold-membership" />
|
|
8
|
+
*/
|
|
9
|
+
import { siteConfig } from "../site-config.js";
|
|
10
|
+
import { plan as loadPlan, type Plan } from "../storefront.js";
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
handle: string;
|
|
14
|
+
/** Button text. */
|
|
15
|
+
label?: string;
|
|
16
|
+
/** Heading override; defaults to the Shopify product title. */
|
|
17
|
+
title?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const { handle, label = "Join", title } = Astro.props;
|
|
21
|
+
const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
|
|
22
|
+
let p: Plan | null = null;
|
|
23
|
+
if (config) {
|
|
24
|
+
try {
|
|
25
|
+
p = await loadPlan(config, handle);
|
|
26
|
+
} catch {
|
|
27
|
+
p = null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const price = p?.price
|
|
31
|
+
? new Intl.NumberFormat("en-US", { style: "currency", currency: p.price.currencyCode }).format(Number(p.price.amount))
|
|
32
|
+
: null;
|
|
33
|
+
const here = Astro.url.pathname + Astro.url.search;
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
{p ? (
|
|
37
|
+
<div class="redeemid-plan" data-redeemid-plan={p.handle}>
|
|
38
|
+
<h2 class="redeemid-plan__title">{title ?? p.title}</h2>
|
|
39
|
+
{price && <p class="redeemid-plan__price">{price}</p>}
|
|
40
|
+
{p.description && <p class="redeemid-plan__description">{p.description}</p>}
|
|
41
|
+
{p.availableForSale ? (
|
|
42
|
+
<form method="post" action="/_emdash/api/auth/redeemid/buy">
|
|
43
|
+
<input type="hidden" name="plan" value={p.handle} />
|
|
44
|
+
<input type="hidden" name="return" value={here} />
|
|
45
|
+
<button type="submit">{label}</button>
|
|
46
|
+
</form>
|
|
47
|
+
) : (
|
|
48
|
+
<p class="redeemid-plan__unavailable">Not available right now.</p>
|
|
49
|
+
)}
|
|
50
|
+
</div>
|
|
51
|
+
) : (
|
|
52
|
+
<slot />
|
|
53
|
+
)}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RedeemID configuration.
|
|
3
|
+
*
|
|
4
|
+
* Levels and rules live in code (astro.config.mjs), so they are versioned with
|
|
5
|
+
* the site. Secrets come from the environment and never from config.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** A member level, held by Shopify customers with its tags. */
|
|
9
|
+
export interface LevelConfig {
|
|
10
|
+
/** Stable slug, used in rules (e.g. "member"). */
|
|
11
|
+
slug: string;
|
|
12
|
+
/** Name shown to members. */
|
|
13
|
+
name: string;
|
|
14
|
+
/** Shopify customer tags that grant this level. Matched case-insensitively. */
|
|
15
|
+
tags: string[];
|
|
16
|
+
/** "any" (default): one of the tags is enough. "all": every tag is needed. */
|
|
17
|
+
match?: "any" | "all";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Who may read entries. A rule with `term` applies to entries that carry that
|
|
22
|
+
* term; a rule without one applies to the whole collection. Term rules win
|
|
23
|
+
* over collection rules for the same entry.
|
|
24
|
+
*/
|
|
25
|
+
export interface RuleConfig {
|
|
26
|
+
collection: string;
|
|
27
|
+
/** Taxonomy slug, e.g. "category". Requires `term`. */
|
|
28
|
+
taxonomy?: string;
|
|
29
|
+
/** Term slug, e.g. "premium". */
|
|
30
|
+
term?: string;
|
|
31
|
+
/** Level slugs that may read. An empty list means signed-in members of any level. */
|
|
32
|
+
levels: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface RedeemIDConfig {
|
|
36
|
+
/**
|
|
37
|
+
* The store's myshopify.com domain (a custom storefront or customer-account
|
|
38
|
+
* domain works too). Discovery is read from https://<shop>/.well-known/.
|
|
39
|
+
* Leave it out to read REDEEMID_SHOPIFY_STORE_DOMAIN at request time.
|
|
40
|
+
*/
|
|
41
|
+
shop?: string;
|
|
42
|
+
/** Sign-in button label. Members never see "Shopify". */
|
|
43
|
+
label?: string;
|
|
44
|
+
levels?: LevelConfig[];
|
|
45
|
+
rules?: RuleConfig[];
|
|
46
|
+
/**
|
|
47
|
+
* Extra /_emdash path prefixes signed-in members may use. Everything else
|
|
48
|
+
* under /_emdash is refused to Subscribers (see guard.ts).
|
|
49
|
+
*/
|
|
50
|
+
memberPaths?: string[];
|
|
51
|
+
/**
|
|
52
|
+
* Only for local tests: lets `shop` be a full http:// origin of a fake store.
|
|
53
|
+
* Never set this on a live site.
|
|
54
|
+
*/
|
|
55
|
+
allowInsecureShop?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const PROVIDER_ID = "redeemid";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Environment variable names. Values are read at request time, so on Workers
|
|
62
|
+
* they can be secrets.
|
|
63
|
+
*/
|
|
64
|
+
export const ENV = {
|
|
65
|
+
/** The store's myshopify.com domain, when `shop` isn't set in config. */
|
|
66
|
+
storeDomain: "REDEEMID_SHOPIFY_STORE_DOMAIN",
|
|
67
|
+
/** Customer Account API client ID (Headless storefront). */
|
|
68
|
+
clientId: "REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID",
|
|
69
|
+
/** Customer Account API client secret. Confidential clients only; public clients leave it unset. */
|
|
70
|
+
clientSecret: "REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_SECRET",
|
|
71
|
+
/** Client secret of the Shopify (Admin API) app whose webhooks the site receives. Shopify signs webhooks with it. */
|
|
72
|
+
webhookSecret: "REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_SECRET",
|
|
73
|
+
/** Storefront API private token, for server-side plan and checkout calls. */
|
|
74
|
+
storefrontPrivateToken: "REDEEMID_SHOPIFY_STOREFRONT_PRIVATE_TOKEN",
|
|
75
|
+
/** Storefront API public token, used only when the private token isn't set. */
|
|
76
|
+
storefrontPublicToken: "REDEEMID_SHOPIFY_STOREFRONT_PUBLIC_TOKEN",
|
|
77
|
+
} as const;
|
|
78
|
+
|
|
79
|
+
export function env(name: string): string {
|
|
80
|
+
const value = typeof process !== "undefined" ? process.env[name] : undefined;
|
|
81
|
+
return typeof value === "string" ? value.trim() : "";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The store origin, e.g. https://shop.example.com. */
|
|
85
|
+
export function shopOrigin(config: RedeemIDConfig): string {
|
|
86
|
+
const raw = storeDomain(config).replace(/\/+$/, "");
|
|
87
|
+
if (raw === "") throw new Error("RedeemID: no store domain.");
|
|
88
|
+
if (/^https?:\/\//i.test(raw)) {
|
|
89
|
+
const url = new URL(raw);
|
|
90
|
+
if (url.protocol === "http:" && !config.allowInsecureShop) {
|
|
91
|
+
throw new Error("RedeemID: the shop must use https.");
|
|
92
|
+
}
|
|
93
|
+
return url.origin;
|
|
94
|
+
}
|
|
95
|
+
const host = raw.replace(/^admin\.shopify\.com\/store\//i, "").toLowerCase();
|
|
96
|
+
// A bare store handle ("my-store") means its myshopify.com domain.
|
|
97
|
+
return `https://${host.includes(".") ? host : `${host}.myshopify.com`}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The configured store domain, from config or the environment. */
|
|
101
|
+
export function storeDomain(config: RedeemIDConfig): string {
|
|
102
|
+
return (config.shop ?? "").trim() || env(ENV.storeDomain);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Find this provider's config among EmDash's configured auth providers. */
|
|
106
|
+
export function readConfig(emdashConfig: unknown): RedeemIDConfig | null {
|
|
107
|
+
const providers = (emdashConfig as { authProviders?: Array<{ id: string; config?: unknown }> } | null)
|
|
108
|
+
?.authProviders;
|
|
109
|
+
const found = providers?.find((p) => p.id === PROVIDER_ID);
|
|
110
|
+
const config = found?.config as RedeemIDConfig | undefined;
|
|
111
|
+
return config && typeof config === "object" ? config : null;
|
|
112
|
+
}
|