@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,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Peer-protection guards for administrator mutations.
|
|
3
|
+
*
|
|
4
|
+
* `admin` and `superadmin` hold the same capabilities — superadmin is a
|
|
5
|
+
* protected label, not a higher tier. Neither may ban, demote, or revoke
|
|
6
|
+
* credentials of an administrator; nobody may change their own role.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { and, count, eq, inArray, isNull, ne, or } from "drizzle-orm";
|
|
10
|
+
import type { Database } from "@/db";
|
|
11
|
+
import {
|
|
12
|
+
adminAction,
|
|
13
|
+
type JsonValue,
|
|
14
|
+
oauthAccessToken,
|
|
15
|
+
oauthRefreshToken,
|
|
16
|
+
session,
|
|
17
|
+
user,
|
|
18
|
+
} from "@/db/schema";
|
|
19
|
+
import { HttpError } from "@/utils/api-error";
|
|
20
|
+
|
|
21
|
+
const ADMIN_ROLES = ["admin", "superadmin"] as const;
|
|
22
|
+
|
|
23
|
+
export function isAdminRole(role: string | null | undefined): boolean {
|
|
24
|
+
return role === "admin" || role === "superadmin";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Neutralize web + OAuth access for a banned user (sessions + tokens).
|
|
29
|
+
* Starter has no api-key plugin; only sessions and OAuth tokens are revoked.
|
|
30
|
+
*/
|
|
31
|
+
export async function revokeUserCredentials(
|
|
32
|
+
db: Database,
|
|
33
|
+
userId: string,
|
|
34
|
+
): Promise<void> {
|
|
35
|
+
await db.transaction(async (tx) => {
|
|
36
|
+
await tx
|
|
37
|
+
.delete(oauthAccessToken)
|
|
38
|
+
.where(eq(oauthAccessToken.userId, userId));
|
|
39
|
+
await tx
|
|
40
|
+
.delete(oauthRefreshToken)
|
|
41
|
+
.where(eq(oauthRefreshToken.userId, userId));
|
|
42
|
+
await tx.delete(session).where(eq(session.userId, userId));
|
|
43
|
+
await tx
|
|
44
|
+
.update(user)
|
|
45
|
+
.set({ tokensRevokedAt: new Date() })
|
|
46
|
+
.where(eq(user.id, userId));
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Throw if `userId` is the last remaining usable admin/superadmin.
|
|
52
|
+
*/
|
|
53
|
+
export async function assertNotLastAdmin(
|
|
54
|
+
db: Database,
|
|
55
|
+
userId: string,
|
|
56
|
+
): Promise<void> {
|
|
57
|
+
const rows = await db
|
|
58
|
+
.select({ n: count() })
|
|
59
|
+
.from(user)
|
|
60
|
+
.where(
|
|
61
|
+
and(
|
|
62
|
+
inArray(user.role, [...ADMIN_ROLES]),
|
|
63
|
+
ne(user.id, userId),
|
|
64
|
+
or(eq(user.banned, false), isNull(user.banned)),
|
|
65
|
+
),
|
|
66
|
+
);
|
|
67
|
+
if (Number(rows[0]?.n ?? 0) === 0) {
|
|
68
|
+
throw new HttpError({
|
|
69
|
+
status: 409,
|
|
70
|
+
error: "conflict",
|
|
71
|
+
message: "Cannot remove the last administrator.",
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type AdminAuditEntry = {
|
|
77
|
+
actorId: string;
|
|
78
|
+
action: string;
|
|
79
|
+
targetType?: string;
|
|
80
|
+
targetId?: string;
|
|
81
|
+
detail?: JsonValue;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** Best-effort audit row; never blocks the main operation. */
|
|
85
|
+
export async function logAdminAction(
|
|
86
|
+
db: Database,
|
|
87
|
+
entry: AdminAuditEntry,
|
|
88
|
+
onFailure?: (error: unknown, entry: AdminAuditEntry) => void,
|
|
89
|
+
): Promise<void> {
|
|
90
|
+
try {
|
|
91
|
+
await db.insert(adminAction).values({
|
|
92
|
+
id: crypto.randomUUID(),
|
|
93
|
+
actorId: entry.actorId,
|
|
94
|
+
action: entry.action,
|
|
95
|
+
targetType: entry.targetType ?? null,
|
|
96
|
+
targetId: entry.targetId ?? null,
|
|
97
|
+
detail: entry.detail ?? null,
|
|
98
|
+
});
|
|
99
|
+
} catch (e) {
|
|
100
|
+
onFailure?.(e, entry);
|
|
101
|
+
console.error("admin audit log write failed", e);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Who may create, read, update and delete OAuth clients.
|
|
107
|
+
* Both administrator roles are capability-identical.
|
|
108
|
+
*/
|
|
109
|
+
export function canManageOAuthClients(user: unknown): boolean {
|
|
110
|
+
if (!user || typeof user !== "object") return false;
|
|
111
|
+
const { role } = user as { role?: unknown };
|
|
112
|
+
return typeof role === "string" && isAdminRole(role);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Pure peer-protection check. Callers have already read the target row.
|
|
117
|
+
*/
|
|
118
|
+
export function assertAdminMayActOn(args: {
|
|
119
|
+
actorId: string;
|
|
120
|
+
targetId: string;
|
|
121
|
+
targetRole: string | null | undefined;
|
|
122
|
+
/** The role this submit writes, when it writes one. */
|
|
123
|
+
nextRole?: string;
|
|
124
|
+
/** The ban state this submit writes, when it changes it. */
|
|
125
|
+
banning?: boolean;
|
|
126
|
+
/** True on the credential-revocation path. */
|
|
127
|
+
revokingCredentials?: boolean;
|
|
128
|
+
}): void {
|
|
129
|
+
const { actorId, targetId, targetRole, nextRole, banning } = args;
|
|
130
|
+
|
|
131
|
+
const changesRole = nextRole !== undefined && nextRole !== targetRole;
|
|
132
|
+
if (changesRole && actorId === targetId) {
|
|
133
|
+
throw new HttpError({
|
|
134
|
+
status: 403,
|
|
135
|
+
error: "forbidden",
|
|
136
|
+
message: "You cannot change your own role.",
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (!isAdminRole(targetRole)) return;
|
|
141
|
+
if (changesRole) {
|
|
142
|
+
throw new HttpError({
|
|
143
|
+
status: 403,
|
|
144
|
+
error: "forbidden",
|
|
145
|
+
message: "You cannot change another administrator's role.",
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
if (banning === true) {
|
|
149
|
+
throw new HttpError({
|
|
150
|
+
status: 403,
|
|
151
|
+
error: "forbidden",
|
|
152
|
+
message: "You cannot ban another administrator.",
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
if (args.revokingCredentials) {
|
|
156
|
+
throw new HttpError({
|
|
157
|
+
status: 403,
|
|
158
|
+
error: "forbidden",
|
|
159
|
+
message: "You cannot revoke another administrator's credentials.",
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export { ADMIN_ROLES };
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin user list + update. Starter roles only: user | admin | superadmin.
|
|
3
|
+
*/
|
|
4
|
+
import { and, asc, count, desc, eq, ilike, or, type SQL } from "drizzle-orm";
|
|
5
|
+
import type { Database } from "@/db";
|
|
6
|
+
import { user } from "@/db/schema";
|
|
7
|
+
import type { AdminAuditEntry } from "@/services/admin-security";
|
|
8
|
+
|
|
9
|
+
export type AdminUserRole = "user" | "admin" | "superadmin";
|
|
10
|
+
|
|
11
|
+
export type ListAdminUsersParams = {
|
|
12
|
+
page: number;
|
|
13
|
+
pageSize: number;
|
|
14
|
+
search: string;
|
|
15
|
+
sort?: { id: string; desc?: boolean };
|
|
16
|
+
filters: { role?: string; banned?: string };
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export async function listAdminUsers(
|
|
20
|
+
db: Database,
|
|
21
|
+
params: ListAdminUsersParams,
|
|
22
|
+
) {
|
|
23
|
+
const { page, pageSize, search, sort, filters } = params;
|
|
24
|
+
|
|
25
|
+
const conds: SQL[] = [];
|
|
26
|
+
if (search.trim()) {
|
|
27
|
+
const q = `%${search.trim()}%`;
|
|
28
|
+
const m = or(ilike(user.email, q), ilike(user.name, q));
|
|
29
|
+
if (m) conds.push(m);
|
|
30
|
+
}
|
|
31
|
+
if (
|
|
32
|
+
filters.role === "user" ||
|
|
33
|
+
filters.role === "admin" ||
|
|
34
|
+
filters.role === "superadmin"
|
|
35
|
+
) {
|
|
36
|
+
conds.push(eq(user.role, filters.role));
|
|
37
|
+
}
|
|
38
|
+
if (filters.banned === "true" || filters.banned === "false") {
|
|
39
|
+
conds.push(eq(user.banned, filters.banned === "true"));
|
|
40
|
+
}
|
|
41
|
+
const where = conds.length ? and(...conds) : undefined;
|
|
42
|
+
|
|
43
|
+
const sortCol =
|
|
44
|
+
sort?.id === "email"
|
|
45
|
+
? user.email
|
|
46
|
+
: sort?.id === "name"
|
|
47
|
+
? user.name
|
|
48
|
+
: user.createdAt;
|
|
49
|
+
const orderBy = sort && sort.desc === false ? asc(sortCol) : desc(sortCol);
|
|
50
|
+
|
|
51
|
+
const rows = await db
|
|
52
|
+
.select({
|
|
53
|
+
id: user.id,
|
|
54
|
+
email: user.email,
|
|
55
|
+
name: user.name,
|
|
56
|
+
emailVerified: user.emailVerified,
|
|
57
|
+
role: user.role,
|
|
58
|
+
banned: user.banned,
|
|
59
|
+
twoFactorEnabled: user.twoFactorEnabled,
|
|
60
|
+
createdAt: user.createdAt,
|
|
61
|
+
})
|
|
62
|
+
.from(user)
|
|
63
|
+
.where(where)
|
|
64
|
+
.orderBy(orderBy)
|
|
65
|
+
.limit(pageSize)
|
|
66
|
+
.offset(page * pageSize);
|
|
67
|
+
|
|
68
|
+
const [{ n }] = await db.select({ n: count() }).from(user).where(where);
|
|
69
|
+
return {
|
|
70
|
+
rows: rows.map((row) => ({
|
|
71
|
+
...row,
|
|
72
|
+
createdAt: row.createdAt.getTime(),
|
|
73
|
+
})),
|
|
74
|
+
total: Number(n),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type AdminUserCurrent = {
|
|
79
|
+
role: string | null;
|
|
80
|
+
banned: boolean | null;
|
|
81
|
+
emailVerified: boolean;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export type AdminUserUpdateData = {
|
|
85
|
+
userId: string;
|
|
86
|
+
emailVerified: boolean;
|
|
87
|
+
banned: boolean;
|
|
88
|
+
role?: AdminUserRole;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export type AdminUserAuthApi = {
|
|
92
|
+
setRole(args: {
|
|
93
|
+
body: { userId: string; role: AdminUserRole };
|
|
94
|
+
headers: HeadersInit;
|
|
95
|
+
}): Promise<unknown>;
|
|
96
|
+
banUser(args: {
|
|
97
|
+
body: { userId: string };
|
|
98
|
+
headers: HeadersInit;
|
|
99
|
+
}): Promise<unknown>;
|
|
100
|
+
unbanUser(args: {
|
|
101
|
+
body: { userId: string };
|
|
102
|
+
headers: HeadersInit;
|
|
103
|
+
}): Promise<unknown>;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export type AdminUserUpdateDeps = {
|
|
107
|
+
db: Database;
|
|
108
|
+
auth: AdminUserAuthApi;
|
|
109
|
+
headers: HeadersInit;
|
|
110
|
+
actorId: string;
|
|
111
|
+
log: (entry: AdminAuditEntry) => Promise<void>;
|
|
112
|
+
revokeCredentials: (userId: string) => Promise<void>;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Apply admin user mutations. Auth API calls first (other connection), then
|
|
117
|
+
* direct db writes — never hold a row lock across auth.api (deadlock rule).
|
|
118
|
+
*/
|
|
119
|
+
export async function applyAdminUserUpdate(
|
|
120
|
+
deps: AdminUserUpdateDeps,
|
|
121
|
+
current: AdminUserCurrent,
|
|
122
|
+
data: AdminUserUpdateData,
|
|
123
|
+
): Promise<void> {
|
|
124
|
+
const { db, auth, headers, actorId, log, revokeCredentials } = deps;
|
|
125
|
+
|
|
126
|
+
const roleChanged =
|
|
127
|
+
data.role !== undefined && (current.role ?? "user") !== data.role;
|
|
128
|
+
const banChanged = Boolean(current.banned) !== data.banned;
|
|
129
|
+
const verifiedChanged = current.emailVerified !== data.emailVerified;
|
|
130
|
+
|
|
131
|
+
if (roleChanged && data.role !== undefined) {
|
|
132
|
+
await auth.setRole({
|
|
133
|
+
body: { userId: data.userId, role: data.role },
|
|
134
|
+
headers,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
if (banChanged) {
|
|
138
|
+
if (data.banned) {
|
|
139
|
+
await auth.banUser({ body: { userId: data.userId }, headers });
|
|
140
|
+
} else {
|
|
141
|
+
await auth.unbanUser({ body: { userId: data.userId }, headers });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (banChanged && data.banned) {
|
|
146
|
+
await revokeCredentials(data.userId);
|
|
147
|
+
}
|
|
148
|
+
if (verifiedChanged) {
|
|
149
|
+
await db
|
|
150
|
+
.update(user)
|
|
151
|
+
.set({ emailVerified: data.emailVerified, updatedAt: new Date() })
|
|
152
|
+
.where(eq(user.id, data.userId));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (verifiedChanged) {
|
|
156
|
+
await log({
|
|
157
|
+
actorId,
|
|
158
|
+
action: "user.email_verified",
|
|
159
|
+
targetType: "user",
|
|
160
|
+
targetId: data.userId,
|
|
161
|
+
detail: { emailVerified: data.emailVerified },
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
if (roleChanged && data.role !== undefined) {
|
|
165
|
+
await log({
|
|
166
|
+
actorId,
|
|
167
|
+
action: "user.role_change",
|
|
168
|
+
targetType: "user",
|
|
169
|
+
targetId: data.userId,
|
|
170
|
+
detail: { from: current.role ?? "user", to: data.role },
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
if (banChanged) {
|
|
174
|
+
await log({
|
|
175
|
+
actorId,
|
|
176
|
+
action: data.banned ? "user.ban" : "user.unban",
|
|
177
|
+
targetType: "user",
|
|
178
|
+
targetId: data.userId,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Which Better Auth endpoints to throttle, and how to key the limiter. Pure and
|
|
2
|
+
// framework-free so it's unit-testable without the Cloudflare `env` binding.
|
|
3
|
+
//
|
|
4
|
+
// Only the brute-force / abuse-prone POST actions are throttled — credential
|
|
5
|
+
// stuffing (sign-in), mass signup (sign-up), and email-bombing / enumeration
|
|
6
|
+
// (password reset + verification email). Session/callback/get-session POSTs are
|
|
7
|
+
// left alone so normal app traffic isn't limited.
|
|
8
|
+
export const SENSITIVE_AUTH_PATHS = [
|
|
9
|
+
"/api/auth/sign-in",
|
|
10
|
+
"/api/auth/sign-up",
|
|
11
|
+
"/api/auth/forget-password",
|
|
12
|
+
"/api/auth/reset-password",
|
|
13
|
+
"/api/auth/request-password-reset",
|
|
14
|
+
"/api/auth/send-verification-email",
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The rate-limit key for a request that should be throttled, or null to let it
|
|
19
|
+
* through. Keyed per client IP + path so each action gets its own budget.
|
|
20
|
+
* `cf-connecting-ip` is set by the Cloudflare edge and can't be spoofed/removed
|
|
21
|
+
* by the client; the `x-forwarded-for` fallback only matters off-Cloudflare.
|
|
22
|
+
*/
|
|
23
|
+
export function authRateLimitKey(request: Request): string | null {
|
|
24
|
+
if (request.method !== "POST") return null;
|
|
25
|
+
const { pathname } = new URL(request.url);
|
|
26
|
+
if (!SENSITIVE_AUTH_PATHS.some((p) => pathname.startsWith(p))) return null;
|
|
27
|
+
const ip =
|
|
28
|
+
request.headers.get("cf-connecting-ip") ??
|
|
29
|
+
request.headers.get("x-forwarded-for") ??
|
|
30
|
+
"unknown";
|
|
31
|
+
return `${ip}:${pathname}`;
|
|
32
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creem checkout webhook: signature verification and the framework-agnostic
|
|
3
|
+
* callback handler.
|
|
4
|
+
*
|
|
5
|
+
* Kept free of `env` and framework glue so it is unit-testable; the route
|
|
6
|
+
* (src/routes/api/webhooks/creem.ts) supplies the secret and the
|
|
7
|
+
* service-context db.
|
|
8
|
+
*/
|
|
9
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
10
|
+
import type { Database } from "@/db";
|
|
11
|
+
import { markPurchasePaid } from "@/services/purchase";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Verify a Creem signature header: HMAC-SHA256 (hex) over the raw body,
|
|
15
|
+
* compared constant-time. Returns false for anything malformed — never throws
|
|
16
|
+
* on bad input, because a throw would answer "retry me" and Creem would replay
|
|
17
|
+
* a hostile body.
|
|
18
|
+
*/
|
|
19
|
+
export function verifyCreemSignature(
|
|
20
|
+
secret: string,
|
|
21
|
+
rawBody: string,
|
|
22
|
+
signatureHeader: string | null | undefined,
|
|
23
|
+
): boolean {
|
|
24
|
+
if (!signatureHeader) return false;
|
|
25
|
+
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
|
|
26
|
+
try {
|
|
27
|
+
return timingSafeEqual(
|
|
28
|
+
Buffer.from(expected),
|
|
29
|
+
Buffer.from(signatureHeader.trim()),
|
|
30
|
+
);
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type CreemWebhookResult =
|
|
37
|
+
| "invalid_signature"
|
|
38
|
+
| "invalid_payload"
|
|
39
|
+
| "credited"
|
|
40
|
+
| "unknown_order"
|
|
41
|
+
| "noop";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Handle a Creem webhook: verify the signature, then fulfill on the checkout
|
|
45
|
+
* id, joining on it as the Purchase `externalId`. Creem reports no amount we
|
|
46
|
+
* would trust, so fulfillment uses the Purchase row's own amount with no
|
|
47
|
+
* integrity check to run against.
|
|
48
|
+
*
|
|
49
|
+
* `db` must already carry service RLS context (the route wraps this in
|
|
50
|
+
* withRlsService). Throws only on a genuine processing/DB error — including a
|
|
51
|
+
* throw from the purchase-paid hook — so the route can answer 5xx and Creem
|
|
52
|
+
* retries; every mapped outcome resolves so the route answers 200 and Creem
|
|
53
|
+
* stops retrying.
|
|
54
|
+
*/
|
|
55
|
+
export async function handleCreemWebhook(
|
|
56
|
+
db: Database,
|
|
57
|
+
args: { rawBody: string; signature: string | null; secret: string },
|
|
58
|
+
): Promise<CreemWebhookResult> {
|
|
59
|
+
if (!verifyCreemSignature(args.secret, args.rawBody, args.signature)) {
|
|
60
|
+
return "invalid_signature";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Creem signs the raw string, so a valid signature over an unparseable body
|
|
64
|
+
// means the sender signed garbage. That is a client error, not a retry.
|
|
65
|
+
let payload: { id?: string; object?: { id?: string }; eventType?: string };
|
|
66
|
+
try {
|
|
67
|
+
payload = JSON.parse(args.rawBody) as typeof payload;
|
|
68
|
+
} catch {
|
|
69
|
+
return "invalid_payload";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const externalId = payload.object?.id ?? payload.id;
|
|
73
|
+
if (!externalId) return "noop";
|
|
74
|
+
|
|
75
|
+
const result = await markPurchasePaid(db, {
|
|
76
|
+
provider: "creem",
|
|
77
|
+
externalId,
|
|
78
|
+
});
|
|
79
|
+
if (result.status === "not_found") return "unknown_order";
|
|
80
|
+
return result.status === "fulfilled" || result.status === "already_paid"
|
|
81
|
+
? "credited"
|
|
82
|
+
: "noop";
|
|
83
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal demo checkout start: toggle + env gated, pending Purchase under
|
|
3
|
+
* service scope. Product domain fulfills via onPurchasePaid.
|
|
4
|
+
*/
|
|
5
|
+
import type { PaymentToggles } from "@/services/payment-toggles";
|
|
6
|
+
import type { PurchaseProvider } from "@/services/purchase";
|
|
7
|
+
|
|
8
|
+
/** Fixed demo price in minor units ($5.00). Clones replace or parameterize. */
|
|
9
|
+
export const DEMO_CHECKOUT_AMOUNT_MINOR = 500;
|
|
10
|
+
|
|
11
|
+
type CheckoutGateResult =
|
|
12
|
+
| { ok: true }
|
|
13
|
+
| { ok: false; code: "disabled" | "not_configured" };
|
|
14
|
+
|
|
15
|
+
export function resolveCheckoutGate(args: {
|
|
16
|
+
provider: PurchaseProvider;
|
|
17
|
+
toggles: PaymentToggles;
|
|
18
|
+
configured: Record<PurchaseProvider, boolean>;
|
|
19
|
+
}): CheckoutGateResult {
|
|
20
|
+
if (!args.toggles[args.provider]) return { ok: false, code: "disabled" };
|
|
21
|
+
if (!args.configured[args.provider]) {
|
|
22
|
+
return { ok: false, code: "not_configured" };
|
|
23
|
+
}
|
|
24
|
+
return { ok: true };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type DemoCheckoutPort = {
|
|
28
|
+
getToggles(): Promise<PaymentToggles>;
|
|
29
|
+
isConfigured(provider: PurchaseProvider): boolean;
|
|
30
|
+
createHostedSession(input: {
|
|
31
|
+
provider: PurchaseProvider;
|
|
32
|
+
userId: string;
|
|
33
|
+
userEmail: string;
|
|
34
|
+
amount: number;
|
|
35
|
+
currency: string;
|
|
36
|
+
}): Promise<{ externalId: string; url: string }>;
|
|
37
|
+
createPendingPurchase(input: {
|
|
38
|
+
userId: string;
|
|
39
|
+
provider: PurchaseProvider;
|
|
40
|
+
externalId: string;
|
|
41
|
+
amount: number;
|
|
42
|
+
currency: string;
|
|
43
|
+
}): Promise<{ purchaseId: string }>;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
type StartDemoCheckoutResult =
|
|
47
|
+
| {
|
|
48
|
+
ok: true;
|
|
49
|
+
url: string;
|
|
50
|
+
purchaseId: string;
|
|
51
|
+
externalId: string;
|
|
52
|
+
amount: number;
|
|
53
|
+
}
|
|
54
|
+
| { ok: false; code: "disabled" | "not_configured" };
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Start a demo checkout: host session first (external id), then pending row
|
|
58
|
+
* keyed by that id so webhooks can fulfill. Host session before DB so a
|
|
59
|
+
* failed provider call never leaves an orphan pending purchase.
|
|
60
|
+
*/
|
|
61
|
+
export async function startDemoCheckout(
|
|
62
|
+
port: DemoCheckoutPort,
|
|
63
|
+
input: { userId: string; userEmail: string; provider: PurchaseProvider },
|
|
64
|
+
): Promise<StartDemoCheckoutResult> {
|
|
65
|
+
const toggles = await port.getToggles();
|
|
66
|
+
const gate = resolveCheckoutGate({
|
|
67
|
+
provider: input.provider,
|
|
68
|
+
toggles,
|
|
69
|
+
configured: {
|
|
70
|
+
stripe: port.isConfigured("stripe"),
|
|
71
|
+
creem: port.isConfigured("creem"),
|
|
72
|
+
nowpayments: port.isConfigured("nowpayments"),
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
if (!gate.ok) return gate;
|
|
76
|
+
|
|
77
|
+
const currency = "usd";
|
|
78
|
+
const amount = DEMO_CHECKOUT_AMOUNT_MINOR;
|
|
79
|
+
const session = await port.createHostedSession({
|
|
80
|
+
provider: input.provider,
|
|
81
|
+
userId: input.userId,
|
|
82
|
+
userEmail: input.userEmail,
|
|
83
|
+
amount,
|
|
84
|
+
currency,
|
|
85
|
+
});
|
|
86
|
+
const { purchaseId } = await port.createPendingPurchase({
|
|
87
|
+
userId: input.userId,
|
|
88
|
+
provider: input.provider,
|
|
89
|
+
externalId: session.externalId,
|
|
90
|
+
amount,
|
|
91
|
+
currency,
|
|
92
|
+
});
|
|
93
|
+
return {
|
|
94
|
+
ok: true,
|
|
95
|
+
url: session.url,
|
|
96
|
+
purchaseId,
|
|
97
|
+
externalId: session.externalId,
|
|
98
|
+
amount,
|
|
99
|
+
};
|
|
100
|
+
}
|