@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,131 @@
1
+ const CAMPAIGN_PARAM_NAMES = [
2
+ "utm_source",
3
+ "utm_medium",
4
+ "utm_campaign",
5
+ "utm_term",
6
+ "utm_content",
7
+ ];
8
+
9
+ const RELATIVE_URL_BASE = "https://quark.invalid";
10
+
11
+ function getTrimmedString(value) {
12
+ return typeof value === "string" ? value.trim() : "";
13
+ }
14
+
15
+ function escapeHtml(value) {
16
+ return String(value)
17
+ .replaceAll("&", "&")
18
+ .replaceAll("<", "&lt;")
19
+ .replaceAll(">", "&gt;")
20
+ .replaceAll('"', "&quot;")
21
+ .replaceAll("'", "&#39;");
22
+ }
23
+
24
+ function normalizeAbsoluteHttpUrl(value) {
25
+ const trimmed = getTrimmedString(value);
26
+ if (!trimmed) return "";
27
+
28
+ try {
29
+ const parsed = new URL(trimmed);
30
+ if (!["http:", "https:"].includes(parsed.protocol)) {
31
+ return "";
32
+ }
33
+
34
+ return parsed.toString();
35
+ } catch {
36
+ return "";
37
+ }
38
+ }
39
+
40
+ function parseCampaignUrl(value) {
41
+ const trimmed = getTrimmedString(value);
42
+ if (!trimmed) return null;
43
+
44
+ const absoluteUrl = normalizeAbsoluteHttpUrl(trimmed);
45
+ if (absoluteUrl) {
46
+ return {
47
+ isAbsolute: true,
48
+ url: new URL(absoluteUrl),
49
+ };
50
+ }
51
+
52
+ if (
53
+ trimmed.startsWith("/") ||
54
+ trimmed.startsWith("?") ||
55
+ trimmed.startsWith("#")
56
+ ) {
57
+ return {
58
+ isAbsolute: false,
59
+ url: new URL(trimmed, RELATIVE_URL_BASE),
60
+ };
61
+ }
62
+
63
+ return null;
64
+ }
65
+
66
+ export function buildCampaignUrl(href, campaign = {}) {
67
+ const parsed = parseCampaignUrl(href);
68
+ if (!parsed) return "";
69
+
70
+ for (const [key, value] of Object.entries(campaign)) {
71
+ if (!CAMPAIGN_PARAM_NAMES.includes(key)) continue;
72
+
73
+ const trimmed = getTrimmedString(value);
74
+ if (!trimmed) continue;
75
+
76
+ parsed.url.searchParams.set(key, trimmed);
77
+ }
78
+
79
+ if (parsed.isAbsolute) {
80
+ return parsed.url.toString();
81
+ }
82
+
83
+ return `${parsed.url.pathname}${parsed.url.search}${parsed.url.hash}`;
84
+ }
85
+
86
+ function normalizeLinkHref(value) {
87
+ const parsed = parseCampaignUrl(value);
88
+ if (!parsed) return "";
89
+
90
+ if (parsed.isAbsolute) {
91
+ return parsed.url.toString();
92
+ }
93
+
94
+ return `${parsed.url.pathname}${parsed.url.search}${parsed.url.hash}`;
95
+ }
96
+
97
+ export function resolveUmamiLinkHref({ href = "", trackingHref = "" } = {}) {
98
+ return normalizeAbsoluteHttpUrl(trackingHref) || normalizeLinkHref(href);
99
+ }
100
+
101
+ export function getUmamiPixelProps(
102
+ pixelUrl,
103
+ { alt = "", width = 1, height = 1 } = {},
104
+ ) {
105
+ const normalizedPixelUrl = normalizeAbsoluteHttpUrl(pixelUrl);
106
+ if (!normalizedPixelUrl) return null;
107
+
108
+ return {
109
+ src: normalizedPixelUrl,
110
+ alt: getTrimmedString(alt),
111
+ width,
112
+ height,
113
+ decoding: "async",
114
+ "aria-hidden": "true",
115
+ referrerPolicy: "strict-origin-when-cross-origin",
116
+ style: {
117
+ border: 0,
118
+ display: "none",
119
+ height: "1px",
120
+ overflow: "hidden",
121
+ width: "1px",
122
+ },
123
+ };
124
+ }
125
+
126
+ export function getUmamiEmailPixelHtml(pixelUrl) {
127
+ const normalizedPixelUrl = normalizeAbsoluteHttpUrl(pixelUrl);
128
+ if (!normalizedPixelUrl) return "";
129
+
130
+ return `<img src="${escapeHtml(normalizedPixelUrl)}" alt="" width="1" height="1" decoding="async" aria-hidden="true" style="display:none !important;width:1px;height:1px;overflow:hidden;border:0;" />`;
131
+ }
@@ -0,0 +1,204 @@
1
+ import { createLogger } from "@techstream/quark-core/logger";
2
+ import { getUmamiConfig, normalizeUmamiUrl } from "./umami-config.js";
3
+
4
+ const log = createLogger("umami-replay");
5
+
6
+ export const UMAMI_REPLAY_DEFAULTS = Object.freeze({
7
+ // Validate end-to-end at 100% before lowering sampling (see REPLAY.md).
8
+ sampleRate: 1,
9
+ flushIntervalMs: 10000,
10
+ flushEventCount: 100,
11
+ maxDurationMs: 300000,
12
+ sessionPollIntervalMs: 100,
13
+ // Wait indefinitely for the session cache: the token only appears after
14
+ // the first /api/send round-trip, which can be delayed on slow networks.
15
+ // Cancellation is handled via AbortSignal from the recorder.
16
+ sessionPollAttempts: Infinity,
17
+ maskLevel: "moderate",
18
+ blockSelector: "[data-umami-block]",
19
+ });
20
+
21
+ export function shouldSampleReplay(
22
+ randomValue = Math.random(),
23
+ sampleRate = UMAMI_REPLAY_DEFAULTS.sampleRate,
24
+ ) {
25
+ if (sampleRate >= 1) return true;
26
+ if (sampleRate <= 0) return false;
27
+ return randomValue <= sampleRate;
28
+ }
29
+
30
+ export function getReplayMaskConfig(
31
+ maskLevel = UMAMI_REPLAY_DEFAULTS.maskLevel,
32
+ ) {
33
+ if (maskLevel === "strict") {
34
+ return {
35
+ maskAllInputs: true,
36
+ maskTextSelector: "*",
37
+ };
38
+ }
39
+
40
+ return {
41
+ maskAllInputs: true,
42
+ };
43
+ }
44
+
45
+ export function createReplayBuffer() {
46
+ let events = [];
47
+
48
+ return {
49
+ push(event) {
50
+ events.push(event);
51
+ return events.length;
52
+ },
53
+ drain() {
54
+ const drained = events;
55
+ events = [];
56
+ return drained;
57
+ },
58
+ restore(drainedEvents) {
59
+ if (!Array.isArray(drainedEvents) || !drainedEvents.length)
60
+ return events.length;
61
+ events = [...drainedEvents, ...events];
62
+ return events.length;
63
+ },
64
+ size() {
65
+ return events.length;
66
+ },
67
+ };
68
+ }
69
+
70
+ export function buildReplayEndpoint(env = process.env) {
71
+ const url =
72
+ typeof env === "string" ? normalizeUmamiUrl(env) : getUmamiConfig(env).url;
73
+
74
+ return url ? `${url}/api/record` : "";
75
+ }
76
+
77
+ export function getUmamiSessionCache(umami = globalThis.window?.umami) {
78
+ const session = umami?.getSession?.();
79
+ return session?.cache || "";
80
+ }
81
+
82
+ export function resolveUmamiSessionCache({
83
+ cache = "",
84
+ getUmami = () => globalThis.window?.umami,
85
+ } = {}) {
86
+ return getUmamiSessionCache(getUmami()) || cache;
87
+ }
88
+
89
+ export function waitForUmamiSessionCache({
90
+ getUmami = () => globalThis.window?.umami,
91
+ pollIntervalMs = UMAMI_REPLAY_DEFAULTS.sessionPollIntervalMs,
92
+ maxAttempts = UMAMI_REPLAY_DEFAULTS.sessionPollAttempts,
93
+ signal,
94
+ } = {}) {
95
+ return new Promise((resolve) => {
96
+ if (signal?.aborted) {
97
+ resolve("");
98
+ return;
99
+ }
100
+
101
+ let timer = null;
102
+ const onAbort = () => {
103
+ cleanup();
104
+ resolve("");
105
+ };
106
+ const cleanup = () => {
107
+ if (timer !== null) {
108
+ clearTimeout(timer);
109
+ timer = null;
110
+ }
111
+ signal?.removeEventListener("abort", onAbort);
112
+ };
113
+
114
+ const poll = (attempts = 0) => {
115
+ const cache = getUmamiSessionCache(getUmami());
116
+ if (cache) {
117
+ cleanup();
118
+ resolve(cache);
119
+ return;
120
+ }
121
+
122
+ if (attempts >= maxAttempts) {
123
+ cleanup();
124
+ resolve("");
125
+ return;
126
+ }
127
+
128
+ timer = setTimeout(() => poll(attempts + 1), pollIntervalMs);
129
+ };
130
+
131
+ signal?.addEventListener("abort", onAbort, { once: true });
132
+ poll();
133
+ });
134
+ }
135
+
136
+ export function buildReplayBody({ websiteId, events, timestamp }) {
137
+ return JSON.stringify({
138
+ type: "record",
139
+ payload: {
140
+ website: websiteId,
141
+ events,
142
+ timestamp,
143
+ },
144
+ });
145
+ }
146
+
147
+ export async function postReplayBatch({
148
+ endpoint,
149
+ cache,
150
+ websiteId,
151
+ events,
152
+ timestamp = Math.floor(Date.now() / 1000),
153
+ useKeepalive = false,
154
+ getUmami = () => globalThis.window?.umami,
155
+ fetchImpl = globalThis.fetch?.bind(globalThis),
156
+ } = {}) {
157
+ const sessionCache = resolveUmamiSessionCache({ cache, getUmami });
158
+
159
+ if (
160
+ !endpoint ||
161
+ !sessionCache ||
162
+ !websiteId ||
163
+ !events?.length ||
164
+ !fetchImpl
165
+ ) {
166
+ return false;
167
+ }
168
+
169
+ const body = buildReplayBody({ websiteId, events, timestamp });
170
+ const keepalive = useKeepalive && body.length < 60000;
171
+
172
+ try {
173
+ const response = await fetchImpl(endpoint, {
174
+ keepalive,
175
+ method: "POST",
176
+ body,
177
+ headers: {
178
+ "Content-Type": "application/json",
179
+ "x-umami-cache": sessionCache,
180
+ },
181
+ credentials: "omit",
182
+ });
183
+
184
+ if (!response?.ok) return false;
185
+
186
+ // Umami answers 200 with { ok: false, reason } when replay is disabled
187
+ // server-side. Treat that as a failure so events are restored instead
188
+ // of silently dropped.
189
+ const responseBody =
190
+ typeof response.json === "function"
191
+ ? await response.json().catch(() => null)
192
+ : null;
193
+ if (responseBody && responseBody.ok === false) {
194
+ log.warn(
195
+ `batch rejected by server: ${responseBody.reason ?? "unknown reason"}`,
196
+ );
197
+ return false;
198
+ }
199
+
200
+ return true;
201
+ } catch {
202
+ return false;
203
+ }
204
+ }
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Anonymous Real User Monitoring (RUM) — Core Web Vitals Pipeline
3
+ *
4
+ * Observes browser performance metrics (LCP, FID, CLS, INP, TTFB) and
5
+ * dispatches them to the unified Umami instance as anonymous custom events
6
+ * (event_type = 2). No user identifiers, session tokens, or PII are ever
7
+ * included in the payload.
8
+ *
9
+ * Privacy guarantees:
10
+ * - Zero user data: no email, name, ID, token, or cookie values.
11
+ * - Layout timings only: metric name, numeric value, rating string,
12
+ * and a sanitised URL path (origin stripped, never full URL with query).
13
+ * - No referrer, no user-agent, no IP collected by this script.
14
+ * - Uses sendBeacon for reliable delivery without blocking the page.
15
+ *
16
+ * Integration:
17
+ * Import and call `observeWebVitals()` once from a client component
18
+ * (e.g. a "use client" wrapper in the root layout).
19
+ *
20
+ * Environment:
21
+ * Controlled by NEXT_PUBLIC_UMAMI_URL and NEXT_PUBLIC_UMAMI_WEBSITE_ID.
22
+ * When Umami analytics is not configured, the observer is a no-op.
23
+ */
24
+
25
+ import { getUmamiConfig } from "./umami-config.js";
26
+
27
+ // ── Initialization guard ──────────────────────────────────────
28
+
29
+ /**
30
+ * Returns true when the browser supports PerformanceObserver.
31
+ * @returns {boolean}
32
+ */
33
+ function isSupported() {
34
+ return (
35
+ typeof window !== "undefined" &&
36
+ "PerformanceObserver" in window &&
37
+ "performance" in window
38
+ );
39
+ }
40
+
41
+ /**
42
+ * Strips the origin and query string from a URL, returning only the
43
+ * pathname. This ensures no PII (query params, hash fragments) or
44
+ * internal hostnames leak to the analytics pipeline.
45
+ *
46
+ * @param {string} url
47
+ * @returns {string}
48
+ */
49
+ function sanitiseUrl(url) {
50
+ try {
51
+ const parsed = new URL(url, "http://localhost");
52
+ return parsed.pathname;
53
+ } catch {
54
+ return "/";
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Dispatches a single CWV observation to the Umami collect endpoint
60
+ * as a custom event (event_type = 2). The payload is fully anonymous.
61
+ *
62
+ * @param {{ name: string, value: number, rating: string, navigationType?: string }} metric
63
+ */
64
+ function dispatchMetric(metric) {
65
+ const config = getUmamiConfig();
66
+ if (!config.enabled) return;
67
+
68
+ const payload = {
69
+ type: "event",
70
+ payload: {
71
+ website: config.websiteId,
72
+ event_name: "cwv",
73
+ event_type: 2, // custom event
74
+ url: sanitiseUrl(window.location.href),
75
+ event_data: {
76
+ metric_name: metric.name,
77
+ metric_value: Math.round(metric.value * 1000) / 1000, // round to 3dp
78
+ metric_rating: metric.rating,
79
+ navigation_type: metric.navigationType || "navigate",
80
+ },
81
+ },
82
+ };
83
+
84
+ const body = JSON.stringify(payload);
85
+
86
+ // Use sendBeacon for reliability during page lifecycle transitions.
87
+ if (navigator.sendBeacon) {
88
+ const blob = new Blob([body], { type: "application/json" });
89
+ navigator.sendBeacon(`${config.url}/api/collect`, blob);
90
+ } else {
91
+ // Fallback to fetch with keepalive.
92
+ fetch(`${config.url}/api/collect`, {
93
+ method: "POST",
94
+ body,
95
+ keepalive: true,
96
+ credentials: "omit",
97
+ headers: { "Content-Type": "application/json" },
98
+ }).catch(() => {
99
+ // Best-effort — swallow network errors silently.
100
+ });
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Starts observing Core Web Vitals and dispatching them to Umami.
106
+ * Safe to call multiple times — internal guards prevent double registration.
107
+ *
108
+ * Call this once from a "use client" component mounted in the root layout.
109
+ *
110
+ * @returns {() => void} Cleanup function that disconnects all observers.
111
+ */
112
+ export function observeWebVitals() {
113
+ if (!isSupported()) return () => {};
114
+
115
+ /** @type {PerformanceObserver[]} */
116
+ const observers = [];
117
+
118
+ // ── Largest Contentful Paint ──────────────────────────────────
119
+ try {
120
+ const lcpObserver = new PerformanceObserver((list) => {
121
+ const entries = list.getEntries();
122
+ if (entries.length === 0) return;
123
+
124
+ const entry = entries[entries.length - 1];
125
+ const value = entry.startTime;
126
+ dispatchMetric({
127
+ name: "LCP",
128
+ value,
129
+ rating:
130
+ value < 2500 ? "good" : value < 4000 ? "needs-improvement" : "poor",
131
+ navigationType: /** @type {any} */ (entry).navigationType,
132
+ });
133
+ });
134
+ lcpObserver.observe({ type: "largest-contentful-paint", buffered: true });
135
+ observers.push(lcpObserver);
136
+ } catch {
137
+ // LCP not supported.
138
+ }
139
+
140
+ // ── First Input Delay ─────────────────────────────────────────
141
+ try {
142
+ const fidObserver = new PerformanceObserver((list) => {
143
+ const entry = list.getEntries()[0];
144
+ if (!entry) return;
145
+
146
+ const value = entry.processingStart - entry.startTime;
147
+ dispatchMetric({
148
+ name: "FID",
149
+ value,
150
+ rating:
151
+ value < 100 ? "good" : value < 300 ? "needs-improvement" : "poor",
152
+ });
153
+ });
154
+ fidObserver.observe({ type: "first-input", buffered: true });
155
+ observers.push(fidObserver);
156
+ } catch {
157
+ // FID not supported.
158
+ }
159
+
160
+ // ── Cumulative Layout Shift (reported on page hide) ───────────
161
+ let clsValue = 0;
162
+ try {
163
+ const clsObserver = new PerformanceObserver((list) => {
164
+ for (const entry of list.getEntries()) {
165
+ if (!entry.hadRecentInput) clsValue += entry.value;
166
+ }
167
+ });
168
+ clsObserver.observe({ type: "layout-shift", buffered: true });
169
+ observers.push(clsObserver);
170
+
171
+ const reportCls = () => {
172
+ dispatchMetric({
173
+ name: "CLS",
174
+ value: clsValue,
175
+ rating:
176
+ clsValue < 0.1
177
+ ? "good"
178
+ : clsValue < 0.25
179
+ ? "needs-improvement"
180
+ : "poor",
181
+ });
182
+ };
183
+
184
+ window.addEventListener("visibilitychange", reportCls, { once: true });
185
+ window.addEventListener("pagehide", reportCls, { once: true });
186
+ } catch {
187
+ // CLS not supported.
188
+ }
189
+
190
+ // ── Interaction to Next Paint ─────────────────────────────────
191
+ try {
192
+ if (PerformanceObserver.supportedEntryTypes?.includes("interaction")) {
193
+ const inpObserver = new PerformanceObserver((list) => {
194
+ for (const entry of list.getEntries()) {
195
+ const value = entry.duration;
196
+ dispatchMetric({
197
+ name: "INP",
198
+ value,
199
+ rating:
200
+ value < 200 ? "good" : value < 500 ? "needs-improvement" : "poor",
201
+ });
202
+ }
203
+ });
204
+ inpObserver.observe({ type: "interaction", buffered: true });
205
+ observers.push(inpObserver);
206
+ }
207
+ } catch {
208
+ // INP not supported.
209
+ }
210
+
211
+ // ── Time to First Byte (from Navigation Timing API) ───────────
212
+ try {
213
+ if (performance.getEntriesByType?.("navigation").length > 0) {
214
+ const navEntry = performance.getEntriesByType("navigation")[0];
215
+ const value = navEntry.responseStart - navEntry.requestStart;
216
+ dispatchMetric({
217
+ name: "TTFB",
218
+ value,
219
+ rating:
220
+ value < 800 ? "good" : value < 1800 ? "needs-improvement" : "poor",
221
+ });
222
+ }
223
+ } catch {
224
+ // TTFB not available.
225
+ }
226
+
227
+ // Return a cleanup function that disconnects all observers.
228
+ return () => {
229
+ for (const observer of observers) {
230
+ try {
231
+ observer.disconnect();
232
+ } catch {
233
+ // Ignore disconnect errors.
234
+ }
235
+ }
236
+ };
237
+ }
@@ -0,0 +1,57 @@
1
+ import { getUmamiConfig } from "./umami-config.js";
2
+
3
+ /**
4
+ * Read the Umami user role cookie set by the admin layout.
5
+ * Returns the role string ("admin", "client_admin", etc.) or null.
6
+ * The inline before-send script in layout.js duplicates this regex
7
+ * because it runs before any module code.
8
+ */
9
+ export function getUmamiUserRole() {
10
+ if (typeof document === "undefined") return null;
11
+ return (document.cookie.match(/umami_user_role=([^;]+)/) || [])[1] || null;
12
+ }
13
+
14
+ /** True when any staff role cookie is present (any value = staff). */
15
+ export function isUmamiStaff() {
16
+ return Boolean(getUmamiUserRole());
17
+ }
18
+
19
+ function getUmamiClient() {
20
+ if (typeof window === "undefined") return null;
21
+ if (!getUmamiConfig().enabled) return null;
22
+ return window.umami ?? null;
23
+ }
24
+
25
+ export function isUmamiEnabled(env = process.env) {
26
+ return getUmamiConfig(env).enabled;
27
+ }
28
+
29
+ export function track(name, data) {
30
+ const umami = getUmamiClient();
31
+ if (!umami?.track) return false;
32
+
33
+ if (typeof name === "string") {
34
+ umami.track(name, data);
35
+ return true;
36
+ }
37
+
38
+ if (name && typeof name === "object") {
39
+ umami.track(name);
40
+ return true;
41
+ }
42
+
43
+ return false;
44
+ }
45
+
46
+ export function identify(data) {
47
+ const umami = getUmamiClient();
48
+ if (!umami?.identify) return false;
49
+
50
+ umami.identify(data);
51
+ return true;
52
+ }
53
+
54
+ export function trackRevenue(amount, currency = "USD", data = {}) {
55
+ if (typeof amount !== "number" || Number.isNaN(amount)) return false;
56
+ return track("revenue", { revenue: amount, currency, ...data });
57
+ }
@@ -0,0 +1,82 @@
1
+ import { ForbiddenError, UnauthorizedError } from "@techstream/quark-core";
2
+ import { auth } from "./auth";
3
+
4
+ const UNAUTHENTICATED = "You must be logged in to access this resource";
5
+
6
+ /**
7
+ * Whether `value` is a session that identifies a real, signed-in user.
8
+ *
9
+ * Truthiness is not enough. `@auth/core` answers a misconfigured provider with
10
+ * a 500, and a client that returns the error body as though it were a session
11
+ * would sail through `if (!session)`. That is not hypothetical: it is exactly
12
+ * what next-auth 5.0.0-beta.31 did, which is why beta.32 added
13
+ * `parseSessionResponse`. An error object is truthy, so the old guard treated a
14
+ * server-side misconfiguration as an authenticated request and handed callers a
15
+ * session with `user === undefined`.
16
+ *
17
+ * beta.32 fixes the source, but the defence belongs here too: a guard that
18
+ * depends on a pre-release library behaving correctly is one beta bump away from
19
+ * failing open again. Requiring a user with an `id` means an error body, a
20
+ * partially-populated session, or a `{}` all fail closed regardless of what the
21
+ * client returns.
22
+ *
23
+ * @param {unknown} value
24
+ * @returns {value is import("next-auth").Session}
25
+ */
26
+ function isAuthenticatedSession(value) {
27
+ if (typeof value !== "object" || value === null) {
28
+ return false;
29
+ }
30
+
31
+ const sessionUser = /** @type {{ user?: unknown }} */ (value).user;
32
+ if (typeof sessionUser !== "object" || sessionUser === null) {
33
+ return false;
34
+ }
35
+
36
+ const id = /** @type {{ id?: unknown, sub?: unknown }} */ (sessionUser).id;
37
+ const subject = /** @type {{ id?: unknown, sub?: unknown }} */ (sessionUser)
38
+ .sub;
39
+
40
+ return (
41
+ (typeof id === "string" && id.length > 0) ||
42
+ (typeof subject === "string" && subject.length > 0)
43
+ );
44
+ }
45
+
46
+ /**
47
+ * Assert that a session identifies a real, signed-in user, or throw.
48
+ *
49
+ * @param {unknown} session
50
+ * @returns {Promise<import("next-auth").Session>}
51
+ */
52
+ export async function requireAuth(session) {
53
+ if (session === undefined) {
54
+ session = await auth();
55
+ }
56
+
57
+ if (!isAuthenticatedSession(session)) {
58
+ throw new UnauthorizedError(UNAUTHENTICATED);
59
+ }
60
+
61
+ return session;
62
+ }
63
+
64
+ /**
65
+ * Require the current user to have one of the specified roles.
66
+ *
67
+ * @param {string | string[]} role - Required role(s) (e.g. "admin" or ["admin", "editor"])
68
+ * @param {unknown} [session] - Injectable for tests; read from `auth()` when omitted.
69
+ * @returns {Promise<import("next-auth").Session>}
70
+ */
71
+ export async function requireRole(role, session) {
72
+ const authenticated = await requireAuth(session);
73
+
74
+ const allowed = Array.isArray(role) ? role : [role];
75
+ if (!allowed.includes(authenticated.user?.role)) {
76
+ throw new ForbiddenError(
77
+ "You do not have permission to access this resource",
78
+ );
79
+ }
80
+
81
+ return authenticated;
82
+ }