@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.
@@ -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. 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.
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 that may read. An empty list means signed-in members of any level. */
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: start a Shopify checkout for one plan.
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) and sends the buyer to checkout. A
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 { checkoutUrl, plan } from "../storefront.js";
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 to = await checkoutUrl(
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
- return redirect(to, 303);
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 {