@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/README.md +58 -36
- package/docs/developer-api.md +13 -10
- package/docs/shopify-flow.md +70 -0
- package/docs/shopify-setup.md +13 -7
- package/package.json +2 -1
- package/src/access-admin.ts +12 -6
- package/src/access.ts +33 -9
- package/src/admin-api.ts +26 -4
- package/src/api-version.ts +1 -1
- package/src/cart-ops.ts +3 -2
- package/src/checkout.ts +5 -4
- package/src/components/LockedPanel.astro +12 -2
- package/src/components/MemberCard.astro +16 -2
- package/src/components/Plan.astro +12 -6
- package/src/config.ts +6 -0
- package/src/contract.ts +1 -1
- package/src/descriptor.ts +1 -0
- package/src/events.ts +122 -16
- package/src/files.ts +8 -2
- package/src/guard.ts +1 -1
- package/src/index.ts +47 -1
- package/src/log.ts +5 -2
- package/src/monitor.ts +63 -1
- package/src/plugin.ts +91 -4
- package/src/rate-limit.ts +3 -1
- package/src/routes/account.ts +2 -2
- package/src/routes/api/state.ts +2 -1
- package/src/routes/buy.ts +3 -3
- package/src/routes/callback.ts +16 -1
- package/src/routes/preferences.ts +2 -2
- package/src/routes/privacy.ts +2 -2
- package/src/routes/shared.ts +19 -0
- package/src/routes/webhook.ts +26 -3
- package/src/settings.ts +96 -8
- package/src/setup.ts +280 -0
- package/src/site-config.ts +5 -1
- package/src/staff.ts +13 -0
- package/src/storage.ts +33 -0
- package/src/template-scan.ts +77 -0
- package/src/templates.ts +15 -0
- package/src/text.ts +8 -1
- package/src/token-vault.ts +83 -0
- package/src/version.ts +1 -1
- package/src/webhooks.ts +4 -0
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
|
|
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
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
|
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
|
|
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));
|
package/src/api-version.ts
CHANGED
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 =
|
|
104
|
-
const member = user
|
|
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))
|
|
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,
|
|
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
|
|
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:
|
|
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
|
-
/**
|
|
30
|
-
export
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
51
|
-
return res.ok;
|
|
61
|
+
return res.ok ? null : `HTTP ${res.status}`;
|
|
52
62
|
} catch (e) {
|
|
53
|
-
|
|
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
|
|
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 {
|