@redeemid/emdash 0.0.19 → 0.0.21

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/settings.ts CHANGED
@@ -63,7 +63,16 @@ export interface EntryRule {
63
63
  updatedAt?: string;
64
64
  }
65
65
 
66
- export type AccessRecord = SiteAccess | EntryRule;
66
+ /**
67
+ * Bumped on every write to the collection, so a request can tell with one
68
+ * small read whether the settings it holds are still current.
69
+ */
70
+ export interface AccessStamp {
71
+ kind: "stamp";
72
+ version: string;
73
+ }
74
+
75
+ export type AccessRecord = SiteAccess | EntryRule | AccessStamp;
67
76
 
68
77
  export interface LoadedAccess {
69
78
  site: SiteAccess | null;
@@ -71,6 +80,7 @@ export interface LoadedAccess {
71
80
  }
72
81
 
73
82
  export const SITE_ID = "site";
83
+ export const STAMP_ID = "stamp";
74
84
  export const entryKey = (entryId: string) => `entry:${entryId}`;
75
85
 
76
86
  /** Read everything saved, in one query. */
@@ -118,18 +128,96 @@ export function overridden(site: SiteAccess | null): Set<ManagedKey> {
118
128
  return new Set(MANAGED.filter((k) => site && k in site));
119
129
  }
120
130
 
121
- /** Code config plus the admin's settings, read with `db`. */
131
+ /**
132
+ * A level slug no level can have (slugs can't hold a colon), so nobody
133
+ * passes a requirement that names it: what every entry needs while the
134
+ * saved settings can't be read.
135
+ */
136
+ export const UNAVAILABLE = "redeemid:unavailable";
137
+
138
+ /**
139
+ * The settings while the saved ones can't be read and none were read before:
140
+ * every entry locked (see UNAVAILABLE), no meter to open one, and files in
141
+ * any folder refused. Failing open would show what the admin locked.
142
+ */
143
+ export function lockdown(code: RedeemIDConfig): RedeemIDConfig {
144
+ const out: RedeemIDConfig = { ...code, unavailable: true };
145
+ delete out.meter;
146
+ return out;
147
+ }
148
+
149
+ /** What this server last read, and the stamp it was read at. */
150
+ interface Held {
151
+ version: string | null;
152
+ saved: LoadedAccess;
153
+ }
154
+ const HELD = Symbol.for("redeemid:access-held");
155
+ const held = () => globalThis as unknown as Record<symbol, Held | undefined>;
156
+
157
+ /** Forget the settings held in memory (tests, and after a write here). */
158
+ export function forgetAccess(): void {
159
+ held()[HELD] = undefined;
160
+ }
161
+
162
+ /** Record that the settings changed, so every server reads them again. */
163
+ export async function touchAccess(store: StorageCollection<AccessRecord>): Promise<void> {
164
+ forgetAccess();
165
+ await store.put(STAMP_ID, { kind: "stamp", version: crypto.randomUUID() });
166
+ }
167
+
168
+ /** Save one record and bump the stamp. */
169
+ export async function putAccess(store: StorageCollection<AccessRecord>, id: string, data: SiteAccess | EntryRule): Promise<void> {
170
+ await store.put(id, data);
171
+ await touchAccess(store);
172
+ }
173
+
174
+ /** Delete one record and bump the stamp. */
175
+ export async function deleteAccess(store: StorageCollection<AccessRecord>, id: string): Promise<void> {
176
+ await store.delete(id);
177
+ await touchAccess(store);
178
+ }
179
+
180
+ /**
181
+ * The saved settings, read again only when the stamp has moved: one small
182
+ * read per request instead of the whole collection.
183
+ */
184
+ async function savedAccess(store: StorageCollection<AccessRecord>): Promise<LoadedAccess> {
185
+ const stamp = (await store.get(STAMP_ID)) as AccessStamp | null;
186
+ const version = stamp?.kind === "stamp" ? stamp.version : null;
187
+ const known = held()[HELD];
188
+ if (known && version !== null && known.version === version) return known.saved;
189
+ const saved = await loadAccess(store);
190
+ if (version === null) {
191
+ // Saved before stamps existed: start one, so the next request can skip the read.
192
+ const fresh = crypto.randomUUID();
193
+ await store.put(STAMP_ID, { kind: "stamp", version: fresh }).catch(() => undefined);
194
+ held()[HELD] = { version: fresh, saved };
195
+ } else {
196
+ held()[HELD] = { version, saved };
197
+ }
198
+ return saved;
199
+ }
200
+
201
+ /**
202
+ * Code config plus the admin's settings, read with `db`. When storage can't
203
+ * be read, the settings last read stand; with none, every entry is locked
204
+ * (see lockdown). Never the code's settings alone: a rule saved only in the
205
+ * admin would open its entries for as long as the error lasts.
206
+ */
122
207
  export async function withSaved(code: RedeemIDConfig | null, db: unknown): Promise<RedeemIDConfig | null> {
123
208
  if (!code) return null;
209
+ let saved: LoadedAccess;
124
210
  try {
125
- const merged = mergeAccess(code, await loadAccess(stores(db).access));
126
- announceLevels(merged?.levels);
127
- return merged;
211
+ saved = await savedAccess(stores(db).access);
128
212
  } catch (e) {
129
- // No storage yet (or it can't be read): the code's settings stand.
130
- console.error("[redeemid] couldn't read saved access settings:", (e as Error).message);
131
- return code;
213
+ const last = held()[HELD];
214
+ console.error("[redeemid] couldn't read saved access settings:", (e as Error).message, last ? "(using the last ones read)" : "(locking entries)");
215
+ if (!last) return lockdown(code);
216
+ saved = last.saved;
132
217
  }
218
+ const merged = mergeAccess(code, saved);
219
+ announceLevels(merged?.levels);
220
+ return merged;
133
221
  }
134
222
 
135
223
  // ---------------------------------------------------------------- level pickers
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",