@redeemid/emdash 0.0.12 → 0.0.15
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 +13 -5
- package/bin/redeemid-emdash.mjs +1 -1
- package/package.json +1 -1
- package/src/admin-api.ts +21 -5
- package/src/components/Content.astro +23 -2
- package/src/components/Notices.astro +1 -0
- package/src/config.ts +6 -0
- package/src/guard.ts +43 -1
- package/src/plugin.ts +5 -3
- package/src/routes/callback.ts +19 -7
- package/src/routes/shared.ts +26 -0
- package/src/staff.ts +6 -1
- package/src/text.ts +1 -0
- package/src/version.ts +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Shopify customer accounts, member levels and paywalls for [EmDash](https://github.com/emdash-cms/emdash) sites. Shopify runs the store and the customer account; EmDash runs the content.
|
|
4
4
|
|
|
5
|
-
**Status: 0.0.
|
|
5
|
+
**Status: 0.0.15, early.** Runs on Node and Cloudflare Workers. Sign-in, the member mirror, webhooks, levels, rules, the meter and drip, teasers, paywall markup, badges, the page gate, the Shopify cart and checkout, the staff tools, preferences, protected files, privacy requests, AI agent tools, the command line, replacement text and outbound events are covered by an end-to-end test against a fake store.
|
|
6
6
|
|
|
7
7
|
## What it does
|
|
8
8
|
|
|
@@ -241,7 +241,7 @@ With `agents: { actions: true }` in the config, three actions are offered too:
|
|
|
241
241
|
| MCP tool | Command | What it does |
|
|
242
242
|
| --- | --- | --- |
|
|
243
243
|
| `resync_member` | `resync <customer id>` | Reads one member from Shopify and updates the site. Changes nothing in Shopify. |
|
|
244
|
-
| `resync_batch` | `resync-batch` | Reads the
|
|
244
|
+
| `resync_batch` | `resync-batch` | Reads the 25 members synced longest ago. |
|
|
245
245
|
| `edit_member_tags` | `tags <customer id> --add a,b --remove c` | Adds or removes tags on the Shopify customer, then reads back what Shopify holds. It's marked destructive for agents. |
|
|
246
246
|
|
|
247
247
|
**Agents.** EmDash's MCP server is at `/_emdash/api/mcp`, on EmDash versions with plugin MCP tools. RedeemID's tools stay off until an administrator turns them on for RedeemID under **Plugins**. Turning them on approves the list as it stands, and a release that changes the list needs approving again. The agent uses an EmDash API token with the `mcp:tools` or `mcp:tools:redeemid` scope, made by an administrator. Tool names carry the plugin's prefix, for example `redeemid__explain_access`.
|
|
@@ -295,8 +295,8 @@ No event carries an email address or a name: match people by the Shopify `custom
|
|
|
295
295
|
With `REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID` set (it also runs member preferences), RedeemID gets an Admin API token for the webhook app with the client credentials grant (the app must belong to the same Shopify organization as the store, as Dev Dashboard apps do) and needs the `read_customers` and `write_customers` scopes. Then, in the admin:
|
|
296
296
|
|
|
297
297
|
- **Member detail.** Open a member from the Members page to see their levels, tags and features, resync them from Shopify, or add and remove tags. Tag edits are made in Shopify, then read back; nothing is granted on the EmDash side.
|
|
298
|
-
- **Bulk resync.** Reads the
|
|
299
|
-
- **Webhooks.** The Status page lists the store's subscriptions to this site and registers any that are missing (`customers/redact` stays in the app's own settings).
|
|
298
|
+
- **Bulk resync.** Reads the 25 members synced longest ago from Shopify in one request, few enough for the Workers Free limit of 50 database queries a request. Run it again for the next 25. It never runs on its own.
|
|
299
|
+
- **Webhooks.** The Status page lists the store's subscriptions to this site and registers any that are missing (`customers/redact` stays in the app's own settings). A topic Shopify refuses, such as `customers/merge` without the `read_customer_merge` scope, is named with the reason and doesn't stop the others.
|
|
300
300
|
- **Recent deliveries.** The last webhooks the site took, with their topics and times.
|
|
301
301
|
- **Access test.** Enter a collection, terms and a reader's tags to see what the entry needs and whether that reader gets in.
|
|
302
302
|
|
|
@@ -310,13 +310,15 @@ EmDash has no content filter hook, so templates decide what to render. Render th
|
|
|
310
310
|
---
|
|
311
311
|
import Content from "@redeemid/emdash/components/Content.astro";
|
|
312
312
|
---
|
|
313
|
-
<Content value={post.data.content} collection="posts" terms={post.data.terms} id={post.id}>
|
|
313
|
+
<Content value={post.data.content} collection="posts" terms={post.data.terms} id={post.id} title={post.data.title}>
|
|
314
314
|
<a slot="locked" href="/join/">Become a member</a>
|
|
315
315
|
</Content>
|
|
316
316
|
```
|
|
317
317
|
|
|
318
318
|
Everything before the first Paywall break block is shown to everyone. What follows reaches the page only for readers the break allows: the levels picked on it ("Signed in" for any member), or when none are picked the entry's rule. With none picked and no rule covering the entry, the break changes nothing. A body with no break follows the entry's rule as a whole; readers it keeps out see the collection's `teaser` (the paragraphs within that many words, never half a paragraph), if one is set. Plain `<PortableText>` ignores the break and shows everything, so use `<Content>` for any body that may hold one.
|
|
319
319
|
|
|
320
|
+
On the entry's own page (with `id`), a locked entry also gets Google's paywalled-content markup: JSON-LD saying `isAccessibleForFree: false`, with the wall (`.redeemid-locked`) as the part that isn't free. Search engines can then tell a paywall from cloaking. Pass `title` for its headline. Set `schema: false` in the config if an SEO plugin already adds this markup.
|
|
321
|
+
|
|
320
322
|
A "Members only" block starts a part for the levels picked on it (none picked: the entry's rule, or any signed-in member) that runs to the next "Members only: end" block. Readers who can't read it see the wall there, and the body goes on after the end block.
|
|
321
323
|
|
|
322
324
|
The wall is `<LockedPanel>`: it says why the reader is kept out and offers sign-in or the join link, with the wall text of the first level they are missing. `<Content>` and `<Gate>` show it; its default slot (and `<Content>`'s "locked" slot) goes on top.
|
|
@@ -360,6 +362,12 @@ import Plan from "@redeemid/emdash/components/Plan.astro";
|
|
|
360
362
|
</Plan>
|
|
361
363
|
```
|
|
362
364
|
|
|
365
|
+
### Caching
|
|
366
|
+
|
|
367
|
+
`redeemidGuard()` keeps every site page private for a signed-in visitor or a visitor with a cart: `Cache-Control: private, no-store`, and out of Astro's route cache (it calls `cache.set(false)` after the page has set its own cache hint). Visitors with neither get the same page, so their pages stay cacheable.
|
|
368
|
+
|
|
369
|
+
A route cache keys pages by URL alone and serves a stored page without running any code. So if the site turns on Astro's route cache, don't cache the pages that render `<Content>` or `<Gate>` for locked entries: a member would get the visitor's stored copy, wall included. Call `Astro.cache.set(hint)` only on pages whose entries no rule covers.
|
|
370
|
+
|
|
363
371
|
### Shop
|
|
364
372
|
|
|
365
373
|
Products, collections and the cart come from the Storefront API; Shopify works out prices, discounts, taxes and stock.
|
package/bin/redeemid-emdash.mjs
CHANGED
|
@@ -28,7 +28,7 @@ Commands:
|
|
|
28
28
|
health What's set up (never the values)
|
|
29
29
|
changes --since <date> Who joined or left a level [--level <slug>]
|
|
30
30
|
resync <customer id> Read one member from Shopify again *
|
|
31
|
-
resync-batch Read the
|
|
31
|
+
resync-batch Read the 25 members synced longest ago *
|
|
32
32
|
tags <customer id> Edit tags in Shopify: --add a,b --remove c *
|
|
33
33
|
|
|
34
34
|
* only when the site sets agents: { actions: true }
|
package/package.json
CHANGED
package/src/admin-api.ts
CHANGED
|
@@ -124,9 +124,12 @@ export async function customers(config: RedeemIDConfig, ids: string[], fetcher:
|
|
|
124
124
|
*/
|
|
125
125
|
export async function resync(config: RedeemIDConfig, s: Stores, ids: string[], source = "resync", fetcher: typeof fetch = fetch): Promise<{ updated: MemberRecord[]; missing: string[] }> {
|
|
126
126
|
const found = await customers(config, ids, fetcher);
|
|
127
|
+
// One read for the whole batch: on Workers Free one request may make only
|
|
128
|
+
// 50 database queries, and each write below is one.
|
|
129
|
+
const known = await s.members.getMany(found.map((c) => c.customerId));
|
|
127
130
|
const updated: MemberRecord[] = [];
|
|
128
131
|
for (const c of found) {
|
|
129
|
-
const m =
|
|
132
|
+
const m = known.get(c.customerId) ?? emptyMember(c.customerId);
|
|
130
133
|
m.email = c.email ?? m.email;
|
|
131
134
|
m.firstName = c.firstName ?? m.firstName;
|
|
132
135
|
m.lastName = c.lastName ?? m.lastName;
|
|
@@ -173,10 +176,22 @@ export async function webhookSubscriptions(config: RedeemIDConfig, fetcher: type
|
|
|
173
176
|
return data.webhookSubscriptions.nodes.map((n) => ({ id: n.id, topic: n.topic, uri: n.uri ?? "" }));
|
|
174
177
|
}
|
|
175
178
|
|
|
176
|
-
/**
|
|
177
|
-
|
|
179
|
+
/** Scopes a topic needs beyond read_customers, to explain a refusal. */
|
|
180
|
+
const TOPIC_SCOPES: Partial<Record<(typeof WEBHOOK_TOPICS)[number], string>> = { CUSTOMERS_MERGE: "read_customer_merge" };
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Register the topics RedeemID needs that no subscription sends to `uri` yet.
|
|
184
|
+
* A topic Shopify refuses doesn't stop the others: it comes back in `failed`.
|
|
185
|
+
*/
|
|
186
|
+
export async function registerWebhooks(
|
|
187
|
+
config: RedeemIDConfig,
|
|
188
|
+
uri: string,
|
|
189
|
+
fetcher: typeof fetch = fetch,
|
|
190
|
+
): Promise<{ added: string[]; failed: Array<{ topic: string; message: string }> }> {
|
|
178
191
|
const have = await webhookSubscriptions(config, fetcher);
|
|
179
192
|
const missing = WEBHOOK_TOPICS.filter((t) => !have.some((h) => h.topic === t && h.uri === uri));
|
|
193
|
+
const added: string[] = [];
|
|
194
|
+
const failed: Array<{ topic: string; message: string }> = [];
|
|
180
195
|
for (const topic of missing) {
|
|
181
196
|
const data = await admin<{ webhookSubscriptionCreate: { userErrors: Array<{ message: string }> } }>(
|
|
182
197
|
config,
|
|
@@ -185,7 +200,8 @@ export async function registerWebhooks(config: RedeemIDConfig, uri: string, fetc
|
|
|
185
200
|
fetcher,
|
|
186
201
|
);
|
|
187
202
|
const err = data.webhookSubscriptionCreate.userErrors[0];
|
|
188
|
-
if (err)
|
|
203
|
+
if (!err) added.push(topic);
|
|
204
|
+
else failed.push({ topic, message: TOPIC_SCOPES[topic] ? `${err.message} (the app needs the ${TOPIC_SCOPES[topic]} scope)` : err.message });
|
|
189
205
|
}
|
|
190
|
-
return
|
|
206
|
+
return { added, failed };
|
|
191
207
|
}
|
|
@@ -24,6 +24,11 @@
|
|
|
24
24
|
* `drip` keeps it shut until the member has held their level that many days,
|
|
25
25
|
* and config `meter` lets signed-in readers open a few locked entries each
|
|
26
26
|
* period (each entry counts once). Leave `id` off in lists.
|
|
27
|
+
*
|
|
28
|
+
* With `id`, a locked entry's page also gets Google's paywalled-content
|
|
29
|
+
* markup (isAccessibleForFree false, pointing at the wall), the same for
|
|
30
|
+
* every reader. Pass `title` for its headline. Config `schema: false` turns
|
|
31
|
+
* it off.
|
|
27
32
|
*/
|
|
28
33
|
import { PortableText } from "emdash/ui";
|
|
29
34
|
|
|
@@ -37,11 +42,13 @@ interface Props {
|
|
|
37
42
|
value?: unknown[] | null;
|
|
38
43
|
collection?: string;
|
|
39
44
|
terms?: Terms;
|
|
40
|
-
/** The entry's id, on its own page: turns on drip and the
|
|
45
|
+
/** The entry's id, on its own page: turns on drip, the meter and the paywall markup. */
|
|
41
46
|
id?: string;
|
|
47
|
+
/** The entry's title, for the paywall markup's headline. */
|
|
48
|
+
title?: string;
|
|
42
49
|
}
|
|
43
50
|
|
|
44
|
-
const { value, collection = "", terms, id } = Astro.props;
|
|
51
|
+
const { value, collection = "", terms, id, title } = Astro.props;
|
|
45
52
|
const split = splitAtPaywall(value);
|
|
46
53
|
const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
|
|
47
54
|
const entryRule = await entryRequirement(config, collection, terms);
|
|
@@ -77,6 +84,19 @@ async function parts(blocks: unknown[]) {
|
|
|
77
84
|
}
|
|
78
85
|
const before = await parts(shown);
|
|
79
86
|
const after = await parts(rest);
|
|
87
|
+
// Google's paywalled-content markup, on the entry's own page when any of it is locked.
|
|
88
|
+
const lockedEntry = Boolean(split.paywall ? required : entryRule) || sections(value ?? []).some((s) => s.members);
|
|
89
|
+
const schema =
|
|
90
|
+
id && lockedEntry && config?.schema !== false
|
|
91
|
+
? JSON.stringify({
|
|
92
|
+
"@context": "https://schema.org",
|
|
93
|
+
"@type": "Article",
|
|
94
|
+
...(title ? { headline: title } : {}),
|
|
95
|
+
url: Astro.url.origin + Astro.url.pathname,
|
|
96
|
+
isAccessibleForFree: false,
|
|
97
|
+
hasPart: { "@type": "WebPageElement", isAccessibleForFree: false, cssSelector: ".redeemid-locked" },
|
|
98
|
+
}).replace(/</g, "\\u003c")
|
|
99
|
+
: null;
|
|
80
100
|
const metered = verdict.reason === "metered" ? (verdict as Verdict) : null;
|
|
81
101
|
const week = metered?.meter?.period === "week";
|
|
82
102
|
const left = metered?.meterLeft ?? 0;
|
|
@@ -88,6 +108,7 @@ const meterText =
|
|
|
88
108
|
: text(config, week ? "You have {count} free reads left this week." : "You have {count} free reads left this month.", { count: left });
|
|
89
109
|
---
|
|
90
110
|
|
|
111
|
+
{schema && <script type="application/ld+json" set:html={schema} />}
|
|
91
112
|
{metered && (
|
|
92
113
|
<p class="redeemid-meter" role="status" data-redeemid-left={metered.meterLeft}>
|
|
93
114
|
{meterText}
|
|
@@ -16,6 +16,7 @@ const DEFAULTS: Record<string, string> = {
|
|
|
16
16
|
sign_in_unavailable: "Sign-in isn't available right now. Try again in a few minutes.",
|
|
17
17
|
sign_in_expired: "That sign-in took too long. Try again.",
|
|
18
18
|
staff_account: "This account can't sign in here. Use a different email.",
|
|
19
|
+
account_disabled: "Your account is turned off. Contact the store for help.",
|
|
19
20
|
site_not_ready: "This site isn't ready for sign-in yet.",
|
|
20
21
|
email_required: "Your account needs an email address to sign in.",
|
|
21
22
|
token_failed: "Sign-in didn't finish. Try again.",
|
package/src/config.ts
CHANGED
|
@@ -150,6 +150,12 @@ export interface RedeemIDConfig {
|
|
|
150
150
|
* one they see none of it.
|
|
151
151
|
*/
|
|
152
152
|
teaser?: Record<string, number>;
|
|
153
|
+
/**
|
|
154
|
+
* Google's paywalled-content markup (isAccessibleForFree false, pointing at
|
|
155
|
+
* the wall) on the page of each locked entry, printed by <Content> when it
|
|
156
|
+
* gets `id`. Set false when an SEO plugin already adds it.
|
|
157
|
+
*/
|
|
158
|
+
schema?: false;
|
|
153
159
|
/**
|
|
154
160
|
* Extra /_emdash path prefixes signed-in members may use. Everything else
|
|
155
161
|
* under /_emdash is refused to Subscribers (see guard.ts).
|
package/src/guard.ts
CHANGED
|
@@ -1,17 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Keeps members out of EmDash's own APIs and admin
|
|
2
|
+
* Keeps members out of EmDash's own APIs and admin, and keeps their pages out
|
|
3
|
+
* of shared caches.
|
|
3
4
|
*
|
|
4
5
|
* EmDash's Subscriber role may read every published entry through
|
|
5
6
|
* /_emdash/api/content. With RedeemID, access comes from Shopify tags only, so
|
|
6
7
|
* a member with one level could otherwise read another level's posts there.
|
|
7
8
|
* This middleware runs after EmDash's auth and refuses Subscribers on every
|
|
8
9
|
* /_emdash path except the few public pages need.
|
|
10
|
+
*
|
|
11
|
+
* Site pages for a signed-in visitor, or a visitor with a cart, are private:
|
|
12
|
+
* `Cache-Control: private, no-store` and out of Astro's route cache. The route
|
|
13
|
+
* cache ignores Cache-Control and takes its options from `cache.set()`, which
|
|
14
|
+
* EmDash templates call on every page with the content's cache hint, and it
|
|
15
|
+
* serves a stored page to the next visitor without running any code. EmDash
|
|
16
|
+
* opts editors out, not Subscribers, so without this a member's page (locked
|
|
17
|
+
* text included) could be stored and served to anyone. A later
|
|
18
|
+
* `Astro.cache.set(hint)` turns caching back on, and the route cache reads its
|
|
19
|
+
* options only after the page has rendered, so the opt-out runs after `next()`.
|
|
9
20
|
*/
|
|
10
21
|
|
|
11
22
|
import { defineMiddleware } from "astro:middleware";
|
|
12
23
|
|
|
13
24
|
import { signInUrl } from "./access.js";
|
|
25
|
+
import { readCart } from "./cart.js";
|
|
14
26
|
import { keysFromPath, pathAccess, refusal } from "./files.js";
|
|
27
|
+
import { AFTER_LOGOUT_COOKIE, readAfterLogout } from "./routes/shared.js";
|
|
15
28
|
import { siteConfig } from "./site-config.js";
|
|
16
29
|
|
|
17
30
|
const SUBSCRIBER = 10;
|
|
@@ -45,6 +58,20 @@ async function withUser(context: { locals: unknown; session?: { get(k: string):
|
|
|
45
58
|
return { ...context, locals: { ...locals, user: { id: user.id, role: user.role, disabled: user.disabled } } };
|
|
46
59
|
}
|
|
47
60
|
|
|
61
|
+
/** Keep one visitor's response out of every shared cache, the route cache included. */
|
|
62
|
+
export function keepPrivate(context: { cache?: { set(options: false): void } }, res: Response): Response {
|
|
63
|
+
context.cache?.set(false);
|
|
64
|
+
try {
|
|
65
|
+
res.headers.set("cache-control", "private, no-store");
|
|
66
|
+
return res;
|
|
67
|
+
} catch {
|
|
68
|
+
// Immutable headers (a redirect made with Response.redirect).
|
|
69
|
+
const out = new Response(res.body, res);
|
|
70
|
+
out.headers.set("cache-control", "private, no-store");
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
48
75
|
export const onRequest = defineMiddleware(async (context, next) => {
|
|
49
76
|
const { locals, url } = context;
|
|
50
77
|
const user = (locals as { user?: { role?: number } | null }).user;
|
|
@@ -72,6 +99,21 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
72
99
|
}
|
|
73
100
|
}
|
|
74
101
|
|
|
102
|
+
// Back from ending the store session after a refused sign-in.
|
|
103
|
+
if (url.pathname === "/" && !url.searchParams.has("redeemid_notice")) {
|
|
104
|
+
const after = readAfterLogout(context.cookies.get(AFTER_LOGOUT_COOKIE)?.value);
|
|
105
|
+
if (after) {
|
|
106
|
+
const sep = after.returnTo.includes("?") ? "&" : "?";
|
|
107
|
+
const location = `${after.returnTo}${sep}redeemid_notice=${encodeURIComponent(after.code)}`;
|
|
108
|
+
return new Response(null, { status: 302, headers: { location, "cache-control": "private, no-store", "set-cookie": `${AFTER_LOGOUT_COOKIE}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax` } });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!url.pathname.startsWith("/_emdash")) {
|
|
113
|
+
const own = Boolean(user) || Boolean(await readCart(context.session as never).catch(() => null));
|
|
114
|
+
return own ? keepPrivate(context, await next()) : next();
|
|
115
|
+
}
|
|
116
|
+
|
|
75
117
|
if (!user || user.role !== SUBSCRIBER) return next();
|
|
76
118
|
|
|
77
119
|
if (memberMayUse(url.pathname, config?.memberPaths)) return next();
|
package/src/plugin.ts
CHANGED
|
@@ -279,7 +279,7 @@ function agentTools(config: RedeemIDConfig) {
|
|
|
279
279
|
};
|
|
280
280
|
if (config.agents?.actions) {
|
|
281
281
|
tools.resync_member = { route: "resync-member", input: CUSTOMER, description: "Read one member from Shopify now and update the site. Changes nothing in Shopify." };
|
|
282
|
-
tools.resync_batch = { route: "resync-batch", input: EMPTY, description:
|
|
282
|
+
tools.resync_batch = { route: "resync-batch", input: EMPTY, description: `Read the ${BULK_BATCH} members synced longest ago from Shopify. Run again for the next ${BULK_BATCH}.` };
|
|
283
283
|
tools.edit_member_tags = { route: "edit-member-tags", input: TAGS, destructive: true, description: "Add or remove tags on a member's Shopify customer, then read back what Shopify holds. Access follows from Shopify." };
|
|
284
284
|
}
|
|
285
285
|
return { tools };
|
|
@@ -342,8 +342,10 @@ async function action(
|
|
|
342
342
|
}
|
|
343
343
|
case "webhooks_register": {
|
|
344
344
|
try {
|
|
345
|
-
const added = await registerWebhooks(config, webhookUri(request));
|
|
346
|
-
|
|
345
|
+
const { added, failed } = await registerWebhooks(config, webhookUri(request));
|
|
346
|
+
const done = added.length ? `Registered ${added.length} webhooks.` : failed.length ? "" : "Nothing was missing.";
|
|
347
|
+
const refused = failed.map((f) => `Shopify refused ${f.topic}: ${f.message}`).join(" ");
|
|
348
|
+
return { ...(await statusPage(config, request)), toast: { type: failed.length ? "error" : "success", message: [done, refused].filter(Boolean).join(" ") } };
|
|
347
349
|
} catch (e) {
|
|
348
350
|
return { ...(await statusPage(config, request)), toast: fail(e) };
|
|
349
351
|
}
|
package/src/routes/callback.ts
CHANGED
|
@@ -12,15 +12,16 @@
|
|
|
12
12
|
import { findOrCreateOAuthUser, OAuthError, Role } from "@emdash-cms/auth";
|
|
13
13
|
import { createKyselyAdapter, type AuthTables } from "@emdash-cms/auth/adapters/kysely";
|
|
14
14
|
import type { APIRoute } from "astro";
|
|
15
|
-
import { OptionsRepository } from "emdash/api/route-utils";
|
|
16
15
|
import type { Kysely } from "kysely";
|
|
17
16
|
|
|
18
17
|
import { env, ENV, PROVIDER_ID, readConfig, storeDomain } from "../config.js";
|
|
19
18
|
import { emit, levelChange } from "../events.js";
|
|
20
19
|
import { levelsFor } from "../access.js";
|
|
21
|
-
import { discover, exchangeCode, identity, ShopifyError, verifyIdToken } from "../shopify.js";
|
|
20
|
+
import { discover, exchangeCode, identity, logoutUrl, ShopifyError, verifyIdToken, type Discovery } from "../shopify.js";
|
|
22
21
|
import { emptyMember, stampTags, stores } from "../storage.js";
|
|
23
|
-
import {
|
|
22
|
+
import { getPublicOrigin, OptionsRepository } from "emdash/api/route-utils";
|
|
23
|
+
|
|
24
|
+
import { afterLogoutCookie, callbackUrl, fail, FLOW_TTL_MS, SESSION_FLOW, SESSION_ID_TOKEN, type Flow } from "./shared.js";
|
|
24
25
|
|
|
25
26
|
export const prerender = false;
|
|
26
27
|
|
|
@@ -63,10 +64,13 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
|
|
|
63
64
|
verifier: flow.verifier,
|
|
64
65
|
});
|
|
65
66
|
const claims = await verifyIdToken(d, tokens.idToken, { clientId, nonce: flow.nonce });
|
|
67
|
+
// Refusals from here on also end the store session, so the next Sign in
|
|
68
|
+
// asks for an email again instead of reusing this account.
|
|
69
|
+
const refuse = (code: string) => signedOut(d, tokens.idToken, url, emdash.config, returnTo, code) ?? fail(redirect, returnTo, code);
|
|
66
70
|
const who = await identity(d, tokens.accessToken);
|
|
67
71
|
|
|
68
72
|
const email = (typeof claims.email === "string" ? claims.email : who.email)?.toLowerCase() ?? null;
|
|
69
|
-
if (!email) return
|
|
73
|
+
if (!email) return refuse("email_required");
|
|
70
74
|
const emailVerified = claims.email_verified === true;
|
|
71
75
|
|
|
72
76
|
const adapter = createKyselyAdapter(emdash.db as Kysely<AuthTables>);
|
|
@@ -74,7 +78,7 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
|
|
|
74
78
|
// Staff accounts never sign in through the store, even with the same email.
|
|
75
79
|
const linked = await adapter.getOAuthAccount(PROVIDER_ID, who.customerId);
|
|
76
80
|
const existing = linked ? await adapter.getUserById(linked.userId) : await adapter.getUserByEmail(email);
|
|
77
|
-
if (existing && existing.role > Role.SUBSCRIBER) return
|
|
81
|
+
if (existing && existing.role > Role.SUBSCRIBER) return refuse("staff_account");
|
|
78
82
|
|
|
79
83
|
const name = [who.firstName, who.lastName].filter(Boolean).join(" ") || null;
|
|
80
84
|
const user = await findOrCreateOAuthUser(
|
|
@@ -84,8 +88,8 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
|
|
|
84
88
|
async () => ({ allowed: true, role: Role.SUBSCRIBER }),
|
|
85
89
|
{ requireVerifiedEmail: true },
|
|
86
90
|
);
|
|
87
|
-
if (user.disabled) return
|
|
88
|
-
if (user.role > Role.SUBSCRIBER) return
|
|
91
|
+
if (user.disabled) return refuse("account_disabled");
|
|
92
|
+
if (user.role > Role.SUBSCRIBER) return refuse("staff_account");
|
|
89
93
|
|
|
90
94
|
const s = stores(emdash.db);
|
|
91
95
|
const found = await s.members.get(who.customerId);
|
|
@@ -123,3 +127,11 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
|
|
|
123
127
|
return fail(redirect, returnTo, code);
|
|
124
128
|
}
|
|
125
129
|
};
|
|
130
|
+
|
|
131
|
+
/** Ends the store session, then shows `code` on `returnTo` (see AFTER_LOGOUT_COOKIE). */
|
|
132
|
+
function signedOut(d: Discovery, idToken: string, url: URL, emdashConfig: unknown, returnTo: string, code: string): Response | null {
|
|
133
|
+
const origin = getPublicOrigin(url, emdashConfig as Parameters<typeof getPublicOrigin>[1]);
|
|
134
|
+
const to = logoutUrl(d, idToken, `${origin}/`);
|
|
135
|
+
if (!to) return null;
|
|
136
|
+
return new Response(null, { status: 302, headers: { location: to, "set-cookie": afterLogoutCookie(code, returnTo), "cache-control": "private, no-store" } });
|
|
137
|
+
}
|
package/src/routes/shared.ts
CHANGED
|
@@ -26,3 +26,29 @@ export function fail(redirect: (to: string) => Response, returnTo: string, code:
|
|
|
26
26
|
const sep = returnTo.includes("?") ? "&" : "?";
|
|
27
27
|
return redirect(`${returnTo}${sep}redeemid_notice=${encodeURIComponent(code)}`);
|
|
28
28
|
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A refused sign-in also ends the store session, or the next Sign in would
|
|
32
|
+
* pass straight through with the same account. Shopify only returns to the
|
|
33
|
+
* site root after that, so this short cookie carries the notice and the page
|
|
34
|
+
* back; the guard middleware turns it into the usual `?redeemid_notice=`.
|
|
35
|
+
*/
|
|
36
|
+
export const AFTER_LOGOUT_COOKIE = "redeemid_after_logout";
|
|
37
|
+
export const SIGNED_OUT_NOTICES = ["staff_account", "account_disabled", "email_required"] as const;
|
|
38
|
+
|
|
39
|
+
export function afterLogoutCookie(code: string, returnTo: string): string {
|
|
40
|
+
return `${AFTER_LOGOUT_COOKIE}=${encodeURIComponent(JSON.stringify([code, returnTo]))}; Path=/; Max-Age=600; HttpOnly; Secure; SameSite=Lax`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The notice and page a refused sign-in left, or null. */
|
|
44
|
+
export function readAfterLogout(raw: string | undefined): { code: string; returnTo: string } | null {
|
|
45
|
+
if (!raw) return null;
|
|
46
|
+
try {
|
|
47
|
+
const [code, returnTo] = JSON.parse(decodeURIComponent(raw)) as [unknown, unknown];
|
|
48
|
+
if (typeof code !== "string" || !(SIGNED_OUT_NOTICES as readonly string[]).includes(code)) return null;
|
|
49
|
+
const to = typeof returnTo === "string" && returnTo.startsWith("/") && !returnTo.startsWith("//") && !returnTo.startsWith("/\\") ? returnTo : "/";
|
|
50
|
+
return { code, returnTo: to };
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/staff.ts
CHANGED
|
@@ -13,7 +13,12 @@ import { recentRequests } from "./privacy.js";
|
|
|
13
13
|
import { cleanTags, numericId, type MemberRecord, type Stores } from "./storage.js";
|
|
14
14
|
|
|
15
15
|
/** Members resynced per click; well inside Shopify's query cost limits. */
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Members per bulk resync click. Each one costs a database write, and on the
|
|
18
|
+
* Workers Free plan one request may make only 50 database queries (EmDash's
|
|
19
|
+
* own session and settings reads included), so a batch has to leave room.
|
|
20
|
+
*/
|
|
21
|
+
export const BULK_BATCH = 25;
|
|
17
22
|
|
|
18
23
|
type Blocks = { blocks: unknown[]; toast?: { message: string; type: "success" | "error" | "info" } };
|
|
19
24
|
|
package/src/text.ts
CHANGED
|
@@ -84,6 +84,7 @@ export const STRINGS = {
|
|
|
84
84
|
"Sign-in isn't available right now. Try again in a few minutes.",
|
|
85
85
|
"That sign-in took too long. Try again.",
|
|
86
86
|
"This account can't sign in here. Use a different email.",
|
|
87
|
+
"Your account is turned off. Contact the store for help.",
|
|
87
88
|
"This site isn't ready for sign-in yet.",
|
|
88
89
|
"Your account needs an email address to sign in.",
|
|
89
90
|
"Sign-in didn't finish. Try again.",
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.0.
|
|
1
|
+
export const VERSION = "0.0.15";
|