@redeemid/emdash 0.0.16 → 0.0.18

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 (73) hide show
  1. package/README.md +447 -232
  2. package/bin/redeemid-emdash.mjs +13 -1
  3. package/docs/developer-api.md +126 -0
  4. package/docs/shopify-setup.md +1 -1
  5. package/package.json +3 -1
  6. package/src/access-admin.ts +754 -0
  7. package/src/access.ts +84 -18
  8. package/src/admin-api.ts +9 -2
  9. package/src/agents.ts +62 -3
  10. package/src/api-version.ts +6 -0
  11. package/src/blocks/CollectionBlock.astro +4 -1
  12. package/src/blocks/SearchBlock.astro +14 -0
  13. package/src/blocks/index.ts +2 -0
  14. package/src/blocks-names.ts +1 -0
  15. package/src/cart-ops.ts +164 -0
  16. package/src/catalog.ts +287 -0
  17. package/src/checkout.ts +73 -0
  18. package/src/client/redeemid.ts +563 -0
  19. package/src/components/AccessBadge.astro +7 -5
  20. package/src/components/Account.astro +1 -1
  21. package/src/components/BrowseControls.astro +87 -0
  22. package/src/components/Cart.astro +12 -6
  23. package/src/components/CartDrawer.astro +59 -0
  24. package/src/components/CartView.astro +2 -1
  25. package/src/components/Collection.astro +23 -18
  26. package/src/components/Content.astro +10 -5
  27. package/src/components/Contribution.astro +1 -1
  28. package/src/components/Gate.astro +4 -2
  29. package/src/components/LockedPanel.astro +5 -3
  30. package/src/components/MemberCard.astro +1 -1
  31. package/src/components/Notices.astro +3 -28
  32. package/src/components/Plan.astro +1 -1
  33. package/src/components/Preferences.astro +1 -1
  34. package/src/components/Product.astro +116 -25
  35. package/src/components/ProductGrid.astro +52 -0
  36. package/src/components/ProductSearch.astro +83 -0
  37. package/src/components/Recommendations.astro +45 -0
  38. package/src/components/RedeemIDClient.astro +45 -0
  39. package/src/components/YourData.astro +1 -1
  40. package/src/config.ts +31 -5
  41. package/src/contract.ts +50 -0
  42. package/src/descriptor.ts +7 -0
  43. package/src/events.ts +3 -0
  44. package/src/features.ts +1 -1
  45. package/src/files.ts +1 -1
  46. package/src/guard.ts +5 -3
  47. package/src/index.ts +3 -0
  48. package/src/log.ts +96 -0
  49. package/src/monitor.ts +560 -0
  50. package/src/notices.ts +29 -0
  51. package/src/plugin.ts +234 -88
  52. package/src/rate-limit.ts +7 -0
  53. package/src/routes/api/cart.ts +84 -0
  54. package/src/routes/api/search.ts +49 -0
  55. package/src/routes/api/shared.ts +4 -0
  56. package/src/routes/api/state.ts +69 -0
  57. package/src/routes/buy.ts +4 -3
  58. package/src/routes/callback.ts +23 -5
  59. package/src/routes/cart-edit.ts +15 -66
  60. package/src/routes/cart.ts +4 -2
  61. package/src/routes/preferences.ts +2 -2
  62. package/src/routes/privacy.ts +2 -2
  63. package/src/routes/shared.ts +11 -0
  64. package/src/routes/webhook.ts +24 -11
  65. package/src/settings.ts +195 -0
  66. package/src/shopify.ts +37 -1
  67. package/src/site-config.ts +38 -5
  68. package/src/staff.ts +12 -23
  69. package/src/storage.ts +12 -0
  70. package/src/storefront.ts +231 -59
  71. package/src/text.ts +43 -2
  72. package/src/version.ts +1 -1
  73. package/src/webhooks.ts +2 -1
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Access settings made in the admin.
3
+ *
4
+ * Levels, rules, the meter, protected folders and the rest can be set in
5
+ * astro.config.mjs (the defaults) and changed in the admin (Plugins →
6
+ * RedeemID → Access). A setting saved in the admin replaces the code's value
7
+ * for that setting; "Use the code's value" removes it again. Each entry can
8
+ * also carry its own rule, set in the editor's Access panel.
9
+ *
10
+ * Saved in the auth-provider storage collection `access`: record `site` for
11
+ * the site settings and `entry:<id>` for each entry's own rule. Nothing here
12
+ * grants access to anyone: levels still come only from Shopify tags.
13
+ */
14
+
15
+ import type { FeatureConfig, FileRule, LevelConfig, MeterConfig, RedeemIDConfig, RuleConfig } from "./config.js";
16
+ import type { StorageCollection } from "emdash";
17
+
18
+ import { stores } from "./storage.js";
19
+
20
+ /** The settings the admin can manage, by config key. */
21
+ export const MANAGED = ["levels", "rules", "meter", "files", "features", "combine", "teaser", "join"] as const;
22
+ export type ManagedKey = (typeof MANAGED)[number];
23
+
24
+ /** What the admin saved. A key that's present replaces the code's value (null turns it off). */
25
+ export interface SiteAccess {
26
+ kind: "site";
27
+ levels?: LevelConfig[];
28
+ rules?: RuleConfig[];
29
+ meter?: MeterConfig | null;
30
+ files?: FileRule[];
31
+ features?: FeatureConfig[];
32
+ combine?: "all" | "any";
33
+ teaser?: Record<string, number>;
34
+ join?: { href: string; label?: string } | null;
35
+ updatedAt?: string;
36
+ }
37
+
38
+ /**
39
+ * One entry's own rule, set in the editor:
40
+ *
41
+ * - inherit: the site's rules decide (the default).
42
+ * - open: free to everyone.
43
+ * - levels: only the levels picked (none picked means any signed-in member).
44
+ * - add: the site's rules, and also one of the levels picked.
45
+ *
46
+ * `lockedUntil` opens the entry to everyone from that time; `openUntil` keeps
47
+ * it free to everyone until that time. `drip` and `meter: false` work as on
48
+ * a rule.
49
+ */
50
+ export interface EntryRule {
51
+ kind: "entry";
52
+ collection: string;
53
+ /** EmDash's id for the entry. */
54
+ entryId: string;
55
+ /** Its slug when last saved, so page code can pass either. */
56
+ slug?: string | null;
57
+ mode: "inherit" | "open" | "levels" | "add";
58
+ levels: string[];
59
+ drip?: number;
60
+ meter?: false;
61
+ lockedUntil?: string | null;
62
+ openUntil?: string | null;
63
+ updatedAt?: string;
64
+ }
65
+
66
+ export type AccessRecord = SiteAccess | EntryRule;
67
+
68
+ export interface LoadedAccess {
69
+ site: SiteAccess | null;
70
+ entries: EntryRule[];
71
+ }
72
+
73
+ export const SITE_ID = "site";
74
+ export const entryKey = (entryId: string) => `entry:${entryId}`;
75
+
76
+ /** Read everything saved, in one query. */
77
+ export async function loadAccess(store: StorageCollection<AccessRecord>): Promise<LoadedAccess> {
78
+ const out: LoadedAccess = { site: null, entries: [] };
79
+ let cursor: string | undefined;
80
+ let pages = 0;
81
+ do {
82
+ const page = await store.query({ limit: 1000, cursor });
83
+ for (const r of page.items) {
84
+ if (r.data?.kind === "site") out.site = r.data;
85
+ else if (r.data?.kind === "entry") out.entries.push(r.data);
86
+ }
87
+ cursor = page.cursor;
88
+ } while (cursor && ++pages < 10);
89
+ return out;
90
+ }
91
+
92
+ /** The code's config with the admin's settings and entry rules laid over it. */
93
+ export function mergeAccess(code: RedeemIDConfig | null, saved: LoadedAccess | null): RedeemIDConfig | null {
94
+ if (!code) return null;
95
+ if (!saved) return code;
96
+ const out: Record<string, unknown> = { ...code };
97
+ const site = saved.site as Record<string, unknown> | null;
98
+ if (site) {
99
+ for (const key of MANAGED) {
100
+ if (!(key in site)) continue;
101
+ const value = site[key];
102
+ if (value === null || value === undefined) delete out[key];
103
+ else out[key] = value;
104
+ }
105
+ }
106
+ const entries: Record<string, EntryRule> = {};
107
+ for (const e of saved.entries) {
108
+ if (e.mode === "inherit" && !e.drip && e.meter !== false && !e.lockedUntil && !e.openUntil) continue;
109
+ entries[`${e.collection}:${e.entryId}`] = e;
110
+ if (e.slug) entries[`${e.collection}:${e.slug}`] ??= e;
111
+ }
112
+ out.entries = { ...(code.entries ?? {}), ...entries };
113
+ return out as RedeemIDConfig;
114
+ }
115
+
116
+ /** Which settings the admin has taken over from the code. */
117
+ export function overridden(site: SiteAccess | null): Set<ManagedKey> {
118
+ return new Set(MANAGED.filter((k) => site && k in site));
119
+ }
120
+
121
+ /** Code config plus the admin's settings, read with `db`. */
122
+ export async function withSaved(code: RedeemIDConfig | null, db: unknown): Promise<RedeemIDConfig | null> {
123
+ if (!code) return null;
124
+ try {
125
+ const merged = mergeAccess(code, await loadAccess(stores(db).access));
126
+ announceLevels(merged?.levels);
127
+ return merged;
128
+ } catch (e) {
129
+ // No storage yet (or it can't be read): the code's settings stand.
130
+ console.error("[redeemid] couldn't read saved access settings:", (e as Error).message);
131
+ return code;
132
+ }
133
+ }
134
+
135
+ // ---------------------------------------------------------------- level pickers
136
+
137
+ type LevelListener = (levels: LevelConfig[]) => void;
138
+ const LISTENERS = Symbol.for("redeemid:level-listeners");
139
+
140
+ /**
141
+ * The editor's level pickers (on the Paywall break and other blocks) are
142
+ * declared when the plugin starts. The plugin registers a listener here so
143
+ * they follow levels saved in the admin.
144
+ */
145
+ export function onLevels(listener: LevelListener): void {
146
+ const g = globalThis as unknown as Record<symbol, LevelListener[]>;
147
+ (g[LISTENERS] ??= []).push(listener);
148
+ }
149
+
150
+ export function announceLevels(levels: LevelConfig[] = []): void {
151
+ const g = globalThis as unknown as Record<symbol, LevelListener[] | undefined>;
152
+ for (const l of g[LISTENERS] ?? []) {
153
+ try {
154
+ l(levels);
155
+ } catch {
156
+ // A listener's failure never breaks a page.
157
+ }
158
+ }
159
+ }
160
+
161
+ // ---------------------------------------------------------------- checks
162
+
163
+ const SLUG = /^[a-z0-9][a-z0-9_-]{0,62}$/;
164
+
165
+ /** A slug from a name: "Gold members" → "gold-members". */
166
+ export function slugify(raw: string): string {
167
+ return raw
168
+ .toLowerCase()
169
+ .normalize("NFKD")
170
+ .replace(/[̀-ͯ]/g, "")
171
+ .replace(/[^a-z0-9]+/g, "-")
172
+ .replace(/^-+|-+$/g, "")
173
+ .slice(0, 63);
174
+ }
175
+
176
+ export function validSlug(slug: string): boolean {
177
+ return SLUG.test(slug) && slug !== "signed-in";
178
+ }
179
+
180
+ /** Comma- or line-separated text to a trimmed, unique list. */
181
+ export function list(raw: unknown): string[] {
182
+ const parts = Array.isArray(raw) ? raw : String(raw ?? "").split(/[,\n]/);
183
+ const out: string[] = [];
184
+ for (const p of parts) {
185
+ const t = String(p).trim();
186
+ if (t && !out.some((o) => o.toLowerCase() === t.toLowerCase())) out.push(t);
187
+ }
188
+ return out;
189
+ }
190
+
191
+ /** A whole number of days, 0 to 3650. */
192
+ export function days(raw: unknown): number {
193
+ const n = Math.floor(Number(raw));
194
+ return Number.isFinite(n) && n > 0 ? Math.min(n, 3650) : 0;
195
+ }
package/src/shopify.ts CHANGED
@@ -127,6 +127,42 @@ export function authorizeUrl(
127
127
  export interface Tokens {
128
128
  accessToken: string;
129
129
  idToken: string;
130
+ /** Present when Shopify issued one; used to renew the access token for a signed-in checkout. */
131
+ refreshToken?: string | null;
132
+ /** When the access token expires (ms since epoch), when Shopify said. */
133
+ expiresAt?: number | null;
134
+ }
135
+
136
+ function tokenTimes(data: Record<string, unknown>): { refreshToken: string | null; expiresAt: number | null } {
137
+ const ttl = typeof data.expires_in === "number" ? data.expires_in : Number(data.expires_in);
138
+ return {
139
+ refreshToken: typeof data.refresh_token === "string" ? data.refresh_token : null,
140
+ expiresAt: Number.isFinite(ttl) && ttl > 0 ? Date.now() + ttl * 1000 : null,
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Renew a Customer Account API access token with its refresh token. Throws
146
+ * when Shopify refuses (the member's store session ended).
147
+ */
148
+ export async function refreshTokens(
149
+ d: Discovery,
150
+ args: { clientId: string; clientSecret: string; origin?: string; refreshToken: string },
151
+ fetcher: typeof fetch = fetch,
152
+ ): Promise<{ accessToken: string; refreshToken: string | null; expiresAt: number | null }> {
153
+ const body = new URLSearchParams({ grant_type: "refresh_token", client_id: args.clientId, refresh_token: args.refreshToken });
154
+ const headers: Record<string, string> = {
155
+ "Content-Type": "application/x-www-form-urlencoded",
156
+ Accept: "application/json",
157
+ "User-Agent": USER_AGENT,
158
+ };
159
+ if (args.origin) headers.Origin = args.origin;
160
+ if (args.clientSecret) headers.Authorization = `Basic ${btoa(`${args.clientId}:${args.clientSecret}`)}`;
161
+ const res = await fetcher(d.tokenEndpoint, { method: "POST", headers, body });
162
+ const data = (await res.json().catch(() => ({}))) as Record<string, unknown>;
163
+ if (!res.ok || typeof data.access_token !== "string") throw new ShopifyError("token_failed", `Token refresh failed (${res.status}).`);
164
+ const t = tokenTimes(data);
165
+ return { accessToken: data.access_token, refreshToken: t.refreshToken ?? args.refreshToken, expiresAt: t.expiresAt };
130
166
  }
131
167
 
132
168
  export async function exchangeCode(
@@ -155,7 +191,7 @@ export async function exchangeCode(
155
191
  if (!res.ok || typeof data.access_token !== "string" || typeof data.id_token !== "string") {
156
192
  throw new ShopifyError("token_failed", `Token exchange failed (${res.status}).`);
157
193
  }
158
- return { accessToken: data.access_token, idToken: data.id_token };
194
+ return { accessToken: data.access_token, idToken: data.id_token, ...tokenTimes(data) };
159
195
  }
160
196
 
161
197
  /** Verify the ID token's signature, issuer, audience, expiry and nonce. */
@@ -1,15 +1,48 @@
1
1
  /**
2
- * The site's RedeemID config at request time.
2
+ * The site's RedeemID config at request time: the code's config (from
3
+ * astro.config.mjs) with the settings saved in the admin laid over it.
3
4
  *
4
5
  * EmDash's anonymous fast path leaves `locals.emdash` without its config, so
5
- * page checks read the integration's serialized config module instead. That
6
- * module only exists inside the site's Vite build.
6
+ * the code's config is read from the integration's serialized config module
7
+ * when it's missing. That module only exists inside the site's Vite build.
8
+ *
9
+ * The saved settings are read once per request (one query) and shared by
10
+ * everything that asks during it.
7
11
  */
8
12
  // @ts-expect-error -- virtual module provided by the EmDash integration
9
13
  import virtualConfig from "virtual:emdash/config";
10
14
 
11
15
  import { readConfig, type RedeemIDConfig } from "./config.js";
16
+ import { withSaved } from "./settings.js";
17
+
18
+ type Locals = { emdash?: { config?: unknown; db?: unknown } | null } | null | undefined;
19
+
20
+ const loaded = new WeakMap<object, Promise<RedeemIDConfig | null>>();
21
+
22
+ /** The code's config only, without the admin's settings. */
23
+ export function codeConfig(locals?: unknown): RedeemIDConfig | null {
24
+ return readConfig((locals as Locals)?.emdash?.config) ?? readConfig(virtualConfig);
25
+ }
26
+
27
+ async function database(locals: Locals): Promise<unknown> {
28
+ if (locals?.emdash?.db) return locals.emdash.db;
29
+ const { getDb } = await import("emdash/runtime");
30
+ return getDb();
31
+ }
32
+
33
+ /** The site's config for this request (pass `Astro.locals` or the route's `locals`). */
34
+ export function siteConfig(locals?: unknown): Promise<RedeemIDConfig | null> {
35
+ const key = locals && typeof locals === "object" ? (locals as object) : null;
36
+ const known = key ? loaded.get(key) : undefined;
37
+ if (known) return known;
38
+ const code = codeConfig(locals);
39
+ const fresh = code ? database(locals as Locals).then((db) => withSaved(code, db), () => code) : Promise.resolve(null);
40
+ if (key) loaded.set(key, fresh);
41
+ return fresh;
42
+ }
12
43
 
13
- export function siteConfig(emdashConfig?: unknown): RedeemIDConfig | null {
14
- return readConfig(emdashConfig) ?? readConfig(virtualConfig);
44
+ /** Share an already-read config with a copy of the request's locals. */
45
+ export function shareConfig(from: unknown, to: unknown): void {
46
+ const known = from && typeof from === "object" ? loaded.get(from as object) : undefined;
47
+ if (known && to && typeof to === "object") loaded.set(to as object, known);
15
48
  }
package/src/staff.ts CHANGED
@@ -1,14 +1,15 @@
1
1
  /**
2
2
  * Staff tools in the admin (Block Kit): member detail with Resync and tag
3
3
  * edits written to Shopify, a paced manual bulk resync, webhook
4
- * registration and health, recent deliveries and the access test.
4
+ * registration and the access test.
5
5
  */
6
6
 
7
7
  import { adminConfigured, adminScopes, editTags, registerWebhooks, resync, webhookSubscriptions, WEBHOOK_TOPICS } from "./admin-api.js";
8
- import { describe, levelsFor, membership, passes, requirement, termTree, type Terms } from "./access.js";
8
+ import { applyEntryRule, describe, entryRuleFor, levelsFor, membership, passes, requirement, termTree, type Terms } from "./access.js";
9
9
  import type { RedeemIDConfig } from "./config.js";
10
10
  import { levelChange } from "./events.js";
11
11
  import { featuresFor } from "./features.js";
12
+ import { record } from "./log.js";
12
13
  import { recentRequests } from "./privacy.js";
13
14
  import { cleanTags, numericId, type MemberRecord, type Stores } from "./storage.js";
14
15
 
@@ -193,6 +194,7 @@ export async function bulkResync(config: RedeemIDConfig, s: Stores): Promise<{ d
193
194
  if (was.join("\n") !== m.tags.join("\n")) changed++;
194
195
  await levelChange(config, was, m, "bulk resync");
195
196
  }
197
+ await record({ event: "staff.resync", label: "bulk", status: `${updated.length} read, ${changed} changed${missing.length ? `, ${missing.length} missing` : ""}`, origin: "staff" });
196
198
  return { done: updated.length, missing: missing.length, changed };
197
199
  }
198
200
 
@@ -201,13 +203,16 @@ export async function resyncOne(config: RedeemIDConfig, s: Stores, customerId: s
201
203
  const { updated } = await resync(config, s, [customerId], source);
202
204
  const m = updated[0] ?? null;
203
205
  if (m) await levelChange(config, before?.tags ?? null, m, source);
206
+ await record({ event: "staff.resync", label: source, status: m ? "ok" : "not in Shopify", level: m ? "ok" : "warn", origin: source.startsWith("agent") ? "agent" : "staff", customerId });
204
207
  return m;
205
208
  }
206
209
 
207
210
  export async function tagEdit(config: RedeemIDConfig, s: Stores, customerId: string, change: "add" | "remove", raw: unknown): Promise<MemberRecord | null> {
208
211
  const id = numericId(customerId);
209
212
  if (!id) return null;
210
- await editTags(config, id, change, cleanTags(raw));
213
+ const tags = cleanTags(raw);
214
+ await editTags(config, id, change, tags);
215
+ await record({ event: "staff.tags", label: change === "add" ? "added" : "removed", status: "ok", origin: "staff", customerId: id, note: tags.join(", ").slice(0, 200) });
211
216
  return resyncOne(config, s, id, change === "add" ? "staff added tags" : "staff removed tags");
212
217
  }
213
218
 
@@ -225,7 +230,7 @@ export async function webhooksSection(config: RedeemIDConfig, uri: string): Prom
225
230
  });
226
231
  const missing = rows.filter((r) => r.state !== "Registered").length;
227
232
  return [
228
- { type: "fields", fields: [{ label: "Admin API", value: `Connected (${scope || "no scopes reported"})` }, { label: "Webhook address", value: uri }] },
233
+ { type: "section", text: `Admin API: connected (${scope || "no scopes reported"}).` },
229
234
  { type: "table", page_action_id: "webhooks", columns: [{ key: "topic", label: "Topic" }, { key: "state", label: "Sends here", format: "badge" }], rows },
230
235
  missing
231
236
  ? { type: "actions", elements: [{ type: "button", action_id: "webhooks_register", label: `Register ${missing} missing`, style: "primary", value: uri }] }
@@ -236,24 +241,6 @@ export async function webhooksSection(config: RedeemIDConfig, uri: string): Prom
236
241
  }
237
242
  }
238
243
 
239
- export async function deliveriesSection(s: Stores): Promise<unknown[]> {
240
- const { items } = await s.deliveries.query({ orderBy: { receivedAt: "desc" }, limit: 20 });
241
- return items.length
242
- ? [
243
- {
244
- type: "table",
245
- page_action_id: "deliveries",
246
- columns: [
247
- { key: "topic", label: "Topic", format: "code" },
248
- { key: "at", label: "Received", format: "relative_time" },
249
- { key: "id", label: "Delivery" },
250
- ],
251
- rows: items.map((r) => ({ topic: r.data.topic, at: r.data.receivedAt, id: r.id })),
252
- },
253
- ]
254
- : [{ type: "context", text: "No webhooks received yet." }];
255
- }
256
-
257
244
  export { registerWebhooks };
258
245
 
259
246
  /** "category:pro, tag:weekend" → { category: ["pro"], tag: ["weekend"] } */
@@ -269,6 +256,7 @@ export function parseTerms(raw: string): Terms {
269
256
  /** The access test: what an entry needs, and whether a reader with these tags passes. */
270
257
  export async function accessTest(config: RedeemIDConfig, values: Record<string, unknown> = {}): Promise<Blocks> {
271
258
  const collection = String(values.collection ?? "").trim();
259
+ const entryId = String(values.entry ?? "").trim();
272
260
  const termsRaw = String(values.terms ?? "").trim();
273
261
  const tags = cleanTags(String(values.tags ?? ""));
274
262
  const signedIn = values.signed_in !== false;
@@ -278,6 +266,7 @@ export async function accessTest(config: RedeemIDConfig, values: Record<string,
278
266
  block_id: "access_test",
279
267
  fields: [
280
268
  { type: "text_input", action_id: "collection", label: "Collection", placeholder: "posts", initial_value: collection },
269
+ { type: "text_input", action_id: "entry", label: "Entry id or slug (optional: applies its own rule)", initial_value: entryId },
281
270
  { type: "text_input", action_id: "terms", label: "Terms (taxonomy:slug, comma separated)", placeholder: "category:investigations, tag:free-to-read", initial_value: termsRaw },
282
271
  { type: "text_input", action_id: "tags", label: "Reader's Shopify tags (comma separated)", placeholder: "gold-member", initial_value: tags.join(", ") },
283
272
  { type: "toggle", action_id: "signed_in", label: "Signed in", initial_value: signedIn },
@@ -292,7 +281,7 @@ export async function accessTest(config: RedeemIDConfig, values: Record<string,
292
281
  if (collection) {
293
282
  const terms = parseTerms(termsRaw);
294
283
  const tree = await termTree(config.rules, collection, terms);
295
- const required = requirement(config.rules, collection, terms, { combine: config.combine, tree });
284
+ const required = applyEntryRule(requirement(config.rules, collection, terms, { combine: config.combine, tree }), entryRuleFor(config, collection, entryId));
296
285
  const held = levelsFor(tags, levels);
297
286
  const ok = required === null || (signedIn && passes(held, required));
298
287
  out.push(
package/src/storage.ts CHANGED
@@ -11,6 +11,8 @@ import type { StorageCollection } from "emdash";
11
11
  import { getAuthProviderStorage } from "emdash/api/route-utils";
12
12
 
13
13
  import { PROVIDER_ID } from "./config.js";
14
+ import type { AccessRecord } from "./settings.js";
15
+ import type { LogEntry } from "./log.js";
14
16
 
15
17
  export interface MemberRecord {
16
18
  /** Numeric Shopify customer id, also the record id. */
@@ -77,6 +79,10 @@ export function stampTags(m: MemberRecord, now = new Date().toISOString()): Memb
77
79
  export interface DeliveryRecord {
78
80
  topic: string;
79
81
  receivedAt: string;
82
+ /** What became of it: processed, skipped, stale, ignored, or pending while it runs. */
83
+ outcome?: string;
84
+ /** The Shopify customer it was about, when it named one. */
85
+ customerId?: string | null;
80
86
  }
81
87
 
82
88
  /** One privacy request: an export or erase, and where it came from. Names no one but the customer id. */
@@ -97,6 +103,8 @@ export const STORAGE_CONFIG = {
97
103
  deliveries: { indexes: ["receivedAt"] },
98
104
  meter: { indexes: ["userId"] },
99
105
  requests: { indexes: ["at", "customerId", "status"] },
106
+ access: { indexes: ["kind"] },
107
+ log: { indexes: ["at", "event", "level"] },
100
108
  };
101
109
 
102
110
  export interface Stores {
@@ -104,6 +112,10 @@ export interface Stores {
104
112
  deliveries: StorageCollection<DeliveryRecord>;
105
113
  meter: StorageCollection<MeterRecord>;
106
114
  requests: StorageCollection<PrivacyRequest>;
115
+ /** Access settings saved in the admin and editor (see settings.ts). */
116
+ access: StorageCollection<AccessRecord>;
117
+ /** App events (see log.ts). */
118
+ log: StorageCollection<LogEntry>;
107
119
  }
108
120
 
109
121
  export function stores(db: unknown): Stores {