@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.
@@ -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
@@ -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
@@ -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 };
@@ -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 };
@@ -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 };
@@ -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 };
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.