@redeemid/emdash 0.0.16 → 0.0.17
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/README.md +416 -233
- package/docs/shopify-setup.md +1 -1
- package/package.json +1 -1
- package/src/access-admin.ts +754 -0
- package/src/access.ts +84 -18
- package/src/admin-api.ts +9 -2
- package/src/agents.ts +7 -3
- package/src/components/AccessBadge.astro +7 -5
- package/src/components/Account.astro +1 -1
- package/src/components/Cart.astro +1 -1
- package/src/components/CartView.astro +1 -1
- package/src/components/Collection.astro +1 -1
- package/src/components/Content.astro +7 -3
- package/src/components/Contribution.astro +1 -1
- package/src/components/Gate.astro +4 -2
- package/src/components/LockedPanel.astro +1 -1
- package/src/components/MemberCard.astro +1 -1
- package/src/components/Notices.astro +1 -1
- package/src/components/Plan.astro +1 -1
- package/src/components/Preferences.astro +1 -1
- package/src/components/Product.astro +1 -1
- package/src/components/YourData.astro +1 -1
- package/src/config.ts +17 -5
- package/src/descriptor.ts +7 -0
- package/src/events.ts +3 -0
- package/src/features.ts +1 -1
- package/src/files.ts +1 -1
- package/src/guard.ts +5 -3
- package/src/log.ts +96 -0
- package/src/monitor.ts +560 -0
- package/src/plugin.ts +202 -86
- package/src/routes/callback.ts +21 -4
- package/src/routes/preferences.ts +2 -2
- package/src/routes/privacy.ts +2 -2
- package/src/routes/webhook.ts +24 -11
- package/src/settings.ts +195 -0
- package/src/site-config.ts +38 -5
- package/src/staff.ts +12 -23
- package/src/storage.ts +12 -0
- package/src/version.ts +1 -1
- package/src/webhooks.ts +2 -1
package/src/settings.ts
ADDED
|
@@ -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/site-config.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
14
|
-
|
|
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
|
|
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
|
-
|
|
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: "
|
|
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 {
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.0.
|
|
1
|
+
export const VERSION = "0.0.17";
|
package/src/webhooks.ts
CHANGED
|
@@ -75,7 +75,8 @@ function str(raw: unknown): string | null {
|
|
|
75
75
|
return typeof raw === "string" && raw.trim() !== "" ? raw.trim() : null;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
/** The Shopify customer a payload is about, when it names one. */
|
|
79
|
+
export function customerOf(payload: Record<string, unknown>): string | null {
|
|
79
80
|
const nested = (payload.customer as { id?: unknown } | undefined)?.id;
|
|
80
81
|
for (const c of [payload.id, payload.customerId, payload.customer_id, nested]) {
|
|
81
82
|
const id = numericId(c);
|