@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 +20 -0
- package/README.md +151 -2
- package/docs/shopify-setup.md +56 -0
- package/package.json +63 -5
- package/src/access.ts +134 -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 +27 -0
- package/src/components/Cart.astro +21 -0
- package/src/components/Content.astro +53 -0
- package/src/components/Gate.astro +40 -0
- package/src/components/Plan.astro +53 -0
- package/src/config.ts +112 -0
- package/src/descriptor.ts +29 -0
- package/src/guard.ts +45 -0
- package/src/index.ts +81 -0
- package/src/plugin.ts +283 -0
- package/src/routes/account.ts +31 -0
- package/src/routes/buy.ts +49 -0
- package/src/routes/callback.ts +106 -0
- package/src/routes/cart.ts +37 -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 +234 -0
- package/src/site-config.ts +15 -0
- package/src/storage.ts +95 -0
- package/src/storefront.ts +186 -0
- package/src/version.ts +1 -0
- package/src/webhooks.ts +161 -0
|
@@ -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
|
+
}
|
|
@@ -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/guard.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeps members out of EmDash's own APIs and admin.
|
|
3
|
+
*
|
|
4
|
+
* EmDash's Subscriber role may read every published entry through
|
|
5
|
+
* /_emdash/api/content. With RedeemID, access comes from Shopify tags only, so
|
|
6
|
+
* a member with one level could otherwise read another level's posts there.
|
|
7
|
+
* This middleware runs after EmDash's auth and refuses Subscribers on every
|
|
8
|
+
* /_emdash path except the few public pages need.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { defineMiddleware } from "astro:middleware";
|
|
12
|
+
|
|
13
|
+
import { siteConfig } from "./site-config.js";
|
|
14
|
+
|
|
15
|
+
const SUBSCRIBER = 10;
|
|
16
|
+
|
|
17
|
+
/** Paths signed-in members still need. Everything else under /_emdash is refused. */
|
|
18
|
+
export const MEMBER_PATHS = [
|
|
19
|
+
"/_emdash/api/auth/redeemid/",
|
|
20
|
+
"/_emdash/api/auth/logout",
|
|
21
|
+
"/_emdash/api/media/file/",
|
|
22
|
+
"/_emdash/api/comments",
|
|
23
|
+
"/_emdash/api/plugins/",
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export function memberMayUse(path: string, extra: string[] = []): boolean {
|
|
27
|
+
if (!path.startsWith("/_emdash")) return true;
|
|
28
|
+
return [...MEMBER_PATHS, ...extra].some((p) => path === p.replace(/\/$/, "") || path.startsWith(p));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const onRequest = defineMiddleware(async ({ locals, url }, next) => {
|
|
32
|
+
const user = (locals as { user?: { role?: number } | null }).user;
|
|
33
|
+
if (!user || user.role !== SUBSCRIBER) return next();
|
|
34
|
+
|
|
35
|
+
const config = siteConfig((locals as { emdash?: { config?: unknown } }).emdash?.config);
|
|
36
|
+
if (memberMayUse(url.pathname, config?.memberPaths)) return next();
|
|
37
|
+
|
|
38
|
+
if (url.pathname.startsWith("/_emdash/admin")) {
|
|
39
|
+
return new Response(null, { status: 302, headers: { location: "/", "cache-control": "private, no-store" } });
|
|
40
|
+
}
|
|
41
|
+
return new Response(JSON.stringify({ error: { code: "FORBIDDEN", message: "Members can't use this API." } }), {
|
|
42
|
+
status: 403,
|
|
43
|
+
headers: { "content-type": "application/json", "cache-control": "private, no-store" },
|
|
44
|
+
});
|
|
45
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RedeemID for EmDash.
|
|
3
|
+
*
|
|
4
|
+
* Shopify runs the store and the customer account; EmDash runs the content.
|
|
5
|
+
* Add to astro.config.mjs:
|
|
6
|
+
*
|
|
7
|
+
* integrations: [
|
|
8
|
+
* emdash({ authProviders: [redeemid({ ... })] }),
|
|
9
|
+
* redeemidGuard(),
|
|
10
|
+
* ]
|
|
11
|
+
*
|
|
12
|
+
* In full:
|
|
13
|
+
*
|
|
14
|
+
* emdash({
|
|
15
|
+
* authProviders: [
|
|
16
|
+
* redeemid({
|
|
17
|
+
* shop: "my-store.myshopify.com",
|
|
18
|
+
* levels: [{ slug: "member", name: "Member", tags: ["member"] }],
|
|
19
|
+
* rules: [{ collection: "posts", taxonomy: "category", term: "members", levels: ["member"] }],
|
|
20
|
+
* }),
|
|
21
|
+
* ],
|
|
22
|
+
* })
|
|
23
|
+
*
|
|
24
|
+
* Secrets come from the environment (see ENV in config.ts):
|
|
25
|
+
* REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID and, for confidential clients,
|
|
26
|
+
* REDEEMID_SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_SECRET (the Headless storefront's
|
|
27
|
+
* Customer Account API client), and REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_SECRET
|
|
28
|
+
* (the client secret of the Shopify app whose webhooks the site receives).
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { AstroIntegration } from "astro";
|
|
32
|
+
import type { AuthProviderDescriptor } from "emdash";
|
|
33
|
+
|
|
34
|
+
import { PROVIDER_ID, type RedeemIDConfig } from "./config.js";
|
|
35
|
+
import { STORAGE_CONFIG } from "./storage.js";
|
|
36
|
+
|
|
37
|
+
export type { LevelConfig, RedeemIDConfig, RuleConfig } from "./config.js";
|
|
38
|
+
export type { MemberRecord } from "./storage.js";
|
|
39
|
+
export { redeemidPlugin } from "./descriptor.js";
|
|
40
|
+
// Page-time helpers live in "@redeemid/emdash/access": they read the site's
|
|
41
|
+
// build-time config module, which astro.config.mjs can't load.
|
|
42
|
+
|
|
43
|
+
const BASE = "/_emdash/api/auth/redeemid";
|
|
44
|
+
|
|
45
|
+
export function redeemid(config: RedeemIDConfig): AuthProviderDescriptor {
|
|
46
|
+
if (!config) throw new Error("RedeemID: pass a config object.");
|
|
47
|
+
return {
|
|
48
|
+
id: PROVIDER_ID,
|
|
49
|
+
label: config.label ?? "Sign in",
|
|
50
|
+
config,
|
|
51
|
+
// No adminEntry: members sign in from the site, not the EmDash login page.
|
|
52
|
+
routes: [
|
|
53
|
+
{ pattern: `${BASE}/login`, entrypoint: "@redeemid/emdash/routes/login.ts" },
|
|
54
|
+
{ pattern: `${BASE}/callback`, entrypoint: "@redeemid/emdash/routes/callback.ts" },
|
|
55
|
+
{ pattern: `${BASE}/logout`, entrypoint: "@redeemid/emdash/routes/logout.ts" },
|
|
56
|
+
{ pattern: `${BASE}/webhook`, entrypoint: "@redeemid/emdash/routes/webhook.ts" },
|
|
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" },
|
|
60
|
+
],
|
|
61
|
+
publicRoutes: [`${BASE}/`],
|
|
62
|
+
storage: STORAGE_CONFIG,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Astro integration that keeps members (EmDash Subscribers) out of EmDash's
|
|
68
|
+
* content API and admin, so only RedeemID's checks decide who reads what.
|
|
69
|
+
*/
|
|
70
|
+
export function redeemidGuard(): AstroIntegration {
|
|
71
|
+
return {
|
|
72
|
+
name: "@redeemid/emdash/guard",
|
|
73
|
+
hooks: {
|
|
74
|
+
"astro:config:setup": ({ addMiddleware }) => {
|
|
75
|
+
addMiddleware({ entrypoint: "@redeemid/emdash/guard.ts", order: "post" });
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export default redeemid;
|
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
|
+
};
|