@redeemid/emdash 0.0.9 → 0.0.10

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.
@@ -2,8 +2,11 @@
2
2
  /**
3
3
  * The message for a `?redeemid_notice=` code that sign-in, Buy, Cart and
4
4
  * Account send readers back with. Renders nothing without one. Pass
5
- * `messages` to reword any of them.
5
+ * `messages` to reword any of them (config `text` does too, for every page).
6
6
  */
7
+ import { siteConfig } from "../site-config.js";
8
+ import { text as replace } from "../text.js";
9
+
7
10
  interface Props {
8
11
  messages?: Record<string, string>;
9
12
  }
@@ -26,12 +29,17 @@ const DEFAULTS: Record<string, string> = {
26
29
  discount_applied: "Discount applied.",
27
30
  discount_not_applicable: "That discount code doesn't apply to this cart.",
28
31
  refreshed: "Your membership is up to date.",
32
+ preferences_saved: "Your preferences are saved.",
33
+ preferences_refused: "Some answers weren't saved. Check them and try again.",
34
+ preferences_unavailable: "Your preferences can't be saved right now. Try again in a few minutes.",
29
35
  account_unavailable: "Your account page isn't available right now. Try again in a few minutes.",
30
36
  };
31
37
 
32
38
  const { messages = {} } = Astro.props;
33
39
  const code = Astro.url.searchParams.get("redeemid_notice") ?? "";
34
- const text = code ? (messages[code] ?? DEFAULTS[code]) : undefined;
40
+ const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
41
+ const fallback = DEFAULTS[code];
42
+ const text = code ? (messages[code] ?? (fallback ? replace(config, fallback) : undefined)) : undefined;
35
43
  ---
36
44
 
37
45
  {text && (
@@ -12,6 +12,7 @@
12
12
  import { viewer } from "../access.js";
13
13
  import { siteConfig } from "../site-config.js";
14
14
  import { plan as loadPlan, type Plan } from "../storefront.js";
15
+ import { text } from "../text.js";
15
16
 
16
17
  interface Props {
17
18
  handle: string;
@@ -24,8 +25,8 @@ interface Props {
24
25
  heldLabel?: string;
25
26
  }
26
27
 
27
- const { handle, label = "Join", title, levels = [], heldLabel = "You're on this plan." } = Astro.props;
28
28
  const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
29
+ const { handle, label = text(config, "Join"), title, levels = [], heldLabel = text(config, "You're on this plan.") } = Astro.props;
29
30
  let p: Plan | null = null;
30
31
  if (config) {
31
32
  try {
@@ -55,7 +56,7 @@ const held = levels.length > 0 && (await viewer(Astro)).levels.some((l) => level
55
56
  <button type="submit">{label}</button>
56
57
  </form>
57
58
  ) : (
58
- <p class="redeemid-plan__unavailable">Not available right now.</p>
59
+ <p class="redeemid-plan__unavailable">{text(config, "Not available right now.")}</p>
59
60
  )}
60
61
  </div>
61
62
  ) : (
@@ -0,0 +1,102 @@
1
+ ---
2
+ /**
3
+ * The member's preferences form: every field in `preferences`, read from
4
+ * their Shopify customer, with a control for each type. Fields set to "view"
5
+ * are shown, not editable. Works without JavaScript: it posts to the
6
+ * preferences route and comes back with a notice. Visitors and sites without
7
+ * preferences get nothing (or the "signed-out" slot).
8
+ *
9
+ * <Preferences title="Your preferences" />
10
+ */
11
+ import { viewer } from "../access.js";
12
+ import { preferencesConfigured, readPreferences, type PreferenceField } from "../preferences.js";
13
+ import { siteConfig } from "../site-config.js";
14
+ import { text } from "../text.js";
15
+
16
+ interface Props {
17
+ title?: string;
18
+ /** Heading level for the title. */
19
+ heading?: "h2" | "h3" | "h4";
20
+ label?: string;
21
+ }
22
+
23
+ const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
24
+ const { title = text(config, "Your preferences"), heading = "h2", label = text(config, "Save preferences") } = Astro.props;
25
+ const v = await viewer(Astro);
26
+ const m = v.member;
27
+ let fields: PreferenceField[] = [];
28
+ let failed = false;
29
+ if (config && preferencesConfigured(config) && m && m.status !== "disabled") {
30
+ try {
31
+ fields = await readPreferences(config, m.customerId);
32
+ } catch (e) {
33
+ failed = true;
34
+ console.error("[redeemid] preferences not read:", (e as Error).message);
35
+ }
36
+ }
37
+ const here = Astro.url.pathname + Astro.url.search.replace(/[?&]redeemid_notice=[^&]*/, "").replace(/^&/, "?");
38
+ const H = heading;
39
+ const id = (key: string) => `redeemid-pref-${key.replace(/[^\w-]/g, "-")}`;
40
+ const show = (f: PreferenceField) =>
41
+ f.value === null || (Array.isArray(f.value) && f.value.length === 0) ? text(config, "Not set") : Array.isArray(f.value) ? f.value.join(", ") : f.type === "boolean" ? text(config, f.value ? "Yes" : "No") : String(f.value);
42
+ const editable = fields.filter((f) => f.editable);
43
+ ---
44
+
45
+ {v.signedIn && (fields.length > 0 || failed) ? (
46
+ <section class="redeemid-preferences">
47
+ <H class="redeemid-preferences__title">{title}</H>
48
+ {failed ? (
49
+ <p>{text(config, "Your preferences can't be loaded right now. Try again in a few minutes.")}</p>
50
+ ) : (
51
+ <form method="post" action="/_emdash/api/auth/redeemid/preferences">
52
+ <input type="hidden" name="return" value={here} />
53
+ {fields.map((f) => (
54
+ <div class="redeemid-preferences__field" data-redeemid-pref={f.key}>
55
+ {f.editable && <input type="hidden" name={`present:${f.key}`} value="1" />}
56
+ {!f.editable ? (
57
+ <p>
58
+ <strong>{f.name}:</strong> {show(f)}
59
+ </p>
60
+ ) : f.type === "boolean" ? (
61
+ <label>
62
+ <input type="checkbox" name={`pref:${f.key}`} value="true" checked={f.value === true} /> {f.name}
63
+ </label>
64
+ ) : f.type === "list.single_line_text_field" && f.choices ? (
65
+ <fieldset>
66
+ <legend>{f.name}</legend>
67
+ {f.choices.map((c) => (
68
+ <label>
69
+ <input type="checkbox" name={`pref:${f.key}`} value={c} checked={Array.isArray(f.value) && f.value.includes(c)} /> {c}
70
+ </label>
71
+ ))}
72
+ </fieldset>
73
+ ) : (
74
+ <>
75
+ <label for={id(f.key)}>{f.name}</label>
76
+ {f.choices ? (
77
+ <select id={id(f.key)} name={`pref:${f.key}`}>
78
+ <option value="">{text(config, "Not set")}</option>
79
+ {f.choices.map((c) => <option value={c} selected={f.value === c}>{c}</option>)}
80
+ </select>
81
+ ) : f.type === "multi_line_text_field" || f.type === "list.single_line_text_field" ? (
82
+ <textarea id={id(f.key)} name={`pref:${f.key}`} rows="3" maxlength={f.max && f.type === "multi_line_text_field" ? Number(f.max) : undefined}>{Array.isArray(f.value) ? f.value.join("\n") : (f.value ?? "")}</textarea>
83
+ ) : f.type === "number_integer" || f.type === "number_decimal" ? (
84
+ <input id={id(f.key)} type="number" name={`pref:${f.key}`} step={f.type === "number_integer" ? "1" : "any"} min={f.min ?? undefined} max={f.max ?? undefined} value={f.value === null ? "" : String(f.value)} />
85
+ ) : f.type === "date" ? (
86
+ <input id={id(f.key)} type="date" name={`pref:${f.key}`} min={f.min?.slice(0, 10)} max={f.max?.slice(0, 10)} value={typeof f.value === "string" ? f.value : ""} />
87
+ ) : (
88
+ <input id={id(f.key)} type="text" name={`pref:${f.key}`} maxlength={f.max ? Number(f.max) : undefined} value={typeof f.value === "string" ? f.value : ""} />
89
+ )}
90
+ {f.type === "list.single_line_text_field" && !f.choices && <small>{text(config, "One per line.")}</small>}
91
+ </>
92
+ )}
93
+ {f.description && f.editable && <small class="redeemid-preferences__help">{f.description}</small>}
94
+ </div>
95
+ ))}
96
+ {editable.length > 0 && <button type="submit">{label}</button>}
97
+ </form>
98
+ )}
99
+ </section>
100
+ ) : (
101
+ !v.signedIn && <slot name="signed-out" />
102
+ )}
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { siteConfig } from "../site-config.js";
11
11
  import { money, product as loadProduct, type Product } from "../storefront.js";
12
+ import { text } from "../text.js";
12
13
 
13
14
  interface Props {
14
15
  handle: string;
@@ -20,8 +21,8 @@ interface Props {
20
21
  buyLabel?: string;
21
22
  }
22
23
 
23
- const { handle, heading = "h2", compact = false, addLabel = "Add to cart", buyLabel = "Buy now" } = Astro.props;
24
24
  const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
25
+ const { handle, heading = "h2", compact = false, addLabel = text(config, "Add to cart"), buyLabel = text(config, "Buy now") } = Astro.props;
25
26
  let p: Product | null = null;
26
27
  if (config) {
27
28
  try {
@@ -50,11 +51,11 @@ const id = `redeemid-product-${handle}`;
50
51
  <input type="hidden" name="return" value={here} />
51
52
  {p.variants.length > 1 ? (
52
53
  <p>
53
- <label for={`${id}-variant`}>Option</label>
54
+ <label for={`${id}-variant`}>{text(config, "Option")}</label>
54
55
  <select id={`${id}-variant`} name="variant">
55
56
  {p.variants.map((v) => (
56
57
  <option value={v.id} disabled={!v.availableForSale}>
57
- {v.title} ({money(v.price)}){!v.availableForSale && " sold out"}
58
+ {text(config, v.availableForSale ? "{option} ({price})" : "{option} ({price}) sold out", { option: v.title, price: money(v.price) })}
58
59
  </option>
59
60
  ))}
60
61
  </select>
@@ -63,14 +64,14 @@ const id = `redeemid-product-${handle}`;
63
64
  <input type="hidden" name="variant" value={first?.id} />
64
65
  )}
65
66
  <p>
66
- <label for={`${id}-quantity`}>Quantity</label>
67
+ <label for={`${id}-quantity`}>{text(config, "Quantity")}</label>
67
68
  <input id={`${id}-quantity`} name="quantity" type="number" min="1" max="1000" value="1" inputmode="numeric" />
68
69
  </p>
69
70
  <button type="submit" name="then" value="back">{addLabel}</button>
70
71
  <button type="submit" name="then" value="checkout">{buyLabel}</button>
71
72
  </form>
72
73
  ) : (
73
- <p class="redeemid-product__unavailable">Sold out.</p>
74
+ <p class="redeemid-product__unavailable">{text(config, "Sold out.")}</p>
74
75
  )}
75
76
  </div>
76
77
  ) : (
package/src/config.ts CHANGED
@@ -72,10 +72,29 @@ export interface FeatureConfig {
72
72
  }
73
73
 
74
74
  /** Where to send signed member events (see events.ts). The signing secret is REDEEMID_EVENTS_SECRET. */
75
+ /** A media folder whose files only some readers may download. Any of the given ways in is enough. */
76
+ export interface FileRule {
77
+ /** The media folder's name in the EmDash media library. */
78
+ folder: string;
79
+ /** Members holding any of these levels. An empty list means any signed-in member. */
80
+ levels?: string[];
81
+ /** Members holding any of these features. */
82
+ features?: string[];
83
+ /** Whoever can read a published entry that uses the file (its rule, drip and the levels picked in its body). */
84
+ entries?: boolean;
85
+ }
86
+
87
+ export interface PreferenceConfig {
88
+ /** "namespace.key" of a customer metafield definition. */
89
+ key: string;
90
+ /** "edit" (default): members can change it. "view": members only see it. */
91
+ access?: "view" | "edit";
92
+ }
93
+
75
94
  export interface EventsConfig {
76
95
  url: string;
77
96
  /** Send only these events. All of them when left out. */
78
- only?: Array<"member.signed_in" | "member.levels_changed" | "meter.used" | "meter.exhausted">;
97
+ only?: Array<"member.signed_in" | "member.levels_changed" | "meter.used" | "meter.exhausted" | "preferences.saved">;
79
98
  }
80
99
 
81
100
  export interface MeterConfig {
@@ -100,6 +119,19 @@ export interface RedeemIDConfig {
100
119
  meter?: MeterConfig;
101
120
  features?: FeatureConfig[];
102
121
  events?: EventsConfig;
122
+ /**
123
+ * Customer metafields members may see or change, in this order, by
124
+ * "namespace.key". Define them in Shopify (Settings → Custom data →
125
+ * Customers). Needs the Admin API app (staff tools) for reading and saving.
126
+ */
127
+ preferences?: PreferenceConfig[];
128
+ /** Protected files, by media folder. See FileRule. */
129
+ files?: FileRule[];
130
+ /**
131
+ * Replacement text, by each string's original English, e.g.
132
+ * { "Sign in": "Log in" }. One string at a time; see text.ts for the list.
133
+ */
134
+ text?: Record<string, string>;
103
135
  /** The wall's join link: where readers become members, e.g. { href: "/join/", label: "Become a member" }. */
104
136
  join?: { href: string; label?: string };
105
137
  /**
package/src/events.ts CHANGED
@@ -6,7 +6,8 @@
6
6
  *
7
7
  * Headers: X-RedeemID-Event (the name), X-RedeemID-Delivery (a fresh id) and
8
8
  * X-RedeemID-Signature: sha256=<hex HMAC of the raw body>. Events:
9
- * member.signed_in, member.levels_changed, meter.used, meter.exhausted.
9
+ * member.signed_in, member.levels_changed, meter.used, meter.exhausted,
10
+ * preferences.saved.
10
11
  * Sending never blocks or breaks the page: failures are logged and dropped.
11
12
  * No event carries an email address or a name; match people by `customerId`.
12
13
  */
@@ -14,7 +15,7 @@
14
15
  import { env, ENV, type RedeemIDConfig } from "./config.js";
15
16
  import { USER_AGENT } from "./shopify.js";
16
17
 
17
- export type EventName = "member.signed_in" | "member.levels_changed" | "meter.used" | "meter.exhausted";
18
+ export type EventName = "member.signed_in" | "member.levels_changed" | "meter.used" | "meter.exhausted" | "preferences.saved";
18
19
 
19
20
  const TIMEOUT_MS = 3000;
20
21
 
package/src/files.ts ADDED
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Protected files: uploads in a media folder named in `files` are served only
3
+ * to readers the folder's rule lets in, checked on every download. EmDash
4
+ * serves uploads at /_emdash/api/media/file/<storage key>; the guard
5
+ * middleware calls fileAccess() before EmDash answers, refuses readers
6
+ * without access, and marks allowed downloads private so no cache keeps them.
7
+ *
8
+ * files: [
9
+ * { folder: "Gold downloads", levels: ["gold"] },
10
+ * { folder: "Member downloads", levels: [] }, // any signed-in member
11
+ * { folder: "Supporter extras", features: ["supporter"] },
12
+ * { folder: "Story files", entries: true }, // whoever can read an entry using it
13
+ * ]
14
+ *
15
+ * Staff (any EmDash role above Subscriber) can always download. Folder names
16
+ * match case-insensitively. Access still comes only from Shopify tags.
17
+ */
18
+
19
+ import { readEntry, viewer, type AstroLike, type Terms } from "./access.js";
20
+ import { PAYWALL_BLOCK, sections, splitAtPaywall } from "./blocks-names.js";
21
+ import type { FileRule, RedeemIDConfig } from "./config.js";
22
+ import { featuresFor } from "./features.js";
23
+ import { text } from "./text.js";
24
+
25
+ export const FILE_PATH = "/_emdash/api/media/file/";
26
+ const SUBSCRIBER = 10;
27
+
28
+ export type FileVerdict = { protected: false } | { protected: true; allowed: true } | { protected: true; allowed: false; reason: "sign_in" | "denied" };
29
+
30
+ interface MediaRow {
31
+ id: string;
32
+ folder: string | null;
33
+ }
34
+
35
+ /** The media item behind a storage key, with its folder's name. */
36
+ async function mediaByKey(db: unknown, key: string): Promise<MediaRow | null> {
37
+ // Kysely, typed loosely: EmDash's schema types aren't exported.
38
+ const k = db as {
39
+ selectFrom(t: string): {
40
+ leftJoin(t: string, a: string, b: string): { select(c: string[]): { where(c: string, op: string, v: string): { executeTakeFirst(): Promise<Record<string, unknown> | undefined> } } };
41
+ };
42
+ };
43
+ const row = await k
44
+ .selectFrom("media")
45
+ .leftJoin("media_folders", "media_folders.id", "media.folder_id")
46
+ .select(["media.id as id", "media_folders.name as folder"])
47
+ .where("media.storage_key", "=", key)
48
+ .executeTakeFirst();
49
+ return row ? { id: String(row.id), folder: (row.folder as string | null) ?? null } : null;
50
+ }
51
+
52
+ /** Published entries that use the media item: collection and id. */
53
+ async function entriesUsing(db: unknown, mediaId: string): Promise<Array<{ collection: string; id: string }>> {
54
+ const k = db as {
55
+ selectFrom(t: string): {
56
+ innerJoin(t: string, cb: (j: { onRef(a: string, op: string, b: string): { onRef(a: string, op: string, b: string): unknown } }) => unknown): {
57
+ select(c: string[]): {
58
+ where(c: string, op: string, v: unknown): {
59
+ where(c: string, op: string, v: unknown): { where(c: string, op: string, v: unknown): { limit(n: number): { execute(): Promise<Array<Record<string, unknown>>> } } };
60
+ };
61
+ };
62
+ };
63
+ };
64
+ };
65
+ const rows = await k
66
+ .selectFrom("_emdash_media_usage_sources as s")
67
+ .innerJoin("_emdash_media_usage as u", (j) => j.onRef("u.source_key", "=", "s.source_key").onRef("u.generation", "=", "s.current_generation"))
68
+ .select(["s.collection_slug as collection", "s.content_id as id"])
69
+ .where("u.media_id", "=", mediaId)
70
+ .where("s.content_status", "=", "published")
71
+ .where("s.content_deleted_at", "is", null)
72
+ .limit(50)
73
+ .execute();
74
+ const seen = new Set<string>();
75
+ return rows
76
+ .filter((r) => typeof r.collection === "string" && typeof r.id === "string")
77
+ .map((r) => ({ collection: r.collection as string, id: r.id as string }))
78
+ .filter((e) => !seen.has(`${e.collection}:${e.id}`) && Boolean(seen.add(`${e.collection}:${e.id}`)));
79
+ }
80
+
81
+ /** The levels picked on an entry body's breaks and members-only parts: readers must hold one from each. */
82
+ function bodyClauses(body: unknown): string[][] {
83
+ const blocks = Array.isArray(body) ? body : [];
84
+ const out: string[][] = [];
85
+ const split = splitAtPaywall(blocks);
86
+ if (split.paywall && split.levels.length) out.push(split.levels);
87
+ for (const s of sections(blocks)) if (s.members && s.levels.length) out.push(s.levels);
88
+ return out;
89
+ }
90
+
91
+ /** May this reader read the whole entry: its rule, drip, and every part with levels picked? */
92
+ async function readsWhole(astro: AstroLike, entry: { collection: string; id: string }): Promise<boolean> {
93
+ const { getEmDashEntry } = await import("emdash");
94
+ const result = (await getEmDashEntry(entry.collection, entry.id)) as { entry?: { data?: Record<string, unknown> } | null } | null;
95
+ const data = result?.entry?.data;
96
+ if (!data) return false;
97
+ const terms = data.terms as Terms | undefined;
98
+ const verdict = await readEntry(astro, { collection: entry.collection, terms });
99
+ if (!verdict.allowed) return false;
100
+ const body = Object.values(data).find((v) => Array.isArray(v) && v.some((b) => (b as { _type?: string })?._type === PAYWALL_BLOCK || (b as { _type?: string })?._type === "redeemid-members"));
101
+ const clauses = bodyClauses(body);
102
+ return clauses.every((clause) => clause.some((l) => verdict.viewer.levels.includes(l) || (l === "signed-in" && verdict.viewer.signedIn)));
103
+ }
104
+
105
+ export function ruleFor(config: RedeemIDConfig | null, folder: string | null): FileRule | null {
106
+ if (!folder || !config?.files?.length) return null;
107
+ const name = folder.trim().toLowerCase();
108
+ return config.files.find((r) => r.folder.trim().toLowerCase() === name) ?? null;
109
+ }
110
+
111
+ /** Decide one download. `key` is the storage key from the file URL. */
112
+ export async function fileAccess(astro: AstroLike, config: RedeemIDConfig | null, key: string): Promise<FileVerdict> {
113
+ if (!config?.files?.length) return { protected: false };
114
+ const locals = astro.locals as { user?: { role?: number } | null; emdash?: { db?: unknown } | null };
115
+ const db = locals.emdash?.db;
116
+ if (!db) return { protected: false };
117
+ const media = await mediaByKey(db, key);
118
+ const rule = ruleFor(config, media?.folder ?? null);
119
+ if (!media || !rule) return { protected: false };
120
+
121
+ const role = locals.user?.role ?? 0;
122
+ if (role > SUBSCRIBER) return { protected: true, allowed: true };
123
+ const v = await viewer(astro);
124
+ if (!v.signedIn) return { protected: true, allowed: false, reason: "sign_in" };
125
+ const m = v.member;
126
+ if (!m || m.status === "disabled") return { protected: true, allowed: false, reason: "denied" };
127
+
128
+ if (rule.levels && (rule.levels.length === 0 ? true : rule.levels.some((l) => v.levels.includes(l) || l === "signed-in"))) {
129
+ return { protected: true, allowed: true };
130
+ }
131
+ if (rule.features?.length && featuresFor(m, config.features, config.levels).some((f) => rule.features?.includes(f))) {
132
+ return { protected: true, allowed: true };
133
+ }
134
+ if (rule.entries) {
135
+ for (const e of await entriesUsing(db, media.id)) {
136
+ if (await readsWhole(astro, e)) return { protected: true, allowed: true };
137
+ }
138
+ }
139
+ return { protected: true, allowed: false, reason: "denied" };
140
+ }
141
+
142
+ /**
143
+ * Every storage key a file URL path could name: the key after each layer of
144
+ * percent-decoding, because storage backends differ in whether they decode
145
+ * keys. The guard checks all of them, so an encoded key can't slip past.
146
+ */
147
+ export function keysFromPath(pathname: string): string[] | null {
148
+ if (!pathname.startsWith(FILE_PATH)) return null;
149
+ let candidate = pathname.slice(FILE_PATH.length);
150
+ if (!candidate) return null;
151
+ const out: string[] = [];
152
+ for (let layer = 0; layer < 4; layer++) {
153
+ let decoded: string;
154
+ try {
155
+ decoded = decodeURIComponent(candidate);
156
+ } catch {
157
+ break;
158
+ }
159
+ out.push(decoded);
160
+ if (decoded === candidate) break;
161
+ candidate = decoded;
162
+ }
163
+ return [...new Set(out)];
164
+ }
165
+
166
+ /** Decide a download that may name several keys: the first protected one decides. */
167
+ export async function pathAccess(astro: AstroLike, config: RedeemIDConfig | null, keys: string[]): Promise<FileVerdict> {
168
+ for (const key of keys) {
169
+ const v = await fileAccess(astro, config, key);
170
+ if (v.protected) return v;
171
+ }
172
+ return { protected: false };
173
+ }
174
+
175
+ function escapeHtml(s: string): string {
176
+ return s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
177
+ }
178
+
179
+ /** The page a refused download gets. */
180
+ export function refusal(reason: "sign_in" | "denied", signIn: string, config: RedeemIDConfig | null = null): Response {
181
+ const status = reason === "sign_in" ? 401 : 403;
182
+ const t = (s: string) => escapeHtml(text(config, s));
183
+ const body =
184
+ reason === "sign_in"
185
+ ? `<p>${t("Sign in to download this file.")}</p><p><a href="${escapeHtml(signIn)}">${t("Sign in")}</a></p>`
186
+ : `<p>${t("Your membership doesn't include this file.")}</p>`;
187
+ return new Response(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="robots" content="noindex"><title>${t("File not available")}</title></head><body><main>${body}</main></body></html>`, {
188
+ status,
189
+ headers: { "content-type": "text/html; charset=utf-8", "cache-control": "private, no-store", "x-robots-tag": "noindex" },
190
+ });
191
+ }
package/src/guard.ts CHANGED
@@ -10,6 +10,8 @@
10
10
 
11
11
  import { defineMiddleware } from "astro:middleware";
12
12
 
13
+ import { signInUrl } from "./access.js";
14
+ import { keysFromPath, pathAccess, refusal } from "./files.js";
13
15
  import { siteConfig } from "./site-config.js";
14
16
 
15
17
  const SUBSCRIBER = 10;
@@ -28,11 +30,50 @@ export function memberMayUse(path: string, extra: string[] = []): boolean {
28
30
  return [...MEMBER_PATHS, ...extra].some((p) => path === p.replace(/\/$/, "") || path.startsWith(p));
29
31
  }
30
32
 
31
- export const onRequest = defineMiddleware(async ({ locals, url }, next) => {
33
+ /**
34
+ * EmDash skips the session on its public file route, so resolve the signed-in
35
+ * user here: the access check needs it.
36
+ */
37
+ async function withUser(context: { locals: unknown; session?: { get(k: string): Promise<unknown> } }) {
38
+ const locals = context.locals as { user?: { id: string; role?: number } | null; emdash?: { db?: unknown } | null };
39
+ if (locals.user || !locals.emdash?.db) return context;
40
+ const sessionUser = (await context.session?.get("user")) as { id?: string } | undefined;
41
+ if (!sessionUser?.id) return context;
42
+ const { createKyselyAdapter } = await import("@emdash-cms/auth/adapters/kysely");
43
+ const user = await createKyselyAdapter(locals.emdash.db as never).getUserById(sessionUser.id);
44
+ if (!user || user.disabled) return context;
45
+ return { ...context, locals: { ...locals, user: { id: user.id, role: user.role, disabled: user.disabled } } };
46
+ }
47
+
48
+ export const onRequest = defineMiddleware(async (context, next) => {
49
+ const { locals, url } = context;
32
50
  const user = (locals as { user?: { role?: number } | null }).user;
51
+ const config = siteConfig((locals as { emdash?: { config?: unknown } }).emdash?.config);
52
+
53
+ // Protected files: checked for everyone, on every download.
54
+ const keys = config?.files?.length ? keysFromPath(url.pathname) : null;
55
+ if (keys) {
56
+ let verdict;
57
+ try {
58
+ verdict = await pathAccess(await withUser(context), config, keys);
59
+ } catch (e) {
60
+ // Fail closed: a file we can't check isn't served.
61
+ console.error("[redeemid] file check failed:", (e as Error).message);
62
+ return refusal("denied", signInUrl(url.pathname), config);
63
+ }
64
+ if (verdict.protected) {
65
+ if (!verdict.allowed) return refusal(verdict.reason, signInUrl(url.pathname), config);
66
+ const res = await next();
67
+ const out = new Response(res.body, res);
68
+ out.headers.set("cache-control", "private, no-store");
69
+ out.headers.set("vary", "Cookie");
70
+ out.headers.set("x-robots-tag", "noindex");
71
+ return out;
72
+ }
73
+ }
74
+
33
75
  if (!user || user.role !== SUBSCRIBER) return next();
34
76
 
35
- const config = siteConfig((locals as { emdash?: { config?: unknown } }).emdash?.config);
36
77
  if (memberMayUse(url.pathname, config?.memberPaths)) return next();
37
78
 
38
79
  if (url.pathname.startsWith("/_emdash/admin")) {
package/src/index.ts CHANGED
@@ -58,6 +58,7 @@ export function redeemid(config: RedeemIDConfig): AuthProviderDescriptor {
58
58
  { pattern: `${BASE}/cart`, entrypoint: "@redeemid/emdash/routes/cart.ts" },
59
59
  { pattern: `${BASE}/cart-edit`, entrypoint: "@redeemid/emdash/routes/cart-edit.ts" },
60
60
  { pattern: `${BASE}/account`, entrypoint: "@redeemid/emdash/routes/account.ts" },
61
+ { pattern: `${BASE}/preferences`, entrypoint: "@redeemid/emdash/routes/preferences.ts" },
61
62
  ],
62
63
  publicRoutes: [`${BASE}/`],
63
64
  storage: STORAGE_CONFIG,
package/src/plugin.ts CHANGED
@@ -35,6 +35,7 @@ import { PAGES, WIDGETS } from "./descriptor.js";
35
35
  import { VERSION } from "./version.js";
36
36
  import { adminConfigured } from "./admin-api.js";
37
37
  import { accessTest, bulkResync, BULK_BATCH, deliveriesSection, memberDetail, registerWebhooks, resyncOne, tagEdit, webhooksSection } from "./staff.js";
38
+ import { textProblems } from "./text.js";
38
39
 
39
40
  import {
40
41
  COLLECTION_BLOCK,
@@ -43,10 +44,11 @@ import {
43
44
  MEMBERS_END_BLOCK,
44
45
  PAYWALL_BLOCK,
45
46
  PLAN_BLOCK,
47
+ PREFERENCES_BLOCK,
46
48
  PRODUCT_BLOCK,
47
49
  } from "./blocks-names.js";
48
50
 
49
- export { COLLECTION_BLOCK, CONTRIBUTION_BLOCK, MEMBERS_BLOCK, MEMBERS_END_BLOCK, PAYWALL_BLOCK, PLAN_BLOCK, PRODUCT_BLOCK };
51
+ export { COLLECTION_BLOCK, CONTRIBUTION_BLOCK, MEMBERS_BLOCK, MEMBERS_END_BLOCK, PAYWALL_BLOCK, PLAN_BLOCK, PREFERENCES_BLOCK, PRODUCT_BLOCK };
50
52
 
51
53
  function blocks(config: RedeemIDConfig) {
52
54
  const levelOptions = [
@@ -140,6 +142,17 @@ function blocks(config: RedeemIDConfig) {
140
142
  { type: "text_input" as const, action_id: "label", label: "Button label", placeholder: "Give" },
141
143
  ],
142
144
  },
145
+ {
146
+ type: PREFERENCES_BLOCK,
147
+ label: "Preferences",
148
+ icon: "users",
149
+ description: "The signed-in member's preferences, saved on their Shopify customer",
150
+ placeholder: "Member preferences",
151
+ fields: [
152
+ { type: "text_input" as const, action_id: "title", label: "Title", placeholder: "Your preferences" },
153
+ { type: "text_input" as const, action_id: "label", label: "Button label", placeholder: "Save preferences" },
154
+ ],
155
+ },
143
156
  ];
144
157
  }
145
158
 
@@ -364,6 +377,13 @@ async function lastDelivery(): Promise<string | null> {
364
377
  return d ? `${d.topic} at ${d.receivedAt}` : null;
365
378
  }
366
379
 
380
+ function textSummary(config: RedeemIDConfig): string {
381
+ const all = Object.keys(config.text ?? {}).length;
382
+ const ignored = textProblems(config).length;
383
+ if (!all) return "None";
384
+ return ignored ? `${all - ignored} in use, ${ignored} ignored` : `${all} in use`;
385
+ }
386
+
367
387
  async function statusPage(config: RedeemIDConfig, request: Request) {
368
388
  const c = await checks(config);
369
389
  const origin = new URL(request.url).origin;
@@ -392,8 +412,22 @@ async function statusPage(config: RedeemIDConfig, request: Request) {
392
412
  { label: "Events", value: config.events?.url ? `${config.events.url}${env(ENV.eventsSecret) ? "" : " (REDEEMID_EVENTS_SECRET missing)"}` : "Off" },
393
413
  { label: "Storefront API access", value: c.storefront },
394
414
  { label: "Last webhook", value: delivery ?? "None received yet" },
415
+ { label: "Replacement text", value: textSummary(config) },
395
416
  ],
396
417
  },
418
+ ...(textProblems(config).length
419
+ ? [
420
+ {
421
+ type: "table",
422
+ page_action_id: "text_problems",
423
+ columns: [
424
+ { key: "original", label: "Ignored replacement for" },
425
+ { key: "problem", label: "Why" },
426
+ ],
427
+ rows: textProblems(config),
428
+ },
429
+ ]
430
+ : []),
397
431
  { type: "divider" },
398
432
  { type: "header", text: "Webhooks" },
399
433
  ...webhooks,