@redeemid/emdash 0.0.9 → 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.
Files changed (42) hide show
  1. package/README.md +138 -2
  2. package/bin/redeemid-emdash.mjs +144 -0
  3. package/docs/shopify-setup.md +4 -2
  4. package/package.json +8 -2
  5. package/src/access.ts +2 -2
  6. package/src/agents.ts +179 -0
  7. package/src/blocks/ContributionBlock.astro +1 -1
  8. package/src/blocks/PlanBlock.astro +1 -1
  9. package/src/blocks/PreferencesBlock.astro +12 -0
  10. package/src/blocks/index.ts +3 -0
  11. package/src/blocks-names.ts +27 -0
  12. package/src/components/AccessBadge.astro +10 -6
  13. package/src/components/Account.astro +5 -4
  14. package/src/components/Cart.astro +4 -1
  15. package/src/components/CartView.astro +14 -13
  16. package/src/components/Collection.astro +2 -1
  17. package/src/components/Content.astro +25 -10
  18. package/src/components/Contribution.astro +4 -3
  19. package/src/components/LockedPanel.astro +35 -9
  20. package/src/components/MemberCard.astro +15 -14
  21. package/src/components/Notices.astro +12 -2
  22. package/src/components/Plan.astro +3 -2
  23. package/src/components/Preferences.astro +102 -0
  24. package/src/components/Product.astro +6 -5
  25. package/src/components/YourData.astro +42 -0
  26. package/src/config.ts +38 -1
  27. package/src/descriptor.ts +1 -0
  28. package/src/events.ts +3 -2
  29. package/src/files.ts +196 -0
  30. package/src/guard.ts +43 -2
  31. package/src/index.ts +2 -0
  32. package/src/plugin.ts +169 -4
  33. package/src/preferences.ts +298 -0
  34. package/src/privacy.ts +148 -0
  35. package/src/routes/preferences.ts +82 -0
  36. package/src/routes/privacy.ts +59 -0
  37. package/src/routes/webhook.ts +1 -1
  38. package/src/staff.ts +92 -0
  39. package/src/storage.ts +17 -0
  40. package/src/text.ts +185 -0
  41. package/src/version.ts +1 -1
  42. package/src/webhooks.ts +17 -4
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,82 @@
1
+ /**
2
+ * /_emdash/api/auth/redeemid/preferences: the signed-in member's preferences.
3
+ *
4
+ * GET answers JSON: { fields } with values, for the member's own customer.
5
+ * POST takes JSON { values: { "namespace.key": value } } and answers JSON,
6
+ * or a form (from <Preferences>) and goes back to `return` with the notice
7
+ * preferences_saved, preferences_refused or preferences_unavailable.
8
+ * The customer always comes from the session, never from the request.
9
+ * Refused JSON saves answer 400 with { fields: { key: reason } }; more than
10
+ * 10 saves a minute answer 429.
11
+ */
12
+
13
+ import type { APIRoute } from "astro";
14
+
15
+ import { safeReturn, signInUrl } from "../access.js";
16
+ import { readConfig } from "../config.js";
17
+ import { emit } from "../events.js";
18
+ import { allowSave, formValues, PreferenceError, preferenceFields, preferencesConfigured, readPreferences, savePreferences } from "../preferences.js";
19
+ import { memberForUser, stores } from "../storage.js";
20
+ import { fail } from "./shared.js";
21
+
22
+ export const prerender = false;
23
+
24
+ const json = (status: number, body: unknown) =>
25
+ new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json", "Cache-Control": "private, no-store" } });
26
+
27
+ async function context(locals: unknown, session: { get(k: string): Promise<unknown> } | undefined) {
28
+ const emdash = (locals as { emdash?: { config?: unknown; db?: unknown } }).emdash;
29
+ const config = readConfig(emdash?.config);
30
+ if (!config || !preferencesConfigured(config) || !emdash?.db) return { error: "unavailable" as const };
31
+ const user = (await session?.get("user")) as { id?: string } | undefined;
32
+ if (!user?.id) return { error: "sign_in" as const, config };
33
+ const s = stores(emdash.db);
34
+ const member = await memberForUser(s, user.id);
35
+ if (!member) return { error: "sign_in" as const, config };
36
+ if (member.status === "disabled") return { error: "disabled" as const, config };
37
+ return { config, s, member };
38
+ }
39
+
40
+ export const GET: APIRoute = async ({ locals, session }) => {
41
+ const c = await context(locals, session);
42
+ if ("error" in c) return json(c.error === "unavailable" ? 404 : 401, { error: c.error });
43
+ try {
44
+ return json(200, { fields: await readPreferences(c.config, c.member.customerId) });
45
+ } catch (e) {
46
+ console.error("[redeemid] preferences not read:", (e as Error).message);
47
+ return json(502, { error: "unavailable" });
48
+ }
49
+ };
50
+
51
+ export const POST: APIRoute = async ({ request, locals, session, redirect }) => {
52
+ const isJson = (request.headers.get("content-type") ?? "").includes("application/json");
53
+ const form = isJson ? null : await request.formData().catch(() => null);
54
+ const returnTo = safeReturn(String(form?.get("return") ?? "/"));
55
+ const c = await context(locals, session);
56
+ if ("error" in c) {
57
+ if (isJson) return json(c.error === "unavailable" ? 404 : 401, { error: c.error });
58
+ return c.error === "sign_in" ? redirect(signInUrl(returnTo)) : fail(redirect, returnTo, "preferences_unavailable");
59
+ }
60
+
61
+ const slots = allowSave(c.member);
62
+ if (!slots) return isJson ? json(429, { error: "too_many_saves" }) : fail(redirect, returnTo, "preferences_unavailable");
63
+
64
+ let values: Record<string, unknown>;
65
+ try {
66
+ if (isJson) {
67
+ const body = (await request.json()) as { values?: unknown };
68
+ values = body.values && typeof body.values === "object" && !Array.isArray(body.values) ? (body.values as Record<string, unknown>) : {};
69
+ } else {
70
+ values = formValues(form ?? new FormData(), await preferenceFields(c.config));
71
+ }
72
+ await c.s.members.put(c.member.customerId, { ...c.member, prefSaves: slots });
73
+ const { fields, changed } = await savePreferences(c.config, c.member.customerId, values);
74
+ // Field names only, never the answers.
75
+ if (changed.length) await emit(c.config, "preferences.saved", { customerId: c.member.customerId, fields: changed });
76
+ return isJson ? json(200, { fields }) : fail(redirect, returnTo, "preferences_saved");
77
+ } catch (e) {
78
+ if (e instanceof PreferenceError) return isJson ? json(400, { error: "refused", fields: e.fields }) : fail(redirect, returnTo, "preferences_refused");
79
+ console.error("[redeemid] preferences not saved:", (e as Error).message);
80
+ return isJson ? json(502, { error: "unavailable" }) : fail(redirect, returnTo, "preferences_unavailable");
81
+ }
82
+ };
@@ -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);
package/src/staff.ts CHANGED
@@ -9,6 +9,7 @@ import { describe, levelsFor, membership, passes, requirement, termTree, type Te
9
9
  import type { RedeemIDConfig } from "./config.js";
10
10
  import { levelChange } from "./events.js";
11
11
  import { featuresFor } from "./features.js";
12
+ import { recentRequests } from "./privacy.js";
12
13
  import { cleanTags, numericId, type MemberRecord, type Stores } from "./storage.js";
13
14
 
14
15
  /** Members resynced per click; well inside Shopify's query cost limits. */
@@ -80,6 +81,97 @@ export function memberDetail(config: RedeemIDConfig, m: MemberRecord, toast?: Bl
80
81
  ? { type: "actions", elements: [{ type: "button", action_id: "member_resync", label: "Resync from Shopify", style: "primary", value: m.customerId }] }
81
82
  : { type: "divider" },
82
83
  { type: "context", text: "Tags are written to Shopify, then read back. Shopify stays the only source of access." },
84
+ ...privacySection(m.customerId),
85
+ ],
86
+ };
87
+ }
88
+
89
+ /** Export and erase for one member, on their detail page. */
90
+ function privacySection(customerId: string): unknown[] {
91
+ const erase = {
92
+ type: "button",
93
+ action_id: "member_erase",
94
+ label: "Erase site data",
95
+ style: "danger",
96
+ value: customerId,
97
+ confirm: {
98
+ title: "Erase this member's data on the site?",
99
+ text: "Removes their synced record and meter here. Their site user and everything in Shopify stay. If they sign in again they're linked again.",
100
+ confirm: "Erase",
101
+ deny: "Cancel",
102
+ style: "danger",
103
+ },
104
+ };
105
+ const shopify = adminConfigured()
106
+ ? [
107
+ {
108
+ type: "button",
109
+ action_id: "member_shopify_erase",
110
+ label: "Also request erasure in Shopify",
111
+ style: "danger",
112
+ value: customerId,
113
+ confirm: {
114
+ title: "Ask Shopify to erase this customer?",
115
+ text: "Shopify erases the customer's personal data across the store, orders included, after a waiting period. Needs the write_customer_data_erasure scope.",
116
+ confirm: "Request erasure",
117
+ deny: "Cancel",
118
+ style: "danger",
119
+ },
120
+ },
121
+ { type: "button", action_id: "member_shopify_cancel", label: "Cancel the Shopify erasure", style: "secondary", value: customerId },
122
+ ]
123
+ : [];
124
+ return [
125
+ { type: "header", text: "Privacy" },
126
+ { type: "actions", elements: [{ type: "button", action_id: "member_export", label: "Export data", style: "secondary", value: customerId }, erase, ...shopify] },
127
+ { type: "context", text: "Export shows everything RedeemID keeps about this member. Erasing removes it from the site; Shopify stays the record of the customer." },
128
+ ];
129
+ }
130
+
131
+ /** The member's export, as JSON, under their detail page. */
132
+ export function exportBlocks(detail: Blocks, data: unknown): Blocks {
133
+ return { ...detail, blocks: [...detail.blocks, { type: "header", text: "Export" }, { type: "code", language: "jsonc", code: JSON.stringify(data, null, 2) }] };
134
+ }
135
+
136
+ /** Every privacy request: what came in, from where, and what's still open. */
137
+ export async function privacyPage(s: Stores, toast?: Blocks["toast"], extra: unknown[] = []): Promise<Blocks> {
138
+ const items = await recentRequests(s);
139
+ return {
140
+ toast,
141
+ blocks: [
142
+ { type: "header", text: "Privacy requests" },
143
+ { type: "context", text: "Exports and erasures of what RedeemID keeps, from members, staff and Shopify's compliance webhooks. Open requests need staff: send a Shopify data request's export to the store owner, then mark it done." },
144
+ items.length
145
+ ? {
146
+ type: "table",
147
+ page_action_id: "privacy_requests",
148
+ columns: [
149
+ { key: "at", label: "When", format: "relative_time" },
150
+ { key: "kind", label: "Request" },
151
+ { key: "source", label: "From" },
152
+ { key: "customer", label: "Shopify customer" },
153
+ { key: "status", label: "Status", format: "badge" },
154
+ { key: "act", label: "", format: "element" },
155
+ ],
156
+ rows: items.map(({ id, data: r }) => ({
157
+ at: r.at,
158
+ kind: r.kind === "export" ? "Export" : "Erase",
159
+ source: { member: "Member, on the site", staff: "Staff", shopify: "Shopify" }[r.source] + (r.shopifyRequestId ? ` (request ${r.shopifyRequestId})` : "") + (r.note ? `: ${r.note}` : ""),
160
+ customer: r.customerId ?? "All",
161
+ status: r.status === "open" ? "Open" : "Done",
162
+ act:
163
+ r.status === "open"
164
+ ? {
165
+ type: "button",
166
+ action_id: "privacy_done",
167
+ label: r.kind === "export" && r.customerId ? "Export and mark done" : "Mark done",
168
+ value: id,
169
+ }
170
+ : null,
171
+ })),
172
+ }
173
+ : { type: "context", text: "No requests yet." },
174
+ ...extra,
83
175
  ],
84
176
  };
85
177
  }
package/src/storage.ts CHANGED
@@ -37,6 +37,8 @@ export interface MemberRecord {
37
37
  linkedAt?: string | null;
38
38
  /** Tags Shopify has removed, with when each was held (newest last, at most 50). */
39
39
  pastTags?: PastTag[];
40
+ /** When the member last saved preferences (ms), newest last, at most 10: the rate limit. */
41
+ prefSaves?: number[];
40
42
  }
41
43
 
42
44
  /** A tag the customer held from `from` (when known) until `to`. */
@@ -77,16 +79,31 @@ export interface DeliveryRecord {
77
79
  receivedAt: string;
78
80
  }
79
81
 
82
+ /** One privacy request: an export or erase, and where it came from. Names no one but the customer id. */
83
+ export interface PrivacyRequest {
84
+ kind: "export" | "erase";
85
+ /** member: on the site; staff: in the admin; shopify: a compliance webhook. */
86
+ source: "member" | "staff" | "shopify";
87
+ customerId: string | null;
88
+ at: string;
89
+ /** open: staff still need to act (a Shopify data request, or a Shopify erasure that's pending). */
90
+ status: "open" | "done";
91
+ shopifyRequestId?: string | null;
92
+ note?: string;
93
+ }
94
+
80
95
  export const STORAGE_CONFIG = {
81
96
  members: { indexes: ["email", "userId", "syncedAt"] },
82
97
  deliveries: { indexes: ["receivedAt"] },
83
98
  meter: { indexes: ["userId"] },
99
+ requests: { indexes: ["at", "customerId", "status"] },
84
100
  };
85
101
 
86
102
  export interface Stores {
87
103
  members: StorageCollection<MemberRecord>;
88
104
  deliveries: StorageCollection<DeliveryRecord>;
89
105
  meter: StorageCollection<MeterRecord>;
106
+ requests: StorageCollection<PrivacyRequest>;
90
107
  }
91
108
 
92
109
  export function stores(db: unknown): Stores {
package/src/text.ts ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Replacement text: give any string members and visitors see new words, one
3
+ * string at a time. Config `text` maps a string's original English to its
4
+ * replacement:
5
+ *
6
+ * text: {
7
+ * "Sign in": "Log in",
8
+ * "This is for {level} members.": "{level} members only.",
9
+ * }
10
+ *
11
+ * Only the strings named change: there is no find-and-replace across
12
+ * strings, so nothing changes that nobody looked at. STRINGS lists every
13
+ * original. Some have placeholders such as `{level}`, filled in when the
14
+ * text shows; a replacement may use its original's placeholders, in any
15
+ * order or not at all, but no others. A replacement that breaks this, or
16
+ * names a string RedeemID doesn't show, is ignored (the original shows) and
17
+ * listed on the Status page. Text is plain: HTML in a replacement shows as
18
+ * typed. Explicit component props (`label`, `title`, Notices `messages`) win
19
+ * over `text`.
20
+ */
21
+
22
+ import type { RedeemIDConfig } from "./config.js";
23
+
24
+ /** Every string RedeemID shows members and visitors, by where it shows. */
25
+ export const STRINGS = {
26
+ account: ["Signed in", "Account", "Sign out", "Sign in"],
27
+ badge: ["Free", "Signed in", " (locked)"],
28
+ cart: [
29
+ "Cart",
30
+ "Your cart is empty.",
31
+ "Your cart isn't available right now. Try again in a few minutes.",
32
+ "{price} each · {total}",
33
+ "Quantity",
34
+ "Update",
35
+ "Remove",
36
+ "Discount code",
37
+ "Apply",
38
+ "{code} doesn't apply to this cart.",
39
+ "Subtotal",
40
+ "Total",
41
+ "Taxes and shipping are worked out at checkout.",
42
+ "Checkout",
43
+ ],
44
+ collection: ["From {price}", "Sold out"],
45
+ contribution: ["Give", "Another amount ({currency})", "Quantity ({price} each)"],
46
+ meter: ["This is your last free read this week.", "This is your last free read this month.", "You have 1 free read left this week.", "You have 1 free read left this month.", "You have {count} free reads left this week.", "You have {count} free reads left this month."],
47
+ wall: [
48
+ "Become a member",
49
+ "This is for members. {signIn} to read on.",
50
+ "Sign in",
51
+ "Not a member yet? {join}.",
52
+ "You've used your free reads this week.",
53
+ "You've used your free reads this month.",
54
+ "This opens for you on {date}.",
55
+ "This opens for you after you've been a member a little longer.",
56
+ "This is for {level} members.",
57
+ "Your membership doesn't include this yet.",
58
+ "Your account is turned off. Contact the store for help.",
59
+ "This is for members.",
60
+ "Or buy just this for {price}.",
61
+ "Buy just this",
62
+ ],
63
+ member: [
64
+ "Your membership",
65
+ "Your account is turned off. Contact the store for help.",
66
+ "since {date}",
67
+ "You don't have a membership yet.",
68
+ "Become a member",
69
+ "Includes: {features}",
70
+ "Before",
71
+ "{from} to {to}",
72
+ "until {to}",
73
+ "Free reads this week: {used} of {limit} used.",
74
+ "Free reads this month: {used} of {limit} used.",
75
+ "Orders and details",
76
+ "Refresh membership",
77
+ "Sign out",
78
+ "Just bought a plan? It can take a minute to show here. Refresh membership checks again.",
79
+ "{signIn} to see your membership.",
80
+ "Sign in",
81
+ ],
82
+ notices: [
83
+ "Sign-in isn't set up on this site yet.",
84
+ "Sign-in isn't available right now. Try again in a few minutes.",
85
+ "That sign-in took too long. Try again.",
86
+ "This account can't sign in here. Use a different email.",
87
+ "This site isn't ready for sign-in yet.",
88
+ "Your account needs an email address to sign in.",
89
+ "Sign-in didn't finish. Try again.",
90
+ "That plan isn't available right now.",
91
+ "Checkout didn't open. Try again.",
92
+ "Your cart is empty.",
93
+ "Added to your cart.",
94
+ "Your cart is updated.",
95
+ "Discount applied.",
96
+ "That discount code doesn't apply to this cart.",
97
+ "Your membership is up to date.",
98
+ "Your preferences are saved.",
99
+ "Some answers weren't saved. Check them and try again.",
100
+ "Your preferences can't be saved right now. Try again in a few minutes.",
101
+ "Your data on this site is deleted, and you're signed out.",
102
+ "Tick the box to confirm, then try again.",
103
+ "Your account page isn't available right now. Try again in a few minutes.",
104
+ ],
105
+ privacy: [
106
+ "Your data",
107
+ "Download my data",
108
+ "Deleting removes what this site keeps about you and signs you out. Your store account and orders stay; ask the store to delete those.",
109
+ "I understand",
110
+ "Delete my data",
111
+ ],
112
+ plan: ["Join", "You're on this plan.", "Not available right now."],
113
+ 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."],
114
+ product: ["Add to cart", "Buy now", "Option", "{option} ({price})", "{option} ({price}) sold out", "Quantity", "Sold out."],
115
+ files: ["File not available", "Sign in to download this file.", "Sign in", "Your membership doesn't include this file."],
116
+ } as const;
117
+
118
+ const ALL = new Set<string>(Object.values(STRINGS).flat());
119
+
120
+ const PLACEHOLDER = /\{(\w+)\}/g;
121
+
122
+ export function placeholders(s: string): string[] {
123
+ return [...s.matchAll(PLACEHOLDER)].map((m) => m[1] as string);
124
+ }
125
+
126
+ /** Why a replacement can't be used, or null when it can. */
127
+ export function textProblem(original: string, replacement: unknown): string | null {
128
+ if (!ALL.has(original)) return "RedeemID doesn't show this string";
129
+ if (typeof replacement !== "string") return "The replacement isn't text";
130
+ const allowed = new Set(placeholders(original));
131
+ const extra = placeholders(replacement).filter((p) => !allowed.has(p));
132
+ if (extra.length) return `Uses ${[...new Set(extra)].map((p) => `{${p}}`).join(", ")}, which the original doesn't have`;
133
+ return null;
134
+ }
135
+
136
+ const checked = new WeakMap<object, { ok: Map<string, string>; problems: Array<{ original: string; problem: string }> }>();
137
+
138
+ function table(config: RedeemIDConfig | null | undefined) {
139
+ const map = config?.text;
140
+ if (!map || typeof map !== "object") return null;
141
+ let t = checked.get(map);
142
+ if (!t) {
143
+ t = { ok: new Map(), problems: [] };
144
+ for (const [original, replacement] of Object.entries(map)) {
145
+ const problem = textProblem(original, replacement);
146
+ if (problem) t.problems.push({ original, problem });
147
+ else t.ok.set(original, replacement as string);
148
+ }
149
+ if (t.problems.length) console.warn(`[redeemid] ${t.problems.length} text replacement(s) ignored; see Status.`);
150
+ checked.set(map, t);
151
+ }
152
+ return t;
153
+ }
154
+
155
+ /** Replacements that are ignored, and why. */
156
+ export function textProblems(config: RedeemIDConfig | null | undefined): Array<{ original: string; problem: string }> {
157
+ return table(config)?.problems ?? [];
158
+ }
159
+
160
+ /** The text for one string, with plain placeholders filled in. */
161
+ export function text(config: RedeemIDConfig | null | undefined, original: string, vars: Record<string, string | number> = {}): string {
162
+ const template = table(config)?.ok.get(original) ?? original;
163
+ return template.replace(PLACEHOLDER, (whole, name: string) => (name in vars ? String(vars[name]) : whole));
164
+ }
165
+
166
+ /**
167
+ * The text for a string whose placeholders are elements (a link inside a
168
+ * sentence): the parts to render, in order.
169
+ *
170
+ * {textParts(config, "This is for members. {signIn} to read on.", { signIn: <a href={u}>Sign in</a> })}
171
+ */
172
+ export function textParts(config: RedeemIDConfig | null | undefined, original: string, slots: Record<string, unknown>): unknown[] {
173
+ const template = table(config)?.ok.get(original) ?? original;
174
+ const out: unknown[] = [];
175
+ let last = 0;
176
+ for (const m of template.matchAll(PLACEHOLDER)) {
177
+ const name = m[1] as string;
178
+ if (!(name in slots)) continue;
179
+ if (m.index > last) out.push(template.slice(last, m.index));
180
+ out.push(slots[name]);
181
+ last = m.index + m[0].length;
182
+ }
183
+ if (last < template.length) out.push(template.slice(last));
184
+ return out;
185
+ }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.0.9";
1
+ export const VERSION = "0.0.11";