@redeemid/emdash 0.0.28 → 0.0.29

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.
@@ -56,7 +56,7 @@ Under `/_emdash/api/auth/redeemid/`. Same-origin only: EmDash checks `Origin` on
56
56
  - `api/state` (GET): the visitor's own state.
57
57
 
58
58
  ```json
59
- { "apiVersion": "1.5.0", "version": "0.0.28", "signedIn": true, "linked": true,
59
+ { "apiVersion": "1.5.0", "version": "0.0.29", "signedIn": true, "linked": true,
60
60
  "levels": ["gold"], "features": ["ad-free"], "cart": { "count": 2 },
61
61
  "meter": { "used": 1, "limit": 3, "period": "month" } }
62
62
  ```
@@ -27,11 +27,11 @@ The store must use the new customer accounts (Settings → Customer accounts). C
27
27
 
28
28
  RedeemID keeps tags and status current from webhooks sent by a Shopify app on the store (for example a Dev Dashboard app). Shopify signs every webhook with that app's **client secret**, which is the site's webhook secret.
29
29
 
30
- Give the app the `read_customers` and `write_customers` Admin API scopes, plus `read_customer_merge` for `customers/merge`, and set its **client ID** on the site as well. Then the Setup page (or **Webhooks → Setup**) registers these topics at `https://<site>/_emdash/api/auth/redeemid/webhook` with one button:
30
+ Give the app the `read_customers` and `write_customers` Admin API scopes (`write_customers` alone is enough, since a write scope includes its read scope), plus `read_customer_merge` for `customers/merge`, and set its **client ID** on the site as well. Then the Setup page (or **Webhooks → Setup**) registers these topics at `https://<site>/_emdash/api/auth/redeemid/webhook` with one button:
31
31
 
32
32
  `customers/create`, `customers/update`, `customers/delete`, `customers/disable`, `customers/enable`, `customers/merge`, `customer.tags_added`, `customer.tags_removed`
33
33
 
34
- Without the client ID, subscribe them to that URL yourself. If the app already sends webhooks to another site, add this URL as extra subscriptions.
34
+ Without the client ID, subscribe them to that URL yourself. If the app already sends webhooks to another site, add this URL as extra subscriptions. Subscriptions set in the app's own configuration (its Dev Dashboard version or `shopify.app.toml`) don't show in the Admin API, so Setup counts each of those topics as sent once a delivery of it arrives.
35
35
 
36
36
  The client ID and those scopes also run the staff tools (member resync and tag edits written to Shopify), member preferences, and the reads that keep tags in order (see [staff tools](staff.md#staff-tools)). RedeemID uses the client credentials grant, which works when the app and the store are in the same Shopify organization. For the **Also request erasure in Shopify** button on a member's page, add the optional `write_customer_data_erasure` scope.
37
37
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redeemid/emdash",
3
- "version": "0.0.28",
3
+ "version": "0.0.29",
4
4
  "description": "Shopify commerce for EmDash sites: storefront, cart, checkout, customer accounts, memberships and paywalls. Shopify runs commerce; EmDash runs content.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/admin-api.ts CHANGED
@@ -28,6 +28,11 @@ export const WEBHOOK_TOPICS = [
28
28
  "CUSTOMER_TAGS_REMOVED",
29
29
  ] as const;
30
30
 
31
+ /** WEBHOOK_TOPICS name → the topic Shopify shows: CUSTOMER_TAGS_ADDED → customer.tags_added. */
32
+ export function topicName(t: string): string {
33
+ return t.toLowerCase().replace("_", "/").replace("customer/tags", "customer.tags");
34
+ }
35
+
31
36
  const MARGIN_MS = 5 * 60 * 1000;
32
37
  const tokens = new Map<string, { token: string; until: number; scope: string }>();
33
38
 
@@ -64,6 +69,17 @@ async function token(config: RedeemIDConfig, fetcher: typeof fetch): Promise<{ t
64
69
  return value;
65
70
  }
66
71
 
72
+ /**
73
+ * The scopes a token's `scope` string grants. Shopify lists only write_x
74
+ * when an app holds both, because a write scope includes its read scope.
75
+ */
76
+ export function grantedScopes(scope: string): string[] {
77
+ const listed = scope.split(",").map((t) => t.trim()).filter(Boolean);
78
+ const out = new Set(listed);
79
+ for (const t of listed) if (t.startsWith("write_")) out.add(`read_${t.slice("write_".length)}`);
80
+ return [...out];
81
+ }
82
+
67
83
  /** The scopes the Admin API token carries, or an error message. */
68
84
  export async function adminScopes(config: RedeemIDConfig, fetcher: typeof fetch = fetch): Promise<string> {
69
85
  return (await token(config, fetcher)).scope;
package/src/monitor.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  */
14
14
 
15
15
  import { levelsFor, membership } from "./access.js";
16
- import { adminConfigured, registerWebhooks } from "./admin-api.js";
16
+ import { adminConfigured, registerWebhooks, topicName } from "./admin-api.js";
17
17
  import { env, ENV, type RedeemIDConfig } from "./config.js";
18
18
  import { LOG_DAYS, LOG_EVENTS, prune, record, type LogEntry } from "./log.js";
19
19
  import { RETRY_DELAYS_MS, type FailedEvent } from "./events.js";
@@ -193,10 +193,7 @@ export async function tidy(s: Stores): Promise<void> {
193
193
  await prune(s.requests, "at", REQUEST_DAYS, { status: "done" }).catch(() => 0);
194
194
  }
195
195
 
196
- /** WEBHOOK_TOPICS name → the topic Shopify shows: CUSTOMER_TAGS_ADDED → customer.tags_added. */
197
- export function topicName(t: string): string {
198
- return t.toLowerCase().replace("_", "/").replace("customer/tags", "customer.tags");
199
- }
196
+ export { topicName };
200
197
 
201
198
  // ---------------------------------------------------------------- Webhooks page
202
199
 
package/src/setup.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  * setupSteps() and setupPage() are pure.
17
17
  */
18
18
 
19
- import { adminConfigured, adminScopes, webhookSubscriptions, WEBHOOK_TOPICS } from "./admin-api.js";
19
+ import { adminConfigured, adminScopes, grantedScopes, topicName, webhookSubscriptions, WEBHOOK_TOPICS } from "./admin-api.js";
20
20
  import { env, ENV, levelTags, storeDomain, type RedeemIDConfig } from "./config.js";
21
21
  import { discover } from "./shopify.js";
22
22
  import type { Stores } from "./storage.js";
@@ -43,7 +43,12 @@ export interface SetupFacts {
43
43
  /** null without the app; "ok", or why the Admin API token was refused. */
44
44
  admin: string | null;
45
45
  scopes: string[];
46
- /** Topics not yet sent to this site, or null when that can't be checked. */
46
+ /**
47
+ * Topics not yet sent to this site, or null when that can't be checked.
48
+ * A topic counts as sent when the Admin API lists a subscription for it
49
+ * here, or when a delivery of it has arrived: subscriptions set in the
50
+ * app's own configuration don't show in the Admin API.
51
+ */
47
52
  webhooksMissing: string[] | null;
48
53
  levels: Array<{ name: string; tags: string[] }>;
49
54
  /** Members who have signed in on this site. */
@@ -74,10 +79,10 @@ export async function setupFacts(config: RedeemIDConfig, s: Stores, origin: stri
74
79
  let webhooksMissing: string[] | null = null;
75
80
  if (domain && adminConfigured()) {
76
81
  try {
77
- scopes = (await adminScopes(config)).split(",").map((t) => t.trim()).filter(Boolean);
82
+ scopes = grantedScopes(await adminScopes(config));
78
83
  admin = "ok";
79
- const subs = await webhookSubscriptions(config);
80
- webhooksMissing = WEBHOOK_TOPICS.filter((t) => !subs.some((h) => h.topic === t && h.uri === `${origin}${WEBHOOK}`));
84
+ const [subs, heard] = await Promise.all([webhookSubscriptions(config), heardTopics(s)]);
85
+ webhooksMissing = WEBHOOK_TOPICS.filter((t) => !subs.some((h) => h.topic === t && h.uri === `${origin}${WEBHOOK}`) && !heard.has(topicName(t)));
81
86
  } catch (e) {
82
87
  admin = (e as Error).message || "failed";
83
88
  }
@@ -106,6 +111,16 @@ export async function setupFacts(config: RedeemIDConfig, s: Stores, origin: stri
106
111
  };
107
112
  }
108
113
 
114
+ /** Topics of the latest deliveries, as Shopify names them (customers/update). */
115
+ async function heardTopics(s: Stores): Promise<Set<string>> {
116
+ try {
117
+ const { items } = await s.deliveries.query({ orderBy: { receivedAt: "desc" }, limit: 200 });
118
+ return new Set(items.map((r) => r.data.topic.trim().toLowerCase()));
119
+ } catch {
120
+ return new Set();
121
+ }
122
+ }
123
+
109
124
  const missingScopes = (f: SetupFacts) => SETUP_SCOPES.filter((sc) => !f.scopes.includes(sc));
110
125
 
111
126
  /** The steps and where each one stands. Pure. */
@@ -201,7 +216,11 @@ function stepBlocks(step: Step, f: SetupFacts): unknown[] {
201
216
  const missing = f.webhooksMissing ?? [];
202
217
  return [
203
218
  ...out,
204
- { type: "section", text: `${missing.length} of ${WEBHOOK_TOPICS.length} topics aren't sent to this site yet. RedeemID can register them through the app.` },
219
+ { type: "section", text: `${missing.length} of ${WEBHOOK_TOPICS.length} topics aren't sent to this site yet: ${missing.map(topicName).join(", ")}. RedeemID can register them through the app.` },
220
+ {
221
+ type: "context",
222
+ text: "Webhooks set in the app's own configuration (Dev Dashboard version or shopify.app.toml) don't show in the Admin API. Each of those topics counts here once Shopify has sent one, so registering them again isn't needed.",
223
+ },
205
224
  { type: "actions", elements: [{ type: "button", action_id: "setup_register", label: `Register ${missing.length} webhooks`, style: "primary" }] },
206
225
  {
207
226
  type: "context",
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.0.28";
1
+ export const VERSION = "0.0.29";