@redeemid/emdash 0.0.2 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -28
- package/docs/shopify-setup.md +1 -1
- package/package.json +3 -1
- package/src/access.ts +186 -18
- 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 +46 -0
- package/src/cart.ts +25 -0
- package/src/components/AccessBadge.astro +45 -0
- package/src/components/Account.astro +3 -2
- package/src/components/Cart.astro +21 -0
- package/src/components/Content.astro +60 -0
- package/src/config.ts +29 -4
- package/src/descriptor.ts +29 -0
- package/src/index.ts +3 -0
- package/src/plugin.ts +290 -0
- package/src/routes/account.ts +31 -0
- package/src/routes/buy.ts +10 -5
- package/src/routes/cart.ts +37 -0
- package/src/shopify.ts +10 -3
- package/src/storefront.ts +64 -1
- package/src/version.ts +1 -0
|
@@ -0,0 +1,60 @@
|
|
|
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 (with no rule,
|
|
10
|
+
* the break changes nothing, as on WordPress). Others get the "locked" slot (or a built-in notice).
|
|
11
|
+
* A body with no break follows the entry's rule as a whole; readers who can't
|
|
12
|
+
* read it see the collection's teaser (config `teaser`, in words), if any.
|
|
13
|
+
*/
|
|
14
|
+
import { PortableText } from "emdash/ui";
|
|
15
|
+
|
|
16
|
+
import { canRead, entryRequirement, signInUrl, type Terms } from "../access.js";
|
|
17
|
+
import { splitAtPaywall, teaser } from "../blocks-names.js";
|
|
18
|
+
import { siteConfig } from "../site-config.js";
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
value?: unknown[] | null;
|
|
22
|
+
collection?: string;
|
|
23
|
+
terms?: Terms;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const { value, collection = "", terms } = Astro.props;
|
|
27
|
+
const split = splitAtPaywall(value);
|
|
28
|
+
const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
|
|
29
|
+
// A break with levels picked locks the rest to them; with none picked it
|
|
30
|
+
// follows the entry's rule, and does nothing when no rule covers the entry.
|
|
31
|
+
const required = split.paywall
|
|
32
|
+
? split.levels.length
|
|
33
|
+
? [split.levels]
|
|
34
|
+
: await entryRequirement(config, collection, terms)
|
|
35
|
+
: undefined;
|
|
36
|
+
const verdict =
|
|
37
|
+
split.paywall && required === null
|
|
38
|
+
? { allowed: true, reason: "open" as const }
|
|
39
|
+
: await canRead(Astro, { collection, terms }, required ? { required } : undefined);
|
|
40
|
+
const words = config?.teaser?.[collection] ?? 0;
|
|
41
|
+
const shown = split.paywall ? split.open : verdict.allowed ? split.open : teaser(split.open, words);
|
|
42
|
+
const here = Astro.url.pathname + Astro.url.search;
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
{shown.length > 0 && <PortableText value={shown} />}
|
|
46
|
+
{verdict.allowed ? (
|
|
47
|
+
split.locked.length > 0 && <PortableText value={split.locked} />
|
|
48
|
+
) : (
|
|
49
|
+
<div class="redeemid-locked" data-redeemid-reason={verdict.reason}>
|
|
50
|
+
<slot name="locked" />
|
|
51
|
+
{verdict.reason === "sign_in" && (
|
|
52
|
+
<p>
|
|
53
|
+
This is for members. <a href={signInUrl(here)}>Sign in</a> to read on.
|
|
54
|
+
</p>
|
|
55
|
+
)}
|
|
56
|
+
{verdict.reason === "upgrade" && <p>Your membership doesn't include the rest of this yet.</p>}
|
|
57
|
+
{verdict.reason === "disabled" && <p>Your account is turned off. Contact the store for help.</p>}
|
|
58
|
+
{verdict.reason === "unconfigured" && <p>This is for members.</p>}
|
|
59
|
+
</div>
|
|
60
|
+
)}
|
package/src/config.ts
CHANGED
|
@@ -18,18 +18,32 @@ export interface LevelConfig {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* Who may read entries
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* Who may read entries, like RedeemID's content rules on WordPress:
|
|
22
|
+
*
|
|
23
|
+
* - A rule with `taxonomy` and `term` covers entries that carry that term, or
|
|
24
|
+
* a child of it in a hierarchical taxonomy (a child's own rule wins over its
|
|
25
|
+
* parent's).
|
|
26
|
+
* - A rule with neither covers the whole collection.
|
|
27
|
+
* - `collection: "*"` is the site default, for collections with no rule.
|
|
28
|
+
*
|
|
29
|
+
* Term rules beat the collection rule, which beats the site default. When
|
|
30
|
+
* several term rules cover one entry, a reader must pass every one of them
|
|
31
|
+
* (most restrictive), unless `combine` is "any".
|
|
24
32
|
*/
|
|
25
33
|
export interface RuleConfig {
|
|
34
|
+
/** Collection slug, or "*" for the site default. */
|
|
26
35
|
collection: string;
|
|
27
36
|
/** Taxonomy slug, e.g. "category". Requires `term`. */
|
|
28
37
|
taxonomy?: string;
|
|
29
38
|
/** Term slug, e.g. "premium". */
|
|
30
39
|
term?: string;
|
|
31
|
-
/** Level slugs
|
|
40
|
+
/** Level slugs, any of which may read. An empty list means any signed-in member. */
|
|
32
41
|
levels: string[];
|
|
42
|
+
/**
|
|
43
|
+
* A term rule that opens its entries to everyone, whatever else covers them
|
|
44
|
+
* ("Opens" on WordPress). `levels` is ignored.
|
|
45
|
+
*/
|
|
46
|
+
open?: boolean;
|
|
33
47
|
}
|
|
34
48
|
|
|
35
49
|
export interface RedeemIDConfig {
|
|
@@ -43,6 +57,17 @@ export interface RedeemIDConfig {
|
|
|
43
57
|
label?: string;
|
|
44
58
|
levels?: LevelConfig[];
|
|
45
59
|
rules?: RuleConfig[];
|
|
60
|
+
/**
|
|
61
|
+
* How several term rules on one entry combine: "all" (default, the most
|
|
62
|
+
* restrictive: pass every rule) or "any" (pass one of them).
|
|
63
|
+
*/
|
|
64
|
+
combine?: "all" | "any";
|
|
65
|
+
/**
|
|
66
|
+
* Teaser length in words, per collection, for locked entries with no paywall
|
|
67
|
+
* break: readers who can't read on see this many words of the body. Without
|
|
68
|
+
* one they see none of it.
|
|
69
|
+
*/
|
|
70
|
+
teaser?: Record<string, number>;
|
|
46
71
|
/**
|
|
47
72
|
* Extra /_emdash path prefixes signed-in members may use. Everything else
|
|
48
73
|
* under /_emdash is refused to Subscribers (see guard.ts).
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The RedeemID plugin descriptor, safe to load from astro.config.mjs (no
|
|
3
|
+
* runtime imports). The plugin itself is in plugin.ts.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { PluginDescriptor } from "emdash";
|
|
7
|
+
|
|
8
|
+
import type { RedeemIDConfig } from "./config.js";
|
|
9
|
+
import { VERSION } from "./version.js";
|
|
10
|
+
|
|
11
|
+
export const PAGES = [
|
|
12
|
+
{ path: "/", label: "Members", icon: "users" },
|
|
13
|
+
{ path: "/status", label: "Status", icon: "pulse" },
|
|
14
|
+
];
|
|
15
|
+
export const WIDGETS = [{ id: "status", title: "RedeemID", size: "half" as const }];
|
|
16
|
+
|
|
17
|
+
/** The descriptor for `emdash({ plugins: [...] })`. Pass the same config as `redeemid()`. */
|
|
18
|
+
export function redeemidPlugin(config: RedeemIDConfig): PluginDescriptor<RedeemIDConfig> {
|
|
19
|
+
return {
|
|
20
|
+
id: "redeemid",
|
|
21
|
+
version: VERSION,
|
|
22
|
+
entrypoint: "@redeemid/emdash/plugin",
|
|
23
|
+
componentsEntry: "@redeemid/emdash/blocks",
|
|
24
|
+
options: config,
|
|
25
|
+
adminPages: PAGES,
|
|
26
|
+
adminWidgets: WIDGETS,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
package/src/index.ts
CHANGED
|
@@ -36,6 +36,7 @@ import { STORAGE_CONFIG } from "./storage.js";
|
|
|
36
36
|
|
|
37
37
|
export type { LevelConfig, RedeemIDConfig, RuleConfig } from "./config.js";
|
|
38
38
|
export type { MemberRecord } from "./storage.js";
|
|
39
|
+
export { redeemidPlugin } from "./descriptor.js";
|
|
39
40
|
// Page-time helpers live in "@redeemid/emdash/access": they read the site's
|
|
40
41
|
// build-time config module, which astro.config.mjs can't load.
|
|
41
42
|
|
|
@@ -54,6 +55,8 @@ export function redeemid(config: RedeemIDConfig): AuthProviderDescriptor {
|
|
|
54
55
|
{ pattern: `${BASE}/logout`, entrypoint: "@redeemid/emdash/routes/logout.ts" },
|
|
55
56
|
{ pattern: `${BASE}/webhook`, entrypoint: "@redeemid/emdash/routes/webhook.ts" },
|
|
56
57
|
{ pattern: `${BASE}/buy`, entrypoint: "@redeemid/emdash/routes/buy.ts" },
|
|
58
|
+
{ pattern: `${BASE}/cart`, entrypoint: "@redeemid/emdash/routes/cart.ts" },
|
|
59
|
+
{ pattern: `${BASE}/account`, entrypoint: "@redeemid/emdash/routes/account.ts" },
|
|
57
60
|
],
|
|
58
61
|
publicRoutes: [`${BASE}/`],
|
|
59
62
|
storage: STORAGE_CONFIG,
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
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 ("Signed in" for any
|
|
15
|
+
* member); none picked means the entry's rule, and nothing is locked when
|
|
16
|
+
* no rule covers the entry, as on WordPress.
|
|
17
|
+
* - "Plan" (`redeemid-plan`): a Shopify product's title and price with a
|
|
18
|
+
* button that adds it to the cart and opens checkout.
|
|
19
|
+
*
|
|
20
|
+
* Render bodies with `<Content>` (components/Content.astro), which keeps the
|
|
21
|
+
* part after a paywall break off the page for readers who can't read it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { ResolvedPlugin, RouteContext } from "emdash";
|
|
25
|
+
import { definePlugin } from "emdash";
|
|
26
|
+
|
|
27
|
+
import { describe, levelsFor, SIGNED_IN } from "./access.js";
|
|
28
|
+
import { env, ENV, storeDomain, type RedeemIDConfig } from "./config.js";
|
|
29
|
+
import { discover } from "./shopify.js";
|
|
30
|
+
import { stores, type MemberRecord } from "./storage.js";
|
|
31
|
+
import { PAGES, WIDGETS } from "./descriptor.js";
|
|
32
|
+
import { VERSION } from "./version.js";
|
|
33
|
+
|
|
34
|
+
import { PAYWALL_BLOCK, PLAN_BLOCK } from "./blocks-names.js";
|
|
35
|
+
|
|
36
|
+
export { PAYWALL_BLOCK, PLAN_BLOCK };
|
|
37
|
+
|
|
38
|
+
function blocks(config: RedeemIDConfig) {
|
|
39
|
+
const levelOptions = [
|
|
40
|
+
{ label: "Signed in (any member)", value: SIGNED_IN },
|
|
41
|
+
...(config.levels ?? []).map((l) => ({ label: l.name, value: l.slug })),
|
|
42
|
+
];
|
|
43
|
+
return [
|
|
44
|
+
{
|
|
45
|
+
type: PAYWALL_BLOCK,
|
|
46
|
+
label: "Paywall break",
|
|
47
|
+
icon: "lock",
|
|
48
|
+
description: "Everything after this is for members",
|
|
49
|
+
fields: [
|
|
50
|
+
{
|
|
51
|
+
type: "checkbox" as const,
|
|
52
|
+
action_id: "levels",
|
|
53
|
+
label: "Who can read on (none = the entry's rule)",
|
|
54
|
+
options: levelOptions,
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
type: PLAN_BLOCK,
|
|
60
|
+
label: "Plan",
|
|
61
|
+
icon: "tag",
|
|
62
|
+
description: "A Shopify product with its price and a buy button",
|
|
63
|
+
placeholder: "Shopify product handle",
|
|
64
|
+
fields: [
|
|
65
|
+
{ type: "text_input" as const, action_id: "handle", label: "Shopify product handle", placeholder: "gold-membership" },
|
|
66
|
+
{ type: "text_input" as const, action_id: "title", label: "Title (optional, Shopify's title otherwise)" },
|
|
67
|
+
{ type: "text_input" as const, action_id: "label", label: "Button label", placeholder: "Join" },
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function createPlugin(config: RedeemIDConfig = {}): ResolvedPlugin {
|
|
74
|
+
return definePlugin({
|
|
75
|
+
id: "redeemid",
|
|
76
|
+
version: VERSION,
|
|
77
|
+
capabilities: [],
|
|
78
|
+
admin: {
|
|
79
|
+
pages: PAGES,
|
|
80
|
+
widgets: WIDGETS,
|
|
81
|
+
portableTextBlocks: blocks(config),
|
|
82
|
+
},
|
|
83
|
+
routes: {
|
|
84
|
+
admin: {
|
|
85
|
+
handler: async (ctx: RouteContext) => {
|
|
86
|
+
const i = (ctx.input ?? {}) as { type?: string; page?: string };
|
|
87
|
+
if (i.type !== "page_load") return { blocks: [] };
|
|
88
|
+
try {
|
|
89
|
+
if (i.page === "/") return await membersPage(config);
|
|
90
|
+
if (i.page === "/status") return await statusPage(config, ctx.request);
|
|
91
|
+
if (i.page === "widget:status") return await statusWidget(config);
|
|
92
|
+
} catch (e) {
|
|
93
|
+
return { blocks: [{ type: "banner", variant: "error", title: "RedeemID couldn't load this page", description: (e as Error).message }] };
|
|
94
|
+
}
|
|
95
|
+
return { blocks: [] };
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export default createPlugin;
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------- pages
|
|
105
|
+
|
|
106
|
+
async function memberStores() {
|
|
107
|
+
const { getDb } = await import("emdash/runtime");
|
|
108
|
+
return stores(await getDb());
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function allMembers(): Promise<MemberRecord[]> {
|
|
112
|
+
const s = await memberStores();
|
|
113
|
+
const out: MemberRecord[] = [];
|
|
114
|
+
let cursor: string | undefined;
|
|
115
|
+
do {
|
|
116
|
+
const page = await s.members.query({ limit: 100, cursor });
|
|
117
|
+
out.push(...page.items.map((r) => r.data));
|
|
118
|
+
cursor = page.cursor;
|
|
119
|
+
} while (cursor && out.length < 1000);
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function membersPage(config: RedeemIDConfig) {
|
|
124
|
+
const members = await allMembers();
|
|
125
|
+
const levels = config.levels ?? [];
|
|
126
|
+
const counts = levels.map((l) => ({
|
|
127
|
+
label: l.name,
|
|
128
|
+
value: String(members.filter((m) => m.status === "enabled" && levelsFor(m.tags, levels).includes(l.slug)).length),
|
|
129
|
+
}));
|
|
130
|
+
const name = (m: MemberRecord) => [m.firstName, m.lastName].filter(Boolean).join(" ") || "—";
|
|
131
|
+
const levelNames = (m: MemberRecord) => {
|
|
132
|
+
const held = levelsFor(m.tags, levels);
|
|
133
|
+
return levels.filter((l) => held.includes(l.slug)).map((l) => l.name).join(", ") || "—";
|
|
134
|
+
};
|
|
135
|
+
return {
|
|
136
|
+
blocks: [
|
|
137
|
+
{ type: "header", text: "Members" },
|
|
138
|
+
{
|
|
139
|
+
type: "context",
|
|
140
|
+
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.",
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
type: "stats",
|
|
144
|
+
items: [
|
|
145
|
+
{ label: "Members", value: String(members.length) },
|
|
146
|
+
{ label: "Turned off", value: String(members.filter((m) => m.status === "disabled").length) },
|
|
147
|
+
...counts,
|
|
148
|
+
],
|
|
149
|
+
},
|
|
150
|
+
members.length === 0
|
|
151
|
+
? { type: "context", text: "No members yet. They appear here after their first sign-in or a Shopify customer webhook." }
|
|
152
|
+
: {
|
|
153
|
+
type: "table",
|
|
154
|
+
page_action_id: "members_page",
|
|
155
|
+
columns: [
|
|
156
|
+
{ key: "name", label: "Name" },
|
|
157
|
+
{ key: "email", label: "Email" },
|
|
158
|
+
{ key: "levels", label: "Levels" },
|
|
159
|
+
{ key: "tags", label: "Shopify tags" },
|
|
160
|
+
{ key: "status", label: "Account" },
|
|
161
|
+
{ key: "synced", label: "Last synced", format: "relative_time" },
|
|
162
|
+
],
|
|
163
|
+
rows: members
|
|
164
|
+
.sort((a, b) => b.syncedAt.localeCompare(a.syncedAt))
|
|
165
|
+
.map((m) => ({
|
|
166
|
+
name: name(m),
|
|
167
|
+
email: m.email ?? "—",
|
|
168
|
+
levels: levelNames(m),
|
|
169
|
+
tags: m.tags.join(", ") || "—",
|
|
170
|
+
status: m.status === "enabled" ? "On" : "Off",
|
|
171
|
+
synced: m.syncedAt,
|
|
172
|
+
})),
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function checks(config: RedeemIDConfig) {
|
|
179
|
+
const domain = storeDomain(config);
|
|
180
|
+
let discovery = "Not checked";
|
|
181
|
+
if (domain) {
|
|
182
|
+
discovery = await discover(config)
|
|
183
|
+
.then((d) => `OK (${new URL(d.authorizationEndpoint).host})`)
|
|
184
|
+
.catch((e: Error) => `Failed: ${e.message}`);
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
domain,
|
|
188
|
+
discovery,
|
|
189
|
+
clientId: Boolean(env(ENV.clientId)),
|
|
190
|
+
webhookSecret: Boolean(env(ENV.webhookSecret)),
|
|
191
|
+
storefront: env(ENV.storefrontPrivateToken) ? "Private token" : env(ENV.storefrontPublicToken) ? "Public token" : "None (tokenless)",
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function lastDelivery(): Promise<string | null> {
|
|
196
|
+
const s = await memberStores();
|
|
197
|
+
const { items } = await s.deliveries.query({ orderBy: { receivedAt: "desc" }, limit: 1 });
|
|
198
|
+
const d = items[0]?.data;
|
|
199
|
+
return d ? `${d.topic} at ${d.receivedAt}` : null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function statusPage(config: RedeemIDConfig, request: Request) {
|
|
203
|
+
const c = await checks(config);
|
|
204
|
+
const origin = new URL(request.url).origin;
|
|
205
|
+
const yes = (v: boolean) => (v ? "Set" : "Missing");
|
|
206
|
+
const levels = config.levels ?? [];
|
|
207
|
+
const rules = config.rules ?? [];
|
|
208
|
+
const delivery = await lastDelivery().catch(() => null);
|
|
209
|
+
return {
|
|
210
|
+
blocks: [
|
|
211
|
+
{ type: "header", text: "Status" },
|
|
212
|
+
c.discovery.startsWith("OK") && c.clientId
|
|
213
|
+
? { type: "banner", variant: "default", title: "Sign-in is ready", description: "Members can sign in with their Shopify customer account." }
|
|
214
|
+
: { type: "banner", variant: "alert", title: "Sign-in isn't ready", description: "Set the missing values below as environment secrets, then reload." },
|
|
215
|
+
{
|
|
216
|
+
type: "fields",
|
|
217
|
+
fields: [
|
|
218
|
+
{ label: "Store domain (REDEEMID_SHOPIFY_STORE_DOMAIN or shop)", value: c.domain || "Missing" },
|
|
219
|
+
{ label: "Store discovery", value: c.discovery },
|
|
220
|
+
{ label: "Customer Account API client ID", value: yes(c.clientId) },
|
|
221
|
+
{ label: "Webhook signing secret", value: yes(c.webhookSecret) },
|
|
222
|
+
{ label: "Storefront API access", value: c.storefront },
|
|
223
|
+
{ label: "Last webhook", value: delivery ?? "None received yet" },
|
|
224
|
+
],
|
|
225
|
+
},
|
|
226
|
+
{ type: "divider" },
|
|
227
|
+
{ type: "header", text: "Shopify settings" },
|
|
228
|
+
{
|
|
229
|
+
type: "fields",
|
|
230
|
+
fields: [
|
|
231
|
+
{ label: "Callback URI", value: `${origin}/_emdash/api/auth/redeemid/callback` },
|
|
232
|
+
{ label: "JavaScript origin", value: origin },
|
|
233
|
+
{ label: "Logout URI", value: `${origin}/` },
|
|
234
|
+
{ label: "Webhook URL", value: `${origin}/_emdash/api/auth/redeemid/webhook` },
|
|
235
|
+
],
|
|
236
|
+
},
|
|
237
|
+
{ type: "divider" },
|
|
238
|
+
{ type: "header", text: "Levels" },
|
|
239
|
+
{ type: "context", text: "Set in astro.config.mjs." },
|
|
240
|
+
levels.length
|
|
241
|
+
? {
|
|
242
|
+
type: "table",
|
|
243
|
+
page_action_id: "levels_page",
|
|
244
|
+
columns: [
|
|
245
|
+
{ key: "name", label: "Level" },
|
|
246
|
+
{ key: "tags", label: "Shopify tags" },
|
|
247
|
+
{ key: "match", label: "Needs" },
|
|
248
|
+
],
|
|
249
|
+
rows: levels.map((l) => ({ name: l.name, tags: l.tags.join(", "), match: l.match === "all" ? "All tags" : "Any tag" })),
|
|
250
|
+
}
|
|
251
|
+
: { type: "context", text: "No levels configured." },
|
|
252
|
+
{ type: "header", text: "Rules" },
|
|
253
|
+
rules.length
|
|
254
|
+
? {
|
|
255
|
+
type: "table",
|
|
256
|
+
page_action_id: "rules_page",
|
|
257
|
+
columns: [
|
|
258
|
+
{ key: "where", label: "Covers" },
|
|
259
|
+
{ key: "levels", label: "Who can read" },
|
|
260
|
+
],
|
|
261
|
+
rows: rules.map((r) => ({
|
|
262
|
+
where:
|
|
263
|
+
r.taxonomy && r.term
|
|
264
|
+
? `${r.collection === "*" ? "Every collection" : r.collection}, ${r.taxonomy}: ${r.term} (and its child terms)`
|
|
265
|
+
: r.collection === "*"
|
|
266
|
+
? "Site default (collections with no rule)"
|
|
267
|
+
: `All ${r.collection}`,
|
|
268
|
+
levels: r.open ? "Everyone (opens the entry)" : r.levels.length ? describe([r.levels], levels) : "Any signed-in member",
|
|
269
|
+
})),
|
|
270
|
+
}
|
|
271
|
+
: { type: "context", text: "No rules. Entries are open unless their body has a paywall break." },
|
|
272
|
+
],
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function statusWidget(config: RedeemIDConfig) {
|
|
277
|
+
const c = await checks(config);
|
|
278
|
+
const members = await allMembers().catch(() => []);
|
|
279
|
+
return {
|
|
280
|
+
blocks: [
|
|
281
|
+
{
|
|
282
|
+
type: "fields",
|
|
283
|
+
fields: [
|
|
284
|
+
{ label: "Sign-in", value: c.discovery.startsWith("OK") && c.clientId ? "Ready" : "Not ready" },
|
|
285
|
+
{ label: "Members", value: String(members.length) },
|
|
286
|
+
],
|
|
287
|
+
},
|
|
288
|
+
],
|
|
289
|
+
};
|
|
290
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /_emdash/api/auth/redeemid/account
|
|
3
|
+
*
|
|
4
|
+
* Sends a member to their Shopify customer account (orders, profile,
|
|
5
|
+
* addresses), on the store's own account domain. Visitors who aren't
|
|
6
|
+
* signed in go through sign-in first; Shopify would ask them anyway.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { APIRoute } from "astro";
|
|
10
|
+
|
|
11
|
+
import { safeReturn, signInUrl } from "../access.js";
|
|
12
|
+
import { readConfig, storeDomain } from "../config.js";
|
|
13
|
+
import { discover } from "../shopify.js";
|
|
14
|
+
import { fail } from "./shared.js";
|
|
15
|
+
|
|
16
|
+
export const prerender = false;
|
|
17
|
+
|
|
18
|
+
export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
|
|
19
|
+
const returnTo = safeReturn(new URL(request.url).searchParams.get("return"));
|
|
20
|
+
const config = readConfig((locals as { emdash?: { config?: unknown } }).emdash?.config);
|
|
21
|
+
if (!config || !storeDomain(config)) return fail(redirect, returnTo, "not_configured");
|
|
22
|
+
|
|
23
|
+
const user = (await session?.get("user")) as { id?: string } | undefined;
|
|
24
|
+
if (!user?.id) return redirect(signInUrl(returnTo));
|
|
25
|
+
try {
|
|
26
|
+
const d = await discover(config);
|
|
27
|
+
return redirect(new URL("/", d.authorizationEndpoint).href);
|
|
28
|
+
} catch {
|
|
29
|
+
return fail(redirect, returnTo, "account_unavailable");
|
|
30
|
+
}
|
|
31
|
+
};
|
package/src/routes/buy.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* POST /_emdash/api/auth/redeemid/buy:
|
|
2
|
+
* POST /_emdash/api/auth/redeemid/buy: add a plan to the cart and check out.
|
|
3
3
|
*
|
|
4
4
|
* Takes a product handle, looks its variant up again on Shopify (never
|
|
5
|
-
* trusting a posted variant or price)
|
|
5
|
+
* trusting a posted variant or price), adds it to the visitor's Shopify
|
|
6
|
+
* cart and sends them to that cart's checkout. A
|
|
6
7
|
* signed-in member's email is passed along so the order lands on the same
|
|
7
8
|
* customer. Tags, and so access, still only change when Shopify says so.
|
|
8
9
|
*/
|
|
@@ -12,7 +13,8 @@ import type { APIRoute } from "astro";
|
|
|
12
13
|
import { readConfig, storeDomain } from "../config.js";
|
|
13
14
|
import { safeReturn } from "../access.js";
|
|
14
15
|
import { memberForUser, stores } from "../storage.js";
|
|
15
|
-
import {
|
|
16
|
+
import { saveCart, readCart } from "../cart.js";
|
|
17
|
+
import { addToCart, plan } from "../storefront.js";
|
|
16
18
|
import { fail } from "./shared.js";
|
|
17
19
|
|
|
18
20
|
export const prerender = false;
|
|
@@ -32,12 +34,15 @@ export const POST: APIRoute = async ({ request, locals, session, redirect }) =>
|
|
|
32
34
|
// Provider routes are public, so read the member from the session.
|
|
33
35
|
const user = (await session?.get("user")) as { id?: string } | undefined;
|
|
34
36
|
const member = user?.id && emdash?.db ? await memberForUser(stores(emdash.db), user.id) : null;
|
|
35
|
-
const
|
|
37
|
+
const current = await readCart(session);
|
|
38
|
+
const cart = await addToCart(
|
|
36
39
|
config,
|
|
40
|
+
current?.id ?? null,
|
|
37
41
|
{ variantId: p.variantId, sellingPlanId: p.sellingPlanId },
|
|
38
42
|
{ email: member?.email ?? null, buyerIp: request.headers.get("cf-connecting-ip") },
|
|
39
43
|
);
|
|
40
|
-
|
|
44
|
+
saveCart(session, { id: cart.id, count: cart.totalQuantity });
|
|
45
|
+
return redirect(cart.checkoutUrl, 303);
|
|
41
46
|
} catch {
|
|
42
47
|
return fail(redirect, returnTo, "checkout_failed");
|
|
43
48
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /_emdash/api/auth/redeemid/cart?return=/path
|
|
3
|
+
*
|
|
4
|
+
* Sends the visitor to their Shopify cart's checkout. With no cart, or one
|
|
5
|
+
* Shopify has closed (its checkout completed), they go back with the
|
|
6
|
+
* `cart_empty` notice and the stored cart is cleared.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { APIRoute } from "astro";
|
|
10
|
+
|
|
11
|
+
import { safeReturn } from "../access.js";
|
|
12
|
+
import { readCart, saveCart } from "../cart.js";
|
|
13
|
+
import { readConfig, storeDomain } from "../config.js";
|
|
14
|
+
import { getCart } from "../storefront.js";
|
|
15
|
+
import { fail } from "./shared.js";
|
|
16
|
+
|
|
17
|
+
export const prerender = false;
|
|
18
|
+
|
|
19
|
+
export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
|
|
20
|
+
const returnTo = safeReturn(new URL(request.url).searchParams.get("return"));
|
|
21
|
+
const config = readConfig((locals as { emdash?: { config?: unknown } }).emdash?.config);
|
|
22
|
+
if (!config || !storeDomain(config)) return fail(redirect, returnTo, "not_configured");
|
|
23
|
+
|
|
24
|
+
const ref = await readCart(session);
|
|
25
|
+
if (!ref) return fail(redirect, returnTo, "cart_empty");
|
|
26
|
+
try {
|
|
27
|
+
const cart = await getCart(config, ref.id, { buyerIp: request.headers.get("cf-connecting-ip") });
|
|
28
|
+
if (!cart || cart.totalQuantity < 1) {
|
|
29
|
+
saveCart(session, null);
|
|
30
|
+
return fail(redirect, returnTo, "cart_empty");
|
|
31
|
+
}
|
|
32
|
+
saveCart(session, { id: cart.id, count: cart.totalQuantity });
|
|
33
|
+
return redirect(cart.checkoutUrl, 303);
|
|
34
|
+
} catch {
|
|
35
|
+
return fail(redirect, returnTo, "checkout_failed");
|
|
36
|
+
}
|
|
37
|
+
};
|
package/src/shopify.ts
CHANGED
|
@@ -24,6 +24,12 @@ const DISCOVERY_TTL_MS = 60 * 60 * 1000;
|
|
|
24
24
|
const discoveryCache = new Map<string, { at: number; value: Discovery }>();
|
|
25
25
|
const jwksCache = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Shopify answers 403 to requests with no User-Agent, and fetch on
|
|
29
|
+
* Cloudflare Workers sends none, so every server-side call names itself.
|
|
30
|
+
*/
|
|
31
|
+
export const USER_AGENT = "@redeemid/emdash (+https://github.com/webmultipliers/redeemid-emdash)";
|
|
32
|
+
|
|
27
33
|
export class ShopifyError extends Error {
|
|
28
34
|
constructor(
|
|
29
35
|
public readonly code: string,
|
|
@@ -65,7 +71,7 @@ export async function discover(config: RedeemIDConfig, fetcher: typeof fetch = f
|
|
|
65
71
|
}
|
|
66
72
|
|
|
67
73
|
async function getJson(fetcher: typeof fetch, url: string): Promise<Record<string, unknown>> {
|
|
68
|
-
const res = await fetcher(url, { headers: { Accept: "application/json" } });
|
|
74
|
+
const res = await fetcher(url, { headers: { Accept: "application/json", "User-Agent": USER_AGENT } });
|
|
69
75
|
if (!res.ok) throw new ShopifyError("discovery_failed", `Discovery failed (${res.status}) for ${url}`);
|
|
70
76
|
return (await res.json()) as Record<string, unknown>;
|
|
71
77
|
}
|
|
@@ -128,6 +134,7 @@ export async function exchangeCode(
|
|
|
128
134
|
const headers: Record<string, string> = {
|
|
129
135
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
130
136
|
Accept: "application/json",
|
|
137
|
+
"User-Agent": USER_AGENT,
|
|
131
138
|
};
|
|
132
139
|
// Public clients are checked against their registered JavaScript origins.
|
|
133
140
|
if (args.origin) headers.Origin = args.origin;
|
|
@@ -149,7 +156,7 @@ export async function verifyIdToken(
|
|
|
149
156
|
): Promise<JWTPayload> {
|
|
150
157
|
let jwks = jwksCache.get(d.jwksUri);
|
|
151
158
|
if (!jwks) {
|
|
152
|
-
jwks = createRemoteJWKSet(new URL(d.jwksUri));
|
|
159
|
+
jwks = createRemoteJWKSet(new URL(d.jwksUri), { headers: { "User-Agent": USER_AGENT } });
|
|
153
160
|
jwksCache.set(d.jwksUri, jwks);
|
|
154
161
|
}
|
|
155
162
|
try {
|
|
@@ -191,7 +198,7 @@ export async function identity(d: Discovery, accessToken: string, fetcher: typeo
|
|
|
191
198
|
const res = await fetcher(d.graphqlApi, {
|
|
192
199
|
method: "POST",
|
|
193
200
|
// The Customer Account API takes the bare access token, without "Bearer".
|
|
194
|
-
headers: { "Content-Type": "application/json", Authorization: accessToken },
|
|
201
|
+
headers: { "Content-Type": "application/json", Authorization: accessToken, "User-Agent": USER_AGENT },
|
|
195
202
|
body: JSON.stringify({ query: IDENTITY_QUERY }),
|
|
196
203
|
});
|
|
197
204
|
const data = (await res.json().catch(() => ({}))) as {
|