@redeemid/emdash 0.0.27 → 0.0.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -56,7 +56,7 @@ Under `/_emdash/api/auth/redeemid/`. Same-origin only: EmDash checks `Origin` on
56
56
  - `api/state` (GET): the visitor's own state.
57
57
 
58
58
  ```json
59
- { "apiVersion": "1.5.0", "version": "0.0.27", "signedIn": true, "linked": true,
59
+ { "apiVersion": "1.5.0", "version": "0.0.28", "signedIn": true, "linked": true,
60
60
  "levels": ["gold"], "features": ["ad-free"], "cart": { "count": 2 },
61
61
  "meter": { "used": 1, "limit": 3, "period": "month" } }
62
62
  ```
package/docs/shop.md CHANGED
@@ -64,7 +64,14 @@ import type { APIRoute } from "astro";
64
64
  export const GET: APIRoute = async ({ locals, url }) => productSitemap(await siteConfig(locals), url, { productHref: "/shop/{handle}/" });
65
65
  ```
66
66
 
67
- Then submit it to search engines, or list it in robots.txt. A custom robots.txt in EmDash's SEO settings replaces the generated one, so keep both lines in it: `Sitemap: https://<your site>/sitemap.xml` and `Sitemap: https://<your site>/sitemap-products.xml`. If the store's Online Store channel is open too, search engines find two pages for each product; headless stores usually password-protect the Online Store or redirect it to the site.
67
+ Then submit it to search engines, or list it in robots.txt. A custom robots.txt in EmDash's SEO settings replaces the generated one, so keep both lines in it: `Sitemap: https://<your site>/sitemap.xml` and `Sitemap: https://<your site>/sitemap-products.xml`. If the store's Online Store channel is open too, search engines find two pages for each product: publish the [redirect theme](https://github.com/webmultipliers/redeemid-emdash-shopify-redirect-theme) on the store to send its pages to the site (below).
68
+
69
+ <a id="discount-links"></a>**The store's own pages and discount links.** The [RedeemID redirect theme](https://github.com/webmultipliers/redeemid-emdash-shopify-redirect-theme) is a Shopify theme that sends the store's online store pages to the site (`/products/gold` to `/shop/gold/`, with editable path rules), while checkout and customer accounts stay on Shopify. It passes two things along, and the guard middleware (`redeemidGuard()`) takes both off the URL before the page renders:
70
+
71
+ - `?discount=CODE`, from Shopify's discount links (`https://<store>/discount/CODE?redirect=/products/gold`). The code goes on the visitor's cart straight away, or waits in their session for the first cart made for them (an Add to cart or a plan's Join button). Shopify decides at checkout whether it applies.
72
+ - `?logged_in=true`, when the customer is signed in to the store. A visitor who isn't signed in on the site is sent through sign-in with `prompt=none` (`login?quiet=1`), so they come back signed in without a form, or still signed out if the store session has ended, with no notice either way.
73
+
74
+ You can link to any site page with `?discount=CODE` yourself, too.
68
75
 
69
76
  **Shoppers' agents.** Shopify runs a Storefront MCP server for every store at `https://<store domain>/api/mcp`, for AI agents that browse, build a cart and check out. Point shopping agents there; RedeemID's own tools are for staff.
70
77
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redeemid/emdash",
3
- "version": "0.0.27",
3
+ "version": "0.0.28",
4
4
  "description": "Shopify commerce for EmDash sites: storefront, cart, checkout, customer accounts, memberships and paywalls. Shopify runs commerce; EmDash runs content.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Visitors sent over from the store's own pages.
3
+ *
4
+ * A store that keeps its Online Store channel usually publishes a redirect
5
+ * theme, which sends its pages to the site with two extras:
6
+ *
7
+ * - `?discount=CODE`: Shopify's discount links (`/discount/CODE`) leave the
8
+ * code in a store cookie, and the theme passes it on. The code goes on the
9
+ * visitor's cart now, or is kept in their session and goes on the first
10
+ * cart made for them.
11
+ * - `?logged_in=true`: the customer is signed in to the store. A visitor who
12
+ * isn't signed in here is signed in without a form (`prompt=none`). If the
13
+ * store session has ended, they come back signed out, with no notice.
14
+ *
15
+ * Either way the visitor then lands on the same page without those
16
+ * parameters, so the page itself, and any cache in front of it, never sees
17
+ * them.
18
+ */
19
+
20
+ import { readCart, type CartRef } from "./cart.js";
21
+ import { env, ENV, type RedeemIDConfig } from "./config.js";
22
+ import { setDiscountCodes, type Cart } from "./storefront.js";
23
+
24
+ export const SESSION_DISCOUNT = "redeemid:discount";
25
+
26
+ /** Parameters the redirect theme adds, taken off before the page renders. */
27
+ export const ARRIVAL_PARAMS = ["discount", "logged_in", "company_location_id"] as const;
28
+
29
+ type Session = { get(key: string): unknown; set(key: string, value: unknown): void; delete?(key: string): void } | undefined;
30
+
31
+ /** A discount code as a link carries it, or null for anything that can't be one. */
32
+ export function discountCode(raw: string | null | undefined): string | null {
33
+ const code = (raw ?? "").trim();
34
+ if (!code || code.length > 255 || /[\u0000-\u001f\u007f]/.test(code)) return null;
35
+ return code;
36
+ }
37
+
38
+ /** The login route checks `return` again (safeReturn), so this stays free of site config. */
39
+ export function quietSignInUrl(returnTo: string): string {
40
+ return `/_emdash/api/auth/redeemid/login?quiet=1&return=${encodeURIComponent(returnTo)}`;
41
+ }
42
+
43
+ /**
44
+ * The response for a page request that carries the theme's parameters, or
45
+ * null when it carries none. Only GET and HEAD requests are touched.
46
+ */
47
+ export async function arrival(
48
+ context: { url: URL; request?: Request; session?: unknown },
49
+ config: RedeemIDConfig | null,
50
+ user: unknown,
51
+ deps: { setCodes?: typeof setDiscountCodes } = {},
52
+ ): Promise<Response | null> {
53
+ const { url } = context;
54
+ const method = context.request?.method ?? "GET";
55
+ if (method !== "GET" && method !== "HEAD") return null;
56
+ if (!ARRIVAL_PARAMS.some((p) => url.searchParams.has(p))) return null;
57
+
58
+ const session = context.session as Session;
59
+ const code = discountCode(url.searchParams.get("discount"));
60
+ if (code && session && config) {
61
+ const cart = await readCart(session as never).catch(() => null);
62
+ if (cart) {
63
+ const buyerIp = context.request?.headers.get("cf-connecting-ip") ?? null;
64
+ await (deps.setCodes ?? setDiscountCodes)(config, cart.id, [code], { buyerIp }).catch((e: Error) => {
65
+ console.error("[redeemid] discount link failed:", e.message);
66
+ });
67
+ } else {
68
+ session.set(SESSION_DISCOUNT, code);
69
+ }
70
+ }
71
+
72
+ const clean = new URL(url);
73
+ for (const p of ARRIVAL_PARAMS) clean.searchParams.delete(p);
74
+ // "//host" would leave the site.
75
+ const here = /^\/[\/\\]/.test(clean.pathname) ? "/" : `${clean.pathname}${clean.search}`;
76
+
77
+ const signIn = url.searchParams.get("logged_in") === "true" && !user && session && config && env(ENV.clientId);
78
+ const location = signIn ? quietSignInUrl(here) : here;
79
+ return new Response(null, { status: 302, headers: { location, "cache-control": "private, no-store" } });
80
+ }
81
+
82
+ /** The code a discount link left for the visitor's next cart, if any. */
83
+ export async function pendingDiscount(session: Session): Promise<string | null> {
84
+ return discountCode((await session?.get(SESSION_DISCOUNT)) as string | undefined);
85
+ }
86
+
87
+ /**
88
+ * After adding to a cart with a pending code: the code is used up. A new cart
89
+ * got it when it was made; a cart that already existed gets it now.
90
+ */
91
+ export async function usedDiscount(
92
+ session: Session,
93
+ config: RedeemIDConfig,
94
+ code: string | null,
95
+ cart: Cart,
96
+ before: CartRef | null,
97
+ opts: { buyerIp?: string | null } = {},
98
+ ): Promise<void> {
99
+ if (!code || !session) return;
100
+ if (session.delete) session.delete(SESSION_DISCOUNT);
101
+ else session.set(SESSION_DISCOUNT, undefined);
102
+ if (before && before.id === cart.id) await setDiscountCodes(config, cart.id, [code], opts).catch(() => null);
103
+ }
package/src/cart-ops.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  * says the product or cart holds.
9
9
  */
10
10
 
11
+ import { pendingDiscount, usedDiscount } from "./arrivals.js";
11
12
  import { readCart, saveCart } from "./cart.js";
12
13
  import type { RedeemIDConfig } from "./config.js";
13
14
  import { memberForUser, stores } from "./storage.js";
@@ -104,12 +105,14 @@ export async function editCart(input: CartInput, ctx: CartContext): Promise<Cart
104
105
  const user = await sessionUser(session, ctx.db);
105
106
  const member = user && ctx.db ? await memberForUser(stores(ctx.db), user.id) : null;
106
107
  const current = await readCart(session);
108
+ const discount = await pendingDiscount(session);
107
109
  const cart = await addToCart(
108
110
  config,
109
111
  current?.id ?? null,
110
112
  { variantId: variant.id, sellingPlanId: plan, quantity },
111
- { email: member?.email ?? null, attributes: input.page ? { _redeemid_page: input.page } : {}, buyerIp, context },
113
+ { email: member?.email ?? null, attributes: input.page ? { _redeemid_page: input.page } : {}, buyerIp, context, discountCodes: discount ? [discount] : [] },
112
114
  );
115
+ await usedDiscount(session, config, discount, cart, current, { buyerIp });
113
116
  save(cart);
114
117
  return { notice: "cart_added", cart: null, base: cart, added: { product: p.handle, variant: variant.id, quantity, sellingPlan: Boolean(plan) } };
115
118
  }
package/src/guard.ts CHANGED
@@ -28,6 +28,7 @@
28
28
  import { defineMiddleware } from "astro:middleware";
29
29
 
30
30
  import { signInUrl } from "./access.js";
31
+ import { arrival } from "./arrivals.js";
31
32
  import { readCart } from "./cart.js";
32
33
  import { keysFromUrl, pathAccess, refusal } from "./files.js";
33
34
  import { keptOut, purgeRouteCache } from "./route-cache.js";
@@ -142,6 +143,9 @@ async function guard(context: Context, next: Next): Promise<Response> {
142
143
  }
143
144
 
144
145
  if (!url.pathname.startsWith("/_emdash")) {
146
+ // Sent over from the store's own pages, with a discount code or a store sign-in.
147
+ const arrived = await arrival(context, config, user);
148
+ if (arrived) return arrived;
145
149
  const own = Boolean(user) || Boolean(await readCart(context.session as never).catch(() => null));
146
150
  if (own) return keepPrivate(context, await next());
147
151
  const res = await next();
package/src/routes/buy.ts CHANGED
@@ -13,6 +13,7 @@ import type { APIRoute } from "astro";
13
13
  import { readConfig, storeDomain } from "../config.js";
14
14
  import { safeReturn } from "../access.js";
15
15
  import { memberForUser, stores } from "../storage.js";
16
+ import { pendingDiscount, usedDiscount } from "../arrivals.js";
16
17
  import { saveCart, readCart } from "../cart.js";
17
18
  import { checkoutFor } from "../checkout.js";
18
19
  import { addToCart, plan, storeContext } from "../storefront.js";
@@ -37,12 +38,15 @@ export const POST: APIRoute = async ({ request, locals, session, redirect }) =>
37
38
  const user = await sessionUser(session, emdash?.db);
38
39
  const member = user && emdash?.db ? await memberForUser(stores(emdash.db), user.id) : null;
39
40
  const current = await readCart(session);
41
+ const discount = await pendingDiscount(session);
42
+ const buyerIp = request.headers.get("cf-connecting-ip");
40
43
  const cart = await addToCart(
41
44
  config,
42
45
  current?.id ?? null,
43
46
  { variantId: p.variantId, sellingPlanId: p.sellingPlanId },
44
- { email: member?.email ?? null, attributes: { _redeemid_page: returnTo }, buyerIp: request.headers.get("cf-connecting-ip"), context },
47
+ { email: member?.email ?? null, attributes: { _redeemid_page: returnTo }, buyerIp, context, discountCodes: discount ? [discount] : [] },
45
48
  );
49
+ await usedDiscount(session, config, discount, cart, current, { buyerIp });
46
50
  saveCart(session, { id: cart.id, count: cart.totalQuantity });
47
51
  return redirect(await checkoutFor(config, cart, { session, request, emdashConfig: emdash?.config }), 303);
48
52
  } catch {
@@ -41,6 +41,8 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
41
41
  if (!config || !storeDomain(config) || !clientId || !emdash?.db || !session) return fail(redirect, returnTo, "not_configured");
42
42
 
43
43
  if (url.searchParams.get("error")) {
44
+ // The store session had ended after all: back to the page, still signed out.
45
+ if (flow?.quiet) return redirect(returnTo);
44
46
  // Refresh found no open store session: sign in as usual.
45
47
  if (flow?.refresh) return redirect(`/_emdash/api/auth/redeemid/login?return=${encodeURIComponent(returnTo)}`);
46
48
  await refused("sign_in_cancelled", "ok");
@@ -5,6 +5,9 @@
5
5
  * With `refresh=1` it asks the store not to show a form (`prompt=none`):
6
6
  * a member whose store session is still open comes straight back with their
7
7
  * tags read again; anyone else is sent through the usual sign-in.
8
+ * With `quiet=1` it asks the same, but anyone without an open store session
9
+ * just goes back, still signed out: for visitors the store's redirect theme
10
+ * says are signed in there.
8
11
  */
9
12
 
10
13
  import type { APIRoute } from "astro";
@@ -20,11 +23,12 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
20
23
  const url = new URL(request.url);
21
24
  const returnTo = safeReturn(url.searchParams.get("return"));
22
25
  const refresh = url.searchParams.get("refresh") === "1";
26
+ const quiet = url.searchParams.get("quiet") === "1";
23
27
  const emdash = (locals as { emdash?: { config?: unknown } }).emdash;
24
28
  const config = readConfig(emdash?.config);
25
29
  const clientId = env(ENV.clientId);
26
30
 
27
- if (!config || !storeDomain(config) || !clientId || !session) return fail(redirect, returnTo, "not_configured");
31
+ if (!config || !storeDomain(config) || !clientId || !session) return quiet ? redirect(returnTo) : fail(redirect, returnTo, "not_configured");
28
32
 
29
33
  try {
30
34
  const d = await discover(config);
@@ -35,6 +39,7 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
35
39
  returnTo,
36
40
  at: Date.now(),
37
41
  ...(refresh ? { refresh: true } : {}),
42
+ ...(quiet ? { quiet: true } : {}),
38
43
  };
39
44
  session.set(SESSION_FLOW, flow);
40
45
  return redirect(
@@ -45,11 +50,11 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
45
50
  nonce: flow.nonce,
46
51
  challenge: await challengeFor(flow.verifier),
47
52
  locale: request.headers.get("accept-language")?.split(",")[0]?.trim(),
48
- ...(refresh ? { prompt: "none" as const } : {}),
53
+ ...(refresh || quiet ? { prompt: "none" as const } : {}),
49
54
  }),
50
55
  );
51
56
  } catch (e) {
52
57
  console.error("[redeemid] sign-in start failed:", (e as Error).message);
53
- return fail(redirect, returnTo, "sign_in_unavailable");
58
+ return quiet ? redirect(returnTo) : fail(redirect, returnTo, "sign_in_unavailable");
54
59
  }
55
60
  };
@@ -24,6 +24,8 @@ export interface Flow {
24
24
  at: number;
25
25
  /** Started by Refresh membership (prompt=none). */
26
26
  refresh?: boolean;
27
+ /** Started because the store said the customer is signed in there: a refusal comes back with no notice and no form. */
28
+ quiet?: boolean;
27
29
  }
28
30
 
29
31
  export const CALLBACK_PATH = "/_emdash/api/auth/redeemid/callback";
package/src/storefront.ts CHANGED
@@ -232,6 +232,8 @@ export async function addToCart(
232
232
  fetcher?: typeof fetch;
233
233
  /** A new cart's country, for local prices. */
234
234
  context?: StoreContext;
235
+ /** Discount codes for a new cart. */
236
+ discountCodes?: string[];
235
237
  } = {},
236
238
  ): Promise<Cart> {
237
239
  const quantity = Math.max(1, Math.floor(line.quantity ?? 1));
@@ -248,6 +250,7 @@ export async function addToCart(
248
250
  if (Object.keys(identity).length) input.buyerIdentity = identity;
249
251
  const attributes = Object.entries(opts.attributes ?? {}).filter(([, v]) => v);
250
252
  if (attributes.length) input.attributes = attributes.map(([key, value]) => ({ key, value }));
253
+ if (opts.discountCodes?.length) input.discountCodes = opts.discountCodes;
251
254
  const data = await storefront<{ cartCreate: CartResult }>(config, CART_NEW, { input }, opts);
252
255
  const cart = data.cartCreate.cart;
253
256
  if (!cart) throw new Error(data.cartCreate.userErrors[0]?.message ?? "Shopify returned no cart.");
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.0.27";
1
+ export const VERSION = "0.0.28";