@redeemid/emdash 0.0.10 → 0.0.11

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.
@@ -9,6 +9,8 @@
9
9
  * Anything in the default slot is shown above the built-in text.
10
10
  */
11
11
  import { passes, signInUrl, type Reason } from "../access.js";
12
+ import type { SoldItem } from "../blocks-names.js";
13
+ import { plan as loadPlan } from "../storefront.js";
12
14
  import { siteConfig } from "../site-config.js";
13
15
  import { text, textParts } from "../text.js";
14
16
 
@@ -23,9 +25,11 @@ interface Props {
23
25
  /** Free reads left when a meter ran (Verdict.meterLeft); 0 shows the meter-used text. */
24
26
  meterLeft?: number;
25
27
  meterPeriod?: "month" | "week";
28
+ /** The entry's "Sold on its own" block: offers "Buy just this". */
29
+ item?: SoldItem | null;
26
30
  }
27
31
 
28
- const { reason, required = [], held = [], opensAt, meterLeft, meterPeriod } = Astro.props;
32
+ const { reason, required = [], held = [], opensAt, meterLeft, meterPeriod, item } = Astro.props;
29
33
  const opens = opensAt
30
34
  ? new Intl.DateTimeFormat("en-US", { dateStyle: "long", timeZone: "UTC" }).format(opensAt)
31
35
  : null;
@@ -35,6 +39,18 @@ const level = missing ? config?.levels?.find((l) => l.slug === missing[0]) : und
35
39
  const joinHref = level?.joinUrl ?? config?.join?.href;
36
40
  const joinLabel = config?.join?.label ?? text(config, "Become a member");
37
41
  const week = meterPeriod === "week";
42
+ // "Buy just this": the item's product and price, read from Shopify.
43
+ let single: { handle: string; price: string } | null = null;
44
+ if (item?.product && config && (reason === "sign_in" || reason === "upgrade" || reason === "drip")) {
45
+ try {
46
+ const p = await loadPlan(config, item.product);
47
+ if (p?.availableForSale && p.price) {
48
+ single = { handle: p.handle, price: new Intl.NumberFormat("en-US", { style: "currency", currency: p.price.currencyCode }).format(Number(p.price.amount)) };
49
+ }
50
+ } catch {
51
+ single = null;
52
+ }
53
+ }
38
54
  const here = Astro.url.pathname + Astro.url.search;
39
55
  ---
40
56
 
@@ -61,6 +77,14 @@ const here = Astro.url.pathname + Astro.url.search;
61
77
  {joinHref && <> <a href={joinHref}>{joinLabel}</a></>}
62
78
  </p>
63
79
  )}
80
+ {single && (
81
+ <form class="redeemid-locked__single" method="post" action="/_emdash/api/auth/redeemid/buy" data-redeemid-item={item?.tag}>
82
+ <input type="hidden" name="plan" value={single.handle} />
83
+ <input type="hidden" name="return" value={here} />
84
+ <p>{text(config, "Or buy just this for {price}.", { price: single.price })}</p>
85
+ <button type="submit">{text(config, "Buy just this")}</button>
86
+ </form>
87
+ )}
64
88
  {reason === "disabled" && <p>{text(config, "Your account is turned off. Contact the store for help.")}</p>}
65
89
  {reason === "unconfigured" && <p>{text(config, "This is for members.")}</p>}
66
90
  </div>
@@ -32,6 +32,8 @@ const DEFAULTS: Record<string, string> = {
32
32
  preferences_saved: "Your preferences are saved.",
33
33
  preferences_refused: "Some answers weren't saved. Check them and try again.",
34
34
  preferences_unavailable: "Your preferences can't be saved right now. Try again in a few minutes.",
35
+ data_erased: "Your data on this site is deleted, and you're signed out.",
36
+ erase_unconfirmed: "Tick the box to confirm, then try again.",
35
37
  account_unavailable: "Your account page isn't available right now. Try again in a few minutes.",
36
38
  };
37
39
 
@@ -0,0 +1,42 @@
1
+ ---
2
+ /**
3
+ * The member's own data, for an account page: "Download my data" (everything
4
+ * this site keeps about them, as JSON) and "Delete my data", which erases it
5
+ * after they tick a box and signs them out. Their store account and orders
6
+ * stay; the text says so. Visitors get nothing.
7
+ *
8
+ * <YourData />
9
+ */
10
+ import { viewer } from "../access.js";
11
+ import { siteConfig } from "../site-config.js";
12
+ import { text } from "../text.js";
13
+
14
+ interface Props {
15
+ title?: string;
16
+ heading?: "h2" | "h3" | "h4";
17
+ }
18
+
19
+ const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
20
+ const { title = text(config, "Your data"), heading = "h2" } = Astro.props;
21
+ const v = await viewer(Astro);
22
+ const route = "/_emdash/api/auth/redeemid/privacy";
23
+ const here = Astro.url.pathname;
24
+ const H = heading;
25
+ ---
26
+
27
+ {v.signedIn && v.member && (
28
+ <section class="redeemid-data">
29
+ <H class="redeemid-data__title">{title}</H>
30
+ <p>
31
+ <a href={`${route}?return=${encodeURIComponent(here)}`} download>{text(config, "Download my data")}</a>
32
+ </p>
33
+ <form method="post" action={route} class="redeemid-data__erase">
34
+ <input type="hidden" name="return" value={here} />
35
+ <p>{text(config, "Deleting removes what this site keeps about you and signs you out. Your store account and orders stay; ask the store to delete those.")}</p>
36
+ <label>
37
+ <input type="checkbox" name="confirm" value="yes" required /> {text(config, "I understand")}
38
+ </label>
39
+ <button type="submit">{text(config, "Delete my data")}</button>
40
+ </form>
41
+ </section>
42
+ )}
package/src/config.ts CHANGED
@@ -94,7 +94,7 @@ export interface PreferenceConfig {
94
94
  export interface EventsConfig {
95
95
  url: string;
96
96
  /** Send only these events. All of them when left out. */
97
- only?: Array<"member.signed_in" | "member.levels_changed" | "meter.used" | "meter.exhausted" | "preferences.saved">;
97
+ only?: Array<"member.signed_in" | "member.levels_changed" | "meter.used" | "meter.exhausted" | "preferences.saved" | "member.erased">;
98
98
  }
99
99
 
100
100
  export interface MeterConfig {
@@ -125,6 +125,11 @@ export interface RedeemIDConfig {
125
125
  * Customers). Needs the Admin API app (staff tools) for reading and saving.
126
126
  */
127
127
  preferences?: PreferenceConfig[];
128
+ /**
129
+ * AI agents (EmDash's MCP server) and the command line. Read-only tools are
130
+ * always declared; `actions: true` also offers resync and tag edits.
131
+ */
132
+ agents?: { actions?: boolean };
128
133
  /** Protected files, by media folder. See FileRule. */
129
134
  files?: FileRule[];
130
135
  /**
package/src/descriptor.ts CHANGED
@@ -12,6 +12,7 @@ export const PAGES = [
12
12
  { path: "/", label: "Members", icon: "users" },
13
13
  { path: "/status", label: "Status", icon: "pulse" },
14
14
  { path: "/test", label: "Access test", icon: "search" },
15
+ { path: "/privacy", label: "Privacy", icon: "lock" },
15
16
  ];
16
17
  export const WIDGETS = [{ id: "status", title: "RedeemID", size: "half" as const }];
17
18
 
package/src/events.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  import { env, ENV, type RedeemIDConfig } from "./config.js";
16
16
  import { USER_AGENT } from "./shopify.js";
17
17
 
18
- export type EventName = "member.signed_in" | "member.levels_changed" | "meter.used" | "meter.exhausted" | "preferences.saved";
18
+ export type EventName = "member.signed_in" | "member.levels_changed" | "meter.used" | "meter.exhausted" | "preferences.saved" | "member.erased";
19
19
 
20
20
  const TIMEOUT_MS = 3000;
21
21
 
package/src/files.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  */
18
18
 
19
19
  import { readEntry, viewer, type AstroLike, type Terms } from "./access.js";
20
- import { PAYWALL_BLOCK, sections, splitAtPaywall } from "./blocks-names.js";
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";
23
23
  import { text } from "./text.js";
@@ -79,7 +79,7 @@ async function entriesUsing(db: unknown, mediaId: string): Promise<Array<{ colle
79
79
  }
80
80
 
81
81
  /** The levels picked on an entry body's breaks and members-only parts: readers must hold one from each. */
82
- function bodyClauses(body: unknown): string[][] {
82
+ export function bodyClauses(body: unknown): string[][] {
83
83
  const blocks = Array.isArray(body) ? body : [];
84
84
  const out: string[][] = [];
85
85
  const split = splitAtPaywall(blocks);
@@ -95,9 +95,14 @@ async function readsWhole(astro: AstroLike, entry: { collection: string; id: str
95
95
  const data = result?.entry?.data;
96
96
  if (!data) return false;
97
97
  const terms = data.terms as Terms | undefined;
98
+ const body = Object.values(data).find((v) => Array.isArray(v) && v.some((b) => [PAYWALL_BLOCK, "redeemid-members", ITEM_BLOCK].includes((b as { _type?: string })?._type ?? "")));
99
+ const item = soldItem(body);
100
+ if (item) {
101
+ const m = (await viewer(astro)).member;
102
+ if (m && m.status !== "disabled" && holdsItem(item, m.tags)) return true;
103
+ }
98
104
  const verdict = await readEntry(astro, { collection: entry.collection, terms });
99
105
  if (!verdict.allowed) return false;
100
- const body = Object.values(data).find((v) => Array.isArray(v) && v.some((b) => (b as { _type?: string })?._type === PAYWALL_BLOCK || (b as { _type?: string })?._type === "redeemid-members"));
101
106
  const clauses = bodyClauses(body);
102
107
  return clauses.every((clause) => clause.some((l) => verdict.viewer.levels.includes(l) || (l === "signed-in" && verdict.viewer.signedIn)));
103
108
  }
package/src/index.ts CHANGED
@@ -59,6 +59,7 @@ export function redeemid(config: RedeemIDConfig): AuthProviderDescriptor {
59
59
  { pattern: `${BASE}/cart-edit`, entrypoint: "@redeemid/emdash/routes/cart-edit.ts" },
60
60
  { pattern: `${BASE}/account`, entrypoint: "@redeemid/emdash/routes/account.ts" },
61
61
  { pattern: `${BASE}/preferences`, entrypoint: "@redeemid/emdash/routes/preferences.ts" },
62
+ { pattern: `${BASE}/privacy`, entrypoint: "@redeemid/emdash/routes/privacy.ts" },
62
63
  ],
63
64
  publicRoutes: [`${BASE}/`],
64
65
  storage: STORAGE_CONFIG,
package/src/plugin.ts CHANGED
@@ -30,16 +30,20 @@ import { definePlugin } from "emdash";
30
30
  import { describe, levelsFor, SIGNED_IN } from "./access.js";
31
31
  import { env, ENV, storeDomain, type RedeemIDConfig } from "./config.js";
32
32
  import { discover } from "./shopify.js";
33
- import { stores, type MemberRecord } from "./storage.js";
33
+ import { stores, type MemberRecord, type Stores } from "./storage.js";
34
34
  import { PAGES, WIDGETS } from "./descriptor.js";
35
35
  import { VERSION } from "./version.js";
36
- import { adminConfigured } from "./admin-api.js";
37
- import { accessTest, bulkResync, BULK_BATCH, deliveriesSection, memberDetail, registerWebhooks, resyncOne, tagEdit, webhooksSection } from "./staff.js";
36
+ import { AdminError, adminConfigured } from "./admin-api.js";
37
+ import { cancelShopifyErasure, closeRequest, eraseMember, exportMember, exportRequest, requestShopifyErasure } from "./privacy.js";
38
+ import { accessTest, bulkResync, BULK_BATCH, deliveriesSection, exportBlocks, memberDetail, privacyPage, registerWebhooks, resyncOne, tagEdit, webhooksSection } from "./staff.js";
38
39
  import { textProblems } from "./text.js";
40
+ import { z } from "astro/zod";
41
+ import { AgentError, explainAccess, getMember, getRules, levelChanges, listLevels, siteHealth, type ExplainInput } from "./agents.js";
39
42
 
40
43
  import {
41
44
  COLLECTION_BLOCK,
42
45
  CONTRIBUTION_BLOCK,
46
+ ITEM_BLOCK,
43
47
  MEMBERS_BLOCK,
44
48
  MEMBERS_END_BLOCK,
45
49
  PAYWALL_BLOCK,
@@ -48,7 +52,7 @@ import {
48
52
  PRODUCT_BLOCK,
49
53
  } from "./blocks-names.js";
50
54
 
51
- export { COLLECTION_BLOCK, CONTRIBUTION_BLOCK, MEMBERS_BLOCK, MEMBERS_END_BLOCK, PAYWALL_BLOCK, PLAN_BLOCK, PREFERENCES_BLOCK, PRODUCT_BLOCK };
55
+ export { COLLECTION_BLOCK, CONTRIBUTION_BLOCK, ITEM_BLOCK, MEMBERS_BLOCK, MEMBERS_END_BLOCK, PAYWALL_BLOCK, PLAN_BLOCK, PREFERENCES_BLOCK, PRODUCT_BLOCK };
52
56
 
53
57
  function blocks(config: RedeemIDConfig) {
54
58
  const levelOptions = [
@@ -91,6 +95,16 @@ function blocks(config: RedeemIDConfig) {
91
95
  description: "Ends the members-only part; everyone reads on from here",
92
96
  fields: [],
93
97
  },
98
+ {
99
+ type: ITEM_BLOCK,
100
+ label: "Sold on its own",
101
+ icon: "tag",
102
+ description: "Buyers of this entry read all of it, whatever its levels say",
103
+ fields: [
104
+ { type: "text_input" as const, action_id: "tag", label: "Shopify customer tag buyers get", placeholder: "item-big-story" },
105
+ { type: "text_input" as const, action_id: "product", label: "Shopify product that sells it (handle or link)", placeholder: "the-big-story" },
106
+ ],
107
+ },
94
108
  {
95
109
  type: PLAN_BLOCK,
96
110
  label: "Plan",
@@ -166,7 +180,9 @@ export function createPlugin(config: RedeemIDConfig = {}): ResolvedPlugin {
166
180
  widgets: WIDGETS,
167
181
  portableTextBlocks: blocks(config),
168
182
  },
183
+ mcp: agentTools(config),
169
184
  routes: {
185
+ ...agentRoutes(config),
170
186
  admin: {
171
187
  handler: async (ctx: RouteContext) => {
172
188
  const i = (ctx.input ?? {}) as { type?: string; page?: string; action_id?: string; value?: unknown; values?: Record<string, unknown>; block_id?: string };
@@ -176,6 +192,7 @@ export function createPlugin(config: RedeemIDConfig = {}): ResolvedPlugin {
176
192
  if (i.page === "/") return await membersPage(config);
177
193
  if (i.page === "/status") return await statusPage(config, ctx.request);
178
194
  if (i.page === "/test") return await accessTest(config);
195
+ if (i.page === "/privacy") return await privacyPage(await memberStores());
179
196
  if (i.page === "widget:status") return await statusWidget(config);
180
197
  } catch (e) {
181
198
  return { blocks: [{ type: "banner", variant: "error", title: "RedeemID couldn't load this page", description: (e as Error).message }] };
@@ -189,6 +206,85 @@ export function createPlugin(config: RedeemIDConfig = {}): ResolvedPlugin {
189
206
 
190
207
  export default createPlugin;
191
208
 
209
+ // ---------------------------------------------------------------- agents and CLI
210
+
211
+ const STAFF = "users:read" as const;
212
+
213
+ /** Staff-only JSON routes, also offered as MCP tools. */
214
+ function agentRoutes(config: RedeemIDConfig) {
215
+ const run = (fn: (s: Stores, input: Record<string, unknown>) => Promise<unknown> | unknown) => async (ctx: RouteContext) => {
216
+ try {
217
+ return { ok: true, result: await fn(await memberStores(), (ctx.input ?? {}) as Record<string, unknown>) };
218
+ } catch (e) {
219
+ if (e instanceof AgentError || e instanceof AdminError) return { ok: false, error: e.message };
220
+ throw e;
221
+ }
222
+ };
223
+ const route = (input: z.ZodType, fn: Parameters<typeof run>[0]) => ({ permission: STAFF, input, request: { body: "json" as const }, handler: run(fn) });
224
+ const read = {
225
+ "explain-access": route(EXPLAIN, (s, i) => explainAccess(config, s, i as unknown as ExplainInput)),
226
+ "list-levels": route(EMPTY, () => listLevels(config)),
227
+ "get-rules": route(EMPTY, () => getRules(config)),
228
+ "get-member": route(MEMBER, (s, i) => getMember(config, s, i as { customerId?: string; email?: string })),
229
+ "site-health": route(EMPTY, (s) => siteHealth(config, s)),
230
+ "level-changes": route(CHANGES, (s, i) => levelChanges(config, s, i as { since: string; level?: string })),
231
+ };
232
+ if (!config.agents?.actions) return read;
233
+ return {
234
+ ...read,
235
+ "resync-member": route(CUSTOMER, async (s, i) => {
236
+ const m = await resyncOne(config, s, String(i.customerId), "agent resync");
237
+ if (!m) throw new AgentError("Shopify no longer has this customer");
238
+ return getMember(config, s, { customerId: m.customerId });
239
+ }),
240
+ "resync-batch": route(EMPTY, (s) => bulkResync(config, s)),
241
+ "edit-member-tags": route(TAGS, async (s, i) => {
242
+ const id = String(i.customerId);
243
+ const add = (i.add as string[] | undefined) ?? [];
244
+ const remove = (i.remove as string[] | undefined) ?? [];
245
+ if (add.length) await tagEdit(config, s, id, "add", add);
246
+ if (remove.length) await tagEdit(config, s, id, "remove", remove);
247
+ return getMember(config, s, { customerId: id });
248
+ }),
249
+ };
250
+ }
251
+
252
+ const EMPTY = z.object({});
253
+ const EXPLAIN = z.object({
254
+ collection: z.string().min(1).describe("Collection slug, e.g. posts"),
255
+ id: z.string().optional().describe("An entry's id or slug; its terms and body are read from the site"),
256
+ terms: z.record(z.string(), z.array(z.string())).optional().describe('Terms by taxonomy when no id is given, e.g. {"category":["investigations"]}'),
257
+ tags: z.array(z.string()).optional().describe("The reader's Shopify customer tags"),
258
+ customerId: z.string().optional().describe("A real member's Shopify customer id, in place of tags"),
259
+ signedIn: z.boolean().optional().describe("false for a signed-out visitor (default true)"),
260
+ });
261
+ const MEMBER = z.object({ customerId: z.string().optional(), email: z.string().optional() });
262
+ const CUSTOMER = z.object({ customerId: z.string().min(1).describe("Shopify customer id") });
263
+ const CHANGES = z.object({ since: z.string().describe("A date, e.g. 2026-11-01"), level: z.string().optional().describe("Only this level slug") });
264
+ const TAGS = z.object({
265
+ customerId: z.string().min(1).describe("Shopify customer id"),
266
+ add: z.array(z.string()).optional().describe("Tags to add in Shopify"),
267
+ remove: z.array(z.string()).optional().describe("Tags to remove in Shopify"),
268
+ });
269
+
270
+ /** MCP tools for the routes above. Staff turn them on for RedeemID under Plugins. */
271
+ function agentTools(config: RedeemIDConfig) {
272
+ const tools: Record<string, { description: string; route: string; input: z.ZodType; destructive?: boolean }> = {
273
+ explain_access: { route: "explain-access", input: EXPLAIN, description: "Can this reader read this entry, and why? Give the entry by collection and id (or terms), and the reader by Shopify tags, a member's customer id, or signedIn:false. Answers what the entry needs, the reader's levels, the result, the level a locked reader is offered, and each members-only part." },
274
+ list_levels: { route: "list-levels", input: EMPTY, description: "Every membership level: slug, name, the Shopify tags that grant it (any or all), wall text and join link." },
275
+ get_rules: { route: "get-rules", input: EMPTY, description: "The paywall rules: how they combine, each rule (collection, term, levels, drip, open), the meter, teasers, features and protected-file rules." },
276
+ get_member: { route: "get-member", input: MEMBER, description: "One synced member by Shopify customer id or email: status, tags, current levels with start dates, past levels, features and last sync." },
277
+ site_health: { route: "site-health", input: EMPTY, description: "Whether each setting is in place (never the values), the last webhook, open privacy requests and ignored text replacements." },
278
+ level_changes: { route: "level-changes", input: CHANGES, description: "Who joined or left a level since a date, from each member's tag dates." },
279
+ };
280
+ if (config.agents?.actions) {
281
+ tools.resync_member = { route: "resync-member", input: CUSTOMER, description: "Read one member from Shopify now and update the site. Changes nothing in Shopify." };
282
+ tools.resync_batch = { route: "resync-batch", input: EMPTY, description: "Read the 50 members synced longest ago from Shopify. Run again for the next 50." };
283
+ tools.edit_member_tags = { route: "edit-member-tags", input: TAGS, destructive: true, description: "Add or remove tags on a member's Shopify customer, then read back what Shopify holds. Access follows from Shopify." };
284
+ }
285
+ return { tools };
286
+ }
287
+
192
288
  // ---------------------------------------------------------------- actions
193
289
 
194
290
  const webhookUri = (request: Request) => `${new URL(request.url).origin}/_emdash/api/auth/redeemid/webhook`;
@@ -254,6 +350,41 @@ async function action(
254
350
  }
255
351
  case "access_test":
256
352
  return accessTest(config, i.values);
353
+ case "member_export": {
354
+ const id = String(i.value ?? "");
355
+ const m = await s.members.get(id);
356
+ if (!m) return membersPage(config);
357
+ return exportBlocks(memberDetail(config, m, { type: "success", message: "Export below." }), await exportRequest(s, id, "staff"));
358
+ }
359
+ case "member_erase": {
360
+ const id = String(i.value ?? "");
361
+ await eraseMember(config, s, id, "staff");
362
+ return { ...(await membersPage(config)), toast: { type: "success", message: "Erased from the site." } };
363
+ }
364
+ case "member_shopify_erase":
365
+ case "member_shopify_cancel": {
366
+ const id = String(i.value ?? "");
367
+ const m = await s.members.get(id);
368
+ try {
369
+ if (i.action_id === "member_shopify_erase") await requestShopifyErasure(config, s, id);
370
+ else await cancelShopifyErasure(config, s, id);
371
+ const message = i.action_id === "member_shopify_erase" ? "Shopify will erase the customer after its waiting period." : "The Shopify erasure is cancelled.";
372
+ return m ? memberDetail(config, m, { type: "success", message }) : privacyPage(s, { type: "success", message });
373
+ } catch (e) {
374
+ return m ? memberDetail(config, m, fail(e)) : privacyPage(s, fail(e));
375
+ }
376
+ }
377
+ case "privacy_done": {
378
+ const r = await closeRequest(s, String(i.value ?? ""));
379
+ if (r?.kind === "export" && r.customerId) {
380
+ const data = await exportMember(s, r.customerId);
381
+ return privacyPage(s, { type: "success", message: "Marked done. Send the export below to the store owner." }, [
382
+ { type: "header", text: `Export for customer ${r.customerId}` },
383
+ { type: "code", language: "jsonc", code: JSON.stringify(data, null, 2) },
384
+ ]);
385
+ }
386
+ return privacyPage(s, { type: "success", message: "Marked done." });
387
+ }
257
388
  }
258
389
  return { blocks: [] };
259
390
  }
package/src/privacy.ts ADDED
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Privacy requests: every export and erase of the member data RedeemID keeps,
3
+ * whether the member asked on the site, staff started it, or Shopify sent it.
4
+ *
5
+ * What RedeemID keeps about a member: the mirror record (customer id, email,
6
+ * name, tags and their dates, status, when they first signed in and last
7
+ * synced, preference save times) and their meter records. Preference answers
8
+ * live on the Shopify customer, and webhook deliveries keep only a topic and
9
+ * a time. Shopify stays the record of the customer: erasing here never
10
+ * erases Shopify data, though staff can ask Shopify to (requestShopifyErasure).
11
+ */
12
+
13
+ import { admin, AdminError } from "./admin-api.js";
14
+ import type { RedeemIDConfig } from "./config.js";
15
+ import { emit } from "./events.js";
16
+ import type { MemberRecord, MeterRecord, PrivacyRequest, Stores } from "./storage.js";
17
+
18
+ export const EXPORT_VERSION = 1;
19
+
20
+ export interface MemberExport {
21
+ format: "redeemid-member-export";
22
+ version: number;
23
+ exportedAt: string;
24
+ member: MemberRecord | null;
25
+ meter: MeterRecord[];
26
+ elsewhere: string[];
27
+ }
28
+
29
+ async function meterFor(s: Stores, userId: string | null): Promise<Array<{ id: string; data: MeterRecord }>> {
30
+ if (!userId) return [];
31
+ const out: Array<{ id: string; data: MeterRecord }> = [];
32
+ let cursor: string | undefined;
33
+ do {
34
+ const page = await s.meter.query({ where: { userId }, limit: 100, cursor });
35
+ out.push(...page.items);
36
+ cursor = page.cursor;
37
+ } while (cursor);
38
+ return out;
39
+ }
40
+
41
+ async function log(s: Stores, r: Omit<PrivacyRequest, "at"> & { at?: string }): Promise<string> {
42
+ const id = crypto.randomUUID();
43
+ await s.requests.put(id, { ...r, at: r.at ?? new Date().toISOString() });
44
+ return id;
45
+ }
46
+
47
+ /** Everything RedeemID keeps about one member. */
48
+ export async function exportMember(s: Stores, customerId: string): Promise<MemberExport> {
49
+ const member = await s.members.get(customerId);
50
+ const meter = await meterFor(s, member?.userId ?? null);
51
+ return {
52
+ format: "redeemid-member-export",
53
+ version: EXPORT_VERSION,
54
+ exportedAt: new Date().toISOString(),
55
+ member,
56
+ meter: meter.map((m) => m.data),
57
+ elsewhere: [
58
+ "Orders, addresses, preference answers and the full customer record are kept by the store.",
59
+ "The site's own user account (email and name) is kept by the site's content system.",
60
+ ],
61
+ };
62
+ }
63
+
64
+ /** Export and log it. */
65
+ export async function exportRequest(s: Stores, customerId: string, source: PrivacyRequest["source"]): Promise<MemberExport> {
66
+ const data = await exportMember(s, customerId);
67
+ await log(s, { kind: "export", source, customerId, status: "done" });
68
+ return data;
69
+ }
70
+
71
+ /**
72
+ * Erase what RedeemID keeps about one member: the mirror record and their
73
+ * meter. The site user stays; if they sign in again they're linked again.
74
+ */
75
+ export async function eraseMember(config: RedeemIDConfig | null, s: Stores, customerId: string, source: PrivacyRequest["source"], shopifyRequestId: string | null = null): Promise<boolean> {
76
+ const member = await s.members.get(customerId);
77
+ for (const m of await meterFor(s, member?.userId ?? null)) await s.meter.delete(m.id);
78
+ if (member) await s.members.delete(customerId);
79
+ await log(s, { kind: "erase", source, customerId, status: "done", shopifyRequestId });
80
+ // Receivers of earlier events hold their own copies: tell them, by id only.
81
+ await emit(config, "member.erased", { customerId, source });
82
+ return Boolean(member);
83
+ }
84
+
85
+ /** shop/redact: erase every member and meter record. */
86
+ export async function eraseAll(config: RedeemIDConfig | null, s: Stores): Promise<number> {
87
+ let n = 0;
88
+ for (;;) {
89
+ const page = await s.members.query({ limit: 100 });
90
+ 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
+ }
96
+ }
97
+ for (;;) {
98
+ const page = await s.meter.query({ limit: 100 });
99
+ if (page.items.length === 0) break;
100
+ for (const r of page.items) await s.meter.delete(r.id);
101
+ }
102
+ await log(s, { kind: "erase", source: "shopify", customerId: null, status: "done", note: `Shop redacted: ${n} members` });
103
+ return n;
104
+ }
105
+
106
+ /** customers/data_request: an open export request for staff to send to the store owner. */
107
+ export async function shopifyDataRequest(s: Stores, customerId: string, shopifyRequestId: string | null): Promise<void> {
108
+ await log(s, { kind: "export", source: "shopify", customerId, status: "open", shopifyRequestId });
109
+ }
110
+
111
+ export async function closeRequest(s: Stores, id: string): Promise<PrivacyRequest | null> {
112
+ const r = await s.requests.get(id);
113
+ if (!r) return null;
114
+ const done = { ...r, status: "done" as const };
115
+ await s.requests.put(id, done);
116
+ return done;
117
+ }
118
+
119
+ export async function recentRequests(s: Stores, limit = 50): Promise<Array<{ id: string; data: PrivacyRequest }>> {
120
+ const { items } = await s.requests.query({ orderBy: { at: "desc" }, limit });
121
+ return items;
122
+ }
123
+
124
+ /**
125
+ * Ask Shopify to erase the customer's personal data across the store, orders
126
+ * included. Shopify waits before it does; cancelShopifyErasure withdraws it.
127
+ * Needs the write_customer_data_erasure scope.
128
+ */
129
+ export async function requestShopifyErasure(config: RedeemIDConfig, s: Stores, customerId: string, fetcher: typeof fetch = fetch): Promise<void> {
130
+ await erasureMutation(config, "customerRequestDataErasure", customerId, fetcher);
131
+ await log(s, { kind: "erase", source: "staff", customerId, status: "open", note: "Asked Shopify to erase" });
132
+ }
133
+
134
+ export async function cancelShopifyErasure(config: RedeemIDConfig, s: Stores, customerId: string, fetcher: typeof fetch = fetch): Promise<void> {
135
+ await erasureMutation(config, "customerCancelDataErasure", customerId, fetcher);
136
+ await log(s, { kind: "erase", source: "staff", customerId, status: "done", note: "Cancelled the Shopify erasure" });
137
+ }
138
+
139
+ async function erasureMutation(config: RedeemIDConfig, name: "customerRequestDataErasure" | "customerCancelDataErasure", customerId: string, fetcher: typeof fetch): Promise<void> {
140
+ const data = await admin<Record<string, { userErrors: Array<{ message: string }> } | null>>(
141
+ config,
142
+ `mutation RedeemIDErasure($id: ID!) { ${name}(customerId: $id) { customerId userErrors { field message code } } }`,
143
+ { id: `gid://shopify/Customer/${customerId}` },
144
+ fetcher,
145
+ );
146
+ const err = data[name]?.userErrors[0];
147
+ if (err) throw new AdminError(err.message);
148
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * /_emdash/api/auth/redeemid/privacy: the signed-in member's own data.
3
+ *
4
+ * GET downloads everything RedeemID keeps about them as JSON.
5
+ * POST with `confirm=yes` (from <YourData>) erases it, signs them out and
6
+ * goes back to `return` with the notice data_erased. The member always comes
7
+ * from the session, never from the request, and a POST from another origin
8
+ * is refused.
9
+ */
10
+
11
+ import type { APIRoute } from "astro";
12
+ import { getPublicOrigin } from "emdash/api/route-utils";
13
+
14
+ import { safeReturn, signInUrl } from "../access.js";
15
+ import { readConfig } from "../config.js";
16
+ import { eraseMember, exportRequest } from "../privacy.js";
17
+ import { memberForUser, stores } from "../storage.js";
18
+ import { fail } from "./shared.js";
19
+
20
+ export const prerender = false;
21
+
22
+ async function context(locals: unknown, session: { get(k: string): Promise<unknown> } | undefined) {
23
+ const emdash = (locals as { emdash?: { config?: unknown; db?: unknown } }).emdash;
24
+ if (!emdash?.db) return null;
25
+ const user = (await session?.get("user")) as { id?: string } | undefined;
26
+ if (!user?.id) return null;
27
+ const s = stores(emdash.db);
28
+ const member = await memberForUser(s, user.id);
29
+ return member ? { s, member, config: readConfig(emdash.config), emdash } : null;
30
+ }
31
+
32
+ export const GET: APIRoute = async ({ locals, session, redirect, url }) => {
33
+ const c = await context(locals, session);
34
+ if (!c) return redirect(signInUrl(safeReturn(url.searchParams.get("return"))));
35
+ const data = await exportRequest(c.s, c.member.customerId, "member");
36
+ return new Response(JSON.stringify(data, null, 2), {
37
+ status: 200,
38
+ headers: {
39
+ "Content-Type": "application/json; charset=utf-8",
40
+ "Content-Disposition": 'attachment; filename="my-data.json"',
41
+ "Cache-Control": "private, no-store",
42
+ },
43
+ });
44
+ };
45
+
46
+ export const POST: APIRoute = async ({ request, locals, session, redirect, url }) => {
47
+ const form = await request.formData().catch(() => null);
48
+ const returnTo = safeReturn(String(form?.get("return") ?? "/"));
49
+ const emdashConfig = (locals as { emdash?: { config?: unknown } }).emdash?.config;
50
+ const origin = getPublicOrigin(url, emdashConfig as Parameters<typeof getPublicOrigin>[1]);
51
+ const from = request.headers.get("origin");
52
+ if (from && from !== origin && from !== url.origin) return new Response("Forbidden", { status: 403 });
53
+ const c = await context(locals, session);
54
+ if (!c) return redirect(signInUrl(returnTo));
55
+ if (form?.get("confirm") !== "yes") return fail(redirect, returnTo, "erase_unconfirmed");
56
+ await eraseMember(c.config, c.s, c.member.customerId, "member");
57
+ session?.destroy();
58
+ return fail(redirect, returnTo, "data_erased");
59
+ };
@@ -50,7 +50,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
50
50
 
51
51
  try {
52
52
  const config = readConfig(emdash.config);
53
- const outcome = await apply(s, topic, payload, (before, after) => levelChange(config, before, after, topic));
53
+ const outcome = await apply(s, topic, payload, (before, after) => levelChange(config, before, after, topic), config);
54
54
  return new Response(outcome, { status: 200 });
55
55
  } catch (e) {
56
56
  console.error("[redeemid] webhook failed:", topic, (e as Error).message);