@redeemid/emdash 0.0.19 → 0.0.20

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/src/setup.ts ADDED
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Plugins → RedeemID → Setup: connecting a store, step by step, from the
3
+ * admin alone. Each step says what to do in Shopify (with the exact values
4
+ * to paste), what to set on the site, and has a button for whatever
5
+ * RedeemID can do itself: register the webhooks, add the first level.
6
+ *
7
+ * Nothing about progress is stored. Each step's state comes from the site
8
+ * as it is (setupFacts), so opening Setup again picks up where things stand
9
+ * and it always agrees with the Status page.
10
+ *
11
+ * Secrets stay where they are today, in the site's environment: the wizard
12
+ * checks them and gives the command to set each one, and never stores one.
13
+ * It writes only what staff can already write (a level, through Access) and
14
+ * never grants access: levels still come only from Shopify tags.
15
+ *
16
+ * setupSteps() and setupPage() are pure.
17
+ */
18
+
19
+ import { adminConfigured, adminScopes, webhookSubscriptions, WEBHOOK_TOPICS } from "./admin-api.js";
20
+ import { env, ENV, storeDomain, type RedeemIDConfig } from "./config.js";
21
+ import { discover } from "./shopify.js";
22
+ import type { Stores } from "./storage.js";
23
+
24
+ type Toast = { message: string; type: "success" | "error" | "info" };
25
+ export type Blocks = { blocks: unknown[]; toast?: Toast };
26
+
27
+ /** Scopes the webhook app needs for staff tools and webhook registration. */
28
+ export const SETUP_SCOPES = ["read_customers", "write_customers"] as const;
29
+
30
+ export const CALLBACK = "/_emdash/api/auth/redeemid/callback";
31
+ export const WEBHOOK = "/_emdash/api/auth/redeemid/webhook";
32
+
33
+ /** What the site looks like right now, for the steps. */
34
+ export interface SetupFacts {
35
+ origin: string;
36
+ domain: string | null;
37
+ /** null when there's no domain to look up; "ok", or why discovery failed. */
38
+ discovery: string | null;
39
+ clientId: boolean;
40
+ clientSecret: boolean;
41
+ webhookSecret: boolean;
42
+ adminClientId: boolean;
43
+ /** null without the app; "ok", or why the Admin API token was refused. */
44
+ admin: string | null;
45
+ scopes: string[];
46
+ /** Topics not yet sent to this site, or null when that can't be checked. */
47
+ webhooksMissing: string[] | null;
48
+ levels: Array<{ name: string; tags: string[] }>;
49
+ /** Members who have signed in on this site. */
50
+ signedIn: number;
51
+ storefront: boolean;
52
+ }
53
+
54
+ export type StepState = "done" | "todo" | "waiting" | "optional";
55
+
56
+ export interface Step {
57
+ key: "store" | "sign_in" | "app" | "webhooks" | "level" | "try" | "storefront";
58
+ title: string;
59
+ state: StepState;
60
+ /** Why it's waiting, for a step that needs an earlier one. */
61
+ waitingFor?: string;
62
+ }
63
+
64
+ /** Read the facts. Every check that fails says why instead of throwing. */
65
+ export async function setupFacts(config: RedeemIDConfig, s: Stores, origin: string): Promise<SetupFacts> {
66
+ const domain = storeDomain(config) || null;
67
+ const discovery = domain
68
+ ? await discover(config)
69
+ .then(() => "ok")
70
+ .catch((e: Error) => e.message || "failed")
71
+ : null;
72
+ let admin: string | null = null;
73
+ let scopes: string[] = [];
74
+ let webhooksMissing: string[] | null = null;
75
+ if (domain && adminConfigured()) {
76
+ try {
77
+ scopes = (await adminScopes(config)).split(",").map((t) => t.trim()).filter(Boolean);
78
+ admin = "ok";
79
+ const subs = await webhookSubscriptions(config);
80
+ webhooksMissing = WEBHOOK_TOPICS.filter((t) => !subs.some((h) => h.topic === t && h.uri === `${origin}${WEBHOOK}`));
81
+ } catch (e) {
82
+ admin = (e as Error).message || "failed";
83
+ }
84
+ }
85
+ let signedIn = 0;
86
+ try {
87
+ const { items } = await s.members.query({ limit: 100 });
88
+ signedIn = items.filter((r) => r.data.userId).length;
89
+ } catch {
90
+ signedIn = 0;
91
+ }
92
+ return {
93
+ origin,
94
+ domain,
95
+ discovery,
96
+ clientId: Boolean(env(ENV.clientId)),
97
+ clientSecret: Boolean(env(ENV.clientSecret)),
98
+ webhookSecret: Boolean(env(ENV.webhookSecret)),
99
+ adminClientId: Boolean(env(ENV.adminClientId)),
100
+ admin,
101
+ scopes,
102
+ webhooksMissing,
103
+ levels: (config.levels ?? []).map((l) => ({ name: l.name, tags: l.tags })),
104
+ signedIn,
105
+ storefront: Boolean(env(ENV.storefrontPrivateToken) || env(ENV.storefrontPublicToken)),
106
+ };
107
+ }
108
+
109
+ const missingScopes = (f: SetupFacts) => SETUP_SCOPES.filter((sc) => !f.scopes.includes(sc));
110
+
111
+ /** The steps and where each one stands. Pure. */
112
+ export function setupSteps(f: SetupFacts): Step[] {
113
+ const store = f.discovery === "ok";
114
+ const signIn = store && f.clientId;
115
+ const app = store && f.webhookSecret && f.adminClientId && f.admin === "ok" && missingScopes(f).length === 0;
116
+ const hooks = app && f.webhooksMissing !== null && f.webhooksMissing.length === 0;
117
+ const level = f.levels.some((l) => l.tags.length > 0);
118
+ const ready = signIn && hooks && level;
119
+ return [
120
+ { key: "store", title: "Connect the store", state: store ? "done" : "todo" },
121
+ { key: "sign_in", title: "Create the sign-in client", state: signIn ? "done" : store ? "todo" : "waiting", waitingFor: "Connect the store first." },
122
+ { key: "app", title: "Connect the webhook app", state: app ? "done" : store ? "todo" : "waiting", waitingFor: "Connect the store first." },
123
+ { key: "webhooks", title: "Register the webhooks", state: hooks ? "done" : app ? "todo" : "waiting", waitingFor: "Connect the webhook app first: webhooks are registered through it." },
124
+ { key: "level", title: "Create your first level", state: level ? "done" : "todo" },
125
+ { key: "try", title: "Try a sign-in", state: f.signedIn > 0 ? "done" : ready ? "todo" : "waiting", waitingFor: "Finish the steps above first." },
126
+ { key: "storefront", title: "Add Storefront tokens (for plans and the shop)", state: f.storefront ? "done" : "optional" },
127
+ ];
128
+ }
129
+
130
+ const BADGE: Record<StepState, string> = { done: "Done", todo: "To do", waiting: "Waiting", optional: "Optional" };
131
+
132
+ function secretCommand(names: string[]): unknown {
133
+ return {
134
+ type: "code",
135
+ language: "sh",
136
+ code: [
137
+ "# Cloudflare Workers (or set them as encrypted secrets in the dashboard):",
138
+ ...names.map((n) => `npx wrangler secret put ${n}`),
139
+ "# Node: add them to the site's environment (.env), then restart.",
140
+ ].join("\n"),
141
+ };
142
+ }
143
+
144
+ function stepBlocks(step: Step, f: SetupFacts): unknown[] {
145
+ const out: unknown[] = [{ type: "header", text: `${step.title}: ${BADGE[step.state]}` }];
146
+ if (step.state === "waiting") return [...out, { type: "context", text: step.waitingFor ?? "" }];
147
+ switch (step.key) {
148
+ case "store":
149
+ if (step.state === "done") return [...out, { type: "context", text: `Connected to ${f.domain}.` }];
150
+ return [
151
+ ...out,
152
+ {
153
+ type: "section",
154
+ text: f.domain
155
+ ? `RedeemID couldn't read ${f.domain}'s sign-in settings: ${f.discovery}. Check the domain (the store's myshopify.com domain, or its custom customer-account domain), and that the store uses the new customer accounts (Shopify admin, Settings, Customer accounts).`
156
+ : "Tell RedeemID which store to use: its myshopify.com domain, such as my-store.myshopify.com. The store must use the new customer accounts (Shopify admin, Settings, Customer accounts); classic accounts can't sign in.",
157
+ },
158
+ secretCommand([ENV.storeDomain]),
159
+ { type: "context", text: "Or set `shop` in the RedeemID config in astro.config.mjs." },
160
+ ];
161
+ case "sign_in":
162
+ if (step.state === "done") return [...out, { type: "context", text: `Client ID set${f.clientSecret ? ", with a client secret (confidential client)" : " (public client)"}.` }];
163
+ return [
164
+ ...out,
165
+ {
166
+ type: "section",
167
+ text: "In Shopify admin, open Sales channels, then Headless (add the channel if it isn't there), and Create storefront. Name it after this site. Open its Customer Account API settings, pick a client type (Public needs no secret; Confidential gives one) and, under Application setup, add these:",
168
+ },
169
+ {
170
+ type: "fields",
171
+ fields: [
172
+ { label: "Callback URI", value: `${f.origin}${CALLBACK}` },
173
+ { label: "JavaScript origin (public clients)", value: f.origin },
174
+ { label: "Logout URI", value: `${f.origin}/` },
175
+ ],
176
+ },
177
+ { type: "section", text: "Copy the Client ID (and the Client secret for a confidential client) and set them on the site:" },
178
+ secretCommand([ENV.clientId, `${ENV.clientSecret} # confidential clients only`]),
179
+ { type: "context", text: "Shopify only accepts https callback URIs, so a local site needs a tunnel to sign in for real." },
180
+ ];
181
+ case "app": {
182
+ if (step.state === "done") return [...out, { type: "context", text: `Admin API connected (${f.scopes.join(", ")}).` }];
183
+ const why =
184
+ f.webhookSecret && f.adminClientId && f.admin && f.admin !== "ok"
185
+ ? `Shopify refused the app: ${f.admin} The app must be installed on this store and be in the same Shopify organization.`
186
+ : f.admin === "ok"
187
+ ? `The app is connected but lacks ${missingScopes(f).join(" and ")}. Add the scopes, release a new version and let the store accept it.`
188
+ : "Shopify signs webhooks with an app's client secret, and RedeemID uses the same app to register webhooks and to resync members.";
189
+ return [
190
+ ...out,
191
+ { type: "section", text: why },
192
+ {
193
+ type: "section",
194
+ text: "In the Shopify Dev Dashboard (dev.shopify.com), create an app (or use one that already sends this store's webhooks), give it the Admin API scopes read_customers and write_customers (and read_customer_merge, for customers/merge), release it and install it on the store. Then copy its Client ID and Client secret:",
195
+ },
196
+ secretCommand([ENV.adminClientId, ENV.webhookSecret]),
197
+ ];
198
+ }
199
+ case "webhooks": {
200
+ if (step.state === "done") return [...out, { type: "context", text: `Every topic RedeemID needs is sent to ${f.origin}${WEBHOOK}.` }];
201
+ const missing = f.webhooksMissing ?? [];
202
+ return [
203
+ ...out,
204
+ { type: "section", text: `${missing.length} of ${WEBHOOK_TOPICS.length} topics aren't sent to this site yet. RedeemID can register them through the app.` },
205
+ { type: "actions", elements: [{ type: "button", action_id: "setup_register", label: `Register ${missing.length} webhooks`, style: "primary" }] },
206
+ {
207
+ type: "context",
208
+ text: `Shopify's privacy topics (customers/data_request, customers/redact, shop/redact) can't be registered this way: add them to the app's configuration under [webhooks.privacy_compliance], pointing at ${f.origin}${WEBHOOK}.`,
209
+ },
210
+ ];
211
+ }
212
+ case "level":
213
+ if (step.state === "done") return [...out, { type: "context", text: `Levels: ${f.levels.map((l) => `${l.name} (${l.tags.join(", ")})`).join("; ")}. Change them under Access.` }];
214
+ return [
215
+ ...out,
216
+ { type: "section", text: "A level is what members hold: a name, and the Shopify customer tag that gives it. Shopify Flow or your subscription app puts the tag on customers who buy." },
217
+ {
218
+ type: "form",
219
+ block_id: "setup_level",
220
+ fields: [
221
+ { type: "text_input", action_id: "name", label: "Level name", placeholder: "Members" },
222
+ { type: "text_input", action_id: "tags", label: "Shopify tag", placeholder: "member" },
223
+ ],
224
+ submit: { label: "Add level", action_id: "setup_level" },
225
+ },
226
+ ];
227
+ case "try": {
228
+ if (step.state === "done") return [...out, { type: "context", text: `${f.signedIn === 1 ? "A member has" : `${f.signedIn} members have`} signed in. You're set up.` }];
229
+ const tag = f.levels.find((l) => l.tags.length)?.tags[0] ?? "member";
230
+ return [
231
+ ...out,
232
+ {
233
+ type: "section",
234
+ text: `In Shopify, add the tag "${tag}" to a test customer. Then open the link below in a private window and sign in with that customer's email: you're signed in here as staff, and staff accounts can't sign in through the store. Come back and press Check again.`,
235
+ },
236
+ { type: "actions", elements: [{ type: "link", label: "Sign in as a member", target: { kind: "external", url: `${f.origin}/_emdash/api/auth/redeemid/login?return=%2F` }, appearance: "secondary" }] },
237
+ ];
238
+ }
239
+ case "storefront":
240
+ if (step.state === "done") return [...out, { type: "context", text: "Storefront API token set." }];
241
+ return [
242
+ ...out,
243
+ { type: "section", text: "Only for selling on the site (Plan cards, the shop, the cart). In the same Headless storefront, open Storefront API and copy the private access token:" },
244
+ secretCommand([ENV.storefrontPrivateToken]),
245
+ ];
246
+ }
247
+ return out;
248
+ }
249
+
250
+ /** The Setup page. Pure. */
251
+ export function setupPage(f: SetupFacts, toast?: Toast): Blocks {
252
+ const steps = setupSteps(f);
253
+ const required = steps.filter((s) => s.state !== "optional" && s.key !== "storefront");
254
+ const done = required.filter((s) => s.state === "done").length;
255
+ return {
256
+ blocks: [
257
+ { type: "header", text: "Setup" },
258
+ done === required.length
259
+ ? { type: "banner", variant: "default", title: "RedeemID is set up", description: "Members can sign in with their Shopify customer account. Levels, rules and the meter are under Access." }
260
+ : {
261
+ type: "banner",
262
+ variant: "alert",
263
+ title: `${done} of ${required.length} steps done`,
264
+ description: "Work down the steps. Each one checks the site as it is, so set a secret, redeploy or restart, and press Check again.",
265
+ },
266
+ { type: "actions", elements: [{ type: "button", action_id: "setup_check", label: "Check again", style: "secondary" }] },
267
+ {
268
+ type: "table",
269
+ page_action_id: "setup_steps",
270
+ columns: [
271
+ { key: "step", label: "Step" },
272
+ { key: "state", label: "State", format: "badge" },
273
+ ],
274
+ rows: steps.map((s) => ({ step: s.title, state: BADGE[s.state] })),
275
+ },
276
+ ...steps.flatMap((s) => [{ type: "divider" }, ...stepBlocks(s, f)]),
277
+ ],
278
+ ...(toast ? { toast } : {}),
279
+ };
280
+ }
@@ -36,7 +36,11 @@ export function siteConfig(locals?: unknown): Promise<RedeemIDConfig | null> {
36
36
  const known = key ? loaded.get(key) : undefined;
37
37
  if (known) return known;
38
38
  const code = codeConfig(locals);
39
- const fresh = code ? database(locals as Locals).then((db) => withSaved(code, db), () => code) : Promise.resolve(null);
39
+ const fresh = code ? database(locals as Locals).then(
40
+ (db) => withSaved(code, db),
41
+ // No database: the same as storage that can't be read.
42
+ () => withSaved(code, null),
43
+ ) : Promise.resolve(null);
40
44
  if (key) loaded.set(key, fresh);
41
45
  return fresh;
42
46
  }
package/src/staff.ts CHANGED
@@ -25,6 +25,13 @@ type Blocks = { blocks: unknown[]; toast?: { message: string; type: "success" |
25
25
 
26
26
  const date = (iso: string | null | undefined) => (iso ? iso.slice(0, 10) : "—");
27
27
 
28
+ /** Shopify's marketing state in words: SUBSCRIBED → "Subscribed". */
29
+ function consentLabel(state: string | null | undefined): string {
30
+ if (!state) return "—";
31
+ const t = state.toLowerCase().replace(/_/g, " ");
32
+ return t.charAt(0).toUpperCase() + t.slice(1);
33
+ }
34
+
28
35
  export function memberDetail(config: RedeemIDConfig, m: MemberRecord, toast?: Blocks["toast"]): Blocks {
29
36
  const levels = config.levels ?? [];
30
37
  const { current, past } = membership(m, levels);
@@ -46,6 +53,12 @@ export function memberDetail(config: RedeemIDConfig, m: MemberRecord, toast?: Bl
46
53
  { label: "Levels", value: current.map((l) => `${l.name} (since ${date(l.since)})`).join(", ") || "—" },
47
54
  { label: "Levels before", value: past.map((l) => `${l.name} (${date(l.from)} to ${date(l.to)})`).join(", ") || "—" },
48
55
  { label: "Features", value: featureNames.join(", ") || "—" },
56
+ { label: "Member since (Shopify)", value: m.dates?.since ?? "—" },
57
+ { label: m.dates?.ends ? "Ends on (Shopify)" : "Renews on (Shopify)", value: m.dates?.ends ?? m.dates?.renews ?? "—" },
58
+ { label: "Gift until (Shopify)", value: m.dates?.gift ?? "—" },
59
+ { label: "Email verified", value: m.emailVerified === undefined ? "—" : m.emailVerified ? "Yes" : "No" },
60
+ { label: "Email marketing", value: consentLabel(m.emailMarketing) },
61
+ { label: "SMS marketing", value: consentLabel(m.smsMarketing) },
49
62
  { label: "Last synced", value: `${m.syncedAt} (${m.source || "unknown"})` },
50
63
  ],
51
64
  },
package/src/storage.ts CHANGED
@@ -13,6 +13,7 @@ import { getAuthProviderStorage } from "emdash/api/route-utils";
13
13
  import { PROVIDER_ID } from "./config.js";
14
14
  import type { AccessRecord } from "./settings.js";
15
15
  import type { LogEntry } from "./log.js";
16
+ import type { FailedEvent } from "./events.js";
16
17
 
17
18
  export interface MemberRecord {
18
19
  /** Numeric Shopify customer id, also the record id. */
@@ -39,10 +40,39 @@ export interface MemberRecord {
39
40
  linkedAt?: string | null;
40
41
  /** Tags Shopify has removed, with when each was held (newest last, at most 50). */
41
42
  pastTags?: PastTag[];
43
+ /**
44
+ * Membership dates the shop keeps in customer metafields (membership.member_since,
45
+ * membership.renews_on, membership.ends_on for a pass that doesn't renew,
46
+ * membership.gift_until), written by Shopify Flow or a subscription app and
47
+ * read with the Admin API. YYYY-MM-DD. Display only: never access.
48
+ */
49
+ dates?: MembershipDates;
50
+ /** Shopify's email marketing state (SUBSCRIBED, NOT_SUBSCRIBED, ...), as Shopify last said. */
51
+ emailMarketing?: string | null;
52
+ /** Shopify's SMS marketing state, from the customer webhooks. */
53
+ smsMarketing?: string | null;
54
+ /** Whether Shopify had verified the email at the member's last sign-in. */
55
+ emailVerified?: boolean;
42
56
  /** When the member last saved preferences (ms), newest last, at most 10: the rate limit. */
43
57
  prefSaves?: number[];
44
58
  }
45
59
 
60
+ export interface MembershipDates {
61
+ since?: string | null;
62
+ renews?: string | null;
63
+ ends?: string | null;
64
+ gift?: string | null;
65
+ }
66
+
67
+ /** A metafield date or date-time as YYYY-MM-DD, or null when it isn't one. */
68
+ export function metaDate(raw: unknown): string | null {
69
+ if (typeof raw !== "string") return null;
70
+ const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(raw.trim());
71
+ if (!m) return null;
72
+ const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])));
73
+ return d.getUTCFullYear() === Number(m[1]) && d.getUTCMonth() === Number(m[2]) - 1 && d.getUTCDate() === Number(m[3]) ? `${m[1]}-${m[2]}-${m[3]}` : null;
74
+ }
75
+
46
76
  /** A tag the customer held from `from` (when known) until `to`. */
47
77
  export interface PastTag {
48
78
  tag: string;
@@ -105,6 +135,7 @@ export const STORAGE_CONFIG = {
105
135
  requests: { indexes: ["at", "customerId", "status"] },
106
136
  access: { indexes: ["kind"] },
107
137
  log: { indexes: ["at", "event", "level"] },
138
+ failedEvents: { indexes: ["at"] },
108
139
  };
109
140
 
110
141
  export interface Stores {
@@ -116,6 +147,8 @@ export interface Stores {
116
147
  access: StorageCollection<AccessRecord>;
117
148
  /** App events (see log.ts). */
118
149
  log: StorageCollection<LogEntry>;
150
+ /** Outbound events that failed every try (see events.ts). */
151
+ failedEvents: StorageCollection<FailedEvent>;
119
152
  }
120
153
 
121
154
  export function stores(db: unknown): Stores {
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Templates that put an entry's body on the page with EmDash's own
3
+ * `<PortableText>` instead of RedeemID's `<Content>`. Those bodies skip
4
+ * every rule, Paywall break and Members only part, so a gated collection
5
+ * rendered that way reaches every visitor in full.
6
+ *
7
+ * redeemidGuard() scans the site's src/ at build time (scanTemplate, in
8
+ * Node) and hands the findings to the running site; the Status page then
9
+ * compares them with the rules as they are now (gatedFindings), because
10
+ * rules can change in the admin after the build.
11
+ *
12
+ * Both functions are pure.
13
+ */
14
+
15
+ import type { RedeemIDConfig } from "./config.js";
16
+
17
+ export interface TemplateFinding {
18
+ /** Path from the site's root, e.g. src/pages/[slug].astro. */
19
+ file: string;
20
+ /** Lines with a <PortableText> tag. */
21
+ lines: number[];
22
+ /** Collections the file loads with getEmDashEntry or getEmDashCollection. */
23
+ collections: string[];
24
+ }
25
+
26
+ const FROM_EMDASH_UI = /import\s*\{[^}]*\bPortableText\b[^}]*\}\s*from\s*["']emdash\/ui["']/;
27
+ const LOADS = /getEmDash(?:Entry|Collection)\(\s*["'`]([\w-]+)["'`]/g;
28
+
29
+ /** One template's finding, or null when it doesn't render <PortableText> from emdash/ui. */
30
+ export function scanTemplate(file: string, source: string): TemplateFinding | null {
31
+ if (!FROM_EMDASH_UI.test(source)) return null;
32
+ const lines: number[] = [];
33
+ source.split("\n").forEach((line, i) => {
34
+ if (/<PortableText[\s/>]/.test(line)) lines.push(i + 1);
35
+ });
36
+ if (!lines.length) return null;
37
+ const collections = [...new Set([...source.matchAll(LOADS)].map((m) => m[1]!))].sort();
38
+ return { file, lines, collections };
39
+ }
40
+
41
+ /** Collections some rule or entry rule can lock: "*" when the site default does. */
42
+ export function gatedCollections(config: RedeemIDConfig): Set<string> {
43
+ const out = new Set<string>();
44
+ for (const r of config.rules ?? []) if (!r.open) out.add(r.collection);
45
+ for (const e of Object.values(config.entries ?? {})) if (e && (e.mode === "levels" || e.mode === "add")) out.add(e.collection);
46
+ return out;
47
+ }
48
+
49
+ export interface GatedFinding extends TemplateFinding {
50
+ /** The file's collections that a rule covers; empty when the file loads none we could see. */
51
+ gated: string[];
52
+ }
53
+
54
+ /**
55
+ * Findings that matter now: a file that loads a gated collection, or a file
56
+ * whose collection we couldn't see (a component given the body as a prop)
57
+ * while some collection is gated.
58
+ */
59
+ export function gatedFindings(findings: TemplateFinding[], config: RedeemIDConfig): GatedFinding[] {
60
+ const gated = gatedCollections(config);
61
+ if (!gated.size) return [];
62
+ const all = gated.has("*");
63
+ return findings.flatMap((f) => {
64
+ if (!f.collections.length) return [{ ...f, gated: [] }];
65
+ const hit = f.collections.filter((c) => all || gated.has(c));
66
+ return hit.length ? [{ ...f, gated: hit }] : [];
67
+ });
68
+ }
69
+
70
+ /** Where the running site keeps the scan; unset when redeemidGuard() isn't installed. */
71
+ export const TEMPLATES_KEY = "__redeemidTemplates";
72
+
73
+ /** The scan, or null when redeemidGuard() isn't installed (or hasn't run yet). */
74
+ export function templateFindings(): TemplateFinding[] | null {
75
+ const v = (globalThis as Record<string, unknown>)[TEMPLATES_KEY];
76
+ return Array.isArray(v) ? (v as TemplateFinding[]) : null;
77
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Middleware added by redeemidGuard(): hands the build-time template scan
3
+ * (see template-scan.ts) to the Status page. The guard's Vite plugin
4
+ * replaces FINDINGS below with the scan when it builds this file.
5
+ */
6
+
7
+ import { defineMiddleware } from "astro:middleware";
8
+
9
+ import { TEMPLATES_KEY, type TemplateFinding } from "./template-scan.js";
10
+
11
+ const FINDINGS: TemplateFinding[] | null = null; // redeemid:templates
12
+
13
+ (globalThis as Record<string, unknown>)[TEMPLATES_KEY] = FINDINGS;
14
+
15
+ export const onRequest = defineMiddleware((_context, next) => next());
package/src/text.ts CHANGED
@@ -59,11 +59,18 @@ export const STRINGS = {
59
59
  "This is for members.",
60
60
  "Or buy just this for {price}.",
61
61
  "Buy just this",
62
+ "Your {level} membership has ended.",
63
+ "Renew {level}",
64
+ "Already renewed? {refresh}",
65
+ "Refresh",
62
66
  ],
63
67
  member: [
64
68
  "Your membership",
65
69
  "Your account is turned off. Contact the store for help.",
66
70
  "since {date}",
71
+ "Renews on {date}",
72
+ "Ends on {date}",
73
+ "Gift membership until {date}",
67
74
  "You don't have a membership yet.",
68
75
  "Become a member",
69
76
  "Includes: {features}",
@@ -111,7 +118,7 @@ export const STRINGS = {
111
118
  "I understand",
112
119
  "Delete my data",
113
120
  ],
114
- plan: ["Join", "You're on this plan.", "Not available right now."],
121
+ plan: ["Join", "Renew", "You're on this plan.", "Not available right now."],
115
122
  preferences: ["Your preferences", "Save preferences", "Your preferences can't be loaded right now. Try again in a few minutes.", "Not set", "Yes", "No", "One per line."],
116
123
  product: [
117
124
  "Add to cart",
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The member's Customer Account API tokens, sealed before they go into the
3
+ * session store.
4
+ *
5
+ * Signed-in checkout needs the member's access and refresh tokens, and they
6
+ * carry the Customer Account API's whole scope. They live only in the
7
+ * server-side session, encrypted (AES-GCM) with a key RedeemID keeps in the
8
+ * site's database (option `redeemid:session_key`, made on first need). So a
9
+ * copy of the session store alone doesn't give anyone a member's tokens.
10
+ * A token that can't be opened (an older session, a changed key) is treated
11
+ * as none: checkout falls back to a guest checkout.
12
+ */
13
+
14
+ import type { CustomerToken } from "./routes/shared.js";
15
+
16
+ const OPTION = "redeemid:session_key";
17
+
18
+ /** A sealed token as the session holds it. */
19
+ export interface SealedToken {
20
+ sealed: string;
21
+ }
22
+
23
+ let held: Promise<CryptoKey> | null = null;
24
+
25
+ function b64(bytes: Uint8Array): string {
26
+ let s = "";
27
+ for (const b of bytes) s += String.fromCharCode(b);
28
+ return btoa(s);
29
+ }
30
+
31
+ function unb64(text: string): Uint8Array<ArrayBuffer> {
32
+ return Uint8Array.from(atob(text), (c) => c.charCodeAt(0));
33
+ }
34
+
35
+ async function importKey(raw: string): Promise<CryptoKey> {
36
+ return crypto.subtle.importKey("raw", unb64(raw), { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
37
+ }
38
+
39
+ /** Use this key (base64 of 32 bytes) instead of the site's: for tests. */
40
+ export function useVaultKey(raw: string | null): void {
41
+ held = raw ? importKey(raw) : null;
42
+ }
43
+
44
+ async function siteKey(db?: unknown): Promise<CryptoKey> {
45
+ if (!held) {
46
+ held = (async () => {
47
+ const database = db ?? (await (await import("emdash/runtime")).getDb());
48
+ const { OptionsRepository } = await import("emdash/api/route-utils");
49
+ const options = new OptionsRepository(database as ConstructorParameters<typeof OptionsRepository>[0]);
50
+ await options.setIfAbsent(OPTION, b64(crypto.getRandomValues(new Uint8Array(32))));
51
+ const raw = await options.get(OPTION);
52
+ if (typeof raw !== "string") throw new Error("no session key");
53
+ return importKey(raw);
54
+ })();
55
+ held.catch(() => {
56
+ held = null;
57
+ });
58
+ }
59
+ return held;
60
+ }
61
+
62
+ /** Seal the tokens for the session. */
63
+ export async function seal(token: CustomerToken, db?: unknown): Promise<SealedToken> {
64
+ const iv = crypto.getRandomValues(new Uint8Array(12));
65
+ const data = new TextEncoder().encode(JSON.stringify(token));
66
+ const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, await siteKey(db), data));
67
+ return { sealed: `${b64(iv)}.${b64(ct)}` };
68
+ }
69
+
70
+ /** Open what the session holds, or null when it holds none or it can't be opened. */
71
+ export async function unseal(value: unknown, db?: unknown): Promise<CustomerToken | null> {
72
+ const sealed = (value as SealedToken | null | undefined)?.sealed;
73
+ if (typeof sealed !== "string") return null;
74
+ const [iv, ct] = sealed.split(".");
75
+ if (!iv || !ct) return null;
76
+ try {
77
+ const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv: unb64(iv) }, await siteKey(db), unb64(ct));
78
+ const t = JSON.parse(new TextDecoder().decode(plain)) as CustomerToken;
79
+ return typeof t?.accessToken === "string" ? t : null;
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.0.19";
1
+ export const VERSION = "0.0.20";
package/src/webhooks.ts CHANGED
@@ -161,6 +161,10 @@ export async function apply(
161
161
  current.firstName = str(payload.first_name) ?? current.firstName;
162
162
  current.lastName = str(payload.last_name) ?? current.lastName;
163
163
  if ("tags" in payload) current.tags = cleanTags(payload.tags);
164
+ // Marketing consent, as Shopify states it (display and export only).
165
+ const consent = (raw: unknown) => str((raw as { state?: unknown } | null | undefined)?.state)?.toUpperCase() ?? null;
166
+ if (payload.email_marketing_consent && typeof payload.email_marketing_consent === "object") current.emailMarketing = consent(payload.email_marketing_consent);
167
+ if (payload.sms_marketing_consent && typeof payload.sms_marketing_consent === "object") current.smsMarketing = consent(payload.sms_marketing_consent);
164
168
  current.profileUpdatedAt = updatedAt ?? current.profileUpdatedAt;
165
169
  } else if (kind === "status") {
166
170
  if (updatedAt !== null && current.statusUpdatedAt !== null && updatedAt < current.statusUpdatedAt) {