@01.software/init 0.9.2 → 0.10.1
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/ai-docs.d.ts +13 -0
- package/dist/browser-auth-CJDrpp5T.d.ts +11 -0
- package/dist/{chunk-UA7WNT2F.js → chunk-4LHYICUL.js} +1 -1
- package/dist/chunk-4LHYICUL.js.map +1 -0
- package/dist/{chunk-R4GGO33X.js → chunk-NJ4X7VNK.js} +1 -1
- package/dist/{chunk-R4GGO33X.js.map → chunk-NJ4X7VNK.js.map} +1 -1
- package/dist/chunk-STM4DKVZ.js +183 -0
- package/dist/chunk-STM4DKVZ.js.map +1 -0
- package/dist/{chunk-ENQSB4OF.js → chunk-WDWJ73KP.js} +40 -214
- package/dist/chunk-WDWJ73KP.js.map +1 -0
- package/dist/create-app-templates/ecommerce/AGENTS.md +88 -0
- package/dist/create-app-templates/ecommerce/CHANGELOG.md +48 -0
- package/dist/create-app-templates/ecommerce/CLAUDE.md +1 -0
- package/dist/create-app-templates/ecommerce/README.md +154 -0
- package/dist/create-app-templates/ecommerce/app/api/auth/login/route.ts +30 -0
- package/dist/create-app-templates/ecommerce/app/api/auth/logout/route.ts +18 -0
- package/dist/create-app-templates/ecommerce/app/api/auth/register/route.ts +41 -0
- package/dist/create-app-templates/ecommerce/app/api/cart/clear/route.ts +12 -0
- package/dist/create-app-templates/ecommerce/app/api/cart/items/route.ts +45 -0
- package/dist/create-app-templates/ecommerce/app/api/cart/route.ts +14 -0
- package/dist/create-app-templates/ecommerce/app/api/checkout/payment-return/route.ts +86 -0
- package/dist/create-app-templates/ecommerce/app/api/checkout/reconcile/route.ts +50 -0
- package/dist/create-app-templates/ecommerce/app/api/checkout/route.ts +41 -0
- package/dist/create-app-templates/ecommerce/app/cart/page.tsx +10 -0
- package/dist/create-app-templates/ecommerce/app/checkout/page.tsx +10 -0
- package/dist/create-app-templates/ecommerce/app/checkout/success/page.tsx +34 -0
- package/dist/create-app-templates/ecommerce/app/favicon.ico +0 -0
- package/dist/create-app-templates/ecommerce/app/globals.css +67 -0
- package/dist/create-app-templates/ecommerce/app/layout.tsx +23 -0
- package/dist/create-app-templates/ecommerce/app/login/page.tsx +11 -0
- package/dist/create-app-templates/ecommerce/app/page.tsx +5 -0
- package/dist/create-app-templates/ecommerce/app/products/[slug]/page.tsx +46 -0
- package/dist/create-app-templates/ecommerce/app/products/page.tsx +45 -0
- package/dist/create-app-templates/ecommerce/app/register/page.tsx +11 -0
- package/dist/create-app-templates/ecommerce/app/webhook/payment/route.ts +20 -0
- package/dist/create-app-templates/ecommerce/app-config.ts +54 -0
- package/dist/create-app-templates/ecommerce/components/auth/auth-form.tsx +109 -0
- package/dist/create-app-templates/ecommerce/components/cart/cart-content.tsx +129 -0
- package/dist/create-app-templates/ecommerce/components/checkout/checkout-form.tsx +307 -0
- package/dist/create-app-templates/ecommerce/components/checkout/checkout-reconcile.tsx +78 -0
- package/dist/create-app-templates/ecommerce/components/layout/account-nav.tsx +48 -0
- package/dist/create-app-templates/ecommerce/components/layout/account-slot.tsx +12 -0
- package/dist/create-app-templates/ecommerce/components/layout/cart-link.tsx +13 -0
- package/dist/create-app-templates/ecommerce/components/layout/page-shell.tsx +11 -0
- package/dist/create-app-templates/ecommerce/components/layout/site-header.tsx +22 -0
- package/dist/create-app-templates/ecommerce/components/product/add-to-cart.tsx +116 -0
- package/dist/create-app-templates/ecommerce/components/product/product-card.tsx +50 -0
- package/dist/create-app-templates/ecommerce/components/product/product-gallery.tsx +39 -0
- package/dist/create-app-templates/ecommerce/data/mock-catalog.json +173 -0
- package/dist/create-app-templates/ecommerce/eslint.config.mjs +18 -0
- package/dist/create-app-templates/ecommerce/lib/cart/cookie.ts +40 -0
- package/dist/create-app-templates/ecommerce/lib/cart/normalize.ts +32 -0
- package/dist/create-app-templates/ecommerce/lib/cart/parse-cart-request.ts +56 -0
- package/dist/create-app-templates/ecommerce/lib/cart/route-helpers.ts +17 -0
- package/dist/create-app-templates/ecommerce/lib/cart/select-provider.ts +44 -0
- package/dist/create-app-templates/ecommerce/lib/cart/server-cart.ts +135 -0
- package/dist/create-app-templates/ecommerce/lib/cart/sync-on-login.server.ts +34 -0
- package/dist/create-app-templates/ecommerce/lib/cart/use-cart.tsx +151 -0
- package/dist/create-app-templates/ecommerce/lib/checkout/checkout-errors.ts +22 -0
- package/dist/create-app-templates/ecommerce/lib/checkout/checkout-provider.ts +28 -0
- package/dist/create-app-templates/ecommerce/lib/checkout/parse-checkout-payload.ts +86 -0
- package/dist/create-app-templates/ecommerce/lib/checkout/start-checkout.ts +73 -0
- package/dist/create-app-templates/ecommerce/lib/checkout/types.ts +6 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/adapters/mock.ts +346 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/adapters/software-mappers.ts +312 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/adapters/software.ts +930 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/product-summary.ts +37 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/provider.server.ts +60 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/provider.ts +96 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/stock.ts +37 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/types.ts +208 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/variant-selection.ts +23 -0
- package/dist/create-app-templates/ecommerce/lib/customer/auth-actions.ts +131 -0
- package/dist/create-app-templates/ecommerce/lib/customer/cart-sync.ts +44 -0
- package/dist/create-app-templates/ecommerce/lib/customer/client.server.ts +109 -0
- package/dist/create-app-templates/ecommerce/lib/customer/current-customer.ts +15 -0
- package/dist/create-app-templates/ecommerce/lib/customer/route-guard.ts +58 -0
- package/dist/create-app-templates/ecommerce/lib/customer/route-helpers.ts +75 -0
- package/dist/create-app-templates/ecommerce/lib/customer/session.ts +108 -0
- package/dist/create-app-templates/ecommerce/lib/format.ts +7 -0
- package/dist/create-app-templates/ecommerce/lib/payment/adapters/mock.ts +84 -0
- package/dist/create-app-templates/ecommerce/lib/payment/adapters/portone.ts +254 -0
- package/dist/create-app-templates/ecommerce/lib/payment/adapters/tosspayments.ts +287 -0
- package/dist/create-app-templates/ecommerce/lib/payment/amount-gate.ts +86 -0
- package/dist/create-app-templates/ecommerce/lib/payment/provider.server.ts +51 -0
- package/dist/create-app-templates/ecommerce/lib/payment/provider.ts +18 -0
- package/dist/create-app-templates/ecommerce/lib/payment/sync-order-payment.ts +96 -0
- package/dist/create-app-templates/ecommerce/lib/payment/types.ts +71 -0
- package/dist/create-app-templates/ecommerce/lib/server-only-guard.ts +20 -0
- package/dist/create-app-templates/ecommerce/next-env.d.ts +6 -0
- package/dist/create-app-templates/ecommerce/next.config.ts +16 -0
- package/dist/create-app-templates/ecommerce/package.json +33 -0
- package/dist/create-app-templates/ecommerce/postcss.config.mjs +7 -0
- package/dist/create-app-templates/ecommerce/tests/customer-auth.test.ts +263 -0
- package/dist/create-app-templates/ecommerce/tests/customer-cart.test.ts +401 -0
- package/dist/create-app-templates/ecommerce/tests/domain.test.ts +1632 -0
- package/dist/create-app-templates/ecommerce/tsconfig.json +35 -0
- package/dist/create-app-templates/registry.json +66 -0
- package/dist/create-app.d.ts +40 -0
- package/dist/create-app.js +652 -0
- package/dist/create-app.js.map +1 -0
- package/dist/detect-Bjxp9wcS.d.ts +13 -0
- package/dist/file-ops.d.ts +21 -0
- package/dist/file-ops.js +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +40 -0
- package/dist/init.js +4 -3
- package/dist/templates.d.ts +27 -0
- package/dist/templates.js +1 -1
- package/package.json +18 -3
- package/dist/chunk-ENQSB4OF.js.map +0 -1
- package/dist/chunk-UA7WNT2F.js.map +0 -1
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import "../server-only-guard.ts";
|
|
2
|
+
import type { CartSyncCommerce } from "./cart-sync.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Per-request customer SDK client.
|
|
6
|
+
*
|
|
7
|
+
* Customer auth uses the **browser** SDK client (`createClient`, root export),
|
|
8
|
+
* not the server client — there is **no secret key** involved. The customer JWT
|
|
9
|
+
* is the only credential; the publishable key routes the tenant. The browser
|
|
10
|
+
* client is server-safe with `customer: { persist: false, token }`: `window` is
|
|
11
|
+
* `typeof`-guarded and the `localStorage` path is skipped when `persist` is
|
|
12
|
+
* false, so it runs inside a Next.js route handler without throwing.
|
|
13
|
+
*
|
|
14
|
+
* A fresh client is built per request because the JWT comes from the request
|
|
15
|
+
* cookie — clients are not shared across requests. The SDK is imported
|
|
16
|
+
* dynamically so this module stays importable under `node --test` (mirrors the
|
|
17
|
+
* commerce adapter's `await import('@01.software/sdk/server')`).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface CustomerClientConfig {
|
|
21
|
+
publishableKey: string;
|
|
22
|
+
apiUrl: string | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Minimal shape of the SDK customer-auth surface this template uses. */
|
|
26
|
+
export interface CustomerAuthClient {
|
|
27
|
+
customer: {
|
|
28
|
+
auth: {
|
|
29
|
+
login(data: { email: string; password: string }): Promise<{
|
|
30
|
+
token: string;
|
|
31
|
+
customer: unknown;
|
|
32
|
+
}>;
|
|
33
|
+
register(data: {
|
|
34
|
+
name: string;
|
|
35
|
+
email: string;
|
|
36
|
+
password: string;
|
|
37
|
+
phone?: string;
|
|
38
|
+
}): Promise<{ customer: unknown }>;
|
|
39
|
+
me(): Promise<unknown | null>;
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Minimal shape of the customer-JWT commerce client the login cart-sync uses;
|
|
46
|
+
* its `commerce` namespace is the {@link CartSyncCommerce} merge/mine surface.
|
|
47
|
+
*/
|
|
48
|
+
export interface CustomerCommerceClient {
|
|
49
|
+
commerce: CartSyncCommerce;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Resolve customer-client config from the environment, or `null` when 01.software
|
|
54
|
+
* credentials are not configured (e.g. the zero-backend mock demo). Only the
|
|
55
|
+
* publishable key is required — customer auth never uses the secret key.
|
|
56
|
+
*/
|
|
57
|
+
export function getCustomerClientConfig(
|
|
58
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
59
|
+
): CustomerClientConfig | null {
|
|
60
|
+
const publishableKey = env.NEXT_PUBLIC_SOFTWARE_PUBLISHABLE_KEY?.trim();
|
|
61
|
+
if (!publishableKey) return null;
|
|
62
|
+
const apiUrl =
|
|
63
|
+
env.SOFTWARE_API_URL?.trim() || env.NEXT_PUBLIC_SOFTWARE_API_URL?.trim();
|
|
64
|
+
return { publishableKey, apiUrl: apiUrl || undefined };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Whether customer auth is available in this deployment. */
|
|
68
|
+
export function isCustomerAuthEnabled(
|
|
69
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
70
|
+
): boolean {
|
|
71
|
+
return getCustomerClientConfig(env) !== null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Build a per-request customer SDK client bound to `token` (the JWT from the
|
|
76
|
+
* session cookie, or `null` for an anonymous client used only for login/
|
|
77
|
+
* register). Returns `null` when credentials are not configured.
|
|
78
|
+
*/
|
|
79
|
+
export async function createCustomerClient(
|
|
80
|
+
token: string | null,
|
|
81
|
+
config: CustomerClientConfig | null = getCustomerClientConfig(),
|
|
82
|
+
): Promise<CustomerAuthClient | null> {
|
|
83
|
+
if (!config) return null;
|
|
84
|
+
const { createClient } = await import("@01.software/sdk");
|
|
85
|
+
return createClient({
|
|
86
|
+
publishableKey: config.publishableKey,
|
|
87
|
+
apiUrl: config.apiUrl,
|
|
88
|
+
customer: { persist: false, token: token ?? undefined },
|
|
89
|
+
}) as unknown as CustomerAuthClient;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Build a per-request customer-JWT commerce client for the login cart-sync
|
|
94
|
+
* (merge/mine). Same browser SDK client family as {@link createCustomerClient}
|
|
95
|
+
* (publishable key + JWT, no secret); returns `null` when credentials are not
|
|
96
|
+
* configured.
|
|
97
|
+
*/
|
|
98
|
+
export async function createCustomerCommerceClient(
|
|
99
|
+
token: string,
|
|
100
|
+
config: CustomerClientConfig | null = getCustomerClientConfig(),
|
|
101
|
+
): Promise<CustomerCommerceClient | null> {
|
|
102
|
+
if (!config) return null;
|
|
103
|
+
const { createClient } = await import("@01.software/sdk");
|
|
104
|
+
return createClient({
|
|
105
|
+
publishableKey: config.publishableKey,
|
|
106
|
+
apiUrl: config.apiUrl,
|
|
107
|
+
customer: { persist: false, token },
|
|
108
|
+
}) as unknown as CustomerCommerceClient;
|
|
109
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import "../server-only-guard.ts";
|
|
2
|
+
import type { CustomerSummary } from "./auth-actions.ts";
|
|
3
|
+
import { loadCustomer } from "./auth-actions.ts";
|
|
4
|
+
import { getSessionToken } from "./session.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolve the current customer for render paths (Server Components). Read-only
|
|
8
|
+
* and crash-proof: an expired/invalid token or a backend error resolves to
|
|
9
|
+
* `null` (guest), and the cookie is **not** mutated here (cookies are not
|
|
10
|
+
* writable during RSC render). Returns `null` with no network call when customer
|
|
11
|
+
* auth is not configured.
|
|
12
|
+
*/
|
|
13
|
+
export async function getCurrentCustomer(): Promise<CustomerSummary | null> {
|
|
14
|
+
return loadCustomer(await getSessionToken());
|
|
15
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSRF guard for the customer auth mutation routes.
|
|
3
|
+
*
|
|
4
|
+
* The session JWT lives in a SameSite=lax cookie, so a cross-site form POST
|
|
5
|
+
* could otherwise ride the cookie. Two cheap, layered checks defeat that without
|
|
6
|
+
* a token round-trip:
|
|
7
|
+
*
|
|
8
|
+
* 1. **`application/json` content-type, rejected hard.** A cross-site HTML form
|
|
9
|
+
* can only send `application/x-www-form-urlencoded`, `multipart/form-data`, or
|
|
10
|
+
* `text/plain` — never `application/json` (that would require a CORS preflight
|
|
11
|
+
* the server never allows). Same-origin `fetch` sets it freely.
|
|
12
|
+
* 2. **Same-origin `Origin`.** When the browser sends an `Origin` header it must
|
|
13
|
+
* match the request host; a mismatch is a cross-site request and is rejected.
|
|
14
|
+
*
|
|
15
|
+
* Both checks are pure over the `Request`, so they unit-test without a server.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export class CsrfError extends Error {
|
|
19
|
+
constructor(message: string) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = "CsrfError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function requestHost(request: Request): string | null {
|
|
26
|
+
// `x-forwarded-host` is the deployed host behind Vercel/most proxies. It is a
|
|
27
|
+
// forbidden header for browser `fetch`, so a real cross-site request cannot
|
|
28
|
+
// set it; the JSON content-type check above is the primary defense regardless.
|
|
29
|
+
// If you self-host without a trusted proxy that sets this, compare `host`.
|
|
30
|
+
const forwarded = request.headers.get("x-forwarded-host");
|
|
31
|
+
if (forwarded) return forwarded.split(",")[0]!.trim();
|
|
32
|
+
return request.headers.get("host");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Throw `CsrfError` unless the request is a same-origin JSON request. Call at the
|
|
37
|
+
* top of every state-changing auth route handler.
|
|
38
|
+
*/
|
|
39
|
+
export function assertSameOriginJson(request: Request): void {
|
|
40
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
41
|
+
if (!contentType.toLowerCase().includes("application/json")) {
|
|
42
|
+
throw new CsrfError("Request must be application/json");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const origin = request.headers.get("origin");
|
|
46
|
+
if (origin) {
|
|
47
|
+
let originHost: string;
|
|
48
|
+
try {
|
|
49
|
+
originHost = new URL(origin).host;
|
|
50
|
+
} catch {
|
|
51
|
+
throw new CsrfError("Invalid Origin header");
|
|
52
|
+
}
|
|
53
|
+
const host = requestHost(request);
|
|
54
|
+
if (!host || originHost !== host) {
|
|
55
|
+
throw new CsrfError("Cross-origin request rejected");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { CsrfError } from "./route-guard.ts";
|
|
2
|
+
|
|
3
|
+
/** Map an auth-route error to a public JSON response with a sensible status. */
|
|
4
|
+
export function authErrorResponse(error: unknown): Response {
|
|
5
|
+
if (error instanceof CsrfError) {
|
|
6
|
+
return Response.json(
|
|
7
|
+
{ code: "forbidden", message: error.message },
|
|
8
|
+
{ status: 403 },
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
if (error instanceof SyntaxError) {
|
|
12
|
+
// request.json() on a non-JSON / malformed body.
|
|
13
|
+
return Response.json(
|
|
14
|
+
{ code: "invalid_request", message: "Request body must be JSON" },
|
|
15
|
+
{ status: 400 },
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
// Validation errors from the `parse*` helpers carry safe, caller-facing
|
|
19
|
+
// messages; anything else is internal and must not leak verbatim.
|
|
20
|
+
const message = error instanceof Error ? error.message : "Request failed";
|
|
21
|
+
if (/required|must be/.test(message)) {
|
|
22
|
+
return Response.json({ code: "invalid_request", message }, { status: 400 });
|
|
23
|
+
}
|
|
24
|
+
return Response.json(
|
|
25
|
+
{ code: "auth_failed", message: "Request failed" },
|
|
26
|
+
{ status: 500 },
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function asRecord(input: unknown): Record<string, unknown> {
|
|
31
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
32
|
+
throw new Error("Request body must be an object");
|
|
33
|
+
}
|
|
34
|
+
return input as Record<string, unknown>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function requiredString(input: unknown, field: string): string {
|
|
38
|
+
if (typeof input !== "string" || input.trim() === "") {
|
|
39
|
+
throw new Error(`${field} is required`);
|
|
40
|
+
}
|
|
41
|
+
return input.trim();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function optionalString(input: unknown): string | undefined {
|
|
45
|
+
if (input === undefined || input === null || input === "") return undefined;
|
|
46
|
+
if (typeof input !== "string") return undefined;
|
|
47
|
+
return input.trim() || undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function parseLogin(input: unknown): {
|
|
51
|
+
email: string;
|
|
52
|
+
password: string;
|
|
53
|
+
} {
|
|
54
|
+
const body = asRecord(input);
|
|
55
|
+
return {
|
|
56
|
+
email: requiredString(body.email, "email"),
|
|
57
|
+
password: requiredString(body.password, "password"),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function parseRegister(input: unknown): {
|
|
62
|
+
name: string;
|
|
63
|
+
email: string;
|
|
64
|
+
password: string;
|
|
65
|
+
phone?: string;
|
|
66
|
+
} {
|
|
67
|
+
const body = asRecord(input);
|
|
68
|
+
const phone = optionalString(body.phone);
|
|
69
|
+
return {
|
|
70
|
+
name: requiredString(body.name, "name"),
|
|
71
|
+
email: requiredString(body.email, "email"),
|
|
72
|
+
password: requiredString(body.password, "password"),
|
|
73
|
+
...(phone ? { phone } : {}),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import "../server-only-guard.ts";
|
|
2
|
+
// Relative (not the `@/` alias) so the pure cookie helpers stay importable under
|
|
3
|
+
// `node --test`, which does not resolve tsconfig path aliases.
|
|
4
|
+
import { appConfig } from "../../app-config.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Customer session cookie. It holds the customer JWT issued by
|
|
8
|
+
* `client.customer.auth.*` and is **HttpOnly + Secure (prod) + SameSite=lax** so
|
|
9
|
+
* it is invisible to client JS (XSS) and only travels on same-site requests. The
|
|
10
|
+
* JWT is the session credential the server attaches to per-request SDK calls
|
|
11
|
+
* (`lib/customer/client.server.ts`); the browser never sees it.
|
|
12
|
+
*
|
|
13
|
+
* `.set`/`.delete` are only valid in Route Handlers / Server Functions, never
|
|
14
|
+
* during Server Component render — keep cookie writes in the `app/api/auth/*`
|
|
15
|
+
* routes. Render paths read this cookie but must not mutate it.
|
|
16
|
+
*
|
|
17
|
+
* The cookie read/write/clear logic is split into pure functions over a minimal
|
|
18
|
+
* cookie-store shape so it stays unit-testable; `next/headers` is imported
|
|
19
|
+
* dynamically inside the async wrappers (the `server-only` npm package is
|
|
20
|
+
* avoided — its default export throws under `node --test`).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; // 7 days — matches a typical JWT lifetime
|
|
24
|
+
|
|
25
|
+
/** The subset of the Next.js cookie store this module needs. */
|
|
26
|
+
export interface SessionCookieStore {
|
|
27
|
+
get(name: string): { value: string } | undefined;
|
|
28
|
+
set(name: string, value: string, options: SessionCookieOptions): void;
|
|
29
|
+
delete(name: string): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SessionCookieOptions {
|
|
33
|
+
httpOnly: boolean;
|
|
34
|
+
secure: boolean;
|
|
35
|
+
sameSite: "lax";
|
|
36
|
+
path: string;
|
|
37
|
+
maxAge: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Build the hardened cookie options for the customer session JWT. */
|
|
41
|
+
export function sessionCookieOptions(): SessionCookieOptions {
|
|
42
|
+
return {
|
|
43
|
+
httpOnly: true,
|
|
44
|
+
// Secure cookies are dropped by browsers over plain http, so allow http in
|
|
45
|
+
// local dev and enforce Secure in production.
|
|
46
|
+
secure: process.env.NODE_ENV === "production",
|
|
47
|
+
sameSite: "lax",
|
|
48
|
+
path: "/",
|
|
49
|
+
maxAge: COOKIE_MAX_AGE_SECONDS,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Read the customer JWT from a cookie store, or `null` when none is set. */
|
|
54
|
+
export function readSessionToken(store: SessionCookieStore): string | null {
|
|
55
|
+
return store.get(appConfig.customerKey)?.value ?? null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Write the customer JWT to a cookie store with hardened options. */
|
|
59
|
+
export function writeSessionToken(
|
|
60
|
+
store: SessionCookieStore,
|
|
61
|
+
token: string,
|
|
62
|
+
): void {
|
|
63
|
+
store.set(appConfig.customerKey, token, sessionCookieOptions());
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Remove the customer JWT from a cookie store. */
|
|
67
|
+
export function clearSessionToken(store: SessionCookieStore): void {
|
|
68
|
+
store.delete(appConfig.customerKey);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Clear both the customer session JWT and the active-cart cookie on a store. The
|
|
73
|
+
* single cart-token handle is slaved to the session, so logout drops both
|
|
74
|
+
* atomically — otherwise the next anonymous (or different) session would inherit
|
|
75
|
+
* the prior identity's customer-cart handle.
|
|
76
|
+
*/
|
|
77
|
+
export function clearSessionAndCartTokens(store: SessionCookieStore): void {
|
|
78
|
+
store.delete(appConfig.customerKey);
|
|
79
|
+
store.delete(appConfig.cartKey);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function cookieStore(): Promise<SessionCookieStore> {
|
|
83
|
+
const { cookies } = await import("next/headers");
|
|
84
|
+
return (await cookies()) as unknown as SessionCookieStore;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Read the current customer JWT from the request cookies (Route Handler / RSC). */
|
|
88
|
+
export async function getSessionToken(): Promise<string | null> {
|
|
89
|
+
return readSessionToken(await cookieStore());
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Set the customer JWT cookie. Route Handlers / Server Functions only. */
|
|
93
|
+
export async function setSessionToken(token: string): Promise<void> {
|
|
94
|
+
writeSessionToken(await cookieStore(), token);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Clear the customer JWT cookie. Route Handlers / Server Functions only. */
|
|
98
|
+
export async function clearSession(): Promise<void> {
|
|
99
|
+
clearSessionToken(await cookieStore());
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Clear the customer JWT cookie and the active-cart cookie together (logout).
|
|
104
|
+
* Route Handlers / Server Functions only.
|
|
105
|
+
*/
|
|
106
|
+
export async function clearSessionAndCart(): Promise<void> {
|
|
107
|
+
clearSessionAndCartTokens(await cookieStore());
|
|
108
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import '../../server-only-guard.ts'
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import type { PaymentProvider } from '../provider.ts'
|
|
5
|
+
import type { ProviderPayment } from '../types.ts'
|
|
6
|
+
|
|
7
|
+
const paymentStorePath = join(process.cwd(), '.mock-payments.json')
|
|
8
|
+
|
|
9
|
+
export function createMockPaymentProvider(): PaymentProvider {
|
|
10
|
+
return {
|
|
11
|
+
provider: 'mock',
|
|
12
|
+
|
|
13
|
+
async requestPayment(input) {
|
|
14
|
+
writeMockPayment({
|
|
15
|
+
paymentId: input.paymentId,
|
|
16
|
+
status: 'paid',
|
|
17
|
+
amount: input.amount,
|
|
18
|
+
provider: 'mock',
|
|
19
|
+
orderNumber: input.orderNumber,
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const params = new URLSearchParams({
|
|
23
|
+
paymentId: input.paymentId,
|
|
24
|
+
orderNumber: input.orderNumber,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
ok: true,
|
|
29
|
+
paymentId: input.paymentId,
|
|
30
|
+
redirectUrl: `/api/checkout/payment-return?${params.toString()}`,
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
async getPayment(paymentId) {
|
|
35
|
+
return readMockPayment(paymentId)
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
async confirmPayment(input) {
|
|
39
|
+
const payment = await this.getPayment(input.paymentId)
|
|
40
|
+
if (!payment) throw new Error('Payment not found')
|
|
41
|
+
return payment
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
async verifyWebhook(request) {
|
|
45
|
+
const body = (await request.json()) as {
|
|
46
|
+
paymentId?: string
|
|
47
|
+
eventId?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!body.paymentId || !body.eventId) {
|
|
51
|
+
throw new Error('Invalid mock webhook')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
type: 'payment.updated',
|
|
56
|
+
paymentId: body.paymentId,
|
|
57
|
+
eventId: body.eventId,
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readMockPayment(paymentId: string): ProviderPayment | null {
|
|
64
|
+
return readMockPayments()[paymentId] ?? null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function writeMockPayment(payment: ProviderPayment): void {
|
|
68
|
+
const payments = readMockPayments()
|
|
69
|
+
payments[payment.paymentId] = payment
|
|
70
|
+
writeFileSync(paymentStorePath, JSON.stringify(payments, null, 2))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function readMockPayments(): Record<string, ProviderPayment> {
|
|
74
|
+
if (!existsSync(paymentStorePath)) return {}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
return JSON.parse(readFileSync(paymentStorePath, 'utf8')) as Record<
|
|
78
|
+
string,
|
|
79
|
+
ProviderPayment
|
|
80
|
+
>
|
|
81
|
+
} catch {
|
|
82
|
+
return {}
|
|
83
|
+
}
|
|
84
|
+
}
|