@usequark/quark-create-app 1.25.6 → 1.25.8

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usequark/quark-create-app",
3
- "version": "1.25.6",
3
+ "version": "1.25.8",
4
4
  "description": "CLI to scaffold new Quark projects with Next.js, Prisma, BullMQ, and Railway deployment",
5
5
  "keywords": [
6
6
  "quark",
@@ -106,6 +106,7 @@ export async function generateIacFile({
106
106
  variableRefs = {},
107
107
  serviceVars = {},
108
108
  projectName = "quark-app",
109
+ provision = true,
109
110
  }) {
110
111
  const { default: fs } = await import("fs-extra");
111
112
  const { dirname } = await import("node:path");
@@ -167,7 +168,83 @@ export async function generateIacFile({
167
168
  );
168
169
  }
169
170
 
170
- const resources = services.map((s) => toIdentifier(s.name)).join(", ");
171
+ // `postgres` and `redis` are dedicated helpers; anything else falls back to
172
+ // the generic `database` helper. Both are already in IAC_HELPERS, so the
173
+ // import list picks them up automatically.
174
+ const DATABASE_HELPERS = { Postgres: "postgres", Redis: "redis" };
175
+
176
+ // Declare every database the services reference, so a whole-project apply
177
+ // does not plan to delete it.
178
+ //
179
+ // Railway treats an omitted resource in a single-file project as absent, and
180
+ // absent means delete: "One project definition, one apply, and omitting a
181
+ // resource means deleting it." The databases are provisioned out-of-band by
182
+ // `ensurePlugin()` (`railway add --database`), then referenced here via
183
+ // `${{Postgres.DATABASE_URL}}`. Without this block a second deploy plans to
184
+ // destroy the databases the first one created.
185
+ //
186
+ // When `provision` is false (`--no-provision`), we must distinguish two
187
+ // cases:
188
+ //
189
+ // 1. A fresh project that has never been provisioned: no databases should be
190
+ // declared, because declaring them would provision them on the next apply
191
+ // — exactly what the user opted out of.
192
+ //
193
+ // 2. An existing project whose databases were provisioned by an earlier
194
+ // deploy: those databases are already managed by this file. Removing
195
+ // their declarations would make `railway config apply` delete them,
196
+ // because omitted resources are deletions. So we preserve any database
197
+ // that the existing file already declares, and only suppress declarations
198
+ // that would be new.
199
+ let previousContent = null;
200
+ try {
201
+ previousContent = await fs.readFile(iacPath, "utf8");
202
+ } catch {
203
+ previousContent = null;
204
+ }
205
+
206
+ const serviceIdentifiers = new Set(services.map((s) => toIdentifier(s.name)));
207
+ const databaseNames = new Set();
208
+ const referencePattern = /\$\{\{([A-Za-z_][\w]*)\.([A-Za-z_][\w]*)\}\}/g;
209
+
210
+ for (const value of Object.values(variableRefs)) {
211
+ for (const match of String(value).matchAll(referencePattern)) {
212
+ if (!serviceIdentifiers.has(match[1])) {
213
+ databaseNames.add(match[1]);
214
+ }
215
+ }
216
+ }
217
+
218
+ if (!provision) {
219
+ // Preserve databases the existing file already declares; suppress only
220
+ // declarations that would be new. When there is no existing file, no
221
+ // databases are already declared, so all are suppressed.
222
+ for (const dbName of [...databaseNames]) {
223
+ const alreadyDeclared =
224
+ previousContent !== null &&
225
+ new RegExp(
226
+ `\\b${DATABASE_HELPERS[dbName] ?? "database"}\\s*\\(\\s*["']${escapeTsString(dbName)}["']`,
227
+ ).test(previousContent);
228
+ if (!alreadyDeclared) {
229
+ databaseNames.delete(dbName);
230
+ }
231
+ }
232
+ }
233
+
234
+ const sortedDatabases = [...databaseNames].sort();
235
+
236
+ for (const dbName of sortedDatabases) {
237
+ const helper = DATABASE_HELPERS[dbName] ?? "database";
238
+ const dbIdentifier = `${toIdentifier(dbName)}Db`;
239
+ resourceLines.push(
240
+ `\tconst ${dbIdentifier} = ${helper}("${escapeTsString(dbName)}");`,
241
+ );
242
+ }
243
+
244
+ const resources = [
245
+ ...services.map((s) => toIdentifier(s.name)),
246
+ ...sortedDatabases.map((name) => `${toIdentifier(name)}Db`),
247
+ ].join(", ");
171
248
 
172
249
  const body = `export default defineRailway(() => {
173
250
  ${resourceLines.join("\n\n")}
@@ -181,6 +181,7 @@ export async function deployToRailway(options = {}) {
181
181
  services: discovery.services,
182
182
  // Pass raw name — generateIacFile handles escaping internally
183
183
  projectName: projectLabel,
184
+ provision,
184
185
  variableRefs: {
185
186
  // Quoted: these land inside generated TypeScript as object values,
186
187
  // where `${{...}}` must be a string literal to parse at all. The
@@ -23,6 +23,21 @@ AUTH_ALLOW_SIGNUP=false
23
23
  # Must be at least 32 characters or startup validation fails.
24
24
  NEXTAUTH_SECRET=CHANGE_ME_TO_A_32_CHARACTERS_OR_LONGER_SECRET
25
25
 
26
+ # --- Optional OAuth providers ---
27
+ # Unset by default; social sign-in stays off. These are the credentials the
28
+ # /api/auth/google and /api/auth/apple routes bind tokens to — with no client
29
+ # id, the matching route refuses every request with 503. Server-only: do not
30
+ # prefix them with NEXT_PUBLIC_.
31
+ #
32
+ # APPLE_CLIENT_ID accepts a comma-separated list: Apple identity tokens carry
33
+ # either the bundle identifier (native iOS) or the Services ID (web flow) as
34
+ # their `aud` claim, so set both to accept either.
35
+ # GOOGLE_CLIENT_ID=
36
+ # GOOGLE_CLIENT_SECRET=
37
+ # APPLE_CLIENT_ID=
38
+ # GITHUB_ID=
39
+ # GITHUB_SECRET=
40
+
26
41
  # APP_URL is the canonical public URL. NEXTAUTH_URL is derived automatically.
27
42
  # Uncomment only if you need to override it explicitly.
28
43
  # NEXTAUTH_URL=https://__QUARK_PROJECT_NAME__.railway.app
@@ -1,4 +1,4 @@
1
- import { defineRailway, preserve, project, service } from "railway/iac";
1
+ import { defineRailway, postgres, preserve, project, redis, service } from "railway/iac";
2
2
 
3
3
  export default defineRailway(() => {
4
4
  const web = service("web", {
@@ -34,7 +34,10 @@ export default defineRailway(() => {
34
34
  },
35
35
  });
36
36
 
37
+ const PostgresDb = postgres("Postgres");
38
+ const RedisDb = redis("Redis");
39
+
37
40
  return project("__QUARK_PROJECT_NAME__", {
38
- resources: [web, worker],
41
+ resources: [web, worker, PostgresDb, RedisDb],
39
42
  });
40
43
  });
@@ -0,0 +1,39 @@
1
+ # Deployment Guide
2
+
3
+ ## Railway
4
+
5
+ ### 1. Push to GitHub
6
+
7
+ ```bash
8
+ gh repo create your-project-name --private --source=. --push
9
+ ```
10
+
11
+ No `gh` CLI? Go to [github.com/new](https://github.com/new), create the repo, then:
12
+
13
+ ```bash
14
+ git remote add origin https://github.com/your-username/your-project-name.git
15
+ git push -u origin main
16
+ ```
17
+
18
+ ### 2. Deploy on Railway
19
+
20
+ 1. Go to [railway.app](https://railway.app) → **New Project** → **Deploy from GitHub repo** → select `your-project-name`
21
+ 2. In the service → **Settings** → **Source**:
22
+ - Set **Root Directory** to `/` (the repo root - Railpack needs the full monorepo context to resolve workspace dependencies)
23
+ 3. In the project → **+ New** → **Database** → **Add PostgreSQL**
24
+ 4. In the project → **+ New** → **Database** → **Add Redis**
25
+ 5. Use `.env.railway.example` as the default Railway variable set:
26
+ - Copy the non-service defaults into Railway shared variables or each service's variables as your starting point
27
+ - Wire `DATABASE_URL` and `REDIS_URL` with Railway service references or paste the managed service connection strings Railway provides
28
+ - Replace `NEXTAUTH_SECRET`, `APP_URL`, and `ADMIN_PASSWORD`
29
+ - `HOSTNAME=0.0.0.0` is included so the standalone Next server binds correctly on Railway
30
+ - Apply `DATABASE_URL`, `REDIS_URL`, `STORAGE_PROVIDER`, and `WORKER_CONCURRENCY` to the worker too if you deploy it
31
+ 6. *(If you included the worker)* **+ New** → **GitHub Repo** → same repo → **Root Directory** → `/`
32
+
33
+ Railway auto-deploys on every push to `main`. Migrations run automatically before each deploy via `preDeploy` in `.railway/railway.ts`.
34
+
35
+ If you need to seed Railway manually after the first migration, run `SEED_PROFILE=minimal pnpm db:seed` inside a Railway shell/one-off command, or from a machine using Railway's externally reachable database credentials, and provide the required admin credentials. The seed guard rejects remote seeds without an explicit profile on purpose.
36
+
37
+ > **Important:** Root Directory must be `/` (not `apps/web`) so Railpack can resolve pnpm workspace dependencies. The `.railway/railway.ts` file defines all service configuration via Infrastructure as Code.
38
+ >
39
+ > Railway starts the web service with `HOSTNAME=0.0.0.0 pnpm --dir apps/web start:deploy` (configured in `.railway/railway.ts`), which delegates to the standalone entrypoint `node .next/standalone/apps/web/server.js`. This forces the standalone Next server to bind on Railway's network interface. The `pnpm start` script uses `next start` which requires full `node_modules` - it's for local testing only.
@@ -1,164 +1,85 @@
1
- # __QUARK_PROJECT_NAME__
1
+ <p align="center">
2
+ <img src="apps/web/public/quark.svg" alt="__QUARK_PROJECT_NAME__" width="120" />
3
+ </p>
2
4
 
3
- Scaffolded with [Quark](https://github.com/usequark/quark) on __QUARK_SCAFFOLD_DATE__.
5
+ <p align="center">
6
+ <strong>__QUARK_PROJECT_BRIEF__</strong>
7
+ </p>
4
8
 
5
- ## Start Here
9
+ <p align="center">
10
+ <img src="https://img.shields.io/badge/Node.js-22-339933?style=flat&logo=node.js&logoColor=white" alt="Node.js 22" />
11
+ <img src="https://img.shields.io/badge/Next.js-16-000000?style=flat&logo=next.js&logoColor=white" alt="Next.js 16" />
12
+ <img src="https://img.shields.io/badge/Prisma-7-2D3748?style=flat&logo=prisma&logoColor=white" alt="Prisma 7" />
13
+ </p>
6
14
 
7
- - `docs/START_HERE.md` - current onboarding path for this project
8
- - `docs/FIRST_FEATURE.md` - add your first domain model, query helper, and page
9
- - `.opencode/skills/` - embedded skills (add a model, endpoint, dashboard, or build a booking/CRM/CMS/AI system)
10
- - `CLAUDE.md` - AI tool context for Claude Code, Cursor, Copilot, and others
15
+ ---
11
16
 
12
- ## Local Development
17
+ ## Quick Start
13
18
 
14
19
  ```bash
15
- docker compose up -d # Start PostgreSQL, Redis, Mailpit
16
- pnpm install # Install dependencies
17
- pnpm db:migrate # Apply migrations
18
- pnpm db:seed # Seed the database (admin user + sample data)
19
- pnpm dev # Start the app (and worker, if included)
20
+ pnpm install
21
+ docker compose up -d
22
+ pnpm db:migrate
23
+ pnpm dev
20
24
  ```
21
25
 
22
26
  Open [http://localhost:__QUARK_WEB_PORT__](http://localhost:__QUARK_WEB_PORT__)
23
27
 
24
- ## UI References
28
+ ## Services
25
29
 
26
- - `apps/web/src/app/page.js` - production-style public page built from the shared UI package
27
- - `packages/ui/README.md` - supported component surface and props
28
-
29
- ## Included in This Project
30
-
31
- | Area | Status | Where to start |
32
- |---|---|---|
33
- | Web app | Included | `apps/web/src/app/` |
34
- | Database | Included | `packages/db/prisma/schema.prisma` |
35
- | Config | Included | `packages/config/src/load-config.js` |
36
- __QUARK_FEATURE_ROWS__
37
-
38
- Optional features can be added later with:
30
+ | Service | Description | Port(s) |
31
+ | ---------- | -------------------------- | ------------------ |
32
+ | PostgreSQL | Database | `5432` |
33
+ | Redis | Cache & job queue | `6379` |
34
+ | Mailpit | Local SMTP / email viewer | `1025` / `8025` |
39
35
 
40
36
  ```bash
41
- npx @usequark/quark-create-app add <feature>
37
+ docker compose up -d # start all services
38
+ docker compose down # stop all services
42
39
  ```
43
40
 
44
- ### PWA (Optional)
45
-
46
- When enabled, your app includes:
47
- - `src/app/manifest.json` — web app manifest for installability (served natively by Next.js)
48
- - `public/sw.js` — vanilla service worker with cache-first static assets and network-first navigation
49
- - `src/app/_components/PWARegister.js` — client component that registers the SW in production
50
-
51
- Edit `manifest.json` to set your app name, theme color, and icons.
52
- The service worker uses standard Web APIs with no external dependencies.
53
-
54
- ## First Files to Edit
55
-
56
- - `packages/db/prisma/schema.prisma` - add your domain models
57
- - `packages/db/src/queries.js` - add reusable query helpers
58
- - `apps/web/src/app/` - build pages, Server Actions, and route handlers
59
- __QUARK_FIRST_EDITS__
60
-
61
- ## Feature Guides
62
-
63
- - `docs/FIRST_FEATURE.md` - the best first walkthrough for product work
64
- - `packages/db/src/queries.js` - the canonical place for reusable data access
65
- __QUARK_FEATURE_GUIDES__
66
-
67
- ## Database
68
-
69
- | Task | Command |
70
- |------|---------|
71
- | Run migrations | `pnpm db:migrate` |
72
- | Push schema (no migration) | `pnpm db:push` |
73
- | Generate Prisma client | `pnpm db:generate` |
74
- | Seed database | `pnpm db:seed` |
75
- | Open Prisma Studio | `pnpm db:studio` |
76
-
77
- ## Other Commands
41
+ ## Development
78
42
 
79
43
  ```bash
80
- pnpm build # Build all packages
81
- pnpm test # Run all tests (requires Docker)
82
- pnpm lint # Lint + format check (Biome)
83
- pnpm clean:check # Review reclaimable build artifacts
84
- pnpm clean # Remove local build artifacts
85
- pnpm clean:deep # Also remove repo-local temp workspaces
86
- QUARK_SKIP_AUTO_CLEAN=1 pnpm dev # Disable dev auto-clean for this run
87
- npx @usequark/quark-create-app update --scaffold-check # Review scaffold-managed drift
88
- npx @usequark/quark-create-app update --scaffold-check --fail-on-drift # CI-friendly drift check
44
+ pnpm build # Build all packages
45
+ pnpm test # Run tests
46
+ pnpm lint # Lint & format (Biome)
89
47
  ```
90
48
 
91
- ## Launch
92
-
93
- ### 1. Push to GitHub
49
+ ## Database
94
50
 
95
- ```bash
96
- gh repo create __QUARK_PROJECT_NAME__ --private --source=. --push
97
- ```
51
+ | Task | Command |
52
+ | -------------------------- | ------------------- |
53
+ | Run migrations | `pnpm db:migrate` |
54
+ | Push schema (no migration) | `pnpm db:push` |
55
+ | Generate Prisma client | `pnpm db:generate` |
56
+ | Seed database | `pnpm db:seed` |
57
+ | Open Prisma Studio | `pnpm db:studio` |
98
58
 
99
- No `gh` CLI? Go to [github.com/new](https://github.com/new), create the repo, then:
59
+ ## Project Structure
100
60
 
101
- ```bash
102
- git remote add origin https://github.com/<you>/__QUARK_PROJECT_NAME__.git
103
- git push -u origin main
61
+ ```
62
+ __QUARK_PROJECT_NAME__/
63
+ apps/
64
+ web/ → Next.js 16 (App Router, Server Actions)
65
+ __QUARK_OPTIONAL_APPS__packages/
66
+ config/ → Shared environment & app configuration
67
+ db/ → Prisma schema, migrations & queries
68
+ __QUARK_OPTIONAL_PACKAGES__
104
69
  ```
105
70
 
106
- > **Nested inside an existing repository?** GitHub Actions only reads workflow
107
- > files from the repository root, so this project's `.github/workflows/*.yml`
108
- > will be ignored. Copy them into the parent repository's
109
- > `.github/workflows/` and add `working-directory: <path-to-this-project>` to
110
- > each job's steps (plus path filters so the workflows only run on changes in
111
- > this directory).
112
-
113
- ### 2. Deploy on Railway
114
-
115
- > **One-click Railway template coming soon.** For now, follow these steps:
116
-
117
- 1. Go to [railway.app](https://railway.app) → **New Project** → **Deploy from GitHub repo** → select `__QUARK_PROJECT_NAME__`
118
- 2. In the service → **Settings** → **Source**:
119
- - Set **Root Directory** to `/` (the repo root - Railpack needs the full monorepo context to resolve workspace dependencies)
120
- 3. In the project → **+ New** → **Database** → **Add PostgreSQL**
121
- 4. In the project → **+ New** → **Database** → **Add Redis**
122
- 5. Use `.env.railway.example` as the default Railway variable set:
123
- - Copy the non-service defaults into Railway shared variables or each service's variables as your starting point
124
- - Wire `DATABASE_URL` and `REDIS_URL` with Railway service references or paste the managed service connection strings Railway provides
125
- - Replace `NEXTAUTH_SECRET`, `APP_URL`, and `ADMIN_PASSWORD`
126
- - `HOSTNAME=0.0.0.0` is included so the standalone Next server binds correctly on Railway
127
- - Apply `DATABASE_URL`, `REDIS_URL`, `STORAGE_PROVIDER`, and `WORKER_CONCURRENCY` to the worker too if you deploy it
128
- 6. *(If you included the worker)* **+ New** → **GitHub Repo** → same repo → **Root Directory** → `/`
129
-
130
- Railway auto-deploys on every push to `main`. Migrations run automatically before each deploy via `preDeploy` in `.railway/railway.ts`.
131
-
132
- If you need to seed Railway manually after the first migration, run `SEED_PROFILE=minimal pnpm db:seed` inside a Railway shell/one-off command, or from a machine using Railway's externally reachable database credentials, and provide the required admin credentials. The seed guard rejects remote seeds without an explicit profile on purpose.
133
-
134
- > **Important:** Root Directory must be `/` (not `apps/web`) so Railpack can resolve pnpm workspace dependencies. The `.railway/railway.ts` file defines all service configuration via Infrastructure as Code.
135
- >
136
- > Railway starts the web service with `HOSTNAME=0.0.0.0 pnpm --dir apps/web start:deploy` (configured in `.railway/railway.ts`), which delegates to the standalone entrypoint `node .next/standalone/apps/web/server.js`. This forces the standalone Next server to bind on Railway's network interface. The `pnpm start` script uses `next start` which requires full `node_modules` - it's for local testing only.
137
-
138
- ## AI-Assisted Development
139
-
140
- This project ships with pre-loaded context for Claude Code, Cursor, GitHub Copilot, and others - see `CLAUDE.md` for the full reference.
141
-
142
- **Suggested first prompt:**
71
+ ## Tech Stack
143
72
 
144
- > I'm building __QUARK_PROJECT_NAME__ - [describe what your app does in one sentence].
145
- >
146
- > Start by reviewing `CLAUDE.md` so you understand the full stack, then:
147
- > 1. Add the Prisma models we need to `packages/db/prisma/schema.prisma`
148
- > 2. Create the query helpers in `packages/db/src/queries.js`
149
- > 3. Build the first page in `apps/web/src/app/` using our UI component system
73
+ - **Framework** - Next.js 16 (React 19)
74
+ - **Styling** - Tailwind CSS 4
75
+ - **Database** - PostgreSQL 16 + Prisma 7
76
+ - **Queue** - Redis 7 (BullMQ)
77
+ - **Auth** - NextAuth.js v5
78
+ - **Monorepo** - pnpm workspaces + Turborepo
79
+ - **Linting** - Biome
150
80
 
151
- Replace that bracketed description with what you're actually building, and the AI has everything it needs.
81
+ ## Deployment
152
82
 
153
- ## Structure
83
+ See [DEPLOYMENT.md](DEPLOYMENT.md) for Railway setup instructions.
154
84
 
155
- ```
156
- __QUARK_PROJECT_NAME__/
157
- ├── apps/
158
- │ ├── web/ # Next.js 16 (App Router, Server Actions)
159
- __QUARK_OPTIONAL_APPS__├── packages/
160
- │ ├── db/ # Prisma schema + query helpers
161
- │ └── config/ # Environment validation
162
- ├── docker-compose.yml
163
- └── CLAUDE.md # AI tool context - keep this updated
164
- ```
85
+ <!-- Scaffolded with Quark on __QUARK_SCAFFOLD_DATE__ -->
@@ -26,11 +26,35 @@ NEXTAUTH_URL=http://localhost:3005
26
26
  NEXTAUTH_SECRET=replace_with_a_long_random_secret
27
27
 
28
28
  # --- OAuth Providers (optional) ---
29
- # Uncomment and fill in to enable GitHub / Google sign-in
30
- # GITHUB_ID=your_github_oauth_app_client_id
31
- # GITHUB_SECRET=your_github_oauth_app_client_secret
32
- # GOOGLE_CLIENT_ID=your_google_oauth_client_id
33
- # GOOGLE_CLIENT_SECRET=your_google_oauth_client_secret
29
+ # Leave every variable in this section unset and social sign-in is off.
30
+ # Nothing in Quark requires these.
31
+ #
32
+ # Each provider is enabled by its client id alone. These are the credentials
33
+ # the /api/auth/google and /api/auth/apple routes bind tokens to — with no
34
+ # client id, the matching route refuses every request with 503 rather than
35
+ # exchanging a token it cannot bind to this deployment.
36
+ #
37
+ # GitHub - Get credentials at: https://github.com/settings/developers
38
+ # GITHUB_ID=your_github_client_id
39
+ # GITHUB_SECRET=your_github_client_secret
40
+
41
+ # Google - Get credentials at: https://console.cloud.google.com/apis/credentials
42
+ # Both halves together add the Google button to the web sign-in page.
43
+ # The client id is also the token audience POST /api/auth/google checks on
44
+ # every sign-in, so a mismatch with the mobile app fails the login.
45
+ # GOOGLE_CLIENT_ID=your_google_client_id
46
+ # GOOGLE_CLIENT_SECRET=your_google_client_secret
47
+
48
+ # Apple - Create a Services ID at: https://developer.apple.com/account/resources/identifiers/list/serviceId
49
+ # The token audience POST /api/auth/apple checks on every sign-in. No secret is
50
+ # involved - that route verifies the signed identity token itself.
51
+ #
52
+ # Apple identity tokens carry *either* the bundle identifier (native iOS) *or*
53
+ # the Services ID (web flow) as their `aud` claim, depending on which flow the
54
+ # client uses. Set both, comma-separated, to accept either:
55
+ # APPLE_CLIENT_ID=com.example.web, com.example.app
56
+ # A single value works too if you only use one flow.
57
+ # APPLE_CLIENT_ID=com.example.yourapp
34
58
 
35
59
  # --- SEO / Indexing ---
36
60
  # Set to "true" ONLY in your production deployment.
@@ -1,3 +1,5 @@
1
+ import { createHash, timingSafeEqual } from "node:crypto";
2
+ import { getAppleClientIds } from "@usequark/quark-config/oauth";
1
3
  import { validateBody } from "@usequark/quark-core";
2
4
  import { user } from "@usequark/quark-db";
3
5
  import { importJWK, jwtVerify } from "jose";
@@ -8,18 +10,44 @@ import { handleError } from "../../error-handler";
8
10
 
9
11
  const appleAuthSchema = z.object({
10
12
  identityToken: z.string().min(1),
11
- nonce: z.string().optional(),
13
+ nonce: z.string().min(1),
12
14
  });
13
15
 
14
16
  const APPLE_KEYS_URL = "https://appleid.apple.com/auth/keys";
15
17
 
18
+ /**
19
+ * SHA-256 of a UTF-8 string, lowercase hex — the same digest the mobile client
20
+ * computes before handing it to Apple as the sign-in nonce.
21
+ *
22
+ * @param {string} value
23
+ * @returns {string}
24
+ */
25
+ function sha256Hex(value) {
26
+ return createHash("sha256").update(value, "utf8").digest("hex");
27
+ }
28
+
16
29
  /**
17
30
  * POST /api/auth/apple
18
31
  * Verify Apple identity token and issue a custom JWT.
19
32
  */
20
33
  export async function POST(request) {
21
34
  try {
22
- const { identityToken } = await validateBody(request, appleAuthSchema);
35
+ const clientIds = getAppleClientIds();
36
+
37
+ // Fail closed when unconfigured. This route is public, so an unconfigured
38
+ // deployment must not keep authenticating people — see the note in the
39
+ // Google route for the full reasoning.
40
+ if (!clientIds) {
41
+ return NextResponse.json(
42
+ { message: "Apple sign-in is not configured on this server" },
43
+ { status: 503 },
44
+ );
45
+ }
46
+
47
+ const { identityToken, nonce } = await validateBody(
48
+ request,
49
+ appleAuthSchema,
50
+ );
23
51
 
24
52
  // Decode the JWT header to get the kid
25
53
  const [headerB64] = identityToken.split(".");
@@ -38,13 +66,21 @@ export async function POST(request) {
38
66
  );
39
67
  }
40
68
 
41
- // Verify the token using Apple's public key
69
+ // Verify the token using Apple's public key. `audience` is what binds the
70
+ // identity token to this app: without it, an identity token Apple issued
71
+ // to a different bundle or service id for the same Apple ID verifies fine
72
+ // and would be exchanged for a first-party session.
73
+ //
74
+ // Apple identity tokens can carry *either* the bundle identifier (native
75
+ // iOS) *or* the Services ID (web flow) as their `aud` claim, depending on
76
+ // which flow the client uses. Passing both accepts either.
42
77
  const publicKey = await importJWK(matchingKey, "RS256");
43
78
 
44
79
  let payload;
45
80
  try {
46
81
  const result = await jwtVerify(identityToken, publicKey, {
47
82
  issuer: "https://appleid.apple.com",
83
+ audience: clientIds,
48
84
  });
49
85
  payload = result.payload;
50
86
  } catch {
@@ -54,6 +90,30 @@ export async function POST(request) {
54
90
  );
55
91
  }
56
92
 
93
+ // The nonce is Apple's replay defence. The client sends a raw nonce and
94
+ // hands Apple its SHA-256 digest; Apple returns that digest in the token's
95
+ // `nonce` claim. Re-hashing what we received is what proves this token was
96
+ // minted for *this* sign-in attempt rather than lifted from an earlier one.
97
+ //
98
+ // Both halves must be present. A token with no `nonce` claim proves nothing
99
+ // about which attempt it came from, so treating "the client sent no nonce" as
100
+ // "skip the check" would leave the replay window open to any caller who
101
+ // simply omits the field.
102
+ if (typeof payload.nonce !== "string" || payload.nonce === "") {
103
+ return NextResponse.json(
104
+ { message: "Invalid Apple token" },
105
+ { status: 401 },
106
+ );
107
+ }
108
+
109
+ const expectedNonce = sha256Hex(nonce);
110
+ if (!safeEqualStrings(expectedNonce, payload.nonce)) {
111
+ return NextResponse.json(
112
+ { message: "Invalid Apple token" },
113
+ { status: 401 },
114
+ );
115
+ }
116
+
57
117
  const appleEmail = payload.email;
58
118
  if (!appleEmail) {
59
119
  return NextResponse.json(
@@ -80,3 +140,21 @@ export async function POST(request) {
80
140
  return handleError(error);
81
141
  }
82
142
  }
143
+
144
+ /**
145
+ * Constant-time string comparison.
146
+ *
147
+ * `timingSafeEqual` throws when the two buffers differ in length, so the length
148
+ * check has to come first. It short-circuits on length — which is fine, because
149
+ * both sides are fixed-length hex digests here, so length is not a secret.
150
+ *
151
+ * @param {string} a
152
+ * @param {string} b
153
+ * @returns {boolean}
154
+ */
155
+ function safeEqualStrings(a, b) {
156
+ const aBuf = Buffer.from(a, "utf8");
157
+ const bBuf = Buffer.from(b, "utf8");
158
+ if (aBuf.length !== bBuf.length) return false;
159
+ return timingSafeEqual(aBuf, bBuf);
160
+ }
@@ -1,3 +1,7 @@
1
+ import {
2
+ getGoogleClientId,
3
+ isAudienceValid,
4
+ } from "@usequark/quark-config/oauth";
1
5
  import { validateBody } from "@usequark/quark-core";
2
6
  import { user } from "@usequark/quark-db";
3
7
  import { NextResponse } from "next/server";
@@ -17,6 +21,21 @@ const GOOGLE_TOKENINFO_URL = "https://oauth2.googleapis.com/tokeninfo";
17
21
  */
18
22
  export async function POST(request) {
19
23
  try {
24
+ const clientId = getGoogleClientId();
25
+
26
+ // Fail closed when unconfigured. This route is public — it does not need
27
+ // your frontend to call it, or anyone to have set up Google sign-in — so
28
+ // "nobody configured it" is not a reason to keep serving. Without a client
29
+ // id there is no audience to compare `tokeninfo.aud` against, and a route
30
+ // that skips the comparison accepts any token Google ever issued to any
31
+ // app for this address. Refusing is the only safe default.
32
+ if (!clientId) {
33
+ return NextResponse.json(
34
+ { message: "Google sign-in is not configured on this server" },
35
+ { status: 503 },
36
+ );
37
+ }
38
+
20
39
  const { idToken } = await validateBody(request, googleAuthSchema);
21
40
 
22
41
  // Verify the token with Google's tokeninfo endpoint
@@ -33,6 +52,19 @@ export async function POST(request) {
33
52
 
34
53
  const tokenInfo = await tokenInfoResponse.json();
35
54
 
55
+ // The audience is the OAuth client id the token was minted for. Google
56
+ // verified the signature; it did not verify the token was for *this* app. A
57
+ // token Google issued to a different app for the victim's address is
58
+ // genuine and correctly signed, and must still be refused — otherwise
59
+ // anyone who can get the victim to sign in to any Google app at all can log
60
+ // in as them here.
61
+ if (!isAudienceValid(tokenInfo.aud, clientId)) {
62
+ return NextResponse.json(
63
+ { message: "Invalid Google token" },
64
+ { status: 401 },
65
+ );
66
+ }
67
+
36
68
  const googleEmail = tokenInfo.email;
37
69
  if (!googleEmail) {
38
70
  return NextResponse.json(
@@ -221,6 +221,42 @@ if (exampleContent) {
221
221
  }
222
222
  }
223
223
 
224
+ // ── Check S6: README branding block is still the Quark default ───────────────
225
+ {
226
+ const readmeForBranding = read("README.md");
227
+ if (readmeForBranding) {
228
+ const staleBranding = [];
229
+
230
+ // The scaffold ships the Quark mark as a stand-in logo; it resolves
231
+ // (unlike a missing file) but it is Quark's brand, not the user's.
232
+ if (/src="[^"]*quark\.svg"/i.test(readmeForBranding)) {
233
+ staleBranding.push(
234
+ "README.md → logo still points at apps/web/public/quark.svg (the Quark mark)",
235
+ );
236
+ }
237
+
238
+ // Mirrors the S5 heuristic: a tagline that is only "<name> application"
239
+ // is the default the CLI seeds from the project name, not a real one.
240
+ const tagline =
241
+ readmeForBranding.match(/<strong>(.*?)<\/strong>/)?.[1]?.trim() ?? "";
242
+ if (/\bapplication\b$/i.test(tagline) && tagline.split(/\s+/).length <= 3) {
243
+ staleBranding.push(
244
+ `README.md → tagline is still the scaffold default: "${tagline}"`,
245
+ );
246
+ }
247
+
248
+ if (staleBranding.length > 0) {
249
+ warn(
250
+ "readme-branding",
251
+ "branding",
252
+ "README branding block is still the Quark default",
253
+ staleBranding.join("\n"),
254
+ "Swap apps/web/public/quark.svg for your own logo (keep the filename so the PWA icon keeps working) and rewrite the tagline to describe your app",
255
+ );
256
+ }
257
+ }
258
+ }
259
+
224
260
  // ── Environment-dependent checks (skipped in CI mode) ─────────────────────────
225
261
 
226
262
  if (!CI) {
@@ -318,14 +354,22 @@ if (envContent && exampleContent) {
318
354
 
319
355
  // ── Check E7: README still contains Quark template content ───────────────────
320
356
  const readmeContent = read("README.md");
321
- if (readmeContent && /quark/i.test(readmeContent)) {
322
- info(
323
- "readme",
324
- "documentation",
325
- "README.md still contains references to Quark",
326
- "README.md",
327
- "Update the README to describe your own project",
328
- );
357
+ if (readmeContent) {
358
+ // Strip the invisible scaffold-metadata comment and the default logo src -
359
+ // check S6 owns the branding block. What is left is Quark references the
360
+ // author actually wrote into the README content.
361
+ const visibleReadme = readmeContent
362
+ .replace(/<!--[\s\S]*?-->/g, "")
363
+ .replace(/src="[^"]*quark\.svg"/gi, "");
364
+ if (/quark/i.test(visibleReadme)) {
365
+ info(
366
+ "readme",
367
+ "documentation",
368
+ "README.md still contains references to Quark",
369
+ "README.md",
370
+ "Update the README to describe your own project",
371
+ );
372
+ }
329
373
  }
330
374
 
331
375
  } // end if (!CI)
@@ -8,6 +8,7 @@
8
8
  "./rate-limit": "./src/rate-limit.js",
9
9
  "./app-url": "./src/app-url.js",
10
10
  "./environment": "./src/environment.js",
11
+ "./oauth": "./src/oauth.js",
11
12
  "./load-config": "./src/load-config.js",
12
13
  "./validate-env": "./src/validate-env.js",
13
14
  "./redis": "./src/redis.js"
@@ -11,6 +11,13 @@ export {
11
11
  resolveEnvironment,
12
12
  } from "./environment.js";
13
13
  export { getConfig, loadConfig, resetConfig } from "./load-config.js";
14
+ export {
15
+ getAppleClientIds,
16
+ getGoogleClientId,
17
+ isAppleAuthEnabled,
18
+ isAudienceValid,
19
+ isGoogleAuthEnabled,
20
+ } from "./oauth.js";
14
21
  export { applyRateLimit, rateLimit } from "./rate-limit.js";
15
22
  export { closeSharedRedisClient, getSharedRedisClient } from "./redis.js";
16
23
  export { loadEnv } from "./validate-env.js";
@@ -0,0 +1,124 @@
1
+ /**
2
+ * OAuth provider configuration.
3
+ *
4
+ * Every provider here is optional. Nothing in Quark requires an OAuth client
5
+ * id, so an app that never enables social sign-in reads `undefined` from these
6
+ * helpers and is unaffected.
7
+ *
8
+ * The contract the auth routes depend on: a provider is either fully
9
+ * configured or it is *off*. There is no third state in which a route serves
10
+ * requests with a weakened trust check. A caller that reaches
11
+ * `/api/auth/google` without `GOOGLE_CLIENT_ID` set gets a refusal, not an
12
+ * unverified login — the route is a public endpoint, so "nobody configured it"
13
+ * is not a reason to leave it serving.
14
+ *
15
+ * The client id is the `aud` claim on the id token the provider issues, which
16
+ * is what binds a token to *this* deployment. Without it, a token minted by any
17
+ * other app registered with the same provider verifies fine and would be
18
+ * accepted here.
19
+ */
20
+
21
+ /**
22
+ * Reads a client id, treating a blank value as unset.
23
+ *
24
+ * A variable set to `""` or whitespace survives `process.env` but is not a
25
+ * usable client id. Treating it as configured would turn the audience check
26
+ * into a comparison against the empty string and reject every real token,
27
+ * which reads as "sign-in is broken" rather than "sign-in is off".
28
+ *
29
+ * @param {string | undefined} value
30
+ * @returns {string | undefined} The trimmed id, or undefined when blank.
31
+ */
32
+ function readClientId(value) {
33
+ const trimmed = value?.trim();
34
+ return trimmed ? trimmed : undefined;
35
+ }
36
+
37
+ /**
38
+ * Splits a comma-separated audience list into individual ids, trimming each
39
+ * and dropping blanks. Returns undefined when the whole value is blank.
40
+ *
41
+ * @param {string | undefined} value
42
+ * @returns {string[] | undefined}
43
+ */
44
+ function readClientIdList(value) {
45
+ const ids = (value ?? "")
46
+ .split(",")
47
+ .map((id) => id.trim())
48
+ .filter(Boolean);
49
+ return ids.length > 0 ? ids : undefined;
50
+ }
51
+
52
+ /**
53
+ * The Google OAuth client id, or undefined when Google sign-in is not configured.
54
+ *
55
+ * @returns {string | undefined}
56
+ */
57
+ export function getGoogleClientId() {
58
+ return readClientId(process.env.GOOGLE_CLIENT_ID);
59
+ }
60
+
61
+ /**
62
+ * The Apple OAuth audience(s), or undefined when Apple sign-in is not configured.
63
+ *
64
+ * Apple identity tokens can carry *either* the bundle identifier (native iOS)
65
+ * *or* the Services ID (web flow) as their `aud` claim — which one depends on
66
+ * how the client was registered and which flow it uses. A single-value audience
67
+ * would reject one of the two, so this returns a list.
68
+ *
69
+ * @returns {string[] | undefined}
70
+ */
71
+ export function getAppleClientIds() {
72
+ return readClientIdList(process.env.APPLE_CLIENT_ID);
73
+ }
74
+
75
+ /**
76
+ * Whether Google sign-in is enabled on this deployment.
77
+ *
78
+ * @returns {boolean}
79
+ */
80
+ export function isGoogleAuthEnabled() {
81
+ return Boolean(getGoogleClientId());
82
+ }
83
+
84
+ /**
85
+ * Whether Apple sign-in is enabled on this deployment.
86
+ *
87
+ * @returns {boolean}
88
+ */
89
+ export function isAppleAuthEnabled() {
90
+ return Boolean(getAppleClientIds());
91
+ }
92
+
93
+ /**
94
+ * Compares a token's `aud` claim against the expected client id(s).
95
+ *
96
+ * Used for Google, where the token is verified out of process by the provider's
97
+ * own introspection endpoint and the claims arrive as parsed JSON. Apple
98
+ * verifies the signature locally, so its audience is passed to `jwtVerify`
99
+ * instead and never comes through here.
100
+ *
101
+ * Accepts either a single id or an array. For Apple, pass the full list from
102
+ * `getAppleClientIds()` so both the bundle identifier and the Services ID are
103
+ * accepted.
104
+ *
105
+ * @param {unknown} actual - The `aud` claim from the verified token.
106
+ * @param {string | string[] | undefined} expected - The configured client id(s).
107
+ * @returns {boolean} False when either side is missing, so an unconfigured
108
+ * deployment cannot pass by accident.
109
+ */
110
+ export function isAudienceValid(actual, expected) {
111
+ if (typeof actual !== "string" || actual === "") {
112
+ return false;
113
+ }
114
+
115
+ if (Array.isArray(expected)) {
116
+ if (expected.length === 0) return false;
117
+ return expected.includes(actual);
118
+ }
119
+
120
+ if (typeof expected !== "string" || expected === "") {
121
+ return false;
122
+ }
123
+ return actual === expected;
124
+ }
@@ -85,6 +85,15 @@ const ENV_DESCRIPTIONS = {
85
85
  NEXTAUTH_SECRET: "NextAuth secret for JWT signing",
86
86
  NEXTAUTH_URL: "NextAuth callback URL (derived from APP_URL if not set)",
87
87
 
88
+ // OAuth (optional — each provider is inert unless its client id is set)
89
+ GITHUB_ID: "GitHub OAuth client id for NextAuth sign-in",
90
+ GITHUB_SECRET: "GitHub OAuth client secret for NextAuth sign-in",
91
+ GOOGLE_CLIENT_ID:
92
+ "Google OAuth client id — enables POST /api/auth/google and the id token audience check",
93
+ GOOGLE_CLIENT_SECRET: "Google OAuth client secret for NextAuth sign-in",
94
+ APPLE_CLIENT_ID:
95
+ "Apple Services ID — enables POST /api/auth/apple and the identity token audience check",
96
+
88
97
  // Application
89
98
  APP_NAME: "Application name - used in metadata, emails, and page titles",
90
99
  APP_DESCRIPTION:
@@ -221,6 +230,13 @@ function createEnvSchema(service) {
221
230
  "NEXTAUTH_URL must be an absolute http(s) URL",
222
231
  ),
223
232
 
233
+ // OAuth (optional — each provider is inert unless its client id is set)
234
+ GITHUB_ID: optionalString,
235
+ GITHUB_SECRET: optionalString,
236
+ GOOGLE_CLIENT_ID: optionalString,
237
+ GOOGLE_CLIENT_SECRET: optionalString,
238
+ APPLE_CLIENT_ID: optionalString,
239
+
224
240
  // Application
225
241
  APP_NAME: optionalString,
226
242
  APP_DESCRIPTION: optionalString,
@@ -416,6 +432,24 @@ function collectWarnings() {
416
432
  );
417
433
  }
418
434
 
435
+ // OAuth: half a provider is a provider that silently does not work.
436
+ // `auth.js` only registers a NextAuth provider when BOTH the client id and
437
+ // the secret are set, so an id with no secret leaves the sign-in button
438
+ // absent and the failure looks like a missing feature rather than a
439
+ // half-finished configuration. Worth saying out loud.
440
+ for (const [idKey, secretKey, label] of [
441
+ ["GITHUB_ID", "GITHUB_SECRET", "GitHub"],
442
+ ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET", "Google"],
443
+ ]) {
444
+ const hasId = Boolean(process.env[idKey]?.trim());
445
+ const hasSecret = Boolean(process.env[secretKey]?.trim());
446
+ if (hasId !== hasSecret) {
447
+ warnings.push(
448
+ `${label} OAuth is incomplete: set both ${idKey} and ${secretKey} to enable it. The sign-in button stays hidden until both are present.`,
449
+ );
450
+ }
451
+ }
452
+
419
453
  // Umami pair completeness
420
454
  const umamiUrl = process.env.NEXT_PUBLIC_UMAMI_URL?.trim();
421
455
  const umamiWebsiteId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID?.trim();
@@ -10,7 +10,18 @@ EXPO_PUBLIC_API_URL=http://localhost:3000
10
10
  # --- Google OAuth (Optional) ---
11
11
  # Get credentials at: https://console.cloud.google.com/apis/credentials
12
12
  # EXPO_PUBLIC_GOOGLE_CLIENT_ID=your_google_client_id
13
+ #
14
+ # This must be the SAME client id the web app has as GOOGLE_CLIENT_ID. The
15
+ # server compares it against the token's audience on every sign-in, so a
16
+ # mismatch here fails the login with an "invalid token" error.
13
17
 
14
18
  # --- Apple Sign In (Optional) ---
15
19
  # Apple Sign In is configured via EAS and Apple Developer Console.
16
20
  # No environment variables needed - it uses native module configuration.
21
+ #
22
+ # The web app must have APPLE_CLIENT_ID set, or POST /api/auth/apple
23
+ # refuses every request as unconfigured. It takes a comma-separated
24
+ # list of audiences, and native iOS identity tokens carry the bundle
25
+ # identifier from app.json as their audience (the Services ID applies
26
+ # to web-flow tokens only) - so include the bundle identifier here, or
27
+ # every native sign-in fails with 401.