@redeemid/emdash 0.0.0-stage → 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/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,152 @@
1
- # Temporary Holding Version
1
+ # RedeemID for EmDash
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.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
+
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, 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.
18
+ - **Keeps members out of EmDash's own APIs**, so the gate is the only way to read locked content.
19
+
20
+ ## Install
21
+
22
+ 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.
23
+
24
+ ```sh
25
+ pnpm add @redeemid/emdash
26
+ ```
27
+
28
+ ```js title="astro.config.mjs"
29
+ import { redeemid, redeemidGuard, redeemidPlugin } from "@redeemid/emdash";
30
+ import emdash from "emdash/astro";
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
+
46
+ export default defineConfig({
47
+ integrations: [
48
+ emdash({
49
+ // database, storage, …
50
+ authProviders: [redeemid(config)], // sign-in, webhooks, cart and checkout routes
51
+ plugins: [redeemidPlugin(config)], // admin pages and the editor blocks
52
+ }),
53
+ redeemidGuard(),
54
+ ],
55
+ });
56
+ ```
57
+
58
+ 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:
59
+
60
+ | Variable | Shopify API | What it is |
61
+ | --- | --- | --- |
62
+ | `REDEEMID_SHOPIFY_STORE_DOMAIN` | Customer Account API discovery | The store's `*.myshopify.com` domain. Only needed when `shop` isn't set in the config. |
63
+ | `REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID` | Customer Account API | Client ID of the Headless storefront's Customer Account API client |
64
+ | `REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_SECRET` | Customer Account API | Its client secret. Confidential clients only; leave unset for a public client. |
65
+ | `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. |
66
+ | `REDEEMID_SHOPIFY_STOREFRONT_PRIVATE_TOKEN` | Storefront API | Private access token, for the plan cards and checkout (server-side). |
67
+ | `REDEEMID_SHOPIFY_STOREFRONT_PUBLIC_TOKEN` | Storefront API | Public access token. Only used when the private token isn't set. |
68
+
69
+ RedeemID finds the authorization, token and logout endpoints itself from the store's discovery documents, so they need no setting.
70
+
71
+ In Shopify, give the Customer Account API client these settings:
72
+
73
+ - Callback URI: `https://your-site.example/_emdash/api/auth/redeemid/callback`
74
+ - JavaScript origin (public clients): `https://your-site.example`
75
+ - Logout URI: `https://your-site.example/`
76
+
77
+ 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`.
78
+
79
+ ## Use it in templates
80
+
81
+ EmDash has no content filter hook, so templates decide what to render. Render the entry body with `<Content>` instead of `<PortableText>`:
82
+
83
+ ```astro
84
+ ---
85
+ import Content from "@redeemid/emdash/components/Content.astro";
86
+ ---
87
+ <Content value={post.data.content} collection="posts" terms={post.data.terms}>
88
+ <a slot="locked" href="/join/">Become a member</a>
89
+ </Content>
90
+ ```
91
+
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:
97
+
98
+ ```astro
99
+ ---
100
+ import Account from "@redeemid/emdash/components/Account.astro";
101
+ import Cart from "@redeemid/emdash/components/Cart.astro";
102
+ ---
103
+ <Account />
104
+ <Cart />
105
+ ```
106
+
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:
108
+
109
+ ```astro
110
+ ---
111
+ import Plan from "@redeemid/emdash/components/Plan.astro";
112
+ ---
113
+ <Plan handle="gold-membership" label="Join Gold">
114
+ <p>Shown if the product can't be loaded.</p>
115
+ </Plan>
116
+ ```
117
+
118
+ Or ask directly:
119
+
120
+ ```ts
121
+ import { canRead } from "@redeemid/emdash/access";
122
+
123
+ const verdict = await canRead(Astro, { collection: "posts", terms: post.data.terms });
124
+ // verdict.allowed, verdict.reason: open | allowed | sign_in | upgrade | disabled
125
+ ```
126
+
127
+ Pages rendered for a signed-in member are sent with `Cache-Control: private, no-store`; pages for visitors stay cacheable.
128
+
129
+ ## Rules
130
+
131
+ - Shopify is the only source of entitlements. Nothing on the EmDash side grants access.
132
+ - Levels read the mirrored tags as they are, however old; they change only when Shopify sends new ones.
133
+ - Status (enabled or disabled) comes only from the `customers/enable` and `customers/disable` webhooks.
134
+ - Staff accounts (any EmDash role above Subscriber) can't sign in through the store, even with a matching email.
135
+ - Customers can't create the first (admin) account on a new site.
136
+
137
+ ## Develop
138
+
139
+ ```sh
140
+ pnpm install
141
+ pnpm test # unit tests
142
+ pnpm typecheck
143
+ pnpm e2e # boots the playground site against a fake Shopify store
144
+ ```
145
+
146
+ 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`.
147
+
148
+ `playground/` is the EmDash starter template with RedeemID wired in and two locked posts in its seed.
149
+
150
+ ## License
151
+
152
+ 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`. 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,64 @@
1
1
  {
2
- "name": "@redeemid/emdash",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@redeemid/emdash",
3
+ "version": "0.0.3",
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
+ "./plugin": "./src/plugin.ts",
19
+ "./blocks": "./src/blocks/index.ts",
20
+ "./components/*": "./src/components/*",
21
+ "./routes/*": "./src/routes/*"
22
+ },
23
+ "files": [
24
+ "src",
25
+ "README.md",
26
+ "LICENSE",
27
+ "docs"
28
+ ],
29
+ "keywords": [
30
+ "emdash",
31
+ "shopify",
32
+ "paywall",
33
+ "membership",
34
+ "customer-accounts"
35
+ ],
36
+ "scripts": {
37
+ "test": "vitest run",
38
+ "typecheck": "tsc --noEmit",
39
+ "e2e": "node tests/e2e/run.mjs"
40
+ },
41
+ "peerDependencies": {
42
+ "@emdash-cms/auth": ">=1.1.0",
43
+ "astro": ">=6",
44
+ "emdash": ">=1.1.0"
45
+ },
46
+ "dependencies": {
47
+ "jose": "^6.1.3"
48
+ },
49
+ "devDependencies": {
50
+ "@emdash-cms/auth": "^1.4.0",
51
+ "astro": "^7.3.2",
52
+ "emdash": "^1.4.0",
53
+ "kysely": "^0.29.0",
54
+ "typescript": "^6.0.3",
55
+ "vitest": "4.1.10"
56
+ },
57
+ "packageManager": "pnpm@10.28.0",
58
+ "publishConfig": {
59
+ "access": "public"
60
+ },
61
+ "bugs": {
62
+ "url": "https://github.com/webmultipliers/redeemid-emdash/issues"
63
+ }
64
+ }
package/src/access.ts ADDED
@@ -0,0 +1,134 @@
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
+ /** 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
+
132
+ export function signOutUrl(returnTo = "/"): string {
133
+ return `/_emdash/api/auth/redeemid/logout?return=${encodeURIComponent(safeReturn(returnTo))}`;
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
+ }
@@ -0,0 +1,27 @@
1
+ ---
2
+ /**
3
+ * Sign in link for visitors; name, levels, Account and Sign out for members.
4
+ */
5
+ import { siteConfig } from "../site-config.js";
6
+ import { accountUrl, 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
+ <a class="redeemid-account__link" href={accountUrl(here)}>Account</a>
20
+ <form method="post" action={signOutUrl(here)}>
21
+ <button type="submit">Sign out</button>
22
+ </form>
23
+ </>
24
+ ) : (
25
+ <a href={signInUrl(here)}>{config?.label ?? "Sign in"}</a>
26
+ )}
27
+ </div>
@@ -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
+ )}