@0x0501/mystack 0.0.0-stage → 0.1.0-bootstrap.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -2
  3. package/assets/auth.sqlite.schema.ts +479 -0
  4. package/assets/bun-sqlite.test.ts +46 -0
  5. package/assets/d1.test.ts +41 -0
  6. package/assets/drizzle.config.ts +2 -0
  7. package/assets/generate-auth-schema.mjs +25 -0
  8. package/assets/platform.sqlite.schema.ts +46 -0
  9. package/assets/polar-checkout.ts +10 -0
  10. package/assets/polar-route.ts +19 -0
  11. package/assets/polar.test.ts +30 -0
  12. package/assets/polar.ts +12 -0
  13. package/assets/purchase.bun-sqlite.ts +38 -0
  14. package/assets/purchase.sqlite.ts +39 -0
  15. package/assets/sqlite-migrations/0000_thin_phalanx.sql +295 -0
  16. package/assets/sqlite-migrations/meta/0000_snapshot.json +2058 -0
  17. package/assets/sqlite-migrations/meta/_journal.json +13 -0
  18. package/assets/turso.test.ts +40 -0
  19. package/package.json +44 -5
  20. package/src/adapters/database.mjs +448 -0
  21. package/src/adapters/deployment.mjs +101 -0
  22. package/src/adapters/email.mjs +75 -0
  23. package/src/adapters/payments.mjs +270 -0
  24. package/src/catalog.mjs +128 -0
  25. package/src/cli.mjs +294 -0
  26. package/src/generate.mjs +183 -0
  27. package/src/template.mjs +93 -0
  28. package/template/.env.docker +16 -0
  29. package/template/.env.example +67 -0
  30. package/template/DESIGN.md +58 -0
  31. package/template/LICENSE +21 -0
  32. package/template/biome.json +46 -0
  33. package/template/docker-compose.yml +47 -0
  34. package/template/docs/project-guide.md +61 -0
  35. package/template/drizzle/0000_create-app-role.sql +54 -0
  36. package/template/drizzle/0001_add-rls-context.sql +21 -0
  37. package/template/drizzle/0002_platform_tables.sql +320 -0
  38. package/template/drizzle/meta/0002_snapshot.json +2125 -0
  39. package/template/drizzle/meta/_journal.json +27 -0
  40. package/template/drizzle.config.ts +25 -0
  41. package/template/e2e/authenticated-ui.spec.ts +40 -0
  42. package/template/e2e/headless-design.spec.ts +66 -0
  43. package/template/e2e/navigation-loading.spec.ts +70 -0
  44. package/template/e2e/theme-responsive.spec.ts +260 -0
  45. package/template/gitignore +24 -0
  46. package/template/messages/de.json +212 -0
  47. package/template/messages/en.json +212 -0
  48. package/template/package.json +101 -0
  49. package/template/paraglide.config.ts +49 -0
  50. package/template/playwright.config.ts +58 -0
  51. package/template/project.inlang/settings.json +12 -0
  52. package/template/public/drizzle.svg +1 -0
  53. package/template/public/favicon.ico +0 -0
  54. package/template/public/logo192.png +0 -0
  55. package/template/public/logo512.png +0 -0
  56. package/template/public/manifest.json +25 -0
  57. package/template/public/robots.txt +3 -0
  58. package/template/scripts/check-client-bundle.ts +150 -0
  59. package/template/scripts/check-server-fns.ts +290 -0
  60. package/template/scripts/check-sql-raw.ts +50 -0
  61. package/template/scripts/compile-paraglide.ts +12 -0
  62. package/template/scripts/postgres/README.md +76 -0
  63. package/template/scripts/postgres/create-app-role.sh +60 -0
  64. package/template/scripts/seed-local.ts +331 -0
  65. package/template/scripts/validate-prod-env.ts +53 -0
  66. package/template/src/__tests__/README.md +9 -0
  67. package/template/src/__tests__/account-security.test.ts +664 -0
  68. package/template/src/__tests__/admin-http-surface.test.ts +311 -0
  69. package/template/src/__tests__/admin-peer-protection.test.ts +178 -0
  70. package/template/src/__tests__/auth-harness.ts +104 -0
  71. package/template/src/__tests__/auth-rate-limit.test.ts +43 -0
  72. package/template/src/__tests__/auth-redirect.test.ts +120 -0
  73. package/template/src/__tests__/body-limit.test.ts +47 -0
  74. package/template/src/__tests__/creem.test.ts +62 -0
  75. package/template/src/__tests__/demo-checkout.test.ts +141 -0
  76. package/template/src/__tests__/email-locale.test.ts +110 -0
  77. package/template/src/__tests__/global-setup.ts +80 -0
  78. package/template/src/__tests__/input-rules.test.ts +0 -0
  79. package/template/src/__tests__/message-parity.test.ts +38 -0
  80. package/template/src/__tests__/navigation-loading.test.tsx +252 -0
  81. package/template/src/__tests__/nowpayments.test.ts +106 -0
  82. package/template/src/__tests__/paraglide-url-contract.test.ts +87 -0
  83. package/template/src/__tests__/purchase-fulfillment.test.ts +204 -0
  84. package/template/src/__tests__/query-unauthorized-redirect.test.ts +95 -0
  85. package/template/src/__tests__/rls-policy.test.ts +120 -0
  86. package/template/src/__tests__/same-page-locale-href.test.ts +47 -0
  87. package/template/src/__tests__/search-params-oauth.test.ts +57 -0
  88. package/template/src/__tests__/security-headers.test.ts +12 -0
  89. package/template/src/__tests__/server-fn-error-contract.test.ts +173 -0
  90. package/template/src/__tests__/stripe.test.ts +84 -0
  91. package/template/src/__tests__/test-db.ts +32 -0
  92. package/template/src/__tests__/test-rls-db.ts +23 -0
  93. package/template/src/__tests__/user-update-guard.test.ts +109 -0
  94. package/template/src/components/LocaleSwitcher.tsx +79 -0
  95. package/template/src/components/ThemeToggle.tsx +47 -0
  96. package/template/src/components/auth/social.tsx +90 -0
  97. package/template/src/components/auth/turnstile.tsx +67 -0
  98. package/template/src/components/root/navigation-progress.tsx +32 -0
  99. package/template/src/components/root/not-found.tsx +20 -0
  100. package/template/src/components/root/pending.tsx +9 -0
  101. package/template/src/components/root/root-document.tsx +37 -0
  102. package/template/src/components/root/root-error.tsx +34 -0
  103. package/template/src/components/shells.tsx +201 -0
  104. package/template/src/components/ui/styles.ts +9 -0
  105. package/template/src/db/auth.schema.ts +455 -0
  106. package/template/src/db/helper.ts +38 -0
  107. package/template/src/db/index.ts +15 -0
  108. package/template/src/db/platform.schema.ts +122 -0
  109. package/template/src/db/schema.ts +2 -0
  110. package/template/src/env.ts +65 -0
  111. package/template/src/hooks/use-theme.ts +32 -0
  112. package/template/src/integrations/better-auth/header-user.tsx +36 -0
  113. package/template/src/integrations/better-auth/session-query.ts +11 -0
  114. package/template/src/integrations/react-email/PasswordOtpEmail.tsx +62 -0
  115. package/template/src/integrations/react-email/ResetPasswordEmail.tsx +64 -0
  116. package/template/src/integrations/react-email/VerifyEmail.tsx +72 -0
  117. package/template/src/integrations/react-email/theme.ts +75 -0
  118. package/template/src/integrations/tanstack-query/devtools.tsx +6 -0
  119. package/template/src/integrations/tanstack-query/root-provider.tsx +60 -0
  120. package/template/src/lib/auth-client.ts +27 -0
  121. package/template/src/lib/auth-paths.ts +94 -0
  122. package/template/src/lib/auth-plugins/existing-email-signup.ts +86 -0
  123. package/template/src/lib/auth-plugins/field-rules.ts +84 -0
  124. package/template/src/lib/auth-plugins/social-two-factor.ts +194 -0
  125. package/template/src/lib/auth-timing.ts +9 -0
  126. package/template/src/lib/auth.ts +279 -0
  127. package/template/src/lib/captcha.ts +11 -0
  128. package/template/src/lib/email.tsx +162 -0
  129. package/template/src/lib/github.ts +18 -0
  130. package/template/src/lib/locale-cookie.ts +12 -0
  131. package/template/src/lib/localized-pages.ts +25 -0
  132. package/template/src/lib/oauth-metadata.ts +19 -0
  133. package/template/src/lib/permissions.ts +29 -0
  134. package/template/src/lib/security-headers.ts +20 -0
  135. package/template/src/lib/site.ts +59 -0
  136. package/template/src/lib/theme.ts +42 -0
  137. package/template/src/lib/user-update-guard.ts +35 -0
  138. package/template/src/middlewares/admin.ts +46 -0
  139. package/template/src/middlewares/auth-rate-limit.ts +37 -0
  140. package/template/src/middlewares/better-auth.ts +15 -0
  141. package/template/src/middlewares/body-limit.ts +62 -0
  142. package/template/src/middlewares/database.ts +13 -0
  143. package/template/src/middlewares/protected.ts +45 -0
  144. package/template/src/routeTree.gen.ts +536 -0
  145. package/template/src/router.tsx +53 -0
  146. package/template/src/routes/[.]well-known/oauth-authorization-server/api/auth.ts +18 -0
  147. package/template/src/routes/[.]well-known/oauth-protected-resource/index.ts +32 -0
  148. package/template/src/routes/__root.tsx +175 -0
  149. package/template/src/routes/admin/audit.tsx +74 -0
  150. package/template/src/routes/admin/index.tsx +7 -0
  151. package/template/src/routes/admin/oauth-apps.tsx +164 -0
  152. package/template/src/routes/admin/route.tsx +27 -0
  153. package/template/src/routes/admin/users.tsx +215 -0
  154. package/template/src/routes/api/auth/$.ts +14 -0
  155. package/template/src/routes/api/auth/[.]well-known/openid-configuration.ts +15 -0
  156. package/template/src/routes/api/webhooks/creem.ts +69 -0
  157. package/template/src/routes/api/webhooks/nowpayments.ts +62 -0
  158. package/template/src/routes/api/webhooks/stripe.ts +74 -0
  159. package/template/src/routes/dashboard/account.tsx +430 -0
  160. package/template/src/routes/dashboard/index.tsx +128 -0
  161. package/template/src/routes/dashboard/route.tsx +17 -0
  162. package/template/src/routes/forgot-password.tsx +65 -0
  163. package/template/src/routes/index.tsx +85 -0
  164. package/template/src/routes/oauth/consent.tsx +77 -0
  165. package/template/src/routes/reset-password.tsx +78 -0
  166. package/template/src/routes/sign-in.tsx +174 -0
  167. package/template/src/routes/sign-up.tsx +135 -0
  168. package/template/src/routes/two-factor.tsx +74 -0
  169. package/template/src/server/README.md +3 -0
  170. package/template/src/server/account-security.ts +163 -0
  171. package/template/src/server/admin-audit.ts +37 -0
  172. package/template/src/server/admin-users.ts +112 -0
  173. package/template/src/server/auth.ts +44 -0
  174. package/template/src/server/demo-checkout.ts +67 -0
  175. package/template/src/server/oauth-clients.ts +83 -0
  176. package/template/src/server/payment-toggles.ts +39 -0
  177. package/template/src/server.ts +20 -0
  178. package/template/src/services/README.md +3 -0
  179. package/template/src/services/account-security.ts +893 -0
  180. package/template/src/services/admin-security.ts +164 -0
  181. package/template/src/services/admin-users.ts +181 -0
  182. package/template/src/services/auth-rate-limit.ts +32 -0
  183. package/template/src/services/creem.ts +83 -0
  184. package/template/src/services/demo-checkout.ts +100 -0
  185. package/template/src/services/nowpayments.ts +154 -0
  186. package/template/src/services/oauth-client.ts +85 -0
  187. package/template/src/services/oauth-clients.ts +15 -0
  188. package/template/src/services/payment-providers.ts +157 -0
  189. package/template/src/services/payment-toggles.ts +48 -0
  190. package/template/src/services/purchase.ts +233 -0
  191. package/template/src/services/stripe.ts +106 -0
  192. package/template/src/start.ts +35 -0
  193. package/template/src/styles/layout.css +93 -0
  194. package/template/src/styles/theme.css +84 -0
  195. package/template/src/styles/ui.css +233 -0
  196. package/template/src/styles.css +90 -0
  197. package/template/src/utils/api-error.ts +129 -0
  198. package/template/src/utils/auth-errors.ts +157 -0
  199. package/template/src/utils/auth-redirect.ts +61 -0
  200. package/template/src/utils/body-limit.ts +84 -0
  201. package/template/src/utils/cn.ts +6 -0
  202. package/template/src/utils/input-rules.ts +172 -0
  203. package/template/src/utils/locale-paths.ts +18 -0
  204. package/template/src/utils/same-page-locale-href.ts +20 -0
  205. package/template/src/utils/search-params.ts +71 -0
  206. package/template/tsconfig.json +30 -0
  207. package/template/tsr.config.json +6 -0
  208. package/template/vite.config.ts +24 -0
  209. package/template/vitest.config.ts +26 -0
  210. package/template/worker-configuration.d.ts +14660 -0
  211. package/template/wrangler.jsonc +39 -0
@@ -0,0 +1,154 @@
1
+ /**
2
+ * NowPayments crypto IPN: signature verification and the framework-agnostic
3
+ * callback handler. Crypto settles asynchronously, so the signed IPN is the
4
+ * sole source of truth for fulfillment — the return page never confirms.
5
+ *
6
+ * Kept free of `env` and framework glue so it is unit-testable; the route
7
+ * (src/routes/api/webhooks/nowpayments.ts) supplies the secret and the
8
+ * service-context db.
9
+ */
10
+ import type { Database } from "@/db";
11
+ import { markPurchasePaid } from "@/services/purchase";
12
+
13
+ /** Only these statuses release funds; everything else leaves the row pending. */
14
+ const PAID_STATUSES = new Set(["finished", "confirmed"]);
15
+
16
+ /**
17
+ * Deeper than any real IPN payload, shallow enough that the recursion below
18
+ * cannot exhaust the stack on a hostile body.
19
+ */
20
+ const MAX_CANONICAL_DEPTH = 64;
21
+
22
+ class TooDeepError extends Error {}
23
+
24
+ /** Recursively sort object keys so serialization is canonical (arrays keep order). */
25
+ function sortKeys(value: unknown, depth = 0): unknown {
26
+ if (depth > MAX_CANONICAL_DEPTH) throw new TooDeepError();
27
+ if (Array.isArray(value)) return value.map((v) => sortKeys(v, depth + 1));
28
+ if (value && typeof value === "object") {
29
+ return Object.fromEntries(
30
+ Object.keys(value as Record<string, unknown>)
31
+ .sort()
32
+ .map((k) => [
33
+ k,
34
+ sortKeys((value as Record<string, unknown>)[k], depth + 1),
35
+ ]),
36
+ );
37
+ }
38
+ return value;
39
+ }
40
+
41
+ async function hmacSha512Hex(key: string, message: string): Promise<string> {
42
+ const enc = new TextEncoder();
43
+ const cryptoKey = await crypto.subtle.importKey(
44
+ "raw",
45
+ enc.encode(key),
46
+ { name: "HMAC", hash: "SHA-512" },
47
+ false,
48
+ ["sign"],
49
+ );
50
+ const sig = await crypto.subtle.sign("HMAC", cryptoKey, enc.encode(message));
51
+ return Array.from(new Uint8Array(sig))
52
+ .map((b) => b.toString(16).padStart(2, "0"))
53
+ .join("");
54
+ }
55
+
56
+ /** Length-independent, constant-time comparison of two equal-length hex strings. */
57
+ function timingSafeEqualHex(a: string, b: string): boolean {
58
+ if (a.length !== b.length) return false;
59
+ let diff = 0;
60
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
61
+ return diff === 0;
62
+ }
63
+
64
+ /**
65
+ * Verify an IPN callback: HMAC-SHA512 (hex) over the recursively key-sorted,
66
+ * re-serialized JSON body, compared constant-time against the
67
+ * `x-nowpayments-sig` header. Returns false for a missing/invalid signature or
68
+ * an unparseable body — never throws on bad input.
69
+ */
70
+ export async function verifyNowPaymentsSignature(
71
+ ipnKey: string,
72
+ rawBody: string,
73
+ signature: string | null | undefined,
74
+ ): Promise<boolean> {
75
+ if (!signature) return false;
76
+ let canonical: string;
77
+ try {
78
+ // Parsing and canonicalising are both refusals, not crashes: a body that
79
+ // is unparseable or nested past the depth limit is a bad signature. If it
80
+ // escaped as a 500 the route would answer "retry me" and NowPayments
81
+ // would replay the hostile body forever.
82
+ canonical = JSON.stringify(sortKeys(JSON.parse(rawBody)));
83
+ } catch {
84
+ return false;
85
+ }
86
+ const expected = await hmacSha512Hex(ipnKey, canonical);
87
+ return timingSafeEqualHex(expected, signature.trim());
88
+ }
89
+
90
+ export type NowPaymentsWebhookResult =
91
+ | "invalid_signature"
92
+ | "credited"
93
+ | "underpaid"
94
+ | "unknown_order"
95
+ | "noop";
96
+
97
+ /**
98
+ * Handle a NowPayments IPN: verify the signature, then map the payment status
99
+ * to the matching action, joining on our own `order_id` (= Purchase
100
+ * `externalId`). Only a terminal paid status fulfills; an underpaid invoice and
101
+ * every intermediate status leave the Purchase `pending`. Fulfillment is
102
+ * idempotent and status-guarded (see services/purchase), so retries and
103
+ * out-of-order callbacks never deliver twice.
104
+ *
105
+ * `db` must already carry service RLS context (the route wraps this in
106
+ * withRlsService). Throws only on a genuine processing/DB error — including a
107
+ * throw from the purchase-paid hook — so the route can answer 5xx and
108
+ * NowPayments retries; every mapped outcome resolves so the route answers 200
109
+ * and NowPayments stops retrying.
110
+ */
111
+ export async function handleNowPaymentsWebhook(
112
+ db: Database,
113
+ args: { rawBody: string; signature: string | null; ipnKey: string },
114
+ ): Promise<NowPaymentsWebhookResult> {
115
+ if (
116
+ !(await verifyNowPaymentsSignature(
117
+ args.ipnKey,
118
+ args.rawBody,
119
+ args.signature,
120
+ ))
121
+ ) {
122
+ return "invalid_signature";
123
+ }
124
+
125
+ // Parsing cannot fail here: verification already parsed this exact body.
126
+ const body = JSON.parse(args.rawBody) as {
127
+ order_id?: string;
128
+ payment_status?: string;
129
+ actually_paid?: number;
130
+ price_amount?: number;
131
+ };
132
+
133
+ const externalId = body.order_id;
134
+ if (!externalId || !body.payment_status) return "noop";
135
+ if (!PAID_STATUSES.has(body.payment_status)) return "noop";
136
+
137
+ // Underpayment: actually_paid < price_amount → do not fulfill.
138
+ if (
139
+ typeof body.actually_paid === "number" &&
140
+ typeof body.price_amount === "number" &&
141
+ body.actually_paid + 1e-9 < body.price_amount
142
+ ) {
143
+ return "underpaid";
144
+ }
145
+
146
+ const result = await markPurchasePaid(db, {
147
+ provider: "nowpayments",
148
+ externalId,
149
+ });
150
+ if (result.status === "not_found") return "unknown_order";
151
+ return result.status === "fulfilled" || result.status === "already_paid"
152
+ ? "credited"
153
+ : "noop";
154
+ }
@@ -0,0 +1,85 @@
1
+ import { z } from "zod";
2
+
3
+ export const DEFAULT_OAUTH_SCOPE = "openid profile email offline_access";
4
+
5
+ const redirectUriSchema = z
6
+ .url()
7
+ .trim()
8
+ .refine(
9
+ (value) => {
10
+ const url = new URL(value);
11
+ if (url.hash || url.username || url.password) return false;
12
+ if (url.protocol === "https:") return true;
13
+ if (url.protocol !== "http:") return false;
14
+ return url.hostname === "127.0.0.1" || url.hostname === "[::1]";
15
+ },
16
+ {
17
+ message:
18
+ "Redirect URI must be HTTPS, or HTTP on 127.0.0.1/[::1], without userinfo or fragments.",
19
+ },
20
+ );
21
+
22
+ const scopeToken = /^[\x21\x23-\x5B\x5D-\x7E]+$/;
23
+ const scopeSchema = z
24
+ .string()
25
+ .trim()
26
+ .min(1)
27
+ .max(500)
28
+ .refine(
29
+ (value) => value.split(/\s+/).every((token) => scopeToken.test(token)),
30
+ { message: "Scope contains invalid characters." },
31
+ )
32
+ .transform((value) => value.split(/\s+/).join(" "));
33
+
34
+ export const createOAuthClientInputSchema = z.object({
35
+ clientName: z.string().trim().min(1).max(100),
36
+ redirectUris: z
37
+ .array(redirectUriSchema)
38
+ .min(1)
39
+ .max(10)
40
+ .transform((uris) => [...new Set(uris)]),
41
+ scope: scopeSchema.default(DEFAULT_OAUTH_SCOPE),
42
+ publicClient: z.boolean().default(false),
43
+ });
44
+
45
+ export type CreateOAuthClientInput = z.infer<
46
+ typeof createOAuthClientInputSchema
47
+ >;
48
+
49
+ /**
50
+ * Written as mutable arrays, not `as const` tuples: the provider's registration
51
+ * body declares these as writable arrays of enum members, and a `readonly`
52
+ * tuple is not assignable to one. Fresh arrays per call so a caller cannot
53
+ * reach back through the returned body and edit a shared literal.
54
+ */
55
+ type GrantType = "authorization_code" | "refresh_token";
56
+ const grantTypes = (): GrantType[] => ["authorization_code", "refresh_token"];
57
+ const responseTypes = (): Array<"code"> => ["code"];
58
+
59
+ export function oauthClientRegistrationBody(input: CreateOAuthClientInput) {
60
+ if (input.publicClient) {
61
+ return {
62
+ client_name: input.clientName,
63
+ redirect_uris: input.redirectUris,
64
+ type: "native" as const,
65
+ token_endpoint_auth_method: "none" as const,
66
+ require_pkce: true,
67
+ grant_types: grantTypes(),
68
+ response_types: responseTypes(),
69
+ skip_consent: false,
70
+ scope: input.scope,
71
+ enable_end_session: true,
72
+ };
73
+ }
74
+ return {
75
+ client_name: input.clientName,
76
+ redirect_uris: input.redirectUris,
77
+ token_endpoint_auth_method: "client_secret_post" as const,
78
+ require_pkce: true,
79
+ grant_types: grantTypes(),
80
+ response_types: responseTypes(),
81
+ skip_consent: false,
82
+ scope: input.scope,
83
+ enable_end_session: true,
84
+ };
85
+ }
@@ -0,0 +1,15 @@
1
+ import { eq } from "drizzle-orm";
2
+ import type { Database } from "@/db";
3
+ import { oauthClient } from "@/db/schema";
4
+
5
+ /** Returns false when there was no such client. */
6
+ export async function deleteOAuthClientById(
7
+ db: Database,
8
+ clientId: string,
9
+ ): Promise<boolean> {
10
+ const deleted = await db
11
+ .delete(oauthClient)
12
+ .where(eq(oauthClient.clientId, clientId))
13
+ .returning({ clientId: oauthClient.clientId });
14
+ return deleted.length > 0;
15
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Thin hosted-checkout adapters for demo purchase start.
3
+ * Webhooks remain the paid confirmation path.
4
+ */
5
+ import { env } from "@/env";
6
+ import type { PurchaseProvider } from "@/services/purchase";
7
+
8
+ export function isPaymentProviderConfigured(
9
+ provider: PurchaseProvider,
10
+ ): boolean {
11
+ switch (provider) {
12
+ case "stripe":
13
+ return Boolean(env.STRIPE_SECRET_KEY);
14
+ case "creem":
15
+ return Boolean(env.CREEM_API_KEY && env.CREEM_PRODUCT_ID);
16
+ case "nowpayments":
17
+ return Boolean(env.NOW_PAYMENTS_API_KEY);
18
+ default:
19
+ return false;
20
+ }
21
+ }
22
+
23
+ export async function createHostedCheckoutSession(input: {
24
+ provider: PurchaseProvider;
25
+ userId: string;
26
+ userEmail: string;
27
+ amount: number;
28
+ currency: string;
29
+ }): Promise<{ externalId: string; url: string }> {
30
+ switch (input.provider) {
31
+ case "stripe":
32
+ return createStripeSession(input);
33
+ case "creem":
34
+ return createCreemCheckout(input);
35
+ case "nowpayments":
36
+ return createNowPaymentsInvoice(input);
37
+ default:
38
+ throw new Error(`Unknown payment provider: ${input.provider}`);
39
+ }
40
+ }
41
+
42
+ async function createStripeSession(input: {
43
+ userId: string;
44
+ userEmail: string;
45
+ amount: number;
46
+ currency: string;
47
+ }): Promise<{ externalId: string; url: string }> {
48
+ const key = env.STRIPE_SECRET_KEY;
49
+ if (!key) throw new Error("Stripe is not configured.");
50
+ const origin = env.APP_ORIGIN;
51
+ const body = new URLSearchParams();
52
+ body.set("mode", "payment");
53
+ body.set("success_url", `${origin}/dashboard?checkout=success`);
54
+ body.set("cancel_url", `${origin}/dashboard?checkout=cancel`);
55
+ body.set("customer_email", input.userEmail);
56
+ body.set("client_reference_id", input.userId);
57
+ body.set("line_items[0][quantity]", "1");
58
+ body.set("line_items[0][price_data][currency]", input.currency);
59
+ body.set("line_items[0][price_data][unit_amount]", String(input.amount));
60
+ body.set("line_items[0][price_data][product_data][name]", "Demo purchase");
61
+
62
+ const res = await fetch("https://api.stripe.com/v1/checkout/sessions", {
63
+ method: "POST",
64
+ headers: {
65
+ authorization: `Bearer ${key}`,
66
+ "content-type": "application/x-www-form-urlencoded",
67
+ },
68
+ body,
69
+ });
70
+ if (!res.ok) {
71
+ const text = await res.text();
72
+ throw new Error(`Stripe checkout failed: ${res.status} ${text}`);
73
+ }
74
+ const data = (await res.json()) as { id: string; url: string };
75
+ if (!data.id || !data.url) throw new Error("Stripe returned no session URL.");
76
+ return { externalId: data.id, url: data.url };
77
+ }
78
+
79
+ async function createCreemCheckout(input: {
80
+ userId: string;
81
+ userEmail: string;
82
+ amount: number;
83
+ }): Promise<{ externalId: string; url: string }> {
84
+ const apiKey = env.CREEM_API_KEY;
85
+ const productId = env.CREEM_PRODUCT_ID;
86
+ if (!apiKey || !productId) throw new Error("Creem is not configured.");
87
+ const base = env.CREEM_TEST_MODE
88
+ ? "https://test-api.creem.io"
89
+ : "https://api.creem.io";
90
+ const res = await fetch(`${base}/v1/checkouts`, {
91
+ method: "POST",
92
+ headers: {
93
+ "content-type": "application/json",
94
+ "x-api-key": apiKey,
95
+ },
96
+ body: JSON.stringify({
97
+ product_id: productId,
98
+ request_id: input.userId,
99
+ customer: { email: input.userEmail },
100
+ // custom_price in minor units when product allows
101
+ custom_price: input.amount,
102
+ success_url: `${env.APP_ORIGIN}/dashboard?checkout=success`,
103
+ }),
104
+ });
105
+ if (!res.ok) {
106
+ const text = await res.text();
107
+ throw new Error(`Creem checkout failed: ${res.status} ${text}`);
108
+ }
109
+ const data = (await res.json()) as {
110
+ id?: string;
111
+ checkout_url?: string;
112
+ url?: string;
113
+ };
114
+ const externalId = data.id;
115
+ const url = data.checkout_url ?? data.url;
116
+ if (!externalId || !url) throw new Error("Creem returned no checkout URL.");
117
+ return { externalId, url };
118
+ }
119
+
120
+ async function createNowPaymentsInvoice(input: {
121
+ userId: string;
122
+ userEmail: string;
123
+ amount: number;
124
+ currency: string;
125
+ }): Promise<{ externalId: string; url: string }> {
126
+ const apiKey = env.NOW_PAYMENTS_API_KEY;
127
+ if (!apiKey) throw new Error("NOWPayments is not configured.");
128
+ const priceAmount = (input.amount / 100).toFixed(2);
129
+ const res = await fetch("https://api.nowpayments.io/v1/invoice", {
130
+ method: "POST",
131
+ headers: {
132
+ "content-type": "application/json",
133
+ "x-api-key": apiKey,
134
+ },
135
+ body: JSON.stringify({
136
+ price_amount: Number(priceAmount),
137
+ price_currency: input.currency,
138
+ order_id: `demo-${input.userId}-${Date.now()}`,
139
+ order_description: "Demo purchase",
140
+ ipn_callback_url: `${env.APP_ORIGIN}/api/webhooks/nowpayments`,
141
+ success_url: `${env.APP_ORIGIN}/dashboard?checkout=success`,
142
+ cancel_url: `${env.APP_ORIGIN}/dashboard?checkout=cancel`,
143
+ }),
144
+ });
145
+ if (!res.ok) {
146
+ const text = await res.text();
147
+ throw new Error(`NOWPayments invoice failed: ${res.status} ${text}`);
148
+ }
149
+ const data = (await res.json()) as {
150
+ id?: string | number;
151
+ invoice_url?: string;
152
+ };
153
+ if (data.id == null || !data.invoice_url) {
154
+ throw new Error("NOWPayments returned no invoice URL.");
155
+ }
156
+ return { externalId: String(data.id), url: data.invoice_url };
157
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Runtime payment method toggles stored in system_config.
3
+ * Disabled rails are refused server-side, not merely hidden.
4
+ */
5
+ import { eq } from "drizzle-orm";
6
+ import type { Database } from "@/db";
7
+ import { systemConfig } from "@/db/schema";
8
+ import type { PurchaseProvider } from "@/services/purchase";
9
+
10
+ const KEY = "payment_toggles";
11
+
12
+ export type PaymentToggles = Record<PurchaseProvider, boolean>;
13
+
14
+ const DEFAULT_TOGGLES: PaymentToggles = {
15
+ stripe: true,
16
+ creem: true,
17
+ nowpayments: true,
18
+ };
19
+
20
+ export async function getPaymentToggles(db: Database): Promise<PaymentToggles> {
21
+ const [row] = await db
22
+ .select()
23
+ .from(systemConfig)
24
+ .where(eq(systemConfig.key, KEY))
25
+ .limit(1);
26
+ if (!row || typeof row.value !== "object" || row.value === null) {
27
+ return { ...DEFAULT_TOGGLES };
28
+ }
29
+ const v = row.value as Partial<PaymentToggles>;
30
+ return {
31
+ stripe: v.stripe !== false,
32
+ creem: v.creem !== false,
33
+ nowpayments: v.nowpayments !== false,
34
+ };
35
+ }
36
+
37
+ export async function setPaymentToggles(
38
+ db: Database,
39
+ toggles: PaymentToggles,
40
+ ): Promise<void> {
41
+ await db
42
+ .insert(systemConfig)
43
+ .values({ key: KEY, value: toggles })
44
+ .onConflictDoUpdate({
45
+ target: systemConfig.key,
46
+ set: { value: toggles, updatedAt: new Date() },
47
+ });
48
+ }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Purchase fulfillment: idempotent mark-paid with the purchase-paid hook
3
+ * running inside the same transaction as the pending→paid flip.
4
+ *
5
+ * Product domain implements fulfillment by replacing `onPurchasePaid`.
6
+ * Adapters (Stripe/Creem/NowPayments) call `markPurchasePaid` after verifying
7
+ * the webhook; they never invent balances or entitlements, and they trust the
8
+ * Purchase row's amount and user rather than the numbers in the callback.
9
+ */
10
+ import { and, eq } from "drizzle-orm";
11
+ import type { Database } from "@/db";
12
+ import type { JsonValue } from "@/db/platform.schema";
13
+ import { adminAction, purchase as purchaseTable } from "@/db/schema";
14
+
15
+ export type PurchaseProvider = "stripe" | "creem" | "nowpayments";
16
+
17
+ export type PurchaseStatus = "pending" | "paid" | "failed";
18
+
19
+ export type Purchase = {
20
+ id: string;
21
+ userId: string;
22
+ provider: PurchaseProvider;
23
+ externalId: string;
24
+ /** Trusted amount in minor units (e.g. cents). */
25
+ amount: number;
26
+ currency: string;
27
+ status: PurchaseStatus;
28
+ paidAt: Date | null;
29
+ };
30
+
31
+ export type MarkPurchasePaidResult =
32
+ | { status: "fulfilled"; purchase: Purchase }
33
+ | { status: "already_paid"; purchase: Purchase }
34
+ | { status: "not_found" }
35
+ | { status: "not_pending"; purchase: Purchase }
36
+ | { status: "amount_mismatch"; purchase: Purchase };
37
+
38
+ type PurchaseAuditEntry = {
39
+ action: "purchase.paid" | "purchase.failed" | "purchase.duplicate_ignored";
40
+ targetId?: string;
41
+ detail?: Record<string, JsonValue>;
42
+ };
43
+
44
+ /** Parse a minor-unit amount stored as decimal text (no float parse). */
45
+ function parseMinorUnits(amount: string): number {
46
+ if (!/^-?\d+$/.test(amount)) {
47
+ throw new Error(`Invalid purchase amount: ${amount}`);
48
+ }
49
+ const n = Number.parseInt(amount, 10);
50
+ if (!Number.isSafeInteger(n)) {
51
+ throw new Error(`Purchase amount outside safe integer range: ${amount}`);
52
+ }
53
+ return n;
54
+ }
55
+
56
+ function rowToPurchase(row: typeof purchaseTable.$inferSelect): Purchase {
57
+ return {
58
+ id: row.id,
59
+ userId: row.userId,
60
+ provider: row.provider as PurchaseProvider,
61
+ externalId: row.externalId,
62
+ amount: parseMinorUnits(row.amount),
63
+ currency: row.currency,
64
+ status: row.status as PurchaseStatus,
65
+ paidAt: row.paidAt,
66
+ };
67
+ }
68
+
69
+ async function findPurchase(
70
+ db: Database,
71
+ provider: PurchaseProvider,
72
+ externalId: string,
73
+ ): Promise<Purchase | null> {
74
+ const [row] = await db
75
+ .select()
76
+ .from(purchaseTable)
77
+ .where(
78
+ and(
79
+ eq(purchaseTable.provider, provider),
80
+ eq(purchaseTable.externalId, externalId),
81
+ ),
82
+ )
83
+ .limit(1);
84
+ return row ? rowToPurchase(row) : null;
85
+ }
86
+
87
+ /**
88
+ * The single extension point a product domain implements. Default: no-op.
89
+ *
90
+ * It runs **inside the transaction that flips the row to `paid`**, so a throw
91
+ * rolls the flip back, `markPurchasePaid` rethrows, the route answers 5xx, and
92
+ * the provider's retry re-enters and delivers exactly once. That is the whole
93
+ * reason the hook receives `tx`.
94
+ *
95
+ * Because it shares that transaction it must do database work only. An HTTP
96
+ * call or an email send here holds a Postgres connection open across the
97
+ * network (the deadlock rule in ADR 0006, and Hyperdrive cannot multiplex a
98
+ * held connection) and couples the provider's retry window to its latency. If
99
+ * fulfillment needs IO, write a row here and do the IO afterwards.
100
+ */
101
+ export async function onPurchasePaid(
102
+ _tx: Database,
103
+ _purchase: Purchase,
104
+ ): Promise<void> {
105
+ // Product domain implements entitlements here.
106
+ }
107
+
108
+ /**
109
+ * Idempotent payment fulfillment. Amounts and user identity come only from the
110
+ * Purchase row — never from callback-reported amounts alone (optional
111
+ * `reportedAmount` is an integrity check that fails closed on mismatch).
112
+ *
113
+ * Throws only on a genuine processing/DB error — including a throw from
114
+ * `hook` — so the caller can answer 5xx and the provider retries. Every mapped
115
+ * outcome resolves, so the caller answers 200 and the provider stops retrying.
116
+ */
117
+ export async function markPurchasePaid(
118
+ db: Database,
119
+ input: {
120
+ provider: PurchaseProvider;
121
+ externalId: string;
122
+ /** Optional integrity check against the local Purchase amount. */
123
+ reportedAmount?: number;
124
+ },
125
+ hook: (tx: Database, purchase: Purchase) => Promise<void> = onPurchasePaid,
126
+ ): Promise<MarkPurchasePaidResult> {
127
+ const existing = await findPurchase(db, input.provider, input.externalId);
128
+ if (!existing) return { status: "not_found" };
129
+ if (existing.status === "paid") {
130
+ await safeAudit(db, {
131
+ action: "purchase.duplicate_ignored",
132
+ targetId: existing.id,
133
+ detail: { provider: input.provider, externalId: input.externalId },
134
+ });
135
+ return { status: "already_paid", purchase: existing };
136
+ }
137
+ if (existing.status !== "pending") {
138
+ return { status: "not_pending", purchase: existing };
139
+ }
140
+ if (
141
+ input.reportedAmount !== undefined &&
142
+ input.reportedAmount !== existing.amount
143
+ ) {
144
+ return { status: "amount_mismatch", purchase: existing };
145
+ }
146
+
147
+ const now = new Date();
148
+ const paid = await db.transaction(async (tx) => {
149
+ const flipped = await tx
150
+ .update(purchaseTable)
151
+ .set({ status: "paid", paidAt: now })
152
+ .where(
153
+ and(
154
+ eq(purchaseTable.id, existing.id),
155
+ eq(purchaseTable.status, "pending"),
156
+ ),
157
+ )
158
+ .returning();
159
+ // Lost the race: a concurrent confirmation already fulfilled this row.
160
+ if (!flipped[0]) return null;
161
+ const row = rowToPurchase(flipped[0]);
162
+ await hook(tx as unknown as Database, row);
163
+ return row;
164
+ });
165
+
166
+ if (!paid) {
167
+ const again = await findPurchase(db, input.provider, input.externalId);
168
+ if (again?.status === "paid") {
169
+ await safeAudit(db, {
170
+ action: "purchase.duplicate_ignored",
171
+ targetId: again.id,
172
+ });
173
+ return { status: "already_paid", purchase: again };
174
+ }
175
+ return { status: "not_pending", purchase: existing };
176
+ }
177
+
178
+ // After the commit, never inside it. Audit writes are best-effort: a logging
179
+ // failure must not roll back a payment that has already been delivered.
180
+ await safeAudit(db, {
181
+ action: "purchase.paid",
182
+ targetId: paid.id,
183
+ detail: {
184
+ provider: paid.provider,
185
+ externalId: paid.externalId,
186
+ amount: paid.amount,
187
+ currency: paid.currency,
188
+ userId: paid.userId,
189
+ },
190
+ });
191
+ return { status: "fulfilled", purchase: paid };
192
+ }
193
+
194
+ export async function createPendingPurchase(
195
+ db: Database,
196
+ input: {
197
+ userId: string;
198
+ provider: PurchaseProvider;
199
+ externalId: string;
200
+ amount: number;
201
+ currency?: string;
202
+ },
203
+ ): Promise<{ purchaseId: string }> {
204
+ const id = crypto.randomUUID();
205
+ await db.insert(purchaseTable).values({
206
+ id,
207
+ userId: input.userId,
208
+ provider: input.provider,
209
+ externalId: input.externalId,
210
+ amount: String(input.amount),
211
+ currency: input.currency ?? "usd",
212
+ status: "pending",
213
+ });
214
+ return { purchaseId: id };
215
+ }
216
+
217
+ async function safeAudit(
218
+ db: Database,
219
+ entry: PurchaseAuditEntry,
220
+ ): Promise<void> {
221
+ try {
222
+ await db.insert(adminAction).values({
223
+ id: crypto.randomUUID(),
224
+ actorId: null,
225
+ action: entry.action,
226
+ targetType: "purchase",
227
+ targetId: entry.targetId ?? null,
228
+ detail: entry.detail ?? null,
229
+ });
230
+ } catch (e) {
231
+ console.error("purchase audit log write failed", e);
232
+ }
233
+ }