@redeemid/emdash 0.0.19 → 0.0.21

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/src/access.ts CHANGED
@@ -11,9 +11,12 @@
11
11
  */
12
12
 
13
13
  import type { LevelConfig, RedeemIDConfig, RuleConfig } from "./config.js";
14
- import type { EntryRule } from "./settings.js";
14
+ import { UNAVAILABLE, type EntryRule } from "./settings.js";
15
15
  import { siteConfig } from "./site-config.js";
16
- import { memberForUser, stores, type MemberRecord } from "./storage.js";
16
+ import { memberForUser, stores, type MemberRecord, type MeterRecord } from "./storage.js";
17
+
18
+ /** The site's config for a request: the code's config with the admin's saved settings laid over it. */
19
+ export { siteConfig };
17
20
 
18
21
  /** The parts of Astro's global that access checks read. */
19
22
  export interface AstroLike {
@@ -336,7 +339,7 @@ export function passes(held: string[], required: string[][]): boolean {
336
339
  */
337
340
  export function describe(required: Requirement, levels: LevelConfig[] = [], open = "Free", signedIn = "Signed in"): string {
338
341
  if (required === null) return open;
339
- const name = (slug: string) => levels.find((l) => l.slug === slug)?.name ?? slug;
342
+ const name = (slug: string) => (slug === UNAVAILABLE ? "Members" : (levels.find((l) => l.slug === slug)?.name ?? slug));
340
343
  const parts = reduce(required).filter((c) => c.length > 0).map((c) => c.map(name).join(" or "));
341
344
  return parts.length ? parts.join(" + ") : signedIn;
342
345
  }
@@ -384,6 +387,8 @@ export async function entryRequirement(
384
387
  id?: string | null,
385
388
  ): Promise<Requirement> {
386
389
  if (!config) return null;
390
+ // Saved settings can't be read: nobody passes this.
391
+ if (config.unavailable) return [[UNAVAILABLE]];
387
392
  const own = entryRuleFor(config, collection, id);
388
393
  if (own && (own.mode === "open" || own.mode === "levels") && !own.openUntil && !own.lockedUntil) return applyEntryRule(null, own);
389
394
  const tree = await termTree(config.rules, collection, terms);
@@ -445,7 +450,7 @@ export async function canRead(
445
450
  const v = await viewer(astro);
446
451
 
447
452
  if (required === null) return { allowed: true, reason: "open", required: [], viewer: v };
448
- if (!config) return { allowed: false, reason: "unconfigured", required, viewer: v };
453
+ if (!config || config.unavailable) return { allowed: false, reason: "unconfigured", required, viewer: v };
449
454
  if (!v.signedIn) return { allowed: false, reason: "sign_in", required, viewer: v };
450
455
  if (v.member?.status === "disabled") return { allowed: false, reason: "disabled", required, viewer: v };
451
456
  if (!v.member) return { allowed: false, reason: "upgrade", required, viewer: v };
@@ -529,12 +534,31 @@ export async function readEntry(
529
534
  const key = `${user.id}:${periodKey(new Date(), period)}`;
530
535
  try {
531
536
  const s = stores(db);
532
- const record = (await s.meter.get(key)) ?? { userId: user.id, period: periodKey(new Date(), period), items: [] };
533
- const used = consume(record.items, `${entry.collection}:${entry.id}`, meter.limit);
534
537
  const info = { limit: meter.limit, period };
535
- if (!used.allowed) return { ...verdict, meterLeft: 0, meter: info };
536
- if (used.items !== record.items) {
537
- await s.meter.put(key, { ...record, items: used.items });
538
+ // Read, count and write with compare-and-set, so parallel requests
539
+ // can't each take the last free entry.
540
+ let record: MeterRecord | null = null;
541
+ let used: ReturnType<typeof consume> | null = null;
542
+ let wrote = false;
543
+ for (let attempt = 0; attempt < 3 && !used; attempt++) {
544
+ const current = await s.meter.getVersioned(key);
545
+ const r = current?.value ?? { userId: user.id, period: periodKey(new Date(), period), items: [] };
546
+ const next = consume(r.items, `${entry.collection}:${entry.id}`, meter.limit);
547
+ if (!next.allowed || next.items === r.items) {
548
+ record = r;
549
+ used = next;
550
+ break;
551
+ }
552
+ const write = await s.meter.compareAndSet(key, current?.revision ?? null, { ...r, items: next.items });
553
+ if (write.applied) {
554
+ record = r;
555
+ used = next;
556
+ wrote = true;
557
+ }
558
+ }
559
+ // Lost every race: count it as used up rather than open one too many.
560
+ if (!used || !record || !used.allowed) return { ...verdict, meterLeft: 0, meter: info };
561
+ if (wrote) {
538
562
  const { emit } = await import("./events.js");
539
563
  const data = { userId: user.id, entry: `${entry.collection}:${entry.id}`, used: used.items.length, limit: meter.limit, period: record.period };
540
564
  const { record: log } = await import("./log.js");
package/src/admin-api.ts CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { env, ENV, shopOrigin, type RedeemIDConfig } from "./config.js";
13
13
  import { USER_AGENT } from "./shopify.js";
14
- import { cleanTags, emptyMember, stampTags, type MemberRecord, type Stores } from "./storage.js";
14
+ import { cleanTags, emptyMember, metaDate, stampTags, type MemberRecord, type MembershipDates, type Stores } from "./storage.js";
15
15
  import { timed } from "./log.js";
16
16
 
17
17
  export const ADMIN_API_VERSION = "2026-10";
@@ -100,15 +100,32 @@ export interface AdminCustomer {
100
100
  lastName: string | null;
101
101
  tags: string[];
102
102
  updatedAt: number | null;
103
+ emailMarketing: string | null;
104
+ dates: MembershipDates;
103
105
  }
104
106
 
107
+ /** The membership.* customer metafields read with each customer (see MemberRecord.dates). */
108
+ const DATE_FIELDS = `since: metafield(namespace: "membership", key: "member_since") { value } renews: metafield(namespace: "membership", key: "renews_on") { value } ends: metafield(namespace: "membership", key: "ends_on") { value } gift: metafield(namespace: "membership", key: "gift_until") { value }`;
109
+
105
110
  /** Up to 100 customers by numeric id. Customers Shopify no longer has are left out. */
106
111
  export async function customers(config: RedeemIDConfig, ids: string[], fetcher: typeof fetch = fetch): Promise<AdminCustomer[]> {
107
112
  if (ids.length === 0) return [];
108
- type Node = { id: string; firstName: string | null; lastName: string | null; tags: string[]; updatedAt: string; defaultEmailAddress: { emailAddress: string | null } | null } | null;
113
+ type Meta = { value: string | null } | null;
114
+ type Node = {
115
+ id: string;
116
+ firstName: string | null;
117
+ lastName: string | null;
118
+ tags: string[];
119
+ updatedAt: string;
120
+ defaultEmailAddress: { emailAddress: string | null; marketingState?: string | null } | null;
121
+ since?: Meta;
122
+ renews?: Meta;
123
+ ends?: Meta;
124
+ gift?: Meta;
125
+ } | null;
109
126
  const data = await admin<{ nodes: Node[] }>(
110
127
  config,
111
- `query RedeemIDCustomers($ids: [ID!]!) { nodes(ids: $ids) { ... on Customer { id firstName lastName tags updatedAt defaultEmailAddress { emailAddress } } } }`,
128
+ `query RedeemIDCustomers($ids: [ID!]!) { nodes(ids: $ids) { ... on Customer { id firstName lastName tags updatedAt defaultEmailAddress { emailAddress marketingState } ${DATE_FIELDS} } } }`,
112
129
  { ids: ids.slice(0, 100).map(gid) },
113
130
  fetcher,
114
131
  );
@@ -121,11 +138,14 @@ export async function customers(config: RedeemIDConfig, ids: string[], fetcher:
121
138
  lastName: n.lastName || null,
122
139
  tags: cleanTags(n.tags),
123
140
  updatedAt: Number.isFinite(Date.parse(n.updatedAt)) ? Date.parse(n.updatedAt) : null,
141
+ emailMarketing: n.defaultEmailAddress?.marketingState ?? null,
142
+ dates: { since: metaDate(n.since?.value), renews: metaDate(n.renews?.value), ends: metaDate(n.ends?.value), gift: metaDate(n.gift?.value) },
124
143
  }));
125
144
  }
126
145
 
127
146
  /**
128
- * Read members from Shopify again and update the mirror: profile and tags.
147
+ * Read members from Shopify again and update the mirror: profile, tags,
148
+ * email marketing state and the membership dates.
129
149
  * Status stays with the enable/disable webhooks. Members Shopify no longer
130
150
  * has are reported in `missing` and left alone.
131
151
  */
@@ -142,6 +162,8 @@ export async function resync(config: RedeemIDConfig, s: Stores, ids: string[], s
142
162
  m.lastName = c.lastName ?? m.lastName;
143
163
  m.tags = c.tags;
144
164
  m.profileUpdatedAt = c.updatedAt ?? m.profileUpdatedAt;
165
+ m.emailMarketing = c.emailMarketing ?? m.emailMarketing ?? null;
166
+ m.dates = c.dates;
145
167
  m.syncedAt = new Date().toISOString();
146
168
  m.source = source;
147
169
  await s.members.put(c.customerId, stampTags(m));
@@ -3,4 +3,4 @@
3
3
  * when something is added, the major part when something stable changes or
4
4
  * goes. Separate from the package version.
5
5
  */
6
- export const API_VERSION = "1.0.0";
6
+ export const API_VERSION = "1.1.0";
package/src/cart-ops.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  import { readCart, saveCart } from "./cart.js";
12
12
  import type { RedeemIDConfig } from "./config.js";
13
13
  import { memberForUser, stores } from "./storage.js";
14
+ import { sessionUser } from "./routes/shared.js";
14
15
  import { addToCart, cartDetails, product, realOptions, removeLines, setDiscountCodes, storeContext, updateLines, variantFor, type Cart, type CartDetails, type Product, type Variant } from "./storefront.js";
15
16
 
16
17
  export const MAX_QUANTITY = 1000;
@@ -100,8 +101,8 @@ export async function editCart(input: CartInput, ctx: CartContext): Promise<Cart
100
101
  const variant = p ? pickVariant(p, input) : undefined;
101
102
  const plan = p ? pickSellingPlan(p, input.sellingPlan) : undefined;
102
103
  if (!p || !variant?.availableForSale || plan === undefined) return { notice: "plan_unavailable", cart: null };
103
- const user = (await session?.get("user")) as { id?: string } | undefined;
104
- const member = user?.id && ctx.db ? await memberForUser(stores(ctx.db), user.id) : null;
104
+ const user = await sessionUser(session, ctx.db);
105
+ const member = user && ctx.db ? await memberForUser(stores(ctx.db), user.id) : null;
105
106
  const current = await readCart(session);
106
107
  const cart = await addToCart(
107
108
  config,
package/src/checkout.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * A signed-in member's cart gets their Customer Account API access token
5
5
  * (cartBuyerIdentityUpdate), so checkout opens signed in and the order lands
6
6
  * on their Shopify customer, where the shop's Flow or apps tag it. The token
7
- * lives only in the server-side session and is renewed with its refresh
7
+ * lives only in the server-side session, sealed (token-vault.ts), and is renewed with its refresh
8
8
  * token when it's about to expire. Anything that fails falls back to a guest
9
9
  * checkout: buying never breaks because sign-in couldn't be carried over.
10
10
  */
@@ -13,6 +13,7 @@ import { env, ENV, type RedeemIDConfig } from "./config.js";
13
13
  import { SESSION_CUSTOMER_TOKEN, callbackUrl, type CustomerToken } from "./routes/shared.js";
14
14
  import { discover, refreshTokens } from "./shopify.js";
15
15
  import { signInCart } from "./storefront.js";
16
+ import { seal, unseal } from "./token-vault.js";
16
17
 
17
18
  type Session = { get(key: string): unknown; set(key: string, value: unknown): void; delete?(key: string): void } | undefined;
18
19
 
@@ -23,9 +24,9 @@ const RENEW_MS = 2 * 60 * 1000;
23
24
  export async function customerToken(
24
25
  config: RedeemIDConfig,
25
26
  session: Session,
26
- opts: { url: URL; emdashConfig?: unknown; fetcher?: typeof fetch; now?: number },
27
+ opts: { url: URL; emdashConfig?: unknown; fetcher?: typeof fetch; now?: number; db?: unknown },
27
28
  ): Promise<string | null> {
28
- const t = (await session?.get(SESSION_CUSTOMER_TOKEN)) as CustomerToken | undefined;
29
+ const t = await unseal(await session?.get(SESSION_CUSTOMER_TOKEN), opts.db);
29
30
  if (!t?.accessToken) return null;
30
31
  const now = opts.now ?? Date.now();
31
32
  if (!t.expiresAt || t.expiresAt - now > RENEW_MS) return t.accessToken;
@@ -45,7 +46,7 @@ export async function customerToken(
45
46
  { clientId, clientSecret: env(ENV.clientSecret), origin: new URL(redirect).origin, refreshToken: t.refreshToken },
46
47
  opts.fetcher,
47
48
  );
48
- session?.set(SESSION_CUSTOMER_TOKEN, next satisfies CustomerToken);
49
+ session?.set(SESSION_CUSTOMER_TOKEN, await seal(next satisfies CustomerToken, opts.db));
49
50
  return next.accessToken;
50
51
  } catch {
51
52
  forget();
@@ -8,7 +8,7 @@
8
8
  * and `joinUrl` come from the level's config, the join link from `join`.
9
9
  * Anything in the default slot is shown above the built-in text.
10
10
  */
11
- import { passes, signInUrl, type Reason } from "../access.js";
11
+ import { membership, passes, refreshUrl, signInUrl, viewer, type Reason } from "../access.js";
12
12
  import type { SoldItem } from "../blocks-names.js";
13
13
  import { plan as loadPlan } from "../storefront.js";
14
14
  import { siteConfig } from "../site-config.js";
@@ -39,6 +39,9 @@ const config = await siteConfig(Astro.locals);
39
39
  const missing = required.find((clause) => clause.length > 0 && !passes(held, [clause]));
40
40
  const level = missing ? config?.levels?.find((l) => l.slug === missing[0]) : undefined;
41
41
  const joinHref = level?.joinUrl ?? config?.join?.href;
42
+ // A member who held the level before is offered it back ("Renew"), not "Join" as if new.
43
+ const member = reason === "upgrade" && level ? (await viewer(Astro)).member : null;
44
+ const lapsed = Boolean(member && member.status !== "disabled" && membership(member, config?.levels).past.some((p) => p.slug === level?.slug));
42
45
  const joinLabel = config?.join?.label ?? text(config, "Become a member");
43
46
  const week = meterPeriod === "week";
44
47
  // "Buy just this": the item's product and price, read from Shopify.
@@ -73,7 +76,14 @@ const here = Astro.url.pathname + Astro.url.search;
73
76
  {opens ? textParts(config, "This opens for you on {date}.", { date: <time datetime={opensAt?.toISOString()}>{opens}</time> }) : text(config, "This opens for you after you've been a member a little longer.")}
74
77
  </p>
75
78
  )}
76
- {reason === "upgrade" && (
79
+ {reason === "upgrade" && lapsed && level && (
80
+ <p class="redeemid-locked__renew">
81
+ {text(config, "Your {level} membership has ended.", { level: level.name })}
82
+ {joinHref && <> <a href={joinHref}>{text(config, "Renew {level}", { level: level.name })}</a></>}
83
+ {" "}{textParts(config, "Already renewed? {refresh}", { refresh: <a href={refreshUrl(here)}>{text(config, "Refresh")}</a> })}
84
+ </p>
85
+ )}
86
+ {reason === "upgrade" && !lapsed && (
77
87
  <p>
78
88
  {level ? text(config, "This is for {level} members.", { level: level.name }) : text(config, "Your membership doesn't include this yet.")}
79
89
  {joinHref && <> <a href={joinHref}>{joinLabel}</a></>}
@@ -1,7 +1,9 @@
1
1
  ---
2
2
  /**
3
3
  * The member's own account card, for an account page: their levels with
4
- * when each started, levels they held before, the meter, and links to the
4
+ * when each started, when the membership renews or ends and any gift
5
+ * membership (from the shop's membership.* customer metafields), levels
6
+ * they held before, the meter, and links to the
5
7
  * store account, Refresh membership and Sign out. Visitors get a sign-in link.
6
8
  *
7
9
  * <MemberCard />
@@ -35,6 +37,11 @@ const date = (iso: string | null) => {
35
37
  return Number.isNaN(t) ? null : new Intl.DateTimeFormat("en-US", { dateStyle: "long", timeZone: "UTC" }).format(new Date(t));
36
38
  };
37
39
  const name = [m?.firstName, m?.lastName].filter(Boolean).join(" ");
40
+ // Dates the shop keeps in Shopify (Flow or a subscription app). Display only.
41
+ const dates = m && !disabled ? (m.dates ?? {}) : {};
42
+ const today = new Date().toISOString().slice(0, 10);
43
+ const gift = dates.gift && dates.gift >= today ? dates.gift : null;
44
+ const day = (ymd: string | null | undefined) => (ymd ? date(`${ymd}T12:00:00Z`) : null);
38
45
  const join = config?.join;
39
46
  ---
40
47
 
@@ -55,7 +62,7 @@ const join = config?.join;
55
62
  {current.map((l) => (
56
63
  <li data-redeemid-level={l.slug}>
57
64
  <strong>{l.name}</strong>
58
- {date(l.since) && <> {textParts(config, "since {date}", { date: <time datetime={l.since ?? undefined}>{date(l.since)}</time> })}</>}
65
+ {(dates.since ? day(dates.since) : date(l.since)) && <> {textParts(config, "since {date}", { date: <time datetime={dates.since ?? l.since ?? undefined}>{dates.since ? day(dates.since) : date(l.since)}</time> })}</>}
59
66
  </li>
60
67
  ))}
61
68
  </ul>
@@ -64,6 +71,13 @@ const join = config?.join;
64
71
  {text(config, "You don't have a membership yet.")}{join && <> <a href={join.href}>{join.label ?? text(config, "Become a member")}</a></>}
65
72
  </p>
66
73
  )}
74
+ {current.length > 0 && (dates.renews || dates.ends || gift) && (
75
+ <ul class="redeemid-member__dates">
76
+ {dates.renews && <li data-redeemid-date="renews">{textParts(config, "Renews on {date}", { date: <time datetime={dates.renews}>{day(dates.renews)}</time> })}</li>}
77
+ {dates.ends && <li data-redeemid-date="ends">{textParts(config, "Ends on {date}", { date: <time datetime={dates.ends}>{day(dates.ends)}</time> })}</li>}
78
+ {gift && <li data-redeemid-date="gift">{textParts(config, "Gift membership until {date}", { date: <time datetime={gift}>{day(gift)}</time> })}</li>}
79
+ </ul>
80
+ )}
67
81
  {features.length > 0 && (
68
82
  <p class="redeemid-member__features">
69
83
  {text(config, "Includes: {features}", { features: features.map((f) => f.name).join(", ") })}
@@ -7,9 +7,10 @@
7
7
  * <Plan handle="gold-membership" levels={["gold"]} />
8
8
  *
9
9
  * With `levels`, members who hold one of them see "You're on this plan"
10
- * in place of the button.
10
+ * in place of the button, and a member who held one before (Shopify removed
11
+ * its tag) sees "Renew" instead of "Join".
11
12
  */
12
- import { viewer } from "../access.js";
13
+ import { membership, viewer } from "../access.js";
13
14
  import { siteConfig } from "../site-config.js";
14
15
  import { plan as loadPlan, type Plan } from "../storefront.js";
15
16
  import { text } from "../text.js";
@@ -23,10 +24,12 @@ interface Props {
23
24
  /** The levels this plan gives. Members holding one see `heldLabel`, not the button. */
24
25
  levels?: string[];
25
26
  heldLabel?: string;
27
+ /** Button text for a member whose plan lapsed. */
28
+ renewLabel?: string;
26
29
  }
27
30
 
28
31
  const config = await siteConfig(Astro.locals);
29
- const { handle, label = text(config, "Join"), title, levels = [], heldLabel = text(config, "You're on this plan.") } = Astro.props;
32
+ const { handle, label = text(config, "Join"), title, levels = [], heldLabel = text(config, "You're on this plan."), renewLabel = text(config, "Renew") } = Astro.props;
30
33
  let p: Plan | null = null;
31
34
  if (config) {
32
35
  try {
@@ -39,11 +42,14 @@ const price = p?.price
39
42
  ? new Intl.NumberFormat("en-US", { style: "currency", currency: p.price.currencyCode }).format(Number(p.price.amount))
40
43
  : null;
41
44
  const here = Astro.url.pathname + Astro.url.search;
42
- const held = levels.length > 0 && (await viewer(Astro)).levels.some((l) => levels.includes(l));
45
+ const v = levels.length > 0 ? await viewer(Astro) : null;
46
+ const held = Boolean(v?.levels.some((l) => levels.includes(l)));
47
+ // Lapsed: held one of these levels before. Offered their plan back, not "Join" as if new.
48
+ const lapsed = Boolean(!held && v?.member && v.member.status !== "disabled" && membership(v.member, config?.levels).past.some((p) => levels.includes(p.slug)));
43
49
  ---
44
50
 
45
51
  {p ? (
46
- <div class:list={["redeemid-plan", { "is-held": held }]} data-redeemid-plan={p.handle}>
52
+ <div class:list={["redeemid-plan", { "is-held": held, "is-lapsed": lapsed }]} data-redeemid-plan={p.handle} data-redeemid-state={held ? "held" : lapsed ? "renew" : "join"}>
47
53
  <h2 class="redeemid-plan__title">{title ?? p.title}</h2>
48
54
  {price && <p class="redeemid-plan__price">{price}</p>}
49
55
  {p.description && <p class="redeemid-plan__description">{p.description}</p>}
@@ -53,7 +59,7 @@ const held = levels.length > 0 && (await viewer(Astro)).levels.some((l) => level
53
59
  <form method="post" action="/_emdash/api/auth/redeemid/buy">
54
60
  <input type="hidden" name="plan" value={p.handle} />
55
61
  <input type="hidden" name="return" value={here} />
56
- <button type="submit">{label}</button>
62
+ <button type="submit">{lapsed ? renewLabel : label}</button>
57
63
  </form>
58
64
  ) : (
59
65
  <p class="redeemid-plan__unavailable">{text(config, "Not available right now.")}</p>
package/src/config.ts CHANGED
@@ -187,6 +187,12 @@ export interface RedeemIDConfig {
187
187
  * in the editor's Access panel rather than here.
188
188
  */
189
189
  entries?: Record<string, EntryRule>;
190
+ /**
191
+ * Set by RedeemID, never in astro.config.mjs: the settings saved in the
192
+ * admin couldn't be read and none were read before, so every entry is
193
+ * locked until they can be (see lockdown in settings.ts).
194
+ */
195
+ unavailable?: boolean;
190
196
  /**
191
197
  * Only for local tests: lets `shop` be a full http:// origin of a fake store.
192
198
  * Never set this on a live site.
package/src/contract.ts CHANGED
@@ -16,7 +16,7 @@ export { API_VERSION } from "./api-version.js";
16
16
  /** Stable exports, by import path. */
17
17
  export const MODULES = {
18
18
  "@redeemid/emdash": ["redeemid", "redeemidGuard", "redeemidPlugin"],
19
- "@redeemid/emdash/access": ["viewer", "canRead", "readEntry", "entryRequirement", "meterUsage", "membership", "levelsFor", "signInUrl", "signOutUrl", "accountUrl", "cartUrl", "refreshUrl", "safeReturn"],
19
+ "@redeemid/emdash/access": ["siteConfig", "viewer", "canRead", "readEntry", "entryRequirement", "meterUsage", "membership", "levelsFor", "signInUrl", "signOutUrl", "accountUrl", "cartUrl", "refreshUrl", "safeReturn"],
20
20
  "@redeemid/emdash/features": ["hasFeature", "viewerFeatures", "featuresFor"],
21
21
  "@redeemid/emdash/preferences": ["readPreferences", "savePreferences", "preferenceFields"],
22
22
  "@redeemid/emdash/storefront": ["plan", "product", "collection", "money", "realOptions", "variantFor", "storeContext", "cartDetails", "addToCart", "updateLines", "removeLines", "setDiscountCodes"],
package/src/descriptor.ts CHANGED
@@ -15,6 +15,7 @@ export const PAGES = [
15
15
  { path: "/events", label: "App events", icon: "list" },
16
16
  { path: "/reports", label: "Reports", icon: "chart" },
17
17
  { path: "/status", label: "Status", icon: "pulse" },
18
+ { path: "/setup", label: "Setup", icon: "settings" },
18
19
  { path: "/test", label: "Access test", icon: "search" },
19
20
  { path: "/privacy", label: "Privacy", icon: "lock" },
20
21
  ];
package/src/events.ts CHANGED
@@ -8,7 +8,10 @@
8
8
  * X-RedeemID-Signature: sha256=<hex HMAC of the raw body>. Events:
9
9
  * member.signed_in, member.levels_changed, meter.used, meter.exhausted,
10
10
  * preferences.saved.
11
- * Sending never blocks or breaks the page: failures are logged and dropped.
11
+ * Sending never blocks or breaks the page: events go out after the response
12
+ * (waitUntil on Workers), are tried three times, and one that fails every
13
+ * try is kept under App events → Failed events, where staff can send it
14
+ * again or clear it.
12
15
  * No event carries an email address or a name; match people by `customerId`.
13
16
  */
14
17
 
@@ -19,6 +22,8 @@ import { record } from "./log.js";
19
22
  export type EventName = "member.signed_in" | "member.levels_changed" | "meter.used" | "meter.exhausted" | "preferences.saved" | "member.erased";
20
23
 
21
24
  const TIMEOUT_MS = 3000;
25
+ /** Waits before the second and third tries. */
26
+ export const RETRY_DELAYS_MS = [1000, 5000];
22
27
 
23
28
  export async function signBody(body: string, secret: string): Promise<string> {
24
29
  const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
@@ -26,14 +31,20 @@ export async function signBody(body: string, secret: string): Promise<string> {
26
31
  return [...mac].map((b) => b.toString(16).padStart(2, "0")).join("");
27
32
  }
28
33
 
29
- /** Send one event. Returns whether the receiver answered 2xx; never throws. */
30
- export async function emit(config: RedeemIDConfig | null, name: EventName, data: Record<string, unknown>, fetcher: typeof fetch = fetch): Promise<boolean> {
31
- const url = config?.events?.url;
32
- const secret = env(ENV.eventsSecret);
33
- if (!url || !secret) return false;
34
- if (config.events?.only && !config.events.only.includes(name)) return false;
35
- const id = crypto.randomUUID();
36
- const body = JSON.stringify({ id, event: name, at: new Date().toISOString(), data });
34
+ /** An event that failed every try, kept so staff can send it again or clear it (App events). */
35
+ export interface FailedEvent {
36
+ /** The delivery id, also the record id: a resend keeps it, so receivers can drop duplicates. */
37
+ id: string;
38
+ event: EventName;
39
+ /** The JSON body as first sent (ids and levels, never an email or a name). */
40
+ body: string;
41
+ at: string;
42
+ attempts: number;
43
+ error: string;
44
+ }
45
+
46
+ /** One POST. Resolves to null when the receiver answered 2xx, else what went wrong. */
47
+ async function post(url: string, name: string, id: string, body: string, secret: string, fetcher: typeof fetch): Promise<string | null> {
37
48
  try {
38
49
  const res = await fetcher(url, {
39
50
  method: "POST",
@@ -47,22 +58,117 @@ export async function emit(config: RedeemIDConfig | null, name: EventName, data:
47
58
  body,
48
59
  signal: AbortSignal.timeout(TIMEOUT_MS),
49
60
  });
50
- if (!res.ok) await record({ event: "events.failed", label: name, status: `HTTP ${res.status}`, level: "warn", origin: "site" });
51
- return res.ok;
61
+ return res.ok ? null : `HTTP ${res.status}`;
52
62
  } catch (e) {
53
- console.error("[redeemid] event not sent:", name, (e as Error).message);
54
- await record({ event: "events.failed", label: name, status: "error", level: "error", origin: "site", note: String((e as Error).message).slice(0, 200) });
55
- return false;
63
+ return String((e as Error).message || "error").slice(0, 200);
56
64
  }
57
65
  }
58
66
 
67
+ type Sleep = (ms: number) => Promise<void>;
68
+ const sleep: Sleep = (ms) => new Promise((r) => setTimeout(r, ms));
69
+
70
+ /**
71
+ * Try a delivery up to three times (now, after 1 s, after 5 s). One that
72
+ * fails every try goes to the failed list (App events) for staff. True when
73
+ * the receiver took it.
74
+ */
75
+ export async function deliver(
76
+ url: string,
77
+ secret: string,
78
+ name: EventName,
79
+ id: string,
80
+ body: string,
81
+ opts: { fetcher?: typeof fetch; wait?: Sleep; failed?: (f: FailedEvent) => Promise<void> } = {},
82
+ ): Promise<boolean> {
83
+ const fetcher = opts.fetcher ?? fetch;
84
+ let error: string | null = null;
85
+ let attempts = 0;
86
+ for (const delay of [0, ...RETRY_DELAYS_MS]) {
87
+ if (delay) await (opts.wait ?? sleep)(delay);
88
+ attempts++;
89
+ error = await post(url, name, id, body, secret, fetcher);
90
+ if (error === null) return true;
91
+ }
92
+ console.error("[redeemid] event not sent:", name, error);
93
+ const failed: FailedEvent = { id, event: name, body, at: new Date().toISOString(), attempts, error: error ?? "error" };
94
+ await record({ event: "events.failed", label: name, status: error ?? "error", level: "error", origin: "site", note: `${attempts} tries` });
95
+ await (opts.failed ?? keepFailed)(failed).catch(() => undefined);
96
+ return false;
97
+ }
98
+
99
+ async function keepFailed(f: FailedEvent): Promise<void> {
100
+ const { getDb } = await import("emdash/runtime");
101
+ const { stores } = await import("./storage.js");
102
+ await stores(await getDb()).failedEvents.put(f.id, f);
103
+ }
104
+
105
+ /** Deliveries started and not finished yet: tests and the CLI can wait for them. */
106
+ const pending = new Set<Promise<boolean>>();
107
+
108
+ /** Wait until every event started so far has been delivered or given up on. */
109
+ export async function flushEvents(): Promise<void> {
110
+ while (pending.size) await Promise.allSettled([...pending]);
111
+ }
112
+
113
+ /** EmDash's after(), loaded once; null outside an EmDash site. */
114
+ let afterFn: Promise<((fn: () => Promise<void>) => void) | null> | null = null;
115
+
116
+ /** Keep the work alive after the response (waitUntil on Workers); plain Node needs nothing. Never waits. */
117
+ function keepAlive(work: Promise<unknown>): void {
118
+ afterFn ??= import("emdash").then(
119
+ (m) => m.after,
120
+ () => null,
121
+ );
122
+ void afterFn.then((after) =>
123
+ after?.(async () => {
124
+ await work;
125
+ }),
126
+ );
127
+ }
128
+
129
+ /**
130
+ * Send one event, after the response: the page, sign-in or webhook never
131
+ * waits for the receiver. Returns at once; false when events are off.
132
+ */
133
+ export async function emit(
134
+ config: RedeemIDConfig | null,
135
+ name: EventName,
136
+ data: Record<string, unknown>,
137
+ opts: { fetcher?: typeof fetch; wait?: Sleep; failed?: (f: FailedEvent) => Promise<void> } = {},
138
+ ): Promise<boolean> {
139
+ const url = config?.events?.url;
140
+ const secret = env(ENV.eventsSecret);
141
+ if (!url || !secret) return false;
142
+ if (config.events?.only && !config.events.only.includes(name)) return false;
143
+ const id = crypto.randomUUID();
144
+ const body = JSON.stringify({ id, event: name, at: new Date().toISOString(), data });
145
+ const work = deliver(url, secret, name, id, body, opts).catch(() => false);
146
+ pending.add(work);
147
+ void work.finally(() => pending.delete(work));
148
+ keepAlive(work);
149
+ return true;
150
+ }
151
+
152
+ /** Send a failed event again (staff). On success it leaves the failed list. */
153
+ export async function resend(config: RedeemIDConfig, s: { failedEvents: { get(id: string): Promise<FailedEvent | null>; delete(id: string): Promise<boolean> } }, id: string, fetcher: typeof fetch = fetch): Promise<boolean> {
154
+ const f = await s.failedEvents.get(id);
155
+ const url = config.events?.url;
156
+ const secret = env(ENV.eventsSecret);
157
+ if (!f || !url || !secret) return false;
158
+ const error = await post(url, f.event, f.id, f.body, secret, fetcher);
159
+ await record({ event: "events.resent", label: f.event, status: error ?? "ok", level: error ? "warn" : "ok", origin: "staff" });
160
+ if (error) return false;
161
+ await s.failedEvents.delete(id);
162
+ return true;
163
+ }
164
+
59
165
  /** Send member.levels_changed when a mirror write changed the member's levels. */
60
166
  export async function levelChange(
61
167
  config: RedeemIDConfig | null,
62
168
  beforeTags: string[] | null,
63
169
  after: { customerId: string; tags: string[]; status: string },
64
170
  source: string,
65
- fetcher: typeof fetch = fetch,
171
+ fetcher?: typeof fetch,
66
172
  ): Promise<void> {
67
173
  if (!config?.events?.url) return;
68
174
  const { levelsFor } = await import("./access.js");
@@ -73,6 +179,6 @@ export async function levelChange(
73
179
  config,
74
180
  "member.levels_changed",
75
181
  { customerId: after.customerId, levels: now, added: now.filter((l) => !was.includes(l)), removed: was.filter((l) => !now.includes(l)), source },
76
- fetcher,
182
+ fetcher ? { fetcher } : {},
77
183
  );
78
184
  }
package/src/files.ts CHANGED
@@ -115,16 +115,22 @@ export function ruleFor(config: RedeemIDConfig | null, folder: string | null): F
115
115
 
116
116
  /** Decide one download. `key` is the storage key from the file URL. */
117
117
  export async function fileAccess(astro: AstroLike, config: RedeemIDConfig | null, key: string): Promise<FileVerdict> {
118
- if (!config?.files?.length) return { protected: false };
118
+ if (!config?.files?.length && !config?.unavailable) return { protected: false };
119
119
  const locals = astro.locals as { user?: { role?: number } | null; emdash?: { db?: unknown } | null };
120
120
  const db = locals.emdash?.db;
121
121
  // No database to look the file up in: the caller fails closed.
122
122
  if (!db) throw new Error("no database for the file check");
123
123
  const media = await mediaByKey(db, key);
124
+ const role = locals.user?.role ?? 0;
125
+ // Saved settings can't be read: any file in a folder might be protected.
126
+ if (config.unavailable) {
127
+ if (!media?.folder) return { protected: false };
128
+ if (role > SUBSCRIBER) return { protected: true, allowed: true };
129
+ return { protected: true, allowed: false, reason: (await viewer(astro)).signedIn ? "denied" : "sign_in" };
130
+ }
124
131
  const rule = ruleFor(config, media?.folder ?? null);
125
132
  if (!media || !rule) return { protected: false };
126
133
 
127
- const role = locals.user?.role ?? 0;
128
134
  if (role > SUBSCRIBER) return { protected: true, allowed: true };
129
135
  const v = await viewer(astro);
130
136
  if (!v.signedIn) return { protected: true, allowed: false, reason: "sign_in" };
package/src/guard.ts CHANGED
@@ -87,7 +87,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
87
87
  const config = await siteConfig(locals);
88
88
 
89
89
  // Protected files: checked for everyone, on every download.
90
- const keys = config?.files?.length ? keysFromUrl(url) : null;
90
+ const keys = config?.files?.length || config?.unavailable ? keysFromUrl(url) : null;
91
91
  if (keys) {
92
92
  let verdict;
93
93
  try {