@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
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Peer protection must hold against Better Auth's own HTTP surface, not only
|
|
3
|
+
* server functions. The admin plugin mounts `/admin/*` routes that check
|
|
4
|
+
* `hasPermission` alone — without disabledPaths, a console fetch could demote
|
|
5
|
+
* a superadmin. Server-side `auth.api.*` still reaches those endpoints.
|
|
6
|
+
*
|
|
7
|
+
* Also measures DISABLED_AUTH_PATHS against routes plugins actually mount so a
|
|
8
|
+
* plugin upgrade cannot silently remount a surface believed closed.
|
|
9
|
+
*
|
|
10
|
+
* Starter omits the api-key plugin and product roles (no `provider`).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
|
|
14
|
+
import { passkey } from "@better-auth/passkey";
|
|
15
|
+
import { betterAuth } from "better-auth";
|
|
16
|
+
import { admin, emailOTP, twoFactor } from "better-auth/plugins";
|
|
17
|
+
import { describe, expect, it } from "vitest";
|
|
18
|
+
import { DISABLED_AUTH_PATHS } from "@/lib/auth-paths";
|
|
19
|
+
import { ac, admin as adminRole, superadmin, user } from "@/lib/permissions";
|
|
20
|
+
import {
|
|
21
|
+
AUTH_DDL,
|
|
22
|
+
cookieHeaders,
|
|
23
|
+
insertPasskey,
|
|
24
|
+
PASSKEY_DDL,
|
|
25
|
+
} from "./auth-harness";
|
|
26
|
+
import { createTestDatabase, hasTestDatabase } from "./test-db";
|
|
27
|
+
|
|
28
|
+
const BASE = "http://localhost:3000";
|
|
29
|
+
const PW = "Passw0rd!";
|
|
30
|
+
|
|
31
|
+
async function makeAuth() {
|
|
32
|
+
const db = await createTestDatabase(AUTH_DDL);
|
|
33
|
+
const auth = betterAuth({
|
|
34
|
+
baseURL: BASE,
|
|
35
|
+
secret: "admin-http-surface-test-secret",
|
|
36
|
+
telemetry: { enabled: false },
|
|
37
|
+
database: drizzleAdapter(db, { provider: "pg" }),
|
|
38
|
+
emailAndPassword: { enabled: true },
|
|
39
|
+
disabledPaths: [...DISABLED_AUTH_PATHS],
|
|
40
|
+
plugins: [
|
|
41
|
+
admin({
|
|
42
|
+
ac,
|
|
43
|
+
roles: { admin: adminRole, superadmin, user },
|
|
44
|
+
}),
|
|
45
|
+
],
|
|
46
|
+
});
|
|
47
|
+
return { auth, db };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function signedInAs(
|
|
51
|
+
auth: Awaited<ReturnType<typeof makeAuth>>["auth"],
|
|
52
|
+
db: Awaited<ReturnType<typeof makeAuth>>["db"],
|
|
53
|
+
email: string,
|
|
54
|
+
role: string,
|
|
55
|
+
) {
|
|
56
|
+
const { eq } = await import("drizzle-orm");
|
|
57
|
+
const { user: userTable } = await import("@/db/auth.schema");
|
|
58
|
+
const res = await auth.api.signUpEmail({
|
|
59
|
+
body: { email, password: PW, name: role },
|
|
60
|
+
returnHeaders: true,
|
|
61
|
+
});
|
|
62
|
+
await db
|
|
63
|
+
.update(userTable)
|
|
64
|
+
.set({ role, emailVerified: true })
|
|
65
|
+
.where(eq(userTable.email, email));
|
|
66
|
+
const [row] = await db
|
|
67
|
+
.select({ id: userTable.id })
|
|
68
|
+
.from(userTable)
|
|
69
|
+
.where(eq(userTable.email, email));
|
|
70
|
+
return { headers: cookieHeaders(res.headers), id: row.id };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const roleOf = async (
|
|
74
|
+
db: Awaited<ReturnType<typeof makeAuth>>["db"],
|
|
75
|
+
id: string,
|
|
76
|
+
) => {
|
|
77
|
+
const { eq } = await import("drizzle-orm");
|
|
78
|
+
const { user: userTable } = await import("@/db/auth.schema");
|
|
79
|
+
const [row] = await db
|
|
80
|
+
.select({ role: userTable.role, banned: userTable.banned })
|
|
81
|
+
.from(userTable)
|
|
82
|
+
.where(eq(userTable.id, id));
|
|
83
|
+
return row;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const post = (
|
|
87
|
+
auth: Awaited<ReturnType<typeof makeAuth>>["auth"],
|
|
88
|
+
path: string,
|
|
89
|
+
body: unknown,
|
|
90
|
+
headers: Headers,
|
|
91
|
+
) =>
|
|
92
|
+
auth.handler(
|
|
93
|
+
new Request(`${BASE}/api/auth${path}`, {
|
|
94
|
+
method: "POST",
|
|
95
|
+
headers: {
|
|
96
|
+
"content-type": "application/json",
|
|
97
|
+
cookie: headers.get("cookie") ?? "",
|
|
98
|
+
},
|
|
99
|
+
body: JSON.stringify(body),
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
describe.skipIf(!hasTestDatabase)("the admin plugin's HTTP surface", () => {
|
|
104
|
+
it("does not let an administrator demote a superadmin", async () => {
|
|
105
|
+
const { auth, db } = await makeAuth();
|
|
106
|
+
const actor = await signedInAs(auth, db, "admin@test.local", "admin");
|
|
107
|
+
const target = await signedInAs(auth, db, "root@test.local", "superadmin");
|
|
108
|
+
|
|
109
|
+
const res = await post(
|
|
110
|
+
auth,
|
|
111
|
+
"/admin/set-role",
|
|
112
|
+
{ userId: target.id, role: "user" },
|
|
113
|
+
actor.headers,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
expect(res.status).not.toBe(200);
|
|
117
|
+
expect((await roleOf(db, target.id)).role).toBe("superadmin");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("does not let an administrator promote themselves", async () => {
|
|
121
|
+
const { auth, db } = await makeAuth();
|
|
122
|
+
const actor = await signedInAs(auth, db, "admin@test.local", "admin");
|
|
123
|
+
|
|
124
|
+
const res = await post(
|
|
125
|
+
auth,
|
|
126
|
+
"/admin/set-role",
|
|
127
|
+
{ userId: actor.id, role: "superadmin" },
|
|
128
|
+
actor.headers,
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
expect(res.status).not.toBe(200);
|
|
132
|
+
expect((await roleOf(db, actor.id)).role).toBe("admin");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("does not let an administrator ban a peer", async () => {
|
|
136
|
+
const { auth, db } = await makeAuth();
|
|
137
|
+
const actor = await signedInAs(auth, db, "admin@test.local", "admin");
|
|
138
|
+
const target = await signedInAs(auth, db, "peer@test.local", "admin");
|
|
139
|
+
|
|
140
|
+
const res = await post(
|
|
141
|
+
auth,
|
|
142
|
+
"/admin/ban-user",
|
|
143
|
+
{ userId: target.id },
|
|
144
|
+
actor.headers,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
expect(res.status).not.toBe(200);
|
|
148
|
+
expect((await roleOf(db, target.id)).banned).toBeFalsy();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("does not let an administrator hard-delete anyone", async () => {
|
|
152
|
+
const { auth, db } = await makeAuth();
|
|
153
|
+
const actor = await signedInAs(auth, db, "admin@test.local", "admin");
|
|
154
|
+
const target = await signedInAs(auth, db, "victim@test.local", "user");
|
|
155
|
+
|
|
156
|
+
const res = await post(
|
|
157
|
+
auth,
|
|
158
|
+
"/admin/remove-user",
|
|
159
|
+
{ userId: target.id },
|
|
160
|
+
actor.headers,
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
expect(res.status).not.toBe(200);
|
|
164
|
+
expect(await roleOf(db, target.id)).toBeTruthy();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("still reaches admin operations server-side via auth.api", async () => {
|
|
168
|
+
const { auth, db } = await makeAuth();
|
|
169
|
+
const actor = await signedInAs(auth, db, "admin@test.local", "admin");
|
|
170
|
+
const target = await signedInAs(auth, db, "victim@test.local", "user");
|
|
171
|
+
|
|
172
|
+
await auth.api.setRole({
|
|
173
|
+
body: { userId: target.id, role: "admin" },
|
|
174
|
+
headers: actor.headers,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
expect((await roleOf(db, target.id)).role).toBe("admin");
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Routes managed plugins mount that stay reachable on purpose.
|
|
183
|
+
*/
|
|
184
|
+
const PUBLIC_BY_DESIGN = new Set([
|
|
185
|
+
"/two-factor/verify-totp",
|
|
186
|
+
"/two-factor/verify-backup-code",
|
|
187
|
+
]);
|
|
188
|
+
|
|
189
|
+
describe.skipIf(!hasTestDatabase)("the disabled-path list", () => {
|
|
190
|
+
it("declares every route of every plugin whose surface it controls", async () => {
|
|
191
|
+
// Measured, not assumed: diff mounted routes with vs without each plugin.
|
|
192
|
+
const db = await createTestDatabase(AUTH_DDL);
|
|
193
|
+
const base = {
|
|
194
|
+
baseURL: BASE,
|
|
195
|
+
secret: "admin-http-surface-test-secret",
|
|
196
|
+
telemetry: { enabled: false },
|
|
197
|
+
database: drizzleAdapter(db, { provider: "pg" }),
|
|
198
|
+
emailAndPassword: { enabled: true },
|
|
199
|
+
};
|
|
200
|
+
const pathsOf = (plugins: Parameters<typeof betterAuth>[0]["plugins"]) =>
|
|
201
|
+
new Set(
|
|
202
|
+
Object.values(
|
|
203
|
+
betterAuth({ ...base, plugins }).api as Record<
|
|
204
|
+
string,
|
|
205
|
+
{ path?: string }
|
|
206
|
+
>,
|
|
207
|
+
)
|
|
208
|
+
.map((endpoint) => endpoint?.path)
|
|
209
|
+
.filter((p): p is string => typeof p === "string"),
|
|
210
|
+
);
|
|
211
|
+
const stock = pathsOf([]);
|
|
212
|
+
const declared = DISABLED_AUTH_PATHS as readonly string[];
|
|
213
|
+
|
|
214
|
+
// No api-key plugin — not a Starter platform default.
|
|
215
|
+
const managed = [
|
|
216
|
+
["admin", admin({ ac, roles: { admin: adminRole, superadmin, user } })],
|
|
217
|
+
["two-factor", twoFactor()],
|
|
218
|
+
["email-otp", emailOTP({ async sendVerificationOTP() {} })],
|
|
219
|
+
] as const;
|
|
220
|
+
|
|
221
|
+
const undeclared: string[] = [];
|
|
222
|
+
for (const [name, plugin] of managed) {
|
|
223
|
+
const mounted = [...pathsOf([plugin])].filter((p) => !stock.has(p));
|
|
224
|
+
expect(mounted.length, `${name} mounted no routes`).toBeGreaterThan(0);
|
|
225
|
+
undeclared.push(
|
|
226
|
+
...mounted.filter(
|
|
227
|
+
(p) => !declared.includes(p) && !PUBLIC_BY_DESIGN.has(p),
|
|
228
|
+
),
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
expect(undeclared).toEqual([]);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("has no stale admin entry — every disabled admin path is still mounted", async () => {
|
|
236
|
+
const { auth } = await makeAuth();
|
|
237
|
+
const adminMounted = Object.values(
|
|
238
|
+
auth.api as Record<string, { path?: string }>,
|
|
239
|
+
)
|
|
240
|
+
.map((endpoint) => endpoint?.path)
|
|
241
|
+
.filter((p): p is string => typeof p === "string")
|
|
242
|
+
.filter((p) => p.startsWith("/admin/"));
|
|
243
|
+
|
|
244
|
+
expect([...adminMounted].sort()).toEqual(
|
|
245
|
+
(DISABLED_AUTH_PATHS as readonly string[])
|
|
246
|
+
.filter((p) => p.startsWith("/admin/"))
|
|
247
|
+
.slice()
|
|
248
|
+
.sort(),
|
|
249
|
+
);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe.skipIf(!hasTestDatabase)(
|
|
254
|
+
"credential-mutating routes the server fns guard",
|
|
255
|
+
() => {
|
|
256
|
+
it("POST /change-password is off — OTP flow with eviction is the only path", async () => {
|
|
257
|
+
const { auth } = await makeAuth();
|
|
258
|
+
const signedUp = await auth.api.signUpEmail({
|
|
259
|
+
body: { email: "pw@example.com", password: PW, name: "PW" },
|
|
260
|
+
returnHeaders: true,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const res = await post(
|
|
264
|
+
auth,
|
|
265
|
+
"/change-password",
|
|
266
|
+
{ currentPassword: PW, newPassword: "Changed1!" },
|
|
267
|
+
cookieHeaders(signedUp.headers),
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
expect(res.status).toBe(404);
|
|
271
|
+
await expect(
|
|
272
|
+
auth.api.signInEmail({
|
|
273
|
+
body: { email: "pw@example.com", password: PW },
|
|
274
|
+
}),
|
|
275
|
+
).resolves.toBeTruthy();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("POST /passkey/delete-passkey is off — removal stays fresh-gated", async () => {
|
|
279
|
+
const db = await createTestDatabase(AUTH_DDL + PASSKEY_DDL);
|
|
280
|
+
const auth = betterAuth({
|
|
281
|
+
baseURL: BASE,
|
|
282
|
+
secret: "admin-http-surface-test-secret",
|
|
283
|
+
telemetry: { enabled: false },
|
|
284
|
+
database: drizzleAdapter(db, { provider: "pg" }),
|
|
285
|
+
emailAndPassword: { enabled: true },
|
|
286
|
+
disabledPaths: [...DISABLED_AUTH_PATHS],
|
|
287
|
+
plugins: [passkey({ rpID: "localhost", rpName: "Starter" })],
|
|
288
|
+
});
|
|
289
|
+
const signedUp = await auth.api.signUpEmail({
|
|
290
|
+
body: { email: "pk@example.com", password: PW, name: "PK" },
|
|
291
|
+
returnHeaders: true,
|
|
292
|
+
});
|
|
293
|
+
const pkId = await insertPasskey(db, signedUp.response.user.id);
|
|
294
|
+
|
|
295
|
+
const res = await auth.handler(
|
|
296
|
+
new Request(`${BASE}/api/auth/passkey/delete-passkey`, {
|
|
297
|
+
method: "POST",
|
|
298
|
+
headers: {
|
|
299
|
+
"content-type": "application/json",
|
|
300
|
+
cookie: cookieHeaders(signedUp.headers).get("cookie") ?? "",
|
|
301
|
+
},
|
|
302
|
+
body: JSON.stringify({ id: pkId }),
|
|
303
|
+
}),
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
expect(res.status).toBe(404);
|
|
307
|
+
const { passkey: pkTable } = await import("@/db/auth.schema");
|
|
308
|
+
expect(await db.select().from(pkTable)).toHaveLength(1);
|
|
309
|
+
});
|
|
310
|
+
},
|
|
311
|
+
);
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Administrators are peers: none may ban, demote, or revoke credentials of an
|
|
3
|
+
* admin or superadmin, and none may change their own role. superadmin is a
|
|
4
|
+
* protected label, not a higher-privilege tier.
|
|
5
|
+
*
|
|
6
|
+
* Last-admin checks hit real (temp) user rows — pure role matrices alone cannot
|
|
7
|
+
* prove the SQL count.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
import type { Database as AppDatabase } from "@/db";
|
|
11
|
+
import { user } from "@/db/schema";
|
|
12
|
+
import {
|
|
13
|
+
assertAdminMayActOn,
|
|
14
|
+
assertNotLastAdmin,
|
|
15
|
+
isAdminRole,
|
|
16
|
+
} from "@/services/admin-security";
|
|
17
|
+
import { HttpError } from "@/utils/api-error";
|
|
18
|
+
import { createTestDatabase, hasTestDatabase } from "./test-db";
|
|
19
|
+
|
|
20
|
+
const ACTOR = "actor-admin";
|
|
21
|
+
const TARGET = "target";
|
|
22
|
+
|
|
23
|
+
describe("administrator peer protection", () => {
|
|
24
|
+
it("lets an administrator ban, demote and revoke an ordinary user", () => {
|
|
25
|
+
const act = (extra: Record<string, unknown>) =>
|
|
26
|
+
assertAdminMayActOn({
|
|
27
|
+
actorId: ACTOR,
|
|
28
|
+
targetId: TARGET,
|
|
29
|
+
targetRole: "user",
|
|
30
|
+
...extra,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
expect(() => act({ banning: true })).not.toThrow();
|
|
34
|
+
expect(() => act({ nextRole: "admin" })).not.toThrow();
|
|
35
|
+
expect(() => act({ revokingCredentials: true })).not.toThrow();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
for (const peerRole of ["admin", "superadmin"] as const) {
|
|
39
|
+
const act = (extra: Record<string, unknown>) =>
|
|
40
|
+
assertAdminMayActOn({
|
|
41
|
+
actorId: ACTOR,
|
|
42
|
+
targetId: TARGET,
|
|
43
|
+
targetRole: peerRole,
|
|
44
|
+
...extra,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// A bare Error would reach the client without a status, so the query
|
|
48
|
+
// client's "a 4xx is a refusal, not a flake" rule would miss it and retry
|
|
49
|
+
// a decision that can never change.
|
|
50
|
+
it(`refuses to ban a ${peerRole} with a 403`, () => {
|
|
51
|
+
expect(() => act({ banning: true })).toThrow(HttpError);
|
|
52
|
+
try {
|
|
53
|
+
act({ banning: true });
|
|
54
|
+
expect.unreachable();
|
|
55
|
+
} catch (e) {
|
|
56
|
+
expect((e as HttpError).status).toBe(403);
|
|
57
|
+
expect((e as HttpError).error).toBe("forbidden");
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it(`refuses to demote a ${peerRole}`, () => {
|
|
62
|
+
expect(() => act({ nextRole: "user" })).toThrow(/administrator/i);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it(`refuses to revoke a ${peerRole}'s credentials`, () => {
|
|
66
|
+
expect(() => act({ revokingCredentials: true })).toThrow(
|
|
67
|
+
/administrator/i,
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it(`still lets a ${peerRole} be unbanned`, () => {
|
|
72
|
+
expect(() => act({ banning: false })).not.toThrow();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it(`leaves a ${peerRole}'s other fields editable`, () => {
|
|
76
|
+
expect(() => act({})).not.toThrow();
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
it("refuses an administrator changing their own role", () => {
|
|
81
|
+
expect(() =>
|
|
82
|
+
assertAdminMayActOn({
|
|
83
|
+
actorId: ACTOR,
|
|
84
|
+
targetId: ACTOR,
|
|
85
|
+
targetRole: "admin",
|
|
86
|
+
nextRole: "user",
|
|
87
|
+
}),
|
|
88
|
+
).toThrow(/own role/i);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("does not refuse a self-update that leaves the role alone", () => {
|
|
92
|
+
expect(() =>
|
|
93
|
+
assertAdminMayActOn({
|
|
94
|
+
actorId: ACTOR,
|
|
95
|
+
targetId: ACTOR,
|
|
96
|
+
targetRole: "admin",
|
|
97
|
+
nextRole: "admin",
|
|
98
|
+
}),
|
|
99
|
+
).not.toThrow();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("treats admin and superadmin as administrator roles", () => {
|
|
103
|
+
expect(isAdminRole("admin")).toBe(true);
|
|
104
|
+
expect(isAdminRole("superadmin")).toBe(true);
|
|
105
|
+
expect(isAdminRole("user")).toBe(false);
|
|
106
|
+
expect(isAdminRole(null)).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Temp table: assertNotLastAdmin only reads `user`; shared rls_test is never
|
|
111
|
+
// truncated, so counting needs its own isolation.
|
|
112
|
+
async function makeUserTable(): Promise<AppDatabase> {
|
|
113
|
+
return createTestDatabase(`
|
|
114
|
+
CREATE TEMP TABLE "user" (
|
|
115
|
+
id TEXT PRIMARY KEY,
|
|
116
|
+
name TEXT NOT NULL,
|
|
117
|
+
email TEXT NOT NULL,
|
|
118
|
+
email_verified BOOLEAN NOT NULL DEFAULT false,
|
|
119
|
+
two_factor_enabled BOOLEAN DEFAULT false,
|
|
120
|
+
image TEXT,
|
|
121
|
+
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
|
122
|
+
updated_at TIMESTAMP NOT NULL DEFAULT now(),
|
|
123
|
+
role TEXT,
|
|
124
|
+
banned BOOLEAN DEFAULT false,
|
|
125
|
+
ban_reason TEXT,
|
|
126
|
+
ban_expires TIMESTAMP,
|
|
127
|
+
tokens_revoked_at TIMESTAMP,
|
|
128
|
+
locale TEXT
|
|
129
|
+
);
|
|
130
|
+
`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const seed = (db: AppDatabase, rows: Array<[string, string | null, boolean]>) =>
|
|
134
|
+
db.insert(user).values(
|
|
135
|
+
rows.map(([id, role, banned]) => ({
|
|
136
|
+
id,
|
|
137
|
+
name: id,
|
|
138
|
+
email: `${id}@peer.test`,
|
|
139
|
+
role,
|
|
140
|
+
banned,
|
|
141
|
+
})),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
describe.skipIf(!hasTestDatabase)("the last usable administrator", () => {
|
|
145
|
+
it("counts a superadmin as an administrator", async () => {
|
|
146
|
+
const db = await makeUserTable();
|
|
147
|
+
await seed(db, [
|
|
148
|
+
["a1", "admin", false],
|
|
149
|
+
["s1", "superadmin", false],
|
|
150
|
+
]);
|
|
151
|
+
|
|
152
|
+
await expect(assertNotLastAdmin(db, "a1")).resolves.toBeUndefined();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("refuses when the only other administrator is a banned superadmin", async () => {
|
|
156
|
+
const db = await makeUserTable();
|
|
157
|
+
await seed(db, [
|
|
158
|
+
["a1", "admin", false],
|
|
159
|
+
["s1", "superadmin", true],
|
|
160
|
+
]);
|
|
161
|
+
|
|
162
|
+
await expect(assertNotLastAdmin(db, "a1")).rejects.toThrow(
|
|
163
|
+
/last administrator/i,
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("refuses when the target is the only administrator left", async () => {
|
|
168
|
+
const db = await makeUserTable();
|
|
169
|
+
await seed(db, [
|
|
170
|
+
["s1", "superadmin", false],
|
|
171
|
+
["u1", "user", false],
|
|
172
|
+
]);
|
|
173
|
+
|
|
174
|
+
await expect(assertNotLastAdmin(db, "s1")).rejects.toThrow(
|
|
175
|
+
/last administrator/i,
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { Database as AppDatabase } from "@/db";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Core Better Auth tables for temp-table suites (column names match the
|
|
5
|
+
* generated auth schema / platform migration).
|
|
6
|
+
*/
|
|
7
|
+
export const AUTH_DDL = `
|
|
8
|
+
CREATE TEMP TABLE "user" (
|
|
9
|
+
id TEXT PRIMARY KEY,
|
|
10
|
+
name TEXT NOT NULL,
|
|
11
|
+
email TEXT NOT NULL UNIQUE,
|
|
12
|
+
email_verified BOOLEAN DEFAULT false NOT NULL,
|
|
13
|
+
two_factor_enabled boolean default false,
|
|
14
|
+
image TEXT,
|
|
15
|
+
created_at TIMESTAMP DEFAULT now() NOT NULL,
|
|
16
|
+
updated_at TIMESTAMP DEFAULT now() NOT NULL,
|
|
17
|
+
role TEXT,
|
|
18
|
+
banned BOOLEAN DEFAULT false,
|
|
19
|
+
ban_reason TEXT,
|
|
20
|
+
ban_expires TIMESTAMP,
|
|
21
|
+
tokens_revoked_at TIMESTAMP,
|
|
22
|
+
locale TEXT
|
|
23
|
+
);
|
|
24
|
+
CREATE TEMP TABLE "session" (
|
|
25
|
+
id TEXT PRIMARY KEY,
|
|
26
|
+
expires_at TIMESTAMP NOT NULL,
|
|
27
|
+
token TEXT NOT NULL UNIQUE,
|
|
28
|
+
created_at TIMESTAMP DEFAULT now() NOT NULL,
|
|
29
|
+
updated_at TIMESTAMP NOT NULL,
|
|
30
|
+
ip_address TEXT,
|
|
31
|
+
user_agent TEXT,
|
|
32
|
+
user_id TEXT NOT NULL,
|
|
33
|
+
impersonated_by TEXT
|
|
34
|
+
);
|
|
35
|
+
CREATE TEMP TABLE "account" (
|
|
36
|
+
id TEXT PRIMARY KEY,
|
|
37
|
+
account_id TEXT NOT NULL,
|
|
38
|
+
provider_id TEXT NOT NULL,
|
|
39
|
+
user_id TEXT NOT NULL,
|
|
40
|
+
access_token TEXT,
|
|
41
|
+
refresh_token TEXT,
|
|
42
|
+
id_token TEXT,
|
|
43
|
+
access_token_expires_at TIMESTAMP,
|
|
44
|
+
refresh_token_expires_at TIMESTAMP,
|
|
45
|
+
scope TEXT,
|
|
46
|
+
password TEXT,
|
|
47
|
+
created_at TIMESTAMP DEFAULT now() NOT NULL,
|
|
48
|
+
updated_at TIMESTAMP NOT NULL
|
|
49
|
+
);
|
|
50
|
+
CREATE TEMP TABLE "verification" (
|
|
51
|
+
id TEXT PRIMARY KEY,
|
|
52
|
+
identifier TEXT NOT NULL,
|
|
53
|
+
value TEXT NOT NULL,
|
|
54
|
+
expires_at TIMESTAMP NOT NULL,
|
|
55
|
+
created_at TIMESTAMP DEFAULT now() NOT NULL,
|
|
56
|
+
updated_at TIMESTAMP DEFAULT now() NOT NULL
|
|
57
|
+
);
|
|
58
|
+
CREATE TEMP TABLE "two_factor" (
|
|
59
|
+
id TEXT PRIMARY KEY,
|
|
60
|
+
secret TEXT NOT NULL,
|
|
61
|
+
backup_codes TEXT NOT NULL,
|
|
62
|
+
user_id TEXT NOT NULL,
|
|
63
|
+
verified BOOLEAN DEFAULT true,
|
|
64
|
+
failed_verification_count INTEGER DEFAULT 0,
|
|
65
|
+
locked_until TIMESTAMP
|
|
66
|
+
);
|
|
67
|
+
`;
|
|
68
|
+
|
|
69
|
+
export const PASSKEY_DDL = `
|
|
70
|
+
CREATE TEMP TABLE "passkey" (
|
|
71
|
+
id TEXT PRIMARY KEY, name TEXT, public_key TEXT NOT NULL, user_id TEXT NOT NULL,
|
|
72
|
+
credential_id TEXT NOT NULL, counter INTEGER NOT NULL, device_type TEXT NOT NULL,
|
|
73
|
+
backed_up BOOLEAN NOT NULL, transports TEXT, created_at TIMESTAMP, aaguid TEXT
|
|
74
|
+
);
|
|
75
|
+
`;
|
|
76
|
+
|
|
77
|
+
export async function insertPasskey(
|
|
78
|
+
db: AppDatabase,
|
|
79
|
+
userId: string,
|
|
80
|
+
id = "pk-1",
|
|
81
|
+
) {
|
|
82
|
+
const { passkey: pk } = await import("@/db/auth.schema");
|
|
83
|
+
await db.insert(pk).values({
|
|
84
|
+
id,
|
|
85
|
+
name: "Starter",
|
|
86
|
+
publicKey: "fake-public-key",
|
|
87
|
+
userId,
|
|
88
|
+
credentialID: `cred-${id}`,
|
|
89
|
+
counter: 0,
|
|
90
|
+
deviceType: "singleDevice",
|
|
91
|
+
backedUp: false,
|
|
92
|
+
createdAt: new Date(),
|
|
93
|
+
});
|
|
94
|
+
return id;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Turn a returnHeaders response into a Cookie header for follow-up calls. */
|
|
98
|
+
export function cookieHeaders(headers: Headers): Headers {
|
|
99
|
+
const cookie = headers
|
|
100
|
+
.getSetCookie()
|
|
101
|
+
.map((c) => c.split(";")[0])
|
|
102
|
+
.join("; ");
|
|
103
|
+
return new Headers({ cookie });
|
|
104
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { authRateLimitKey } from "@/services/auth-rate-limit";
|
|
3
|
+
|
|
4
|
+
const req = (method: string, path: string, ip?: string) =>
|
|
5
|
+
new Request(`https://starter.test${path}`, {
|
|
6
|
+
method,
|
|
7
|
+
headers: ip ? { "cf-connecting-ip": ip } : undefined,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
describe("authRateLimitKey", () => {
|
|
11
|
+
it("keys sensitive POST endpoints by ip + path", () => {
|
|
12
|
+
expect(
|
|
13
|
+
authRateLimitKey(req("POST", "/api/auth/sign-in/email", "1.2.3.4")),
|
|
14
|
+
).toBe("1.2.3.4:/api/auth/sign-in/email");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("throttles every sensitive action", () => {
|
|
18
|
+
for (const p of [
|
|
19
|
+
"/api/auth/sign-up/email",
|
|
20
|
+
"/api/auth/forget-password",
|
|
21
|
+
"/api/auth/request-password-reset",
|
|
22
|
+
"/api/auth/reset-password",
|
|
23
|
+
"/api/auth/send-verification-email",
|
|
24
|
+
]) {
|
|
25
|
+
expect(authRateLimitKey(req("POST", p, "9.9.9.9"))).toBe(`9.9.9.9:${p}`);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("does not throttle GET, or non-sensitive POST paths", () => {
|
|
30
|
+
expect(
|
|
31
|
+
authRateLimitKey(req("GET", "/api/auth/sign-in/email", "1.2.3.4")),
|
|
32
|
+
).toBeNull();
|
|
33
|
+
expect(
|
|
34
|
+
authRateLimitKey(req("POST", "/api/auth/get-session", "1.2.3.4")),
|
|
35
|
+
).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("falls back to a stable key when the edge IP header is absent", () => {
|
|
39
|
+
expect(authRateLimitKey(req("POST", "/api/auth/sign-in/email"))).toBe(
|
|
40
|
+
"unknown:/api/auth/sign-in/email",
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
});
|