@0x0501/mystack 0.0.0-stage → 0.1.0-bootstrap.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 (211) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -2
  3. package/assets/auth.sqlite.schema.ts +479 -0
  4. package/assets/bun-sqlite.test.ts +46 -0
  5. package/assets/d1.test.ts +41 -0
  6. package/assets/drizzle.config.ts +2 -0
  7. package/assets/generate-auth-schema.mjs +25 -0
  8. package/assets/platform.sqlite.schema.ts +46 -0
  9. package/assets/polar-checkout.ts +10 -0
  10. package/assets/polar-route.ts +19 -0
  11. package/assets/polar.test.ts +30 -0
  12. package/assets/polar.ts +12 -0
  13. package/assets/purchase.bun-sqlite.ts +38 -0
  14. package/assets/purchase.sqlite.ts +39 -0
  15. package/assets/sqlite-migrations/0000_thin_phalanx.sql +295 -0
  16. package/assets/sqlite-migrations/meta/0000_snapshot.json +2058 -0
  17. package/assets/sqlite-migrations/meta/_journal.json +13 -0
  18. package/assets/turso.test.ts +40 -0
  19. package/package.json +44 -5
  20. package/src/adapters/database.mjs +448 -0
  21. package/src/adapters/deployment.mjs +101 -0
  22. package/src/adapters/email.mjs +75 -0
  23. package/src/adapters/payments.mjs +270 -0
  24. package/src/catalog.mjs +128 -0
  25. package/src/cli.mjs +294 -0
  26. package/src/generate.mjs +183 -0
  27. package/src/template.mjs +93 -0
  28. package/template/.env.docker +16 -0
  29. package/template/.env.example +67 -0
  30. package/template/DESIGN.md +58 -0
  31. package/template/LICENSE +21 -0
  32. package/template/biome.json +46 -0
  33. package/template/docker-compose.yml +47 -0
  34. package/template/docs/project-guide.md +61 -0
  35. package/template/drizzle/0000_create-app-role.sql +54 -0
  36. package/template/drizzle/0001_add-rls-context.sql +21 -0
  37. package/template/drizzle/0002_platform_tables.sql +320 -0
  38. package/template/drizzle/meta/0002_snapshot.json +2125 -0
  39. package/template/drizzle/meta/_journal.json +27 -0
  40. package/template/drizzle.config.ts +25 -0
  41. package/template/e2e/authenticated-ui.spec.ts +40 -0
  42. package/template/e2e/headless-design.spec.ts +66 -0
  43. package/template/e2e/navigation-loading.spec.ts +70 -0
  44. package/template/e2e/theme-responsive.spec.ts +260 -0
  45. package/template/gitignore +24 -0
  46. package/template/messages/de.json +212 -0
  47. package/template/messages/en.json +212 -0
  48. package/template/package.json +101 -0
  49. package/template/paraglide.config.ts +49 -0
  50. package/template/playwright.config.ts +58 -0
  51. package/template/project.inlang/settings.json +12 -0
  52. package/template/public/drizzle.svg +1 -0
  53. package/template/public/favicon.ico +0 -0
  54. package/template/public/logo192.png +0 -0
  55. package/template/public/logo512.png +0 -0
  56. package/template/public/manifest.json +25 -0
  57. package/template/public/robots.txt +3 -0
  58. package/template/scripts/check-client-bundle.ts +150 -0
  59. package/template/scripts/check-server-fns.ts +290 -0
  60. package/template/scripts/check-sql-raw.ts +50 -0
  61. package/template/scripts/compile-paraglide.ts +12 -0
  62. package/template/scripts/postgres/README.md +76 -0
  63. package/template/scripts/postgres/create-app-role.sh +60 -0
  64. package/template/scripts/seed-local.ts +331 -0
  65. package/template/scripts/validate-prod-env.ts +53 -0
  66. package/template/src/__tests__/README.md +9 -0
  67. package/template/src/__tests__/account-security.test.ts +664 -0
  68. package/template/src/__tests__/admin-http-surface.test.ts +311 -0
  69. package/template/src/__tests__/admin-peer-protection.test.ts +178 -0
  70. package/template/src/__tests__/auth-harness.ts +104 -0
  71. package/template/src/__tests__/auth-rate-limit.test.ts +43 -0
  72. package/template/src/__tests__/auth-redirect.test.ts +120 -0
  73. package/template/src/__tests__/body-limit.test.ts +47 -0
  74. package/template/src/__tests__/creem.test.ts +62 -0
  75. package/template/src/__tests__/demo-checkout.test.ts +141 -0
  76. package/template/src/__tests__/email-locale.test.ts +110 -0
  77. package/template/src/__tests__/global-setup.ts +80 -0
  78. package/template/src/__tests__/input-rules.test.ts +0 -0
  79. package/template/src/__tests__/message-parity.test.ts +38 -0
  80. package/template/src/__tests__/navigation-loading.test.tsx +252 -0
  81. package/template/src/__tests__/nowpayments.test.ts +106 -0
  82. package/template/src/__tests__/paraglide-url-contract.test.ts +87 -0
  83. package/template/src/__tests__/purchase-fulfillment.test.ts +204 -0
  84. package/template/src/__tests__/query-unauthorized-redirect.test.ts +95 -0
  85. package/template/src/__tests__/rls-policy.test.ts +120 -0
  86. package/template/src/__tests__/same-page-locale-href.test.ts +47 -0
  87. package/template/src/__tests__/search-params-oauth.test.ts +57 -0
  88. package/template/src/__tests__/security-headers.test.ts +12 -0
  89. package/template/src/__tests__/server-fn-error-contract.test.ts +173 -0
  90. package/template/src/__tests__/stripe.test.ts +84 -0
  91. package/template/src/__tests__/test-db.ts +32 -0
  92. package/template/src/__tests__/test-rls-db.ts +23 -0
  93. package/template/src/__tests__/user-update-guard.test.ts +109 -0
  94. package/template/src/components/LocaleSwitcher.tsx +79 -0
  95. package/template/src/components/ThemeToggle.tsx +47 -0
  96. package/template/src/components/auth/social.tsx +90 -0
  97. package/template/src/components/auth/turnstile.tsx +67 -0
  98. package/template/src/components/root/navigation-progress.tsx +32 -0
  99. package/template/src/components/root/not-found.tsx +20 -0
  100. package/template/src/components/root/pending.tsx +9 -0
  101. package/template/src/components/root/root-document.tsx +37 -0
  102. package/template/src/components/root/root-error.tsx +34 -0
  103. package/template/src/components/shells.tsx +201 -0
  104. package/template/src/components/ui/styles.ts +9 -0
  105. package/template/src/db/auth.schema.ts +455 -0
  106. package/template/src/db/helper.ts +38 -0
  107. package/template/src/db/index.ts +15 -0
  108. package/template/src/db/platform.schema.ts +122 -0
  109. package/template/src/db/schema.ts +2 -0
  110. package/template/src/env.ts +65 -0
  111. package/template/src/hooks/use-theme.ts +32 -0
  112. package/template/src/integrations/better-auth/header-user.tsx +36 -0
  113. package/template/src/integrations/better-auth/session-query.ts +11 -0
  114. package/template/src/integrations/react-email/PasswordOtpEmail.tsx +62 -0
  115. package/template/src/integrations/react-email/ResetPasswordEmail.tsx +64 -0
  116. package/template/src/integrations/react-email/VerifyEmail.tsx +72 -0
  117. package/template/src/integrations/react-email/theme.ts +75 -0
  118. package/template/src/integrations/tanstack-query/devtools.tsx +6 -0
  119. package/template/src/integrations/tanstack-query/root-provider.tsx +60 -0
  120. package/template/src/lib/auth-client.ts +27 -0
  121. package/template/src/lib/auth-paths.ts +94 -0
  122. package/template/src/lib/auth-plugins/existing-email-signup.ts +86 -0
  123. package/template/src/lib/auth-plugins/field-rules.ts +84 -0
  124. package/template/src/lib/auth-plugins/social-two-factor.ts +194 -0
  125. package/template/src/lib/auth-timing.ts +9 -0
  126. package/template/src/lib/auth.ts +279 -0
  127. package/template/src/lib/captcha.ts +11 -0
  128. package/template/src/lib/email.tsx +162 -0
  129. package/template/src/lib/github.ts +18 -0
  130. package/template/src/lib/locale-cookie.ts +12 -0
  131. package/template/src/lib/localized-pages.ts +25 -0
  132. package/template/src/lib/oauth-metadata.ts +19 -0
  133. package/template/src/lib/permissions.ts +29 -0
  134. package/template/src/lib/security-headers.ts +20 -0
  135. package/template/src/lib/site.ts +59 -0
  136. package/template/src/lib/theme.ts +42 -0
  137. package/template/src/lib/user-update-guard.ts +35 -0
  138. package/template/src/middlewares/admin.ts +46 -0
  139. package/template/src/middlewares/auth-rate-limit.ts +37 -0
  140. package/template/src/middlewares/better-auth.ts +15 -0
  141. package/template/src/middlewares/body-limit.ts +62 -0
  142. package/template/src/middlewares/database.ts +13 -0
  143. package/template/src/middlewares/protected.ts +45 -0
  144. package/template/src/routeTree.gen.ts +536 -0
  145. package/template/src/router.tsx +53 -0
  146. package/template/src/routes/[.]well-known/oauth-authorization-server/api/auth.ts +18 -0
  147. package/template/src/routes/[.]well-known/oauth-protected-resource/index.ts +32 -0
  148. package/template/src/routes/__root.tsx +175 -0
  149. package/template/src/routes/admin/audit.tsx +74 -0
  150. package/template/src/routes/admin/index.tsx +7 -0
  151. package/template/src/routes/admin/oauth-apps.tsx +164 -0
  152. package/template/src/routes/admin/route.tsx +27 -0
  153. package/template/src/routes/admin/users.tsx +215 -0
  154. package/template/src/routes/api/auth/$.ts +14 -0
  155. package/template/src/routes/api/auth/[.]well-known/openid-configuration.ts +15 -0
  156. package/template/src/routes/api/webhooks/creem.ts +69 -0
  157. package/template/src/routes/api/webhooks/nowpayments.ts +62 -0
  158. package/template/src/routes/api/webhooks/stripe.ts +74 -0
  159. package/template/src/routes/dashboard/account.tsx +430 -0
  160. package/template/src/routes/dashboard/index.tsx +128 -0
  161. package/template/src/routes/dashboard/route.tsx +17 -0
  162. package/template/src/routes/forgot-password.tsx +65 -0
  163. package/template/src/routes/index.tsx +85 -0
  164. package/template/src/routes/oauth/consent.tsx +77 -0
  165. package/template/src/routes/reset-password.tsx +78 -0
  166. package/template/src/routes/sign-in.tsx +174 -0
  167. package/template/src/routes/sign-up.tsx +135 -0
  168. package/template/src/routes/two-factor.tsx +74 -0
  169. package/template/src/server/README.md +3 -0
  170. package/template/src/server/account-security.ts +163 -0
  171. package/template/src/server/admin-audit.ts +37 -0
  172. package/template/src/server/admin-users.ts +112 -0
  173. package/template/src/server/auth.ts +44 -0
  174. package/template/src/server/demo-checkout.ts +67 -0
  175. package/template/src/server/oauth-clients.ts +83 -0
  176. package/template/src/server/payment-toggles.ts +39 -0
  177. package/template/src/server.ts +20 -0
  178. package/template/src/services/README.md +3 -0
  179. package/template/src/services/account-security.ts +893 -0
  180. package/template/src/services/admin-security.ts +164 -0
  181. package/template/src/services/admin-users.ts +181 -0
  182. package/template/src/services/auth-rate-limit.ts +32 -0
  183. package/template/src/services/creem.ts +83 -0
  184. package/template/src/services/demo-checkout.ts +100 -0
  185. package/template/src/services/nowpayments.ts +154 -0
  186. package/template/src/services/oauth-client.ts +85 -0
  187. package/template/src/services/oauth-clients.ts +15 -0
  188. package/template/src/services/payment-providers.ts +157 -0
  189. package/template/src/services/payment-toggles.ts +48 -0
  190. package/template/src/services/purchase.ts +233 -0
  191. package/template/src/services/stripe.ts +106 -0
  192. package/template/src/start.ts +35 -0
  193. package/template/src/styles/layout.css +93 -0
  194. package/template/src/styles/theme.css +84 -0
  195. package/template/src/styles/ui.css +233 -0
  196. package/template/src/styles.css +90 -0
  197. package/template/src/utils/api-error.ts +129 -0
  198. package/template/src/utils/auth-errors.ts +157 -0
  199. package/template/src/utils/auth-redirect.ts +61 -0
  200. package/template/src/utils/body-limit.ts +84 -0
  201. package/template/src/utils/cn.ts +6 -0
  202. package/template/src/utils/input-rules.ts +172 -0
  203. package/template/src/utils/locale-paths.ts +18 -0
  204. package/template/src/utils/same-page-locale-href.ts +20 -0
  205. package/template/src/utils/search-params.ts +71 -0
  206. package/template/tsconfig.json +30 -0
  207. package/template/tsr.config.json +6 -0
  208. package/template/vite.config.ts +24 -0
  209. package/template/vitest.config.ts +26 -0
  210. package/template/worker-configuration.d.ts +14660 -0
  211. package/template/wrangler.jsonc +39 -0
Binary file
Binary file
Binary file
@@ -0,0 +1,25 @@
1
+ {
2
+ "short_name": "Starter",
3
+ "name": "TanStack Starter",
4
+ "icons": [
5
+ {
6
+ "src": "/favicon.ico",
7
+ "sizes": "64x64 32x32 24x24 16x16",
8
+ "type": "image/x-icon"
9
+ },
10
+ {
11
+ "src": "/logo192.png",
12
+ "type": "image/png",
13
+ "sizes": "192x192"
14
+ },
15
+ {
16
+ "src": "/logo512.png",
17
+ "type": "image/png",
18
+ "sizes": "512x512"
19
+ }
20
+ ],
21
+ "start_url": "/",
22
+ "display": "standalone",
23
+ "theme_color": "#ffffff",
24
+ "background_color": "#ffffff"
25
+ }
@@ -0,0 +1,3 @@
1
+ # https://www.robotstxt.org/robotstxt.html
2
+ User-agent: *
3
+ Disallow:
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Guardrail: nothing server-only may reach `dist/client`.
3
+ * Run after `bun run build`: `bun run check:client-bundle`.
4
+ */
5
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
6
+ import { dirname, join, relative } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
10
+ const CLIENT_DIST = join(ROOT, "dist", "client");
11
+ const ENV_FILES = [join(ROOT, ".env"), join(ROOT, ".env.prod")];
12
+ const TEXT = /\.(js|mjs|css|html|json|txt|map)$/;
13
+
14
+ /**
15
+ * A marker only earns its place if it can fire on a *bundled, minified*
16
+ * artifact. Module specifiers (`drizzle-orm`) and local identifiers
17
+ * (`betterAuth`) are erased by bundling, so a marker written against them
18
+ * reports "ok" forever. The schema-shaped markers below are written against
19
+ * strings that survive: `Symbol.for("drizzle:…")` keys, property names, and
20
+ * SQL text baked into policy definitions.
21
+ *
22
+ * Verify a new marker by building a tree that genuinely leaks and watching it
23
+ * fail — a green run against an artifact that never contained the thing proves
24
+ * nothing.
25
+ */
26
+ const MARKERS: [string, RegExp][] = [
27
+ ["cloudflare:workers import", /cloudflare:workers/],
28
+ ["node:async_hooks", /node:async_hooks|async_hooks/],
29
+ ["server function factory", /createServerFn/],
30
+ ["Hyperdrive binding", /HYPERDRIVE/],
31
+ ["drizzle ORM", /drizzle-orm/],
32
+ // Drizzle brands every table object with these Symbol.for keys, so they
33
+ // survive minification and are present whenever a table definition — and
34
+ // with it every table and column name — reaches the browser.
35
+ ["drizzle table definitions", /drizzle:(?:entityKind|Name|Columns|Schema)/],
36
+ ["drizzle pg table class", /\bPgTable\b/],
37
+ // Policy predicates ship as SQL string literals. Leaking them tells a reader
38
+ // which GUC the RLS bypass trusts and what the predicate is.
39
+ ["RLS policy definition", /app_private\.|rls_(?:policy|user_read|service_write)/],
40
+ ["postgres driver", /node-postgres|pg-connection-string/],
41
+ ["postgres connection string", /postgres(?:ql)?:\/\//],
42
+ ["better-auth server factory", /betterAuth\(/],
43
+ ["private key", /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/],
44
+ ["stripe secret", /\b(?:sk_live_|sk_test_|whsec_)[A-Za-z0-9]{10,}/],
45
+ ];
46
+
47
+ /**
48
+ * Keys whose value may legitimately appear client-side, named one at a time.
49
+ *
50
+ * Everything else is treated as a secret, so a newly added key is secret by
51
+ * default. The rule used to be the other way round — only keys *matching*
52
+ * `SECRET|KEY|TOKEN|PASSWORD|CREDENTIAL|DATABASE` were checked — which silently
53
+ * exempted anything named for what it is rather than what it holds
54
+ * (`SENTRY_DSN`, `..._PROXY_URL`, `ADMIN_BOOTSTRAP`).
55
+ */
56
+ const CLIENT_SAFE_KEYS = new Set([
57
+ // The site's own public origin: canonical URLs, OG tags and the auth client
58
+ // all need it in the browser.
59
+ "APP_ORIGIN",
60
+ "BETTER_AUTH_URL",
61
+ ]);
62
+
63
+ export function secretValues(
64
+ envFiles: string[] = ENV_FILES,
65
+ ): [string, string][] {
66
+ const pairs = envFiles
67
+ .filter((file) => existsSync(file))
68
+ .flatMap((file) => readFileSync(file, "utf8").split(/\r?\n/))
69
+ .filter((line) => /^\s*[A-Z0-9_]+=/.test(line))
70
+ .map((line) => {
71
+ const eq = line.indexOf("=");
72
+ const key = line.slice(0, eq).trim();
73
+ const raw = line.slice(eq + 1).trim();
74
+ const value = /^["']/.test(raw)
75
+ ? raw.replace(/^["']|["']$/g, "")
76
+ : raw.replace(/(^|\s)#.*$/, "").trim();
77
+ return [key, value] as [string, string];
78
+ });
79
+
80
+ // Published on purpose, so their *value* is public wherever it comes from.
81
+ // A server-side key holding the same string as a `VITE_` one (e.g.
82
+ // `APP_ORIGIN` and `VITE_APP_ORIGIN`) is not a leak when it shows up.
83
+ const publicValues = new Set(
84
+ pairs
85
+ .filter(([key]) => /^(VITE_|PUBLIC_)/.test(key))
86
+ .map(([, value]) => value)
87
+ .filter((value) => value.length > 0),
88
+ );
89
+
90
+ return pairs.filter(
91
+ ([key, value]) =>
92
+ value.length >= 8 &&
93
+ !/^(VITE_|PUBLIC_)/.test(key) &&
94
+ !CLIENT_SAFE_KEYS.has(key) &&
95
+ !publicValues.has(value),
96
+ );
97
+ }
98
+
99
+ function walk(dir: string): string[] {
100
+ const out: string[] = [];
101
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
102
+ const path = join(dir, entry.name);
103
+ if (entry.isDirectory()) out.push(...walk(path));
104
+ else if (TEXT.test(entry.name)) out.push(path);
105
+ }
106
+ return out;
107
+ }
108
+
109
+ export function findClientBundleLeaks(
110
+ clientDist: string = CLIENT_DIST,
111
+ secrets: [string, string][] = secretValues(),
112
+ ): string[] {
113
+ const hits: string[] = [];
114
+ for (const file of walk(clientDist)) {
115
+ const source = readFileSync(file, "utf8");
116
+ const name = relative(dirname(clientDist), file);
117
+ for (const [label, pattern] of MARKERS) {
118
+ if (pattern.test(source)) {
119
+ hits.push(`${name}: server-only marker — ${label}`);
120
+ }
121
+ }
122
+ for (const [key, value] of secrets) {
123
+ if (source.includes(value)) hits.push(`${name}: literal value of ${key}`);
124
+ }
125
+ }
126
+ return hits.sort();
127
+ }
128
+
129
+ if (import.meta.main) {
130
+ if (!existsSync(CLIENT_DIST)) {
131
+ console.error(
132
+ `No client bundle at ${CLIENT_DIST} — run \`bun run build\` first.`,
133
+ );
134
+ process.exit(1);
135
+ }
136
+ const secrets = secretValues();
137
+ const hits = findClientBundleLeaks(CLIENT_DIST, secrets);
138
+ if (hits.length > 0) {
139
+ console.error("Server-only code or secrets found in the client bundle:\n");
140
+ console.error(hits.map((hit) => ` ${hit}`).join("\n"));
141
+ console.error(
142
+ "\nMove the offending code into a server-only module (import only from" +
143
+ " server handlers / API routes).",
144
+ );
145
+ process.exit(1);
146
+ }
147
+ console.log(
148
+ `check:client-bundle ok (${walk(CLIENT_DIST).length} files, ${secrets.length} secret values checked)`,
149
+ );
150
+ }
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Guardrail: every state-changing server function declares POST, and every
3
+ * server function that accepts input validates it.
4
+ *
5
+ * bun run check:server-fns (also chained into `bun run check`)
6
+ *
7
+ * `createServerFn()` defaults to GET, which puts the payload in the URL (logs,
8
+ * history, caches), makes the call replayable, and lets a same-origin prefetch
9
+ * or an XSS-driven fetch perform the write. The CSRF middleware in
10
+ * `src/start.ts` blocks cross-site and non-browser callers — not those. Pure
11
+ * reads stay on GET so loaders keep prefetching and caching.
12
+ *
13
+ * This class of rule regresses silently: the codebase drifted to 74 default-GET
14
+ * server functions without anyone deciding to. So it is checked in CI, and a
15
+ * test asserts zero violations.
16
+ *
17
+ * Analysis is name-based rather than type-directed: a set of writing symbols is
18
+ * seeded from anything containing a drizzle `.insert(`/`.update(`/`.delete(` or
19
+ * a mutating `auth.api.*` call, then grown transitively through callers. A false
20
+ * positive costs one `method: "POST"`, which is never wrong for a writer — so
21
+ * the analysis errs toward flagging.
22
+ *
23
+ * Escape hatch: annotate the `createServerFn(` line with `server-fn-ok` and a
24
+ * reason (e.g. a read that lazily backfills a row) so it is auditable in review.
25
+ */
26
+ import { readdirSync, readFileSync } from "node:fs";
27
+ import { dirname, join } from "node:path";
28
+ import { fileURLToPath } from "node:url";
29
+
30
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "src");
31
+ const ALLOW = "server-fn-ok";
32
+
33
+ /**
34
+ * Drizzle writes, raw-SQL writes, and the Better Auth api calls that write.
35
+ *
36
+ * The verb is matched anywhere in the method name, not just at the start:
37
+ * `adminCreateOAuthClient` is a write, and anchoring on the prefix missed it.
38
+ */
39
+ const DIRECT_WRITE_RE = new RegExp(
40
+ [
41
+ // drizzle
42
+ /\.(insert|update|delete)\(/.source,
43
+ // raw SQL
44
+ /sql`\s*(insert|update|delete)\s/.source,
45
+ // Better Auth's api. `check`/`verify` are in the list because they are
46
+ // writes the name hides: the OTP endpoints burn an attempt on a wrong code
47
+ // and delete the row on the last one, so a replayed GET consumes the
48
+ // victim's attempts. `send`/`request` are here for the same reason:
49
+ // `sendVerificationOTP` matches none of the other verbs ("Verification"
50
+ // does not contain "verify") yet it writes a verification row and sends an
51
+ // email — replayable, exactly what this rule forces onto POST. Nothing
52
+ // read-only in the auth surface we call (getSession, userHasPermission,
53
+ // list*, get*) contains either verb.
54
+ /auth\.api\.\w*(create|set|update|delete|ban|unban|revoke|remove|sign|send|request|approve|reject|mark|check|verify|consume)\w*\(/
55
+ .source,
56
+ // Better Auth's adapters, which several services write through directly
57
+ /(internalAdapter|adapter)\.\w*(create|update|delete)\w*\(/.source,
58
+ ].join("|"),
59
+ "i",
60
+ );
61
+
62
+ /**
63
+ * A refusal returned from guard middleware, rather than thrown.
64
+ *
65
+ * TanStack Start treats a `Response` returned from server-fn middleware as a
66
+ * deliberate raw success: it stamps the response and hands the object to the
67
+ * client as the resolved query value, so a 401 arrives as *data* and TanStack
68
+ * Query caches it. The failure is silent by construction — the component
69
+ * either crashes on a shape it never expected or renders a confident zero to a
70
+ * user whose session just died. Throwing an `HttpError` makes it a rejected
71
+ * query instead.
72
+ *
73
+ * `throw new APIError(...)` (Better Auth's class, used inside plugin hooks) is
74
+ * a different symbol and deliberately does not match.
75
+ */
76
+ const RETURNED_REFUSAL_RE = /\breturn\s+APIError\s*\(/;
77
+
78
+ /**
79
+ * The annotation, on the line itself or in the comment block above it.
80
+ *
81
+ * Above it, because Biome relocates a trailing comment out of a multi-line
82
+ * argument list — a same-line-only hatch would stop applying the first time
83
+ * anyone ran the formatter, and the rule would fire on a decision that had
84
+ * already been made.
85
+ */
86
+ function isAnnotated(lines: string[], at: number): boolean {
87
+ if (lines[at]?.includes(ALLOW)) return true;
88
+ for (let i = at - 1; i >= 0 && /^\s*(\/\/|\/\*|\*)/.test(lines[i]); i--) {
89
+ if (lines[i].includes(ALLOW)) return true;
90
+ }
91
+ return false;
92
+ }
93
+
94
+ export type Violation = {
95
+ file: string;
96
+ line: number;
97
+ symbol: string;
98
+ kind:
99
+ | "missing-post"
100
+ | "missing-validator"
101
+ | "bare-validator"
102
+ | "returned-refusal";
103
+ detail: string;
104
+ };
105
+
106
+ function walk(dir: string): string[] {
107
+ const out: string[] = [];
108
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
109
+ if (entry.name === "__tests__") continue;
110
+ const path = join(dir, entry.name);
111
+ if (entry.isDirectory()) out.push(...walk(path));
112
+ else if (/\.(ts|tsx)$/.test(entry.name)) out.push(path);
113
+ }
114
+ return out;
115
+ }
116
+
117
+ /**
118
+ * Every top-level declaration body, by name — `function NAME`, `const NAME =`,
119
+ * exported or not.
120
+ *
121
+ * Non-exported helpers count: a server fn that writes through a module-local
122
+ * function is still a writer, and indexing only exports both hid those helpers
123
+ * and threw away everything before the first `export` (which is exactly where
124
+ * they live).
125
+ */
126
+ function topLevelFunctions(source: string): Map<string, string> {
127
+ const out = new Map<string, string>();
128
+ const pattern =
129
+ /^(?:export\s+)?(?:async\s+)?(?:function\s+(\w+)|const\s+(\w+)\s*=)/gm;
130
+ const starts: Array<{ name: string; at: number }> = [];
131
+ for (const m of source.matchAll(pattern)) {
132
+ starts.push({ name: m[1] ?? m[2], at: m.index ?? 0 });
133
+ }
134
+ starts.forEach((s, i) => {
135
+ out.set(s.name, source.slice(s.at, starts[i + 1]?.at ?? source.length));
136
+ });
137
+ return out;
138
+ }
139
+
140
+ type Declaration = {
141
+ name: string;
142
+ body: string;
143
+ file: string;
144
+ source: string;
145
+ };
146
+
147
+ /**
148
+ * Every declared name that writes, or reaches something that does. Grown to a
149
+ * fixpoint so a server fn three hops from the write is still caught. Matching
150
+ * is by bare name across files, which is what makes an alias import
151
+ * (`applyForProvider as applyForProviderSvc`) still resolve.
152
+ */
153
+ function collectWriterNames(declarations: Declaration[]): Set<string> {
154
+ const writers = new Set<string>();
155
+ for (const d of declarations) {
156
+ if (DIRECT_WRITE_RE.test(d.body)) writers.add(d.name);
157
+ }
158
+ for (let changed = true; changed; ) {
159
+ changed = false;
160
+ for (const d of declarations) {
161
+ if (writers.has(d.name)) continue;
162
+ for (const writer of writers) {
163
+ // The declaration's own name never counts as a call to itself.
164
+ if (writer === d.name) continue;
165
+ if (new RegExp(`\\b${writer}\\w*\\s*\\(`).test(d.body)) {
166
+ writers.add(d.name);
167
+ changed = true;
168
+ break;
169
+ }
170
+ }
171
+ }
172
+ }
173
+ return writers;
174
+ }
175
+
176
+ export function findServerFnViolations(root: string = ROOT): Violation[] {
177
+ const declarations: Declaration[] = [];
178
+ for (const file of walk(root)) {
179
+ const source = readFileSync(file, "utf8");
180
+ for (const [name, body] of topLevelFunctions(source)) {
181
+ declarations.push({ name, body, file, source });
182
+ }
183
+ }
184
+ const writers = collectWriterNames(declarations);
185
+
186
+ const violations: Violation[] = [];
187
+ for (const home of declarations) {
188
+ const { name, body } = home;
189
+
190
+ // Guards only. Route handlers and the raw-HTTP OAuth surface both answer
191
+ // with a `Response` by contract, and neither lives here.
192
+ if (/[\\/]middlewares[\\/]/.test(home.file)) {
193
+ const bodyStart = home.source
194
+ .slice(0, home.source.indexOf(body))
195
+ .split("\n").length;
196
+ const lines = body.split("\n");
197
+ lines.forEach((text, i) => {
198
+ if (!RETURNED_REFUSAL_RE.test(text) || isAnnotated(lines, i)) return;
199
+ violations.push({
200
+ file: home.file,
201
+ line: bodyStart + i,
202
+ symbol: name,
203
+ kind: "returned-refusal",
204
+ detail:
205
+ "returns an APIError Response from middleware — throw an HttpError so the refusal is not delivered as data",
206
+ });
207
+ });
208
+ }
209
+
210
+ if (!body.includes("createServerFn")) continue;
211
+ const declaration = body.slice(body.indexOf("createServerFn"));
212
+ const line = home.source.slice(0, home.source.indexOf(body)).split("\n")
213
+ .length;
214
+ const declLine =
215
+ home.source
216
+ .split("\n")
217
+ .findIndex((l, i) => i >= line - 1 && l.includes("createServerFn")) + 1;
218
+ if (home.source.split("\n")[declLine - 1]?.includes(ALLOW)) continue;
219
+
220
+ const declaresPost = /createServerFn\(\s*\{[^}]*method:\s*"POST"/.test(
221
+ declaration,
222
+ );
223
+ const declaresValidator = /\.validator\(/.test(declaration);
224
+ // A schema handed straight to `.validator()` leaves the framework's
225
+ // `execValidator` to throw — an `Error` whose message is a JSON dump of
226
+ // zod's issues, carrying no `.status`. `validated()` turns that into a
227
+ // 400 like every other refusal, so it is the only accepted form.
228
+ // Matched by walking each occurrence rather than one lookahead: `\s*`
229
+ // backtracks, so `.validator(\n\tvalidated(` would satisfy a negative
230
+ // lookahead placed after it and report every wrapped call as bare.
231
+ const bareValidator = [...declaration.matchAll(/\.validator\(\s*/g)].some(
232
+ (hit) =>
233
+ !declaration
234
+ .slice((hit.index ?? 0) + hit[0].length)
235
+ .startsWith("validated("),
236
+ );
237
+ // The handler destructures `data` only when it takes input.
238
+ const acceptsInput = /\.handler\(\s*(?:async\s*)?\(\s*\{[^}]*\bdata\b/.test(
239
+ declaration,
240
+ );
241
+
242
+ if (!declaresPost && writers.has(name)) {
243
+ violations.push({
244
+ file: home.file,
245
+ line: declLine,
246
+ symbol: name,
247
+ kind: "missing-post",
248
+ detail: "reaches a write but does not declare method: \"POST\"",
249
+ });
250
+ }
251
+ if (acceptsInput && !declaresValidator) {
252
+ violations.push({
253
+ file: home.file,
254
+ line: declLine,
255
+ symbol: name,
256
+ kind: "missing-validator",
257
+ detail: "accepts input but declares no .validator()",
258
+ });
259
+ }
260
+ if (bareValidator) {
261
+ violations.push({
262
+ file: home.file,
263
+ line: declLine,
264
+ symbol: name,
265
+ kind: "bare-validator",
266
+ detail:
267
+ "passes a schema straight to .validator() — wrap it in validated() so a bad field is a 400, not a zod dump",
268
+ });
269
+ }
270
+ }
271
+
272
+ return violations.sort(
273
+ (a, b) => a.file.localeCompare(b.file) || a.line - b.line,
274
+ );
275
+ }
276
+
277
+ if (import.meta.main) {
278
+ const violations = findServerFnViolations();
279
+ if (violations.length > 0) {
280
+ console.error("Server function contract violations:");
281
+ for (const v of violations) {
282
+ console.error(` ${v.file}:${v.line} ${v.symbol} — ${v.detail}`);
283
+ }
284
+ console.error(
285
+ `\nA handler that writes must declare createServerFn({ method: "POST" }), one that takes input must declare .validator(), and a guard middleware must throw an HttpError rather than return an APIError Response. If a case is genuinely fine, annotate the offending line with '${ALLOW}: <reason>'.`,
286
+ );
287
+ process.exit(1);
288
+ }
289
+ console.log("check:server-fns ok (writes are POST, inputs are validated)");
290
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Guardrail: forbid drizzle `sql.raw(...)` in app source.
3
+ *
4
+ * bun run check:sql-raw (also chained into `bun run check`)
5
+ *
6
+ * `sql.raw` interpolates its argument into SQL verbatim. With user input it is a
7
+ * SQL-injection vector, and an injection on the app connection can `SET`/`set_config`
8
+ * the USERSET `app.service` GUC to self-escalate past RLS (see is_admin() and
9
+ * src/db/helper.ts). The real fix — a dedicated BYPASSRLS service role — is deferred
10
+ * with H1. Until then this keeps the "no sql.raw + user input" precondition true.
11
+ *
12
+ * There are currently zero `sql.raw` uses. If you must add a *static, constant* one,
13
+ * annotate the line with `sql-raw-ok` (e.g. `sql.raw(FOO) // sql-raw-ok: constant`)
14
+ * so the guard skips it and the escape is auditable in review.
15
+ */
16
+ import { readdirSync, readFileSync } from "node:fs";
17
+ import { dirname, join } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ const SRC = join(dirname(fileURLToPath(import.meta.url)), "..", "src");
21
+ const ALLOW = "sql-raw-ok";
22
+
23
+ function walk(dir: string): string[] {
24
+ const out: string[] = [];
25
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
26
+ const path = join(dir, entry.name);
27
+ if (entry.isDirectory()) out.push(...walk(path));
28
+ else if (/\.(ts|tsx)$/.test(entry.name)) out.push(path);
29
+ }
30
+ return out;
31
+ }
32
+
33
+ const hits: string[] = [];
34
+ for (const file of walk(SRC)) {
35
+ const lines = readFileSync(file, "utf8").split("\n");
36
+ lines.forEach((line, i) => {
37
+ if (line.includes("sql.raw(") && !line.includes(ALLOW)) {
38
+ hits.push(`${file}:${i + 1}: ${line.trim()}`);
39
+ }
40
+ });
41
+ }
42
+
43
+ if (hits.length > 0) {
44
+ console.error("Forbidden sql.raw( usage (SQLi / RLS self-escalation vector):");
45
+ for (const h of hits) console.error(` ${h}`);
46
+ console.error(`\nUse parameterized drizzle sql\`...\`. If truly static, annotate with '${ALLOW}'.`);
47
+ process.exit(1);
48
+ }
49
+
50
+ console.log("check:sql-raw ok (no unguarded sql.raw)");
@@ -0,0 +1,12 @@
1
+ import { compile } from "@inlang/paraglide-js";
2
+ import { paraglideOptions } from "../paraglide.config";
3
+
4
+ // Regenerate src/paraglide before Vitest, which doesn't run the Vite plugin.
5
+ // `isServer` uses a Node-friendly check so the compiled runtime works in the
6
+ // test runner as well as the app build.
7
+ await compile({
8
+ ...paraglideOptions,
9
+ isServer: "typeof window === 'undefined'",
10
+ });
11
+
12
+ console.log("✓ paraglide compiled (en, de)");
@@ -0,0 +1,76 @@
1
+ # Local Postgres (Docker)
2
+
3
+ First boot of `docker compose` runs every executable under this directory
4
+ (`docker-entrypoint-initdb.d`). `create-app-role.sh` creates the runtime
5
+ `starter_app` role with **NOBYPASSRLS**.
6
+
7
+ ## Credentials (defaults in repo root `.env.docker`)
8
+
9
+ | Role | User | Password | Use |
10
+ |------|------|----------|-----|
11
+ | Superuser | `postgres` | `postgres_dev_password` | Drizzle migrate / seed |
12
+ | App | `starter_app` | `starter_app_dev_password` | Worker via Hyperdrive |
13
+
14
+ Database name: **`starter`**.
15
+
16
+ ```text
17
+ # Migrations / seed (privileged) — host port = POSTGRES_PORT in .env.docker
18
+ postgresql://postgres:postgres_dev_password@localhost:5432/starter
19
+
20
+ # Runtime (app role — wrangler.jsonc localConnectionString)
21
+ postgresql://starter_app:starter_app_dev_password@localhost:5432/starter
22
+ ```
23
+
24
+ Postgres **inside** the container always listens on `5432`. Compose maps
25
+ `localhost:${POSTGRES_PORT} → container:5432` (e.g. `5432:5432`).
26
+ Wrong: `5433:5433` — the host port is published but the container side is not
27
+ where Postgres listens, so the host connects and nothing answers. To move only
28
+ the host port, set `POSTGRES_PORT=5433` (Compose still maps it to `5432`) and
29
+ update `DATABASE_MIGRATION_URL` plus `hyperdrive[].localConnectionString`.
30
+
31
+ ## Commands
32
+
33
+ ```bash
34
+ bun run db:up # start container
35
+ bun run db:down # stop container
36
+ bun run db:reset # wipe volume + recreate (destroys local data)
37
+ bun run db:migrate # apply drizzle migrations
38
+ bun run db:seed # local admin + user
39
+ ```
40
+
41
+ ## Production / hosted Postgres
42
+
43
+ Do **not** use `.env.docker` passwords. Create a dedicated app role with a
44
+ strong secret (SQL editor or operator-run script), point Hyperdrive at it, and
45
+ set `DATABASE_MIGRATION_URL` to a privileged direct URL for migrations only.
46
+
47
+ Example production role (replace password):
48
+
49
+ ```sql
50
+ CREATE ROLE starter_app
51
+ LOGIN
52
+ PASSWORD 'replace-with-strong-secret'
53
+ NOSUPERUSER
54
+ NOCREATEDB
55
+ NOCREATEROLE
56
+ NOINHERIT
57
+ NOBYPASSRLS;
58
+
59
+ GRANT CONNECT ON DATABASE your_db TO starter_app;
60
+ GRANT USAGE ON SCHEMA public TO starter_app;
61
+ GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO starter_app;
62
+ GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA public TO starter_app;
63
+ ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public
64
+ GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO starter_app;
65
+ ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public
66
+ GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO starter_app;
67
+ ```
68
+
69
+ ## Re-running init scripts
70
+
71
+ `docker-entrypoint-initdb.d` runs **only on an empty data volume**. After the
72
+ first `db:up`, changing `.env.docker` or these scripts requires:
73
+
74
+ ```bash
75
+ bun run db:reset
76
+ ```
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env bash
2
+ # Runs once on empty Postgres data dir (docker-entrypoint-initdb.d).
3
+ # Creates the runtime app role used by Hyperdrive localConnectionString.
4
+ # Passwords come from container env (.env.docker → docker-compose environment).
5
+
6
+ set -Eeuo pipefail
7
+
8
+ psql \
9
+ --set=ON_ERROR_STOP=1 \
10
+ --username "$POSTGRES_USER" \
11
+ --dbname "$POSTGRES_DB" \
12
+ --set=database_name="$POSTGRES_DB" \
13
+ --set=postgres_user="$POSTGRES_USER" \
14
+ --set=app_db_user="$APP_DB_USER" \
15
+ --set=app_db_password="$APP_DB_PASSWORD" <<'EOSQL'
16
+
17
+ -- Runtime database user — CANNOT be the privileged superuser.
18
+ -- NOBYPASSRLS so RLS policies always apply through Hyperdrive.
19
+ CREATE ROLE :"app_db_user"
20
+ LOGIN
21
+ PASSWORD :'app_db_password'
22
+ NOSUPERUSER
23
+ NOCREATEDB
24
+ NOCREATEROLE
25
+ NOINHERIT
26
+ NOBYPASSRLS;
27
+
28
+ GRANT CONNECT
29
+ ON DATABASE :"database_name"
30
+ TO :"app_db_user";
31
+
32
+ GRANT USAGE
33
+ ON SCHEMA public
34
+ TO :"app_db_user";
35
+
36
+ -- Existing tables / sequences (usually empty at init)
37
+ GRANT SELECT, INSERT, UPDATE, DELETE
38
+ ON ALL TABLES IN SCHEMA public
39
+ TO :"app_db_user";
40
+
41
+ GRANT USAGE, SELECT, UPDATE
42
+ ON ALL SEQUENCES IN SCHEMA public
43
+ TO :"app_db_user";
44
+
45
+ -- Tables created later by Drizzle migrations under the superuser
46
+ ALTER DEFAULT PRIVILEGES
47
+ FOR ROLE :"postgres_user"
48
+ IN SCHEMA public
49
+ GRANT SELECT, INSERT, UPDATE, DELETE
50
+ ON TABLES
51
+ TO :"app_db_user";
52
+
53
+ ALTER DEFAULT PRIVILEGES
54
+ FOR ROLE :"postgres_user"
55
+ IN SCHEMA public
56
+ GRANT USAGE, SELECT, UPDATE
57
+ ON SEQUENCES
58
+ TO :"app_db_user";
59
+
60
+ EOSQL