@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.
Files changed (90) hide show
  1. package/README.md +84 -0
  2. package/dist/api.d.ts +27 -0
  3. package/dist/api.d.ts.map +1 -0
  4. package/dist/api.js +73 -0
  5. package/dist/api.js.map +1 -0
  6. package/dist/artifact.d.ts +8 -0
  7. package/dist/artifact.d.ts.map +1 -0
  8. package/dist/artifact.js +205 -0
  9. package/dist/artifact.js.map +1 -0
  10. package/dist/cli.d.ts +3 -0
  11. package/dist/cli.d.ts.map +1 -0
  12. package/dist/cli.js +4 -0
  13. package/dist/cli.js.map +1 -0
  14. package/dist/contracts.d.ts +96 -0
  15. package/dist/contracts.d.ts.map +1 -0
  16. package/dist/contracts.js +5 -0
  17. package/dist/contracts.js.map +1 -0
  18. package/dist/errors.d.ts +10 -0
  19. package/dist/errors.d.ts.map +1 -0
  20. package/dist/errors.js +19 -0
  21. package/dist/errors.js.map +1 -0
  22. package/dist/files.d.ts +21 -0
  23. package/dist/files.d.ts.map +1 -0
  24. package/dist/files.js +263 -0
  25. package/dist/files.js.map +1 -0
  26. package/dist/index.d.ts +4 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +4 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/main.d.ts +15 -0
  31. package/dist/main.d.ts.map +1 -0
  32. package/dist/main.js +565 -0
  33. package/dist/main.js.map +1 -0
  34. package/dist/processes.d.ts +12 -0
  35. package/dist/processes.d.ts.map +1 -0
  36. package/dist/processes.js +44 -0
  37. package/dist/processes.js.map +1 -0
  38. package/dist/starter.d.ts +20 -0
  39. package/dist/starter.d.ts.map +1 -0
  40. package/dist/starter.js +159 -0
  41. package/dist/starter.js.map +1 -0
  42. package/package.json +32 -0
  43. package/skill/tender-accounts/SKILL.md +111 -0
  44. package/templates/shopify-customer-account/AGENTS.md +14 -0
  45. package/templates/shopify-customer-account/README.md +44 -0
  46. package/templates/shopify-customer-account/apps/gateway/AGENTS.md +9 -0
  47. package/templates/shopify-customer-account/apps/gateway/README.md +9 -0
  48. package/templates/shopify-customer-account/apps/gateway/database-schema.json +49 -0
  49. package/templates/shopify-customer-account/apps/gateway/dot.dev.vars.example +11 -0
  50. package/templates/shopify-customer-account/apps/gateway/migrations/0001_customer_auth.sql +76 -0
  51. package/templates/shopify-customer-account/apps/gateway/package.json +25 -0
  52. package/templates/shopify-customer-account/apps/gateway/scripts/build-worker.mjs +15 -0
  53. package/templates/shopify-customer-account/apps/gateway/scripts/pack-tender-artifact.mjs +84 -0
  54. package/templates/shopify-customer-account/apps/gateway/src/platform/customer-auth/hostname-policy.ts +66 -0
  55. package/templates/shopify-customer-account/apps/gateway/src/platform/customer-auth/schema.ts +90 -0
  56. package/templates/shopify-customer-account/apps/gateway/src/platform/customer-auth/session-store.ts +864 -0
  57. package/templates/shopify-customer-account/apps/gateway/src/platform/customer-auth/shopify-route-adapter.ts +1157 -0
  58. package/templates/shopify-customer-account/apps/gateway/src/platform/runtime-hostname.ts +46 -0
  59. package/templates/shopify-customer-account/apps/gateway/src/portal-service.test.ts +18 -0
  60. package/templates/shopify-customer-account/apps/gateway/src/portal-service.ts +51 -0
  61. package/templates/shopify-customer-account/apps/gateway/src/runtime-config.test.ts +30 -0
  62. package/templates/shopify-customer-account/apps/gateway/src/runtime-config.ts +56 -0
  63. package/templates/shopify-customer-account/apps/gateway/src/worker.ts +85 -0
  64. package/templates/shopify-customer-account/apps/gateway/tender-accounts.json +10 -0
  65. package/templates/shopify-customer-account/apps/gateway/tender-app.config.json +40 -0
  66. package/templates/shopify-customer-account/apps/gateway/tsconfig.json +13 -0
  67. package/templates/shopify-customer-account/apps/gateway/vitest.config.ts +8 -0
  68. package/templates/shopify-customer-account/apps/gateway/worker-configuration.d.ts +26 -0
  69. package/templates/shopify-customer-account/apps/gateway/wrangler.jsonc +42 -0
  70. package/templates/shopify-customer-account/apps/portal/AGENTS.md +9 -0
  71. package/templates/shopify-customer-account/apps/portal/README.md +5 -0
  72. package/templates/shopify-customer-account/apps/portal/index.html +13 -0
  73. package/templates/shopify-customer-account/apps/portal/package.json +33 -0
  74. package/templates/shopify-customer-account/apps/portal/scripts/build-worker.mjs +11 -0
  75. package/templates/shopify-customer-account/apps/portal/scripts/pack-tender-artifact.mjs +63 -0
  76. package/templates/shopify-customer-account/apps/portal/scripts/portal-worker.mjs +84 -0
  77. package/templates/shopify-customer-account/apps/portal/scripts/portal-worker.test.mjs +14 -0
  78. package/templates/shopify-customer-account/apps/portal/src/App.tsx +111 -0
  79. package/templates/shopify-customer-account/apps/portal/src/main.tsx +10 -0
  80. package/templates/shopify-customer-account/apps/portal/src/styles.css +54 -0
  81. package/templates/shopify-customer-account/apps/portal/tender-accounts.json +10 -0
  82. package/templates/shopify-customer-account/apps/portal/tender-app.config.json +19 -0
  83. package/templates/shopify-customer-account/apps/portal/tsconfig.json +14 -0
  84. package/templates/shopify-customer-account/apps/portal/vite.config.ts +7 -0
  85. package/templates/shopify-customer-account/apps/portal/worker-configuration.d.ts +11 -0
  86. package/templates/shopify-customer-account/apps/portal/wrangler.jsonc +22 -0
  87. package/templates/shopify-customer-account/dot.dev.vars.example +3 -0
  88. package/templates/shopify-customer-account/dot.gitignore +9 -0
  89. package/templates/shopify-customer-account/package.json +22 -0
  90. package/templates/shopify-customer-account/scripts/artifact-contract.mjs +109 -0
@@ -0,0 +1,84 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { describePublicFiles } from "../../../scripts/artifact-contract.mjs";
4
+
5
+ const CONTENT_TYPES = Object.freeze({
6
+ ".css": "text/css; charset=utf-8",
7
+ ".html": "text/html; charset=utf-8",
8
+ ".js": "text/javascript; charset=utf-8",
9
+ ".json": "application/json; charset=utf-8",
10
+ ".png": "image/png",
11
+ ".svg": "image/svg+xml",
12
+ ".webp": "image/webp",
13
+ ".woff": "font/woff",
14
+ ".woff2": "font/woff2",
15
+ });
16
+
17
+ export async function readPortalAssets(root) {
18
+ const files = await describePublicFiles(root);
19
+ return await Promise.all(files.map(async (file) => ({
20
+ path: file.path,
21
+ bytes: (await readFile(join(root, file.path))).toString("base64"),
22
+ contentType: contentTypeForPath(file.path),
23
+ })));
24
+ }
25
+
26
+ export function renderPortalWorker(assets) {
27
+ if (!Array.isArray(assets) || !assets.some((asset) => asset.path === "index.html")) {
28
+ throw new Error("The portal build is missing index.html.");
29
+ }
30
+ return `const assets = new Map(${JSON.stringify(assets.map((asset) => [asset.path, {
31
+ bytes: asset.bytes,
32
+ contentType: asset.contentType,
33
+ }]))});
34
+
35
+ function decoded(value) {
36
+ const binary = atob(value);
37
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
38
+ }
39
+
40
+ function selectedAsset(pathname) {
41
+ if (pathname === "/") return "index.html";
42
+ const direct = pathname.startsWith("/") ? pathname.slice(1) : pathname;
43
+ if (assets.has(direct)) return direct;
44
+ return pathname.slice(pathname.lastIndexOf("/") + 1).includes(".") ? null : "index.html";
45
+ }
46
+
47
+ function headersFor(path, asset) {
48
+ const headers = new Headers({
49
+ "content-type": asset.contentType,
50
+ "referrer-policy": "strict-origin-when-cross-origin",
51
+ "x-content-type-options": "nosniff",
52
+ "x-frame-options": "DENY",
53
+ });
54
+ if (path === "index.html") {
55
+ headers.set("cache-control", "no-cache");
56
+ headers.set("content-security-policy", "default-src 'self'; base-uri 'none'; connect-src 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self'");
57
+ } else if (path.startsWith("assets/")) {
58
+ headers.set("cache-control", "public, max-age=31536000, immutable");
59
+ }
60
+ return headers;
61
+ }
62
+
63
+ export default {
64
+ async fetch(request) {
65
+ const url = new URL(request.url);
66
+ if (url.pathname === "/api/health") {
67
+ if (request.method !== "GET" && request.method !== "HEAD") return new Response(null, { status: 405, headers: { allow: "GET, HEAD" } });
68
+ const response = Response.json({ ok: true, service: "portal-ui" }, { headers: { "cache-control": "no-store" } });
69
+ return request.method === "HEAD" ? new Response(null, { status: 200, headers: response.headers }) : response;
70
+ }
71
+ if (request.method !== "GET" && request.method !== "HEAD") return new Response(null, { status: 405, headers: { allow: "GET, HEAD" } });
72
+ const path = selectedAsset(url.pathname);
73
+ const asset = path === null ? undefined : assets.get(path);
74
+ if (!path || !asset) return new Response("Not found", { status: 404 });
75
+ return new Response(request.method === "HEAD" ? null : decoded(asset.bytes), { status: 200, headers: headersFor(path, asset) });
76
+ },
77
+ };
78
+ `;
79
+ }
80
+
81
+ export function contentTypeForPath(path) {
82
+ const match = /\.[a-z0-9]+$/iu.exec(path);
83
+ return CONTENT_TYPES[match?.[0]?.toLowerCase()] ?? "application/octet-stream";
84
+ }
@@ -0,0 +1,14 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { contentTypeForPath, renderPortalWorker } from "./portal-worker.mjs";
4
+
5
+ test("renders an isolated portal worker with SPA fallback", () => {
6
+ const worker = renderPortalWorker([{ path: "index.html", bytes: "PGgxPk9rPC9oMT4=", contentType: "text/html; charset=utf-8" }]);
7
+ assert.match(worker, /frame-ancestors 'none'/u);
8
+ assert.match(worker, /selectedAsset/u);
9
+ });
10
+
11
+ test("uses safe content types", () => {
12
+ assert.equal(contentTypeForPath("assets/app.js"), "text/javascript; charset=utf-8");
13
+ assert.equal(contentTypeForPath("unknown.bin"), "application/octet-stream");
14
+ });
@@ -0,0 +1,111 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ interface Bootstrap {
4
+ loginUrl: string | null;
5
+ localDevelopment?: boolean;
6
+ }
7
+
8
+ interface Session {
9
+ authenticated: boolean;
10
+ localDevelopment?: boolean;
11
+ customer?: {
12
+ id: string;
13
+ displayName: string;
14
+ emailAddress?: { emailAddress?: string | null } | null;
15
+ };
16
+ }
17
+
18
+ type AccountState =
19
+ | { status: "loading" }
20
+ | { status: "ready"; bootstrap: Bootstrap; session: Session }
21
+ | { status: "error" };
22
+
23
+ export function App() {
24
+ const [state, setState] = useState<AccountState>({ status: "loading" });
25
+
26
+ useEffect(() => {
27
+ const controller = new AbortController();
28
+ void Promise.all([
29
+ fetchJson<Bootstrap>("/api/bootstrap", controller.signal),
30
+ fetchJson<Session>("/api/session", controller.signal),
31
+ ]).then(([bootstrap, session]) => setState({ status: "ready", bootstrap, session }))
32
+ .catch((error: unknown) => {
33
+ if (!(error instanceof DOMException && error.name === "AbortError")) setState({ status: "error" });
34
+ });
35
+ return () => controller.abort();
36
+ }, []);
37
+
38
+ const customer = state.status === "ready" && state.session.authenticated
39
+ ? state.session.customer
40
+ : undefined;
41
+ return (
42
+ <main className="shell">
43
+ <header className="masthead">
44
+ <a className="wordmark" href="/">__APP_NAME__</a>
45
+ <span className="edition">Customer account</span>
46
+ </header>
47
+
48
+ <section className="hero" aria-busy={state.status === "loading"}>
49
+ <p className="eyebrow">Your membership, clearly arranged</p>
50
+ <h1>{customer ? `Welcome back, ${customer.displayName}.` : "Everything yours, in one place."}</h1>
51
+ <p className="lede">
52
+ {customer
53
+ ? "Your account is securely connected to Shopify. Add merchant-specific features without moving customer identity out of its system of record."
54
+ : "Sign in securely with Shopify to see the details connected to this store."}
55
+ </p>
56
+
57
+ {state.status === "loading" && <p className="status">Opening your account…</p>}
58
+ {state.status === "error" && (
59
+ <div className="notice" role="alert">
60
+ <strong>We could not open the account.</strong>
61
+ <span>Refresh the page or try again in a moment.</span>
62
+ </div>
63
+ )}
64
+ {state.status === "ready" && !customer && (
65
+ <div className="actions">
66
+ {state.bootstrap.loginUrl
67
+ ? <a className="primary" href={state.bootstrap.loginUrl}>Sign in</a>
68
+ : <button className="primary" type="button" disabled>Sign in</button>}
69
+ {state.session.localDevelopment && (
70
+ <span className="hint">Real sign-in is available on an HTTPS Tender preview.</span>
71
+ )}
72
+ </div>
73
+ )}
74
+ {customer && (
75
+ <div className="actions">
76
+ <form action="/auth/logout" method="post"><button className="secondary" type="submit">Sign out</button></form>
77
+ </div>
78
+ )}
79
+ </section>
80
+
81
+ <section className="account-grid" aria-label="Account sections">
82
+ <article className="account-card feature">
83
+ <span className="index">01</span>
84
+ <div><h2>Profile</h2><p>{customer?.emailAddress?.emailAddress ?? "Available after sign-in"}</p></div>
85
+ <span className="arrow" aria-hidden="true">↗</span>
86
+ </article>
87
+ <article className="account-card">
88
+ <span className="index">02</span>
89
+ <div><h2>Orders</h2><p>Connect the Shopify order query your experience needs.</p></div>
90
+ <span className="arrow" aria-hidden="true">↗</span>
91
+ </article>
92
+ <article className="account-card">
93
+ <span className="index">03</span>
94
+ <div><h2>Membership</h2><p>Add your subscription provider as a replaceable integration.</p></div>
95
+ <span className="arrow" aria-hidden="true">↗</span>
96
+ </article>
97
+ </section>
98
+
99
+ <footer>
100
+ <span>Built for change.</span>
101
+ <span>Shopify remains the customer system of record.</span>
102
+ </footer>
103
+ </main>
104
+ );
105
+ }
106
+
107
+ async function fetchJson<T>(path: string, signal: AbortSignal): Promise<T> {
108
+ const response = await fetch(path, { signal, headers: { Accept: "application/json" } });
109
+ if (!response.ok) throw new Error(`${path} returned ${response.status}`);
110
+ return await response.json() as T;
111
+ }
@@ -0,0 +1,10 @@
1
+ import { StrictMode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { App } from "./App";
4
+ import "./styles.css";
5
+
6
+ createRoot(document.getElementById("root")!).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ );
@@ -0,0 +1,54 @@
1
+ :root {
2
+ color: #18261f;
3
+ background: #ede8dd;
4
+ font-family: "Avenir Next", "Helvetica Neue", sans-serif;
5
+ font-synthesis: none;
6
+ --ink: #18261f;
7
+ --paper: #f7f3ea;
8
+ --accent: #ee6f3b;
9
+ --line: rgba(24, 38, 31, 0.22);
10
+ }
11
+
12
+ * { box-sizing: border-box; }
13
+ body { margin: 0; min-width: 320px; min-height: 100vh; }
14
+ button, a { font: inherit; }
15
+ button:focus-visible, a:focus-visible { outline: 3px solid var(--accent); outline-offset: 4px; }
16
+
17
+ .shell { min-height: 100vh; padding: 0 clamp(1.25rem, 4vw, 5rem); overflow: hidden; }
18
+ .masthead { height: 5.5rem; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); }
19
+ .wordmark { color: var(--ink); font-family: Georgia, serif; font-size: 1.25rem; font-weight: 700; text-decoration: none; }
20
+ .edition, .eyebrow, .index { font-size: .72rem; font-weight: 800; letter-spacing: .16em; text-transform: uppercase; }
21
+
22
+ .hero { position: relative; max-width: 70rem; padding: clamp(4rem, 10vw, 8rem) 0 clamp(3rem, 7vw, 6rem); }
23
+ .hero::before { content: ""; position: absolute; width: 19rem; height: 19rem; right: -5rem; top: 2rem; border-radius: 50%; border: 1px solid var(--line); box-shadow: inset 0 0 0 3rem rgba(247, 243, 234, .3); }
24
+ .eyebrow { margin: 0 0 1.5rem; color: #526158; }
25
+ h1 { position: relative; max-width: 13ch; margin: 0; font-family: Georgia, serif; font-size: clamp(3.5rem, 9vw, 8.5rem); font-weight: 400; letter-spacing: -.065em; line-height: .86; }
26
+ .lede { max-width: 42rem; margin: 2.5rem 0 0; font-size: clamp(1rem, 1.6vw, 1.35rem); line-height: 1.65; }
27
+ .actions { margin-top: 2.25rem; display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
28
+ .primary, .secondary { min-height: 3rem; padding: .8rem 1.5rem; border: 1px solid var(--ink); border-radius: 999px; font-weight: 800; cursor: pointer; }
29
+ .primary { background: var(--ink); color: var(--paper); text-decoration: none; }
30
+ .primary:disabled { opacity: .45; cursor: not-allowed; }
31
+ .secondary { background: transparent; color: var(--ink); }
32
+ .hint, .status { color: #59675f; font-size: .9rem; }
33
+ .notice { margin-top: 2rem; padding: 1rem 1.25rem; border-left: 5px solid var(--accent); display: grid; gap: .3rem; background: var(--paper); }
34
+
35
+ .account-grid { display: grid; grid-template-columns: repeat(3, 1fr); border-top: 1px solid var(--line); border-left: 1px solid var(--line); }
36
+ .account-card { min-height: 15rem; padding: 1.5rem; display: grid; grid-template-columns: auto 1fr auto; gap: 1.25rem; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); background: rgba(247, 243, 234, .45); transition: transform .25s ease, background .25s ease; }
37
+ .account-card:hover { transform: translateY(-.4rem); background: var(--paper); }
38
+ .account-card.feature { background: var(--accent); }
39
+ .account-card h2 { margin: 0 0 .75rem; font-family: Georgia, serif; font-size: 2rem; font-weight: 400; }
40
+ .account-card p { margin: 0; color: #425048; line-height: 1.5; }
41
+ .feature p { color: var(--ink); }
42
+ .arrow { align-self: end; font-size: 1.5rem; }
43
+ footer { min-height: 6rem; display: flex; align-items: center; justify-content: space-between; gap: 1rem; color: #59675f; font-size: .78rem; }
44
+
45
+ @media (max-width: 760px) {
46
+ .hero::before { width: 11rem; height: 11rem; opacity: .55; }
47
+ .account-grid { grid-template-columns: 1fr; }
48
+ .account-card { min-height: 10rem; }
49
+ footer { align-items: flex-start; flex-direction: column; justify-content: center; }
50
+ }
51
+
52
+ @media (prefers-reduced-motion: reduce) {
53
+ *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; }
54
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "schema": "tender.accounts-cli/v1",
3
+ "commands": {
4
+ "dev": "npm run dev",
5
+ "check": "npm run check",
6
+ "package": "npm run pack:tender"
7
+ },
8
+ "artifactDirectory": ".tender/artifacts/current",
9
+ "returnPath": "/account"
10
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "schema": "tender.account-source/v1",
3
+ "app": {
4
+ "slug": "portal-ui",
5
+ "label": "__APP_NAME__ portal UI",
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
+ "services": [],
18
+ "publicVarNames": []
19
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
5
+ "module": "ESNext",
6
+ "moduleResolution": "Bundler",
7
+ "jsx": "react-jsx",
8
+ "strict": true,
9
+ "noEmit": true,
10
+ "skipLibCheck": true,
11
+ "types": ["vite/client", "node"]
12
+ },
13
+ "include": ["src", "vite.config.ts"]
14
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ build: { outDir: "dist/client", emptyOutDir: true },
7
+ });
@@ -0,0 +1,11 @@
1
+ /* eslint-disable */
2
+ // Generated by Wrangler by running `wrangler types --include-runtime=false` (hash: 6fa2de3ce59698635ff4232fc4f1d661)
3
+ interface __BaseEnv_Env {
4
+ }
5
+ declare namespace Cloudflare {
6
+ interface GlobalProps {
7
+ mainModule: typeof import("./.tender/build/worker");
8
+ }
9
+ interface Env extends __BaseEnv_Env {}
10
+ }
11
+ interface Env extends __BaseEnv_Env {}
@@ -0,0 +1,22 @@
1
+ {
2
+ "$schema": "../../node_modules/wrangler/config-schema.json",
3
+ "name": "__APP_SLUG__-portal",
4
+ "main": "./.tender/build/worker.mjs",
5
+ "workers_dev": false,
6
+ "preview_urls": false,
7
+ "compatibility_date": "2026-08-25",
8
+ "compatibility_flags": ["nodejs_compat"],
9
+ "build": {
10
+ "command": "npm run build --workspace __APP_SLUG__-portal",
11
+ "watch_dir": ["./src", "./index.html", "./scripts"]
12
+ },
13
+ "dev": {
14
+ "ip": "127.0.0.1",
15
+ "port": 8788,
16
+ "local_protocol": "http"
17
+ },
18
+ "observability": {
19
+ "enabled": true,
20
+ "head_sampling_rate": 1
21
+ }
22
+ }
@@ -0,0 +1,3 @@
1
+ # Short-lived, revocable developer credential for the linked Tender projects.
2
+ # This is CLI-only and is never retained in an application artifact.
3
+ TENDER_ACCOUNTS_TOKEN=
@@ -0,0 +1,9 @@
1
+ node_modules/
2
+ dist/
3
+ .tender/
4
+ .wrangler/
5
+ .dev.vars
6
+ .dev.vars.*
7
+ !.dev.vars.example
8
+ *.log
9
+ .DS_Store
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "__APP_SLUG__",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "workspaces": [
7
+ "apps/*"
8
+ ],
9
+ "scripts": {
10
+ "build": "npm run build --workspace __APP_SLUG__-portal && npm run build --workspace __APP_SLUG__-gateway",
11
+ "check": "npm run check --workspace __APP_SLUG__-portal && npm run check --workspace __APP_SLUG__-gateway",
12
+ "cf:types": "npm run cf:types --workspace __APP_SLUG__-portal && npm run cf:types --workspace __APP_SLUG__-gateway",
13
+ "dev": "npm run db:migrate:local --workspace __APP_SLUG__-gateway && wrangler dev -c apps/gateway/wrangler.jsonc -c apps/portal/wrangler.jsonc",
14
+ "dev:gateway": "npm run dev --workspace __APP_SLUG__-gateway",
15
+ "dev:portal": "npm run dev --workspace __APP_SLUG__-portal",
16
+ "test": "npm run test --workspace __APP_SLUG__-portal && npm run test --workspace __APP_SLUG__-gateway",
17
+ "typecheck": "npm run typecheck --workspace __APP_SLUG__-portal && npm run typecheck --workspace __APP_SLUG__-gateway"
18
+ },
19
+ "engines": {
20
+ "node": ">=22.0.0"
21
+ }
22
+ }
@@ -0,0 +1,109 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readdir, readFile } from "node:fs/promises";
3
+ import { join, relative, sep } from "node:path";
4
+
5
+ const PUBLIC_VAR_NAME = /^[A-Z][A-Z0-9_]{2,63}$/;
6
+ const LOGICAL_BINDING_NAME = /^[A-Z][A-Z0-9_]{2,63}$/;
7
+ const RESOURCE_TYPES = new Set(["d1", "kv", "r2", "queue", "workflow", "durable_object"]);
8
+ const SQL_OBJECT_NAME = /^[a-z][a-z0-9_]{1,127}$/;
9
+
10
+ export function sha256(value) {
11
+ return createHash("sha256").update(value).digest("hex");
12
+ }
13
+
14
+ export function bundleDigest(module, files, databaseSchema) {
15
+ return sha256(JSON.stringify({ module, files, databaseSchema }));
16
+ }
17
+
18
+ export async function describePublicFiles(root) {
19
+ const paths = await walk(root);
20
+ const descriptors = [];
21
+ for (const absolutePath of paths) {
22
+ const path = relative(root, absolutePath).split(sep).join("/");
23
+ if (!isSafeArtifactPath(path)) throw new Error("Unsafe artifact path: " + path);
24
+ const bytes = await readFile(absolutePath);
25
+ descriptors.push({ path, sha256: sha256(bytes), bytes: bytes.byteLength });
26
+ }
27
+ return descriptors.sort((left, right) => left.path.localeCompare(right.path));
28
+ }
29
+
30
+ export function exactPublicVars(candidate, requiredNames, optionalNames = []) {
31
+ const result = {};
32
+ for (const name of [...requiredNames].sort()) {
33
+ const value = candidate[name];
34
+ if (!PUBLIC_VAR_NAME.test(name) || name.startsWith("TENDER_") || typeof value !== "string" || !value) {
35
+ throw new Error("Missing or invalid required public var: " + name);
36
+ }
37
+ result[name] = value;
38
+ }
39
+ for (const name of [...optionalNames].sort()) {
40
+ const value = candidate[name];
41
+ if (!PUBLIC_VAR_NAME.test(name) || name.startsWith("TENDER_") || requiredNames.includes(name)) {
42
+ throw new Error("Invalid optional public var: " + name);
43
+ }
44
+ if (value === undefined || value === "") continue;
45
+ if (typeof value !== "string") throw new Error("Invalid optional public var: " + name);
46
+ result[name] = value;
47
+ }
48
+ return result;
49
+ }
50
+
51
+ export function exactResourceRequests(candidate) {
52
+ if (!Array.isArray(candidate) || candidate.length > 24) throw new Error("Invalid resource declarations.");
53
+ const names = new Set();
54
+ return candidate.map((resource) => {
55
+ if (!resource || Object.keys(resource).sort().join(",") !== "name,type"
56
+ || !RESOURCE_TYPES.has(resource.type) || !LOGICAL_BINDING_NAME.test(resource.name)
57
+ || names.has(resource.name)) {
58
+ throw new Error("Invalid resource declaration.");
59
+ }
60
+ names.add(resource.name);
61
+ return { type: resource.type, name: resource.name };
62
+ });
63
+ }
64
+
65
+ export function exactDatabaseSchema(candidate) {
66
+ if (!candidate || candidate.schema !== "tender.d1-schema/v1"
67
+ || !Array.isArray(candidate.statements) || !Array.isArray(candidate.objects)
68
+ || candidate.statements.length !== candidate.objects.length) {
69
+ throw new Error("Invalid D1 schema contract.");
70
+ }
71
+ const statements = candidate.statements.map((statement) => {
72
+ if (typeof statement !== "string" || statement.includes(";")
73
+ || !/^CREATE (?:TABLE|INDEX) IF NOT EXISTS [a-z][a-z0-9_]*\b/.test(statement)) {
74
+ throw new Error("Invalid D1 schema statement.");
75
+ }
76
+ return statement;
77
+ });
78
+ const names = new Set();
79
+ const objects = candidate.objects.map((object, statementIndex) => {
80
+ if (!object || !SQL_OBJECT_NAME.test(object.name) || !SQL_OBJECT_NAME.test(object.tableName)
81
+ || (object.type !== "table" && object.type !== "index")
82
+ || object.statementIndex !== statementIndex || names.has(object.name)) {
83
+ throw new Error("Invalid D1 schema object.");
84
+ }
85
+ names.add(object.name);
86
+ return { type: object.type, name: object.name, tableName: object.tableName, statementIndex };
87
+ });
88
+ return { schema: "tender.d1-schema/v1", statements, objects };
89
+ }
90
+
91
+ function isSafeArtifactPath(value) {
92
+ return Boolean(value) && value.length <= 512 && !value.startsWith("/") && !value.includes("\\")
93
+ && !value.includes("%") && value.split("/").every((segment) => (
94
+ segment !== "." && segment !== ".." && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(segment)
95
+ ));
96
+ }
97
+
98
+ async function walk(root) {
99
+ const entries = await readdir(root, { withFileTypes: true });
100
+ const files = [];
101
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
102
+ const path = join(root, entry.name);
103
+ if (entry.isSymbolicLink()) throw new Error("Artifact symlinks are not allowed: " + path);
104
+ if (entry.isDirectory()) files.push(...await walk(path));
105
+ else if (entry.isFile()) files.push(path);
106
+ else throw new Error("Unsupported artifact entry: " + path);
107
+ }
108
+ return files;
109
+ }