@redeemid/emdash 0.0.15 → 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 (46) hide show
  1. package/README.md +416 -223
  2. package/docs/shopify-setup.md +1 -1
  3. package/package.json +2 -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/astro-modules.d.ts +4 -0
  9. package/src/components/AccessBadge.astro +7 -5
  10. package/src/components/Account.astro +1 -1
  11. package/src/components/Cart.astro +1 -1
  12. package/src/components/CartView.astro +1 -1
  13. package/src/components/Collection.astro +1 -1
  14. package/src/components/Content.astro +7 -3
  15. package/src/components/Contribution.astro +1 -1
  16. package/src/components/Gate.astro +4 -2
  17. package/src/components/LockedPanel.astro +1 -1
  18. package/src/components/MemberCard.astro +1 -1
  19. package/src/components/Notices.astro +2 -1
  20. package/src/components/Plan.astro +1 -1
  21. package/src/components/Preferences.astro +1 -1
  22. package/src/components/Product.astro +1 -1
  23. package/src/components/YourData.astro +1 -1
  24. package/src/config.ts +17 -5
  25. package/src/descriptor.ts +7 -0
  26. package/src/events.ts +3 -0
  27. package/src/features.ts +1 -1
  28. package/src/files.ts +1 -1
  29. package/src/guard.ts +5 -3
  30. package/src/index.ts +4 -1
  31. package/src/log.ts +96 -0
  32. package/src/monitor.ts +560 -0
  33. package/src/plugin.ts +202 -86
  34. package/src/rate-limit-cloudflare.ts +20 -0
  35. package/src/rate-limit.ts +66 -0
  36. package/src/routes/callback.ts +21 -4
  37. package/src/routes/preferences.ts +2 -2
  38. package/src/routes/privacy.ts +2 -2
  39. package/src/routes/webhook.ts +24 -11
  40. package/src/settings.ts +195 -0
  41. package/src/site-config.ts +38 -5
  42. package/src/staff.ts +12 -23
  43. package/src/storage.ts +12 -0
  44. package/src/text.ts +1 -0
  45. package/src/version.ts +1 -1
  46. package/src/webhooks.ts +2 -1
@@ -0,0 +1,754 @@
1
+ /**
2
+ * The Access screens (Block Kit): levels, site rules, content types,
3
+ * taxonomies, features and protected files in the admin, and the Access
4
+ * panel on each entry in the editor.
5
+ *
6
+ * Content types and taxonomies are read from the site's schema every time,
7
+ * so a new collection or taxonomy gets its settings with no code change.
8
+ * Level names and slugs are whatever the site picks; the only built-in level
9
+ * is "signed-in" (any signed-in member).
10
+ *
11
+ * Saving copies the code's value for that setting first, so nothing set in
12
+ * astro.config.mjs is lost; "Use the code's setting" removes the admin's copy.
13
+ * No screen grants access: levels come only from Shopify customer tags.
14
+ */
15
+
16
+ import type { StorageCollection } from "emdash";
17
+
18
+ import { applyEntryRule, describe, requirement, ruleSource, SIGNED_IN, termTree, type Requirement, type Terms } from "./access.js";
19
+ import type { FeatureConfig, FileRule, LevelConfig, MeterConfig, RedeemIDConfig, RuleConfig } from "./config.js";
20
+ import {
21
+ days,
22
+ entryKey,
23
+ list,
24
+ MANAGED,
25
+ mergeAccess,
26
+ overridden,
27
+ SITE_ID,
28
+ slugify,
29
+ validSlug,
30
+ type AccessRecord,
31
+ type EntryRule,
32
+ type LoadedAccess,
33
+ type ManagedKey,
34
+ type SiteAccess,
35
+ } from "./settings.js";
36
+
37
+ type Toast = { message: string; type: "success" | "error" | "info" };
38
+ export type Blocks = { blocks: unknown[]; toast?: Toast };
39
+
40
+ export interface CollectionInfo {
41
+ slug: string;
42
+ label: string;
43
+ }
44
+ export interface TaxonomyInfo {
45
+ name: string;
46
+ label: string;
47
+ collections: string[];
48
+ }
49
+ export interface TermInfo {
50
+ slug: string;
51
+ label: string;
52
+ }
53
+
54
+ /** What the screens need, gathered by the plugin (or by a test). */
55
+ export interface AccessEnv {
56
+ code: RedeemIDConfig;
57
+ store: StorageCollection<AccessRecord>;
58
+ saved: LoadedAccess;
59
+ collections: CollectionInfo[];
60
+ taxonomies: TaxonomyInfo[];
61
+ /** Terms of a taxonomy, parents before children, labels showing the path. */
62
+ terms(taxonomy: string): Promise<TermInfo[]>;
63
+ }
64
+
65
+ export const TABS = ["levels", "site", "types", "taxonomies", "features", "files"] as const;
66
+ export type Tab = (typeof TABS)[number];
67
+ /** Which of the four panels holds each part. */
68
+ const PANEL: Record<Tab, number> = { levels: 0, features: 0, site: 1, types: 1, taxonomies: 2, files: 3 };
69
+
70
+ const effective = (env: AccessEnv) => mergeAccess(env.code, env.saved) ?? env.code;
71
+
72
+ // ---------------------------------------------------------------- shared form parts
73
+
74
+ type Who = "none" | "open" | "signed-in" | "levels";
75
+
76
+ function whoOf(rule: RuleConfig | undefined): Who {
77
+ if (!rule) return "none";
78
+ if (rule.open) return "open";
79
+ return rule.levels.length && !rule.levels.includes(SIGNED_IN) ? "levels" : "signed-in";
80
+ }
81
+
82
+ function whoOptions(none: string | null, open = true) {
83
+ return [
84
+ ...(none ? [{ label: none, value: "none" }] : []),
85
+ ...(open ? [{ label: "Free to everyone", value: "open" }] : []),
86
+ { label: "Any signed-in member", value: "signed-in" },
87
+ { label: "Members with a level", value: "levels" },
88
+ ];
89
+ }
90
+
91
+ function levelOptions(levels: LevelConfig[]) {
92
+ return levels.map((l) => ({ label: l.name, value: l.slug }));
93
+ }
94
+
95
+ function ruleFields(levels: LevelConfig[], rule: RuleConfig | undefined, none: string | null, open = true) {
96
+ const who = whoOf(rule);
97
+ return [
98
+ { type: "select", action_id: "who", label: "Who can read", options: whoOptions(none, open), initial_value: !open && who === "open" ? "none" : !none && who === "none" ? "signed-in" : who },
99
+ ...(levels.length
100
+ ? [{ type: "checkbox", action_id: "levels", label: "Levels (any one)", options: levelOptions(levels), initial_value: rule?.levels.filter((l) => l !== SIGNED_IN) ?? [], condition: { field: "who", eq: "levels" } }]
101
+ : []),
102
+ { type: "number_input", action_id: "drip", label: "Days of membership first (0 = none)", min: 0, max: 3650, initial_value: rule?.drip ?? 0, condition: { field: "who", neq: "open" } },
103
+ { type: "toggle", action_id: "no_meter", label: "Never metered", initial_value: rule?.meter === false, condition: { field: "who", neq: "open" } },
104
+ ];
105
+ }
106
+
107
+ /** A rule from a rule form, or null for "none". Unknown levels are dropped. */
108
+ function ruleFrom(values: Record<string, unknown>, base: Omit<RuleConfig, "levels">, levels: LevelConfig[]): RuleConfig | { error: string } | null {
109
+ const who = String(values.who ?? "none") as Who;
110
+ if (who === "none") return null;
111
+ const known = new Set(levels.map((l) => l.slug));
112
+ const picked = list(values.levels).filter((l) => known.has(l));
113
+ if (who === "levels" && picked.length === 0) return { error: "Tick at least one level, or pick another choice." };
114
+ const rule: RuleConfig = { ...base, levels: who === "levels" ? picked : [] };
115
+ if (who === "open") rule.open = true;
116
+ const drip = days(values.drip);
117
+ if (drip && who !== "open") rule.drip = drip;
118
+ if (values.no_meter === true && who !== "open") rule.meter = false;
119
+ return rule;
120
+ }
121
+
122
+ function sourceLine(env: AccessEnv, key: ManagedKey, what: string): unknown[] {
123
+ const own = overridden(env.saved.site).has(key);
124
+ const inCode = (env.code as Record<string, unknown>)[key] !== undefined;
125
+ return [
126
+ {
127
+ type: "context",
128
+ text: own
129
+ ? `${what}: set here.${inCode ? " The site's code has its own value, which this replaces." : ""}`
130
+ : inCode
131
+ ? `${what}: from the site's code (astro.config.mjs). Saving here copies it and takes over.`
132
+ : `${what}: none yet.`,
133
+ },
134
+ ...(own && inCode
135
+ ? [{ type: "actions", elements: [{ type: "button", action_id: "reset", value: key, label: "Use the code's setting", style: "secondary", confirm: { title: "Use the code's setting?", text: "What was saved here for this setting is removed and the value in astro.config.mjs applies again.", confirm: "Use the code's setting", deny: "Cancel" } }] }]
136
+ : []),
137
+ ];
138
+ }
139
+
140
+ /** Who a rule lets read, without its drip and meter (those get their own columns). */
141
+ function whoText(rule: RuleConfig, levels: LevelConfig[]): string {
142
+ if (rule.open) return "Free to everyone";
143
+ return rule.levels.length ? describe([rule.levels], levels) : "Any signed-in member";
144
+ }
145
+
146
+ function ruleText(rule: RuleConfig | undefined, levels: LevelConfig[]): string {
147
+ if (!rule) return "—";
148
+ if (rule.open) return "Free to everyone";
149
+ const who = rule.levels.length ? describe([rule.levels], levels) : "Any signed-in member";
150
+ const extra = [rule.drip ? `after ${rule.drip} days` : "", rule.meter === false ? "never metered" : ""].filter(Boolean).join(", ");
151
+ return extra ? `${who} (${extra})` : who;
152
+ }
153
+
154
+ // ---------------------------------------------------------------- tabs
155
+
156
+ function levelsTab(env: AccessEnv, editing?: string): unknown[] {
157
+ const config = effective(env);
158
+ const levels = config.levels ?? [];
159
+ const rules = config.rules ?? [];
160
+ const used = (slug: string) => rules.filter((r) => r.levels.includes(slug)).length;
161
+ const current = editing ? levels.find((l) => l.slug === editing) : undefined;
162
+ return [
163
+ { type: "context", text: "A level is a name for readers who hold some Shopify customer tags. Name them anything. Tags come from Shopify (Flow, a subscription app or staff), never from this site." },
164
+ ...sourceLine(env, "levels", "Levels"),
165
+ levels.length
166
+ ? {
167
+ type: "table",
168
+ page_action_id: "levels_table",
169
+ // One fact per column; on a phone the table scrolls sideways.
170
+ columns: [
171
+ { key: "name", label: "Level" },
172
+ { key: "slug", label: "Slug", format: "code" },
173
+ { key: "tags", label: "Shopify tags" },
174
+ { key: "match", label: "Needs" },
175
+ { key: "rules", label: "Rules", format: "number" },
176
+ { key: "act", label: "", format: "element" },
177
+ ],
178
+ rows: levels.map((l) => ({
179
+ name: l.name,
180
+ slug: l.slug,
181
+ tags: l.tags.join(", "),
182
+ match: l.tags.length < 2 ? "The tag" : l.match === "all" ? "Every tag" : "Any tag",
183
+ rules: used(l.slug),
184
+ act: { type: "menu", action_id: "level_menu", label: "Edit", items: [{ label: "Edit", value: `edit:${l.slug}` }, { label: "Remove", value: `remove:${l.slug}` }] },
185
+ })),
186
+ }
187
+ : { type: "empty", title: "No levels yet", description: "Add one below, such as Members for the tag member.", size: "sm" },
188
+ { type: "header", text: current ? `Edit ${current.name}` : "Add a level" },
189
+ {
190
+ type: "form",
191
+ block_id: current ? `level:${current.slug}` : "level:new",
192
+ fields: [
193
+ { type: "text_input", action_id: "name", label: "Name", placeholder: "Members", initial_value: current?.name ?? "" },
194
+ ...(current ? [] : [{ type: "text_input", action_id: "slug", label: "Slug (optional, fixed once saved)", placeholder: "members" }]),
195
+ { type: "text_input", action_id: "tags", label: "Shopify tags, comma separated", placeholder: "member", initial_value: current?.tags.join(", ") ?? "" },
196
+ { type: "select", action_id: "match", label: "Customer needs", options: [{ label: "Any one tag", value: "any" }, { label: "Every tag", value: "all" }], initial_value: current?.match ?? "any" },
197
+ { type: "text_input", action_id: "wall", label: "Wall text (optional)", initial_value: current?.wall ?? "", multiline: true },
198
+ { type: "text_input", action_id: "join_url", label: "Join link (optional)", placeholder: "/join/", initial_value: current?.joinUrl ?? "" },
199
+ ],
200
+ submit: { label: current ? "Save level" : "Add level", action_id: "level_save" },
201
+ },
202
+ ...(current ? [{ type: "actions", elements: [{ type: "button", action_id: "level_cancel", label: "Cancel", style: "secondary" }] }] : []),
203
+ ];
204
+ }
205
+
206
+ function siteTab(env: AccessEnv): unknown[] {
207
+ const config = effective(env);
208
+ const levels = config.levels ?? [];
209
+ const site = (config.rules ?? []).find((r) => r.collection === "*" && !r.taxonomy && !r.term);
210
+ const meter = config.meter;
211
+ return [
212
+ { type: "context", text: "What applies when nothing more specific does, how rules combine, the meter and the join link." },
213
+ ...sourceLine(env, "rules", "Rules"),
214
+ {
215
+ type: "form",
216
+ block_id: "site",
217
+ fields: [
218
+ ...ruleFields(levels, site, "Free to everyone", false),
219
+ { type: "select", action_id: "combine", label: "Several term rules on one entry", options: [{ label: "Pass every one", value: "all" }, { label: "Pass any one", value: "any" }], initial_value: config.combine ?? "all" },
220
+ { type: "number_input", action_id: "meter_limit", label: "Meter: free locked reads per period (0 = off)", min: 0, max: 1000, initial_value: meter?.limit ?? 0 },
221
+ { type: "select", action_id: "meter_period", label: "Meter period", options: [{ label: "Month", value: "month" }, { label: "Week", value: "week" }], initial_value: meter?.period ?? "month" },
222
+ ...(env.collections.length
223
+ ? [{ type: "checkbox", action_id: "meter_collections", label: "Meter covers (none ticked = all)", options: env.collections.map((c) => ({ label: c.label, value: c.slug })), initial_value: meter?.collections ?? [] }]
224
+ : []),
225
+ { type: "text_input", action_id: "join_href", label: "Join link", placeholder: "/join/", initial_value: config.join?.href ?? "" },
226
+ { type: "text_input", action_id: "join_label", label: "Join link text", placeholder: "Become a member", initial_value: config.join?.label ?? "" },
227
+ ],
228
+ submit: { label: "Save site rules", action_id: "site_save" },
229
+ },
230
+ ...sourceLine(env, "meter", "Meter"),
231
+ ...sourceLine(env, "join", "Join link"),
232
+ ];
233
+ }
234
+
235
+ function typesTab(env: AccessEnv): unknown[] {
236
+ const config = effective(env);
237
+ const levels = config.levels ?? [];
238
+ const rules = config.rules ?? [];
239
+ if (env.collections.length === 0) return [{ type: "empty", title: "No content types yet", size: "sm" }];
240
+ return [
241
+ { type: "context", text: "Who can read each content type. New content types appear here as soon as they're made. Term rules (Terms tab) and an entry's own rule (its Access panel) beat these." },
242
+ ...sourceLine(env, "rules", "Rules"),
243
+ ...env.collections.map((c) => {
244
+ const rule = rules.find((r) => r.collection === c.slug && !r.taxonomy && !r.term);
245
+ const teaser = config.teaser?.[c.slug] ?? 0;
246
+ return {
247
+ type: "accordion",
248
+ label: `${c.label}: ${rule ? ruleText(rule, levels) : "follows the site default"}`,
249
+ blocks: [
250
+ {
251
+ type: "form",
252
+ block_id: `type:${c.slug}`,
253
+ fields: [
254
+ ...ruleFields(levels, rule, "Follow the site default"),
255
+ { type: "number_input", action_id: "teaser", label: "Teaser words without a paywall break (0 = none)", min: 0, max: 2000, initial_value: teaser },
256
+ ],
257
+ submit: { label: `Save ${c.label}`, action_id: "type_save" },
258
+ },
259
+ ],
260
+ };
261
+ }),
262
+ ];
263
+ }
264
+
265
+ async function taxonomiesTab(env: AccessEnv): Promise<unknown[]> {
266
+ const config = effective(env);
267
+ const levels = config.levels ?? [];
268
+ const rules = config.rules ?? [];
269
+ if (env.taxonomies.length === 0) return [{ type: "empty", title: "No taxonomies yet", size: "sm" }];
270
+ const label = (slug: string) => (slug === "*" ? "Every content type" : (env.collections.find((c) => c.slug === slug)?.label ?? slug));
271
+ const out: unknown[] = [
272
+ { type: "context", text: "Rules for entries with a term, such as a category. A child term follows its parent's rule unless it has its own. New taxonomies appear here as soon as they're made." },
273
+ ...sourceLine(env, "rules", "Rules"),
274
+ ];
275
+ for (const t of env.taxonomies) {
276
+ const own = rules.filter((r) => r.taxonomy === t.name && r.term);
277
+ const terms = await env.terms(t.name);
278
+ const termLabel = (slug: string) => terms.find((x) => x.slug === slug)?.label ?? slug;
279
+ out.push({
280
+ type: "accordion",
281
+ label: `${t.label}: ${own.length} ${own.length === 1 ? "rule" : "rules"}`,
282
+ default_open: own.length > 0,
283
+ blocks: [
284
+ own.length
285
+ ? {
286
+ type: "table",
287
+ page_action_id: `term_rules:${t.name}`,
288
+ columns: [
289
+ { key: "term", label: "Term" },
290
+ { key: "scope", label: "In" },
291
+ { key: "who", label: "Who can read" },
292
+ { key: "drip", label: "Drip" },
293
+ { key: "meter", label: "Meter" },
294
+ { key: "act", label: "", format: "element" },
295
+ ],
296
+ rows: own.map((r) => ({
297
+ term: termLabel(r.term!),
298
+ scope: r.collection === "*" ? "Every content type" : label(r.collection),
299
+ who: whoText(r, levels),
300
+ drip: r.drip ? `${r.drip} days` : "",
301
+ meter: r.open ? "" : r.meter === false ? "Never" : "Counts",
302
+ act: { type: "button", action_id: "term_remove", label: "Remove", style: "secondary", value: `${r.collection}|${t.name}|${r.term}`, confirm: { title: "Remove this term rule?", text: "Its entries follow the other rules again.", confirm: "Remove", deny: "Cancel" } },
303
+ })),
304
+ }
305
+ : { type: "context", text: "No rules: its terms don't change who can read." },
306
+ terms.length
307
+ ? {
308
+ type: "form",
309
+ block_id: `term:${t.name}`,
310
+ fields: [
311
+ { type: "combobox", action_id: "term", label: "Term", options: terms.map((x) => ({ label: x.label, value: x.slug })) },
312
+ { type: "select", action_id: "scope", label: "In", options: [{ label: "Every content type", value: "*" }, ...t.collections.map((c) => ({ label: label(c), value: c }))], initial_value: "*" },
313
+ ...ruleFields(levels, undefined, null),
314
+ ],
315
+ submit: { label: "Save term rule", action_id: "term_save" },
316
+ }
317
+ : { type: "context", text: "Add terms to this taxonomy to give them rules." },
318
+ ],
319
+ });
320
+ }
321
+ return out;
322
+ }
323
+
324
+ function featuresTab(env: AccessEnv, editing?: string): unknown[] {
325
+ const config = effective(env);
326
+ const levels = config.levels ?? [];
327
+ const features = config.features ?? [];
328
+ const current = editing ? features.find((f) => f.slug === editing) : undefined;
329
+ return [
330
+ { type: "context", text: "Perks members hold through a level or a Shopify tag, such as ad-free reading. Pages check them with <Feature>." },
331
+ ...sourceLine(env, "features", "Features"),
332
+ features.length
333
+ ? {
334
+ type: "table",
335
+ page_action_id: "features_table",
336
+ columns: [
337
+ { key: "name", label: "Feature" },
338
+ { key: "slug", label: "Slug", format: "code" },
339
+ { key: "levels", label: "Levels" },
340
+ { key: "tags", label: "Shopify tags" },
341
+ { key: "act", label: "", format: "element" },
342
+ ],
343
+ rows: features.map((f) => ({
344
+ name: f.name,
345
+ slug: f.slug,
346
+ levels: (f.levels ?? []).map((l) => levels.find((x) => x.slug === l)?.name ?? l).join(", "),
347
+ tags: (f.tags ?? []).join(", "),
348
+ act: { type: "menu", action_id: "feature_menu", label: "Edit", items: [{ label: "Edit", value: `edit:${f.slug}` }, { label: "Remove", value: `remove:${f.slug}` }] },
349
+ })),
350
+ }
351
+ : { type: "context", text: "No features." },
352
+ { type: "header", text: current ? `Edit ${current.name}` : "Add a feature" },
353
+ {
354
+ type: "form",
355
+ block_id: current ? `feature:${current.slug}` : "feature:new",
356
+ fields: [
357
+ { type: "text_input", action_id: "name", label: "Name", placeholder: "Ad-free", initial_value: current?.name ?? "" },
358
+ ...(current ? [] : [{ type: "text_input", action_id: "slug", label: "Slug (optional, fixed once saved)", placeholder: "ad-free" }]),
359
+ ...(levels.length ? [{ type: "checkbox", action_id: "levels", label: "Levels that hold it", options: levelOptions(levels), initial_value: current?.levels ?? [] }] : []),
360
+ { type: "text_input", action_id: "tags", label: "Shopify tags, comma separated (optional)", initial_value: current?.tags?.join(", ") ?? "" },
361
+ ],
362
+ submit: { label: current ? "Save feature" : "Add feature", action_id: "feature_save" },
363
+ },
364
+ ];
365
+ }
366
+
367
+ function filesTab(env: AccessEnv): unknown[] {
368
+ const config = effective(env);
369
+ const levels = config.levels ?? [];
370
+ const features = config.features ?? [];
371
+ const files = config.files ?? [];
372
+ return [
373
+ { type: "context", text: "Media folders whose files only some readers may download. Any of the ways in is enough." },
374
+ ...sourceLine(env, "files", "Protected files"),
375
+ files.length
376
+ ? {
377
+ type: "table",
378
+ page_action_id: "files_table",
379
+ columns: [
380
+ { key: "folder", label: "Media folder" },
381
+ { key: "members", label: "Members" },
382
+ { key: "features", label: "Features" },
383
+ { key: "entries", label: "Entry readers" },
384
+ { key: "act", label: "", format: "element" },
385
+ ],
386
+ rows: files.map((f) => ({
387
+ folder: f.folder,
388
+ members: f.levels ? (f.levels.length ? describe([f.levels], levels) : "Any signed-in member") : "",
389
+ features: (f.features ?? []).map((x) => features.find((y) => y.slug === x)?.name ?? x).join(", "),
390
+ entries: f.entries ? "Yes" : "",
391
+ act: { type: "button", action_id: "file_remove", label: "Remove", style: "secondary", value: f.folder, confirm: { title: "Stop protecting this folder?", text: "Anyone with a file's link can download it again.", confirm: "Remove", deny: "Cancel" } },
392
+ })),
393
+ }
394
+ : { type: "context", text: "No protected folders: every file is public." },
395
+ { type: "header", text: "Protect a folder" },
396
+ {
397
+ type: "form",
398
+ block_id: "file",
399
+ fields: [
400
+ { type: "text_input", action_id: "folder", label: "Media folder name", placeholder: "Member downloads" },
401
+ { type: "toggle", action_id: "members", label: "Signed-in members", initial_value: true },
402
+ ...(levels.length ? [{ type: "checkbox", action_id: "levels", label: "Only these levels (none = any member)", options: levelOptions(levels), condition: { field: "members", eq: true } }] : []),
403
+ ...(features.length ? [{ type: "checkbox", action_id: "features", label: "Features", options: features.map((f) => ({ label: f.name, value: f.slug })) }] : []),
404
+ { type: "toggle", action_id: "entries", label: "Readers of an entry that uses it", initial_value: false },
405
+ ],
406
+ submit: { label: "Save folder", action_id: "file_save" },
407
+ },
408
+ ];
409
+ }
410
+
411
+ /** The Access page, opened on `tab`. */
412
+ export async function accessPage(env: AccessEnv, tab: Tab = "levels", toast?: Toast, edit?: { level?: string; feature?: string }): Promise<Blocks> {
413
+ const panels = [
414
+ // Four short tabs so they fit across a phone without scrolling the page.
415
+ { label: "Levels", blocks: [...levelsTab(env, edit?.level), { type: "divider" }, { type: "header", text: "Features" }, ...featuresTab(env, edit?.feature)] },
416
+ { label: "Rules", blocks: [{ type: "header", text: "Site default" }, ...siteTab(env), { type: "divider" }, { type: "header", text: "Content types" }, ...typesTab(env)] },
417
+ { label: "Terms", blocks: await taxonomiesTab(env) },
418
+ { label: "Files", blocks: filesTab(env) },
419
+ ];
420
+ return {
421
+ toast,
422
+ blocks: [
423
+ { type: "header", text: "Access" },
424
+ { type: "context", text: "Who can read what. Shopify decides who holds which tags; these settings only say what each tag opens." },
425
+ { type: "tab", panels, default_tab: PANEL[tab] ?? 0 },
426
+ ],
427
+ };
428
+ }
429
+
430
+ // ---------------------------------------------------------------- saving
431
+
432
+ async function saveSite(env: AccessEnv, change: (site: SiteAccess, now: RedeemIDConfig) => SiteAccess | string): Promise<string | null> {
433
+ const now = effective(env);
434
+ const site: SiteAccess = structuredClone(env.saved.site ?? { kind: "site" });
435
+ const out = change(site, now);
436
+ if (typeof out === "string") return out;
437
+ out.updatedAt = new Date().toISOString();
438
+ await env.store.put(SITE_ID, out);
439
+ env.saved = { ...env.saved, site: out };
440
+ return null;
441
+ }
442
+
443
+ /** The current value of a setting, to start from when the admin first takes it over. */
444
+ function start<K extends ManagedKey>(site: SiteAccess, now: RedeemIDConfig, key: K): NonNullable<RedeemIDConfig[K]> {
445
+ const value = (key in site ? site[key as keyof SiteAccess] : now[key]) as RedeemIDConfig[K] | null | undefined;
446
+ return structuredClone(value ?? (key === "teaser" ? {} : [])) as NonNullable<RedeemIDConfig[K]>;
447
+ }
448
+
449
+ function newSlug(values: Record<string, unknown>, taken: string[]): string | { error: string } {
450
+ const raw = String(values.slug ?? "").trim() || String(values.name ?? "");
451
+ const slug = slugify(raw);
452
+ if (!validSlug(slug)) return { error: "Give it a name or slug with letters or numbers (\"signed-in\" is built in)." };
453
+ if (taken.includes(slug)) return { error: `"${slug}" is already used.` };
454
+ return slug;
455
+ }
456
+
457
+ export interface Interaction {
458
+ action_id?: string;
459
+ block_id?: string;
460
+ value?: unknown;
461
+ values?: Record<string, unknown>;
462
+ }
463
+
464
+ const ACCESS_ACTIONS = new Set(["reset", "level_menu", "level_cancel", "level_save", "site_save", "type_save", "term_save", "term_remove", "feature_menu", "feature_save", "file_save", "file_remove"]);
465
+
466
+ export function isAccessAction(id: string | undefined): boolean {
467
+ return Boolean(id && ACCESS_ACTIONS.has(id));
468
+ }
469
+
470
+ /** Handle one interaction on the Access page. */
471
+ export async function accessAction(env: AccessEnv, i: Interaction): Promise<Blocks> {
472
+ const values = i.values ?? {};
473
+ const ok = (tab: Tab, message: string) => accessPage(env, tab, { type: "success", message });
474
+ const bad = (tab: Tab, message: string, edit?: { level?: string; feature?: string }) => accessPage(env, tab, { type: "error", message }, edit);
475
+ const target = String(i.block_id ?? "").split(":").slice(1).join(":");
476
+
477
+ switch (i.action_id) {
478
+ case "reset": {
479
+ const key = String(i.value ?? "") as ManagedKey;
480
+ if (!MANAGED.includes(key)) return accessPage(env);
481
+ await saveSite(env, (site) => {
482
+ delete site[key as keyof SiteAccess];
483
+ return site;
484
+ });
485
+ const tab: Tab = key === "levels" ? "levels" : key === "features" ? "features" : key === "files" ? "files" : key === "teaser" ? "types" : "site";
486
+ return ok(tab, "Using the code's setting again.");
487
+ }
488
+
489
+ case "level_cancel":
490
+ return accessPage(env, "levels");
491
+
492
+ case "level_menu": {
493
+ const [verb, slug] = String(i.value ?? "").split(":");
494
+ if (verb === "edit") return accessPage(env, "levels", undefined, { level: slug });
495
+ const now = effective(env);
496
+ const used = (now.rules ?? []).filter((r) => r.levels.includes(slug!)).length + (now.files ?? []).filter((f) => f.levels?.includes(slug!)).length + (now.features ?? []).filter((f) => f.levels?.includes(slug!)).length;
497
+ if (used) return bad("levels", `Rules, features or folders use this level (${used}). Change them first.`);
498
+ await saveSite(env, (site, n) => ({ ...site, levels: start(site, n, "levels").filter((l) => l.slug !== slug) }));
499
+ return ok("levels", "Level removed.");
500
+ }
501
+
502
+ case "level_save": {
503
+ const name = String(values.name ?? "").trim();
504
+ const tags = list(values.tags);
505
+ const editing = target !== "new" ? target : null;
506
+ if (!name) return bad("levels", "Give the level a name.", { level: editing ?? undefined });
507
+ if (tags.length === 0) return bad("levels", "Give it at least one Shopify tag.", { level: editing ?? undefined });
508
+ const fail = await saveSite(env, (site, n) => {
509
+ const levels = start(site, n, "levels");
510
+ let slug = editing;
511
+ if (!slug) {
512
+ const made = newSlug(values, levels.map((l) => l.slug));
513
+ if (typeof made !== "string") return made.error;
514
+ slug = made;
515
+ }
516
+ const level: LevelConfig = { slug, name, tags, ...(values.match === "all" ? { match: "all" as const } : {}) };
517
+ const wall = String(values.wall ?? "").trim();
518
+ const joinUrl = String(values.join_url ?? "").trim();
519
+ if (wall) level.wall = wall;
520
+ if (joinUrl) level.joinUrl = joinUrl;
521
+ const at = levels.findIndex((l) => l.slug === slug);
522
+ if (at >= 0) levels[at] = level;
523
+ else levels.push(level);
524
+ return { ...site, levels };
525
+ });
526
+ return fail ? bad("levels", fail) : ok("levels", editing ? "Level saved." : "Level added.");
527
+ }
528
+
529
+ case "site_save": {
530
+ const now = effective(env);
531
+ const rule = ruleFrom(values, { collection: "*" }, now.levels ?? []);
532
+ if (rule && "error" in rule) return bad("site", rule.error);
533
+ const limit = Math.max(0, Math.floor(Number(values.meter_limit) || 0));
534
+ const known = new Set(env.collections.map((c) => c.slug));
535
+ const covered = list(values.meter_collections).filter((c) => known.has(c));
536
+ const meter: MeterConfig | null = limit ? { limit, period: values.meter_period === "week" ? "week" : "month", ...(covered.length ? { collections: covered } : {}) } : null;
537
+ const href = String(values.join_href ?? "").trim();
538
+ const label = String(values.join_label ?? "").trim();
539
+ await saveSite(env, (site, n) => {
540
+ const rules = start(site, n, "rules").filter((r) => !(r.collection === "*" && !r.taxonomy && !r.term));
541
+ if (rule) rules.push(rule);
542
+ return {
543
+ ...site,
544
+ rules,
545
+ combine: values.combine === "any" ? "any" : "all",
546
+ meter,
547
+ join: href ? { href, ...(label ? { label } : {}) } : null,
548
+ };
549
+ });
550
+ return ok("site", "Site rules saved.");
551
+ }
552
+
553
+ case "type_save": {
554
+ const now = effective(env);
555
+ if (!env.collections.some((c) => c.slug === target)) return bad("types", "That content type is gone.");
556
+ const rule = ruleFrom(values, { collection: target }, now.levels ?? []);
557
+ if (rule && "error" in rule) return bad("types", rule.error);
558
+ const words = Math.max(0, Math.min(2000, Math.floor(Number(values.teaser) || 0)));
559
+ await saveSite(env, (site, n) => {
560
+ const rules = start(site, n, "rules").filter((r) => !(r.collection === target && !r.taxonomy && !r.term));
561
+ if (rule) rules.push(rule);
562
+ const teaser = start(site, n, "teaser");
563
+ if (words) teaser[target] = words;
564
+ else delete teaser[target];
565
+ return { ...site, rules, teaser };
566
+ });
567
+ return ok("types", "Saved.");
568
+ }
569
+
570
+ case "term_save": {
571
+ const now = effective(env);
572
+ const taxonomy = env.taxonomies.find((t) => t.name === target);
573
+ const term = String(values.term ?? "").trim();
574
+ const scope = String(values.scope ?? "*");
575
+ if (!taxonomy) return bad("taxonomies", "That taxonomy is gone.");
576
+ if (!term) return bad("taxonomies", "Pick a term.");
577
+ if (scope !== "*" && !taxonomy.collections.includes(scope)) return bad("taxonomies", "Pick where the rule applies.");
578
+ const rule = ruleFrom({ ...values, who: values.who === "none" ? "signed-in" : values.who }, { collection: scope, taxonomy: taxonomy.name, term }, now.levels ?? []);
579
+ if (!rule) return bad("taxonomies", "Pick who can read.");
580
+ if ("error" in rule) return bad("taxonomies", rule.error);
581
+ await saveSite(env, (site, n) => {
582
+ const rules = start(site, n, "rules").filter((r) => !(r.collection === scope && r.taxonomy === taxonomy.name && r.term === term));
583
+ rules.push(rule);
584
+ return { ...site, rules };
585
+ });
586
+ return ok("taxonomies", "Term rule saved.");
587
+ }
588
+
589
+ case "term_remove": {
590
+ const [collection, taxonomy, term] = String(i.value ?? "").split("|");
591
+ await saveSite(env, (site, n) => ({ ...site, rules: start(site, n, "rules").filter((r) => !(r.collection === collection && r.taxonomy === taxonomy && r.term === term)) }));
592
+ return ok("taxonomies", "Term rule removed.");
593
+ }
594
+
595
+ case "feature_menu": {
596
+ const [verb, slug] = String(i.value ?? "").split(":");
597
+ if (verb === "edit") return accessPage(env, "features", undefined, { feature: slug });
598
+ const usedBy = (effective(env).files ?? []).filter((f) => f.features?.includes(slug!)).length;
599
+ if (usedBy) return bad("features", "A protected folder uses this feature. Change it first.");
600
+ await saveSite(env, (site, n) => ({ ...site, features: start(site, n, "features").filter((f) => f.slug !== slug) }));
601
+ return ok("features", "Feature removed.");
602
+ }
603
+
604
+ case "feature_save": {
605
+ const name = String(values.name ?? "").trim();
606
+ const editing = target !== "new" ? target : null;
607
+ if (!name) return bad("features", "Give the feature a name.", { feature: editing ?? undefined });
608
+ const known = new Set((effective(env).levels ?? []).map((l) => l.slug));
609
+ const levels = list(values.levels).filter((l) => known.has(l));
610
+ const tags = list(values.tags);
611
+ if (!levels.length && !tags.length) return bad("features", "Tick a level or give a tag that holds it.", { feature: editing ?? undefined });
612
+ const fail = await saveSite(env, (site, n) => {
613
+ const features = start(site, n, "features");
614
+ let slug = editing;
615
+ if (!slug) {
616
+ const made = newSlug(values, features.map((f) => f.slug));
617
+ if (typeof made !== "string") return made.error;
618
+ slug = made;
619
+ }
620
+ const feature: FeatureConfig = { slug, name, ...(levels.length ? { levels } : {}), ...(tags.length ? { tags } : {}) };
621
+ const at = features.findIndex((f) => f.slug === slug);
622
+ if (at >= 0) features[at] = feature;
623
+ else features.push(feature);
624
+ return { ...site, features };
625
+ });
626
+ return fail ? bad("features", fail) : ok("features", "Feature saved.");
627
+ }
628
+
629
+ case "file_save": {
630
+ const now = effective(env);
631
+ const folder = String(values.folder ?? "").trim();
632
+ if (!folder) return bad("files", "Give the media folder's name.");
633
+ const knownLevels = new Set((now.levels ?? []).map((l) => l.slug));
634
+ const knownFeatures = new Set((now.features ?? []).map((f) => f.slug));
635
+ const rule: FileRule = { folder };
636
+ if (values.members === true) rule.levels = list(values.levels).filter((l) => knownLevels.has(l));
637
+ const features = list(values.features).filter((f) => knownFeatures.has(f));
638
+ if (features.length) rule.features = features;
639
+ if (values.entries === true) rule.entries = true;
640
+ if (!rule.levels && !rule.features && !rule.entries) return bad("files", "Pick at least one way in.");
641
+ await saveSite(env, (site, n) => {
642
+ const files = start(site, n, "files").filter((f) => f.folder.trim().toLowerCase() !== folder.toLowerCase());
643
+ files.push(rule);
644
+ return { ...site, files };
645
+ });
646
+ return ok("files", "Folder protected.");
647
+ }
648
+
649
+ case "file_remove": {
650
+ const folder = String(i.value ?? "").trim().toLowerCase();
651
+ await saveSite(env, (site, n) => ({ ...site, files: start(site, n, "files").filter((f) => f.folder.trim().toLowerCase() !== folder) }));
652
+ return ok("files", "Folder no longer protected.");
653
+ }
654
+ }
655
+ return accessPage(env);
656
+ }
657
+
658
+ // ---------------------------------------------------------------- the editor's Access panel
659
+
660
+ export interface PanelEntry {
661
+ collection: string;
662
+ id: string;
663
+ slug: string | null;
664
+ terms: Terms;
665
+ }
666
+
667
+ const MODES = [
668
+ { label: "Follow the site rules", value: "inherit" },
669
+ { label: "Free to everyone", value: "open" },
670
+ { label: "Only some levels", value: "levels" },
671
+ { label: "Site rules plus a level", value: "add" },
672
+ ];
673
+
674
+ function words(required: Requirement, levels: LevelConfig[]): string {
675
+ return required === null ? "free to everyone" : `readers need ${describe(required, levels, "", "to be signed in")}`;
676
+ }
677
+
678
+ const day = (iso: string | null | undefined) => (iso ? iso.slice(0, 10) : "");
679
+
680
+ /** The entry's Access panel. */
681
+ export async function entryPanel(env: AccessEnv, entry: PanelEntry, toast?: Toast): Promise<Blocks> {
682
+ const config = effective(env);
683
+ const levels = config.levels ?? [];
684
+ const rules = config.rules ?? [];
685
+ const tree = await termTree(rules, entry.collection, entry.terms);
686
+ const site = requirement(rules, entry.collection, entry.terms, { combine: config.combine, tree });
687
+ const own = env.saved.entries.find((e) => e.entryId === entry.id && e.collection === entry.collection) ?? null;
688
+ const now = applyEntryRule(site, own);
689
+ const hasLevels = levels.length > 0;
690
+ return {
691
+ toast,
692
+ blocks: [
693
+ { type: "section", text: `Right now: ${words(now, levels)}.` },
694
+ { type: "context", text: own && own.mode !== "inherit" ? "Set by: this entry's own rule." : `Set by: ${ruleSource(rules, entry.collection, entry.terms, tree)}.` },
695
+ {
696
+ type: "form",
697
+ block_id: "entry",
698
+ fields: [
699
+ { type: "select", action_id: "mode", label: "Who can read it", options: MODES, initial_value: own?.mode ?? "inherit" },
700
+ ...(hasLevels
701
+ ? [{ type: "checkbox", action_id: "levels", label: "Levels (any one; none = any member)", options: levelOptions(levels), initial_value: own?.levels ?? [], condition: { field: "mode", neq: "inherit" } }]
702
+ : []),
703
+ { type: "number_input", action_id: "drip", label: "Days of membership first (0 = rules decide)", min: 0, max: 3650, initial_value: own?.drip ?? 0 },
704
+ { type: "toggle", action_id: "no_meter", label: "Never metered", initial_value: own?.meter === false },
705
+ { type: "date_input", action_id: "locked_until", label: "Locked until (then free, UTC)", initial_value: day(own?.lockedUntil) },
706
+ { type: "date_input", action_id: "open_until", label: "Free until (then locked, UTC)", initial_value: day(own?.openUntil) },
707
+ ],
708
+ submit: { label: "Save access", action_id: "entry_save" },
709
+ },
710
+ { type: "context", text: "Paywall break and Members only blocks can still lock parts of the body." },
711
+ ],
712
+ };
713
+ }
714
+
715
+ function isoDay(raw: unknown): string | null {
716
+ const s = String(raw ?? "").trim();
717
+ if (!s) return null;
718
+ const t = Date.parse(/^\d{4}-\d{2}-\d{2}$/.test(s) ? `${s}T00:00:00Z` : s);
719
+ return Number.isNaN(t) ? null : new Date(t).toISOString();
720
+ }
721
+
722
+ /** Save the entry's Access panel. */
723
+ export async function entrySave(env: AccessEnv, entry: PanelEntry, values: Record<string, unknown>): Promise<Blocks> {
724
+ const levels = effective(env).levels ?? [];
725
+ const known = new Set(levels.map((l) => l.slug));
726
+ const mode = (["inherit", "open", "levels", "add"].includes(String(values.mode)) ? String(values.mode) : "inherit") as EntryRule["mode"];
727
+ const picked = list(values.levels).filter((l) => known.has(l));
728
+ if (mode === "add" && picked.length === 0) return entryPanel(env, entry, { type: "error", message: "Tick the level readers also need." });
729
+ const rule: EntryRule = {
730
+ kind: "entry",
731
+ collection: entry.collection,
732
+ entryId: entry.id,
733
+ slug: entry.slug,
734
+ mode,
735
+ levels: mode === "levels" || mode === "add" ? picked : [],
736
+ updatedAt: new Date().toISOString(),
737
+ };
738
+ const drip = days(values.drip);
739
+ if (drip) rule.drip = drip;
740
+ if (values.no_meter === true) rule.meter = false;
741
+ rule.lockedUntil = isoDay(values.locked_until);
742
+ rule.openUntil = isoDay(values.open_until);
743
+ const empty = mode === "inherit" && !rule.drip && rule.meter !== false && !rule.lockedUntil && !rule.openUntil;
744
+ if (empty) await env.store.delete(entryKey(entry.id));
745
+ else await env.store.put(entryKey(entry.id), rule);
746
+ env.saved = { ...env.saved, entries: [...env.saved.entries.filter((e) => e.entryId !== entry.id), ...(empty ? [] : [rule])] };
747
+ return entryPanel(env, entry, { type: "success", message: empty ? "This entry follows the site rules." : "Access saved." });
748
+ }
749
+
750
+ /** Keep an entry's own rule in step with its slug, and drop it with the entry. */
751
+ export async function entrySlugChanged(store: StorageCollection<AccessRecord>, collection: string, id: string, slug: string | null): Promise<void> {
752
+ const rule = (await store.get(entryKey(id))) as EntryRule | null;
753
+ if (rule?.kind === "entry" && rule.collection === collection && rule.slug !== slug) await store.put(entryKey(id), { ...rule, slug });
754
+ }