@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,25 @@
1
+ import { spawn } from "node:child_process";
2
+ import { readFile, rm, writeFile } from "node:fs/promises";
3
+ const temporary = "src/lib/.mystack-auth.ts";
4
+ let source = (await readFile("src/lib/auth.ts", "utf8"))
5
+ .replace('import { waitUntil } from "cloudflare:workers";', 'const waitUntil = (promise: Promise<unknown>) => { void promise; };')
6
+ .replaceAll("import.meta.env", '({ DEV: true, VITE_GITHUB_CLIENT_ID: undefined })');
7
+ // The offline generator reads plugin metadata without a connected database.
8
+ // Runtime auth keeps schema validation enabled.
9
+ source = source.includes("\t\tadvanced: {\n")
10
+ ? source.replace("\t\tadvanced: {\n", "\t\tadvanced: {\n\t\t\tdatabase: { validateSchema: false },\n")
11
+ : source.replace("\t\ttelemetry: { enabled: false },", "\t\ttelemetry: { enabled: false },\n\t\tadvanced: { database: { validateSchema: false } },");
12
+ source += "\nexport const auth = createAuth({} as unknown as Database);\n";
13
+ await writeFile(temporary, source, { flag: "wx" });
14
+ try {
15
+ await new Promise((resolve, reject) => {
16
+ const child = spawn("bun", ["x", "auth@1.7.3", "generate", "--config", temporary, "--output", "./src/db/auth.schema.ts", "--yes"], { stdio: "inherit" });
17
+ child.on("error", reject);
18
+ child.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`Auth schema generation failed (${code})`)));
19
+ });
20
+ await new Promise((resolve, reject) => {
21
+ const child = spawn("bun", ["x", "biome", "check", "--write", "src/db/auth.schema.ts"], { stdio: "inherit" });
22
+ child.on("error", reject);
23
+ child.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`Auth schema formatting failed (${code})`)));
24
+ });
25
+ } finally { await rm(temporary, { force: true }); }
@@ -0,0 +1,46 @@
1
+ import { sql } from "drizzle-orm";
2
+ import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
3
+ import { user } from "./auth.sqlite.schema";
4
+
5
+ export type JsonValue = string | number | boolean | null | { [key: string]: JsonValue } | JsonValue[];
6
+ const createdAt = () => integer("created_at", { mode: "timestamp_ms" }).default(sql`(unixepoch('subsec') * 1000)`).notNull();
7
+ const updatedAt = () => integer("updated_at", { mode: "timestamp_ms" }).default(sql`(unixepoch('subsec') * 1000)`).$onUpdate(() => new Date()).notNull();
8
+ export const systemConfig = sqliteTable("system_config", {
9
+ key: text("key").primaryKey(),
10
+ value: text("value", { mode: "json" }).$type<JsonValue>().notNull(),
11
+ updatedAt: updatedAt(),
12
+ });
13
+ export const adminAction = sqliteTable("admin_action", {
14
+ id: text("id").primaryKey(),
15
+ actorId: text("actor_id").references(() => user.id, { onDelete: "set null" }),
16
+ action: text("action").notNull(),
17
+ targetType: text("target_type"),
18
+ targetId: text("target_id"),
19
+ detail: text("detail", { mode: "json" }).$type<JsonValue>(),
20
+ createdAt: createdAt(),
21
+ }, (table) => [index("admin_action_created_idx").on(table.createdAt)]);
22
+ export const purchase = sqliteTable("purchase", {
23
+ id: text("id").primaryKey(),
24
+ userId: text("user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
25
+ provider: text("provider").notNull(),
26
+ externalId: text("external_id").notNull(),
27
+ amount: text("amount").notNull(),
28
+ currency: text("currency").notNull().default("usd"),
29
+ status: text("status").notNull().default("pending"),
30
+ metadata: text("metadata", { mode: "json" }).$type<JsonValue>(),
31
+ paidAt: integer("paid_at", { mode: "timestamp_ms" }),
32
+ fulfillmentKey: text("fulfillment_key"),
33
+ createdAt: createdAt(),
34
+ updatedAt: updatedAt(),
35
+ }, (table) => [uniqueIndex("purchase_provider_external_uq").on(table.provider, table.externalId), index("purchase_userId_idx").on(table.userId)]);
36
+ // Durable paid events. D1 commits this row in the same batch as the paid flip.
37
+ // Product-specific fulfillment consumes these events with purchaseId as its
38
+ // idempotency key; no arbitrary async callback pretends to be a D1 transaction.
39
+ export const purchaseDelivery = sqliteTable("purchase_delivery", {
40
+ purchaseId: text("purchase_id").primaryKey().references(() => purchase.id, { onDelete: "cascade" }),
41
+ createdAt: createdAt(),
42
+ processedAt: integer("processed_at", { mode: "timestamp_ms" }),
43
+ });
44
+ export type SystemConfigRow = typeof systemConfig.$inferSelect;
45
+ export type AdminActionRow = typeof adminAction.$inferSelect;
46
+ export type PurchaseRow = typeof purchase.$inferSelect;
@@ -0,0 +1,10 @@
1
+ async function createPolarCheckout(input: { userId: string; userEmail: string; amount: number; currency: string }): Promise<{ externalId: string; url: string }> {
2
+ const { createPolar } = await import("@polar-sh/sdk/2026-10");
3
+ if (!env.POLAR_ACCESS_TOKEN || !env.POLAR_PRODUCT_ID) throw new Error("Polar is not configured.");
4
+ const polar = createPolar({ accessToken: env.POLAR_ACCESS_TOKEN, environment: env.POLAR_SANDBOX ? "sandbox" : "production" });
5
+ const checkout = await polar.checkouts.create({ products: [env.POLAR_PRODUCT_ID], customer_email: input.userEmail, external_customer_id: input.userId, success_url: `${env.APP_ORIGIN}/dashboard?checkout=success`, return_url: `${env.APP_ORIGIN}/dashboard?checkout=cancel` });
6
+ // The configured catalog price must match the local purchase ledger. Do not
7
+ // issue a pending purchase with an invented price for a fixed-price product.
8
+ if (checkout.amount !== input.amount || checkout.currency.toLowerCase() !== input.currency.toLowerCase()) throw new Error("Polar product price must match the demo purchase price and currency.");
9
+ return { externalId: checkout.id, url: checkout.url };
10
+ }
@@ -0,0 +1,19 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { withRlsService } from "@/db/helper";
3
+ import { env } from "@/env";
4
+ import { webhookBodyLimitMiddleware } from "@/middlewares/body-limit";
5
+ import { databaseMiddleware } from "@/middlewares/database";
6
+ import { handlePolarWebhook } from "@/services/polar";
7
+ import { methodNotAllowed } from "@/utils/api-error";
8
+ export const Route = createFileRoute("/api/webhooks/polar")({
9
+ server: { handlers: ({ createHandlers }) => createHandlers({
10
+ GET: { handler: () => methodNotAllowed("POST") },
11
+ POST: { middleware: [databaseMiddleware, webhookBodyLimitMiddleware], handler: async ({ request, context }) => {
12
+ const secret = env.POLAR_WEBHOOK_SECRET;
13
+ if (!secret) return Response.json({ error: "not_configured" }, { status: 503 });
14
+ const headers = Object.fromEntries(request.headers.entries());
15
+ const result = await withRlsService(context.db, (db) => handlePolarWebhook(db, { rawBody: context.rawBody, headers, secret }));
16
+ return Response.json({ result }, { status: result === "invalid_signature" || result === "invalid_payload" ? 400 : 200 });
17
+ } },
18
+ }) },
19
+ });
@@ -0,0 +1,30 @@
1
+ import { createHmac } from "node:crypto";
2
+ import { beforeEach, expect, test, vi } from "vitest";
3
+ import type { Database } from "@/db";
4
+ import { handlePolarWebhook } from "@/services/polar";
5
+ import { markPurchasePaid } from "@/services/purchase";
6
+ vi.mock("@/services/purchase", () => ({ markPurchasePaid: vi.fn() }));
7
+ const db = {} as Database;
8
+ const secret = "whsec_test-signing-secret";
9
+ function signed(rawBody: string) {
10
+ const id = "test-event";
11
+ const timestamp = String(Math.floor(Date.now() / 1000));
12
+ const signature = createHmac("sha256", secret).update(`${id}.${timestamp}.${rawBody}`).digest("base64");
13
+ return { "webhook-id": id, "webhook-timestamp": timestamp, "webhook-signature": `v1,${signature}` };
14
+ }
15
+ beforeEach(() => vi.clearAllMocks());
16
+ test("invalid signatures never reach the purchase ledger", async () => {
17
+ expect(await handlePolarWebhook(db, { rawBody: "{}", headers: {}, secret })).toBe("invalid_signature");
18
+ expect(markPurchasePaid).not.toHaveBeenCalled();
19
+ });
20
+ test("verified paid events map the checkout id and subtotal to the ledger", async () => {
21
+ vi.mocked(markPurchasePaid).mockResolvedValue({ status: "not_found" });
22
+ const rawBody = JSON.stringify({ type: "order.paid", timestamp: new Date().toISOString(), data: { checkout_id: "checkout-1", subtotal_amount: 500 } });
23
+ expect(await handlePolarWebhook(db, { rawBody, headers: signed(rawBody), secret })).toBe("unknown_order");
24
+ expect(markPurchasePaid).toHaveBeenCalledWith(db, { provider: "polar", externalId: "checkout-1", reportedAmount: 500 });
25
+ });
26
+ test("a signed malformed payload is refused without ledger effects", async () => {
27
+ const rawBody = "{invalid-json";
28
+ expect(await handlePolarWebhook(db, { rawBody, headers: signed(rawBody), secret })).toBe("invalid_payload");
29
+ expect(markPurchasePaid).not.toHaveBeenCalled();
30
+ });
@@ -0,0 +1,12 @@
1
+ import { webhooks } from "@polar-sh/sdk/2026-10";
2
+ import type { Database } from "@/db";
3
+ import { markPurchasePaid } from "./purchase";
4
+ export async function handlePolarWebhook(db: Database, input: { rawBody: string; headers: Record<string, string>; secret: string }) {
5
+ let event: Awaited<ReturnType<typeof webhooks.validateEvent>>;
6
+ try { event = await webhooks.validateEvent(input.rawBody, input.headers, input.secret); }
7
+ catch (error) { if (error instanceof webhooks.PolarWebhookVerificationError) return "invalid_signature"; if (error instanceof webhooks.PolarWebhookError) return "invalid_payload"; throw error; }
8
+ if (event.type !== "order.paid") return "ignored";
9
+ if (!event.data.checkout_id) return "unknown_order";
10
+ const result = await markPurchasePaid(db, { provider: "polar", externalId: event.data.checkout_id, reportedAmount: event.data.subtotal_amount });
11
+ return result.status === "not_found" ? "unknown_order" : result.status;
12
+ }
@@ -0,0 +1,38 @@
1
+ import { and, eq } from "drizzle-orm";
2
+ import type { Database } from "@/db";
3
+ import { purchaseDelivery, purchase as purchaseTable } from "@/db/schema";
4
+ export type PurchaseProvider = "stripe" | "creem" | "nowpayments";
5
+ export type PurchaseStatus = "pending" | "paid" | "failed";
6
+ export type Purchase = { id: string; userId: string; provider: PurchaseProvider; externalId: string; amount: number; currency: string; status: PurchaseStatus; paidAt: Date | null };
7
+ export type MarkPurchasePaidResult =
8
+ | { status: "fulfilled" | "already_paid" | "not_pending" | "amount_mismatch"; purchase: Purchase }
9
+ | { status: "not_found" };
10
+ function rowToPurchase(row: typeof purchaseTable.$inferSelect): Purchase {
11
+ if (!/^\d+$/.test(row.amount) || !Number.isSafeInteger(Number(row.amount))) throw new Error("Invalid purchase amount.");
12
+ return { id: row.id, userId: row.userId, provider: row.provider as PurchaseProvider, externalId: row.externalId, amount: Number(row.amount), currency: row.currency, status: row.status as PurchaseStatus, paidAt: row.paidAt };
13
+ }
14
+ export async function markPurchasePaid(db: Database, input: { provider: PurchaseProvider; externalId: string; reportedAmount?: number }): Promise<MarkPurchasePaidResult> {
15
+ const where = and(eq(purchaseTable.provider, input.provider), eq(purchaseTable.externalId, input.externalId));
16
+ const [existing] = await db.select().from(purchaseTable).where(where).limit(1);
17
+ if (!existing) return { status: "not_found" };
18
+ const purchase = rowToPurchase(existing);
19
+ if (purchase.status === "paid") return { status: "already_paid", purchase };
20
+ if (purchase.status !== "pending") return { status: "not_pending", purchase };
21
+ if (input.reportedAmount !== undefined && purchase.amount !== input.reportedAmount) return { status: "amount_mismatch", purchase };
22
+ const claim = crypto.randomUUID();
23
+ const now = new Date();
24
+ // The paid flip and delivery event commit together in a synchronous transaction.
25
+ const flipped = db.transaction((tx) => {
26
+ const row = tx.update(purchaseTable).set({ status: "paid", paidAt: now, fulfillmentKey: claim }).where(and(where, eq(purchaseTable.status, "pending"))).returning().get();
27
+ if (row) tx.insert(purchaseDelivery).values({ purchaseId: row.id, createdAt: now }).run();
28
+ return row;
29
+ }, { behavior: "immediate" });
30
+ if (flipped) return { status: "fulfilled", purchase: rowToPurchase(flipped) };
31
+ const [again] = await db.select().from(purchaseTable).where(where).limit(1);
32
+ return { status: again?.status === "paid" ? "already_paid" : "not_pending", purchase: again ? rowToPurchase(again) : purchase };
33
+ }
34
+ export async function createPendingPurchase(db: Database, input: { userId: string; provider: PurchaseProvider; externalId: string; amount: number; currency?: string }): Promise<{ purchaseId: string }> {
35
+ const id = crypto.randomUUID();
36
+ await db.insert(purchaseTable).values({ id, userId: input.userId, provider: input.provider, externalId: input.externalId, amount: String(input.amount), currency: input.currency ?? "usd", status: "pending" });
37
+ return { purchaseId: id };
38
+ }
@@ -0,0 +1,39 @@
1
+ import { and, eq, sql } from "drizzle-orm";
2
+ import type { Database } from "@/db";
3
+ import { purchaseDelivery, purchase as purchaseTable } from "@/db/schema";
4
+ export type PurchaseProvider = "stripe" | "creem" | "nowpayments";
5
+ export type PurchaseStatus = "pending" | "paid" | "failed";
6
+ export type Purchase = { id: string; userId: string; provider: PurchaseProvider; externalId: string; amount: number; currency: string; status: PurchaseStatus; paidAt: Date | null };
7
+ export type MarkPurchasePaidResult =
8
+ | { status: "fulfilled" | "already_paid" | "not_pending" | "amount_mismatch"; purchase: Purchase }
9
+ | { status: "not_found" };
10
+ function rowToPurchase(row: typeof purchaseTable.$inferSelect): Purchase {
11
+ if (!/^\d+$/.test(row.amount) || !Number.isSafeInteger(Number(row.amount))) throw new Error("Invalid purchase amount.");
12
+ return { id: row.id, userId: row.userId, provider: row.provider as PurchaseProvider, externalId: row.externalId, amount: Number(row.amount), currency: row.currency, status: row.status as PurchaseStatus, paidAt: row.paidAt };
13
+ }
14
+ export async function markPurchasePaid(db: Database, input: { provider: PurchaseProvider; externalId: string; reportedAmount?: number }): Promise<MarkPurchasePaidResult> {
15
+ const where = and(eq(purchaseTable.provider, input.provider), eq(purchaseTable.externalId, input.externalId));
16
+ const [existing] = await db.select().from(purchaseTable).where(where).limit(1);
17
+ if (!existing) return { status: "not_found" };
18
+ const purchase = rowToPurchase(existing);
19
+ if (purchase.status === "paid") return { status: "already_paid", purchase };
20
+ if (purchase.status !== "pending") return { status: "not_pending", purchase };
21
+ if (input.reportedAmount !== undefined && purchase.amount !== input.reportedAmount) return { status: "amount_mismatch", purchase };
22
+ const claim = crypto.randomUUID();
23
+ const now = new Date();
24
+ // SQLite driver batches are atomic. Only the invocation that flips this pending row
25
+ // owns the claim and can insert the durable delivery event. A failed insert
26
+ // rolls back the flip, so the webhook's retry can try again safely.
27
+ const [flipped] = await db.batch([
28
+ db.update(purchaseTable).set({ status: "paid", paidAt: now, fulfillmentKey: claim }).where(and(where, eq(purchaseTable.status, "pending"))).returning(),
29
+ db.insert(purchaseDelivery).select(sql`SELECT id, ${now.getTime()}, NULL FROM purchase WHERE id = ${existing.id} AND fulfillment_key = ${claim}`),
30
+ ]);
31
+ if (flipped[0]) return { status: "fulfilled", purchase: rowToPurchase(flipped[0]) };
32
+ const [again] = await db.select().from(purchaseTable).where(where).limit(1);
33
+ return { status: again?.status === "paid" ? "already_paid" : "not_pending", purchase: again ? rowToPurchase(again) : purchase };
34
+ }
35
+ export async function createPendingPurchase(db: Database, input: { userId: string; provider: PurchaseProvider; externalId: string; amount: number; currency?: string }): Promise<{ purchaseId: string }> {
36
+ const id = crypto.randomUUID();
37
+ await db.insert(purchaseTable).values({ id, userId: input.userId, provider: input.provider, externalId: input.externalId, amount: String(input.amount), currency: input.currency ?? "usd", status: "pending" });
38
+ return { purchaseId: id };
39
+ }
@@ -0,0 +1,295 @@
1
+ CREATE TABLE `account` (
2
+ `id` text PRIMARY KEY NOT NULL,
3
+ `account_id` text NOT NULL,
4
+ `provider_id` text NOT NULL,
5
+ `user_id` text NOT NULL,
6
+ `access_token` text,
7
+ `refresh_token` text,
8
+ `id_token` text,
9
+ `access_token_expires_at` integer,
10
+ `refresh_token_expires_at` integer,
11
+ `scope` text,
12
+ `password` text,
13
+ `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
14
+ `updated_at` integer NOT NULL,
15
+ FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
16
+ );
17
+ --> statement-breakpoint
18
+ CREATE INDEX `account_userId_idx` ON `account` (`user_id`);--> statement-breakpoint
19
+ CREATE TABLE `jwks` (
20
+ `id` text PRIMARY KEY NOT NULL,
21
+ `public_key` text NOT NULL,
22
+ `private_key` text NOT NULL,
23
+ `created_at` integer NOT NULL,
24
+ `expires_at` integer,
25
+ `alg` text,
26
+ `crv` text
27
+ );
28
+ --> statement-breakpoint
29
+ CREATE TABLE `oauth_access_token` (
30
+ `id` text PRIMARY KEY NOT NULL,
31
+ `token` text NOT NULL,
32
+ `client_id` text NOT NULL,
33
+ `session_id` text,
34
+ `user_id` text,
35
+ `reference_id` text,
36
+ `authorization_code_id` text,
37
+ `resources` text,
38
+ `requested_user_info_claims` text,
39
+ `refresh_id` text,
40
+ `expires_at` integer NOT NULL,
41
+ `created_at` integer NOT NULL,
42
+ `revoked` integer,
43
+ `confirmation` text,
44
+ `scopes` text NOT NULL,
45
+ FOREIGN KEY (`client_id`) REFERENCES `oauth_client`(`client_id`) ON UPDATE no action ON DELETE cascade,
46
+ FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON UPDATE no action ON DELETE set null,
47
+ FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
48
+ FOREIGN KEY (`refresh_id`) REFERENCES `oauth_refresh_token`(`id`) ON UPDATE no action ON DELETE cascade
49
+ );
50
+ --> statement-breakpoint
51
+ CREATE UNIQUE INDEX `oauth_access_token_token_unique` ON `oauth_access_token` (`token`);--> statement-breakpoint
52
+ CREATE INDEX `oauthAccessToken_clientId_idx` ON `oauth_access_token` (`client_id`);--> statement-breakpoint
53
+ CREATE INDEX `oauthAccessToken_sessionId_idx` ON `oauth_access_token` (`session_id`);--> statement-breakpoint
54
+ CREATE INDEX `oauthAccessToken_userId_idx` ON `oauth_access_token` (`user_id`);--> statement-breakpoint
55
+ CREATE INDEX `oauthAccessToken_authorizationCodeId_idx` ON `oauth_access_token` (`authorization_code_id`);--> statement-breakpoint
56
+ CREATE INDEX `oauthAccessToken_refreshId_idx` ON `oauth_access_token` (`refresh_id`);--> statement-breakpoint
57
+ CREATE TABLE `oauth_client` (
58
+ `id` text PRIMARY KEY NOT NULL,
59
+ `client_id` text NOT NULL,
60
+ `client_secret` text,
61
+ `client_discovery_id` text,
62
+ `disabled` integer DEFAULT false,
63
+ `skip_consent` integer,
64
+ `enable_end_session` integer,
65
+ `subject_type` text,
66
+ `scopes` text,
67
+ `client_credentials_scopes` text DEFAULT '[]',
68
+ `user_id` text,
69
+ `created_at` integer,
70
+ `updated_at` integer,
71
+ `name` text,
72
+ `uri` text,
73
+ `icon` text,
74
+ `contacts` text,
75
+ `tos` text,
76
+ `policy` text,
77
+ `software_id` text,
78
+ `software_version` text,
79
+ `software_statement` text,
80
+ `redirect_uris` text NOT NULL,
81
+ `post_logout_redirect_uris` text,
82
+ `backchannel_logout_uri` text,
83
+ `backchannel_logout_session_required` integer,
84
+ `token_endpoint_auth_method` text,
85
+ `application_type` text,
86
+ `jwks` text,
87
+ `jwks_uri` text,
88
+ `grant_types` text,
89
+ `response_types` text,
90
+ `require_pkce` integer,
91
+ `dpop_bound_access_tokens` integer DEFAULT false,
92
+ `reference_id` text,
93
+ `metadata` text,
94
+ FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
95
+ );
96
+ --> statement-breakpoint
97
+ CREATE UNIQUE INDEX `oauth_client_client_id_unique` ON `oauth_client` (`client_id`);--> statement-breakpoint
98
+ CREATE INDEX `oauthClient_userId_idx` ON `oauth_client` (`user_id`);--> statement-breakpoint
99
+ CREATE TABLE `oauth_client_assertion` (
100
+ `id` text PRIMARY KEY NOT NULL,
101
+ `expires_at` integer NOT NULL
102
+ );
103
+ --> statement-breakpoint
104
+ CREATE TABLE `oauth_client_resource` (
105
+ `id` text PRIMARY KEY NOT NULL,
106
+ `client_id` text NOT NULL,
107
+ `resource_id` text NOT NULL,
108
+ `metadata` text,
109
+ `created_at` integer,
110
+ FOREIGN KEY (`client_id`) REFERENCES `oauth_client`(`client_id`) ON UPDATE no action ON DELETE cascade,
111
+ FOREIGN KEY (`resource_id`) REFERENCES `oauth_resource`(`identifier`) ON UPDATE no action ON DELETE cascade
112
+ );
113
+ --> statement-breakpoint
114
+ CREATE UNIQUE INDEX `oauthClientResource_clientId_resourceId_uidx` ON `oauth_client_resource` (`client_id`,`resource_id`);--> statement-breakpoint
115
+ CREATE INDEX `oauthClientResource_clientId_idx` ON `oauth_client_resource` (`client_id`);--> statement-breakpoint
116
+ CREATE INDEX `oauthClientResource_resourceId_idx` ON `oauth_client_resource` (`resource_id`);--> statement-breakpoint
117
+ CREATE TABLE `oauth_consent` (
118
+ `id` text PRIMARY KEY NOT NULL,
119
+ `client_id` text NOT NULL,
120
+ `user_id` text,
121
+ `reference_id` text,
122
+ `resources` text,
123
+ `requested_user_info_claims` text,
124
+ `scopes` text NOT NULL,
125
+ `created_at` integer NOT NULL,
126
+ `updated_at` integer NOT NULL,
127
+ FOREIGN KEY (`client_id`) REFERENCES `oauth_client`(`client_id`) ON UPDATE no action ON DELETE cascade,
128
+ FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
129
+ );
130
+ --> statement-breakpoint
131
+ CREATE INDEX `oauthConsent_clientId_idx` ON `oauth_consent` (`client_id`);--> statement-breakpoint
132
+ CREATE INDEX `oauthConsent_userId_idx` ON `oauth_consent` (`user_id`);--> statement-breakpoint
133
+ CREATE TABLE `oauth_refresh_token` (
134
+ `id` text PRIMARY KEY NOT NULL,
135
+ `token` text NOT NULL,
136
+ `client_id` text NOT NULL,
137
+ `session_id` text,
138
+ `user_id` text NOT NULL,
139
+ `reference_id` text,
140
+ `authorization_code_id` text,
141
+ `resources` text,
142
+ `requested_user_info_claims` text,
143
+ `expires_at` integer NOT NULL,
144
+ `created_at` integer NOT NULL,
145
+ `revoked` integer,
146
+ `rotated_at` integer,
147
+ `rotation_replay_response` text,
148
+ `rotation_replay_expires_at` integer,
149
+ `auth_time` integer,
150
+ `confirmation` text,
151
+ `scopes` text NOT NULL,
152
+ FOREIGN KEY (`client_id`) REFERENCES `oauth_client`(`client_id`) ON UPDATE no action ON DELETE cascade,
153
+ FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON UPDATE no action ON DELETE set null,
154
+ FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
155
+ );
156
+ --> statement-breakpoint
157
+ CREATE UNIQUE INDEX `oauth_refresh_token_token_unique` ON `oauth_refresh_token` (`token`);--> statement-breakpoint
158
+ CREATE INDEX `oauthRefreshToken_clientId_idx` ON `oauth_refresh_token` (`client_id`);--> statement-breakpoint
159
+ CREATE INDEX `oauthRefreshToken_sessionId_idx` ON `oauth_refresh_token` (`session_id`);--> statement-breakpoint
160
+ CREATE INDEX `oauthRefreshToken_userId_idx` ON `oauth_refresh_token` (`user_id`);--> statement-breakpoint
161
+ CREATE INDEX `oauthRefreshToken_authorizationCodeId_idx` ON `oauth_refresh_token` (`authorization_code_id`);--> statement-breakpoint
162
+ CREATE TABLE `oauth_resource` (
163
+ `id` text PRIMARY KEY NOT NULL,
164
+ `identifier` text NOT NULL,
165
+ `name` text NOT NULL,
166
+ `access_token_ttl` integer,
167
+ `refresh_token_ttl` integer,
168
+ `signing_algorithm` text,
169
+ `signing_key_id` text,
170
+ `allowed_scopes` text,
171
+ `custom_claims` text,
172
+ `dpop_bound_access_tokens_required` integer DEFAULT false,
173
+ `disabled` integer DEFAULT false,
174
+ `created_at` integer,
175
+ `updated_at` integer,
176
+ `policy_version` integer DEFAULT 1,
177
+ `metadata` text
178
+ );
179
+ --> statement-breakpoint
180
+ CREATE UNIQUE INDEX `oauth_resource_identifier_unique` ON `oauth_resource` (`identifier`);--> statement-breakpoint
181
+ CREATE TABLE `passkey` (
182
+ `id` text PRIMARY KEY NOT NULL,
183
+ `name` text,
184
+ `public_key` text NOT NULL,
185
+ `user_id` text NOT NULL,
186
+ `credential_id` text NOT NULL,
187
+ `counter` integer NOT NULL,
188
+ `device_type` text NOT NULL,
189
+ `backed_up` integer NOT NULL,
190
+ `transports` text,
191
+ `created_at` integer,
192
+ `aaguid` text,
193
+ FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
194
+ );
195
+ --> statement-breakpoint
196
+ CREATE INDEX `passkey_userId_idx` ON `passkey` (`user_id`);--> statement-breakpoint
197
+ CREATE INDEX `passkey_credentialID_idx` ON `passkey` (`credential_id`);--> statement-breakpoint
198
+ CREATE TABLE `session` (
199
+ `id` text PRIMARY KEY NOT NULL,
200
+ `expires_at` integer NOT NULL,
201
+ `token` text NOT NULL,
202
+ `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
203
+ `updated_at` integer NOT NULL,
204
+ `ip_address` text,
205
+ `user_agent` text,
206
+ `user_id` text NOT NULL,
207
+ `impersonated_by` text,
208
+ FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
209
+ );
210
+ --> statement-breakpoint
211
+ CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> statement-breakpoint
212
+ CREATE INDEX `session_userId_idx` ON `session` (`user_id`);--> statement-breakpoint
213
+ CREATE TABLE `two_factor` (
214
+ `id` text PRIMARY KEY NOT NULL,
215
+ `secret` text NOT NULL,
216
+ `backup_codes` text NOT NULL,
217
+ `user_id` text NOT NULL,
218
+ `verified` integer DEFAULT true,
219
+ `failed_verification_count` integer DEFAULT 0,
220
+ `locked_until` integer,
221
+ FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
222
+ );
223
+ --> statement-breakpoint
224
+ CREATE INDEX `twoFactor_secret_idx` ON `two_factor` (`secret`);--> statement-breakpoint
225
+ CREATE INDEX `twoFactor_userId_idx` ON `two_factor` (`user_id`);--> statement-breakpoint
226
+ CREATE TABLE `user` (
227
+ `id` text PRIMARY KEY NOT NULL,
228
+ `name` text NOT NULL,
229
+ `email` text NOT NULL,
230
+ `email_verified` integer DEFAULT false NOT NULL,
231
+ `image` text,
232
+ `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
233
+ `updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
234
+ `role` text,
235
+ `banned` integer DEFAULT false,
236
+ `ban_reason` text,
237
+ `ban_expires` integer,
238
+ `two_factor_enabled` integer DEFAULT false,
239
+ `tokens_revoked_at` integer,
240
+ `locale` text
241
+ );
242
+ --> statement-breakpoint
243
+ CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint
244
+ CREATE TABLE `verification` (
245
+ `id` text PRIMARY KEY NOT NULL,
246
+ `identifier` text NOT NULL,
247
+ `value` text NOT NULL,
248
+ `expires_at` integer NOT NULL,
249
+ `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
250
+ `updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
251
+ );
252
+ --> statement-breakpoint
253
+ CREATE INDEX `verification_identifier_idx` ON `verification` (`identifier`);--> statement-breakpoint
254
+ CREATE TABLE `admin_action` (
255
+ `id` text PRIMARY KEY NOT NULL,
256
+ `actor_id` text,
257
+ `action` text NOT NULL,
258
+ `target_type` text,
259
+ `target_id` text,
260
+ `detail` text,
261
+ `created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
262
+ FOREIGN KEY (`actor_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null
263
+ );
264
+ --> statement-breakpoint
265
+ CREATE INDEX `admin_action_created_idx` ON `admin_action` (`created_at`);--> statement-breakpoint
266
+ CREATE TABLE `purchase` (
267
+ `id` text PRIMARY KEY NOT NULL,
268
+ `user_id` text NOT NULL,
269
+ `provider` text NOT NULL,
270
+ `external_id` text NOT NULL,
271
+ `amount` text NOT NULL,
272
+ `currency` text DEFAULT 'usd' NOT NULL,
273
+ `status` text DEFAULT 'pending' NOT NULL,
274
+ `metadata` text,
275
+ `paid_at` integer,
276
+ `fulfillment_key` text,
277
+ `created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
278
+ `updated_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
279
+ FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
280
+ );
281
+ --> statement-breakpoint
282
+ CREATE UNIQUE INDEX `purchase_provider_external_uq` ON `purchase` (`provider`,`external_id`);--> statement-breakpoint
283
+ CREATE INDEX `purchase_userId_idx` ON `purchase` (`user_id`);--> statement-breakpoint
284
+ CREATE TABLE `purchase_delivery` (
285
+ `purchase_id` text PRIMARY KEY NOT NULL,
286
+ `created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
287
+ `processed_at` integer,
288
+ FOREIGN KEY (`purchase_id`) REFERENCES `purchase`(`id`) ON UPDATE no action ON DELETE cascade
289
+ );
290
+ --> statement-breakpoint
291
+ CREATE TABLE `system_config` (
292
+ `key` text PRIMARY KEY NOT NULL,
293
+ `value` text NOT NULL,
294
+ `updated_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL
295
+ );