@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,138 @@
1
+ import { getConfig } from "./config";
2
+ import { ApiError, type AppClientError } from "./errors";
3
+ import { clearTokens, getRefreshToken, getToken, storeTokens } from "./storage";
4
+
5
+ interface ApiResponse<T = unknown> {
6
+ data: T;
7
+ status: number;
8
+ }
9
+
10
+ interface FetchOptions extends RequestInit {
11
+ timeout?: number;
12
+ }
13
+
14
+ const DEFAULT_TIMEOUT = 15000;
15
+
16
+ /**
17
+ * Injects Bearer token from SecureStore, handles 401 with refresh + retry.
18
+ */
19
+ export async function apiClient<T = unknown>(
20
+ path: string,
21
+ options: FetchOptions = {},
22
+ ): Promise<ApiResponse<T>> {
23
+ const { timeout = DEFAULT_TIMEOUT, ...fetchOptions } = options;
24
+ const config = getConfig();
25
+
26
+ const headers = new Headers(fetchOptions.headers);
27
+ const token = await getToken();
28
+ if (token) {
29
+ headers.set("Authorization", `Bearer ${token}`);
30
+ }
31
+ if (!headers.has("Content-Type") && fetchOptions.body) {
32
+ headers.set("Content-Type", "application/json");
33
+ }
34
+
35
+ const controller = new AbortController();
36
+ const timer = setTimeout(() => controller.abort(), timeout);
37
+
38
+ try {
39
+ const response = await fetch(`${config.apiUrl}${path}`, {
40
+ ...fetchOptions,
41
+ headers,
42
+ signal: controller.signal,
43
+ });
44
+
45
+ clearTimeout(timer);
46
+
47
+ if (response.status === 401 && token) {
48
+ const refreshed = await attemptTokenRefresh();
49
+ if (refreshed) {
50
+ const retryHeaders = new Headers(headers);
51
+ retryHeaders.set("Authorization", `Bearer ${refreshed}`);
52
+ const retryResponse = await fetch(`${config.apiUrl}${path}`, {
53
+ ...fetchOptions,
54
+ headers: retryHeaders,
55
+ signal: controller.signal,
56
+ });
57
+ return parseResponse<T>(retryResponse);
58
+ }
59
+ await clearTokens();
60
+ throw buildError({
61
+ name: "UnauthorizedError",
62
+ message: "Session expired",
63
+ code: "UNAUTHORIZED",
64
+ statusCode: 401,
65
+ });
66
+ }
67
+
68
+ return parseResponse<T>(response);
69
+ } catch (error) {
70
+ clearTimeout(timer);
71
+ if (error instanceof ApiError) throw error;
72
+ if (error instanceof DOMException && error.name === "AbortError") {
73
+ throw buildError({
74
+ name: "TimeoutError",
75
+ message: "Request timed out",
76
+ code: "TIMEOUT",
77
+ statusCode: 408,
78
+ });
79
+ }
80
+ throw buildError({
81
+ name: "NetworkError",
82
+ message: error instanceof Error ? error.message : "Network error",
83
+ code: "NETWORK_ERROR",
84
+ statusCode: 0,
85
+ });
86
+ }
87
+ }
88
+
89
+ async function attemptTokenRefresh(): Promise<string | null> {
90
+ try {
91
+ const refreshToken = await getRefreshToken();
92
+ if (!refreshToken) return null;
93
+
94
+ const config = getConfig();
95
+ const response = await fetch(`${config.apiUrl}/api/auth/refresh`, {
96
+ method: "POST",
97
+ headers: { "Content-Type": "application/json" },
98
+ body: JSON.stringify({ refreshToken }),
99
+ });
100
+
101
+ if (!response.ok) return null;
102
+
103
+ const data = await response.json();
104
+ await storeTokens(data.token, refreshToken);
105
+ return data.token;
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ async function parseResponse<T>(response: Response): Promise<ApiResponse<T>> {
112
+ const text = await response.text();
113
+ let data: unknown;
114
+ try {
115
+ data = JSON.parse(text);
116
+ } catch {
117
+ data = text;
118
+ }
119
+
120
+ if (!response.ok) {
121
+ const errorData = data as Partial<AppClientError>;
122
+ throw buildError({
123
+ name: errorData.name || "ApiError",
124
+ message:
125
+ errorData.message || `Request failed with status ${response.status}`,
126
+ code: errorData.code || "UNKNOWN_ERROR",
127
+ statusCode: response.status,
128
+ details: errorData.details,
129
+ retryAfter: errorData.retryAfter,
130
+ });
131
+ }
132
+
133
+ return { data: data as T, status: response.status };
134
+ }
135
+
136
+ function buildError(params: AppClientError): ApiError {
137
+ return new ApiError(params);
138
+ }
@@ -0,0 +1,83 @@
1
+ import { apiClient } from "./api-client";
2
+
3
+ /**
4
+ * Typed API client functions.
5
+ * JSDoc types used; replace with generated types when OpenAPI spec is available.
6
+ */
7
+
8
+ /**
9
+ * Get the current user's profile.
10
+ * @returns {Promise<{ id: string, email: string, name: string | null, role: string }>}
11
+ */
12
+ export async function getProfile() {
13
+ const { data } = await apiClient("/api/users/me");
14
+ return data;
15
+ }
16
+
17
+ /**
18
+ * Update the current user's profile.
19
+ * @param {object} updates - Fields to update
20
+ */
21
+ export async function updateProfile(updates: Record<string, unknown>) {
22
+ const { data } = await apiClient("/api/users/me", {
23
+ method: "PATCH",
24
+ body: JSON.stringify(updates),
25
+ });
26
+ return data;
27
+ }
28
+
29
+ /**
30
+ * List items with pagination.
31
+ * @param {object} params - Query parameters
32
+ */
33
+ export async function listItems(params = {}) {
34
+ const query = new URLSearchParams(params).toString();
35
+ const path = query ? `/api/items?${query}` : "/api/items";
36
+ const { data } = await apiClient(path);
37
+ return data;
38
+ }
39
+
40
+ /**
41
+ * Get a single item by ID.
42
+ * @param {string} id
43
+ */
44
+ export async function getItem(id: string) {
45
+ const { data } = await apiClient(`/api/items/${id}`);
46
+ return data;
47
+ }
48
+
49
+ /**
50
+ * Create a new item.
51
+ * @param {object} itemData
52
+ */
53
+ export async function createItem(itemData: Record<string, unknown>) {
54
+ const { data } = await apiClient("/api/items", {
55
+ method: "POST",
56
+ body: JSON.stringify(itemData),
57
+ });
58
+ return data;
59
+ }
60
+
61
+ /**
62
+ * Update an item.
63
+ * @param {string} id
64
+ * @param {object} updates
65
+ */
66
+ export async function updateItem(id: string, updates: Record<string, unknown>) {
67
+ const { data } = await apiClient(`/api/items/${id}`, {
68
+ method: "PATCH",
69
+ body: JSON.stringify(updates),
70
+ });
71
+ return data;
72
+ }
73
+
74
+ /**
75
+ * Delete an item.
76
+ * @param {string} id
77
+ */
78
+ export async function deleteItem(id: string) {
79
+ const { data } = await apiClient(`/api/items/${id}`, {
80
+ method: "DELETE",
81
+ });
82
+ return data;
83
+ }
@@ -0,0 +1,74 @@
1
+ import {
2
+ makeRedirectUri,
3
+ ResponseType,
4
+ useAuthRequest,
5
+ } from "expo-auth-session";
6
+ import { useEffect } from "react";
7
+ import { getConfig } from "./config";
8
+ import { ApiError } from "./errors";
9
+ import { storeTokens } from "./storage";
10
+
11
+ interface SocialAuthResponse {
12
+ token: string;
13
+ refreshToken: string;
14
+ expiresAt: string;
15
+ }
16
+
17
+ const discovery = {
18
+ authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
19
+ tokenEndpoint: "https://oauth2.googleapis.com/token",
20
+ revocationEndpoint: "https://oauth2.googleapis.com/revoke",
21
+ };
22
+
23
+ /**
24
+ * Hook-based Google Sign-In using expo-auth-session.
25
+ * Returns the request function and any error.
26
+ */
27
+ export function useGoogleAuth() {
28
+ const redirectUri = makeRedirectUri({
29
+ scheme: "com.myquark.app",
30
+ path: "google",
31
+ native: "com.myquark.app://google",
32
+ });
33
+
34
+ const [request, response, promptAsync] = useAuthRequest(
35
+ {
36
+ clientId: process.env.EXPO_PUBLIC_GOOGLE_CLIENT_ID || "",
37
+ redirectUri,
38
+ responseType: ResponseType.IdToken,
39
+ scopes: ["openid", "profile", "email"],
40
+ },
41
+ discovery,
42
+ );
43
+
44
+ useEffect(() => {
45
+ if (response?.type === "success") {
46
+ const { id_token } = response.params;
47
+ exchangeGoogleToken(id_token).catch(() => {});
48
+ }
49
+ }, [response]);
50
+
51
+ return { request, promptAsync };
52
+ }
53
+
54
+ async function exchangeGoogleToken(idToken: string): Promise<void> {
55
+ const config = getConfig();
56
+ const response = await fetch(`${config.apiUrl}/api/auth/google`, {
57
+ method: "POST",
58
+ headers: { "Content-Type": "application/json" },
59
+ body: JSON.stringify({ idToken }),
60
+ });
61
+
62
+ if (!response.ok) {
63
+ const error = await response.json().catch(() => null);
64
+ throw new ApiError({
65
+ name: "AuthError",
66
+ message: error?.message || "Google authentication failed",
67
+ code: "GOOGLE_AUTH_FAILED",
68
+ statusCode: 401,
69
+ });
70
+ }
71
+
72
+ const data: SocialAuthResponse = await response.json();
73
+ await storeTokens(data.token, data.refreshToken);
74
+ }
@@ -0,0 +1,74 @@
1
+ import * as AppleAuthentication from "expo-apple-authentication";
2
+ import * as Crypto from "expo-crypto";
3
+ import { getConfig } from "./config";
4
+ import { ApiError } from "./errors";
5
+ import { storeTokens } from "./storage";
6
+
7
+ interface SocialAuthResponse {
8
+ token: string;
9
+ refreshToken: string;
10
+ expiresAt: string;
11
+ }
12
+
13
+ /**
14
+ * Sign in with Apple.
15
+ * Gets an identity token from Apple, sends it to the backend,
16
+ * receives a custom JWT, and stores it.
17
+ */
18
+ export async function signInWithApple(): Promise<void> {
19
+ const isAvailable = await AppleAuthentication.isAvailableAsync();
20
+ if (!isAvailable) {
21
+ throw new ApiError({
22
+ name: "AuthError",
23
+ message: "Apple Sign In is not available on this device",
24
+ code: "APPLE_UNAVAILABLE",
25
+ statusCode: 400,
26
+ });
27
+ }
28
+
29
+ const nonce = Crypto.randomUUID();
30
+ const digest = await Crypto.digestStringAsync(
31
+ Crypto.CryptoDigestAlgorithm.SHA256,
32
+ nonce,
33
+ );
34
+
35
+ const credential = await AppleAuthentication.signInAsync({
36
+ requestedScopes: [
37
+ AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
38
+ AppleAuthentication.AppleAuthenticationScope.EMAIL,
39
+ ],
40
+ nonce: digest,
41
+ });
42
+
43
+ if (!credential.identityToken) {
44
+ throw new ApiError({
45
+ name: "AuthError",
46
+ message: "Apple Sign In failed: no identity token received",
47
+ code: "APPLE_NO_TOKEN",
48
+ statusCode: 400,
49
+ });
50
+ }
51
+
52
+ const config = getConfig();
53
+ const response = await fetch(`${config.apiUrl}/api/auth/apple`, {
54
+ method: "POST",
55
+ headers: { "Content-Type": "application/json" },
56
+ body: JSON.stringify({
57
+ identityToken: credential.identityToken,
58
+ nonce,
59
+ }),
60
+ });
61
+
62
+ if (!response.ok) {
63
+ const error = await response.json().catch(() => null);
64
+ throw new ApiError({
65
+ name: "AuthError",
66
+ message: error?.message || "Apple authentication failed",
67
+ code: "APPLE_AUTH_FAILED",
68
+ statusCode: 401,
69
+ });
70
+ }
71
+
72
+ const data: SocialAuthResponse = await response.json();
73
+ await storeTokens(data.token, data.refreshToken);
74
+ }
@@ -0,0 +1,100 @@
1
+ import { apiClient } from "./api-client";
2
+ import { getConfig } from "./config";
3
+ import { ApiError } from "./errors";
4
+ import { clearTokens, getToken, storeTokens } from "./storage";
5
+
6
+ interface TokenResponse {
7
+ token: string;
8
+ refreshToken: string;
9
+ expiresAt: string;
10
+ }
11
+
12
+ interface RefreshResponse {
13
+ token: string;
14
+ }
15
+
16
+ interface UserProfile {
17
+ id: string;
18
+ email: string;
19
+ name: string | null;
20
+ role: string;
21
+ }
22
+
23
+ export async function signIn(email: string, password: string): Promise<void> {
24
+ const config = getConfig();
25
+ const response = await fetch(`${config.apiUrl}/api/auth/token`, {
26
+ method: "POST",
27
+ headers: { "Content-Type": "application/json" },
28
+ body: JSON.stringify({ email, password }),
29
+ });
30
+
31
+ if (!response.ok) {
32
+ const error = await response.json().catch(() => null);
33
+ throw new ApiError({
34
+ name: "AuthError",
35
+ message: error?.message || "Invalid credentials",
36
+ code: "INVALID_CREDENTIALS",
37
+ statusCode: 401,
38
+ });
39
+ }
40
+
41
+ const data: TokenResponse = await response.json();
42
+ await storeTokens(data.token, data.refreshToken);
43
+ }
44
+
45
+ export async function signOut(): Promise<void> {
46
+ await clearTokens();
47
+ }
48
+
49
+ export async function refreshToken(): Promise<string> {
50
+ const { getRefreshToken } = await import("./storage");
51
+ const refreshTokenValue = await getRefreshToken();
52
+ if (!refreshTokenValue)
53
+ throw new ApiError({
54
+ name: "AuthError",
55
+ message: "No refresh token available",
56
+ code: "NO_REFRESH_TOKEN",
57
+ statusCode: 401,
58
+ });
59
+
60
+ const config = getConfig();
61
+ const response = await fetch(`${config.apiUrl}/api/auth/refresh`, {
62
+ method: "POST",
63
+ headers: { "Content-Type": "application/json" },
64
+ body: JSON.stringify({ refreshToken: refreshTokenValue }),
65
+ });
66
+
67
+ if (!response.ok) {
68
+ await clearTokens();
69
+ throw new ApiError({
70
+ name: "AuthError",
71
+ message: "Token refresh failed",
72
+ code: "TOKEN_REFRESH_FAILED",
73
+ statusCode: 401,
74
+ });
75
+ }
76
+
77
+ const data: RefreshResponse = await response.json();
78
+ await storeTokens(data.token, refreshTokenValue);
79
+ return data.token;
80
+ }
81
+
82
+ export async function isAuthenticated(): Promise<boolean> {
83
+ const token = await getToken();
84
+ return token !== null;
85
+ }
86
+
87
+ export async function getProfile(): Promise<UserProfile> {
88
+ const { data } = await apiClient<UserProfile>("/api/users/me");
89
+ return data;
90
+ }
91
+
92
+ export async function updateProfile(
93
+ updates: Partial<Pick<UserProfile, "name">>,
94
+ ): Promise<UserProfile> {
95
+ const { data } = await apiClient<UserProfile>("/api/users/me", {
96
+ method: "PATCH",
97
+ body: JSON.stringify(updates),
98
+ });
99
+ return data;
100
+ }
@@ -0,0 +1,14 @@
1
+ import { ApiError } from "./errors";
2
+
3
+ export function getConfig() {
4
+ const apiUrl = process.env.EXPO_PUBLIC_API_URL;
5
+ if (!apiUrl) {
6
+ throw new ApiError({
7
+ name: "ConfigError",
8
+ message: "EXPO_PUBLIC_API_URL is not set. Add it to your .env file.",
9
+ code: "MISSING_CONFIG",
10
+ statusCode: 500,
11
+ });
12
+ }
13
+ return { apiUrl };
14
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Mobile equivalent of @techstream/quark-core's AppError.
3
+ *
4
+ * React Native cannot import Node.js packages, so this module defines a
5
+ * client-side error class whose shape mirrors AppError.toJSON() — same
6
+ * fields (code, statusCode, details) — so the web API's error responses
7
+ * are consumed identically. Keep the two in sync if adding new fields.
8
+ */
9
+ export interface AppClientError {
10
+ name: string;
11
+ message: string;
12
+ code: string;
13
+ statusCode: number;
14
+ timestamp?: string;
15
+ details?: unknown;
16
+ retryAfter?: number;
17
+ }
18
+
19
+ export class ApiError extends Error {
20
+ code: string;
21
+ statusCode: number;
22
+ details?: unknown;
23
+
24
+ constructor(error: AppClientError) {
25
+ super(error.message);
26
+ this.name = error.name;
27
+ this.code = error.code;
28
+ this.statusCode = error.statusCode;
29
+ this.details = error.details;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Pattern-match on error codes for conditional handling.
35
+ */
36
+ export function isValidationError(error: unknown): error is ApiError {
37
+ return error instanceof ApiError && error.code === "VALIDATION_ERROR";
38
+ }
39
+
40
+ export function isUnauthorizedError(error: unknown): error is ApiError {
41
+ return error instanceof ApiError && error.code === "UNAUTHORIZED";
42
+ }
43
+
44
+ export function isRateLimitError(error: unknown): error is ApiError {
45
+ return error instanceof ApiError && error.code === "RATE_LIMIT";
46
+ }
@@ -0,0 +1,77 @@
1
+ import { Platform } from "react-native";
2
+ import { apiClient } from "./api-client";
3
+
4
+ /**
5
+ * Register the device push token with the backend.
6
+ * Must be called after authentication.
7
+ */
8
+ export async function registerForPushNotifications(): Promise<string | null> {
9
+ const Device = await import("expo-device");
10
+ if (!Device.isDevice) {
11
+ return null;
12
+ }
13
+
14
+ const Notifications = await import("expo-notifications");
15
+
16
+ const { status: existingStatus } = await Notifications.getPermissionsAsync();
17
+ let finalStatus = existingStatus;
18
+
19
+ if (existingStatus !== "granted") {
20
+ const { status } = await Notifications.requestPermissionsAsync();
21
+ finalStatus = status;
22
+ }
23
+
24
+ if (finalStatus !== "granted") {
25
+ return null;
26
+ }
27
+
28
+ const tokenData = await Notifications.getExpoPushTokenAsync();
29
+ const pushToken = tokenData.data;
30
+
31
+ if (Platform.OS === "android") {
32
+ Notifications.setNotificationChannelAsync("default", {
33
+ name: "default",
34
+ importance: Notifications.AndroidImportance.MAX,
35
+ vibrationPattern: [0, 250, 250, 250],
36
+ lightColor: "#FF231F7C",
37
+ });
38
+ }
39
+
40
+ return pushToken;
41
+ }
42
+
43
+ /**
44
+ * Send the push token to the backend for storage.
45
+ */
46
+ export async function registerDevice(pushToken: string): Promise<void> {
47
+ const { Platform: PlatformModule } = await import("react-native");
48
+ const Constants = await import("expo-constants");
49
+ const deviceId =
50
+ Constants.default?.installationId ||
51
+ Constants.default?.expoConfig?.extra?.eas?.projectId ||
52
+ "unknown";
53
+
54
+ await apiClient("/api/device/register", {
55
+ method: "POST",
56
+ body: JSON.stringify({
57
+ platform: PlatformModule.OS,
58
+ pushToken,
59
+ deviceId,
60
+ }),
61
+ });
62
+ }
63
+
64
+ /**
65
+ * Configure foreground notification handling.
66
+ */
67
+ export async function configureNotificationHandler() {
68
+ const Notifications = await import("expo-notifications");
69
+ Notifications.setNotificationHandler({
70
+ handleNotification: async () => ({
71
+ shouldShowBanner: true,
72
+ shouldShowList: true,
73
+ shouldPlaySound: true,
74
+ shouldSetBadge: false,
75
+ }),
76
+ });
77
+ }
@@ -0,0 +1,25 @@
1
+ import * as SecureStore from "expo-secure-store";
2
+
3
+ const TOKEN_KEY = "auth_token";
4
+ const REFRESH_TOKEN_KEY = "auth_refresh_token";
5
+
6
+ export async function storeTokens(
7
+ token: string,
8
+ refreshToken: string,
9
+ ): Promise<void> {
10
+ await SecureStore.setItemAsync(TOKEN_KEY, token);
11
+ await SecureStore.setItemAsync(REFRESH_TOKEN_KEY, refreshToken);
12
+ }
13
+
14
+ export async function getToken(): Promise<string | null> {
15
+ return SecureStore.getItemAsync(TOKEN_KEY);
16
+ }
17
+
18
+ export async function getRefreshToken(): Promise<string | null> {
19
+ return SecureStore.getItemAsync(REFRESH_TOKEN_KEY);
20
+ }
21
+
22
+ export async function clearTokens(): Promise<void> {
23
+ await SecureStore.deleteItemAsync(TOKEN_KEY);
24
+ await SecureStore.deleteItemAsync(REFRESH_TOKEN_KEY);
25
+ }
@@ -0,0 +1,16 @@
1
+ import { useTheme } from "../components/ThemeProvider";
2
+
3
+ export function useThemeTokens() {
4
+ const theme = useTheme();
5
+ const isDark = theme === "dark";
6
+
7
+ return {
8
+ isDark,
9
+ bgColor: isDark ? "#000" : "#fff",
10
+ textColor: isDark ? "#fff" : "#000",
11
+ mutedColor: isDark ? "#888" : "#666",
12
+ cardBg: isDark ? "#1c1c1e" : "#f2f2f7",
13
+ borderColor: isDark ? "#38383a" : "#e5e5ea",
14
+ inputBg: isDark ? "#2c2c2e" : "#fff",
15
+ };
16
+ }
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@myquark/mobile",
3
+ "version": "0.1.0",
4
+ "main": "expo-router/entry",
5
+ "scripts": {
6
+ "dev": "CI=1 expo start",
7
+ "start": "expo start",
8
+ "android": "expo start --android",
9
+ "ios": "expo start --ios",
10
+ "web": "expo start --web",
11
+ "lint": "biome check .",
12
+ "lint:fix": "biome check --write ."
13
+ },
14
+ "dependencies": {
15
+ "@expo/vector-icons": "^15.1.1",
16
+ "expo": "~57.0.25",
17
+ "expo-apple-authentication": "~57.0.2",
18
+ "expo-auth-session": "~57.0.13",
19
+ "expo-constants": "~57.0.19",
20
+ "expo-crypto": "~57.0.3",
21
+ "expo-device": "~57.0.2",
22
+ "expo-notifications": "~57.0.21",
23
+ "expo-router": "~57.0.23",
24
+ "expo-secure-store": "~57.0.4",
25
+ "expo-status-bar": "~57.0.1",
26
+ "expo-updates": "~57.0.23",
27
+ "react": "19.3.0",
28
+ "react-native": "0.87.1",
29
+ "react-native-safe-area-context": "~5.10.0",
30
+ "react-native-screens": "~4.28.0"
31
+ },
32
+ "devDependencies": {
33
+ "@biomejs/biome": "^2.5.14",
34
+ "@types/react": "~19.3.0",
35
+ "typescript": "~7.0.2"
36
+ }
37
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "expo/tsconfig.base",
3
+ "compilerOptions": {
4
+ "strict": true,
5
+ "paths": {
6
+ "@/*": ["./*"]
7
+ }
8
+ },
9
+ "include": ["**/*.ts", "**/*.tsx"]
10
+ }