@redeemid/emdash 0.0.21 → 0.0.23

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/files.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  * match case-insensitively. Access still comes only from Shopify tags.
17
17
  */
18
18
 
19
- import { readEntry, viewer, type AstroLike, type Terms } from "./access.js";
19
+ import { readEntry, SIGNED_IN, viewer, type AstroLike, type Terms } from "./access.js";
20
20
  import { holdsItem, ITEM_BLOCK, PAYWALL_BLOCK, sections, soldItem, splitAtPaywall } from "./blocks-names.js";
21
21
  import type { FileRule, RedeemIDConfig } from "./config.js";
22
22
  import { featuresFor } from "./features.js";
@@ -78,18 +78,22 @@ async function entriesUsing(db: unknown, mediaId: string): Promise<Array<{ colle
78
78
  .filter((e) => !seen.has(`${e.collection}:${e.id}`) && Boolean(seen.add(`${e.collection}:${e.id}`)));
79
79
  }
80
80
 
81
- /** The levels picked on an entry body's breaks and members-only parts: readers must hold one from each. */
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
84
+ * signed-in reader (on top of the entry's rule, which the caller checks).
85
+ */
82
86
  export function bodyClauses(body: unknown): string[][] {
83
87
  const blocks = Array.isArray(body) ? body : [];
84
88
  const out: string[][] = [];
85
89
  const split = splitAtPaywall(blocks);
86
90
  if (split.paywall && split.levels.length) out.push(split.levels);
87
- for (const s of sections(blocks)) if (s.members && s.levels.length) out.push(s.levels);
91
+ for (const s of sections(blocks)) if (s.members) out.push(s.levels.length ? s.levels : [SIGNED_IN]);
88
92
  return out;
89
93
  }
90
94
 
91
95
  /** May this reader read the whole entry: its rule, drip, and every part with levels picked? */
92
- async function readsWhole(astro: AstroLike, entry: { collection: string; id: string }): Promise<boolean> {
96
+ export async function readsWhole(astro: AstroLike, entry: { collection: string; id: string }): Promise<boolean> {
93
97
  const { getEmDashEntry } = await import("emdash");
94
98
  const result = (await getEmDashEntry(entry.collection, entry.id)) as { entry?: { data?: Record<string, unknown> } | null } | null;
95
99
  const data = result?.entry?.data;
@@ -104,7 +108,7 @@ async function readsWhole(astro: AstroLike, entry: { collection: string; id: str
104
108
  const verdict = await readEntry(astro, { collection: entry.collection, terms, ruleId: entry.id });
105
109
  if (!verdict.allowed) return false;
106
110
  const clauses = bodyClauses(body);
107
- return clauses.every((clause) => clause.some((l) => verdict.viewer.levels.includes(l) || (l === "signed-in" && verdict.viewer.signedIn)));
111
+ return clauses.every((clause) => clause.some((l) => verdict.viewer.levels.includes(l) || (l === SIGNED_IN && verdict.viewer.signedIn)));
108
112
  }
109
113
 
110
114
  export function ruleFor(config: RedeemIDConfig | null, folder: string | null): FileRule | null {
package/src/guard.ts CHANGED
@@ -24,6 +24,7 @@ import { defineMiddleware } from "astro:middleware";
24
24
  import { signInUrl } from "./access.js";
25
25
  import { readCart } from "./cart.js";
26
26
  import { keysFromUrl, pathAccess, refusal } from "./files.js";
27
+ import { guardSearch, SEARCH_PATH, SUGGEST_PATH } from "./search-guard.js";
27
28
  import { AFTER_LOGOUT_COOKIE, readAfterLogout } from "./routes/shared.js";
28
29
  import { shareConfig, siteConfig } from "./site-config.js";
29
30
 
@@ -38,8 +39,12 @@ export const MEMBER_PATHS = [
38
39
  "/_emdash/api/plugins/",
39
40
  ];
40
41
 
42
+ /** Exact paths members may use: site search (results filtered by the guard) and its title suggestions. */
43
+ export const MEMBER_EXACT = [SEARCH_PATH, SUGGEST_PATH];
44
+
41
45
  export function memberMayUse(path: string, extra: string[] = []): boolean {
42
46
  if (!path.startsWith("/_emdash")) return true;
47
+ if (MEMBER_EXACT.includes(path)) return true;
43
48
  return [...MEMBER_PATHS, ...extra].some((p) => path === p.replace(/\/$/, "") || path.startsWith(p));
44
49
  }
45
50
 
@@ -123,6 +128,16 @@ export const onRequest = defineMiddleware(async (context, next) => {
123
128
  return own ? keepPrivate(context, await next()) : next();
124
129
  }
125
130
 
131
+ // Site search answers visitors and members only with what they may read.
132
+ if (url.pathname === SEARCH_PATH && config && (!user || (user.role ?? 0) <= SUBSCRIBER)) {
133
+ try {
134
+ return await guardSearch(context, url, await next());
135
+ } catch (e) {
136
+ console.error("[redeemid] search check failed:", (e as Error).message);
137
+ return new Response(JSON.stringify({ error: { code: "SEARCH_ERROR", message: "Search failed" } }), { status: 500, headers: { "content-type": "application/json", "cache-control": "private, no-store" } });
138
+ }
139
+ }
140
+
126
141
  if (!user || user.role !== SUBSCRIBER) return next();
127
142
 
128
143
  if (memberMayUse(url.pathname, config?.memberPaths)) return next();
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
@@ -28,17 +28,17 @@
28
28
  import type { PluginContext, ResolvedPlugin, RouteContext } from "emdash";
29
29
  import { definePlugin } from "emdash";
30
30
 
31
- import { levelsFor, SIGNED_IN, type Terms } from "./access.js";
31
+ import { levelsFor, SIGNED_IN, topLevels, type Terms } from "./access.js";
32
32
  import { accessAction, accessPage, entryPanel, entrySave, entrySlugChanged, isAccessAction, type AccessEnv, type PanelEntry } from "./access-admin.js";
33
33
  import { deleteAccess, entryKey, loadAccess, onLevels, withSaved } from "./settings.js";
34
- import { env, ENV, storeDomain, unknownEnvNames, type LevelConfig, type RedeemIDConfig } from "./config.js";
34
+ import { env, ENV, shopOrigin, storeDomain, unknownEnvNames, type LevelConfig, type RedeemIDConfig } from "./config.js";
35
35
  import { discover } from "./shopify.js";
36
36
  import { stores, type MemberRecord, type Stores } from "./storage.js";
37
37
  import { EDITOR_PANELS, PAGES, WIDGETS } from "./descriptor.js";
38
38
  import { VERSION } from "./version.js";
39
39
  import { AdminError, adminConfigured } from "./admin-api.js";
40
40
  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";
41
+ import { accessTest, bulkResync, BULK_BATCH, exportBlocks, memberDetail, privacyPage, registerWebhooks, resyncOne, staffLink, staffUnlink, tagEdit } from "./staff.js";
42
42
  import { textProblems } from "./text.js";
43
43
  import { gatedFindings, templateFindings } from "./template-scan.js";
44
44
  import { setupFacts, setupPage, WEBHOOK } from "./setup.js";
@@ -539,6 +539,20 @@ async function action(
539
539
  return m ? memberDetail(config, m, fail(e)) : membersPage(config);
540
540
  }
541
541
  }
542
+ case "member_link":
543
+ case "member_unlink": {
544
+ const id = i.action_id === "member_link" ? String(i.block_id ?? "").replace(/^link:/, "") : String(i.value ?? "");
545
+ try {
546
+ const { getDb } = await import("emdash/runtime");
547
+ const { createKyselyAdapter } = await import("@emdash-cms/auth/adapters/kysely");
548
+ const adapter = createKyselyAdapter((await getDb()) as never);
549
+ const m = i.action_id === "member_link" ? await staffLink(adapter, s, id, i.values?.email) : await staffUnlink(adapter, s, id);
550
+ return memberDetail(config, m, { type: "success", message: m.userId ? "Linked. Their next sign-in uses that site user." : "Unlinked." });
551
+ } catch (e) {
552
+ const m = await s.members.get(id);
553
+ return m ? memberDetail(config, m, fail(e)) : membersPage(config);
554
+ }
555
+ }
542
556
  case "member_tag_add":
543
557
  case "member_tag_remove": {
544
558
  const add = i.action_id === "member_tag_add";
@@ -675,7 +689,7 @@ async function membersPage(config: RedeemIDConfig) {
675
689
  }));
676
690
  const name = (m: MemberRecord) => [m.firstName, m.lastName].filter(Boolean).join(" ") || "No name";
677
691
  const levelNames = (m: MemberRecord) => {
678
- const held = levelsFor(m.tags, levels);
692
+ const held = topLevels(levelsFor(m.tags, levels), levels);
679
693
  return levels.filter((l) => held.includes(l.slug)).map((l) => l.name).join(", ");
680
694
  };
681
695
  return {
@@ -812,6 +826,7 @@ async function statusPage(config: RedeemIDConfig, request: Request) {
812
826
  },
813
827
  ]
814
828
  : []),
829
+ ...customDomainBlocks(config),
815
830
  ...templateBlocks(config),
816
831
  { 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
832
  { type: "divider" },
@@ -831,6 +846,30 @@ async function statusPage(config: RedeemIDConfig, request: Request) {
831
846
  };
832
847
  }
833
848
 
849
+ /**
850
+ * Status: the Admin API (staff tools, webhook registration, tag reads) only
851
+ * answers on the store's myshopify.com domain, so a custom domain in `shop`
852
+ * breaks it while sign-in still works.
853
+ */
854
+ export function customDomainBlocks(config: RedeemIDConfig): unknown[] {
855
+ if (!adminConfigured() || config.allowInsecureShop) return [];
856
+ let host = "";
857
+ try {
858
+ host = new URL(shopOrigin(config)).hostname;
859
+ } catch {
860
+ return [];
861
+ }
862
+ if (host.endsWith(".myshopify.com")) return [];
863
+ return [
864
+ {
865
+ type: "banner",
866
+ variant: "alert",
867
+ title: "Use the myshopify.com domain",
868
+ 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.`,
869
+ },
870
+ ];
871
+ }
872
+
834
873
  /** Status: templates that would show a gated body in full (see template-scan.ts). */
835
874
  function templateBlocks(config: RedeemIDConfig): unknown[] {
836
875
  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;
package/src/routes/buy.ts CHANGED
@@ -30,7 +30,8 @@ export const POST: APIRoute = async ({ request, locals, session, redirect }) =>
30
30
  if (!/^[a-z0-9][a-z0-9-]*$/i.test(handle)) return fail(redirect, returnTo, "plan_unavailable");
31
31
 
32
32
  try {
33
- const p = await plan(config, handle);
33
+ const context = storeContext(config, request);
34
+ const p = await plan(config, handle, undefined, context);
34
35
  if (!p?.variantId || !p.availableForSale) return fail(redirect, returnTo, "plan_unavailable");
35
36
  // Provider routes are public, so read the member from the session.
36
37
  const user = await sessionUser(session, emdash?.db);
@@ -40,7 +41,7 @@ export const POST: APIRoute = async ({ request, locals, session, redirect }) =>
40
41
  config,
41
42
  current?.id ?? null,
42
43
  { variantId: p.variantId, sellingPlanId: p.sellingPlanId },
43
- { email: member?.email ?? null, attributes: { _redeemid_page: returnTo }, buyerIp: request.headers.get("cf-connecting-ip"), context: storeContext(config, request) },
44
+ { email: member?.email ?? null, attributes: { _redeemid_page: returnTo }, buyerIp: request.headers.get("cf-connecting-ip"), context },
44
45
  );
45
46
  saveCart(session, { id: cart.id, count: cart.totalQuantity });
46
47
  return redirect(await checkoutFor(config, cart, { session, request, emdashConfig: emdash?.config }), 303);
@@ -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";
@@ -139,7 +139,8 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
139
139
  await emit(config, "member.signed_in", { customerId: who.customerId, levels, first: !member.linkedAt, refresh: Boolean(flow.refresh) });
140
140
  await levelChange(config, found ? found.tags : null, { customerId: who.customerId, tags: who.tags, status: member.status }, "sign-in");
141
141
 
142
- session.regenerate?.();
142
+ // A new session id for the signed-in session (it keeps the data so far).
143
+ await session.regenerate?.();
143
144
  session.set("user", { id: user.id });
144
145
  session.set(SESSION_ID_TOKEN, tokens.idToken);
145
146
  // Kept only for signed-in checkout, and sealed (see token-vault.ts).
@@ -148,7 +149,10 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
148
149
  if (sealed) session.set(SESSION_CUSTOMER_TOKEN, sealed);
149
150
  else session.delete?.(SESSION_CUSTOMER_TOKEN);
150
151
  } else session.delete?.(SESSION_CUSTOMER_TOKEN);
151
- 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);
152
156
  } catch (e) {
153
157
  const code = e instanceof ShopifyError ? e.code : e instanceof OAuthError ? "signup_not_allowed" : "sign_in_failed";
154
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
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * EmDash's site search (/_emdash/api/search) is public. It matches the
3
+ * collection's searchable fields, entry bodies included, and answers with a
4
+ * snippet of the text around the match. Without this, anyone could search for
5
+ * a word from a locked entry and read the text around it, or learn which
6
+ * locked entries contain it.
7
+ *
8
+ * For visitors and members (never staff), each result is checked like a
9
+ * protected file's entry: the reader must be able to read the whole entry
10
+ * (its rule, drip, and every part with levels picked). A result they can't
11
+ * read keeps its title, which lists show anyway, but loses its snippet, and
12
+ * it stays in the results only when its title matches every searched word.
13
+ */
14
+
15
+ import type { AstroLike } from "./access.js";
16
+
17
+ /** Loaded when used: the access checks read the site's build-time config. */
18
+ async function readsWhole(astro: AstroLike, entry: { collection: string; id: string }): Promise<boolean> {
19
+ return (await import("./files.js")).readsWhole(astro, entry);
20
+ }
21
+
22
+ export const SEARCH_PATH = "/_emdash/api/search";
23
+ /** Title suggestions only: nothing from bodies. */
24
+ export const SUGGEST_PATH = "/_emdash/api/search/suggest";
25
+ const SUBSCRIBER = 10;
26
+
27
+ interface Result {
28
+ collection?: unknown;
29
+ id?: unknown;
30
+ title?: unknown;
31
+ snippet?: unknown;
32
+ }
33
+
34
+ /** The searched words, lowercased. */
35
+ export function words(q: string): string[] {
36
+ return q.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
37
+ }
38
+
39
+ /** Does the title hold every searched word (as a word or the start of one)? */
40
+ export function titleMatches(title: unknown, q: string): boolean {
41
+ if (typeof title !== "string") return false;
42
+ const have = words(title);
43
+ const want = words(q);
44
+ return want.length > 0 && want.every((w) => have.some((h) => h.startsWith(w)));
45
+ }
46
+
47
+ /**
48
+ * Filter one search answer for this reader. `readable` decides one result;
49
+ * a failed check counts as unreadable.
50
+ */
51
+ export async function filterResults(
52
+ items: Result[],
53
+ q: string,
54
+ readable: (collection: string, id: string) => Promise<boolean>,
55
+ ): Promise<Result[]> {
56
+ const out: Result[] = [];
57
+ for (const item of items) {
58
+ const collection = typeof item.collection === "string" ? item.collection : "";
59
+ const id = typeof item.id === "string" ? item.id : "";
60
+ const ok = collection && id ? await readable(collection, id).catch(() => false) : false;
61
+ if (ok) {
62
+ out.push(item);
63
+ continue;
64
+ }
65
+ if (!titleMatches(item.title, q)) continue;
66
+ const { snippet: _gone, ...rest } = item;
67
+ out.push(rest);
68
+ }
69
+ return out;
70
+ }
71
+
72
+ /**
73
+ * For a site's own search page that calls EmDash's `search()` in its
74
+ * template: the results this reader may see, filtered like the API's.
75
+ *
76
+ * const results = await readableResults(Astro, q, (await search(q)).items);
77
+ */
78
+ export async function readableResults<T extends Result>(astro: AstroLike, q: string, items: T[]): Promise<T[]> {
79
+ const role = (astro.locals as { user?: { role?: number } | null }).user?.role ?? 0;
80
+ if (role > SUBSCRIBER) return items;
81
+ return (await filterResults(items, q, (collection, id) => readsWhole(astro, { collection, id }))) as T[];
82
+ }
83
+
84
+ /** The search answer with locked text taken out (see above). Other answers pass as they are. */
85
+ export async function guardSearch(astro: AstroLike, url: URL, res: Response): Promise<Response> {
86
+ if (!res.ok || !(res.headers.get("content-type") ?? "").includes("json")) return res;
87
+ let body: { data?: { items?: Result[] } };
88
+ try {
89
+ body = (await res.clone().json()) as typeof body;
90
+ } catch {
91
+ return res;
92
+ }
93
+ const items = body.data?.items;
94
+ if (!Array.isArray(items)) return res;
95
+ const q = url.searchParams.get("q") ?? "";
96
+ const filtered = await filterResults(items, q, (collection, id) => readsWhole(astro, { collection, id }));
97
+ const out = new Response(JSON.stringify({ ...body, data: { ...body.data, items: filtered } }), { status: res.status, headers: res.headers });
98
+ out.headers.delete("content-length");
99
+ return out;
100
+ }
package/src/setup.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  */
18
18
 
19
19
  import { adminConfigured, adminScopes, webhookSubscriptions, WEBHOOK_TOPICS } from "./admin-api.js";
20
- import { env, ENV, storeDomain, type RedeemIDConfig } from "./config.js";
20
+ import { env, ENV, levelTags, storeDomain, type RedeemIDConfig } from "./config.js";
21
21
  import { discover } from "./shopify.js";
22
22
  import type { Stores } from "./storage.js";
23
23
 
@@ -100,7 +100,7 @@ export async function setupFacts(config: RedeemIDConfig, s: Stores, origin: stri
100
100
  admin,
101
101
  scopes,
102
102
  webhooksMissing,
103
- levels: (config.levels ?? []).map((l) => ({ name: l.name, tags: l.tags })),
103
+ levels: (config.levels ?? []).map((l) => ({ name: l.name, tags: levelTags(l) })),
104
104
  signedIn,
105
105
  storefront: Boolean(env(ENV.storefrontPrivateToken) || env(ENV.storefrontPublicToken)),
106
106
  };
package/src/staff.ts CHANGED
@@ -6,12 +6,12 @@
6
6
 
7
7
  import { adminConfigured, adminScopes, editTags, registerWebhooks, resync, webhookSubscriptions, WEBHOOK_TOPICS } from "./admin-api.js";
8
8
  import { applyEntryRule, describe, entryRuleFor, levelsFor, membership, passes, requirement, termTree, type Terms } from "./access.js";
9
- import type { RedeemIDConfig } from "./config.js";
9
+ import { PROVIDER_ID, type RedeemIDConfig } from "./config.js";
10
10
  import { levelChange } from "./events.js";
11
11
  import { featuresFor } from "./features.js";
12
12
  import { record } from "./log.js";
13
13
  import { recentRequests } from "./privacy.js";
14
- import { cleanTags, numericId, type MemberRecord, type Stores } from "./storage.js";
14
+ import { cleanTags, memberForUser, numericId, type MemberRecord, type Stores } from "./storage.js";
15
15
 
16
16
  /** Members resynced per click; well inside Shopify's query cost limits. */
17
17
  /**
@@ -100,11 +100,98 @@ export function memberDetail(config: RedeemIDConfig, m: MemberRecord, toast?: Bl
100
100
  ? { type: "actions", elements: [{ type: "button", action_id: "member_resync", label: "Resync from Shopify", style: "primary", value: m.customerId }] }
101
101
  : { type: "divider" },
102
102
  { type: "context", text: "Tags are written to Shopify, then read back. Shopify stays the only source of access." },
103
+ ...linkSection(m),
103
104
  ...privacySection(m.customerId),
104
105
  ],
105
106
  };
106
107
  }
107
108
 
109
+ /** Linking the customer to a site user whose email differs, on their detail page. */
110
+ function linkSection(m: MemberRecord): unknown[] {
111
+ return [
112
+ { type: "header", text: "Site user" },
113
+ {
114
+ type: "context",
115
+ text: m.userId
116
+ ? "This customer signs in as the site user linked below. Unlink to let their next sign-in pick the user by email again."
117
+ : "Signing in links the customer to the site user with the same verified email. When their emails differ, link them here: their next sign-in uses that user.",
118
+ },
119
+ m.userId
120
+ ? {
121
+ type: "actions",
122
+ elements: [
123
+ {
124
+ type: "button",
125
+ action_id: "member_unlink",
126
+ label: "Unlink site user",
127
+ style: "danger",
128
+ value: m.customerId,
129
+ confirm: { title: "Unlink this site user?", text: "Their levels stop applying to that user until a sign-in links a user again. Nothing changes in Shopify.", confirm: "Unlink", deny: "Cancel", style: "danger" },
130
+ },
131
+ ],
132
+ }
133
+ : {
134
+ type: "form",
135
+ block_id: `link:${m.customerId}`,
136
+ fields: [{ type: "text_input", action_id: "email", label: "Site user's email", placeholder: "reader@example.com" }],
137
+ submit: { label: "Link", action_id: "member_link" },
138
+ },
139
+ ];
140
+ }
141
+
142
+ /** The parts of EmDash's auth adapter that linking uses. */
143
+ export interface LinkAdapter {
144
+ getUserByEmail(email: string): Promise<{ id: string; role: number; disabled: boolean } | null>;
145
+ getOAuthAccount(provider: string, providerAccountId: string): Promise<{ userId: string } | null>;
146
+ getOAuthAccountsByUserId(userId: string): Promise<Array<{ provider: string; providerAccountId: string }>>;
147
+ createOAuthAccount(account: { provider: string; providerAccountId: string; userId: string }): Promise<unknown>;
148
+ deleteOAuthAccount(provider: string, providerAccountId: string): Promise<void>;
149
+ }
150
+
151
+ /** EmDash's Subscriber role: anything above it is staff. */
152
+ const SUBSCRIBER = 10;
153
+
154
+ export class LinkError extends Error {}
155
+
156
+ /**
157
+ * Link a customer to an existing site user by the user's email, for a member
158
+ * whose store email differs. Only Subscribers, never staff, and never a user
159
+ * already linked to another customer. Access still comes only from the
160
+ * customer's Shopify tags.
161
+ */
162
+ export async function staffLink(adapter: LinkAdapter, s: Stores, customerId: string, rawEmail: unknown): Promise<MemberRecord> {
163
+ const m = await s.members.get(customerId);
164
+ if (!m) throw new LinkError("That member is gone.");
165
+ if (m.userId) throw new LinkError("This customer is already linked. Unlink first.");
166
+ const email = String(rawEmail ?? "").trim().toLowerCase();
167
+ if (!email.includes("@")) throw new LinkError("Give the site user's email.");
168
+ const user = await adapter.getUserByEmail(email);
169
+ if (!user) throw new LinkError("No site user has that email.");
170
+ if (user.role > SUBSCRIBER) throw new LinkError("Staff accounts can't be linked to a store customer.");
171
+ if (user.disabled) throw new LinkError("That site user is turned off.");
172
+ const theirs = (await adapter.getOAuthAccountsByUserId(user.id)).find((a) => a.provider === PROVIDER_ID && a.providerAccountId !== customerId);
173
+ const other = theirs ? null : await memberForUser(s, user.id);
174
+ if (theirs || (other && other.customerId !== customerId)) throw new LinkError(`That site user is linked to customer ${theirs?.providerAccountId ?? other?.customerId}.`);
175
+ const held = await adapter.getOAuthAccount(PROVIDER_ID, customerId);
176
+ if (held && held.userId !== user.id) await adapter.deleteOAuthAccount(PROVIDER_ID, customerId);
177
+ if (!held || held.userId !== user.id) await adapter.createOAuthAccount({ provider: PROVIDER_ID, providerAccountId: customerId, userId: user.id });
178
+ const linked = { ...m, userId: user.id, linkedAt: m.linkedAt ?? new Date().toISOString() };
179
+ await s.members.put(customerId, linked);
180
+ await record({ event: "staff.link", label: "linked", status: "ok", origin: "staff", customerId });
181
+ return linked;
182
+ }
183
+
184
+ /** Undo a link: the customer's next sign-in picks a user by email again. */
185
+ export async function staffUnlink(adapter: LinkAdapter, s: Stores, customerId: string): Promise<MemberRecord> {
186
+ const m = await s.members.get(customerId);
187
+ if (!m) throw new LinkError("That member is gone.");
188
+ if (await adapter.getOAuthAccount(PROVIDER_ID, customerId)) await adapter.deleteOAuthAccount(PROVIDER_ID, customerId);
189
+ const unlinked = { ...m, userId: null };
190
+ await s.members.put(customerId, unlinked);
191
+ await record({ event: "staff.link", label: "unlinked", status: "ok", origin: "staff", customerId });
192
+ return unlinked;
193
+ }
194
+
108
195
  /** Export and erase for one member, on their detail page. */
109
196
  function privacySection(customerId: string): unknown[] {
110
197
  const erase = {
package/src/storefront.ts CHANGED
@@ -128,13 +128,17 @@ interface PlanData {
128
128
  } | null;
129
129
  }
130
130
 
131
- /** A product by handle, cached for five minutes. Null when Shopify has no such product. */
132
- export async function plan(config: RedeemIDConfig, handle: string, fetcher?: typeof fetch): Promise<Plan | null> {
133
- const key = `${shopOrigin(config)}|${handle}`;
131
+ /**
132
+ * A product by handle, cached for five minutes. Null when Shopify has no such
133
+ * product. Pass `context` (storeContext) so the price is the visitor's
134
+ * market's, the one checkout will charge.
135
+ */
136
+ export async function plan(config: RedeemIDConfig, handle: string, fetcher?: typeof fetch, context?: StoreContext): Promise<Plan | null> {
137
+ const key = `${shopOrigin(config)}|${handle}|${contextKey(context)}`;
134
138
  const hit = planCache.get(key);
135
139
  if (hit && Date.now() - hit.at < PLAN_TTL_MS) return hit.value;
136
140
 
137
- const { product: p } = await storefront<PlanData>(config, PLAN_QUERY, { handle }, { fetcher });
141
+ const { product: p } = await storefront<PlanData>(config, PLAN_QUERY, { handle }, { fetcher, context });
138
142
  const variant = p?.variants.nodes[0] ?? null;
139
143
  const value: Plan | null = p
140
144
  ? {
package/src/text.ts CHANGED
@@ -118,7 +118,7 @@ export const STRINGS = {
118
118
  "I understand",
119
119
  "Delete my data",
120
120
  ],
121
- plan: ["Join", "Renew", "You're on this plan.", "Not available right now."],
121
+ plan: ["Join", "Renew", "Upgrade", "You're on this plan.", "Included in your {level} membership.", "Not available right now."],
122
122
  preferences: ["Your preferences", "Save preferences", "Your preferences can't be loaded right now. Try again in a few minutes.", "Not set", "Yes", "No", "One per line."],
123
123
  product: [
124
124
  "Add to cart",
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.0.21";
1
+ export const VERSION = "0.0.23";
package/src/webhooks.ts CHANGED
@@ -10,6 +10,12 @@ import { cleanTags, emptyMember, numericId, stampTags, type MemberRecord, type S
10
10
  import type { RedeemIDConfig } from "./config.js";
11
11
  import { eraseAll, eraseMember, shopifyDataRequest } from "./privacy.js";
12
12
 
13
+ /** customers/merge: the customer kept and the one merged away, or nulls for a failed merge. */
14
+ export function mergeIds(payload: Record<string, unknown>): { kept: string | null; gone: string | null } {
15
+ if (payload.status === "failed") return { kept: null, gone: null };
16
+ return { kept: numericId(payload.admin_graphql_api_customer_kept_id), gone: numericId(payload.admin_graphql_api_customer_deleted_id) };
17
+ }
18
+
13
19
  export type Kind = "profile" | "delete" | "status" | "tags" | "merge" | "compliance" | "ignored";
14
20
 
15
21
  const TOPICS: Record<string, Kind> = {
@@ -124,9 +130,7 @@ export async function apply(
124
130
  }
125
131
 
126
132
  if (kind === "merge") {
127
- if (payload.status === "failed") return "skipped";
128
- const kept = numericId(payload.admin_graphql_api_customer_kept_id);
129
- const gone = numericId(payload.admin_graphql_api_customer_deleted_id);
133
+ const { kept, gone } = mergeIds(payload);
130
134
  if (!kept || !gone) return "skipped";
131
135
  const old = await s.members.get(gone);
132
136
  if (old?.userId) {
@@ -144,7 +148,10 @@ export async function apply(
144
148
  if (!id) return "skipped";
145
149
 
146
150
  if (kind === "delete") {
151
+ const gone = await s.members.get(id);
147
152
  await s.members.delete(id);
153
+ // The levels end with the customer: receivers of member.levels_changed hear it.
154
+ if (gone) await onWrite?.([...gone.tags], { ...gone, tags: [] });
148
155
  return "processed";
149
156
  }
150
157