@redeemid/emdash 0.0.16 → 0.0.17

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.
Files changed (41) hide show
  1. package/README.md +416 -233
  2. package/docs/shopify-setup.md +1 -1
  3. package/package.json +1 -1
  4. package/src/access-admin.ts +754 -0
  5. package/src/access.ts +84 -18
  6. package/src/admin-api.ts +9 -2
  7. package/src/agents.ts +7 -3
  8. package/src/components/AccessBadge.astro +7 -5
  9. package/src/components/Account.astro +1 -1
  10. package/src/components/Cart.astro +1 -1
  11. package/src/components/CartView.astro +1 -1
  12. package/src/components/Collection.astro +1 -1
  13. package/src/components/Content.astro +7 -3
  14. package/src/components/Contribution.astro +1 -1
  15. package/src/components/Gate.astro +4 -2
  16. package/src/components/LockedPanel.astro +1 -1
  17. package/src/components/MemberCard.astro +1 -1
  18. package/src/components/Notices.astro +1 -1
  19. package/src/components/Plan.astro +1 -1
  20. package/src/components/Preferences.astro +1 -1
  21. package/src/components/Product.astro +1 -1
  22. package/src/components/YourData.astro +1 -1
  23. package/src/config.ts +17 -5
  24. package/src/descriptor.ts +7 -0
  25. package/src/events.ts +3 -0
  26. package/src/features.ts +1 -1
  27. package/src/files.ts +1 -1
  28. package/src/guard.ts +5 -3
  29. package/src/log.ts +96 -0
  30. package/src/monitor.ts +560 -0
  31. package/src/plugin.ts +202 -86
  32. package/src/routes/callback.ts +21 -4
  33. package/src/routes/preferences.ts +2 -2
  34. package/src/routes/privacy.ts +2 -2
  35. package/src/routes/webhook.ts +24 -11
  36. package/src/settings.ts +195 -0
  37. package/src/site-config.ts +38 -5
  38. package/src/staff.ts +12 -23
  39. package/src/storage.ts +12 -0
  40. package/src/version.ts +1 -1
  41. package/src/webhooks.ts +2 -1
package/src/access.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import type { LevelConfig, RedeemIDConfig, RuleConfig } from "./config.js";
14
+ import type { EntryRule } from "./settings.js";
14
15
  import { siteConfig } from "./site-config.js";
15
16
  import { memberForUser, stores, type MemberRecord } from "./storage.js";
16
17
 
@@ -108,9 +109,14 @@ function combined(rules: RuleConfig[], mode: "all" | "any"): string[][] {
108
109
  return reduce(rules.map((r) => r.levels));
109
110
  }
110
111
 
112
+ /** Rules that can cover `collection`: its own, and term rules for every collection ("*"). */
113
+ function rulesFor(rules: RuleConfig[], collection: string): RuleConfig[] {
114
+ return rules.filter((r) => r.collection === collection || (r.collection === "*" && r.taxonomy && r.term));
115
+ }
116
+
111
117
  /** The rules that decide an entry: matching term rules, else the collection's, else the site default. */
112
118
  function deciding(rules: RuleConfig[], collection: string, terms: Terms, tree?: TermTree): RuleConfig[] | "open" {
113
- const forCollection = rules.filter((r) => r.collection === collection);
119
+ const forCollection = rulesFor(rules, collection);
114
120
  const matched: RuleConfig[] = [];
115
121
  for (const [taxonomy, list] of Object.entries(terms)) {
116
122
  const taxRules = forCollection.filter((r) => r.taxonomy === taxonomy && r.term);
@@ -123,7 +129,9 @@ function deciding(rules: RuleConfig[], collection: string, terms: Terms, tree?:
123
129
  seen.add(slug);
124
130
  const own = taxRules.filter((r) => r.term === slug);
125
131
  if (own.length > 0) {
126
- matched.push(...own);
132
+ // A rule for this collection beats one for every collection.
133
+ const specific = own.filter((r) => r.collection === collection);
134
+ matched.push(...(specific.length ? specific : own));
127
135
  break;
128
136
  }
129
137
  slug = parents[slug] ?? null;
@@ -132,9 +140,54 @@ function deciding(rules: RuleConfig[], collection: string, terms: Terms, tree?:
132
140
  }
133
141
  if (matched.some((r) => r.open)) return "open";
134
142
  if (matched.length > 0) return matched;
135
- const whole = forCollection.filter((r) => !r.taxonomy && !r.term && !r.open);
143
+ const whole = forCollection.filter((r) => r.collection === collection && !r.taxonomy && !r.term);
144
+ if (whole.some((r) => r.open)) return "open";
136
145
  if (whole.length > 0) return whole;
137
- return rules.filter((r) => r.collection === "*" && !r.taxonomy && !r.term && !r.open);
146
+ const site = rules.filter((r) => r.collection === "*" && !r.taxonomy && !r.term);
147
+ if (site.some((r) => r.open)) return "open";
148
+ return site;
149
+ }
150
+
151
+ /** Where the site's rules for an entry come from, in words, for the editor. */
152
+ export function ruleSource(rules: RuleConfig[] = [], collection: string, terms: Terms = {}, tree?: TermTree): string {
153
+ const found = deciding(rules, collection, terms, tree);
154
+ const termRules = (list: RuleConfig[]) => [...new Set(list.map((r) => `${r.taxonomy}: ${r.term}`))].join(", ");
155
+ if (found === "open") {
156
+ const opener = rulesFor(rules, collection).filter((r) => r.open && r.taxonomy);
157
+ const hit = opener.filter((r) => (terms[r.taxonomy!] ?? []).some((t) => slugOf(t) === r.term));
158
+ if (hit.length) return `A term that makes it free (${termRules(hit)})`;
159
+ return rules.some((r) => r.collection === collection && r.open && !r.taxonomy) ? `The rule for ${collection} (free)` : "The site default (free)";
160
+ }
161
+ if (found.length === 0) return "No rule covers it";
162
+ if (found[0]!.taxonomy) return `Terms: ${termRules(found)}`;
163
+ if (found[0]!.collection === collection) return `The rule for ${collection}`;
164
+ return "The site default";
165
+ }
166
+
167
+ /** The entry's own rule (set in the editor), by id or slug. */
168
+ export function entryRuleFor(config: RedeemIDConfig | null, collection: string, id?: string | null): EntryRule | null {
169
+ if (!config?.entries || !id) return null;
170
+ return config.entries[`${collection}:${id}`] ?? null;
171
+ }
172
+
173
+ /**
174
+ * The site's requirement with the entry's own rule applied (see EntryRule).
175
+ * `now` decides the date windows.
176
+ */
177
+ export function applyEntryRule(site: Requirement, own: EntryRule | null, now = Date.now()): Requirement {
178
+ if (!own) return site;
179
+ if (own.openUntil && now < Date.parse(own.openUntil)) return null;
180
+ if (own.lockedUntil && now >= Date.parse(own.lockedUntil)) return null;
181
+ switch (own.mode) {
182
+ case "open":
183
+ return null;
184
+ case "levels":
185
+ return [own.levels];
186
+ case "add":
187
+ return reduce([...(site ?? []), own.levels]);
188
+ default:
189
+ return site;
190
+ }
138
191
  }
139
192
 
140
193
  /**
@@ -156,14 +209,18 @@ export function requirement(
156
209
  }
157
210
 
158
211
  /** Days of membership the entry's deciding rules ask for (the largest drip), or 0. */
159
- export function dripDays(rules: RuleConfig[] = [], collection: string, terms: Terms = {}, tree?: TermTree): number {
212
+ export function dripDays(rules: RuleConfig[] = [], collection: string, terms: Terms = {}, tree?: TermTree, own?: EntryRule | null): number {
213
+ if (own && typeof own.drip === "number" && own.drip > 0) return own.drip;
214
+ if (own?.mode === "open" || own?.mode === "levels") return 0;
160
215
  const found = deciding(rules, collection, terms, tree);
161
216
  if (found === "open") return 0;
162
217
  return Math.max(0, ...found.map((r) => (typeof r.drip === "number" && r.drip > 0 ? r.drip : 0)));
163
218
  }
164
219
 
165
220
  /** May the meter open the entry? Not when a deciding rule says `meter: false`. */
166
- export function meterable(rules: RuleConfig[] = [], collection: string, terms: Terms = {}, tree?: TermTree): boolean {
221
+ export function meterable(rules: RuleConfig[] = [], collection: string, terms: Terms = {}, tree?: TermTree, own?: EntryRule | null): boolean {
222
+ if (own?.meter === false) return false;
223
+ if (own?.mode === "levels") return true;
167
224
  const found = deciding(rules, collection, terms, tree);
168
225
  return found === "open" || !found.some((r) => r.meter === false);
169
226
  }
@@ -229,7 +286,7 @@ export function membership(member: MemberRecord, levels: LevelConfig[] = []): {
229
286
  /** How much of this period's meter the signed-in reader has used, or null when no meter runs. */
230
287
  export async function meterUsage(astro: AstroLike): Promise<{ used: number; limit: number; period: "month" | "week" } | null> {
231
288
  const locals = astro.locals as Locals;
232
- const meter = siteConfig(locals.emdash?.config)?.meter;
289
+ const meter = (await siteConfig(locals))?.meter;
233
290
  const user = locals.user;
234
291
  const db = locals.emdash?.db;
235
292
  if (!meter || !(meter.limit > 0) || !user?.id || !db) return null;
@@ -291,7 +348,7 @@ export function describe(required: Requirement, levels: LevelConfig[] = [], open
291
348
  */
292
349
  export async function termTree(rules: RuleConfig[] = [], collection: string, terms: Terms = {}): Promise<TermTree> {
293
350
  const tree: TermTree = {};
294
- const forCollection = rules.filter((r) => r.collection === collection && r.taxonomy && r.term);
351
+ const forCollection = rulesFor(rules, collection).filter((r) => r.taxonomy && r.term);
295
352
  const wanted = Object.entries(terms).filter(([taxonomy, list]) => {
296
353
  const own = new Set(forCollection.filter((r) => r.taxonomy === taxonomy).map((r) => r.term));
297
354
  return own.size > 0 && (list ?? []).some((t) => !own.has(slugOf(t)));
@@ -316,15 +373,21 @@ export async function termTree(rules: RuleConfig[] = [], collection: string, ter
316
373
  return tree;
317
374
  }
318
375
 
319
- /** The entry's requirement with the site's config, walking term parents. */
376
+ /**
377
+ * The entry's requirement with the site's config, walking term parents. With
378
+ * `id` (EmDash's id or the slug), the entry's own rule from the editor applies.
379
+ */
320
380
  export async function entryRequirement(
321
381
  config: RedeemIDConfig | null,
322
382
  collection: string,
323
383
  terms: Terms = {},
384
+ id?: string | null,
324
385
  ): Promise<Requirement> {
325
386
  if (!config) return null;
387
+ const own = entryRuleFor(config, collection, id);
388
+ if (own && (own.mode === "open" || own.mode === "levels") && !own.openUntil && !own.lockedUntil) return applyEntryRule(null, own);
326
389
  const tree = await termTree(config.rules, collection, terms);
327
- return requirement(config.rules, collection, terms, { combine: config.combine, tree });
390
+ return applyEntryRule(requirement(config.rules, collection, terms, { combine: config.combine, tree }), own);
328
391
  }
329
392
 
330
393
  const viewers = new WeakMap<object, Promise<Viewer>>();
@@ -354,7 +417,7 @@ async function readViewer(astro: AstroLike): Promise<Viewer> {
354
417
  // Member-specific output must never be shared by a cache.
355
418
  astro.response?.headers.set("Cache-Control", "private, no-store");
356
419
 
357
- const config = siteConfig(locals.emdash?.config);
420
+ const config = await siteConfig(locals);
358
421
  const db = locals.emdash?.db;
359
422
  if (!config || !db) return { signedIn: true, member: null, levels: [] };
360
423
 
@@ -369,16 +432,16 @@ async function readViewer(astro: AstroLike): Promise<Viewer> {
369
432
  */
370
433
  export async function canRead(
371
434
  astro: AstroLike,
372
- entry: { collection: string; terms?: Terms },
435
+ entry: { collection: string; terms?: Terms; id?: string; ruleId?: string },
373
436
  override?: { levels: string[] } | { required: string[][] },
374
437
  ): Promise<Verdict> {
375
438
  const locals = astro.locals as Locals;
376
- const config: RedeemIDConfig | null = siteConfig(locals.emdash?.config);
439
+ const config: RedeemIDConfig | null = await siteConfig(locals);
377
440
  const required: Requirement = override
378
441
  ? "required" in override
379
442
  ? override.required
380
443
  : [override.levels]
381
- : await entryRequirement(config, entry.collection, entry.terms);
444
+ : await entryRequirement(config, entry.collection, entry.terms, entry.ruleId ?? entry.id);
382
445
  const v = await viewer(astro);
383
446
 
384
447
  if (required === null) return { allowed: true, reason: "open", required: [], viewer: v };
@@ -426,17 +489,18 @@ export function signOutUrl(returnTo = "/"): string {
426
489
  */
427
490
  export async function readEntry(
428
491
  astro: AstroLike,
429
- entry: { collection: string; terms?: Terms; id?: string },
492
+ entry: { collection: string; terms?: Terms; id?: string; ruleId?: string },
430
493
  override?: { required: string[][] },
431
494
  ): Promise<Verdict> {
432
495
  const verdict = await canRead(astro, entry, override);
433
496
  const locals = astro.locals as Locals;
434
- const config = siteConfig(locals.emdash?.config);
497
+ const config = await siteConfig(locals);
435
498
  if (!config) return verdict;
436
499
 
437
500
  if (verdict.allowed && verdict.reason === "allowed" && verdict.viewer.member) {
501
+ const own = entryRuleFor(config, entry.collection, entry.ruleId ?? entry.id);
438
502
  const tree = await termTree(config.rules, entry.collection, entry.terms);
439
- const days = dripDays(config.rules, entry.collection, entry.terms, tree);
503
+ const days = dripDays(config.rules, entry.collection, entry.terms, tree, own);
440
504
  const opensAt = dripOpensAt(memberSince(verdict.viewer.member, config.levels), days);
441
505
  if (days > 0 && (!opensAt || opensAt.getTime() > Date.now())) {
442
506
  return { ...verdict, allowed: false, reason: "drip", opensAt: opensAt ?? undefined };
@@ -458,7 +522,7 @@ export async function readEntry(
458
522
  ) {
459
523
  return verdict;
460
524
  }
461
- if (!meterable(config.rules, entry.collection, entry.terms, await termTree(config.rules, entry.collection, entry.terms))) {
525
+ if (!meterable(config.rules, entry.collection, entry.terms, await termTree(config.rules, entry.collection, entry.terms), entryRuleFor(config, entry.collection, entry.ruleId ?? entry.id))) {
462
526
  return verdict;
463
527
  }
464
528
  const period = meter.period ?? "month";
@@ -473,6 +537,8 @@ export async function readEntry(
473
537
  await s.meter.put(key, { ...record, items: used.items });
474
538
  const { emit } = await import("./events.js");
475
539
  const data = { userId: user.id, entry: `${entry.collection}:${entry.id}`, used: used.items.length, limit: meter.limit, period: record.period };
540
+ const { record: log } = await import("./log.js");
541
+ await log({ event: used.left === 0 ? "meter.exhausted" : "meter.used", label: entry.collection, status: used.left === 0 ? "used up" : `${used.left} left`, origin: "member" });
476
542
  await emit(config, "meter.used", data);
477
543
  if (used.left === 0) await emit(config, "meter.exhausted", data);
478
544
  }
package/src/admin-api.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  import { env, ENV, shopOrigin, type RedeemIDConfig } from "./config.js";
13
13
  import { USER_AGENT } from "./shopify.js";
14
14
  import { cleanTags, emptyMember, stampTags, type MemberRecord, type Stores } from "./storage.js";
15
+ import { timed } from "./log.js";
15
16
 
16
17
  export const ADMIN_API_VERSION = "2026-10";
17
18
 
@@ -68,7 +69,13 @@ export async function adminScopes(config: RedeemIDConfig, fetcher: typeof fetch
68
69
  return (await token(config, fetcher)).scope;
69
70
  }
70
71
 
71
- export async function admin<T>(config: RedeemIDConfig, query: string, variables: Record<string, unknown> = {}, fetcher: typeof fetch = fetch, retry = true): Promise<T> {
72
+ /** One Admin API call, recorded under App events by its operation name (never its variables or answer). */
73
+ export async function admin<T>(config: RedeemIDConfig, query: string, variables: Record<string, unknown> = {}, fetcher: typeof fetch = fetch): Promise<T> {
74
+ const op = (/(?:query|mutation)\s+(\w+)/.exec(query)?.[1] ?? "query").replace(/^RedeemID/, "") || "query";
75
+ return timed("shopify.admin", op, () => call<T>(config, query, variables, fetcher, true));
76
+ }
77
+
78
+ async function call<T>(config: RedeemIDConfig, query: string, variables: Record<string, unknown>, fetcher: typeof fetch, retry: boolean): Promise<T> {
72
79
  const t = await token(config, fetcher);
73
80
  const res = await fetcher(`${shopOrigin(config)}/admin/api/${ADMIN_API_VERSION}/graphql.json`, {
74
81
  method: "POST",
@@ -77,7 +84,7 @@ export async function admin<T>(config: RedeemIDConfig, query: string, variables:
77
84
  });
78
85
  if (res.status === 401 && retry) {
79
86
  tokens.delete(shopOrigin(config));
80
- return admin(config, query, variables, fetcher, false);
87
+ return call(config, query, variables, fetcher, false);
81
88
  }
82
89
  const body = (await res.json().catch(() => ({}))) as { data?: T; errors?: Array<{ message?: string }> };
83
90
  if (!res.ok || !body.data) throw new AdminError(body.errors?.[0]?.message ?? `Admin API request failed (${res.status}).`);
package/src/agents.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import { adminConfigured } from "./admin-api.js";
13
- import { describe as describeRequirement, dripDays, levelsFor, membership, passes, requirement, termTree, type Requirement, type Terms } from "./access.js";
13
+ import { applyEntryRule, entryRuleFor, describe as describeRequirement, dripDays, levelsFor, membership, passes, requirement, termTree, type Requirement, type Terms } from "./access.js";
14
14
  import { ENV, env, storeDomain, unknownEnvNames, type RedeemIDConfig } from "./config.js";
15
15
  import { featuresFor } from "./features.js";
16
16
  import { bodyClauses } from "./files.js";
@@ -42,6 +42,7 @@ export async function explainAccess(config: RedeemIDConfig, s: Stores, input: Ex
42
42
  let terms: Terms = input.terms ?? {};
43
43
  let parts: string[][] = [];
44
44
  let entry: { id: string; title: string | null } | null = null;
45
+ let own: ReturnType<typeof entryRuleFor> = null;
45
46
  let item: SoldItem | null = null;
46
47
  if (input.id) {
47
48
  const { getEmDashEntry } = await import("emdash");
@@ -53,6 +54,7 @@ export async function explainAccess(config: RedeemIDConfig, s: Stores, input: Ex
53
54
  parts = bodyClauses(body);
54
55
  item = soldItem(body);
55
56
  entry = { id: String(result?.entry?.id ?? input.id), title: typeof data.title === "string" ? data.title : null };
57
+ own = entryRuleFor(config, collection, typeof data.id === "string" ? data.id : null) ?? entryRuleFor(config, collection, input.id);
56
58
  }
57
59
 
58
60
  let tags = cleanTags(input.tags ?? []);
@@ -67,7 +69,7 @@ export async function explainAccess(config: RedeemIDConfig, s: Stores, input: Ex
67
69
  const disabled = member?.status === "disabled";
68
70
  const held = disabled ? [] : levelsFor(tags, levels);
69
71
  const tree = await termTree(config.rules, collection, terms);
70
- const required: Requirement = requirement(config.rules, collection, terms, { combine: config.combine, tree });
72
+ const required: Requirement = applyEntryRule(requirement(config.rules, collection, terms, { combine: config.combine, tree }), own);
71
73
  const owned = !disabled && holdsItem(item, tags);
72
74
  const canReadRule = owned || required === null || (signedIn && !disabled && passes(held, required));
73
75
  const name = (slug: string) => levels.find((l) => l.slug === slug)?.name ?? slug;
@@ -90,7 +92,8 @@ export async function explainAccess(config: RedeemIDConfig, s: Stores, input: Ex
90
92
  canRead: canReadRule,
91
93
  reason: canReadRule ? "allowed" : !signedIn ? "sign_in" : disabled ? "disabled" : "upgrade",
92
94
  offered: !canReadRule && missing ? name(missing[0] as string) : null,
93
- dripDays: owned ? 0 : dripDays(config.rules, collection, terms, tree),
95
+ dripDays: owned ? 0 : dripDays(config.rules, collection, terms, tree, own),
96
+ entryRule: own ? { mode: own.mode, levels: own.levels.map(name), drip: own.drip ?? 0, neverMetered: own.meter === false, lockedUntil: own.lockedUntil ?? null, openUntil: own.openUntil ?? null } : null,
94
97
  soldOnItsOwn: item ? { tag: item.tag, product: item.product, held: owned } : null,
95
98
  parts: partResults,
96
99
  note: "Drip and the meter depend on the member's dates and use, so they can still change what the page shows.",
@@ -109,6 +112,7 @@ export function getRules(config: RedeemIDConfig): Record<string, unknown> {
109
112
  teaser: config.teaser ?? {},
110
113
  features: config.features ?? [],
111
114
  files: config.files ?? [],
115
+ entries: Object.values(config.entries ?? {}).filter((e, i, all) => all.findIndex((x) => x.entryId === e.entryId) === i),
112
116
  };
113
117
  }
114
118
 
@@ -3,7 +3,7 @@
3
3
  * The level an entry needs, read from its rules (and from a paywall break
4
4
  * with levels):
5
5
  *
6
- * <AccessBadge collection="posts" terms={post.data.terms} content={post.data.content} />
6
+ * <AccessBadge collection="posts" terms={post.data.terms} content={post.data.content} id={post.id} />
7
7
  *
8
8
  * Open entries show `free` ("Free"; pass free="" to show nothing). The badge
9
9
  * carries `data-redeemid-level` (the level slugs) for styling, and
@@ -19,21 +19,23 @@ interface Props {
19
19
  terms?: Terms;
20
20
  /** The entry's body, so a paywall break's levels count. */
21
21
  content?: unknown[] | null;
22
+ /** The entry's id or slug, so its own rule from the editor counts. */
23
+ id?: string;
22
24
  free?: string;
23
25
  class?: string;
24
26
  }
25
27
 
26
- const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
27
- const { collection, terms, content, free = text(config, "Free"), class: className } = Astro.props;
28
+ const config = await siteConfig(Astro.locals);
29
+ const { collection, terms, content, id, free = text(config, "Free"), class: className } = Astro.props;
28
30
  const split = splitAtPaywall(content);
29
31
  const required = split.paywall && split.levels.length
30
32
  ? [split.levels]
31
- : await entryRequirement(config, collection, terms);
33
+ : await entryRequirement(config, collection, terms, id);
32
34
  const label = describe(required, config?.levels, free, text(config, "Signed in"));
33
35
  const item = soldItem(content);
34
36
  const owner = item ? (await viewer(Astro)).member : null;
35
37
  const owned = Boolean(owner && owner.status !== "disabled" && holdsItem(item, owner.tags));
36
- const locked = required !== null && !owned && !(await canRead(Astro, { collection, terms }, { required })).allowed;
38
+ const locked = required !== null && !owned && !(await canRead(Astro, { collection, terms, id }, { required })).allowed;
37
39
  const slugs = required?.flat().join(" ") || (required ? "signed-in" : "free");
38
40
  ---
39
41
 
@@ -7,7 +7,7 @@ import { accountUrl, signInUrl, signOutUrl, viewer } from "../access.js";
7
7
  import { text } from "../text.js";
8
8
 
9
9
  const v = await viewer(Astro);
10
- const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
10
+ const config = await siteConfig(Astro.locals);
11
11
  const here = Astro.url.pathname + Astro.url.search;
12
12
  const names = (config?.levels ?? []).filter((l) => v.levels.includes(l.slug)).map((l) => l.name);
13
13
  ---
@@ -12,7 +12,7 @@ interface Props {
12
12
  label?: string;
13
13
  }
14
14
 
15
- const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
15
+ const config = await siteConfig(Astro.locals);
16
16
  const { label = text(config, "Cart") } = Astro.props;
17
17
  const ref = await readCart(Astro.session);
18
18
  const count = ref?.count ?? 0;
@@ -20,7 +20,7 @@ interface Props {
20
20
  empty?: string;
21
21
  }
22
22
 
23
- const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
23
+ const config = await siteConfig(Astro.locals);
24
24
  const { productHref, empty = text(config, "Your cart is empty.") } = Astro.props;
25
25
  const ref = await readCart(Astro.session);
26
26
  let cart: CartDetails | null = null;
@@ -21,7 +21,7 @@ interface Props {
21
21
  }
22
22
 
23
23
  const { handle, limit = 12, productHref, showTitle = false } = Astro.props;
24
- const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
24
+ const config = await siteConfig(Astro.locals);
25
25
  let c: Collection | null = null;
26
26
  if (config) {
27
27
  try {
@@ -20,6 +20,10 @@
20
20
  *
21
21
  * Readers kept out get the wall (LockedPanel), with the "locked" slot on top.
22
22
  *
23
+ * `id` (the entry's id or slug) also applies the entry's own rule from the
24
+ * editor's Access panel; pass it in lists too when entries have their own
25
+ * rules.
26
+ *
23
27
  * Pass `id` on the entry's own page so drip and the meter apply: a rule's
24
28
  * `drip` keeps it shut until the member has held their level that many days,
25
29
  * and config `meter` lets signed-in readers open a few locked entries each
@@ -50,8 +54,8 @@ interface Props {
50
54
 
51
55
  const { value, collection = "", terms, id, title } = Astro.props;
52
56
  const split = splitAtPaywall(value);
53
- const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
54
- const entryRule = await entryRequirement(config, collection, terms);
57
+ const config = await siteConfig(Astro.locals);
58
+ const entryRule = await entryRequirement(config, collection, terms, id);
55
59
  // Bought on its own: the whole entry, no meter, no drip.
56
60
  const item = soldItem(value);
57
61
  const reader = item ? (await viewer(Astro)).member : null;
@@ -78,7 +82,7 @@ async function parts(blocks: unknown[]) {
78
82
  continue;
79
83
  }
80
84
  const needs = section.levels.length ? [section.levels] : (entryRule ?? [[]]);
81
- out.push({ blocks: section.blocks, verdict: await canRead(Astro, { collection, terms }, { required: needs }) });
85
+ out.push({ blocks: section.blocks, verdict: await canRead(Astro, { collection, terms, id }, { required: needs }) });
82
86
  }
83
87
  return out;
84
88
  }
@@ -21,7 +21,7 @@ interface Props {
21
21
  title?: string;
22
22
  }
23
23
 
24
- const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
24
+ const config = await siteConfig(Astro.locals);
25
25
  const { handle, amounts = [5, 10, 25], initial = 10, label = text(config, "Give"), title } = Astro.props;
26
26
  let p: Product | null = null;
27
27
  if (config) {
@@ -17,10 +17,12 @@ interface Props {
17
17
  collection?: string;
18
18
  terms?: Terms;
19
19
  levels?: string[];
20
+ /** The entry's id or slug, so its own rule from the editor counts. */
21
+ id?: string;
20
22
  }
21
23
 
22
- const { collection = "", terms, levels } = Astro.props;
23
- const verdict = await canRead(Astro, { collection, terms }, levels ? { levels } : undefined);
24
+ const { collection = "", terms, levels, id } = Astro.props;
25
+ const verdict = await canRead(Astro, { collection, terms, id }, levels ? { levels } : undefined);
24
26
  ---
25
27
 
26
28
  {verdict.allowed ? (
@@ -33,7 +33,7 @@ const { reason, required = [], held = [], opensAt, meterLeft, meterPeriod, item
33
33
  const opens = opensAt
34
34
  ? new Intl.DateTimeFormat("en-US", { dateStyle: "long", timeZone: "UTC" }).format(opensAt)
35
35
  : null;
36
- const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
36
+ const config = await siteConfig(Astro.locals);
37
37
  const missing = required.find((clause) => clause.length > 0 && !passes(held, [clause]));
38
38
  const level = missing ? config?.levels?.find((l) => l.slug === missing[0]) : undefined;
39
39
  const joinHref = level?.joinUrl ?? config?.join?.href;
@@ -19,7 +19,7 @@ interface Props {
19
19
  title?: string;
20
20
  }
21
21
 
22
- const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
22
+ const config = await siteConfig(Astro.locals);
23
23
  const { title = text(config, "Your membership") } = Astro.props;
24
24
  const v = await viewer(Astro);
25
25
  const here = Astro.url.pathname + Astro.url.search;
@@ -41,7 +41,7 @@ const DEFAULTS: Record<string, string> = {
41
41
 
42
42
  const { messages = {} } = Astro.props;
43
43
  const code = Astro.url.searchParams.get("redeemid_notice") ?? "";
44
- const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
44
+ const config = await siteConfig(Astro.locals);
45
45
  const fallback = DEFAULTS[code];
46
46
  const text = code ? (messages[code] ?? (fallback ? replace(config, fallback) : undefined)) : undefined;
47
47
  ---
@@ -25,7 +25,7 @@ interface Props {
25
25
  heldLabel?: string;
26
26
  }
27
27
 
28
- const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
28
+ const config = await siteConfig(Astro.locals);
29
29
  const { handle, label = text(config, "Join"), title, levels = [], heldLabel = text(config, "You're on this plan.") } = Astro.props;
30
30
  let p: Plan | null = null;
31
31
  if (config) {
@@ -20,7 +20,7 @@ interface Props {
20
20
  label?: string;
21
21
  }
22
22
 
23
- const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
23
+ const config = await siteConfig(Astro.locals);
24
24
  const { title = text(config, "Your preferences"), heading = "h2", label = text(config, "Save preferences") } = Astro.props;
25
25
  const v = await viewer(Astro);
26
26
  const m = v.member;
@@ -21,7 +21,7 @@ interface Props {
21
21
  buyLabel?: string;
22
22
  }
23
23
 
24
- const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
24
+ const config = await siteConfig(Astro.locals);
25
25
  const { handle, heading = "h2", compact = false, addLabel = text(config, "Add to cart"), buyLabel = text(config, "Buy now") } = Astro.props;
26
26
  let p: Product | null = null;
27
27
  if (config) {
@@ -16,7 +16,7 @@ interface Props {
16
16
  heading?: "h2" | "h3" | "h4";
17
17
  }
18
18
 
19
- const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
19
+ const config = await siteConfig(Astro.locals);
20
20
  const { title = text(config, "Your data"), heading = "h2" } = Astro.props;
21
21
  const v = await viewer(Astro);
22
22
  const route = "/_emdash/api/auth/redeemid/privacy";
package/src/config.ts CHANGED
@@ -1,10 +1,14 @@
1
1
  /**
2
2
  * RedeemID configuration.
3
3
  *
4
- * Levels and rules live in code (astro.config.mjs), so they are versioned with
5
- * the site. Secrets come from the environment and never from config.
4
+ * Levels and rules can be set in code (astro.config.mjs) as the site's
5
+ * defaults, and changed in the admin (Plugins → RedeemID → Access), which
6
+ * wins for each setting it saves (see settings.ts). Secrets come from the
7
+ * environment and never from config.
6
8
  */
7
9
 
10
+ import type { EntryRule } from "./settings.js";
11
+
8
12
  /** A member level, held by Shopify customers with its tags. */
9
13
  export interface LevelConfig {
10
14
  /** Stable slug, used in rules (e.g. "member"). */
@@ -26,9 +30,11 @@ export interface LevelConfig {
26
30
  *
27
31
  * - A rule with `taxonomy` and `term` covers entries that carry that term, or
28
32
  * a child of it in a hierarchical taxonomy (a child's own rule wins over its
29
- * parent's).
33
+ * parent's). With `collection: "*"` it covers that term in every
34
+ * collection; a rule for the same term and a named collection wins there.
30
35
  * - A rule with neither covers the whole collection.
31
- * - `collection: "*"` is the site default, for collections with no rule.
36
+ * - `collection: "*"` with neither is the site default, for collections with
37
+ * no rule.
32
38
  *
33
39
  * Term rules beat the collection rule, which beats the site default. When
34
40
  * several term rules cover one entry, a reader must pass every one of them
@@ -44,7 +50,8 @@ export interface RuleConfig {
44
50
  /** Level slugs, any of which may read. An empty list means any signed-in member. */
45
51
  levels: string[];
46
52
  /**
47
- * A term rule that opens its entries to everyone, whatever else covers them
53
+ * Opens the entries to everyone: a term rule whatever else covers them, a
54
+ * collection rule or the site default unless a term rule locks them.
48
55
  * `levels` is ignored.
49
56
  */
50
57
  open?: boolean;
@@ -161,6 +168,11 @@ export interface RedeemIDConfig {
161
168
  * under /_emdash is refused to Subscribers (see guard.ts).
162
169
  */
163
170
  memberPaths?: string[];
171
+ /**
172
+ * Entries' own rules by "collection:id" (or "collection:slug"). Usually set
173
+ * in the editor's Access panel rather than here.
174
+ */
175
+ entries?: Record<string, EntryRule>;
164
176
  /**
165
177
  * Only for local tests: lets `shop` be a full http:// origin of a fake store.
166
178
  * Never set this on a live site.
package/src/descriptor.ts CHANGED
@@ -10,10 +10,16 @@ import { VERSION } from "./version.js";
10
10
 
11
11
  export const PAGES = [
12
12
  { path: "/", label: "Members", icon: "users" },
13
+ { path: "/access", label: "Access", icon: "lock" },
14
+ { path: "/webhooks", label: "Webhooks", icon: "pulse" },
15
+ { path: "/events", label: "App events", icon: "list" },
16
+ { path: "/reports", label: "Reports", icon: "chart" },
13
17
  { path: "/status", label: "Status", icon: "pulse" },
14
18
  { path: "/test", label: "Access test", icon: "search" },
15
19
  { path: "/privacy", label: "Privacy", icon: "lock" },
16
20
  ];
21
+ /** The Access panel on every saved entry, in every collection (new ones too). */
22
+ export const EDITOR_PANELS = [{ id: "access", title: "Access", route: "access-panel", order: 10 }];
17
23
  export const WIDGETS = [{ id: "status", title: "RedeemID", size: "half" as const }];
18
24
 
19
25
  /** The descriptor for `emdash({ plugins: [...] })`. Pass the same config as `redeemid()`. */
@@ -26,6 +32,7 @@ export function redeemidPlugin(config: RedeemIDConfig): PluginDescriptor<RedeemI
26
32
  options: config,
27
33
  adminPages: PAGES,
28
34
  adminWidgets: WIDGETS,
35
+ editorPanels: EDITOR_PANELS,
29
36
  };
30
37
  }
31
38
 
package/src/events.ts CHANGED
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { env, ENV, type RedeemIDConfig } from "./config.js";
16
16
  import { USER_AGENT } from "./shopify.js";
17
+ import { record } from "./log.js";
17
18
 
18
19
  export type EventName = "member.signed_in" | "member.levels_changed" | "meter.used" | "meter.exhausted" | "preferences.saved" | "member.erased";
19
20
 
@@ -46,9 +47,11 @@ export async function emit(config: RedeemIDConfig | null, name: EventName, data:
46
47
  body,
47
48
  signal: AbortSignal.timeout(TIMEOUT_MS),
48
49
  });
50
+ if (!res.ok) await record({ event: "events.failed", label: name, status: `HTTP ${res.status}`, level: "warn", origin: "site" });
49
51
  return res.ok;
50
52
  } catch (e) {
51
53
  console.error("[redeemid] event not sent:", name, (e as Error).message);
54
+ await record({ event: "events.failed", label: name, status: "error", level: "error", origin: "site", note: String((e as Error).message).slice(0, 200) });
52
55
  return false;
53
56
  }
54
57
  }
package/src/features.ts CHANGED
@@ -32,7 +32,7 @@ export async function hasFeature(astro: AstroLike, slug: string): Promise<boolea
32
32
 
33
33
  /** The visitor's features. */
34
34
  export async function viewerFeatures(astro: AstroLike): Promise<string[]> {
35
- const config = siteConfig((astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
35
+ const config = await siteConfig(astro.locals);
36
36
  if (!config?.features?.length) return [];
37
37
  const v = await viewer(astro);
38
38
  return featuresFor(v.member, config.features, config.levels);
package/src/files.ts CHANGED
@@ -101,7 +101,7 @@ async function readsWhole(astro: AstroLike, entry: { collection: string; id: str
101
101
  const m = (await viewer(astro)).member;
102
102
  if (m && m.status !== "disabled" && holdsItem(item, m.tags)) return true;
103
103
  }
104
- const verdict = await readEntry(astro, { collection: entry.collection, terms });
104
+ const verdict = await readEntry(astro, { collection: entry.collection, terms, ruleId: entry.id });
105
105
  if (!verdict.allowed) return false;
106
106
  const clauses = bodyClauses(body);
107
107
  return clauses.every((clause) => clause.some((l) => verdict.viewer.levels.includes(l) || (l === "signed-in" && verdict.viewer.signedIn)));