@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.
- package/LICENSE +21 -0
- package/README.md +68 -2
- package/assets/auth.sqlite.schema.ts +479 -0
- package/assets/bun-sqlite.test.ts +46 -0
- package/assets/d1.test.ts +41 -0
- package/assets/drizzle.config.ts +2 -0
- package/assets/generate-auth-schema.mjs +25 -0
- package/assets/platform.sqlite.schema.ts +46 -0
- package/assets/polar-checkout.ts +10 -0
- package/assets/polar-route.ts +19 -0
- package/assets/polar.test.ts +30 -0
- package/assets/polar.ts +12 -0
- package/assets/purchase.bun-sqlite.ts +38 -0
- package/assets/purchase.sqlite.ts +39 -0
- package/assets/sqlite-migrations/0000_thin_phalanx.sql +295 -0
- package/assets/sqlite-migrations/meta/0000_snapshot.json +2058 -0
- package/assets/sqlite-migrations/meta/_journal.json +13 -0
- package/assets/turso.test.ts +40 -0
- package/package.json +44 -5
- package/src/adapters/database.mjs +448 -0
- package/src/adapters/deployment.mjs +101 -0
- package/src/adapters/email.mjs +75 -0
- package/src/adapters/payments.mjs +270 -0
- package/src/catalog.mjs +128 -0
- package/src/cli.mjs +294 -0
- package/src/generate.mjs +183 -0
- package/src/template.mjs +93 -0
- package/template/.env.docker +16 -0
- package/template/.env.example +67 -0
- package/template/DESIGN.md +58 -0
- package/template/LICENSE +21 -0
- package/template/biome.json +46 -0
- package/template/docker-compose.yml +47 -0
- package/template/docs/project-guide.md +61 -0
- package/template/drizzle/0000_create-app-role.sql +54 -0
- package/template/drizzle/0001_add-rls-context.sql +21 -0
- package/template/drizzle/0002_platform_tables.sql +320 -0
- package/template/drizzle/meta/0002_snapshot.json +2125 -0
- package/template/drizzle/meta/_journal.json +27 -0
- package/template/drizzle.config.ts +25 -0
- package/template/e2e/authenticated-ui.spec.ts +40 -0
- package/template/e2e/headless-design.spec.ts +66 -0
- package/template/e2e/navigation-loading.spec.ts +70 -0
- package/template/e2e/theme-responsive.spec.ts +260 -0
- package/template/gitignore +24 -0
- package/template/messages/de.json +212 -0
- package/template/messages/en.json +212 -0
- package/template/package.json +101 -0
- package/template/paraglide.config.ts +49 -0
- package/template/playwright.config.ts +58 -0
- package/template/project.inlang/settings.json +12 -0
- package/template/public/drizzle.svg +1 -0
- package/template/public/favicon.ico +0 -0
- package/template/public/logo192.png +0 -0
- package/template/public/logo512.png +0 -0
- package/template/public/manifest.json +25 -0
- package/template/public/robots.txt +3 -0
- package/template/scripts/check-client-bundle.ts +150 -0
- package/template/scripts/check-server-fns.ts +290 -0
- package/template/scripts/check-sql-raw.ts +50 -0
- package/template/scripts/compile-paraglide.ts +12 -0
- package/template/scripts/postgres/README.md +76 -0
- package/template/scripts/postgres/create-app-role.sh +60 -0
- package/template/scripts/seed-local.ts +331 -0
- package/template/scripts/validate-prod-env.ts +53 -0
- package/template/src/__tests__/README.md +9 -0
- package/template/src/__tests__/account-security.test.ts +664 -0
- package/template/src/__tests__/admin-http-surface.test.ts +311 -0
- package/template/src/__tests__/admin-peer-protection.test.ts +178 -0
- package/template/src/__tests__/auth-harness.ts +104 -0
- package/template/src/__tests__/auth-rate-limit.test.ts +43 -0
- package/template/src/__tests__/auth-redirect.test.ts +120 -0
- package/template/src/__tests__/body-limit.test.ts +47 -0
- package/template/src/__tests__/creem.test.ts +62 -0
- package/template/src/__tests__/demo-checkout.test.ts +141 -0
- package/template/src/__tests__/email-locale.test.ts +110 -0
- package/template/src/__tests__/global-setup.ts +80 -0
- package/template/src/__tests__/input-rules.test.ts +0 -0
- package/template/src/__tests__/message-parity.test.ts +38 -0
- package/template/src/__tests__/navigation-loading.test.tsx +252 -0
- package/template/src/__tests__/nowpayments.test.ts +106 -0
- package/template/src/__tests__/paraglide-url-contract.test.ts +87 -0
- package/template/src/__tests__/purchase-fulfillment.test.ts +204 -0
- package/template/src/__tests__/query-unauthorized-redirect.test.ts +95 -0
- package/template/src/__tests__/rls-policy.test.ts +120 -0
- package/template/src/__tests__/same-page-locale-href.test.ts +47 -0
- package/template/src/__tests__/search-params-oauth.test.ts +57 -0
- package/template/src/__tests__/security-headers.test.ts +12 -0
- package/template/src/__tests__/server-fn-error-contract.test.ts +173 -0
- package/template/src/__tests__/stripe.test.ts +84 -0
- package/template/src/__tests__/test-db.ts +32 -0
- package/template/src/__tests__/test-rls-db.ts +23 -0
- package/template/src/__tests__/user-update-guard.test.ts +109 -0
- package/template/src/components/LocaleSwitcher.tsx +79 -0
- package/template/src/components/ThemeToggle.tsx +47 -0
- package/template/src/components/auth/social.tsx +90 -0
- package/template/src/components/auth/turnstile.tsx +67 -0
- package/template/src/components/root/navigation-progress.tsx +32 -0
- package/template/src/components/root/not-found.tsx +20 -0
- package/template/src/components/root/pending.tsx +9 -0
- package/template/src/components/root/root-document.tsx +37 -0
- package/template/src/components/root/root-error.tsx +34 -0
- package/template/src/components/shells.tsx +201 -0
- package/template/src/components/ui/styles.ts +9 -0
- package/template/src/db/auth.schema.ts +455 -0
- package/template/src/db/helper.ts +38 -0
- package/template/src/db/index.ts +15 -0
- package/template/src/db/platform.schema.ts +122 -0
- package/template/src/db/schema.ts +2 -0
- package/template/src/env.ts +65 -0
- package/template/src/hooks/use-theme.ts +32 -0
- package/template/src/integrations/better-auth/header-user.tsx +36 -0
- package/template/src/integrations/better-auth/session-query.ts +11 -0
- package/template/src/integrations/react-email/PasswordOtpEmail.tsx +62 -0
- package/template/src/integrations/react-email/ResetPasswordEmail.tsx +64 -0
- package/template/src/integrations/react-email/VerifyEmail.tsx +72 -0
- package/template/src/integrations/react-email/theme.ts +75 -0
- package/template/src/integrations/tanstack-query/devtools.tsx +6 -0
- package/template/src/integrations/tanstack-query/root-provider.tsx +60 -0
- package/template/src/lib/auth-client.ts +27 -0
- package/template/src/lib/auth-paths.ts +94 -0
- package/template/src/lib/auth-plugins/existing-email-signup.ts +86 -0
- package/template/src/lib/auth-plugins/field-rules.ts +84 -0
- package/template/src/lib/auth-plugins/social-two-factor.ts +194 -0
- package/template/src/lib/auth-timing.ts +9 -0
- package/template/src/lib/auth.ts +279 -0
- package/template/src/lib/captcha.ts +11 -0
- package/template/src/lib/email.tsx +162 -0
- package/template/src/lib/github.ts +18 -0
- package/template/src/lib/locale-cookie.ts +12 -0
- package/template/src/lib/localized-pages.ts +25 -0
- package/template/src/lib/oauth-metadata.ts +19 -0
- package/template/src/lib/permissions.ts +29 -0
- package/template/src/lib/security-headers.ts +20 -0
- package/template/src/lib/site.ts +59 -0
- package/template/src/lib/theme.ts +42 -0
- package/template/src/lib/user-update-guard.ts +35 -0
- package/template/src/middlewares/admin.ts +46 -0
- package/template/src/middlewares/auth-rate-limit.ts +37 -0
- package/template/src/middlewares/better-auth.ts +15 -0
- package/template/src/middlewares/body-limit.ts +62 -0
- package/template/src/middlewares/database.ts +13 -0
- package/template/src/middlewares/protected.ts +45 -0
- package/template/src/routeTree.gen.ts +536 -0
- package/template/src/router.tsx +53 -0
- package/template/src/routes/[.]well-known/oauth-authorization-server/api/auth.ts +18 -0
- package/template/src/routes/[.]well-known/oauth-protected-resource/index.ts +32 -0
- package/template/src/routes/__root.tsx +175 -0
- package/template/src/routes/admin/audit.tsx +74 -0
- package/template/src/routes/admin/index.tsx +7 -0
- package/template/src/routes/admin/oauth-apps.tsx +164 -0
- package/template/src/routes/admin/route.tsx +27 -0
- package/template/src/routes/admin/users.tsx +215 -0
- package/template/src/routes/api/auth/$.ts +14 -0
- package/template/src/routes/api/auth/[.]well-known/openid-configuration.ts +15 -0
- package/template/src/routes/api/webhooks/creem.ts +69 -0
- package/template/src/routes/api/webhooks/nowpayments.ts +62 -0
- package/template/src/routes/api/webhooks/stripe.ts +74 -0
- package/template/src/routes/dashboard/account.tsx +430 -0
- package/template/src/routes/dashboard/index.tsx +128 -0
- package/template/src/routes/dashboard/route.tsx +17 -0
- package/template/src/routes/forgot-password.tsx +65 -0
- package/template/src/routes/index.tsx +85 -0
- package/template/src/routes/oauth/consent.tsx +77 -0
- package/template/src/routes/reset-password.tsx +78 -0
- package/template/src/routes/sign-in.tsx +174 -0
- package/template/src/routes/sign-up.tsx +135 -0
- package/template/src/routes/two-factor.tsx +74 -0
- package/template/src/server/README.md +3 -0
- package/template/src/server/account-security.ts +163 -0
- package/template/src/server/admin-audit.ts +37 -0
- package/template/src/server/admin-users.ts +112 -0
- package/template/src/server/auth.ts +44 -0
- package/template/src/server/demo-checkout.ts +67 -0
- package/template/src/server/oauth-clients.ts +83 -0
- package/template/src/server/payment-toggles.ts +39 -0
- package/template/src/server.ts +20 -0
- package/template/src/services/README.md +3 -0
- package/template/src/services/account-security.ts +893 -0
- package/template/src/services/admin-security.ts +164 -0
- package/template/src/services/admin-users.ts +181 -0
- package/template/src/services/auth-rate-limit.ts +32 -0
- package/template/src/services/creem.ts +83 -0
- package/template/src/services/demo-checkout.ts +100 -0
- package/template/src/services/nowpayments.ts +154 -0
- package/template/src/services/oauth-client.ts +85 -0
- package/template/src/services/oauth-clients.ts +15 -0
- package/template/src/services/payment-providers.ts +157 -0
- package/template/src/services/payment-toggles.ts +48 -0
- package/template/src/services/purchase.ts +233 -0
- package/template/src/services/stripe.ts +106 -0
- package/template/src/start.ts +35 -0
- package/template/src/styles/layout.css +93 -0
- package/template/src/styles/theme.css +84 -0
- package/template/src/styles/ui.css +233 -0
- package/template/src/styles.css +90 -0
- package/template/src/utils/api-error.ts +129 -0
- package/template/src/utils/auth-errors.ts +157 -0
- package/template/src/utils/auth-redirect.ts +61 -0
- package/template/src/utils/body-limit.ts +84 -0
- package/template/src/utils/cn.ts +6 -0
- package/template/src/utils/input-rules.ts +172 -0
- package/template/src/utils/locale-paths.ts +18 -0
- package/template/src/utils/same-page-locale-href.ts +20 -0
- package/template/src/utils/search-params.ts +71 -0
- package/template/tsconfig.json +30 -0
- package/template/tsr.config.json +6 -0
- package/template/vite.config.ts +24 -0
- package/template/vitest.config.ts +26 -0
- package/template/worker-configuration.d.ts +14660 -0
- 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,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
|