@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
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The member's Customer Account API tokens, sealed before they go into the
|
|
3
|
+
* session store.
|
|
4
|
+
*
|
|
5
|
+
* Signed-in checkout needs the member's access and refresh tokens, and they
|
|
6
|
+
* carry the Customer Account API's whole scope. They live only in the
|
|
7
|
+
* server-side session, encrypted (AES-GCM) with a key RedeemID keeps in the
|
|
8
|
+
* site's database (option `redeemid:session_key`, made on first need). So a
|
|
9
|
+
* copy of the session store alone doesn't give anyone a member's tokens.
|
|
10
|
+
* A token that can't be opened (an older session, a changed key) is treated
|
|
11
|
+
* as none: checkout falls back to a guest checkout.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { CustomerToken } from "./routes/shared.js";
|
|
15
|
+
|
|
16
|
+
const OPTION = "redeemid:session_key";
|
|
17
|
+
|
|
18
|
+
/** A sealed token as the session holds it. */
|
|
19
|
+
export interface SealedToken {
|
|
20
|
+
sealed: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let held: Promise<CryptoKey> | null = null;
|
|
24
|
+
|
|
25
|
+
function b64(bytes: Uint8Array): string {
|
|
26
|
+
let s = "";
|
|
27
|
+
for (const b of bytes) s += String.fromCharCode(b);
|
|
28
|
+
return btoa(s);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function unb64(text: string): Uint8Array<ArrayBuffer> {
|
|
32
|
+
return Uint8Array.from(atob(text), (c) => c.charCodeAt(0));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function importKey(raw: string): Promise<CryptoKey> {
|
|
36
|
+
return crypto.subtle.importKey("raw", unb64(raw), { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Use this key (base64 of 32 bytes) instead of the site's: for tests. */
|
|
40
|
+
export function useVaultKey(raw: string | null): void {
|
|
41
|
+
held = raw ? importKey(raw) : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function siteKey(db?: unknown): Promise<CryptoKey> {
|
|
45
|
+
if (!held) {
|
|
46
|
+
held = (async () => {
|
|
47
|
+
const database = db ?? (await (await import("emdash/runtime")).getDb());
|
|
48
|
+
const { OptionsRepository } = await import("emdash/api/route-utils");
|
|
49
|
+
const options = new OptionsRepository(database as ConstructorParameters<typeof OptionsRepository>[0]);
|
|
50
|
+
await options.setIfAbsent(OPTION, b64(crypto.getRandomValues(new Uint8Array(32))));
|
|
51
|
+
const raw = await options.get(OPTION);
|
|
52
|
+
if (typeof raw !== "string") throw new Error("no session key");
|
|
53
|
+
return importKey(raw);
|
|
54
|
+
})();
|
|
55
|
+
held.catch(() => {
|
|
56
|
+
held = null;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return held;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Seal the tokens for the session. */
|
|
63
|
+
export async function seal(token: CustomerToken, db?: unknown): Promise<SealedToken> {
|
|
64
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
65
|
+
const data = new TextEncoder().encode(JSON.stringify(token));
|
|
66
|
+
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, await siteKey(db), data));
|
|
67
|
+
return { sealed: `${b64(iv)}.${b64(ct)}` };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Open what the session holds, or null when it holds none or it can't be opened. */
|
|
71
|
+
export async function unseal(value: unknown, db?: unknown): Promise<CustomerToken | null> {
|
|
72
|
+
const sealed = (value as SealedToken | null | undefined)?.sealed;
|
|
73
|
+
if (typeof sealed !== "string") return null;
|
|
74
|
+
const [iv, ct] = sealed.split(".");
|
|
75
|
+
if (!iv || !ct) return null;
|
|
76
|
+
try {
|
|
77
|
+
const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv: unb64(iv) }, await siteKey(db), unb64(ct));
|
|
78
|
+
const t = JSON.parse(new TextDecoder().decode(plain)) as CustomerToken;
|
|
79
|
+
return typeof t?.accessToken === "string" ? t : null;
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.0.
|
|
1
|
+
export const VERSION = "0.0.21";
|
package/src/webhooks.ts
CHANGED
|
@@ -161,6 +161,10 @@ export async function apply(
|
|
|
161
161
|
current.firstName = str(payload.first_name) ?? current.firstName;
|
|
162
162
|
current.lastName = str(payload.last_name) ?? current.lastName;
|
|
163
163
|
if ("tags" in payload) current.tags = cleanTags(payload.tags);
|
|
164
|
+
// Marketing consent, as Shopify states it (display and export only).
|
|
165
|
+
const consent = (raw: unknown) => str((raw as { state?: unknown } | null | undefined)?.state)?.toUpperCase() ?? null;
|
|
166
|
+
if (payload.email_marketing_consent && typeof payload.email_marketing_consent === "object") current.emailMarketing = consent(payload.email_marketing_consent);
|
|
167
|
+
if (payload.sms_marketing_consent && typeof payload.sms_marketing_consent === "object") current.smsMarketing = consent(payload.sms_marketing_consent);
|
|
164
168
|
current.profileUpdatedAt = updatedAt ?? current.profileUpdatedAt;
|
|
165
169
|
} else if (kind === "status") {
|
|
166
170
|
if (updatedAt !== null && current.statusUpdatedAt !== null && updatedAt < current.statusUpdatedAt) {
|