@tenderprompt/accounts 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/README.md +84 -0
- package/dist/api.d.ts +27 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +73 -0
- package/dist/api.js.map +1 -0
- package/dist/artifact.d.ts +8 -0
- package/dist/artifact.d.ts.map +1 -0
- package/dist/artifact.js +205 -0
- package/dist/artifact.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +4 -0
- package/dist/cli.js.map +1 -0
- package/dist/contracts.d.ts +96 -0
- package/dist/contracts.d.ts.map +1 -0
- package/dist/contracts.js +5 -0
- package/dist/contracts.js.map +1 -0
- package/dist/errors.d.ts +10 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +19 -0
- package/dist/errors.js.map +1 -0
- package/dist/files.d.ts +21 -0
- package/dist/files.d.ts.map +1 -0
- package/dist/files.js +263 -0
- package/dist/files.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/main.d.ts +15 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +565 -0
- package/dist/main.js.map +1 -0
- package/dist/processes.d.ts +12 -0
- package/dist/processes.d.ts.map +1 -0
- package/dist/processes.js +44 -0
- package/dist/processes.js.map +1 -0
- package/dist/starter.d.ts +20 -0
- package/dist/starter.d.ts.map +1 -0
- package/dist/starter.js +159 -0
- package/dist/starter.js.map +1 -0
- package/package.json +32 -0
- package/skill/tender-accounts/SKILL.md +111 -0
- package/templates/shopify-customer-account/AGENTS.md +14 -0
- package/templates/shopify-customer-account/README.md +44 -0
- package/templates/shopify-customer-account/apps/gateway/AGENTS.md +9 -0
- package/templates/shopify-customer-account/apps/gateway/README.md +9 -0
- package/templates/shopify-customer-account/apps/gateway/database-schema.json +49 -0
- package/templates/shopify-customer-account/apps/gateway/dot.dev.vars.example +11 -0
- package/templates/shopify-customer-account/apps/gateway/migrations/0001_customer_auth.sql +76 -0
- package/templates/shopify-customer-account/apps/gateway/package.json +25 -0
- package/templates/shopify-customer-account/apps/gateway/scripts/build-worker.mjs +15 -0
- package/templates/shopify-customer-account/apps/gateway/scripts/pack-tender-artifact.mjs +84 -0
- package/templates/shopify-customer-account/apps/gateway/src/platform/customer-auth/hostname-policy.ts +66 -0
- package/templates/shopify-customer-account/apps/gateway/src/platform/customer-auth/schema.ts +90 -0
- package/templates/shopify-customer-account/apps/gateway/src/platform/customer-auth/session-store.ts +864 -0
- package/templates/shopify-customer-account/apps/gateway/src/platform/customer-auth/shopify-route-adapter.ts +1157 -0
- package/templates/shopify-customer-account/apps/gateway/src/platform/runtime-hostname.ts +46 -0
- package/templates/shopify-customer-account/apps/gateway/src/portal-service.test.ts +18 -0
- package/templates/shopify-customer-account/apps/gateway/src/portal-service.ts +51 -0
- package/templates/shopify-customer-account/apps/gateway/src/runtime-config.test.ts +30 -0
- package/templates/shopify-customer-account/apps/gateway/src/runtime-config.ts +56 -0
- package/templates/shopify-customer-account/apps/gateway/src/worker.ts +85 -0
- package/templates/shopify-customer-account/apps/gateway/tender-accounts.json +10 -0
- package/templates/shopify-customer-account/apps/gateway/tender-app.config.json +40 -0
- package/templates/shopify-customer-account/apps/gateway/tsconfig.json +13 -0
- package/templates/shopify-customer-account/apps/gateway/vitest.config.ts +8 -0
- package/templates/shopify-customer-account/apps/gateway/worker-configuration.d.ts +26 -0
- package/templates/shopify-customer-account/apps/gateway/wrangler.jsonc +42 -0
- package/templates/shopify-customer-account/apps/portal/AGENTS.md +9 -0
- package/templates/shopify-customer-account/apps/portal/README.md +5 -0
- package/templates/shopify-customer-account/apps/portal/index.html +13 -0
- package/templates/shopify-customer-account/apps/portal/package.json +33 -0
- package/templates/shopify-customer-account/apps/portal/scripts/build-worker.mjs +11 -0
- package/templates/shopify-customer-account/apps/portal/scripts/pack-tender-artifact.mjs +63 -0
- package/templates/shopify-customer-account/apps/portal/scripts/portal-worker.mjs +84 -0
- package/templates/shopify-customer-account/apps/portal/scripts/portal-worker.test.mjs +14 -0
- package/templates/shopify-customer-account/apps/portal/src/App.tsx +111 -0
- package/templates/shopify-customer-account/apps/portal/src/main.tsx +10 -0
- package/templates/shopify-customer-account/apps/portal/src/styles.css +54 -0
- package/templates/shopify-customer-account/apps/portal/tender-accounts.json +10 -0
- package/templates/shopify-customer-account/apps/portal/tender-app.config.json +19 -0
- package/templates/shopify-customer-account/apps/portal/tsconfig.json +14 -0
- package/templates/shopify-customer-account/apps/portal/vite.config.ts +7 -0
- package/templates/shopify-customer-account/apps/portal/worker-configuration.d.ts +11 -0
- package/templates/shopify-customer-account/apps/portal/wrangler.jsonc +22 -0
- package/templates/shopify-customer-account/dot.dev.vars.example +3 -0
- package/templates/shopify-customer-account/dot.gitignore +9 -0
- package/templates/shopify-customer-account/package.json +22 -0
- package/templates/shopify-customer-account/scripts/artifact-contract.mjs +109 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export function canonicalApplicationHostname(input: RuntimeIdentity): string {
|
|
4
|
+
return `a-${shortDigest(input.projectId, 14)}--t-${shortDigest(input.tenantId, 14)}.${normalizedDomain(input.baseDomain)}`;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function immutablePreviewHostname(input: RuntimeIdentity & { releaseId: string }): string {
|
|
8
|
+
return [
|
|
9
|
+
`r-${shortDigest(input.releaseId, 12)}`,
|
|
10
|
+
`p-${shortDigest(input.projectId, 10)}`,
|
|
11
|
+
`t-${shortDigest(input.tenantId, 10)}`,
|
|
12
|
+
].join("--") + `.${normalizedDomain(input.baseDomain)}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function previewAliasHostname(input: RuntimeIdentity & { alias: string }): string {
|
|
16
|
+
const readable = input.alias.toLowerCase().replace(/[^a-z0-9-]+/gu, "-").replace(/^-+|-+$/gu, "").slice(0, 12);
|
|
17
|
+
if (readable.length < 2) throw new Error("The preview alias is invalid.");
|
|
18
|
+
return [
|
|
19
|
+
`b-${readable}-${rawDigest(input.alias, 8)}`,
|
|
20
|
+
`p-${shortDigest(input.projectId, 10)}`,
|
|
21
|
+
`t-${shortDigest(input.tenantId, 10)}`,
|
|
22
|
+
].join("--") + `.${normalizedDomain(input.baseDomain)}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface RuntimeIdentity {
|
|
26
|
+
tenantId: string;
|
|
27
|
+
projectId: string;
|
|
28
|
+
baseDomain: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function shortDigest(value: string, length: number): string {
|
|
32
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{1,127}$/u.test(value)) throw new Error("The runtime identity is invalid.");
|
|
33
|
+
return rawDigest(value, length);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function rawDigest(value: string, length: number): string {
|
|
37
|
+
return createHash("sha256").update(value, "utf8").digest("hex").slice(0, length);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizedDomain(value: string): string {
|
|
41
|
+
const domain = value.toLowerCase().replace(/\.$/u, "");
|
|
42
|
+
if (!/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/u.test(domain)) {
|
|
43
|
+
throw new Error("The runtime base domain is invalid.");
|
|
44
|
+
}
|
|
45
|
+
return domain;
|
|
46
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { forwardPortalRequest } from "./portal-service";
|
|
3
|
+
|
|
4
|
+
describe("portal service binding", () => {
|
|
5
|
+
it("forwards the browser path without exposing platform headers", async () => {
|
|
6
|
+
const response = await forwardPortalRequest(new Request("https://account.example.test/account"), {
|
|
7
|
+
fetch: async (request) => {
|
|
8
|
+
const url = request instanceof Request ? request.url : request.toString();
|
|
9
|
+
return new Response(new URL(url).pathname, {
|
|
10
|
+
headers: { "cf-ray": "private", "content-type": "text/plain" },
|
|
11
|
+
});
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
expect(await response.text()).toBe("/account");
|
|
15
|
+
expect(response.headers.get("cf-ray")).toBeNull();
|
|
16
|
+
expect(response.headers.get("x-frame-options")).toBe("DENY");
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export type PortalService = Pick<Fetcher, "fetch">;
|
|
2
|
+
|
|
3
|
+
const FORWARDED_HEADERS = [
|
|
4
|
+
"accept", "accept-language", "cache-control", "content-type", "cookie",
|
|
5
|
+
"if-match", "if-none-match", "origin", "pragma", "referer", "user-agent",
|
|
6
|
+
] as const;
|
|
7
|
+
|
|
8
|
+
export async function forwardPortalRequest(request: Request, portal: PortalService): Promise<Response> {
|
|
9
|
+
if (!portal || typeof portal.fetch !== "function") return gatewayError(503, "portal_ui_unavailable");
|
|
10
|
+
try {
|
|
11
|
+
const response = await portal.fetch(portalRequest(request));
|
|
12
|
+
const headers = new Headers(response.headers);
|
|
13
|
+
for (const name of [...headers.keys()]) {
|
|
14
|
+
const normalized = name.toLowerCase();
|
|
15
|
+
if (normalized.startsWith("cf-") || normalized.startsWith("cloudflare-") || normalized.startsWith("x-tender-")) {
|
|
16
|
+
headers.delete(name);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
20
|
+
headers.set("X-Content-Type-Options", "nosniff");
|
|
21
|
+
headers.set("X-Frame-Options", "DENY");
|
|
22
|
+
return new Response(request.method === "HEAD" ? null : response.body, {
|
|
23
|
+
status: response.status,
|
|
24
|
+
statusText: response.statusText,
|
|
25
|
+
headers,
|
|
26
|
+
});
|
|
27
|
+
} catch {
|
|
28
|
+
return gatewayError(502, "portal_ui_failed");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function portalRequest(request: Request): Request {
|
|
33
|
+
const headers = new Headers();
|
|
34
|
+
for (const name of FORWARDED_HEADERS) {
|
|
35
|
+
const value = request.headers.get(name);
|
|
36
|
+
if (value !== null) headers.set(name, value);
|
|
37
|
+
}
|
|
38
|
+
return new Request(request.url, { method: request.method, headers, redirect: "manual" });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function gatewayError(status: number, code: string): Response {
|
|
42
|
+
return Response.json({ error: { code } }, {
|
|
43
|
+
status,
|
|
44
|
+
headers: {
|
|
45
|
+
"Cache-Control": "no-store",
|
|
46
|
+
"Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'",
|
|
47
|
+
"Referrer-Policy": "no-referrer",
|
|
48
|
+
"X-Content-Type-Options": "nosniff",
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { readRuntimeConfig } from "./runtime-config";
|
|
3
|
+
|
|
4
|
+
describe("runtime config", () => {
|
|
5
|
+
it("derives a stable canonical host from public runtime identity", () => {
|
|
6
|
+
const config = readRuntimeConfig({
|
|
7
|
+
ACCOUNT_TENANT_ID: "ten_example",
|
|
8
|
+
ACCOUNT_PROJECT_ID: "prj_example",
|
|
9
|
+
ACCOUNT_RUNTIME_BASE_DOMAIN: "tenderprompt.dev",
|
|
10
|
+
ACCOUNT_VANITY_HOSTNAMES: "account.example.com, members.example.com",
|
|
11
|
+
SHOPIFY_STOREFRONT_DOMAIN: "example.myshopify.com",
|
|
12
|
+
SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID: "client_12345678",
|
|
13
|
+
SHOPIFY_CUSTOMER_ACCOUNT_DOMAIN: "shopify.com",
|
|
14
|
+
} as Env);
|
|
15
|
+
expect(config.canonicalHostname).toMatch(/^a-[a-f0-9]{14}--t-[a-f0-9]{14}\.tenderprompt\.dev$/u);
|
|
16
|
+
expect(config.vanityHostnames).toEqual(["account.example.com", "members.example.com"]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("rejects an invalid Shopify hostname", () => {
|
|
20
|
+
expect(() => readRuntimeConfig({
|
|
21
|
+
ACCOUNT_TENANT_ID: "ten_example",
|
|
22
|
+
ACCOUNT_PROJECT_ID: "prj_example",
|
|
23
|
+
ACCOUNT_RUNTIME_BASE_DOMAIN: "tenderprompt.dev",
|
|
24
|
+
ACCOUNT_VANITY_HOSTNAMES: "",
|
|
25
|
+
SHOPIFY_STOREFRONT_DOMAIN: "https://example.myshopify.com",
|
|
26
|
+
SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID: "client_12345678",
|
|
27
|
+
SHOPIFY_CUSTOMER_ACCOUNT_DOMAIN: "shopify.com",
|
|
28
|
+
} as Env)).toThrow(/Shopify storefront domain/u);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { canonicalApplicationHostname } from "./platform/runtime-hostname";
|
|
2
|
+
|
|
3
|
+
const PUBLIC_CLIENT_ID = /^[A-Za-z0-9_-]{8,256}$/u;
|
|
4
|
+
const RUNTIME_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{1,127}$/u;
|
|
5
|
+
const HOSTNAME = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/u;
|
|
6
|
+
|
|
7
|
+
export interface RuntimeConfig {
|
|
8
|
+
tenantId: string;
|
|
9
|
+
projectId: string;
|
|
10
|
+
runtimeBaseDomain: string;
|
|
11
|
+
canonicalHostname: string;
|
|
12
|
+
storefrontDomain: string;
|
|
13
|
+
customerAccountClientId: string;
|
|
14
|
+
customerAccountDomain: string;
|
|
15
|
+
vanityHostnames: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function readRuntimeConfig(env: Env): RuntimeConfig {
|
|
19
|
+
const tenantId = requiredMatch(env.ACCOUNT_TENANT_ID, RUNTIME_ID, "tenant identity");
|
|
20
|
+
const projectId = requiredMatch(env.ACCOUNT_PROJECT_ID, RUNTIME_ID, "project identity");
|
|
21
|
+
const runtimeBaseDomain = requiredHostname(env.ACCOUNT_RUNTIME_BASE_DOMAIN, "runtime base domain");
|
|
22
|
+
return {
|
|
23
|
+
tenantId,
|
|
24
|
+
projectId,
|
|
25
|
+
runtimeBaseDomain,
|
|
26
|
+
canonicalHostname: canonicalApplicationHostname({ tenantId, projectId, baseDomain: runtimeBaseDomain }),
|
|
27
|
+
storefrontDomain: requiredHostname(env.SHOPIFY_STOREFRONT_DOMAIN, "Shopify storefront domain"),
|
|
28
|
+
customerAccountClientId: requiredMatch(
|
|
29
|
+
env.SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID,
|
|
30
|
+
PUBLIC_CLIENT_ID,
|
|
31
|
+
"Shopify public customer-account client ID",
|
|
32
|
+
),
|
|
33
|
+
customerAccountDomain: requiredHostname(env.SHOPIFY_CUSTOMER_ACCOUNT_DOMAIN, "Shopify customer-account domain"),
|
|
34
|
+
vanityHostnames: optionalHostnameList(env.ACCOUNT_VANITY_HOSTNAMES),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function optionalHostnameList(value: string | undefined): string[] {
|
|
39
|
+
if (value === undefined) return [];
|
|
40
|
+
if (value.trim() === "") return [];
|
|
41
|
+
const hostnames = [...new Set(value.split(",").map((hostname) => requiredHostname(hostname, "vanity hostname")))];
|
|
42
|
+
if (hostnames.length > 8) throw new Error("Too many vanity hostnames are configured.");
|
|
43
|
+
return hostnames;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function requiredHostname(value: string, label: string): string {
|
|
47
|
+
const hostname = value.trim().toLowerCase().replace(/\.$/u, "");
|
|
48
|
+
if (!HOSTNAME.test(hostname)) throw new Error(`The ${label} is invalid.`);
|
|
49
|
+
return hostname;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function requiredMatch(value: string, pattern: RegExp, label: string): string {
|
|
53
|
+
const normalized = value.trim();
|
|
54
|
+
if (!pattern.test(normalized)) throw new Error(`The ${label} is invalid.`);
|
|
55
|
+
return normalized;
|
|
56
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createTenderRuntimeHostnamePolicy } from "./platform/customer-auth/hostname-policy";
|
|
2
|
+
import { PublicCustomerAuthSessionStore } from "./platform/customer-auth/session-store";
|
|
3
|
+
import { createShopifyCustomerAccountRouteAdapter } from "./platform/customer-auth/shopify-route-adapter";
|
|
4
|
+
import { forwardPortalRequest } from "./portal-service";
|
|
5
|
+
import { readRuntimeConfig } from "./runtime-config";
|
|
6
|
+
|
|
7
|
+
const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
8
|
+
|
|
9
|
+
export default {
|
|
10
|
+
async fetch(request: Request, env: Env): Promise<Response> {
|
|
11
|
+
let config;
|
|
12
|
+
try {
|
|
13
|
+
config = readRuntimeConfig(env);
|
|
14
|
+
} catch {
|
|
15
|
+
return jsonResponse(503, "application_not_configured");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const url = new URL(request.url);
|
|
19
|
+
if (request.method === "GET" && url.pathname === "/api/health") {
|
|
20
|
+
return Response.json({ ok: true, service: "shopify-customer-account-gateway" });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const localDevelopment = (url.protocol === "http:" || url.protocol === "https:")
|
|
24
|
+
&& LOOPBACK_HOSTNAMES.has(url.hostname.toLowerCase());
|
|
25
|
+
if (localDevelopment && request.method === "GET" && url.pathname === "/api/bootstrap") {
|
|
26
|
+
return Response.json({ canonicalHostname: config.canonicalHostname, loginUrl: null, localDevelopment: true });
|
|
27
|
+
}
|
|
28
|
+
if (localDevelopment && request.method === "GET" && url.pathname === "/api/session") {
|
|
29
|
+
return Response.json({ authenticated: false, localDevelopment: true });
|
|
30
|
+
}
|
|
31
|
+
if (localDevelopment && url.pathname.startsWith("/auth/")) {
|
|
32
|
+
return jsonResponse(409, "authentication_requires_https_preview");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const hostnamePolicy = createTenderRuntimeHostnamePolicy({
|
|
36
|
+
tenantId: config.tenantId,
|
|
37
|
+
projectId: config.projectId,
|
|
38
|
+
baseDomain: config.runtimeBaseDomain,
|
|
39
|
+
additionalHostnames: config.vanityHostnames,
|
|
40
|
+
});
|
|
41
|
+
const auth = createShopifyCustomerAccountRouteAdapter({
|
|
42
|
+
config: {
|
|
43
|
+
projectId: config.projectId,
|
|
44
|
+
canonicalHostname: config.canonicalHostname,
|
|
45
|
+
additionalCallbackHostnames: config.vanityHostnames,
|
|
46
|
+
storefrontDomain: config.storefrontDomain,
|
|
47
|
+
clientId: config.customerAccountClientId,
|
|
48
|
+
additionalProviderHostnames: [config.customerAccountDomain],
|
|
49
|
+
},
|
|
50
|
+
sessionStore: new PublicCustomerAuthSessionStore(env.APP_DB, hostnamePolicy),
|
|
51
|
+
hostnamePolicy,
|
|
52
|
+
});
|
|
53
|
+
const authResponse = await auth.handle(request);
|
|
54
|
+
if (authResponse) return authResponse;
|
|
55
|
+
|
|
56
|
+
if (request.method === "GET" && url.pathname === "/api/bootstrap") {
|
|
57
|
+
const hostname = url.hostname.toLowerCase().replace(/\.$/u, "");
|
|
58
|
+
if (!await hostnamePolicy({ projectId: config.projectId, hostname })) return jsonResponse(404, "not_found");
|
|
59
|
+
const loginHostname = config.vanityHostnames.includes(hostname)
|
|
60
|
+
? hostname
|
|
61
|
+
: config.canonicalHostname;
|
|
62
|
+
const loginUrl = new URL(`https://${loginHostname}/auth/login`);
|
|
63
|
+
loginUrl.searchParams.set("return_host", hostname);
|
|
64
|
+
return Response.json({ canonicalHostname: config.canonicalHostname, loginUrl: loginUrl.toString() });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/auth/")) return jsonResponse(404, "not_found");
|
|
68
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
69
|
+
return new Response(null, { status: 405, headers: { Allow: "GET, HEAD" } });
|
|
70
|
+
}
|
|
71
|
+
return await forwardPortalRequest(request, env.PORTAL_UI);
|
|
72
|
+
},
|
|
73
|
+
} satisfies ExportedHandler<Env>;
|
|
74
|
+
|
|
75
|
+
function jsonResponse(status: number, code: string): Response {
|
|
76
|
+
return Response.json({ error: { code } }, {
|
|
77
|
+
status,
|
|
78
|
+
headers: {
|
|
79
|
+
"Cache-Control": "no-store",
|
|
80
|
+
"Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'",
|
|
81
|
+
"Referrer-Policy": "no-referrer",
|
|
82
|
+
"X-Content-Type-Options": "nosniff",
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": "tender.account-source/v1",
|
|
3
|
+
"app": {
|
|
4
|
+
"slug": "shopify-customer-account",
|
|
5
|
+
"label": "__APP_NAME__ gateway",
|
|
6
|
+
"fixture": false
|
|
7
|
+
},
|
|
8
|
+
"runtime": {
|
|
9
|
+
"kind": "worker",
|
|
10
|
+
"compatibilityDate": "2026-08-25",
|
|
11
|
+
"mainModule": "worker.mjs"
|
|
12
|
+
},
|
|
13
|
+
"health": {
|
|
14
|
+
"path": "/api/health"
|
|
15
|
+
},
|
|
16
|
+
"resources": [
|
|
17
|
+
{
|
|
18
|
+
"type": "d1",
|
|
19
|
+
"name": "APP_DB"
|
|
20
|
+
}
|
|
21
|
+
],
|
|
22
|
+
"services": [
|
|
23
|
+
{
|
|
24
|
+
"name": "PORTAL_UI",
|
|
25
|
+
"targetProject": "portal-ui"
|
|
26
|
+
}
|
|
27
|
+
],
|
|
28
|
+
"publicVarNames": [
|
|
29
|
+
"ACCOUNT_PROJECT_ID",
|
|
30
|
+
"ACCOUNT_RUNTIME_BASE_DOMAIN",
|
|
31
|
+
"ACCOUNT_TENANT_ID",
|
|
32
|
+
"SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID",
|
|
33
|
+
"SHOPIFY_CUSTOMER_ACCOUNT_DOMAIN",
|
|
34
|
+
"SHOPIFY_STOREFRONT_DOMAIN"
|
|
35
|
+
],
|
|
36
|
+
"optionalPublicVarNames": [
|
|
37
|
+
"ACCOUNT_VANITY_HOSTNAMES"
|
|
38
|
+
],
|
|
39
|
+
"databaseSchema": "./database-schema.json"
|
|
40
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["ES2022", "WebWorker"],
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"moduleResolution": "Bundler",
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"types": ["@cloudflare/workers-types", "node", "vitest/globals"]
|
|
11
|
+
},
|
|
12
|
+
"include": ["src", "worker-configuration.d.ts", "vitest.config.ts"]
|
|
13
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
// Generated by Wrangler by running `wrangler types --include-runtime=false` (hash: 23b400f970c42d12c11b32e966d43d91)
|
|
3
|
+
interface __BaseEnv_Env {
|
|
4
|
+
APP_DB: D1Database;
|
|
5
|
+
ACCOUNT_TENANT_ID: string;
|
|
6
|
+
ACCOUNT_PROJECT_ID: string;
|
|
7
|
+
ACCOUNT_RUNTIME_BASE_DOMAIN: string;
|
|
8
|
+
ACCOUNT_VANITY_HOSTNAMES?: string;
|
|
9
|
+
SHOPIFY_STOREFRONT_DOMAIN: string;
|
|
10
|
+
SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID: string;
|
|
11
|
+
SHOPIFY_CUSTOMER_ACCOUNT_DOMAIN: string;
|
|
12
|
+
PORTAL_UI: Fetcher /* __APP_SLUG__-portal */;
|
|
13
|
+
}
|
|
14
|
+
declare namespace Cloudflare {
|
|
15
|
+
interface GlobalProps {
|
|
16
|
+
mainModule: typeof import("./src/worker");
|
|
17
|
+
}
|
|
18
|
+
interface Env extends __BaseEnv_Env {}
|
|
19
|
+
}
|
|
20
|
+
interface Env extends __BaseEnv_Env {}
|
|
21
|
+
type StringifyValues<EnvType extends Record<string, unknown>> = {
|
|
22
|
+
[Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;
|
|
23
|
+
};
|
|
24
|
+
declare namespace NodeJS {
|
|
25
|
+
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "ACCOUNT_TENANT_ID" | "ACCOUNT_PROJECT_ID" | "ACCOUNT_RUNTIME_BASE_DOMAIN" | "ACCOUNT_VANITY_HOSTNAMES" | "SHOPIFY_STOREFRONT_DOMAIN" | "SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID" | "SHOPIFY_CUSTOMER_ACCOUNT_DOMAIN">> {}
|
|
26
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../node_modules/wrangler/config-schema.json",
|
|
3
|
+
"name": "__APP_SLUG__-gateway",
|
|
4
|
+
"main": "./src/worker.ts",
|
|
5
|
+
"workers_dev": false,
|
|
6
|
+
"preview_urls": false,
|
|
7
|
+
"compatibility_date": "2026-08-25",
|
|
8
|
+
"compatibility_flags": ["nodejs_compat"],
|
|
9
|
+
"dev": {
|
|
10
|
+
"ip": "127.0.0.1",
|
|
11
|
+
"port": 8787,
|
|
12
|
+
"local_protocol": "http"
|
|
13
|
+
},
|
|
14
|
+
"services": [
|
|
15
|
+
{
|
|
16
|
+
"binding": "PORTAL_UI",
|
|
17
|
+
"service": "__APP_SLUG__-portal"
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"d1_databases": [
|
|
21
|
+
{
|
|
22
|
+
"binding": "APP_DB",
|
|
23
|
+
"database_name": "__APP_SLUG__-sessions",
|
|
24
|
+
"database_id": "00000000-0000-0000-0000-000000000000",
|
|
25
|
+
"migrations_dir": "./migrations"
|
|
26
|
+
}
|
|
27
|
+
],
|
|
28
|
+
"secrets": {
|
|
29
|
+
"required": [
|
|
30
|
+
"ACCOUNT_TENANT_ID",
|
|
31
|
+
"ACCOUNT_PROJECT_ID",
|
|
32
|
+
"ACCOUNT_RUNTIME_BASE_DOMAIN",
|
|
33
|
+
"SHOPIFY_STOREFRONT_DOMAIN",
|
|
34
|
+
"SHOPIFY_CUSTOMER_ACCOUNT_CLIENT_ID",
|
|
35
|
+
"SHOPIFY_CUSTOMER_ACCOUNT_DOMAIN"
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
"observability": {
|
|
39
|
+
"enabled": true,
|
|
40
|
+
"head_sampling_rate": 1
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Portal UI boundary
|
|
2
|
+
|
|
3
|
+
This React app is the normal merchant and marketing-team editing surface.
|
|
4
|
+
|
|
5
|
+
- Keep it presentation-only. It may call same-origin gateway routes but must not receive Shopify customer tokens, D1 bindings, or provider secrets.
|
|
6
|
+
- For UI-only work, never edit `../gateway`.
|
|
7
|
+
- Use real gateway responses; do not add fake authenticated customer data to production code.
|
|
8
|
+
- Keep keyboard navigation, focus states, reduced-motion behavior, and responsive layouts intact.
|
|
9
|
+
- Run `npm run check` and a Tender dry-run preview after changes.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Portal UI
|
|
2
|
+
|
|
3
|
+
React customer-account surface for __APP_NAME__. This app is independently deployable and intentionally has no confidential bindings or customer-token storage.
|
|
4
|
+
|
|
5
|
+
The portal calls only same-origin gateway routes such as `/api/bootstrap` and `/api/session`. Most vibe-coded visual and content changes should stay in this directory.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<meta name="theme-color" content="#18261f" />
|
|
7
|
+
<title>__APP_NAME__</title>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="root"></div>
|
|
11
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__APP_SLUG__-portal",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build": "npm run build:client && npm run build:worker",
|
|
8
|
+
"build:client": "vite build",
|
|
9
|
+
"build:worker": "node ./scripts/build-worker.mjs",
|
|
10
|
+
"cf:types": "npm run build && wrangler types --include-runtime false",
|
|
11
|
+
"check": "npm run typecheck && npm run test && npm run build && npm run pack:check",
|
|
12
|
+
"dev": "wrangler dev",
|
|
13
|
+
"dev:ui": "vite",
|
|
14
|
+
"pack:tender": "node ./scripts/pack-tender-artifact.mjs",
|
|
15
|
+
"pack:check": "node ./scripts/pack-tender-artifact.mjs --source-revision ffffffffffffffffffffffffffffffffffffffff",
|
|
16
|
+
"test": "node --test ./scripts/*.test.mjs",
|
|
17
|
+
"typecheck": "tsc --noEmit"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"react": "^19.2.8",
|
|
21
|
+
"react-dom": "^19.2.8"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@cloudflare/workers-types": "^5.20260825.1",
|
|
25
|
+
"@types/node": "^26.2.0",
|
|
26
|
+
"@types/react": "^19.2.18",
|
|
27
|
+
"@types/react-dom": "^19.2.5",
|
|
28
|
+
"@vitejs/plugin-react": "^6.1.0",
|
|
29
|
+
"typescript": "^7.0.2",
|
|
30
|
+
"vite": "^8.2.2",
|
|
31
|
+
"wrangler": "^4.125.0"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { readPortalAssets, renderPortalWorker } from "./portal-worker.mjs";
|
|
5
|
+
|
|
6
|
+
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
7
|
+
const assets = await readPortalAssets(join(appRoot, "dist/client"));
|
|
8
|
+
const output = join(appRoot, ".tender/build/worker.mjs");
|
|
9
|
+
await mkdir(dirname(output), { recursive: true });
|
|
10
|
+
await writeFile(output, renderPortalWorker(assets), "utf8");
|
|
11
|
+
process.stdout.write(`Built portal Worker with ${assets.length} embedded assets.\n`);
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { bundleDigest, sha256 } from "../../../scripts/artifact-contract.mjs";
|
|
5
|
+
|
|
6
|
+
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
7
|
+
const config = JSON.parse(await readFile(join(appRoot, "tender-app.config.json"), "utf8"));
|
|
8
|
+
const packageJson = JSON.parse(await readFile(join(appRoot, "package.json"), "utf8"));
|
|
9
|
+
const sourceRevision = (argumentValue("--source-revision") || process.env.TENDER_SOURCE_REVISION || "").trim().toLowerCase();
|
|
10
|
+
if (!/^[a-f0-9]{40}$/u.test(sourceRevision)) throw new Error("A committed 40-character source revision is required.");
|
|
11
|
+
if (config.schema !== "tender.account-source/v1" || config.app?.slug !== "portal-ui"
|
|
12
|
+
|| config.runtime?.compatibilityDate !== "2026-08-25" || config.runtime?.mainModule !== "worker.mjs"
|
|
13
|
+
|| JSON.stringify(config.resources) !== "[]" || JSON.stringify(config.services) !== "[]") {
|
|
14
|
+
throw new Error("The portal source contract is invalid.");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const workerBytes = await readFile(join(appRoot, ".tender/build/worker.mjs"));
|
|
18
|
+
const module = {
|
|
19
|
+
path: "worker.mjs",
|
|
20
|
+
sha256: sha256(workerBytes),
|
|
21
|
+
bytes: workerBytes.byteLength,
|
|
22
|
+
contentType: "application/javascript+module",
|
|
23
|
+
};
|
|
24
|
+
const files = [];
|
|
25
|
+
const digest = bundleDigest(module, files);
|
|
26
|
+
const manifest = {
|
|
27
|
+
schema: "tender.account-artifact/v1",
|
|
28
|
+
app: { slug: "portal-ui", version: packageJson.version, label: config.app.label, fixture: false },
|
|
29
|
+
runtime: {
|
|
30
|
+
kind: "worker",
|
|
31
|
+
compatibilityDate: "2026-08-25",
|
|
32
|
+
mainModule: "worker.mjs",
|
|
33
|
+
assetPrefix: `/_tenant-artifacts/${digest}`,
|
|
34
|
+
},
|
|
35
|
+
module,
|
|
36
|
+
files,
|
|
37
|
+
health: { path: "/api/health" },
|
|
38
|
+
resources: [],
|
|
39
|
+
services: [],
|
|
40
|
+
};
|
|
41
|
+
const artifactsRoot = join(appRoot, ".tender/artifacts");
|
|
42
|
+
const nextRoot = join(artifactsRoot, "next");
|
|
43
|
+
const outputRoot = join(artifactsRoot, "current");
|
|
44
|
+
await rm(nextRoot, { recursive: true, force: true });
|
|
45
|
+
await mkdir(nextRoot, { recursive: true });
|
|
46
|
+
await writeFile(join(nextRoot, "worker.mjs"), workerBytes);
|
|
47
|
+
await writeFile(join(nextRoot, "tender-accounts-build.json"), `${JSON.stringify({
|
|
48
|
+
schema: "tender.external-app-build/v1",
|
|
49
|
+
sourceRevision,
|
|
50
|
+
bundleDigest: digest,
|
|
51
|
+
manifest,
|
|
52
|
+
}, null, 2)}\n`, "utf8");
|
|
53
|
+
await rm(outputRoot, { recursive: true, force: true });
|
|
54
|
+
await rename(nextRoot, outputRoot);
|
|
55
|
+
process.stdout.write(`${JSON.stringify({ status: "packed", bundleDigest: digest, outputRoot })}\n`);
|
|
56
|
+
|
|
57
|
+
function argumentValue(name) {
|
|
58
|
+
const index = process.argv.indexOf(name);
|
|
59
|
+
if (index === -1) return null;
|
|
60
|
+
const value = process.argv[index + 1];
|
|
61
|
+
if (!value || value.startsWith("--")) throw new Error(`Missing value for ${name}.`);
|
|
62
|
+
return value;
|
|
63
|
+
}
|