@shop-on/customer-runtime 0.0.0-stage → 0.2.0
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/dist/LICENSE.md +39 -0
- package/dist/customer-session-handoff.d.ts +15 -0
- package/dist/customer-session-handoff.js +12 -0
- package/dist/graphql.d.ts +21 -0
- package/dist/graphql.js +43 -0
- package/dist/handoff.d.ts +19 -0
- package/dist/handoff.js +12 -0
- package/dist/internal-api.js +72 -0
- package/dist/lease.d.ts +26 -0
- package/dist/lease.js +86 -0
- package/dist/oauth.d.ts +38 -0
- package/dist/oauth.js +137 -0
- package/dist/relay.d.ts +8 -0
- package/dist/relay.js +24 -0
- package/dist/request-scope.d.ts +9 -0
- package/dist/request-scope.js +24 -0
- package/dist/sealed-session.d.ts +27 -0
- package/dist/sealed-session.js +154 -0
- package/dist/session-cookie.d.ts +46 -0
- package/dist/session-cookie.js +28 -0
- package/dist/shop-context.d.ts +55 -0
- package/dist/shop-context.js +72 -0
- package/dist/trace.d.ts +13 -0
- package/dist/trace.js +25 -0
- package/package.json +39 -3
- package/README.md +0 -3
package/dist/LICENSE.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Cody Theme Development License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cody LLC. All rights reserved except as granted below.
|
|
4
|
+
|
|
5
|
+
"The Software" is this package, its source, and its documentation. "The
|
|
6
|
+
Platform" is Cody, the commerce platform operated by Cody LLC.
|
|
7
|
+
|
|
8
|
+
## 1. Grant
|
|
9
|
+
|
|
10
|
+
You may use, copy and modify the Software, and distribute modified copies, solely
|
|
11
|
+
to develop, test, and distribute themes, apps and other extensions that run on
|
|
12
|
+
the Platform.
|
|
13
|
+
|
|
14
|
+
## 2. Restrictions
|
|
15
|
+
|
|
16
|
+
You may not:
|
|
17
|
+
|
|
18
|
+
1. use the Software, or anything derived from it, with any commerce platform,
|
|
19
|
+
storefront or hosting service other than the Platform;
|
|
20
|
+
2. use the Software to build a product that competes with the Platform;
|
|
21
|
+
3. sublicense the Software, or distribute it other than as part of a theme,
|
|
22
|
+
app or extension for the Platform;
|
|
23
|
+
4. remove or alter this license or any copyright notice in the Software.
|
|
24
|
+
|
|
25
|
+
## 3. Your work
|
|
26
|
+
|
|
27
|
+
Themes, apps and extensions you build with the Software are yours. The parts of
|
|
28
|
+
the Software they include stay under this license.
|
|
29
|
+
|
|
30
|
+
## 4. Termination
|
|
31
|
+
|
|
32
|
+
This license ends automatically if you break any of its terms. You must then
|
|
33
|
+
stop using the Software and delete your copies of it.
|
|
34
|
+
|
|
35
|
+
## 5. No warranty
|
|
36
|
+
|
|
37
|
+
The Software is provided "as is", without warranty of any kind. Cody LLC is not
|
|
38
|
+
liable for any claim, damages or other liability arising from the Software or
|
|
39
|
+
its use.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ConsumeResult } from "./handoff.js";
|
|
2
|
+
//#region src/customer-session-handoff.d.ts
|
|
3
|
+
export interface CustomerSessionHandoffPayload {
|
|
4
|
+
shopHandle: string;
|
|
5
|
+
customerSessionToken: string;
|
|
6
|
+
customerSessionExpiresAt: string;
|
|
7
|
+
/** Customer's email if Rails has one. Some accounts auth by phone, so
|
|
8
|
+
* this can be absent; consumers fall back to the form input. */
|
|
9
|
+
customerEmail?: string;
|
|
10
|
+
createdAt: number;
|
|
11
|
+
}
|
|
12
|
+
export declare function createCustomerSessionHandoff(payload: CustomerSessionHandoffPayload): Promise<string>;
|
|
13
|
+
export declare function consumeCustomerSessionHandoff(nonce: string): Promise<ConsumeResult<CustomerSessionHandoffPayload>>;
|
|
14
|
+
//#endregion
|
|
15
|
+
export type { ConsumeResult };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { internalDelete, internalPost } from "./internal-api.js";
|
|
2
|
+
//#region src/customer-session-handoff.ts
|
|
3
|
+
const KIND = "customer_session_handoff";
|
|
4
|
+
async function createCustomerSessionHandoff(payload) {
|
|
5
|
+
const { nonce } = await internalPost(`handoff/${KIND}`, { payload });
|
|
6
|
+
return nonce;
|
|
7
|
+
}
|
|
8
|
+
async function consumeCustomerSessionHandoff(nonce) {
|
|
9
|
+
return internalDelete(`handoff/${KIND}/${encodeURIComponent(nonce)}`);
|
|
10
|
+
}
|
|
11
|
+
//#endregion
|
|
12
|
+
export { consumeCustomerSessionHandoff, createCustomerSessionHandoff };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { TadaDocumentNode } from "gql.tada";
|
|
2
|
+
//#region src/graphql.d.ts
|
|
3
|
+
export interface StorefrontRequestOptions {
|
|
4
|
+
/** Pass through as `X-Customer-Session` so the Rails resolver can attach
|
|
5
|
+
* customer context to the call. Required for customer-scoped operations
|
|
6
|
+
* (e.g. `customerLogout`); omit for unauthenticated cart/checkout flows. */
|
|
7
|
+
customerSessionToken?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Typed GraphQL request against the Rails storefront endpoint, authenticated
|
|
11
|
+
* via the per-shop OAuth client_credentials token from `oauth.ts`. On a 401
|
|
12
|
+
* the token it sent is evicted and the request retried once — handles clock
|
|
13
|
+
* skew and silent revocation without bubbling a transient error up.
|
|
14
|
+
*
|
|
15
|
+
* GraphQL-level errors surface as `ClientError` from `graphql-request`.
|
|
16
|
+
* `customer-runtime` doesn't own the schema (each consuming app brings its
|
|
17
|
+
* own gql.tada setup) so `TResult` / `TVariables` come from the caller's
|
|
18
|
+
* document type.
|
|
19
|
+
*/
|
|
20
|
+
export declare function storefrontRequest<TResult, TVariables extends Record<string, unknown> = Record<string, unknown>>(shopHandle: string, document: TadaDocumentNode<TResult, TVariables>, variables?: TVariables, options?: StorefrontRequestOptions): Promise<TResult>;
|
|
21
|
+
//#endregion
|
package/dist/graphql.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { relayHeaders } from "./relay.js";
|
|
2
|
+
import { traceHeaders } from "./trace.js";
|
|
3
|
+
import { evictStorefrontToken, getStorefrontToken } from "./oauth.js";
|
|
4
|
+
import { ClientError, GraphQLClient } from "graphql-request";
|
|
5
|
+
//#region src/graphql.ts
|
|
6
|
+
const API_URL = process.env.SPN_CUSTOMER_API_URL ?? "http://localhost:3003";
|
|
7
|
+
function buildClient(token, extra) {
|
|
8
|
+
return new GraphQLClient(`${API_URL}/graphql`, { headers: {
|
|
9
|
+
Authorization: `Bearer ${token}`,
|
|
10
|
+
...extra
|
|
11
|
+
} });
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Typed GraphQL request against the Rails storefront endpoint, authenticated
|
|
15
|
+
* via the per-shop OAuth client_credentials token from `oauth.ts`. On a 401
|
|
16
|
+
* the token it sent is evicted and the request retried once — handles clock
|
|
17
|
+
* skew and silent revocation without bubbling a transient error up.
|
|
18
|
+
*
|
|
19
|
+
* GraphQL-level errors surface as `ClientError` from `graphql-request`.
|
|
20
|
+
* `customer-runtime` doesn't own the schema (each consuming app brings its
|
|
21
|
+
* own gql.tada setup) so `TResult` / `TVariables` come from the caller's
|
|
22
|
+
* document type.
|
|
23
|
+
*/
|
|
24
|
+
async function storefrontRequest(shopHandle, document, variables, options) {
|
|
25
|
+
const extra = {
|
|
26
|
+
...options?.customerSessionToken ? { "X-Customer-Session": options.customerSessionToken } : {},
|
|
27
|
+
...traceHeaders(),
|
|
28
|
+
...relayHeaders()
|
|
29
|
+
};
|
|
30
|
+
let token = await getStorefrontToken(shopHandle);
|
|
31
|
+
try {
|
|
32
|
+
return await buildClient(token, extra).request(document, variables);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
if (err instanceof ClientError && err.response.status === 401) {
|
|
35
|
+
evictStorefrontToken(shopHandle, token);
|
|
36
|
+
token = await getStorefrontToken(shopHandle);
|
|
37
|
+
return await buildClient(token, extra).request(document, variables);
|
|
38
|
+
}
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
//#endregion
|
|
43
|
+
export { storefrontRequest };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
//#region src/handoff.d.ts
|
|
2
|
+
export interface HandoffPayload {
|
|
3
|
+
shopHandle: string;
|
|
4
|
+
cartToken: string;
|
|
5
|
+
customerSessionToken?: string;
|
|
6
|
+
customerSessionExpiresAt?: string;
|
|
7
|
+
customerEmail?: string;
|
|
8
|
+
createdAt: number;
|
|
9
|
+
}
|
|
10
|
+
export type ConsumeResult<P> = {
|
|
11
|
+
ok: true;
|
|
12
|
+
payload: P;
|
|
13
|
+
} | {
|
|
14
|
+
ok: false;
|
|
15
|
+
reason: "missing_or_consumed" | "malformed";
|
|
16
|
+
};
|
|
17
|
+
export declare function createHandoff(payload: HandoffPayload): Promise<string>;
|
|
18
|
+
export declare function consumeHandoff(nonce: string): Promise<ConsumeResult<HandoffPayload>>;
|
|
19
|
+
//#endregion
|
package/dist/handoff.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { internalDelete, internalPost } from "./internal-api.js";
|
|
2
|
+
//#region src/handoff.ts
|
|
3
|
+
const KIND = "checkout_handoff";
|
|
4
|
+
async function createHandoff(payload) {
|
|
5
|
+
const { nonce } = await internalPost(`handoff/${KIND}`, { payload });
|
|
6
|
+
return nonce;
|
|
7
|
+
}
|
|
8
|
+
async function consumeHandoff(nonce) {
|
|
9
|
+
return internalDelete(`handoff/${KIND}/${encodeURIComponent(nonce)}`);
|
|
10
|
+
}
|
|
11
|
+
//#endregion
|
|
12
|
+
export { consumeHandoff, createHandoff };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { relayHeaders } from "./relay.js";
|
|
2
|
+
import { traceHeaders } from "./trace.js";
|
|
3
|
+
//#region src/internal-api.ts
|
|
4
|
+
function apiUrl() {
|
|
5
|
+
return process.env.SPN_CUSTOMER_API_URL ?? "http://localhost:3003";
|
|
6
|
+
}
|
|
7
|
+
function internalSecret() {
|
|
8
|
+
return process.env.SPN_INTERNAL_API_SECRET ?? "";
|
|
9
|
+
}
|
|
10
|
+
var InternalApiError = class extends Error {
|
|
11
|
+
status;
|
|
12
|
+
bodySnippet;
|
|
13
|
+
constructor(path, status, bodySnippet) {
|
|
14
|
+
super(`[customer-runtime/internal-api] ${path} returned ${status}: ${bodySnippet}`);
|
|
15
|
+
this.name = "InternalApiError";
|
|
16
|
+
this.status = status;
|
|
17
|
+
this.bodySnippet = bodySnippet;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
function requireSecret(path) {
|
|
21
|
+
const secret = internalSecret();
|
|
22
|
+
if (!secret) throw new Error(`[customer-runtime/internal-api] SPN_INTERNAL_API_SECRET is unset; refusing to call ${path} without auth`);
|
|
23
|
+
return secret;
|
|
24
|
+
}
|
|
25
|
+
async function readBodySnippet(res) {
|
|
26
|
+
try {
|
|
27
|
+
return (await res.text()).slice(0, 200);
|
|
28
|
+
} catch {
|
|
29
|
+
return "";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* POST a JSON body to `/api/internal/customer/<path>`. Throws
|
|
34
|
+
* `InternalApiError` on non-2xx so callers can decide how to map status →
|
|
35
|
+
* domain outcome (e.g. token mint maps 404 → CredentialsUnavailableError).
|
|
36
|
+
*/
|
|
37
|
+
async function internalPost(path, body, { signal } = {}) {
|
|
38
|
+
const secret = requireSecret(path);
|
|
39
|
+
const res = await fetch(`${apiUrl()}/api/internal/customer/${path}`, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: {
|
|
42
|
+
"Content-Type": "application/json",
|
|
43
|
+
"X-Internal-Auth": secret,
|
|
44
|
+
...traceHeaders(),
|
|
45
|
+
...relayHeaders()
|
|
46
|
+
},
|
|
47
|
+
body: JSON.stringify(body),
|
|
48
|
+
signal
|
|
49
|
+
});
|
|
50
|
+
if (!res.ok) throw new InternalApiError(path, res.status, await readBodySnippet(res));
|
|
51
|
+
return await res.json();
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* DELETE `/api/internal/customer/<path>`. Used by the handoff consume path —
|
|
55
|
+
* the endpoint always returns 200 (with `{ ok: false, reason }` on miss), so
|
|
56
|
+
* a non-2xx here is a real error, not a domain miss.
|
|
57
|
+
*/
|
|
58
|
+
async function internalDelete(path) {
|
|
59
|
+
const secret = requireSecret(path);
|
|
60
|
+
const res = await fetch(`${apiUrl()}/api/internal/customer/${path}`, {
|
|
61
|
+
method: "DELETE",
|
|
62
|
+
headers: {
|
|
63
|
+
"X-Internal-Auth": secret,
|
|
64
|
+
...traceHeaders(),
|
|
65
|
+
...relayHeaders()
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
if (!res.ok) throw new InternalApiError(path, res.status, await readBodySnippet(res));
|
|
69
|
+
return await res.json();
|
|
70
|
+
}
|
|
71
|
+
//#endregion
|
|
72
|
+
export { InternalApiError, internalDelete, internalPost };
|
package/dist/lease.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
//#region src/lease.d.ts
|
|
2
|
+
export interface Lease<T> {
|
|
3
|
+
run(key: string, work: () => Promise<T>): Promise<T>;
|
|
4
|
+
/** A live lease exists and this request isn't the one running it. */
|
|
5
|
+
heldByOther(key: string): boolean;
|
|
6
|
+
/** Test-only: outcome records not yet swept. */
|
|
7
|
+
outcomeCount(): number;
|
|
8
|
+
/** Test-only: lease records not yet released or swept. */
|
|
9
|
+
leaseCount(): number;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* One `work` per key across concurrent requests. The first caller takes the
|
|
13
|
+
* lease and runs `work`; others poll on their own timer and take the outcome
|
|
14
|
+
* it settles with (value or error). If the holder never settles, the next
|
|
15
|
+
* waiter takes over once the lease expires. Calls within one request share
|
|
16
|
+
* that request's promise directly.
|
|
17
|
+
*
|
|
18
|
+
* `work` must not re-enter its own key — within one request it would await
|
|
19
|
+
* itself. It should settle within `leaseMs`; after that a waiter takes over
|
|
20
|
+
* and the two run concurrently.
|
|
21
|
+
*/
|
|
22
|
+
export declare function createLease<T>({ leaseMs, pollMs }?: {
|
|
23
|
+
leaseMs?: number;
|
|
24
|
+
pollMs?: number;
|
|
25
|
+
}): Lease<T>;
|
|
26
|
+
//#endregion
|
package/dist/lease.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { requestSlot } from "./request-scope.js";
|
|
2
|
+
//#region src/lease.ts
|
|
3
|
+
const isLive = (lease) => lease !== void 0 && lease.until > Date.now();
|
|
4
|
+
/**
|
|
5
|
+
* One `work` per key across concurrent requests. The first caller takes the
|
|
6
|
+
* lease and runs `work`; others poll on their own timer and take the outcome
|
|
7
|
+
* it settles with (value or error). If the holder never settles, the next
|
|
8
|
+
* waiter takes over once the lease expires. Calls within one request share
|
|
9
|
+
* that request's promise directly.
|
|
10
|
+
*
|
|
11
|
+
* `work` must not re-enter its own key — within one request it would await
|
|
12
|
+
* itself. It should settle within `leaseMs`; after that a waiter takes over
|
|
13
|
+
* and the two run concurrently.
|
|
14
|
+
*/
|
|
15
|
+
function createLease({ leaseMs = 3e3, pollMs = 10 } = {}) {
|
|
16
|
+
const leases = /* @__PURE__ */ new Map();
|
|
17
|
+
const outcomes = /* @__PURE__ */ new Map();
|
|
18
|
+
const SLOT = Symbol("lease");
|
|
19
|
+
const ownSlot = () => requestSlot(SLOT, () => /* @__PURE__ */ new Map());
|
|
20
|
+
const sweep = (now) => {
|
|
21
|
+
for (const [k, outcome] of outcomes) if (now - outcome.settledAt > leaseMs) outcomes.delete(k);
|
|
22
|
+
for (const [k, lease] of leases) if (lease.until <= now) leases.delete(k);
|
|
23
|
+
};
|
|
24
|
+
const take = (key, work, slot) => {
|
|
25
|
+
const takenAt = Date.now();
|
|
26
|
+
sweep(takenAt);
|
|
27
|
+
const owner = {};
|
|
28
|
+
leases.set(key, {
|
|
29
|
+
until: takenAt + leaseMs,
|
|
30
|
+
owner
|
|
31
|
+
});
|
|
32
|
+
let promise;
|
|
33
|
+
try {
|
|
34
|
+
promise = work();
|
|
35
|
+
} catch (error) {
|
|
36
|
+
promise = Promise.reject(error);
|
|
37
|
+
}
|
|
38
|
+
slot?.set(key, promise);
|
|
39
|
+
const settle = (result) => {
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
const latest = outcomes.get(key);
|
|
42
|
+
if (!latest || latest.takenAt <= takenAt) outcomes.set(key, {
|
|
43
|
+
takenAt,
|
|
44
|
+
settledAt: now,
|
|
45
|
+
...result
|
|
46
|
+
});
|
|
47
|
+
if (leases.get(key)?.owner === owner) leases.delete(key);
|
|
48
|
+
if (slot?.get(key) === promise) slot.delete(key);
|
|
49
|
+
sweep(now);
|
|
50
|
+
};
|
|
51
|
+
promise.then((value) => settle({
|
|
52
|
+
ok: true,
|
|
53
|
+
value
|
|
54
|
+
}), (error) => settle({
|
|
55
|
+
ok: false,
|
|
56
|
+
error
|
|
57
|
+
}));
|
|
58
|
+
return promise;
|
|
59
|
+
};
|
|
60
|
+
const run = async (key, work) => {
|
|
61
|
+
const slot = ownSlot();
|
|
62
|
+
const own = slot?.get(key);
|
|
63
|
+
if (own) return await own;
|
|
64
|
+
let waitStart;
|
|
65
|
+
while (isLive(leases.get(key))) {
|
|
66
|
+
waitStart ??= Date.now();
|
|
67
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
68
|
+
const mine = slot?.get(key);
|
|
69
|
+
if (mine) return await mine;
|
|
70
|
+
const outcome = outcomes.get(key);
|
|
71
|
+
if (outcome && outcome.settledAt >= waitStart) {
|
|
72
|
+
if (outcome.ok) return outcome.value;
|
|
73
|
+
throw outcome.error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return await take(key, work, slot);
|
|
77
|
+
};
|
|
78
|
+
return {
|
|
79
|
+
run,
|
|
80
|
+
heldByOther: (key) => isLive(leases.get(key)) && !ownSlot()?.has(key),
|
|
81
|
+
outcomeCount: () => outcomes.size,
|
|
82
|
+
leaseCount: () => leases.size
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
//#endregion
|
|
86
|
+
export { createLease };
|
package/dist/oauth.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
//#region src/oauth.d.ts
|
|
2
|
+
export declare const MINT_LEASE_MS = 3000;
|
|
3
|
+
export declare const MINT_FAILURE_HOLD_MS = 2000;
|
|
4
|
+
export declare class OAuthMintError extends Error {
|
|
5
|
+
readonly shopHandle: string;
|
|
6
|
+
/** True when Rails returned 404 — the shop doesn't exist. (A shop with no
|
|
7
|
+
* storefront application is a 422, treated as transient.) Distinguishes
|
|
8
|
+
* "this shop is gone" from "the network is flaky". Routes use this to
|
|
9
|
+
* decide between rendering the shop-not-found page and the transient-error
|
|
10
|
+
* page. */
|
|
11
|
+
readonly notFound: boolean;
|
|
12
|
+
/** True when Rails returned 429 naming the `relay/ip` throttle — the
|
|
13
|
+
* buyer's own bucket, not one other buyers share. */
|
|
14
|
+
readonly buyerThrottled: boolean;
|
|
15
|
+
constructor(shopHandle: string, detail: string, opts?: {
|
|
16
|
+
notFound?: boolean;
|
|
17
|
+
buyerThrottled?: boolean;
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Get (or mint) the storefront access token for `shopHandle`. Cached
|
|
22
|
+
* in-process; concurrent requests for one shop share one mint's outcome
|
|
23
|
+
* through the mint lease, so a cold cache costs exactly one Rails round-trip.
|
|
24
|
+
* Throws `ShopHandleInvalidError` on a malformed handle, which can arrive here
|
|
25
|
+
* unchecked from a server route or an RPC call.
|
|
26
|
+
*/
|
|
27
|
+
export declare function getStorefrontToken(shopHandle: string): Promise<string>;
|
|
28
|
+
/**
|
|
29
|
+
* Drop the cached token for `shopHandle` if it is still `token`. Call when a
|
|
30
|
+
* Rails response signals `token` is no longer valid (typically a 401 from a
|
|
31
|
+
* downstream GraphQL request) so the next call re-mints. Compare-and-clear: a
|
|
32
|
+
* concurrent caller may already have re-minted, and a late 401 on the old
|
|
33
|
+
* token must not throw the new one away.
|
|
34
|
+
*/
|
|
35
|
+
export declare function evictStorefrontToken(shopHandle: string, token: string): void;
|
|
36
|
+
export declare function __resetForTesting(): void;
|
|
37
|
+
export declare function __mintHoldCountForTesting(): number;
|
|
38
|
+
//#endregion
|
package/dist/oauth.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { InternalApiError, internalPost } from "./internal-api.js";
|
|
2
|
+
import { createLease } from "./lease.js";
|
|
3
|
+
import { resolveShopHandleFromParams } from "./shop-context.js";
|
|
4
|
+
//#region src/oauth.ts
|
|
5
|
+
const REFRESH_SKEW_MS = 3e5;
|
|
6
|
+
const STALE_TOKEN_MARGIN_MS = 3e4;
|
|
7
|
+
const CACHE_MAX_ENTRIES = 1e3;
|
|
8
|
+
const cache = /* @__PURE__ */ new Map();
|
|
9
|
+
const MINT_LEASE_MS = 3e3;
|
|
10
|
+
let mintLease = createLease({ leaseMs: MINT_LEASE_MS });
|
|
11
|
+
const MINT_TIMEOUT_MS = 2750;
|
|
12
|
+
const MINT_FAILURE_HOLD_MS = 2e3;
|
|
13
|
+
const mintHolds = /* @__PURE__ */ new Map();
|
|
14
|
+
function touch(shopHandle, token) {
|
|
15
|
+
cache.delete(shopHandle);
|
|
16
|
+
cache.set(shopHandle, token);
|
|
17
|
+
if (cache.size > CACHE_MAX_ENTRIES) {
|
|
18
|
+
const oldest = cache.keys().next().value;
|
|
19
|
+
if (oldest !== void 0) cache.delete(oldest);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
var OAuthMintError = class extends Error {
|
|
23
|
+
shopHandle;
|
|
24
|
+
/** True when Rails returned 404 — the shop doesn't exist. (A shop with no
|
|
25
|
+
* storefront application is a 422, treated as transient.) Distinguishes
|
|
26
|
+
* "this shop is gone" from "the network is flaky". Routes use this to
|
|
27
|
+
* decide between rendering the shop-not-found page and the transient-error
|
|
28
|
+
* page. */
|
|
29
|
+
notFound;
|
|
30
|
+
/** True when Rails returned 429 naming the `relay/ip` throttle — the
|
|
31
|
+
* buyer's own bucket, not one other buyers share. */
|
|
32
|
+
buyerThrottled;
|
|
33
|
+
constructor(shopHandle, detail, opts) {
|
|
34
|
+
super(`[customer-runtime/oauth] token mint failed for shop ${shopHandle}: ${detail}`);
|
|
35
|
+
this.name = "OAuthMintError";
|
|
36
|
+
this.shopHandle = shopHandle;
|
|
37
|
+
this.notFound = opts?.notFound ?? false;
|
|
38
|
+
this.buyerThrottled = opts?.buyerThrottled ?? false;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
function throttleName(bodySnippet) {
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(bodySnippet).throttle;
|
|
44
|
+
} catch {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async function mintToken(shopHandle) {
|
|
49
|
+
let response;
|
|
50
|
+
try {
|
|
51
|
+
response = await internalPost("storefront-token", { shop_handle: shopHandle }, { signal: AbortSignal.timeout(MINT_TIMEOUT_MS) });
|
|
52
|
+
} catch (err) {
|
|
53
|
+
if (err instanceof InternalApiError) throw new OAuthMintError(shopHandle, `${err.status}: ${err.bodySnippet}`, {
|
|
54
|
+
notFound: err.status === 404,
|
|
55
|
+
buyerThrottled: err.status === 429 && throttleName(err.bodySnippet) === "relay/ip"
|
|
56
|
+
});
|
|
57
|
+
throw new OAuthMintError(shopHandle, err instanceof Error ? err.message : String(err));
|
|
58
|
+
}
|
|
59
|
+
if (typeof response.access_token !== "string" || response.access_token.length === 0) throw new OAuthMintError(shopHandle, "missing or invalid access_token in response");
|
|
60
|
+
const expiresAtMs = Date.parse(response.expires_at);
|
|
61
|
+
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= Date.now()) throw new OAuthMintError(shopHandle, `invalid expires_at: ${String(response.expires_at)}`);
|
|
62
|
+
return {
|
|
63
|
+
accessToken: response.access_token,
|
|
64
|
+
expiresAtMs
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function activeMintHold(shopHandle) {
|
|
68
|
+
const hold = mintHolds.get(shopHandle);
|
|
69
|
+
return hold && hold.until > Date.now() ? hold : null;
|
|
70
|
+
}
|
|
71
|
+
function holdMint(shopHandle, error) {
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
for (const [k, hold] of mintHolds) if (hold.until <= now) mintHolds.delete(k);
|
|
74
|
+
mintHolds.set(shopHandle, {
|
|
75
|
+
until: now + MINT_FAILURE_HOLD_MS,
|
|
76
|
+
error
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
async function mintUnderLease(shopHandle) {
|
|
80
|
+
let fresh;
|
|
81
|
+
try {
|
|
82
|
+
fresh = await mintToken(shopHandle);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
if (err instanceof OAuthMintError && err.notFound) cache.delete(shopHandle);
|
|
85
|
+
if (!(err instanceof OAuthMintError && err.buyerThrottled)) holdMint(shopHandle, err);
|
|
86
|
+
throw err;
|
|
87
|
+
}
|
|
88
|
+
mintHolds.delete(shopHandle);
|
|
89
|
+
touch(shopHandle, fresh);
|
|
90
|
+
return fresh;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Get (or mint) the storefront access token for `shopHandle`. Cached
|
|
94
|
+
* in-process; concurrent requests for one shop share one mint's outcome
|
|
95
|
+
* through the mint lease, so a cold cache costs exactly one Rails round-trip.
|
|
96
|
+
* Throws `ShopHandleInvalidError` on a malformed handle, which can arrive here
|
|
97
|
+
* unchecked from a server route or an RPC call.
|
|
98
|
+
*/
|
|
99
|
+
async function getStorefrontToken(shopHandle) {
|
|
100
|
+
resolveShopHandleFromParams({ shopHandle });
|
|
101
|
+
const now = Date.now();
|
|
102
|
+
const hit = cache.get(shopHandle);
|
|
103
|
+
if (hit && hit.expiresAtMs - REFRESH_SKEW_MS > now) {
|
|
104
|
+
touch(shopHandle, hit);
|
|
105
|
+
return hit.accessToken;
|
|
106
|
+
}
|
|
107
|
+
const prev = hit && hit.expiresAtMs - STALE_TOKEN_MARGIN_MS > now ? hit.accessToken : null;
|
|
108
|
+
const held = activeMintHold(shopHandle);
|
|
109
|
+
if (prev && (held || mintLease.heldByOther(shopHandle))) return prev;
|
|
110
|
+
if (held) throw held.error;
|
|
111
|
+
try {
|
|
112
|
+
return (await mintLease.run(shopHandle, () => mintUnderLease(shopHandle))).accessToken;
|
|
113
|
+
} catch (err) {
|
|
114
|
+
if (prev && cache.get(shopHandle)?.accessToken === prev) return prev;
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Drop the cached token for `shopHandle` if it is still `token`. Call when a
|
|
120
|
+
* Rails response signals `token` is no longer valid (typically a 401 from a
|
|
121
|
+
* downstream GraphQL request) so the next call re-mints. Compare-and-clear: a
|
|
122
|
+
* concurrent caller may already have re-minted, and a late 401 on the old
|
|
123
|
+
* token must not throw the new one away.
|
|
124
|
+
*/
|
|
125
|
+
function evictStorefrontToken(shopHandle, token) {
|
|
126
|
+
if (cache.get(shopHandle)?.accessToken === token) cache.delete(shopHandle);
|
|
127
|
+
}
|
|
128
|
+
function __resetForTesting() {
|
|
129
|
+
cache.clear();
|
|
130
|
+
mintHolds.clear();
|
|
131
|
+
mintLease = createLease({ leaseMs: MINT_LEASE_MS });
|
|
132
|
+
}
|
|
133
|
+
function __mintHoldCountForTesting() {
|
|
134
|
+
return mintHolds.size;
|
|
135
|
+
}
|
|
136
|
+
//#endregion
|
|
137
|
+
export { MINT_FAILURE_HOLD_MS, MINT_LEASE_MS, OAuthMintError, __mintHoldCountForTesting, __resetForTesting, evictStorefrontToken, getStorefrontToken };
|
package/dist/relay.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
//#region src/relay.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* `X-Buyer-IP` from Cloudflare's `CF-Connecting-IP` on the request this call
|
|
4
|
+
* runs under, plus `X-Relay-Auth` when SPN_RELAY_SECRET is set. Empty outside
|
|
5
|
+
* a request or without a valid IP. Spread these last into outgoing headers.
|
|
6
|
+
*/
|
|
7
|
+
export declare function relayHeaders(): Record<string, string>;
|
|
8
|
+
//#endregion
|
package/dist/relay.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { currentRequest } from "./request-scope.js";
|
|
2
|
+
//#region src/relay.ts
|
|
3
|
+
const IPV4_OCTET = "(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)";
|
|
4
|
+
const IPV4 = new RegExp(`^${IPV4_OCTET}(\\.${IPV4_OCTET}){3}$`);
|
|
5
|
+
function isIP(value) {
|
|
6
|
+
if (IPV4.test(value)) return true;
|
|
7
|
+
return /^[\da-f:.]+$/i.test(value) && value.includes(":") && URL.canParse(`http://[${value}]`);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* `X-Buyer-IP` from Cloudflare's `CF-Connecting-IP` on the request this call
|
|
11
|
+
* runs under, plus `X-Relay-Auth` when SPN_RELAY_SECRET is set. Empty outside
|
|
12
|
+
* a request or without a valid IP. Spread these last into outgoing headers.
|
|
13
|
+
*/
|
|
14
|
+
function relayHeaders() {
|
|
15
|
+
const ip = currentRequest()?.headers.get("cf-connecting-ip");
|
|
16
|
+
if (!ip || !isIP(ip)) return {};
|
|
17
|
+
const secret = process.env.SPN_RELAY_SECRET;
|
|
18
|
+
return secret ? {
|
|
19
|
+
"X-Buyer-IP": ip,
|
|
20
|
+
"X-Relay-Auth": secret
|
|
21
|
+
} : { "X-Buyer-IP": ip };
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
24
|
+
export { relayHeaders };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
//#region src/request-scope.d.ts
|
|
2
|
+
/** The request this call runs under; undefined outside one (browser, tests, pre-handler). */
|
|
3
|
+
export declare function currentRequest(): Request | undefined;
|
|
4
|
+
/**
|
|
5
|
+
* Per-request state under `key`, created by `init` on first use. Undefined
|
|
6
|
+
* outside a request — callers fall back to not sharing at all.
|
|
7
|
+
*/
|
|
8
|
+
export declare function requestSlot<T>(key: symbol, init: () => T): T | undefined;
|
|
9
|
+
//#endregion
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
//#region src/request-scope.ts
|
|
2
|
+
const EVENT_STORAGE_KEY = Symbol.for("tanstack-start:event-storage");
|
|
3
|
+
/** The request this call runs under; undefined outside one (browser, tests, pre-handler). */
|
|
4
|
+
function currentRequest() {
|
|
5
|
+
return globalThis[EVENT_STORAGE_KEY]?.getStore()?.request;
|
|
6
|
+
}
|
|
7
|
+
const slots = /* @__PURE__ */ new WeakMap();
|
|
8
|
+
/**
|
|
9
|
+
* Per-request state under `key`, created by `init` on first use. Undefined
|
|
10
|
+
* outside a request — callers fall back to not sharing at all.
|
|
11
|
+
*/
|
|
12
|
+
function requestSlot(key, init) {
|
|
13
|
+
const req = currentRequest();
|
|
14
|
+
if (!req) return void 0;
|
|
15
|
+
let bag = slots.get(req);
|
|
16
|
+
if (!bag) {
|
|
17
|
+
bag = /* @__PURE__ */ new Map();
|
|
18
|
+
slots.set(req, bag);
|
|
19
|
+
}
|
|
20
|
+
if (!bag.has(key)) bag.set(key, init());
|
|
21
|
+
return bag.get(key);
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
24
|
+
export { currentRequest, requestSlot };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
//#region src/sealed-session.d.ts
|
|
2
|
+
export interface SealedSessionConfig {
|
|
3
|
+
name: string;
|
|
4
|
+
/** At least 32 chars. */
|
|
5
|
+
password: string;
|
|
6
|
+
/** Seconds. Seal TTL plus an absolute cap counted from the first write. */
|
|
7
|
+
maxAge?: number;
|
|
8
|
+
cookie: {
|
|
9
|
+
secure: boolean;
|
|
10
|
+
httpOnly: true;
|
|
11
|
+
sameSite: "lax";
|
|
12
|
+
path: "/";
|
|
13
|
+
/** Seconds. Sent as Max-Age, refreshed on every write. */
|
|
14
|
+
maxAge?: number;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export interface SealedSession<T> {
|
|
18
|
+
/** Live: reflects every write committed so far in this request. */
|
|
19
|
+
readonly data: Partial<T>;
|
|
20
|
+
update(patch: Partial<T>): Promise<void>;
|
|
21
|
+
clear(): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
export declare class SessionTooLargeError extends Error {
|
|
24
|
+
constructor(name: string, length: number);
|
|
25
|
+
}
|
|
26
|
+
export declare function openSession<T>(config: SealedSessionConfig): Promise<SealedSession<T>>;
|
|
27
|
+
//#endregion
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { requestSlot } from "./request-scope.js";
|
|
2
|
+
import { deleteCookie, getCookie, setCookie } from "@tanstack/react-start/server";
|
|
3
|
+
import { defaults, seal, unseal } from "iron-webcrypto";
|
|
4
|
+
//#region src/sealed-session.ts
|
|
5
|
+
/**
|
|
6
|
+
* Encrypted cookie sessions, wire-compatible with the `useSession` TanStack
|
|
7
|
+
* Start 1.168 shipped (h3 2.0.1-rc.20) and 1.169 removed. Both seal
|
|
8
|
+
* `{ id, createdAt, data }` with iron (Fe26.2, iron's defaults), so a cookie
|
|
9
|
+
* either one wrote opens in the other — no sign-out on deploy, and a rollback
|
|
10
|
+
* stays safe. `test/fixtures/h3-sessions.json` holds cookies Start 1.168
|
|
11
|
+
* minted; `test/sealed-session.test.ts` keeps them opening.
|
|
12
|
+
*
|
|
13
|
+
* Where it deliberately differs from h3:
|
|
14
|
+
* - A read never writes. h3 minted and set an empty session on every
|
|
15
|
+
* cookieless read.
|
|
16
|
+
* - `clear()` sticks for the rest of the request. h3 re-read the stale
|
|
17
|
+
* request cookie afterwards.
|
|
18
|
+
* - Writes are one cookie, never chunked. A seal over 3,800 chars throws
|
|
19
|
+
* `SessionTooLargeError` instead. h3's chunked cookies still open, and
|
|
20
|
+
* the next write or clear deletes their chunks.
|
|
21
|
+
* - No `x-<name>-session` request header.
|
|
22
|
+
*
|
|
23
|
+
* Each request shares one state per cookie name (`requestSlot`), so parallel
|
|
24
|
+
* opens unseal once, and writes queue: each merges onto the state the one
|
|
25
|
+
* before it committed, in call order.
|
|
26
|
+
*/
|
|
27
|
+
var SessionTooLargeError = class extends Error {
|
|
28
|
+
constructor(name, length) {
|
|
29
|
+
super(`Session "${name}" seals to ${length} chars, over the ${MAX_SEALED_LENGTH} limit`);
|
|
30
|
+
this.name = "SessionTooLargeError";
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
const MAX_SEALED_LENGTH = 3800;
|
|
34
|
+
const CHUNKED = "__chunked__";
|
|
35
|
+
const MAX_CHUNKS = 100;
|
|
36
|
+
const DEFAULT_COOKIE = {
|
|
37
|
+
path: "/",
|
|
38
|
+
secure: true,
|
|
39
|
+
httpOnly: true
|
|
40
|
+
};
|
|
41
|
+
const empty = () => ({
|
|
42
|
+
id: "",
|
|
43
|
+
createdAt: 0,
|
|
44
|
+
data: {}
|
|
45
|
+
});
|
|
46
|
+
async function openSession(config) {
|
|
47
|
+
if (typeof config.password !== "string" || config.password.length < 32) throw new Error(`Session "${config.name}" needs a password of at least 32 characters`);
|
|
48
|
+
const init = () => {
|
|
49
|
+
const slot = {
|
|
50
|
+
chain: Promise.resolve(),
|
|
51
|
+
state: empty()
|
|
52
|
+
};
|
|
53
|
+
slot.chain = read(config).then((state) => {
|
|
54
|
+
slot.state = state;
|
|
55
|
+
});
|
|
56
|
+
return slot;
|
|
57
|
+
};
|
|
58
|
+
const slot = requestSlot(Symbol.for(`shop-on:sealed-session:${config.name}`), init) ?? init();
|
|
59
|
+
await slot.chain;
|
|
60
|
+
const enqueue = (run) => {
|
|
61
|
+
const result = slot.chain.then(run);
|
|
62
|
+
slot.chain = result.catch(() => {});
|
|
63
|
+
return result;
|
|
64
|
+
};
|
|
65
|
+
return {
|
|
66
|
+
get data() {
|
|
67
|
+
return slot.state.data;
|
|
68
|
+
},
|
|
69
|
+
update: (patch) => enqueue(() => write(config, slot, patch)),
|
|
70
|
+
clear: () => enqueue(() => {
|
|
71
|
+
slot.state = empty();
|
|
72
|
+
deleteCookie(config.name, {
|
|
73
|
+
...DEFAULT_COOKIE,
|
|
74
|
+
...config.cookie
|
|
75
|
+
});
|
|
76
|
+
deleteChunks(config);
|
|
77
|
+
})
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function sealOptions(config) {
|
|
81
|
+
return {
|
|
82
|
+
...defaults,
|
|
83
|
+
ttl: config.maxAge ? config.maxAge * 1e3 : 0
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
async function read(config) {
|
|
87
|
+
const sealed = readSealed(config.name);
|
|
88
|
+
if (!sealed) return empty();
|
|
89
|
+
try {
|
|
90
|
+
const opened = await unseal(sealed, config.password, sealOptions(config));
|
|
91
|
+
if (typeof opened?.id !== "string" || !opened.id || typeof opened.createdAt !== "number") return empty();
|
|
92
|
+
if (config.maxAge && Date.now() - opened.createdAt > config.maxAge * 1e3) return empty();
|
|
93
|
+
const data = opened.data && typeof opened.data === "object" ? { ...opened.data } : {};
|
|
94
|
+
return {
|
|
95
|
+
id: opened.id,
|
|
96
|
+
createdAt: opened.createdAt,
|
|
97
|
+
data
|
|
98
|
+
};
|
|
99
|
+
} catch {
|
|
100
|
+
return empty();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** The request's sealed value, reassembled from h3's chunks when it split one. */
|
|
104
|
+
function readSealed(name) {
|
|
105
|
+
const value = getCookie(name);
|
|
106
|
+
const count = chunkCount(value);
|
|
107
|
+
if (!count) return value?.startsWith(CHUNKED) ? void 0 : value;
|
|
108
|
+
const chunks = [];
|
|
109
|
+
for (let i = 1; i <= count; i++) {
|
|
110
|
+
const chunk = getCookie(`${name}.${i}`);
|
|
111
|
+
if (!chunk) return void 0;
|
|
112
|
+
chunks.push(chunk);
|
|
113
|
+
}
|
|
114
|
+
return chunks.join("");
|
|
115
|
+
}
|
|
116
|
+
function chunkCount(value) {
|
|
117
|
+
if (!value?.startsWith(CHUNKED)) return 0;
|
|
118
|
+
const count = Number.parseInt(value.slice(11), 10);
|
|
119
|
+
return Number.isInteger(count) && count >= 1 && count <= MAX_CHUNKS ? count : 0;
|
|
120
|
+
}
|
|
121
|
+
function deleteChunks(config) {
|
|
122
|
+
const count = chunkCount(getCookie(config.name));
|
|
123
|
+
for (let i = 1; i <= count; i++) deleteCookie(`${config.name}.${i}`, {
|
|
124
|
+
...DEFAULT_COOKIE,
|
|
125
|
+
...config.cookie
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
async function write(config, slot, patch) {
|
|
129
|
+
const previous = slot.state;
|
|
130
|
+
const fresh = !previous.id || Object.keys(previous.data).length === 0;
|
|
131
|
+
const data = JSON.parse(JSON.stringify({
|
|
132
|
+
...previous.data,
|
|
133
|
+
...patch
|
|
134
|
+
}));
|
|
135
|
+
const next = fresh ? {
|
|
136
|
+
id: globalThis.crypto.randomUUID(),
|
|
137
|
+
createdAt: Date.now(),
|
|
138
|
+
data
|
|
139
|
+
} : {
|
|
140
|
+
...previous,
|
|
141
|
+
data
|
|
142
|
+
};
|
|
143
|
+
const sealed = await seal(next, config.password, sealOptions(config));
|
|
144
|
+
if (sealed.length > MAX_SEALED_LENGTH) throw new SessionTooLargeError(config.name, sealed.length);
|
|
145
|
+
slot.state = next;
|
|
146
|
+
setCookie(config.name, sealed, {
|
|
147
|
+
...DEFAULT_COOKIE,
|
|
148
|
+
expires: config.maxAge ? new Date(next.createdAt + config.maxAge * 1e3) : void 0,
|
|
149
|
+
...config.cookie
|
|
150
|
+
});
|
|
151
|
+
deleteChunks(config);
|
|
152
|
+
}
|
|
153
|
+
//#endregion
|
|
154
|
+
export { SessionTooLargeError, openSession };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
//#region src/session-cookie.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Host-only encrypted session cookies for the central account and checkout
|
|
4
|
+
* apps. Both apps serve every shop from a single process, so we use one
|
|
5
|
+
* process-wide encryption secret per app and bind each cookie's payload to
|
|
6
|
+
* a specific `shopHandle`. A cookie minted for shop A is rejected at read
|
|
7
|
+
* time when the request resolves to shop B — `validateShopBinding` returns
|
|
8
|
+
* `undefined` for both "missing" and "wrong shop" so callers handle them
|
|
9
|
+
* uniformly (treat as logged-out).
|
|
10
|
+
*
|
|
11
|
+
* The returned `ShopBoundCookieConfig` is shape-compatible with
|
|
12
|
+
* `SealedSessionConfig` — pass it straight to `openSession`
|
|
13
|
+
* (`./sealed-session`). This module stays free of TanStack imports.
|
|
14
|
+
*/
|
|
15
|
+
export interface ShopBoundSession {
|
|
16
|
+
shopHandle: string;
|
|
17
|
+
}
|
|
18
|
+
export interface ShopBoundCookieConfig {
|
|
19
|
+
name: string;
|
|
20
|
+
password: string;
|
|
21
|
+
/** Seconds. Omit for a browser-session cookie. */
|
|
22
|
+
maxAge?: number;
|
|
23
|
+
cookie: {
|
|
24
|
+
secure: boolean;
|
|
25
|
+
httpOnly: true;
|
|
26
|
+
sameSite: "lax";
|
|
27
|
+
path: "/";
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export declare function createShopBoundCookieConfig(opts: {
|
|
31
|
+
name: string;
|
|
32
|
+
password: string;
|
|
33
|
+
/** Override `process.env.NODE_ENV === "production"` for tests. */
|
|
34
|
+
secure?: boolean;
|
|
35
|
+
/** Seconds. Omit for a browser-session cookie. */
|
|
36
|
+
maxAge?: number;
|
|
37
|
+
}): ShopBoundCookieConfig;
|
|
38
|
+
/**
|
|
39
|
+
* Narrow a decoded session payload to its shop-bound form. Returns the
|
|
40
|
+
* payload only if it carries a `shopHandle` matching the expected value.
|
|
41
|
+
* Anything else (no payload, no shopHandle field, wrong shopHandle) returns
|
|
42
|
+
* `undefined`, which callers should treat as "no valid session for this
|
|
43
|
+
* shop in this request" — i.e. equivalent to logged-out.
|
|
44
|
+
*/
|
|
45
|
+
export declare function validateShopBinding<TData extends ShopBoundSession>(payload: Partial<TData> | undefined, expectedShopHandle: string): TData | undefined;
|
|
46
|
+
//#endregion
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
//#region src/session-cookie.ts
|
|
2
|
+
function createShopBoundCookieConfig(opts) {
|
|
3
|
+
return {
|
|
4
|
+
name: opts.name,
|
|
5
|
+
password: opts.password,
|
|
6
|
+
...opts.maxAge !== void 0 ? { maxAge: opts.maxAge } : {},
|
|
7
|
+
cookie: {
|
|
8
|
+
secure: opts.secure ?? process.env.NODE_ENV === "production",
|
|
9
|
+
httpOnly: true,
|
|
10
|
+
sameSite: "lax",
|
|
11
|
+
path: "/"
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Narrow a decoded session payload to its shop-bound form. Returns the
|
|
17
|
+
* payload only if it carries a `shopHandle` matching the expected value.
|
|
18
|
+
* Anything else (no payload, no shopHandle field, wrong shopHandle) returns
|
|
19
|
+
* `undefined`, which callers should treat as "no valid session for this
|
|
20
|
+
* shop in this request" — i.e. equivalent to logged-out.
|
|
21
|
+
*/
|
|
22
|
+
function validateShopBinding(payload, expectedShopHandle) {
|
|
23
|
+
if (!payload?.shopHandle) return void 0;
|
|
24
|
+
if (payload.shopHandle !== expectedShopHandle) return void 0;
|
|
25
|
+
return payload;
|
|
26
|
+
}
|
|
27
|
+
//#endregion
|
|
28
|
+
export { createShopBoundCookieConfig, validateShopBinding };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
//#region src/shop-context.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Route-context shape returned by `resolveShopContext`. Intentionally NO
|
|
4
|
+
* token field — anything returned from a TanStack Router `beforeLoad` is
|
|
5
|
+
* dehydrated into the SSR hydration payload and shipped to the browser.
|
|
6
|
+
* Server-only consumers (oauth, graphql) re-resolve the token from cache
|
|
7
|
+
* on each call, so the bearer never needs to leave the server.
|
|
8
|
+
*/
|
|
9
|
+
export interface ShopContext {
|
|
10
|
+
shopHandle: string;
|
|
11
|
+
}
|
|
12
|
+
export declare class ShopHandleInvalidError extends Error {
|
|
13
|
+
constructor(value: unknown);
|
|
14
|
+
}
|
|
15
|
+
export declare class CredentialsUnavailableError extends Error {
|
|
16
|
+
readonly shopHandle: string;
|
|
17
|
+
constructor(shopHandle: string);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Validate a shopHandle pulled from a route param. Matches the format
|
|
21
|
+
* enforced by the Rails Shop model (lowercase alnum + hyphens, leading
|
|
22
|
+
* hyphen permitted). Throws on missing/malformed so router error
|
|
23
|
+
* boundaries can surface a clean 4xx rather than carrying garbage
|
|
24
|
+
* downstream.
|
|
25
|
+
*/
|
|
26
|
+
export declare function resolveShopHandleFromParams(params: {
|
|
27
|
+
shopHandle?: string | undefined;
|
|
28
|
+
}): string;
|
|
29
|
+
/**
|
|
30
|
+
* One-shot resolver for a shop-scoped layout route's `beforeLoad`. Pulls
|
|
31
|
+
* the handle from params, primes the storefront token via a Rails round-
|
|
32
|
+
* trip so a missing shop / shop-without-storefront-application surfaces
|
|
33
|
+
* the typed `CredentialsUnavailableError` page before any child route
|
|
34
|
+
* runs, and returns the minimal context.
|
|
35
|
+
*
|
|
36
|
+
* The token preload doubles as a cache warmer: every downstream GraphQL
|
|
37
|
+
* call in this request will reuse the cached token, so the probe is not
|
|
38
|
+
* wasted work.
|
|
39
|
+
*
|
|
40
|
+
* Returns only `{ shopHandle }` — the token is intentionally discarded
|
|
41
|
+
* from the return value. Anything returned from `beforeLoad` is
|
|
42
|
+
* serialized into the SSR hydration payload and shipped to the browser,
|
|
43
|
+
* so secrets must stay server-side.
|
|
44
|
+
*
|
|
45
|
+
* Routes render a typed error page from `CredentialsUnavailableError`;
|
|
46
|
+
* the structured log emitted below is the load-bearing observability
|
|
47
|
+
* signal. (TanStack Start's `setResponseStatus` doesn't propagate through
|
|
48
|
+
* a thrown `beforeLoad`, so the wire status is 500, not 503.) Recovery
|
|
49
|
+
* for a transient Rails outage is operational; for a missing shop the
|
|
50
|
+
* recovery is to point the URL at a real handle.
|
|
51
|
+
*/
|
|
52
|
+
export declare function resolveShopContext(params: {
|
|
53
|
+
shopHandle?: string | undefined;
|
|
54
|
+
}): Promise<ShopContext>;
|
|
55
|
+
//#endregion
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
//#region src/shop-context.ts
|
|
2
|
+
const SHOP_HANDLE_PATTERN = /^[a-z0-9-]{1,63}$/;
|
|
3
|
+
var ShopHandleInvalidError = class extends Error {
|
|
4
|
+
constructor(value) {
|
|
5
|
+
super(`[customer-runtime/shop-context] invalid shopHandle: ${String(value)}`);
|
|
6
|
+
this.name = "ShopHandleInvalidError";
|
|
7
|
+
}
|
|
8
|
+
};
|
|
9
|
+
var CredentialsUnavailableError = class extends Error {
|
|
10
|
+
shopHandle;
|
|
11
|
+
constructor(shopHandle) {
|
|
12
|
+
super(`[customer-runtime/shop-context] credentials unavailable for shop: ${shopHandle}`);
|
|
13
|
+
this.name = "CredentialsUnavailableError";
|
|
14
|
+
this.shopHandle = shopHandle;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Validate a shopHandle pulled from a route param. Matches the format
|
|
19
|
+
* enforced by the Rails Shop model (lowercase alnum + hyphens, leading
|
|
20
|
+
* hyphen permitted). Throws on missing/malformed so router error
|
|
21
|
+
* boundaries can surface a clean 4xx rather than carrying garbage
|
|
22
|
+
* downstream.
|
|
23
|
+
*/
|
|
24
|
+
function resolveShopHandleFromParams(params) {
|
|
25
|
+
const handle = params.shopHandle;
|
|
26
|
+
if (typeof handle !== "string" || !SHOP_HANDLE_PATTERN.test(handle)) throw new ShopHandleInvalidError(handle);
|
|
27
|
+
return handle;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* One-shot resolver for a shop-scoped layout route's `beforeLoad`. Pulls
|
|
31
|
+
* the handle from params, primes the storefront token via a Rails round-
|
|
32
|
+
* trip so a missing shop / shop-without-storefront-application surfaces
|
|
33
|
+
* the typed `CredentialsUnavailableError` page before any child route
|
|
34
|
+
* runs, and returns the minimal context.
|
|
35
|
+
*
|
|
36
|
+
* The token preload doubles as a cache warmer: every downstream GraphQL
|
|
37
|
+
* call in this request will reuse the cached token, so the probe is not
|
|
38
|
+
* wasted work.
|
|
39
|
+
*
|
|
40
|
+
* Returns only `{ shopHandle }` — the token is intentionally discarded
|
|
41
|
+
* from the return value. Anything returned from `beforeLoad` is
|
|
42
|
+
* serialized into the SSR hydration payload and shipped to the browser,
|
|
43
|
+
* so secrets must stay server-side.
|
|
44
|
+
*
|
|
45
|
+
* Routes render a typed error page from `CredentialsUnavailableError`;
|
|
46
|
+
* the structured log emitted below is the load-bearing observability
|
|
47
|
+
* signal. (TanStack Start's `setResponseStatus` doesn't propagate through
|
|
48
|
+
* a thrown `beforeLoad`, so the wire status is 500, not 503.) Recovery
|
|
49
|
+
* for a transient Rails outage is operational; for a missing shop the
|
|
50
|
+
* recovery is to point the URL at a real handle.
|
|
51
|
+
*/
|
|
52
|
+
async function resolveShopContext(params) {
|
|
53
|
+
const shopHandle = resolveShopHandleFromParams(params);
|
|
54
|
+
const { getStorefrontToken, OAuthMintError } = await import("./oauth.js");
|
|
55
|
+
try {
|
|
56
|
+
await getStorefrontToken(shopHandle);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
if (err instanceof OAuthMintError) {
|
|
59
|
+
console.error(JSON.stringify({
|
|
60
|
+
event: "credentials_unavailable",
|
|
61
|
+
shopHandle,
|
|
62
|
+
notFound: err.notFound,
|
|
63
|
+
ts: Date.now()
|
|
64
|
+
}));
|
|
65
|
+
throw new CredentialsUnavailableError(shopHandle);
|
|
66
|
+
}
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
return { shopHandle };
|
|
70
|
+
}
|
|
71
|
+
//#endregion
|
|
72
|
+
export { CredentialsUnavailableError, ShopHandleInvalidError, resolveShopContext, resolveShopHandleFromParams };
|
package/dist/trace.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
//#region src/trace.d.ts
|
|
2
|
+
export interface TraceOptions {
|
|
3
|
+
/** Continue a valid incoming `traceparent`. Only behind a proxy that started the trace (Traefik). */
|
|
4
|
+
continueIncoming?: boolean;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* `traceparent` for the request this call runs under: one minted once per
|
|
8
|
+
* request, or with `continueIncoming` the incoming ids when valid; always
|
|
9
|
+
* sampled. Empty outside a request. Send it to our own api only, never to a
|
|
10
|
+
* third party.
|
|
11
|
+
*/
|
|
12
|
+
export declare function traceHeaders({ continueIncoming }?: TraceOptions): Record<string, string>;
|
|
13
|
+
//#endregion
|
package/dist/trace.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { currentRequest, requestSlot } from "./request-scope.js";
|
|
2
|
+
//#region src/trace.ts
|
|
3
|
+
const TRACEPARENT = /^00-((?!0{32})[\da-f]{32})-((?!0{16})[\da-f]{16})-[\da-f]{2}$/;
|
|
4
|
+
const MINTED = Symbol("minted traceparent");
|
|
5
|
+
function randomHex(bytes) {
|
|
6
|
+
const buf = crypto.getRandomValues(new Uint8Array(bytes));
|
|
7
|
+
if (buf.every((b) => b === 0)) buf[0] = 1;
|
|
8
|
+
return Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* `traceparent` for the request this call runs under: one minted once per
|
|
12
|
+
* request, or with `continueIncoming` the incoming ids when valid; always
|
|
13
|
+
* sampled. Empty outside a request. Send it to our own api only, never to a
|
|
14
|
+
* third party.
|
|
15
|
+
*/
|
|
16
|
+
function traceHeaders({ continueIncoming = false } = {}) {
|
|
17
|
+
if (continueIncoming) {
|
|
18
|
+
const incoming = currentRequest()?.headers.get("traceparent")?.match(TRACEPARENT);
|
|
19
|
+
if (incoming) return { traceparent: `00-${incoming[1]}-${incoming[2]}-01` };
|
|
20
|
+
}
|
|
21
|
+
const traceparent = requestSlot(MINTED, () => `00-${randomHex(16)}-${randomHex(8)}-01`);
|
|
22
|
+
return traceparent ? { traceparent } : {};
|
|
23
|
+
}
|
|
24
|
+
//#endregion
|
|
25
|
+
export { traceHeaders };
|
package/package.json
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shop-on/customer-runtime",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"license": "SEE LICENSE IN dist/LICENSE.md",
|
|
5
|
+
"files": [
|
|
6
|
+
"dist"
|
|
7
|
+
],
|
|
8
|
+
"type": "module",
|
|
9
|
+
"exports": {
|
|
10
|
+
"./customer-session-handoff": "./dist/customer-session-handoff.js",
|
|
11
|
+
"./graphql": "./dist/graphql.js",
|
|
12
|
+
"./handoff": "./dist/handoff.js",
|
|
13
|
+
"./lease": "./dist/lease.js",
|
|
14
|
+
"./oauth": "./dist/oauth.js",
|
|
15
|
+
"./relay": "./dist/relay.js",
|
|
16
|
+
"./request-scope": "./dist/request-scope.js",
|
|
17
|
+
"./sealed-session": "./dist/sealed-session.js",
|
|
18
|
+
"./session-cookie": "./dist/session-cookie.js",
|
|
19
|
+
"./shop-context": "./dist/shop-context.js",
|
|
20
|
+
"./trace": "./dist/trace.js",
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"gql.tada": "1.11.4",
|
|
25
|
+
"graphql-request": "7.4.0",
|
|
26
|
+
"iron-webcrypto": "2.0.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@tanstack/react-start": "1.169.3",
|
|
30
|
+
"@types/node": "26.6.5",
|
|
31
|
+
"typescript": "7.0.2",
|
|
32
|
+
"vitest": "5.0.5"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@tanstack/react-start": "~1.169.3"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"check-types": "tsc --noEmit",
|
|
39
|
+
"test": "vitest run --maxWorkers=2",
|
|
40
|
+
"test:watch": "vitest"
|
|
41
|
+
}
|
|
6
42
|
}
|
package/README.md
DELETED