@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,95 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Seam: the query layer, entered the way a page enters it.
|
|
5
|
+
*
|
|
6
|
+
* A user whose session expired mid-session used to watch the page crash or
|
|
7
|
+
* render zeros because a 401 arrived as the resolved query value. The remedy
|
|
8
|
+
* is one rule on the shared QueryClient: send them to sign-in and never cache
|
|
9
|
+
* the refusal as data.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, it, vi } from "vitest";
|
|
12
|
+
import { HttpError } from "@/utils/api-error";
|
|
13
|
+
|
|
14
|
+
const { goToSignIn } = vi.hoisted(() => ({ goToSignIn: vi.fn() }));
|
|
15
|
+
|
|
16
|
+
vi.mock("@/utils/auth-redirect", async (importActual) => ({
|
|
17
|
+
...(await importActual<typeof import("@/utils/auth-redirect")>()),
|
|
18
|
+
goToSignIn,
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
const { getContext } = await import(
|
|
22
|
+
"@/integrations/tanstack-query/root-provider"
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
/** A guarded server function refusing the way the guard middleware refuses. */
|
|
26
|
+
function refuses(status: number) {
|
|
27
|
+
return () => {
|
|
28
|
+
throw new HttpError({
|
|
29
|
+
status,
|
|
30
|
+
error: status === 401 ? "unauthorized" : "forbidden",
|
|
31
|
+
message: "Authentication required.",
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Through the client's own defaults, so the retry policy under test is the one
|
|
38
|
+
* a page gets. `retry: false` is only for the transient-fault case.
|
|
39
|
+
*/
|
|
40
|
+
async function runQuery(queryFn: () => unknown, retry?: false) {
|
|
41
|
+
const { queryClient } = getContext();
|
|
42
|
+
await queryClient
|
|
43
|
+
.fetchQuery({ queryKey: ["guarded"], queryFn, retry })
|
|
44
|
+
.catch(() => {});
|
|
45
|
+
return queryClient;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("a guarded query refused mid-session", () => {
|
|
49
|
+
it("sends the user to sign-in, carrying where they were", async () => {
|
|
50
|
+
goToSignIn.mockClear();
|
|
51
|
+
window.history.replaceState({}, "", "/dashboard/account?tab=security");
|
|
52
|
+
|
|
53
|
+
await runQuery(refuses(401));
|
|
54
|
+
|
|
55
|
+
expect(goToSignIn).toHaveBeenCalledWith("/dashboard/account?tab=security");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("never caches the refusal as data", async () => {
|
|
59
|
+
goToSignIn.mockClear();
|
|
60
|
+
|
|
61
|
+
const queryClient = await runQuery(refuses(401));
|
|
62
|
+
|
|
63
|
+
expect(queryClient.getQueryData(["guarded"])).toBeUndefined();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// If the retry policy is ever lost, the three default retries back off for
|
|
67
|
+
// ~7s — this fails by call count (and by timeout) rather than passing slowly.
|
|
68
|
+
it("is refused exactly once — a 4xx is deterministic, so the redirect is immediate", async () => {
|
|
69
|
+
goToSignIn.mockClear();
|
|
70
|
+
const queryFn = vi.fn(refuses(401));
|
|
71
|
+
|
|
72
|
+
await runQuery(queryFn);
|
|
73
|
+
|
|
74
|
+
expect(queryFn).toHaveBeenCalledTimes(1);
|
|
75
|
+
expect(goToSignIn).toHaveBeenCalledTimes(1);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("leaves a 403 where it is — the session is fine, the permission is not", async () => {
|
|
79
|
+
goToSignIn.mockClear();
|
|
80
|
+
|
|
81
|
+
await runQuery(refuses(403));
|
|
82
|
+
|
|
83
|
+
expect(goToSignIn).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("does not mistake a network failure for an expired session", async () => {
|
|
87
|
+
goToSignIn.mockClear();
|
|
88
|
+
|
|
89
|
+
await runQuery(() => {
|
|
90
|
+
throw new TypeError("Failed to fetch");
|
|
91
|
+
}, false);
|
|
92
|
+
|
|
93
|
+
expect(goToSignIn).not.toHaveBeenCalled();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RLS policy integration (seam 6): real migrations, real `starter_app` role
|
|
3
|
+
* (NOBYPASSRLS). Temp-table suites cannot see policies (superuser BYPASSRLS).
|
|
4
|
+
*
|
|
5
|
+
* Platform invariant: ordinary users cannot read `admin_action`; admin and
|
|
6
|
+
* service contexts can.
|
|
7
|
+
*/
|
|
8
|
+
import { eq } from "drizzle-orm";
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
import type { Database as AppDatabase } from "@/db";
|
|
11
|
+
import { withRlsService, withRlsUser } from "@/db/helper";
|
|
12
|
+
import { adminAction, user } from "@/db/schema";
|
|
13
|
+
import { createRlsAppDatabase, hasTestDatabase } from "./test-rls-db";
|
|
14
|
+
|
|
15
|
+
async function seedUser(
|
|
16
|
+
db: AppDatabase,
|
|
17
|
+
role: string | null = "user",
|
|
18
|
+
): Promise<string> {
|
|
19
|
+
const id = crypto.randomUUID();
|
|
20
|
+
// `user` has no RLS — inserts work without a service GUC.
|
|
21
|
+
await db.insert(user).values({
|
|
22
|
+
id,
|
|
23
|
+
name: `rls-${id.slice(0, 8)}`,
|
|
24
|
+
email: `${id}@rls.test`,
|
|
25
|
+
role,
|
|
26
|
+
});
|
|
27
|
+
return id;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe.skipIf(!hasTestDatabase)(
|
|
31
|
+
"admin_action RLS on the real schema (starter_app, NOBYPASSRLS)",
|
|
32
|
+
() => {
|
|
33
|
+
it("lets service write and read audit rows", async () => {
|
|
34
|
+
const db = createRlsAppDatabase();
|
|
35
|
+
const actorId = await seedUser(db, "admin");
|
|
36
|
+
const actionId = crypto.randomUUID();
|
|
37
|
+
|
|
38
|
+
await withRlsService(db, (tx) =>
|
|
39
|
+
tx.insert(adminAction).values({
|
|
40
|
+
id: actionId,
|
|
41
|
+
actorId,
|
|
42
|
+
action: "user.role_change",
|
|
43
|
+
targetType: "user",
|
|
44
|
+
targetId: actorId,
|
|
45
|
+
detail: { test: true },
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const rows = await withRlsService(db, (tx) =>
|
|
50
|
+
tx.select().from(adminAction).where(eq(adminAction.id, actionId)),
|
|
51
|
+
);
|
|
52
|
+
expect(rows).toHaveLength(1);
|
|
53
|
+
expect(rows[0].action).toBe("user.role_change");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("lets an administrator read audit rows under withRlsUser", async () => {
|
|
57
|
+
const db = createRlsAppDatabase();
|
|
58
|
+
const adminId = await seedUser(db, "admin");
|
|
59
|
+
const actionId = crypto.randomUUID();
|
|
60
|
+
|
|
61
|
+
await withRlsService(db, (tx) =>
|
|
62
|
+
tx.insert(adminAction).values({
|
|
63
|
+
id: actionId,
|
|
64
|
+
actorId: adminId,
|
|
65
|
+
action: "oauth_client.create",
|
|
66
|
+
targetType: "oauth_client",
|
|
67
|
+
targetId: "client-1",
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const rows = await withRlsUser(db, adminId, (tx) =>
|
|
72
|
+
tx.select().from(adminAction).where(eq(adminAction.id, actionId)),
|
|
73
|
+
);
|
|
74
|
+
expect(rows).toHaveLength(1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("hides audit rows from an ordinary user under withRlsUser", async () => {
|
|
78
|
+
const db = createRlsAppDatabase();
|
|
79
|
+
const memberId = await seedUser(db, "user");
|
|
80
|
+
const adminId = await seedUser(db, "admin");
|
|
81
|
+
const actionId = crypto.randomUUID();
|
|
82
|
+
|
|
83
|
+
await withRlsService(db, (tx) =>
|
|
84
|
+
tx.insert(adminAction).values({
|
|
85
|
+
id: actionId,
|
|
86
|
+
actorId: adminId,
|
|
87
|
+
action: "user.ban",
|
|
88
|
+
targetType: "user",
|
|
89
|
+
targetId: memberId,
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const rows = await withRlsUser(db, memberId, (tx) =>
|
|
94
|
+
tx.select().from(adminAction).where(eq(adminAction.id, actionId)),
|
|
95
|
+
);
|
|
96
|
+
expect(rows).toHaveLength(0);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("rejects ordinary-user writes to admin_action", async () => {
|
|
100
|
+
const db = createRlsAppDatabase();
|
|
101
|
+
const memberId = await seedUser(db, "user");
|
|
102
|
+
const actionId = crypto.randomUUID();
|
|
103
|
+
|
|
104
|
+
await expect(
|
|
105
|
+
withRlsUser(db, memberId, (tx) =>
|
|
106
|
+
tx.insert(adminAction).values({
|
|
107
|
+
id: actionId,
|
|
108
|
+
actorId: memberId,
|
|
109
|
+
action: "forged.action",
|
|
110
|
+
}),
|
|
111
|
+
),
|
|
112
|
+
).rejects.toThrow();
|
|
113
|
+
|
|
114
|
+
const rows = await withRlsService(db, (tx) =>
|
|
115
|
+
tx.select().from(adminAction).where(eq(adminAction.id, actionId)),
|
|
116
|
+
);
|
|
117
|
+
expect(rows).toHaveLength(0);
|
|
118
|
+
});
|
|
119
|
+
},
|
|
120
|
+
);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, it } from "vitest";
|
|
2
|
+
import { samePageLocaleHref } from "@/utils/same-page-locale-href";
|
|
3
|
+
|
|
4
|
+
beforeAll(async () => {
|
|
5
|
+
if (
|
|
6
|
+
typeof (globalThis as { URLPattern?: unknown }).URLPattern === "undefined"
|
|
7
|
+
) {
|
|
8
|
+
const mod = (await import("@inlang/paraglide-js/urlpattern-polyfill")) as {
|
|
9
|
+
URLPattern?: unknown;
|
|
10
|
+
default?: unknown;
|
|
11
|
+
};
|
|
12
|
+
(globalThis as { URLPattern?: unknown }).URLPattern =
|
|
13
|
+
mod.URLPattern ?? mod.default;
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("samePageLocaleHref", () => {
|
|
18
|
+
it("localizes the path and preserves query + hash", () => {
|
|
19
|
+
expect(
|
|
20
|
+
samePageLocaleHref({
|
|
21
|
+
pathname: "/dashboard/account",
|
|
22
|
+
search: "?tab=security",
|
|
23
|
+
hash: "#sessions",
|
|
24
|
+
locale: "de",
|
|
25
|
+
}),
|
|
26
|
+
).toBe("/de/dashboard/account?tab=security#sessions");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("keeps English on bare paths", () => {
|
|
30
|
+
expect(
|
|
31
|
+
samePageLocaleHref({
|
|
32
|
+
pathname: "/sign-in",
|
|
33
|
+
search: "?client_id=abc",
|
|
34
|
+
locale: "en",
|
|
35
|
+
}),
|
|
36
|
+
).toBe("/sign-in?client_id=abc");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("delocalizes before re-localizing so switching de→en drops the prefix", () => {
|
|
40
|
+
expect(
|
|
41
|
+
samePageLocaleHref({
|
|
42
|
+
pathname: "/de/admin/users",
|
|
43
|
+
locale: "en",
|
|
44
|
+
}),
|
|
45
|
+
).toBe("/admin/users");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { defaultParseSearch } from "@tanstack/react-router";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
type SearchParamsRecord,
|
|
5
|
+
stringifySearch,
|
|
6
|
+
} from "@/utils/search-params";
|
|
7
|
+
|
|
8
|
+
/** TanStack's parse type is an opaque schema; for tests we re-read keys we know. */
|
|
9
|
+
function asSearchRecord(
|
|
10
|
+
parsed: ReturnType<typeof defaultParseSearch>,
|
|
11
|
+
): SearchParamsRecord {
|
|
12
|
+
return parsed as SearchParamsRecord;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Better Auth's oauth-provider signs authorize redirects with a multi-value
|
|
17
|
+
* `ba_param` list. The client rebuilds `oauth_query` from window.location.search
|
|
18
|
+
* using only those listed keys — so a JSON-array rewrite of ba_param makes
|
|
19
|
+
* sign-in POST /api/auth/sign-in/email return 400 invalid_signature.
|
|
20
|
+
*/
|
|
21
|
+
const FAITHFUL_OAUTH_QUERY =
|
|
22
|
+
"response_type=code&client_id=xmgWQuPvLVGwtMhwgryGAEWdUTjPSxpv&redirect_uri=http%3A%2F%2F127.0.0.1%3A38256%2Foauth%2Fcallback&scope=profile+email+offline_access+token%3Aread+token%3Awrite&state=testdebug123&code_challenge=UL6WGNhxgcvacdb5dpFLQ6Dn0A47pdt_vRsPu7YPcww&code_challenge_method=S256&exp=1784947726&ba_iat=1784947126487&ba_param=ba_iat&ba_param=ba_param&ba_param=client_id&ba_param=code_challenge&ba_param=code_challenge_method&ba_param=exp&ba_param=redirect_uri&ba_param=response_type&ba_param=scope&ba_param=state&sig=Z%2Bt0G2B3XBXoVZ1EX%2B%2BvJHDYz%2FB%2FzJKOmZHxVT4gQB8%3D";
|
|
23
|
+
|
|
24
|
+
describe("stringifySearch (OAuth multi-value ba_param)", () => {
|
|
25
|
+
it("round-trips repeated ba_param keys instead of collapsing them to JSON", () => {
|
|
26
|
+
const parsed = asSearchRecord(defaultParseSearch(FAITHFUL_OAUTH_QUERY));
|
|
27
|
+
expect(Array.isArray(parsed.ba_param)).toBe(true);
|
|
28
|
+
|
|
29
|
+
const out = stringifySearch(parsed);
|
|
30
|
+
const outParams = new URLSearchParams(
|
|
31
|
+
out.startsWith("?") ? out.slice(1) : out,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
expect(outParams.getAll("ba_param")).toEqual([
|
|
35
|
+
"ba_iat",
|
|
36
|
+
"ba_param",
|
|
37
|
+
"client_id",
|
|
38
|
+
"code_challenge",
|
|
39
|
+
"code_challenge_method",
|
|
40
|
+
"exp",
|
|
41
|
+
"redirect_uri",
|
|
42
|
+
"response_type",
|
|
43
|
+
"scope",
|
|
44
|
+
"state",
|
|
45
|
+
]);
|
|
46
|
+
// Must not be the JSON-array form the default serializer emits.
|
|
47
|
+
expect(outParams.get("ba_param")?.startsWith("[")).toBe(false);
|
|
48
|
+
expect(outParams.get("client_id")).toBe("xmgWQuPvLVGwtMhwgryGAEWdUTjPSxpv");
|
|
49
|
+
expect(outParams.get("sig")).toBeTruthy();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("still stringifies plain single-value search params", () => {
|
|
53
|
+
expect(stringifySearch({ redirect: "/dashboard", ref: "abc" })).toBe(
|
|
54
|
+
"?redirect=%2Fdashboard&ref=abc",
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { withSecurityHeaders } from "@/lib/security-headers";
|
|
3
|
+
|
|
4
|
+
describe("baseline security headers", () => {
|
|
5
|
+
it("preserves status and body", async () => {
|
|
6
|
+
const res = withSecurityHeaders(
|
|
7
|
+
new Response("payload", { status: 201, statusText: "Created" }),
|
|
8
|
+
);
|
|
9
|
+
expect(res.status).toBe(201);
|
|
10
|
+
expect(await res.text()).toBe("payload");
|
|
11
|
+
});
|
|
12
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seam: the application's own Start configuration, round-tripped through the
|
|
3
|
+
* real serializer.
|
|
4
|
+
*
|
|
5
|
+
* The defect being pinned is a *registration* failure, not a throwing failure.
|
|
6
|
+
* A test that asserts "the middleware throws an HttpError" passes happily
|
|
7
|
+
* against a production build that never registered the adapter — and in that
|
|
8
|
+
* build every refusal still arrives as a bare `Error` whose `.status` the
|
|
9
|
+
* shallow error serializer dropped. So the adapter list is read from
|
|
10
|
+
* `startInstance` itself and composed the way the framework composes it
|
|
11
|
+
* (user adapters ahead of the default `ShallowErrorPlugin`), then an HttpError
|
|
12
|
+
* is pushed through real seroval.
|
|
13
|
+
*/
|
|
14
|
+
import {
|
|
15
|
+
defaultSerovalPlugins,
|
|
16
|
+
makeSerovalPlugin,
|
|
17
|
+
} from "@tanstack/router-core/ssr/client";
|
|
18
|
+
import { fromJSON, toJSONAsync } from "seroval";
|
|
19
|
+
import { describe, expect, it } from "vitest";
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { startInstance } from "@/start";
|
|
22
|
+
import {
|
|
23
|
+
HttpError,
|
|
24
|
+
methodNotAllowed,
|
|
25
|
+
userFacingMessage,
|
|
26
|
+
validated,
|
|
27
|
+
} from "@/utils/api-error";
|
|
28
|
+
|
|
29
|
+
/** What the client ends up holding after a server function rejects. */
|
|
30
|
+
async function acrossTheServerFnBoundary(thrown: unknown) {
|
|
31
|
+
const { serializationAdapters } = await startInstance.getOptions();
|
|
32
|
+
const plugins = [
|
|
33
|
+
...(serializationAdapters ?? []).map(makeSerovalPlugin),
|
|
34
|
+
...defaultSerovalPlugins,
|
|
35
|
+
];
|
|
36
|
+
return fromJSON(await toJSONAsync(thrown, { plugins }), { plugins });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("a server function's refusal crossing the wire", () => {
|
|
40
|
+
it("arrives with its status, code, message and class intact", async () => {
|
|
41
|
+
const crossed = await acrossTheServerFnBoundary(
|
|
42
|
+
new HttpError({
|
|
43
|
+
status: 401,
|
|
44
|
+
error: "unauthorized",
|
|
45
|
+
message: "Authentication required.",
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
expect(crossed).toBeInstanceOf(HttpError);
|
|
50
|
+
expect(crossed).toMatchObject({
|
|
51
|
+
status: 401,
|
|
52
|
+
error: "unauthorized",
|
|
53
|
+
message: "Authentication required.",
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A 4xx `message` is trustworthy enough to render. Everything else (5xx,
|
|
60
|
+
* stray TypeError) carries internal detail and gets the caller's fallback.
|
|
61
|
+
*/
|
|
62
|
+
describe("the message a client may show the user", () => {
|
|
63
|
+
const fallback = "Something went wrong. Please try again.";
|
|
64
|
+
|
|
65
|
+
it("shows a 4xx message, because it was written for them", () => {
|
|
66
|
+
const refusal = new HttpError({
|
|
67
|
+
status: 400,
|
|
68
|
+
error: "invalid_param",
|
|
69
|
+
message: "Display name is required.",
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(userFacingMessage(refusal, fallback)).toBe(
|
|
73
|
+
"Display name is required.",
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("hides a 5xx message, because it was not", () => {
|
|
78
|
+
const fault = new HttpError({
|
|
79
|
+
status: 500,
|
|
80
|
+
error: "internal",
|
|
81
|
+
message: "ECONNREFUSED 10.0.0.4:5432",
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(userFacingMessage(fault, fallback)).toBe(fallback);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("hides an ordinary Error, whose message is never intended for a user", () => {
|
|
88
|
+
expect(
|
|
89
|
+
userFacingMessage(new TypeError("p.map is not a function"), fallback),
|
|
90
|
+
).toBe(fallback);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("a validator's refusal", () => {
|
|
95
|
+
/** A field whose author wrote the rule out for the reader. */
|
|
96
|
+
const authored = z.object({
|
|
97
|
+
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Use the YYYY-MM-DD form."),
|
|
98
|
+
});
|
|
99
|
+
/** The same field with no message — the shape most of the codebase has. */
|
|
100
|
+
const bare = z.object({ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/) });
|
|
101
|
+
|
|
102
|
+
// The cast is the point of the test: a validator's declared input describes
|
|
103
|
+
// what the *call site* may pass, while what actually arrives over the wire
|
|
104
|
+
// is whatever the caller sent. These are the shapes the type system promises
|
|
105
|
+
// cannot happen and the network delivers anyway.
|
|
106
|
+
function refusalFrom<T extends z.ZodType>(schema: T, input: unknown) {
|
|
107
|
+
try {
|
|
108
|
+
validated(schema)(input as z.input<T>);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
return error;
|
|
111
|
+
}
|
|
112
|
+
throw new Error("expected the validator to refuse");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
it("is a refusal and not a fault, so a malformed call is an expected 4xx", async () => {
|
|
116
|
+
const crossed = await acrossTheServerFnBoundary(
|
|
117
|
+
refusalFrom(authored, undefined),
|
|
118
|
+
);
|
|
119
|
+
expect(crossed).toBeInstanceOf(HttpError);
|
|
120
|
+
expect(crossed).toMatchObject({ status: 400, error: "invalid_param" });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("states the rule the input broke, when the schema's author wrote one", async () => {
|
|
124
|
+
const crossed = await acrossTheServerFnBoundary(
|
|
125
|
+
refusalFrom(authored, { date: "3 August" }),
|
|
126
|
+
);
|
|
127
|
+
expect(userFacingMessage(crossed, "Something went wrong.")).toBe(
|
|
128
|
+
"Use the YYYY-MM-DD form.",
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// zod's own wording is written for whoever wrote the schema: "Invalid
|
|
133
|
+
// string: must match pattern /^\d{4}-\d{2}-\d{2}$/" hands the reader a
|
|
134
|
+
// regular expression. Most schemas here carry no message, so this is the
|
|
135
|
+
// common case, not the edge one.
|
|
136
|
+
it("never falls back to zod's wording, which is written for a machine", () => {
|
|
137
|
+
const refusal = refusalFrom(bare, { date: "3 August" });
|
|
138
|
+
const shown = userFacingMessage(refusal, "Something went wrong.");
|
|
139
|
+
expect(shown).not.toContain("\\d");
|
|
140
|
+
expect(shown).not.toContain("Invalid");
|
|
141
|
+
expect(shown).not.toContain("pattern");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("says nothing about the schema when the payload is missing entirely", () => {
|
|
145
|
+
const refusal = refusalFrom(bare, undefined);
|
|
146
|
+
const shown = userFacingMessage(refusal, "Something went wrong.");
|
|
147
|
+
expect(shown).not.toContain("date");
|
|
148
|
+
expect(shown).not.toContain("undefined");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("passes valid input through, so the handler still receives parsed data", () => {
|
|
152
|
+
expect(validated(bare)({ date: "2026-08-03" })).toEqual({
|
|
153
|
+
date: "2026-08-03",
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("an API route asked for a method it does not serve", () => {
|
|
159
|
+
// Without this, a POST-only route lets a browser's GET fall through to the
|
|
160
|
+
// app router, which answers 200 with the whole SPA shell — a webhook URL
|
|
161
|
+
// that renders a page instead of refusing.
|
|
162
|
+
it("answers 405 as JSON, and names the method it does serve", async () => {
|
|
163
|
+
const response = methodNotAllowed("POST");
|
|
164
|
+
|
|
165
|
+
expect(response.status).toBe(405);
|
|
166
|
+
expect(response.headers.get("allow")).toBe("POST");
|
|
167
|
+
expect(response.headers.get("content-type")).toContain("application/json");
|
|
168
|
+
await expect(response.json()).resolves.toMatchObject({
|
|
169
|
+
status: 405,
|
|
170
|
+
error: "method_not_allowed",
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import type { Database } from "@/db";
|
|
4
|
+
import { handleStripeWebhook, verifyStripeSignature } from "@/services/stripe";
|
|
5
|
+
|
|
6
|
+
/** A db that fails the test if the handler touches it. */
|
|
7
|
+
const unusableDb = new Proxy({} as Database, {
|
|
8
|
+
get() {
|
|
9
|
+
throw new Error("handler reached the database on an unverified event");
|
|
10
|
+
},
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
function signedHeader(secret: string, body: string, at = Date.now() / 1000) {
|
|
14
|
+
const t = Math.floor(at);
|
|
15
|
+
const v1 = createHmac("sha256", secret).update(`${t}.${body}`).digest("hex");
|
|
16
|
+
return `t=${t},v1=${v1}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("verifyStripeSignature", () => {
|
|
20
|
+
it("accepts a valid v1 signature", () => {
|
|
21
|
+
const body = '{"id":"evt_1"}';
|
|
22
|
+
expect(
|
|
23
|
+
verifyStripeSignature(
|
|
24
|
+
"whsec_test",
|
|
25
|
+
body,
|
|
26
|
+
signedHeader("whsec_test", body),
|
|
27
|
+
),
|
|
28
|
+
).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("rejects a bad or missing signature", () => {
|
|
32
|
+
const body = '{"id":"evt_1"}';
|
|
33
|
+
expect(verifyStripeSignature("whsec_test", body, "t=1,v1=deadbeef")).toBe(
|
|
34
|
+
false,
|
|
35
|
+
);
|
|
36
|
+
expect(verifyStripeSignature("whsec_test", body, null)).toBe(false);
|
|
37
|
+
expect(verifyStripeSignature("whsec_test", body, "garbage")).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("rejects a replayed signature outside the tolerance window", () => {
|
|
41
|
+
const body = '{"id":"evt_1"}';
|
|
42
|
+
const old = Date.now() / 1000 - 3600;
|
|
43
|
+
expect(
|
|
44
|
+
verifyStripeSignature(
|
|
45
|
+
"whsec_test",
|
|
46
|
+
body,
|
|
47
|
+
signedHeader("whsec_test", body, old),
|
|
48
|
+
),
|
|
49
|
+
).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("handleStripeWebhook", () => {
|
|
54
|
+
it("refuses an unverified event without touching the database", async () => {
|
|
55
|
+
const body = '{"type":"checkout.session.completed"}';
|
|
56
|
+
const result = await handleStripeWebhook(unusableDb, {
|
|
57
|
+
rawBody: body,
|
|
58
|
+
signature: "t=1,v1=deadbeef",
|
|
59
|
+
secret: "whsec_test",
|
|
60
|
+
});
|
|
61
|
+
expect(result).toBe("invalid_signature");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("ignores every event type other than a completed checkout", async () => {
|
|
65
|
+
const body =
|
|
66
|
+
'{"type":"payment_intent.created","data":{"object":{"id":"pi_1"}}}';
|
|
67
|
+
const result = await handleStripeWebhook(unusableDb, {
|
|
68
|
+
rawBody: body,
|
|
69
|
+
signature: signedHeader("whsec_test", body),
|
|
70
|
+
secret: "whsec_test",
|
|
71
|
+
});
|
|
72
|
+
expect(result).toBe("noop");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("refuses a signed body that is not JSON", async () => {
|
|
76
|
+
const body = "{not json";
|
|
77
|
+
const result = await handleStripeWebhook(unusableDb, {
|
|
78
|
+
rawBody: body,
|
|
79
|
+
signature: signedHeader("whsec_test", body),
|
|
80
|
+
secret: "whsec_test",
|
|
81
|
+
});
|
|
82
|
+
expect(result).toBe("invalid_payload");
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { drizzle } from "drizzle-orm/node-postgres";
|
|
2
|
+
import { Pool } from "pg";
|
|
3
|
+
import { afterEach, inject } from "vitest";
|
|
4
|
+
import type { Database as AppDatabase } from "@/db";
|
|
5
|
+
import * as schema from "@/db/schema";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Whether this run has a Postgres container behind it.
|
|
9
|
+
*
|
|
10
|
+
* `SKIP_TESTCONTAINERS=1` (what `bun run test:unit` sets) makes global setup
|
|
11
|
+
* provide empty URIs. Suites that need a database gate on this with
|
|
12
|
+
* `describe.skipIf(!hasTestDatabase)` — the no-Docker path has to be green, or
|
|
13
|
+
* nobody can tell a missing container from a real regression.
|
|
14
|
+
*/
|
|
15
|
+
export const hasTestDatabase = process.env.SKIP_TESTCONTAINERS !== "1";
|
|
16
|
+
|
|
17
|
+
const pools: Pool[] = [];
|
|
18
|
+
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
await Promise.all(pools.splice(0).map((pool) => pool.end()));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Superuser pool against the run-wide testcontainer. Each call uses a single
|
|
25
|
+
* connection so CREATE TEMP TABLE DDL stays invisible to other tests.
|
|
26
|
+
*/
|
|
27
|
+
export async function createTestDatabase(ddl: string): Promise<AppDatabase> {
|
|
28
|
+
const pool = new Pool({ connectionString: inject("pgUri"), max: 1 });
|
|
29
|
+
pools.push(pool);
|
|
30
|
+
await pool.query(ddl);
|
|
31
|
+
return drizzle(pool, { schema }) as unknown as AppDatabase;
|
|
32
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { drizzle } from "drizzle-orm/node-postgres";
|
|
2
|
+
import { Pool } from "pg";
|
|
3
|
+
import { afterEach, inject } from "vitest";
|
|
4
|
+
import type { Database as AppDatabase } from "@/db";
|
|
5
|
+
import * as schema from "@/db/schema";
|
|
6
|
+
|
|
7
|
+
export { hasTestDatabase } from "./test-db";
|
|
8
|
+
|
|
9
|
+
const pools: Pool[] = [];
|
|
10
|
+
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
await Promise.all(pools.splice(0).map((pool) => pool.end()));
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Real migrations as `starter_app` (NOBYPASSRLS). Shared for the run — isolate
|
|
17
|
+
* by unique user ids; do not truncate.
|
|
18
|
+
*/
|
|
19
|
+
export function createRlsAppDatabase(): AppDatabase {
|
|
20
|
+
const pool = new Pool({ connectionString: inject("pgRlsAppUri"), max: 1 });
|
|
21
|
+
pools.push(pool);
|
|
22
|
+
return drizzle(pool, { schema }) as unknown as AppDatabase;
|
|
23
|
+
}
|