@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.
- package/README.md +416 -223
- package/docs/shopify-setup.md +1 -1
- package/package.json +2 -1
- package/src/access-admin.ts +754 -0
- package/src/access.ts +84 -18
- package/src/admin-api.ts +9 -2
- package/src/agents.ts +7 -3
- package/src/astro-modules.d.ts +4 -0
- package/src/components/AccessBadge.astro +7 -5
- package/src/components/Account.astro +1 -1
- package/src/components/Cart.astro +1 -1
- package/src/components/CartView.astro +1 -1
- package/src/components/Collection.astro +1 -1
- package/src/components/Content.astro +7 -3
- package/src/components/Contribution.astro +1 -1
- package/src/components/Gate.astro +4 -2
- package/src/components/LockedPanel.astro +1 -1
- package/src/components/MemberCard.astro +1 -1
- package/src/components/Notices.astro +2 -1
- package/src/components/Plan.astro +1 -1
- package/src/components/Preferences.astro +1 -1
- package/src/components/Product.astro +1 -1
- package/src/components/YourData.astro +1 -1
- package/src/config.ts +17 -5
- package/src/descriptor.ts +7 -0
- package/src/events.ts +3 -0
- package/src/features.ts +1 -1
- package/src/files.ts +1 -1
- package/src/guard.ts +5 -3
- package/src/index.ts +4 -1
- package/src/log.ts +96 -0
- package/src/monitor.ts +560 -0
- package/src/plugin.ts +202 -86
- package/src/rate-limit-cloudflare.ts +20 -0
- package/src/rate-limit.ts +66 -0
- package/src/routes/callback.ts +21 -4
- package/src/routes/preferences.ts +2 -2
- package/src/routes/privacy.ts +2 -2
- package/src/routes/webhook.ts +24 -11
- package/src/settings.ts +195 -0
- package/src/site-config.ts +38 -5
- package/src/staff.ts +12 -23
- package/src/storage.ts +12 -0
- package/src/text.ts +1 -0
- package/src/version.ts +1 -1
- 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
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
package/src/astro-modules.d.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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;
|
|
@@ -17,6 +17,7 @@ const DEFAULTS: Record<string, string> = {
|
|
|
17
17
|
sign_in_expired: "That sign-in took too long. Try again.",
|
|
18
18
|
staff_account: "This account can't sign in here. Use a different email.",
|
|
19
19
|
account_disabled: "Your account is turned off. Contact the store for help.",
|
|
20
|
+
too_many_requests: "Too many tries. Wait a minute, then try again.",
|
|
20
21
|
site_not_ready: "This site isn't ready for sign-in yet.",
|
|
21
22
|
email_required: "Your account needs an email address to sign in.",
|
|
22
23
|
token_failed: "Sign-in didn't finish. Try again.",
|
|
@@ -40,7 +41,7 @@ const DEFAULTS: Record<string, string> = {
|
|
|
40
41
|
|
|
41
42
|
const { messages = {} } = Astro.props;
|
|
42
43
|
const code = Astro.url.searchParams.get("redeemid_notice") ?? "";
|
|
43
|
-
const config = siteConfig(
|
|
44
|
+
const config = await siteConfig(Astro.locals);
|
|
44
45
|
const fallback = DEFAULTS[code];
|
|
45
46
|
const text = code ? (messages[code] ?? (fallback ? replace(config, fallback) : undefined)) : undefined;
|
|
46
47
|
---
|
|
@@ -25,7 +25,7 @@ interface Props {
|
|
|
25
25
|
heldLabel?: string;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
const config = siteConfig(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
5
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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(
|
|
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);
|