@usequark/quark-create-app 1.23.11

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 (237) hide show
  1. package/README.md +210 -0
  2. package/package.json +56 -0
  3. package/src/dependabot-config.test.js +128 -0
  4. package/src/deploy/adapters/iac.js +203 -0
  5. package/src/deploy/adapters/index.js +27 -0
  6. package/src/deploy/adapters/railway.js +508 -0
  7. package/src/deploy/adapters/railway.test.js +318 -0
  8. package/src/deploy/contract.js +59 -0
  9. package/src/deploy/deploy.integration.test.js +183 -0
  10. package/src/deploy/deploy.js +430 -0
  11. package/src/deploy/deploy.test.js +105 -0
  12. package/src/deploy/discovery.js +115 -0
  13. package/src/deploy/discovery.test.js +141 -0
  14. package/src/deploy/index.js +38 -0
  15. package/src/deploy/inspect.js +130 -0
  16. package/src/index.js +2920 -0
  17. package/src/mobile-template.test.js +232 -0
  18. package/src/prepare-hooks.test.js +535 -0
  19. package/src/scaffold-guards.test.js +140 -0
  20. package/src/scaffold-output.test.js +228 -0
  21. package/src/template-scripts.test.js +160 -0
  22. package/src/utils.js +77 -0
  23. package/src/utils.test.js +95 -0
  24. package/src/worker-dockerfile.test.js +85 -0
  25. package/templates/base-project/.cursor/rules/quark.mdc +172 -0
  26. package/templates/base-project/.dockerignore +14 -0
  27. package/templates/base-project/.env.railway.example +87 -0
  28. package/templates/base-project/.github/copilot-instructions.md +65 -0
  29. package/templates/base-project/.github/dependabot.yml +31 -0
  30. package/templates/base-project/.github/skills/project-context/SKILL.md +109 -0
  31. package/templates/base-project/.github/workflows/ci.yml +116 -0
  32. package/templates/base-project/.github/workflows/dependabot-auto-merge.yml +22 -0
  33. package/templates/base-project/.github/workflows/install-windows.yml +27 -0
  34. package/templates/base-project/.github/workflows/release.yml +67 -0
  35. package/templates/base-project/.railway/railway.ts +40 -0
  36. package/templates/base-project/CLAUDE.md +163 -0
  37. package/templates/base-project/MAIN.md +21 -0
  38. package/templates/base-project/README.md +164 -0
  39. package/templates/base-project/apps/web/.env.example +99 -0
  40. package/templates/base-project/apps/web/Dockerfile +38 -0
  41. package/templates/base-project/apps/web/biome.json +8 -0
  42. package/templates/base-project/apps/web/jsconfig.json +9 -0
  43. package/templates/base-project/apps/web/next.config.js +154 -0
  44. package/templates/base-project/apps/web/package.json +42 -0
  45. package/templates/base-project/apps/web/postcss.config.mjs +7 -0
  46. package/templates/base-project/apps/web/public/.well-known/security.txt +3 -0
  47. package/templates/base-project/apps/web/public/quark.svg +47 -0
  48. package/templates/base-project/apps/web/scripts/prepare-standalone.mjs +71 -0
  49. package/templates/base-project/apps/web/src/app/_components/AsciiPanel.js +103 -0
  50. package/templates/base-project/apps/web/src/app/_components/HealthIndicator.js +85 -0
  51. package/templates/base-project/apps/web/src/app/_components/HomeThemeToggle.js +63 -0
  52. package/templates/base-project/apps/web/src/app/_components/QuarkAnimation.js +168 -0
  53. package/templates/base-project/apps/web/src/app/_components/SyncedPanels.js +212 -0
  54. package/templates/base-project/apps/web/src/app/_components/UmamiReplayRecorder.js +198 -0
  55. package/templates/base-project/apps/web/src/app/_components/UmamiWebVitals.js +39 -0
  56. package/templates/base-project/apps/web/src/app/api/auth/[...nextauth]/route.js +7 -0
  57. package/templates/base-project/apps/web/src/app/api/auth/apple/route.js +82 -0
  58. package/templates/base-project/apps/web/src/app/api/auth/google/route.js +59 -0
  59. package/templates/base-project/apps/web/src/app/api/auth/refresh/route.js +48 -0
  60. package/templates/base-project/apps/web/src/app/api/auth/register/route.js +63 -0
  61. package/templates/base-project/apps/web/src/app/api/auth/token/route.js +44 -0
  62. package/templates/base-project/apps/web/src/app/api/csrf/route.js +40 -0
  63. package/templates/base-project/apps/web/src/app/api/device/register/route.js +57 -0
  64. package/templates/base-project/apps/web/src/app/api/error-handler.js +23 -0
  65. package/templates/base-project/apps/web/src/app/api/files/[id]/route.js +89 -0
  66. package/templates/base-project/apps/web/src/app/api/files/route.js +120 -0
  67. package/templates/base-project/apps/web/src/app/api/health/route.js +160 -0
  68. package/templates/base-project/apps/web/src/app/api/users/[id]/route.js +54 -0
  69. package/templates/base-project/apps/web/src/app/api/users/me/route.js +67 -0
  70. package/templates/base-project/apps/web/src/app/api/users/route.js +45 -0
  71. package/templates/base-project/apps/web/src/app/auth/error/page.js +83 -0
  72. package/templates/base-project/apps/web/src/app/auth/forgot-password/page.js +58 -0
  73. package/templates/base-project/apps/web/src/app/auth/provider-loading.js +25 -0
  74. package/templates/base-project/apps/web/src/app/auth/register/RegisterPageClient.js +340 -0
  75. package/templates/base-project/apps/web/src/app/auth/register/page.js +17 -0
  76. package/templates/base-project/apps/web/src/app/auth/signin/SignInPageClient.js +230 -0
  77. package/templates/base-project/apps/web/src/app/auth/signin/page.js +10 -0
  78. package/templates/base-project/apps/web/src/app/favicon.ico +0 -0
  79. package/templates/base-project/apps/web/src/app/global-error.js +58 -0
  80. package/templates/base-project/apps/web/src/app/globals.css +1166 -0
  81. package/templates/base-project/apps/web/src/app/icon.svg +47 -0
  82. package/templates/base-project/apps/web/src/app/layout.js +57 -0
  83. package/templates/base-project/apps/web/src/app/manifest.js +12 -0
  84. package/templates/base-project/apps/web/src/app/not-found.js +39 -0
  85. package/templates/base-project/apps/web/src/app/page.js +262 -0
  86. package/templates/base-project/apps/web/src/app/robots.js +23 -0
  87. package/templates/base-project/apps/web/src/app/sitemap.js +14 -0
  88. package/templates/base-project/apps/web/src/lib/analytics/umami-before-send.js +29 -0
  89. package/templates/base-project/apps/web/src/lib/analytics/umami-config.js +95 -0
  90. package/templates/base-project/apps/web/src/lib/analytics/umami-csp.js +24 -0
  91. package/templates/base-project/apps/web/src/lib/analytics/umami-marketing.js +131 -0
  92. package/templates/base-project/apps/web/src/lib/analytics/umami-replay.js +204 -0
  93. package/templates/base-project/apps/web/src/lib/analytics/umami-web-vitals.js +237 -0
  94. package/templates/base-project/apps/web/src/lib/analytics/umami.js +57 -0
  95. package/templates/base-project/apps/web/src/lib/auth-middleware.js +82 -0
  96. package/templates/base-project/apps/web/src/lib/auth-signup.js +5 -0
  97. package/templates/base-project/apps/web/src/lib/auth.js +191 -0
  98. package/templates/base-project/apps/web/src/lib/csrf-cookie.js +11 -0
  99. package/templates/base-project/apps/web/src/lib/jwt.js +84 -0
  100. package/templates/base-project/apps/web/src/lib/load-crm-config.js +35 -0
  101. package/templates/base-project/apps/web/src/lib/normalize-auth-request.js +33 -0
  102. package/templates/base-project/apps/web/src/lib/proxy-auth.js +80 -0
  103. package/templates/base-project/apps/web/src/lib/seo/indexing.js +25 -0
  104. package/templates/base-project/apps/web/src/lib/seo/site-metadata.js +74 -0
  105. package/templates/base-project/apps/web/src/lib/sitemap-entries.js +22 -0
  106. package/templates/base-project/apps/web/src/preflight.js +55 -0
  107. package/templates/base-project/apps/web/src/proxy.js +328 -0
  108. package/templates/base-project/biome.json +52 -0
  109. package/templates/base-project/docker-compose.override.yml +17 -0
  110. package/templates/base-project/docker-compose.yml +37 -0
  111. package/templates/base-project/docs/FIRST_FEATURE.md +74 -0
  112. package/templates/base-project/docs/START_HERE.md +70 -0
  113. package/templates/base-project/docs/adr/README.md +42 -0
  114. package/templates/base-project/package.json +58 -0
  115. package/templates/base-project/packages/db/package.json +38 -0
  116. package/templates/base-project/packages/db/prisma/migrations/20260202061128_initial/migration.sql +202 -0
  117. package/templates/base-project/packages/db/prisma/migrations/migration_lock.toml +3 -0
  118. package/templates/base-project/packages/db/prisma/schema.prisma +161 -0
  119. package/templates/base-project/packages/db/prisma/seed.js +152 -0
  120. package/templates/base-project/packages/db/prisma.config.js +27 -0
  121. package/templates/base-project/packages/db/src/client.js +75 -0
  122. package/templates/base-project/packages/db/src/connection.js +44 -0
  123. package/templates/base-project/packages/db/src/context.js +275 -0
  124. package/templates/base-project/packages/db/src/index.js +5 -0
  125. package/templates/base-project/packages/db/src/ping.js +66 -0
  126. package/templates/base-project/packages/db/src/queries.js +292 -0
  127. package/templates/base-project/packages/db/src/schemas.js +30 -0
  128. package/templates/base-project/packages/db/src/umami.js +299 -0
  129. package/templates/base-project/pnpm-workspace.yaml +20 -0
  130. package/templates/base-project/scripts/check-loading.mjs +49 -0
  131. package/templates/base-project/scripts/check-standards.mjs +186 -0
  132. package/templates/base-project/scripts/clean-workspace.mjs +479 -0
  133. package/templates/base-project/scripts/dev-preflight.mjs +48 -0
  134. package/templates/base-project/scripts/doctor.js +408 -0
  135. package/templates/base-project/scripts/prepare.js +490 -0
  136. package/templates/base-project/scripts/run-tests.mjs +71 -0
  137. package/templates/base-project/skills/add-dashboard/SKILL.md +28 -0
  138. package/templates/base-project/skills/add-endpoint/SKILL.md +29 -0
  139. package/templates/base-project/skills/add-model/SKILL.md +28 -0
  140. package/templates/base-project/skills/ecommerce/SKILL.md +224 -0
  141. package/templates/base-project/skills/ecommerce-cart/SKILL.md +193 -0
  142. package/templates/base-project/skills/ecommerce-catalog/SKILL.md +179 -0
  143. package/templates/base-project/skills/ecommerce-checkout/SKILL.md +195 -0
  144. package/templates/base-project/skills/i18n/SKILL.md +211 -0
  145. package/templates/base-project/skills/payment/SKILL.md +280 -0
  146. package/templates/base-project/skills/quark-skills/SKILL.md +37 -0
  147. package/templates/base-project/turbo.json +143 -0
  148. package/templates/config/package.json +20 -0
  149. package/templates/config/src/app-url.js +134 -0
  150. package/templates/config/src/environment.js +284 -0
  151. package/templates/config/src/index.js +16 -0
  152. package/templates/config/src/load-config.js +157 -0
  153. package/templates/config/src/rate-limit.js +136 -0
  154. package/templates/config/src/redis.js +43 -0
  155. package/templates/config/src/validate-env.js +532 -0
  156. package/templates/jobs/README.md +52 -0
  157. package/templates/jobs/package.json +9 -0
  158. package/templates/jobs/src/definitions.js +13 -0
  159. package/templates/jobs/src/index.js +1 -0
  160. package/templates/mobile/.env.example +16 -0
  161. package/templates/mobile/CHECKLIST.md +40 -0
  162. package/templates/mobile/README.md +130 -0
  163. package/templates/mobile/app/(app)/_layout.tsx +83 -0
  164. package/templates/mobile/app/(app)/index.tsx +147 -0
  165. package/templates/mobile/app/(app)/profile.tsx +204 -0
  166. package/templates/mobile/app/(app)/settings.tsx +80 -0
  167. package/templates/mobile/app/(auth)/_layout.tsx +10 -0
  168. package/templates/mobile/app/(auth)/sign-in.tsx +140 -0
  169. package/templates/mobile/app/(auth)/sign-up.tsx +178 -0
  170. package/templates/mobile/app/+not-found.tsx +32 -0
  171. package/templates/mobile/app/_layout.tsx +18 -0
  172. package/templates/mobile/app.json +47 -0
  173. package/templates/mobile/assets/adaptive-icon.png +0 -0
  174. package/templates/mobile/assets/icon.png +0 -0
  175. package/templates/mobile/assets/notification-icon.png +0 -0
  176. package/templates/mobile/assets/splash.png +0 -0
  177. package/templates/mobile/biome.json +27 -0
  178. package/templates/mobile/components/AuthGate.tsx +19 -0
  179. package/templates/mobile/components/ThemeProvider.tsx +23 -0
  180. package/templates/mobile/eas.json +29 -0
  181. package/templates/mobile/hooks/use-auth.ts +127 -0
  182. package/templates/mobile/lib/api-client.ts +138 -0
  183. package/templates/mobile/lib/api.ts +83 -0
  184. package/templates/mobile/lib/auth-providers-google.ts +74 -0
  185. package/templates/mobile/lib/auth-providers.ts +74 -0
  186. package/templates/mobile/lib/auth.ts +100 -0
  187. package/templates/mobile/lib/config.ts +14 -0
  188. package/templates/mobile/lib/errors.ts +46 -0
  189. package/templates/mobile/lib/notifications.ts +77 -0
  190. package/templates/mobile/lib/storage.ts +25 -0
  191. package/templates/mobile/lib/theme-tokens.ts +16 -0
  192. package/templates/mobile/package.json +37 -0
  193. package/templates/mobile/tsconfig.json +10 -0
  194. package/templates/pwa/app/_components/PWARegister.js +15 -0
  195. package/templates/pwa/app/manifest.js +32 -0
  196. package/templates/pwa/public/sw.js +72 -0
  197. package/templates/ui/README.md +44 -0
  198. package/templates/ui/package.json +21 -0
  199. package/templates/ui/src/badge.js +23 -0
  200. package/templates/ui/src/button.js +111 -0
  201. package/templates/ui/src/card.js +128 -0
  202. package/templates/ui/src/checkbox.js +20 -0
  203. package/templates/ui/src/container.js +13 -0
  204. package/templates/ui/src/dialog.js +137 -0
  205. package/templates/ui/src/error-banner.js +15 -0
  206. package/templates/ui/src/footer.js +239 -0
  207. package/templates/ui/src/form-field.integration-test.js +142 -0
  208. package/templates/ui/src/form-field.js +50 -0
  209. package/templates/ui/src/index.js +24 -0
  210. package/templates/ui/src/input.js +11 -0
  211. package/templates/ui/src/label.js +14 -0
  212. package/templates/ui/src/lightbox.integration-test.js +167 -0
  213. package/templates/ui/src/lightbox.js +243 -0
  214. package/templates/ui/src/logo.js +52 -0
  215. package/templates/ui/src/navbar.js +602 -0
  216. package/templates/ui/src/password-input.js +31 -0
  217. package/templates/ui/src/rich-text.js +514 -0
  218. package/templates/ui/src/select.js +317 -0
  219. package/templates/ui/src/skeleton.js +10 -0
  220. package/templates/ui/src/spinner.js +31 -0
  221. package/templates/ui/src/table.js +169 -0
  222. package/templates/ui/src/textarea.js +11 -0
  223. package/templates/ui/src/theme-constants.js +24 -0
  224. package/templates/ui/src/theme.js +196 -0
  225. package/templates/ui/src/toast.js +206 -0
  226. package/templates/worker/Dockerfile +64 -0
  227. package/templates/worker/README.md +56 -0
  228. package/templates/worker/package.json +36 -0
  229. package/templates/worker/src/config/pricing.js +46 -0
  230. package/templates/worker/src/handlers/email-job-validation.js +36 -0
  231. package/templates/worker/src/handlers/email.js +98 -0
  232. package/templates/worker/src/handlers/files.js +59 -0
  233. package/templates/worker/src/handlers/index.js +20 -0
  234. package/templates/worker/src/handlers/push-notification.js +234 -0
  235. package/templates/worker/src/index.js +505 -0
  236. package/templates/worker/src/lib/railway.js +438 -0
  237. package/templates/worker/src/lib/tokens.js +81 -0
@@ -0,0 +1,157 @@
1
+ /**
2
+ * @techstream/quark-config - Configuration Loader
3
+ * Centralised configuration management that combines environment validation,
4
+ * environment-specific defaults, and user overrides into a single config object.
5
+ *
6
+ * Usage:
7
+ * import { loadConfig } from "@techstream/quark-config";
8
+ * const config = loadConfig(); // validates env + returns typed config
9
+ * const config = loadConfig({ cache: { defaultTtl: 120 } }); // with overrides
10
+ */
11
+
12
+ import { getAllowedOrigins, getAppUrl } from "./app-url.js";
13
+ import { getEnvironmentConfig, mergeConfig } from "./environment.js";
14
+ import { validateEnv } from "./validate-env.js";
15
+
16
+ /** @type {import("./environment.js").EnvironmentConfig | null} */
17
+ let cachedConfig = null;
18
+
19
+ /**
20
+ * Loads the full application configuration.
21
+ *
22
+ * 1. Validates all required environment variables (throws on failure).
23
+ * 2. Resolves the current environment (dev/test/staging/production).
24
+ * 3. Merges environment-specific defaults with any user overrides.
25
+ * 4. Enriches with computed values (APP_URL, allowed origins, ports).
26
+ * 5. Caches the result - subsequent calls return the same object.
27
+ *
28
+ * @param {Record<string, unknown>} [overrides] - Optional partial overrides
29
+ * @param {Object} [options]
30
+ * @param {boolean} [options.fresh=false] - Force re-computation (bypass cache)
31
+ * @returns {import("./environment.js").EnvironmentConfig & { appUrl: string, allowedOrigins: string[], validated: Record<string, string> }}
32
+ */
33
+ export function loadConfig(overrides = {}, options = {}) {
34
+ if (cachedConfig && !options.fresh) {
35
+ return cachedConfig;
36
+ }
37
+
38
+ // Step 1: Validate environment variables
39
+ const { validated } = validateEnv();
40
+
41
+ // Step 2: Resolve environment and get defaults
42
+ const envConfig = getEnvironmentConfig();
43
+
44
+ // Step 3: Apply env-var driven overrides
45
+ const envOverrides = buildEnvOverrides(envConfig);
46
+
47
+ // Step 4: Merge: env defaults → env-var overrides → user overrides
48
+ let config = mergeConfig(envConfig, envOverrides);
49
+ config = mergeConfig(config, overrides);
50
+
51
+ // Step 5: Attach computed values
52
+ config.appUrl = getAppUrl();
53
+ config.allowedOrigins = getAllowedOrigins();
54
+ config.validated = validated;
55
+
56
+ cachedConfig = config;
57
+ return config;
58
+ }
59
+
60
+ /**
61
+ * Clears the cached configuration. Useful in tests.
62
+ */
63
+ export function resetConfig() {
64
+ cachedConfig = null;
65
+ }
66
+
67
+ /**
68
+ * Returns the cached configuration if already loaded, otherwise null.
69
+ * Does NOT trigger validation - use `loadConfig()` for that.
70
+ * @returns {ReturnType<typeof loadConfig> | null}
71
+ */
72
+ export function getConfig() {
73
+ return cachedConfig;
74
+ }
75
+
76
+ /**
77
+ * Reads specific environment variables and converts them to config overrides.
78
+ * This allows env vars to take precedence over environment defaults.
79
+ *
80
+ * @param {import("./environment.js").EnvironmentConfig} envConfig
81
+ * @returns {Record<string, unknown>}
82
+ */
83
+ function buildEnvOverrides(_envConfig) {
84
+ const overrides = {};
85
+
86
+ // Server
87
+ if (process.env.PORT) {
88
+ const port = Number.parseInt(process.env.PORT, 10);
89
+ if (!Number.isNaN(port)) overrides.server = { port };
90
+ }
91
+
92
+ // Rate limiting
93
+ if (process.env.RATE_LIMIT_MAX) {
94
+ const maxRequests = Number.parseInt(process.env.RATE_LIMIT_MAX, 10);
95
+ if (!Number.isNaN(maxRequests)) {
96
+ overrides.rateLimit = { ...overrides.rateLimit, maxRequests };
97
+ }
98
+ }
99
+ if (process.env.RATE_LIMIT_WINDOW_MS) {
100
+ const windowMs = Number.parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10);
101
+ if (!Number.isNaN(windowMs)) {
102
+ overrides.rateLimit = { ...overrides.rateLimit, windowMs };
103
+ }
104
+ }
105
+
106
+ // Cache
107
+ if (process.env.CACHE_TTL) {
108
+ const defaultTtl = Number.parseInt(process.env.CACHE_TTL, 10);
109
+ if (!Number.isNaN(defaultTtl)) overrides.cache = { defaultTtl };
110
+ }
111
+
112
+ // Logging
113
+ if (process.env.LOG_LEVEL) {
114
+ overrides.logging = { ...overrides.logging, level: process.env.LOG_LEVEL };
115
+ }
116
+
117
+ // Authentication
118
+ if (process.env.AUTH_ALLOW_SIGNUP) {
119
+ const allowSignup = parseBooleanEnv(process.env.AUTH_ALLOW_SIGNUP);
120
+ if (allowSignup !== undefined) {
121
+ overrides.auth = { ...overrides.auth, allowSignup };
122
+ }
123
+ }
124
+
125
+ // Database pool
126
+ if (process.env.DB_POOL_MAX) {
127
+ const poolMax = Number.parseInt(process.env.DB_POOL_MAX, 10);
128
+ if (!Number.isNaN(poolMax)) {
129
+ overrides.db = { ...overrides.db, poolMax };
130
+ }
131
+ }
132
+ if (process.env.DB_POOL_IDLE_TIMEOUT) {
133
+ const poolIdleTimeout = Number.parseInt(
134
+ process.env.DB_POOL_IDLE_TIMEOUT,
135
+ 10,
136
+ );
137
+ if (!Number.isNaN(poolIdleTimeout)) {
138
+ overrides.db = { ...overrides.db, poolIdleTimeout };
139
+ }
140
+ }
141
+
142
+ return overrides;
143
+ }
144
+
145
+ function parseBooleanEnv(value) {
146
+ const normalized = value.trim().toLowerCase();
147
+
148
+ if (["true", "1", "yes", "on"].includes(normalized)) {
149
+ return true;
150
+ }
151
+
152
+ if (["false", "0", "no", "off"].includes(normalized)) {
153
+ return false;
154
+ }
155
+
156
+ return undefined;
157
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Sliding window rate limiter using Redis sorted sets.
3
+ * Provides distributed rate limiting across server instances.
4
+ * Falls back gracefully if Redis is unavailable (fail-open).
5
+ */
6
+
7
+ import { getSharedRedisClient } from "./redis.js";
8
+
9
+ /**
10
+ * Sliding window rate limiter using Redis sorted sets.
11
+ * Each request is scored by its timestamp, and expired entries are pruned on each call.
12
+ * TTL on the key ensures automatic cleanup of old data.
13
+ *
14
+ * @param {string} key - Unique key for the rate limit (e.g., `chat:userId`)
15
+ * @param {number} limit - Max requests per window
16
+ * @param {number} windowMs - Window size in milliseconds (default: 60000 = 1 min)
17
+ * @returns {Promise<{ allowed: boolean, remaining: number, reset: number }>}
18
+ */
19
+ export async function rateLimit(key, limit = 10, windowMs = 60000) {
20
+ const now = Date.now();
21
+ const windowStart = now - windowMs;
22
+
23
+ try {
24
+ const client = await getSharedRedisClient();
25
+ if (!client) {
26
+ // Fail open - Redis unavailable
27
+ return {
28
+ allowed: true,
29
+ remaining: limit,
30
+ reset: Math.ceil((now + windowMs) / 1000),
31
+ };
32
+ }
33
+
34
+ const redisKey = `ratelimit:${key}`;
35
+
36
+ // Pipeline: remove expired entries, count, optionally add request
37
+ // We use a Lua script for atomicity
38
+ const luaScript = `
39
+ local key = KEYS[1]
40
+ local window_start = tonumber(ARGV[1])
41
+ local now = tonumber(ARGV[2])
42
+ local limit = tonumber(ARGV[3])
43
+ local window_ms = tonumber(ARGV[4])
44
+ local window_end = now + window_ms
45
+
46
+ -- Remove entries outside the window
47
+ redis.call('ZREMRANGEBYSCORE', key, 0, window_start)
48
+
49
+ -- Count remaining entries in window
50
+ local count = redis.call('ZCARD', key)
51
+
52
+ if count >= limit then
53
+ -- Rate limited - return oldest entry for reset calculation
54
+ local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
55
+ local reset_time = window_end
56
+ if #oldest >= 2 then
57
+ reset_time = tonumber(oldest[2]) + window_ms
58
+ end
59
+ return {0, 0, reset_time}
60
+ end
61
+
62
+ -- Add current request (score = timestamp, member = unique)
63
+ local member = now .. ':' .. math.random(1000000)
64
+ redis.call('ZADD', key, now, member)
65
+
66
+ -- Set TTL on the key for automatic cleanup
67
+ redis.call('PEXPIRE', key, window_ms + 1000)
68
+
69
+ local remaining = limit - count - 1
70
+ local reset_time = window_end
71
+
72
+ return {1, remaining, reset_time}
73
+ `;
74
+
75
+ const [allowed, remaining, reset] = await client.eval(
76
+ luaScript,
77
+ 1,
78
+ redisKey,
79
+ windowStart,
80
+ now,
81
+ limit,
82
+ windowMs,
83
+ );
84
+
85
+ return {
86
+ allowed: allowed === 1,
87
+ remaining,
88
+ reset: Math.ceil(reset / 1000),
89
+ };
90
+ } catch {
91
+ // Fail open - Redis error
92
+ return {
93
+ allowed: true,
94
+ remaining: limit,
95
+ reset: Math.ceil((now + windowMs) / 1000),
96
+ };
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Apply rate limiting to a Next.js API route request.
102
+ * Returns a 429 Response if rate limited, or null if the request is allowed.
103
+ * Includes rate limit headers in the returned response.
104
+ *
105
+ * @param {string} identifier - Unique identifier (userId or IP)
106
+ * @param {object} options - { limit, windowMs, route }
107
+ * @returns {Promise<Response | null>}
108
+ */
109
+ export async function applyRateLimit(identifier, options = {}) {
110
+ const { limit = 10, windowMs = 60000, route = "default" } = options;
111
+ const key = `${route}:${identifier}`;
112
+
113
+ const result = await rateLimit(key, limit, windowMs);
114
+
115
+ if (!result.allowed) {
116
+ const retryAfter = Math.max(0, result.reset - Math.ceil(Date.now() / 1000));
117
+ return new Response(
118
+ JSON.stringify({
119
+ error: "Rate limit exceeded. Please try again later.",
120
+ retryAfter,
121
+ }),
122
+ {
123
+ status: 429,
124
+ headers: {
125
+ "Content-Type": "application/json",
126
+ "X-RateLimit-Limit": String(limit),
127
+ "X-RateLimit-Remaining": "0",
128
+ "X-RateLimit-Reset": String(result.reset),
129
+ "Retry-After": String(retryAfter),
130
+ },
131
+ },
132
+ );
133
+ }
134
+
135
+ return null;
136
+ }
@@ -0,0 +1,43 @@
1
+ import { resolveRedisConnection } from "@techstream/quark-core";
2
+
3
+ let _redis = null;
4
+
5
+ /**
6
+ * Get a shared Redis client. Creates and connects on first call, reuses thereafter.
7
+ * Returns null if Redis is unavailable (fail-open).
8
+ *
9
+ * @returns {Promise<import("ioredis").default | null>}
10
+ */
11
+ export async function getSharedRedisClient() {
12
+ if (_redis) return _redis;
13
+ try {
14
+ const conn = resolveRedisConnection();
15
+ const { default: Redis } = await import("ioredis");
16
+ _redis = new Redis({
17
+ ...conn,
18
+ lazyConnect: true,
19
+ maxRetriesPerRequest: 0,
20
+ enableReadyCheck: false,
21
+ });
22
+ _redis.on("error", () => {}); // Suppress errors
23
+ await _redis.connect();
24
+ return _redis;
25
+ } catch {
26
+ await closeSharedRedisClient();
27
+ return null;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Close the shared Redis client (for graceful shutdown).
33
+ */
34
+ export async function closeSharedRedisClient() {
35
+ if (_redis) {
36
+ try {
37
+ await _redis.disconnect();
38
+ } catch {
39
+ // ignore disconnect errors
40
+ }
41
+ _redis = null;
42
+ }
43
+ }