@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/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, tags: l.tags, match: l.match ?? "any", wall: l.wall ?? null, joinUrl: l.joinUrl ?? null }));
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> {
@@ -3,4 +3,4 @@
3
3
  * when something is added, the major part when something stable changes or
4
4
  * goes. Separate from the package version.
5
5
  */
6
- export const API_VERSION = "1.2.0";
6
+ export const API_VERSION = "1.3.0";
@@ -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, the
43
- * members part, and the levels picked on the break (empty = none picked).
44
- * With no break, everything is in `open` and `paywall` is false.
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: [], levels: [], paywall: false };
55
- const raw = (blocks[at] as Node).levels;
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 (and from a paywall break
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, splitAtPaywall } from "../blocks-names.js";
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, so a paywall break's levels count. */
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 split = splitAtPaywall(content);
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">🔒&#xFE0E; </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
- const names = (config?.levels ?? []).filter((l) => v.levels.includes(l.slug)).map((l) => l.name);
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
- {names.length > 0 && <span class="redeemid-account__levels">{names.join(", ")}</span>}
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 who may read it: the
9
- * levels picked on the break, or when none are picked the entry's rule
10
- * (with no rule, the break changes nothing).
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
- // A break with levels picked locks the rest to them; with none picked it
64
- // follows the entry's rule, and does nothing when no rule covers the entry.
65
- const required = split.paywall ? (split.levels.length ? [split.levels] : entryRule) : undefined;
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
- * With `levels`, members who hold one of them see "You're on this plan"
10
- * in place of the button, and a member who held one before (Shopify removed
11
- * its tag) sees "Renew" instead of "Join".
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. Members holding one see `heldLabel`, not the button. */
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, levels = [], heldLabel = text(config, "You're on this plan."), renewLabel = text(config, "Renew") } = Astro.props;
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 held = Boolean(v?.levels.some((l) => levels.includes(l)));
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 && v?.member && v.member.status !== "disabled" && membership(v.member, config?.levels).past.some((p) => levels.includes(p.slug)));
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={held ? "held" : lapsed ? "renew" : "join"}>
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
- /** Shopify customer tags that grant this level. Matched case-insensitively. */
19
- tags: string[];
20
- /** "any" (default): one of the tags is enough. "all": every tag is needed. */
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, splitAtPaywall } from "./blocks-names.js";
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 breaks and members-only parts: readers
83
- * must hold one from each. A members-only part with none picked needs a
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`): everything after it in the body is
15
- * for members. Pick the levels that may read on ("Signed in" for any
16
- * member); none picked means the entry's rule, and nothing is locked when
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: "Everything after this is for members",
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
- /** shop/redact: erase every member and meter record. */
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
- for (const r of page.items) {
92
- for (const m of await meterFor(s, r.data.userId)) await s.meter.delete(m.id);
93
- await s.members.delete(r.id);
94
- n++;
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
- for (const r of page.items) await s.meter.delete(r.id);
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;
@@ -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
- return flow.refresh ? fail(redirect, returnTo, "refreshed") : redirect(returnTo);
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);
@@ -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
+ }