@redeemid/emdash 0.0.22 → 0.0.24
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 +38 -14
- package/docs/developer-api.md +5 -3
- package/package.json +1 -1
- package/src/access-admin.ts +84 -13
- package/src/access.ts +129 -29
- package/src/agents.ts +3 -3
- package/src/api-version.ts +1 -1
- package/src/blocks-names.ts +6 -8
- package/src/components/AccessBadge.astro +8 -9
- package/src/components/Account.astro +14 -3
- package/src/components/Content.astro +6 -6
- package/src/components/MemberCard.astro +4 -3
- package/src/components/Plan.astro +30 -10
- package/src/config.ts +54 -3
- package/src/contract.ts +1 -1
- package/src/files.ts +4 -5
- package/src/log.ts +1 -0
- package/src/plugin.ts +48 -17
- package/src/privacy.ts +10 -7
- package/src/routes/callback.ts +5 -2
- package/src/routes/webhook.ts +21 -1
- package/src/settings.ts +1 -1
- package/src/setup.ts +2 -2
- package/src/staff.ts +90 -3
- package/src/text.ts +1 -1
- package/src/version.ts +1 -1
- package/src/webhooks.ts +7 -3
package/src/agents.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { adminConfigured } from "./admin-api.js";
|
|
13
|
-
import { applyEntryRule, entryRuleFor, describe as describeRequirement, dripDays, levelsFor, membership, passes, requirement, termTree, type Requirement, type Terms } from "./access.js";
|
|
13
|
+
import { applyEntryRule, entryRuleFor, groupsOf, simplify, 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";
|
|
@@ -69,7 +69,7 @@ export async function explainAccess(config: RedeemIDConfig, s: Stores, input: Ex
|
|
|
69
69
|
const disabled = member?.status === "disabled";
|
|
70
70
|
const held = disabled ? [] : levelsFor(tags, levels);
|
|
71
71
|
const tree = await termTree(config.rules, collection, terms);
|
|
72
|
-
const required: Requirement = applyEntryRule(requirement(config.rules, collection, terms, { combine: config.combine, tree }), own);
|
|
72
|
+
const required: Requirement = simplify(applyEntryRule(requirement(config.rules, collection, terms, { combine: config.combine, tree }), own), levels);
|
|
73
73
|
const owned = !disabled && holdsItem(item, tags);
|
|
74
74
|
const canReadRule = owned || required === null || (signedIn && !disabled && passes(held, required));
|
|
75
75
|
const name = (slug: string) => (slug === "signed-in" ? "Signed in" : (levels.find((l) => l.slug === slug)?.name ?? slug));
|
|
@@ -101,7 +101,7 @@ export async function explainAccess(config: RedeemIDConfig, s: Stores, input: Ex
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
export function listLevels(config: RedeemIDConfig): Array<Record<string, unknown>> {
|
|
104
|
-
return (config.levels ?? []).map((l) => ({ slug: l.slug, name: l.name,
|
|
104
|
+
return (config.levels ?? []).map((l) => ({ slug: l.slug, name: l.name, groups: groupsOf(l).map((g) => ({ tags: g.tags, match: g.match ?? "any" })), includes: l.includes ?? [], wall: l.wall ?? null, joinUrl: l.joinUrl ?? null }));
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
export function getRules(config: RedeemIDConfig): Record<string, unknown> {
|
package/src/api-version.ts
CHANGED
package/src/blocks-names.ts
CHANGED
|
@@ -39,22 +39,20 @@ export function holdsItem(item: SoldItem | null, tags: string[] | undefined): bo
|
|
|
39
39
|
type Node = { _type?: unknown; levels?: unknown };
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
|
-
* Split a Portable Text body at its first paywall break: the open part
|
|
43
|
-
* members part
|
|
44
|
-
*
|
|
42
|
+
* Split a Portable Text body at its first paywall break: the open part and
|
|
43
|
+
* the members part. The break only says where the entry's rule starts to
|
|
44
|
+
* apply; it carries no levels of its own (levels an older break stored are
|
|
45
|
+
* ignored). With no break, everything is in `open` and `paywall` is false.
|
|
45
46
|
*/
|
|
46
47
|
export function splitAtPaywall<T>(value: T[] | null | undefined): {
|
|
47
48
|
open: T[];
|
|
48
49
|
locked: T[];
|
|
49
|
-
levels: string[];
|
|
50
50
|
paywall: boolean;
|
|
51
51
|
} {
|
|
52
52
|
const blocks = Array.isArray(value) ? value : [];
|
|
53
53
|
const at = blocks.findIndex((b) => (b as Node)?._type === PAYWALL_BLOCK);
|
|
54
|
-
if (at === -1) return { open: blocks, locked: [],
|
|
55
|
-
|
|
56
|
-
const levels = Array.isArray(raw) ? raw.filter((l): l is string => typeof l === "string" && l !== "") : [];
|
|
57
|
-
return { open: blocks.slice(0, at), locked: blocks.slice(at + 1), levels, paywall: true };
|
|
54
|
+
if (at === -1) return { open: blocks, locked: [], paywall: false };
|
|
55
|
+
return { open: blocks.slice(0, at), locked: blocks.slice(at + 1), paywall: true };
|
|
58
56
|
}
|
|
59
57
|
|
|
60
58
|
type TextNode = { _type?: unknown; children?: Array<{ text?: unknown }> };
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
|
-
* The level an entry needs, read from its rules
|
|
4
|
-
* with levels):
|
|
3
|
+
* The level an entry needs, read from its rules:
|
|
5
4
|
*
|
|
6
5
|
* <AccessBadge collection="posts" terms={post.data.terms} content={post.data.content} id={post.id} />
|
|
7
6
|
*
|
|
@@ -9,15 +8,15 @@
|
|
|
9
8
|
* carries `data-redeemid-level` (the level slugs) for styling, and
|
|
10
9
|
* `is-locked` with a lock for readers who can't read the members part.
|
|
11
10
|
*/
|
|
12
|
-
import { canRead, describe, entryRequirement, viewer, type Terms } from "../access.js";
|
|
13
|
-
import { holdsItem, soldItem
|
|
11
|
+
import { badgeColor, canRead, describe, entryRequirement, simplify, viewer, type Terms } from "../access.js";
|
|
12
|
+
import { holdsItem, soldItem } from "../blocks-names.js";
|
|
14
13
|
import { siteConfig } from "../site-config.js";
|
|
15
14
|
import { text } from "../text.js";
|
|
16
15
|
|
|
17
16
|
interface Props {
|
|
18
17
|
collection: string;
|
|
19
18
|
terms?: Terms;
|
|
20
|
-
/** The entry's body,
|
|
19
|
+
/** The entry's body, for its "Sold on its own" block. */
|
|
21
20
|
content?: unknown[] | null;
|
|
22
21
|
/** The entry's id or slug, so its own rule from the editor counts. */
|
|
23
22
|
id?: string;
|
|
@@ -27,22 +26,22 @@ interface Props {
|
|
|
27
26
|
|
|
28
27
|
const config = await siteConfig(Astro.locals);
|
|
29
28
|
const { collection, terms, content, id, free = text(config, "Free"), class: className } = Astro.props;
|
|
30
|
-
const
|
|
31
|
-
const required = split.paywall && split.levels.length
|
|
32
|
-
? [split.levels]
|
|
33
|
-
: await entryRequirement(config, collection, terms, id);
|
|
29
|
+
const required = await entryRequirement(config, collection, terms, id);
|
|
34
30
|
const label = describe(required, config?.levels, free, text(config, "Signed in"));
|
|
35
31
|
const item = soldItem(content);
|
|
36
32
|
const owner = item ? (await viewer(Astro)).member : null;
|
|
37
33
|
const owned = Boolean(owner && owner.status !== "disabled" && holdsItem(item, owner.tags));
|
|
38
34
|
const locked = required !== null && !owned && !(await canRead(Astro, { collection, terms, id }, { required })).allowed;
|
|
39
35
|
const slugs = required?.flat().join(" ") || (required ? "signed-in" : "free");
|
|
36
|
+
// The colour of the level the badge names first, when that level has one.
|
|
37
|
+
const color = badgeColor(config?.levels?.find((l) => l.slug === simplify(required, config?.levels)?.find((c) => c.length)?.[0]));
|
|
40
38
|
---
|
|
41
39
|
|
|
42
40
|
{label && (
|
|
43
41
|
<span
|
|
44
42
|
class:list={["redeemid-badge", { "is-locked": locked }, className]}
|
|
45
43
|
data-redeemid-level={slugs}
|
|
44
|
+
style={color ? `--redeemid-level-color: ${color}` : undefined}
|
|
46
45
|
>
|
|
47
46
|
{locked && <span class="redeemid-badge__lock" aria-hidden="true">🔒︎ </span>}
|
|
48
47
|
{label}
|
|
@@ -3,20 +3,31 @@
|
|
|
3
3
|
* Sign in link for visitors; name, levels, Account and Sign out for members.
|
|
4
4
|
*/
|
|
5
5
|
import { siteConfig } from "../site-config.js";
|
|
6
|
-
import { accountUrl, signInUrl, signOutUrl, viewer } from "../access.js";
|
|
6
|
+
import { accountUrl, badgeColor, signInUrl, signOutUrl, topLevels, viewer } from "../access.js";
|
|
7
7
|
import { text } from "../text.js";
|
|
8
8
|
|
|
9
9
|
const v = await viewer(Astro);
|
|
10
10
|
const config = await siteConfig(Astro.locals);
|
|
11
11
|
const here = Astro.url.pathname + Astro.url.search;
|
|
12
|
-
|
|
12
|
+
// "Gold", not "Gold, Silver", when Gold includes Silver.
|
|
13
|
+
const top = topLevels(v.levels, config?.levels);
|
|
14
|
+
const held = (config?.levels ?? []).filter((l) => top.includes(l.slug));
|
|
13
15
|
---
|
|
14
16
|
|
|
15
17
|
<div class="redeemid-account" data-redeemid-signed-in={v.signedIn ? "true" : "false"}>
|
|
16
18
|
{v.signedIn ? (
|
|
17
19
|
<>
|
|
18
20
|
<span class="redeemid-account__who">{v.member?.firstName ?? v.member?.email ?? text(config, "Signed in")}</span>
|
|
19
|
-
{
|
|
21
|
+
{held.length > 0 && (
|
|
22
|
+
<span class="redeemid-account__levels">
|
|
23
|
+
{held.map((l, i) => (
|
|
24
|
+
<>
|
|
25
|
+
{i > 0 && ", "}
|
|
26
|
+
<span data-redeemid-level={l.slug} style={badgeColor(l) ? `--redeemid-level-color: ${badgeColor(l)}` : undefined}>{l.name}</span>
|
|
27
|
+
</>
|
|
28
|
+
))}
|
|
29
|
+
</span>
|
|
30
|
+
)}
|
|
20
31
|
<a class="redeemid-account__link" href={accountUrl(here)}>{text(config, "Account")}</a>
|
|
21
32
|
<form method="post" action={signOutUrl(here)}>
|
|
22
33
|
<button type="submit">{text(config, "Sign out")}</button>
|
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
* <Content value={post.data.content} collection="posts" terms={post.data.terms} id={post.id} />
|
|
6
6
|
*
|
|
7
7
|
* - "Paywall break": everything before the first one is shown to everyone.
|
|
8
|
-
* What follows reaches the page only for readers
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* What follows reaches the page only for readers the entry's rule lets in
|
|
9
|
+
* (with no rule, the break changes nothing). The break says where the
|
|
10
|
+
* rule starts; the rule itself comes from the entry's settings.
|
|
11
11
|
* - "Members only" … "Members only: end": the part between is for the levels
|
|
12
12
|
* picked on the start block, or the entry's rule, or any signed-in member.
|
|
13
13
|
* Readers who can't read it see the wall in its place, and the body goes on.
|
|
@@ -60,9 +60,9 @@ const entryRule = await entryRequirement(config, collection, terms, id);
|
|
|
60
60
|
const item = soldItem(value);
|
|
61
61
|
const reader = item ? (await viewer(Astro)).member : null;
|
|
62
62
|
const owned = Boolean(reader && reader.status !== "disabled" && holdsItem(item, reader.tags));
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
const required = split.paywall ?
|
|
63
|
+
// The rest after a break follows the entry's rule, and the break does
|
|
64
|
+
// nothing when no rule covers the entry.
|
|
65
|
+
const required = split.paywall ? entryRule : undefined;
|
|
66
66
|
const verdict: Verdict | { allowed: true; reason: "open" } =
|
|
67
67
|
owned || (split.paywall && required === null)
|
|
68
68
|
? { allowed: true, reason: "open" }
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* Everything shown comes from the mirrored Shopify customer; Refresh
|
|
12
12
|
* membership reads it from the store again.
|
|
13
13
|
*/
|
|
14
|
-
import { accountUrl, membership, meterUsage, refreshUrl, signInUrl, signOutUrl, viewer } from "../access.js";
|
|
14
|
+
import { accountUrl, badgeColor, membership, meterUsage, refreshUrl, signInUrl, signOutUrl, viewer } from "../access.js";
|
|
15
15
|
import { featuresFor } from "../features.js";
|
|
16
16
|
import { siteConfig } from "../site-config.js";
|
|
17
17
|
import { text, textParts } from "../text.js";
|
|
@@ -29,6 +29,7 @@ const m = v.member;
|
|
|
29
29
|
const disabled = m?.status === "disabled";
|
|
30
30
|
const { current, past } = m && !disabled ? membership(m, config?.levels) : { current: [], past: [] };
|
|
31
31
|
const meter = v.signedIn ? await meterUsage(Astro) : null;
|
|
32
|
+
const color = (slug: string) => badgeColor(config?.levels?.find((l) => l.slug === slug));
|
|
32
33
|
const held = m ? featuresFor(m, config?.features, config?.levels) : [];
|
|
33
34
|
const features = (config?.features ?? []).filter((f) => held.includes(f.slug));
|
|
34
35
|
const date = (iso: string | null) => {
|
|
@@ -59,8 +60,8 @@ const join = config?.join;
|
|
|
59
60
|
<p class="redeemid-member__disabled">{text(config, "Your account is turned off. Contact the store for help.")}</p>
|
|
60
61
|
) : current.length > 0 ? (
|
|
61
62
|
<ul class="redeemid-member__levels">
|
|
62
|
-
{current.map((l) => (
|
|
63
|
-
<li data-redeemid-level={l.slug}>
|
|
63
|
+
{current.filter((l) => !l.via).map((l) => (
|
|
64
|
+
<li data-redeemid-level={l.slug} style={color(l.slug) ? `--redeemid-level-color: ${color(l.slug)}` : undefined}>
|
|
64
65
|
<strong>{l.name}</strong>
|
|
65
66
|
{(dates.since ? day(dates.since) : date(l.since)) && <> {textParts(config, "since {date}", { date: <time datetime={dates.since ?? l.since ?? undefined}>{dates.since ? day(dates.since) : date(l.since)}</time> })}</>}
|
|
66
67
|
</li>
|
|
@@ -6,11 +6,16 @@
|
|
|
6
6
|
*
|
|
7
7
|
* <Plan handle="gold-membership" levels={["gold"]} />
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* `levels` are the levels the plan gives; left out, they are the levels
|
|
10
|
+
* whose `products` list the handle. Then the card fits the member:
|
|
11
|
+
*
|
|
12
|
+
* - held: they hold one of them through its own tags: "You're on this plan."
|
|
13
|
+
* - included: a level they hold includes it: "Included in your Gold membership."
|
|
14
|
+
* - upgrade: the plan's level includes one they hold: the button says "Upgrade".
|
|
15
|
+
* - renew: they held one before (Shopify removed its tag): "Renew".
|
|
16
|
+
* - join: anyone else.
|
|
12
17
|
*/
|
|
13
|
-
import { membership, viewer } from "../access.js";
|
|
18
|
+
import { closure, directLevels, membership, topLevels, viewer } from "../access.js";
|
|
14
19
|
import { siteConfig } from "../site-config.js";
|
|
15
20
|
import { money, plan as loadPlan, storeContext, type Plan } from "../storefront.js";
|
|
16
21
|
import { text } from "../text.js";
|
|
@@ -21,15 +26,19 @@ interface Props {
|
|
|
21
26
|
label?: string;
|
|
22
27
|
/** Heading override; defaults to the Shopify product title. */
|
|
23
28
|
title?: string;
|
|
24
|
-
/** The levels this plan gives.
|
|
29
|
+
/** The levels this plan gives. Defaults to the levels whose `products` list `handle`. */
|
|
25
30
|
levels?: string[];
|
|
26
31
|
heldLabel?: string;
|
|
27
32
|
/** Button text for a member whose plan lapsed. */
|
|
28
33
|
renewLabel?: string;
|
|
34
|
+
/** Button text for a member this plan would move up. */
|
|
35
|
+
upgradeLabel?: string;
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
const config = await siteConfig(Astro.locals);
|
|
32
|
-
const { handle, label = text(config, "Join"), title,
|
|
39
|
+
const { handle, label = text(config, "Join"), title, heldLabel = text(config, "You're on this plan."), renewLabel = text(config, "Renew"), upgradeLabel = text(config, "Upgrade") } = Astro.props;
|
|
40
|
+
const all = config?.levels ?? [];
|
|
41
|
+
const levels = Astro.props.levels?.length ? Astro.props.levels : all.filter((l) => l.products?.some((h) => h.toLowerCase() === handle.toLowerCase())).map((l) => l.slug);
|
|
33
42
|
let p: Plan | null = null;
|
|
34
43
|
if (config) {
|
|
35
44
|
try {
|
|
@@ -42,23 +51,34 @@ if (config) {
|
|
|
42
51
|
const price = money(p?.price);
|
|
43
52
|
const here = Astro.url.pathname + Astro.url.search;
|
|
44
53
|
const v = levels.length > 0 ? await viewer(Astro) : null;
|
|
45
|
-
const
|
|
54
|
+
const live = v?.member && v.member.status !== "disabled" ? v.member : null;
|
|
55
|
+
const direct = live ? directLevels(live.tags, all) : [];
|
|
56
|
+
const top = topLevels(v?.levels ?? [], all);
|
|
57
|
+
const held = levels.some((l) => direct.includes(l));
|
|
58
|
+
// Held only through a bigger level: name that one.
|
|
59
|
+
const via = held ? undefined : top.find((t) => levels.some((l) => t !== l && closure(t, all).includes(l)));
|
|
60
|
+
const viaName = via ? (all.find((l) => l.slug === via)?.name ?? via) : null;
|
|
61
|
+
// The plan's level includes one the member holds: it moves them up.
|
|
62
|
+
const upgrade = !held && !via && top.length > 0 && levels.some((l) => top.some((t) => t !== l && closure(l, all).includes(t)));
|
|
46
63
|
// Lapsed: held one of these levels before. Offered their plan back, not "Join" as if new.
|
|
47
|
-
const lapsed = Boolean(!held &&
|
|
64
|
+
const lapsed = Boolean(!held && !via && live && membership(live, all).past.some((p) => levels.includes(p.slug)));
|
|
65
|
+
const state = held ? "held" : via ? "included" : upgrade ? "upgrade" : lapsed ? "renew" : "join";
|
|
48
66
|
---
|
|
49
67
|
|
|
50
68
|
{p ? (
|
|
51
|
-
<div class:list={["redeemid-plan", { "is-held": held, "is-lapsed": lapsed }]} data-redeemid-plan={p.handle} data-redeemid-state={
|
|
69
|
+
<div class:list={["redeemid-plan", { "is-held": held || Boolean(via), "is-lapsed": lapsed }]} data-redeemid-plan={p.handle} data-redeemid-state={state}>
|
|
52
70
|
<h2 class="redeemid-plan__title">{title ?? p.title}</h2>
|
|
53
71
|
{price && <p class="redeemid-plan__price">{price}</p>}
|
|
54
72
|
{p.description && <p class="redeemid-plan__description">{p.description}</p>}
|
|
55
73
|
{held ? (
|
|
56
74
|
<p class="redeemid-plan__held">{heldLabel}</p>
|
|
75
|
+
) : viaName ? (
|
|
76
|
+
<p class="redeemid-plan__held">{text(config, "Included in your {level} membership.", { level: viaName })}</p>
|
|
57
77
|
) : p.availableForSale ? (
|
|
58
78
|
<form method="post" action="/_emdash/api/auth/redeemid/buy">
|
|
59
79
|
<input type="hidden" name="plan" value={p.handle} />
|
|
60
80
|
<input type="hidden" name="return" value={here} />
|
|
61
|
-
<button type="submit">{lapsed ? renewLabel : label}</button>
|
|
81
|
+
<button type="submit">{upgrade ? upgradeLabel : lapsed ? renewLabel : label}</button>
|
|
62
82
|
</form>
|
|
63
83
|
) : (
|
|
64
84
|
<p class="redeemid-plan__unavailable">{text(config, "Not available right now.")}</p>
|
package/src/config.ts
CHANGED
|
@@ -15,14 +15,65 @@ export interface LevelConfig {
|
|
|
15
15
|
slug: string;
|
|
16
16
|
/** Name shown to members. */
|
|
17
17
|
name: string;
|
|
18
|
-
/**
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Shopify customer tags that grant this level. Matched case-insensitively.
|
|
20
|
+
* This is the level's first tag group; `groups` adds more.
|
|
21
|
+
*/
|
|
22
|
+
tags?: string[];
|
|
23
|
+
/** For `tags`: "any" (default): one of the tags is enough. "all": every tag is needed. */
|
|
21
24
|
match?: "any" | "all";
|
|
25
|
+
/**
|
|
26
|
+
* More tag groups, any one of which also grants the level: a level that is
|
|
27
|
+
* "gold", or "silver" and "annual", is `tags: ["gold"]` with
|
|
28
|
+
* `groups: [{ tags: ["silver", "annual"], match: "all" }]`.
|
|
29
|
+
*/
|
|
30
|
+
groups?: TagGroup[];
|
|
31
|
+
/**
|
|
32
|
+
* Slugs of levels this one also opens: Gold with `includes: ["silver"]`
|
|
33
|
+
* reads everything for Silver. Inclusion carries on (Gold includes Silver
|
|
34
|
+
* includes Members), so rules name only the lowest level that may read.
|
|
35
|
+
*/
|
|
36
|
+
includes?: string[];
|
|
22
37
|
/** What the wall says to readers this level would let in, e.g. "Gold members read every investigation." */
|
|
23
38
|
wall?: string;
|
|
24
39
|
/** Where the wall's join link goes for this level. Falls back to `join.href`. */
|
|
25
40
|
joinUrl?: string;
|
|
41
|
+
/**
|
|
42
|
+
* Handles of the Shopify products whose purchase leads to this level
|
|
43
|
+
* (Flow or an app tags the buyer). Display only: plan cards use it to say
|
|
44
|
+
* "You're on this plan", "Included in your Gold membership" or "Upgrade".
|
|
45
|
+
* It grants nothing.
|
|
46
|
+
*/
|
|
47
|
+
products?: string[];
|
|
48
|
+
/**
|
|
49
|
+
* Members' home: a same-site path members of this level land on after
|
|
50
|
+
* signing in from the home page or a plain sign-in link, e.g. "/members/gold/".
|
|
51
|
+
* Signing in from an entry returns them to it.
|
|
52
|
+
*/
|
|
53
|
+
home?: string;
|
|
54
|
+
/** Badge colour, a hex such as "#c9a227", set as `--redeemid-level-color` on badges and level names. Badges always show the name too. */
|
|
55
|
+
badge?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One way to hold a level: any one of `tags`, or every one with `match: "all"`. */
|
|
59
|
+
export interface TagGroup {
|
|
60
|
+
tags: string[];
|
|
61
|
+
match?: "any" | "all";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A level's tag groups: `tags`/`match` first, then `groups`. Empty groups are dropped. */
|
|
65
|
+
export function groupsOf(level: LevelConfig): TagGroup[] {
|
|
66
|
+
const out: TagGroup[] = [];
|
|
67
|
+
if (level.tags?.length) out.push({ tags: level.tags, ...(level.match === "all" ? { match: "all" as const } : {}) });
|
|
68
|
+
for (const g of level.groups ?? []) {
|
|
69
|
+
if (g && Array.isArray(g.tags) && g.tags.length) out.push({ tags: g.tags, ...(g.match === "all" ? { match: "all" as const } : {}) });
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Every tag any of the level's groups names, lower case. */
|
|
75
|
+
export function levelTags(level: LevelConfig): string[] {
|
|
76
|
+
return [...new Set(groupsOf(level).flatMap((g) => g.tags.map((t) => t.toLowerCase())))];
|
|
26
77
|
}
|
|
27
78
|
|
|
28
79
|
/**
|
package/src/contract.ts
CHANGED
|
@@ -16,7 +16,7 @@ export { API_VERSION } from "./api-version.js";
|
|
|
16
16
|
/** Stable exports, by import path. */
|
|
17
17
|
export const MODULES = {
|
|
18
18
|
"@redeemid/emdash": ["redeemid", "redeemidGuard", "redeemidPlugin"],
|
|
19
|
-
"@redeemid/emdash/access": ["siteConfig", "viewer", "canRead", "readEntry", "entryRequirement", "meterUsage", "membership", "levelsFor", "signInUrl", "signOutUrl", "accountUrl", "cartUrl", "refreshUrl", "safeReturn"],
|
|
19
|
+
"@redeemid/emdash/access": ["siteConfig", "viewer", "canRead", "readEntry", "entryRequirement", "meterUsage", "membership", "levelsFor", "topLevels", "closure", "simplify", "signInUrl", "signOutUrl", "accountUrl", "cartUrl", "refreshUrl", "safeReturn"],
|
|
20
20
|
"@redeemid/emdash/features": ["hasFeature", "viewerFeatures", "featuresFor"],
|
|
21
21
|
"@redeemid/emdash/preferences": ["readPreferences", "savePreferences", "preferenceFields"],
|
|
22
22
|
"@redeemid/emdash/storefront": ["plan", "product", "collection", "money", "realOptions", "variantFor", "storeContext", "cartDetails", "addToCart", "updateLines", "removeLines", "setDiscountCodes"],
|
package/src/files.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { readEntry, SIGNED_IN, viewer, type AstroLike, type Terms } from "./access.js";
|
|
20
|
-
import { holdsItem, ITEM_BLOCK, PAYWALL_BLOCK, sections, soldItem
|
|
20
|
+
import { holdsItem, ITEM_BLOCK, PAYWALL_BLOCK, sections, soldItem } from "./blocks-names.js";
|
|
21
21
|
import type { FileRule, RedeemIDConfig } from "./config.js";
|
|
22
22
|
import { featuresFor } from "./features.js";
|
|
23
23
|
import { text } from "./text.js";
|
|
@@ -79,15 +79,14 @@ async function entriesUsing(db: unknown, mediaId: string): Promise<Array<{ colle
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
/**
|
|
82
|
-
* The levels picked on an entry body's
|
|
83
|
-
*
|
|
82
|
+
* The levels picked on an entry body's members-only parts: readers must
|
|
83
|
+
* hold one from each. A members-only part with none picked needs a
|
|
84
84
|
* signed-in reader (on top of the entry's rule, which the caller checks).
|
|
85
|
+
* A paywall break adds nothing: what follows it is under the entry's rule.
|
|
85
86
|
*/
|
|
86
87
|
export function bodyClauses(body: unknown): string[][] {
|
|
87
88
|
const blocks = Array.isArray(body) ? body : [];
|
|
88
89
|
const out: string[][] = [];
|
|
89
|
-
const split = splitAtPaywall(blocks);
|
|
90
|
-
if (split.paywall && split.levels.length) out.push(split.levels);
|
|
91
90
|
for (const s of sections(blocks)) if (s.members) out.push(s.levels.length ? s.levels : [SIGNED_IN]);
|
|
92
91
|
return out;
|
|
93
92
|
}
|
package/src/log.ts
CHANGED
|
@@ -42,6 +42,7 @@ export const LOG_EVENTS: Record<string, string> = {
|
|
|
42
42
|
"shopify.admin": "Admin API call",
|
|
43
43
|
"staff.resync": "Resync",
|
|
44
44
|
"staff.tags": "Tag edit",
|
|
45
|
+
"staff.link": "Account link",
|
|
45
46
|
"staff.webhooks": "Webhook registration",
|
|
46
47
|
"settings.saved": "Settings saved",
|
|
47
48
|
"events.failed": "Outbound event failed",
|
package/src/plugin.ts
CHANGED
|
@@ -11,10 +11,9 @@
|
|
|
11
11
|
* test, Privacy), a daily webhook health check and a dashboard widget. Sign-in, webhooks and checkout stay on the
|
|
12
12
|
* auth provider's routes.
|
|
13
13
|
*
|
|
14
|
-
* - "Paywall break" (`redeemid-paywall`):
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* no rule covers the entry.
|
|
14
|
+
* - "Paywall break" (`redeemid-paywall`): marks where the entry's rule starts.
|
|
15
|
+
* Everything before it is for everyone; everything after it follows the
|
|
16
|
+
* entry's rule, and nothing is locked when no rule covers the entry.
|
|
18
17
|
* - "Members only" (`redeemid-members`) and "Members only: end"
|
|
19
18
|
* (`redeemid-members-end`): the part between is for the levels picked on
|
|
20
19
|
* the start block; everyone else sees the wall there and reads on after.
|
|
@@ -28,17 +27,17 @@
|
|
|
28
27
|
import type { PluginContext, ResolvedPlugin, RouteContext } from "emdash";
|
|
29
28
|
import { definePlugin } from "emdash";
|
|
30
29
|
|
|
31
|
-
import { levelsFor, SIGNED_IN, type Terms } from "./access.js";
|
|
30
|
+
import { levelsFor, SIGNED_IN, topLevels, type Terms } from "./access.js";
|
|
32
31
|
import { accessAction, accessPage, entryPanel, entrySave, entrySlugChanged, isAccessAction, type AccessEnv, type PanelEntry } from "./access-admin.js";
|
|
33
32
|
import { deleteAccess, entryKey, loadAccess, onLevels, withSaved } from "./settings.js";
|
|
34
|
-
import { env, ENV, storeDomain, unknownEnvNames, type LevelConfig, type RedeemIDConfig } from "./config.js";
|
|
33
|
+
import { env, ENV, shopOrigin, storeDomain, unknownEnvNames, type LevelConfig, type RedeemIDConfig } from "./config.js";
|
|
35
34
|
import { discover } from "./shopify.js";
|
|
36
35
|
import { stores, type MemberRecord, type Stores } from "./storage.js";
|
|
37
36
|
import { EDITOR_PANELS, PAGES, WIDGETS } from "./descriptor.js";
|
|
38
37
|
import { VERSION } from "./version.js";
|
|
39
38
|
import { AdminError, adminConfigured } from "./admin-api.js";
|
|
40
39
|
import { cancelShopifyErasure, closeRequest, eraseMember, exportMember, exportRequest, requestShopifyErasure } from "./privacy.js";
|
|
41
|
-
import { accessTest, bulkResync, BULK_BATCH, exportBlocks, memberDetail, privacyPage, registerWebhooks, resyncOne, tagEdit } from "./staff.js";
|
|
40
|
+
import { accessTest, bulkResync, BULK_BATCH, exportBlocks, memberDetail, privacyPage, registerWebhooks, resyncOne, staffLink, staffUnlink, tagEdit } from "./staff.js";
|
|
42
41
|
import { textProblems } from "./text.js";
|
|
43
42
|
import { gatedFindings, templateFindings } from "./template-scan.js";
|
|
44
43
|
import { setupFacts, setupPage, WEBHOOK } from "./setup.js";
|
|
@@ -84,15 +83,8 @@ function blocks(config: RedeemIDConfig) {
|
|
|
84
83
|
type: PAYWALL_BLOCK,
|
|
85
84
|
label: "Paywall break",
|
|
86
85
|
icon: "lock",
|
|
87
|
-
description: "
|
|
88
|
-
fields: [
|
|
89
|
-
{
|
|
90
|
-
type: "checkbox" as const,
|
|
91
|
-
action_id: "levels",
|
|
92
|
-
label: "Who can read on (none = the entry's rule)",
|
|
93
|
-
options: levelOptions,
|
|
94
|
-
},
|
|
95
|
-
],
|
|
86
|
+
description: "Everyone reads up to here; the rest follows this entry's access rule",
|
|
87
|
+
fields: [],
|
|
96
88
|
},
|
|
97
89
|
{
|
|
98
90
|
type: MEMBERS_BLOCK,
|
|
@@ -539,6 +531,20 @@ async function action(
|
|
|
539
531
|
return m ? memberDetail(config, m, fail(e)) : membersPage(config);
|
|
540
532
|
}
|
|
541
533
|
}
|
|
534
|
+
case "member_link":
|
|
535
|
+
case "member_unlink": {
|
|
536
|
+
const id = i.action_id === "member_link" ? String(i.block_id ?? "").replace(/^link:/, "") : String(i.value ?? "");
|
|
537
|
+
try {
|
|
538
|
+
const { getDb } = await import("emdash/runtime");
|
|
539
|
+
const { createKyselyAdapter } = await import("@emdash-cms/auth/adapters/kysely");
|
|
540
|
+
const adapter = createKyselyAdapter((await getDb()) as never);
|
|
541
|
+
const m = i.action_id === "member_link" ? await staffLink(adapter, s, id, i.values?.email) : await staffUnlink(adapter, s, id);
|
|
542
|
+
return memberDetail(config, m, { type: "success", message: m.userId ? "Linked. Their next sign-in uses that site user." : "Unlinked." });
|
|
543
|
+
} catch (e) {
|
|
544
|
+
const m = await s.members.get(id);
|
|
545
|
+
return m ? memberDetail(config, m, fail(e)) : membersPage(config);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
542
548
|
case "member_tag_add":
|
|
543
549
|
case "member_tag_remove": {
|
|
544
550
|
const add = i.action_id === "member_tag_add";
|
|
@@ -675,7 +681,7 @@ async function membersPage(config: RedeemIDConfig) {
|
|
|
675
681
|
}));
|
|
676
682
|
const name = (m: MemberRecord) => [m.firstName, m.lastName].filter(Boolean).join(" ") || "No name";
|
|
677
683
|
const levelNames = (m: MemberRecord) => {
|
|
678
|
-
const held = levelsFor(m.tags, levels);
|
|
684
|
+
const held = topLevels(levelsFor(m.tags, levels), levels);
|
|
679
685
|
return levels.filter((l) => held.includes(l.slug)).map((l) => l.name).join(", ");
|
|
680
686
|
};
|
|
681
687
|
return {
|
|
@@ -812,6 +818,7 @@ async function statusPage(config: RedeemIDConfig, request: Request) {
|
|
|
812
818
|
},
|
|
813
819
|
]
|
|
814
820
|
: []),
|
|
821
|
+
...customDomainBlocks(config),
|
|
815
822
|
...templateBlocks(config),
|
|
816
823
|
{ type: "context", text: "Setting up a store step by step is under Setup. Deliveries, their health and the webhook subscriptions are under Webhooks; everything RedeemID did is under App events." },
|
|
817
824
|
{ type: "divider" },
|
|
@@ -831,6 +838,30 @@ async function statusPage(config: RedeemIDConfig, request: Request) {
|
|
|
831
838
|
};
|
|
832
839
|
}
|
|
833
840
|
|
|
841
|
+
/**
|
|
842
|
+
* Status: the Admin API (staff tools, webhook registration, tag reads) only
|
|
843
|
+
* answers on the store's myshopify.com domain, so a custom domain in `shop`
|
|
844
|
+
* breaks it while sign-in still works.
|
|
845
|
+
*/
|
|
846
|
+
export function customDomainBlocks(config: RedeemIDConfig): unknown[] {
|
|
847
|
+
if (!adminConfigured() || config.allowInsecureShop) return [];
|
|
848
|
+
let host = "";
|
|
849
|
+
try {
|
|
850
|
+
host = new URL(shopOrigin(config)).hostname;
|
|
851
|
+
} catch {
|
|
852
|
+
return [];
|
|
853
|
+
}
|
|
854
|
+
if (host.endsWith(".myshopify.com")) return [];
|
|
855
|
+
return [
|
|
856
|
+
{
|
|
857
|
+
type: "banner",
|
|
858
|
+
variant: "alert",
|
|
859
|
+
title: "Use the myshopify.com domain",
|
|
860
|
+
description: `The store domain is ${host}. Shopify's Admin API, which the staff tools, webhook registration and tag reads use, only answers on the store's myshopify.com domain. Set REDEEMID_SHOPIFY_STORE_DOMAIN (or shop) to it; sign-in works with it too.`,
|
|
861
|
+
},
|
|
862
|
+
];
|
|
863
|
+
}
|
|
864
|
+
|
|
834
865
|
/** Status: templates that would show a gated body in full (see template-scan.ts). */
|
|
835
866
|
function templateBlocks(config: RedeemIDConfig): unknown[] {
|
|
836
867
|
const found = templateFindings();
|
package/src/privacy.ts
CHANGED
|
@@ -82,22 +82,25 @@ export async function eraseMember(config: RedeemIDConfig | null, s: Stores, cust
|
|
|
82
82
|
return Boolean(member);
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
/**
|
|
85
|
+
/**
|
|
86
|
+
* shop/redact: erase every member and meter record. Deletes a page of 100 at
|
|
87
|
+
* a time in one statement, so even a large shop stays well inside a Workers
|
|
88
|
+
* Free request's query limit (a few queries per hundred members).
|
|
89
|
+
*/
|
|
86
90
|
export async function eraseAll(config: RedeemIDConfig | null, s: Stores): Promise<number> {
|
|
87
91
|
let n = 0;
|
|
88
92
|
for (;;) {
|
|
89
93
|
const page = await s.members.query({ limit: 100 });
|
|
90
94
|
if (page.items.length === 0) break;
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}
|
|
95
|
+
const deleted = await s.members.deleteMany(page.items.map((r) => r.id));
|
|
96
|
+
n += page.items.length;
|
|
97
|
+
// A store that deletes nothing would loop forever.
|
|
98
|
+
if (deleted === 0) throw new Error("shop/redact: members could not be deleted");
|
|
96
99
|
}
|
|
97
100
|
for (;;) {
|
|
98
101
|
const page = await s.meter.query({ limit: 100 });
|
|
99
102
|
if (page.items.length === 0) break;
|
|
100
|
-
|
|
103
|
+
if ((await s.meter.deleteMany(page.items.map((r) => r.id))) === 0) throw new Error("shop/redact: meter records could not be deleted");
|
|
101
104
|
}
|
|
102
105
|
await log(s, { kind: "erase", source: "shopify", customerId: null, status: "done", note: `Shop redacted: ${n} members` });
|
|
103
106
|
return n;
|
package/src/routes/callback.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { env, ENV, PROVIDER_ID, storeDomain } from "../config.js";
|
|
|
18
18
|
import { siteConfig } from "../site-config.js";
|
|
19
19
|
import { emit, levelChange } from "../events.js";
|
|
20
20
|
import { record } from "../log.js";
|
|
21
|
-
import { levelsFor } from "../access.js";
|
|
21
|
+
import { levelsFor, memberHome } from "../access.js";
|
|
22
22
|
import { discover, exchangeCode, identity, logoutUrl, ShopifyError, verifyIdToken, type Discovery } from "../shopify.js";
|
|
23
23
|
import { emptyMember, stampTags, stores } from "../storage.js";
|
|
24
24
|
import { adminConfigured, resync } from "../admin-api.js";
|
|
@@ -149,7 +149,10 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
|
|
|
149
149
|
if (sealed) session.set(SESSION_CUSTOMER_TOKEN, sealed);
|
|
150
150
|
else session.delete?.(SESSION_CUSTOMER_TOKEN);
|
|
151
151
|
} else session.delete?.(SESSION_CUSTOMER_TOKEN);
|
|
152
|
-
|
|
152
|
+
if (flow.refresh) return fail(redirect, returnTo, "refreshed");
|
|
153
|
+
// From the home page or a plain sign-in link: the member's home for their level.
|
|
154
|
+
const home = returnTo === "/" && member.status !== "disabled" ? memberHome(who.tags, config.levels) : null;
|
|
155
|
+
return redirect(home ?? returnTo);
|
|
153
156
|
} catch (e) {
|
|
154
157
|
const code = e instanceof ShopifyError ? e.code : e instanceof OAuthError ? "signup_not_allowed" : "sign_in_failed";
|
|
155
158
|
console.error("[redeemid] sign-in failed:", (e as Error).message);
|
package/src/routes/webhook.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { stores, type Stores } from "../storage.js";
|
|
|
16
16
|
import { adminConfigured, resync } from "../admin-api.js";
|
|
17
17
|
import type { RedeemIDConfig } from "../config.js";
|
|
18
18
|
import { record } from "../log.js";
|
|
19
|
-
import { apply, customerOf, kindOf, verifyHmac, type Outcome } from "../webhooks.js";
|
|
19
|
+
import { apply, customerOf, kindOf, mergeIds, verifyHmac, type Outcome } from "../webhooks.js";
|
|
20
20
|
|
|
21
21
|
export const prerender = false;
|
|
22
22
|
|
|
@@ -81,6 +81,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|
|
81
81
|
try {
|
|
82
82
|
const config = await siteConfig(locals);
|
|
83
83
|
const outcome = (await currentTags(config, s, topic, customerId)) ?? (await apply(s, topic, payload, (before, after) => levelChange(config, before, after, topic), config));
|
|
84
|
+
if (outcome === "processed" && kindOf(topic) === "merge") await mergedTags(config, s, topic, payload);
|
|
84
85
|
if (delivery) await s.deliveries.put(delivery, { topic, receivedAt, outcome, customerId }).catch(() => undefined);
|
|
85
86
|
await record({ event: "webhook.received", label: topic, status: outcome, origin: "webhook", customerId, ms: Date.now() - started });
|
|
86
87
|
return new Response(outcome, { status: 200 });
|
|
@@ -112,3 +113,22 @@ async function currentTags(config: RedeemIDConfig | null, s: Stores, topic: stri
|
|
|
112
113
|
return null;
|
|
113
114
|
}
|
|
114
115
|
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* customers/merge moves the sign-in link to the customer kept, but the payload
|
|
119
|
+
* has no tags. With the Admin API app set up, read the survivor's tags now
|
|
120
|
+
* rather than wait for its next webhook or sign-in. A failed read leaves the
|
|
121
|
+
* merge as it is.
|
|
122
|
+
*/
|
|
123
|
+
async function mergedTags(config: RedeemIDConfig | null, s: Stores, topic: string, payload: Record<string, unknown>): Promise<void> {
|
|
124
|
+
const { kept } = mergeIds(payload);
|
|
125
|
+
if (!config || !kept || !adminConfigured()) return;
|
|
126
|
+
try {
|
|
127
|
+
const before = await s.members.get(kept);
|
|
128
|
+
if (!before) return;
|
|
129
|
+
const { updated } = await resync(config, s, [kept], topic);
|
|
130
|
+
if (updated[0]) await levelChange(config, before.tags, updated[0], topic);
|
|
131
|
+
} catch (e) {
|
|
132
|
+
console.error("[redeemid] merge resync failed:", (e as Error).message);
|
|
133
|
+
}
|
|
134
|
+
}
|