@usequark/quark-create-app 1.25.7 → 1.25.9
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 +1 -1
- package/src/deploy/adapters/iac.js +78 -1
- package/src/deploy/deploy.js +1 -0
- package/templates/base-project/.env.railway.example +7 -2
- package/templates/base-project/.railway/railway.ts +5 -2
- package/templates/base-project/DEPLOYMENT.md +39 -0
- package/templates/base-project/README.md +57 -136
- package/templates/base-project/apps/web/.env.example +13 -6
- package/templates/base-project/apps/web/src/app/api/auth/apple/route.js +82 -4
- package/templates/base-project/apps/web/src/app/api/auth/google/route.js +71 -1
- package/templates/base-project/apps/web/src/app/api/auth/refresh/route.js +1 -1
- package/templates/base-project/apps/web/src/app/api/auth/register/route.js +7 -12
- package/templates/base-project/apps/web/src/app/api/auth/token/route.js +2 -1
- package/templates/base-project/apps/web/src/app/api/csrf/route.js +1 -1
- package/templates/base-project/apps/web/src/app/api/device/register/route.js +1 -1
- package/templates/base-project/apps/web/src/app/api/error-handler.js +1 -1
- package/templates/base-project/apps/web/src/app/api/files/[id]/route.js +2 -2
- package/templates/base-project/apps/web/src/app/api/files/route.js +6 -4
- package/templates/base-project/apps/web/src/app/api/health/route.js +4 -4
- package/templates/base-project/apps/web/src/app/api/users/[id]/route.js +1 -1
- package/templates/base-project/apps/web/src/app/api/users/me/route.js +1 -1
- package/templates/base-project/apps/web/src/app/api/users/route.js +1 -1
- package/templates/base-project/apps/web/src/lib/auth-middleware.js +1 -1
- package/templates/base-project/apps/web/src/lib/auth.js +2 -5
- package/templates/base-project/apps/web/src/lib/enqueue-welcome-email.js +51 -0
- package/templates/base-project/apps/web/src/lib/jwt.js +2 -1
- package/templates/base-project/apps/web/src/preflight.js +1 -1
- package/templates/base-project/biome.json +1 -1
- package/templates/base-project/packages/db/src/umami.js +1 -1
- package/templates/base-project/scripts/doctor.js +52 -8
- package/templates/config/src/index.js +1 -1
- package/templates/config/src/oauth.js +37 -7
- package/templates/config/src/redis.js +1 -1
- package/templates/config/src/validate-env.js +1 -1
- package/templates/mobile/.env.example +6 -2
- package/templates/worker/src/handlers/email.js +2 -2
- package/templates/worker/src/handlers/files.js +1 -1
- package/templates/worker/src/index.js +4 -4
- package/templates/worker/src/lib/railway.js +1 -1
- package/templates/worker/src/lib/tokens.js +1 -1
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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")}
|
package/src/deploy/deploy.js
CHANGED
|
@@ -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
|
|
@@ -25,8 +25,13 @@ NEXTAUTH_SECRET=CHANGE_ME_TO_A_32_CHARACTERS_OR_LONGER_SECRET
|
|
|
25
25
|
|
|
26
26
|
# --- Optional OAuth providers ---
|
|
27
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
|
|
29
|
-
#
|
|
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.
|
|
30
35
|
# GOOGLE_CLIENT_ID=
|
|
31
36
|
# GOOGLE_CLIENT_SECRET=
|
|
32
37
|
# APPLE_CLIENT_ID=
|
|
@@ -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
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="apps/web/public/quark.svg" alt="__QUARK_PROJECT_NAME__" width="120" />
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
<p align="center">
|
|
6
|
+
<strong>__QUARK_PROJECT_BRIEF__</strong>
|
|
7
|
+
</p>
|
|
4
8
|
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
17
|
+
## Quick Start
|
|
13
18
|
|
|
14
19
|
```bash
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
pnpm db:migrate
|
|
18
|
-
pnpm
|
|
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
|
-
##
|
|
28
|
+
## Services
|
|
25
29
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
37
|
+
docker compose up -d # start all services
|
|
38
|
+
docker compose down # stop all services
|
|
42
39
|
```
|
|
43
40
|
|
|
44
|
-
|
|
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
|
|
81
|
-
pnpm test
|
|
82
|
-
pnpm lint
|
|
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
|
-
##
|
|
92
|
-
|
|
93
|
-
### 1. Push to GitHub
|
|
49
|
+
## Database
|
|
94
50
|
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
59
|
+
## Project Structure
|
|
100
60
|
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
81
|
+
## Deployment
|
|
152
82
|
|
|
153
|
-
|
|
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__ -->
|
|
@@ -30,7 +30,9 @@ NEXTAUTH_SECRET=replace_with_a_long_random_secret
|
|
|
30
30
|
# Nothing in Quark requires these.
|
|
31
31
|
#
|
|
32
32
|
# Each provider is enabled by its client id alone. These are the credentials
|
|
33
|
-
# the /api/auth/google and /api/auth/apple routes
|
|
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.
|
|
34
36
|
#
|
|
35
37
|
# GitHub - Get credentials at: https://github.com/settings/developers
|
|
36
38
|
# GITHUB_ID=your_github_client_id
|
|
@@ -38,15 +40,20 @@ NEXTAUTH_SECRET=replace_with_a_long_random_secret
|
|
|
38
40
|
|
|
39
41
|
# Google - Get credentials at: https://console.cloud.google.com/apis/credentials
|
|
40
42
|
# Both halves together add the Google button to the web sign-in page.
|
|
41
|
-
# The client id is also the token audience
|
|
42
|
-
#
|
|
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.
|
|
43
45
|
# GOOGLE_CLIENT_ID=your_google_client_id
|
|
44
46
|
# GOOGLE_CLIENT_SECRET=your_google_client_secret
|
|
45
47
|
|
|
46
48
|
# Apple - Create a Services ID at: https://developer.apple.com/account/resources/identifiers/list/serviceId
|
|
47
|
-
# The
|
|
48
|
-
#
|
|
49
|
-
#
|
|
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.
|
|
50
57
|
# APPLE_CLIENT_ID=com.example.yourapp
|
|
51
58
|
|
|
52
59
|
# --- SEO / Indexing ---
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { getAppleClientIds } from "@usequark/quark-config/oauth";
|
|
3
|
+
import { validateBody } from "@usequark/quark-core/core";
|
|
2
4
|
import { user } from "@usequark/quark-db";
|
|
3
5
|
import { importJWK, jwtVerify } from "jose";
|
|
4
6
|
import { NextResponse } from "next/server";
|
|
@@ -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().
|
|
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
|
|
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,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
getGoogleClientId,
|
|
3
|
+
isAudienceValid,
|
|
4
|
+
} from "@usequark/quark-config/oauth";
|
|
5
|
+
import { validateBody } from "@usequark/quark-core/core";
|
|
2
6
|
import { user } from "@usequark/quark-db";
|
|
3
7
|
import { NextResponse } from "next/server";
|
|
4
8
|
import { z } from "zod";
|
|
@@ -11,12 +15,47 @@ const googleAuthSchema = z.object({
|
|
|
11
15
|
|
|
12
16
|
const GOOGLE_TOKENINFO_URL = "https://oauth2.googleapis.com/tokeninfo";
|
|
13
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Whether Google's `email_verified` claim says the address is confirmed.
|
|
20
|
+
*
|
|
21
|
+
* `tokeninfo` returns the claim as the *string* `"true"` or `"false"`, not a
|
|
22
|
+
* boolean, so a plain truthiness check would do the exact opposite of the
|
|
23
|
+
* obvious thing: reject every real sign-in (`"false"` is truthy) while waving
|
|
24
|
+
* through the one case that matters. Only an explicit confirmation counts.
|
|
25
|
+
* Anything else — a missing claim, a number, a null — is unverified, because
|
|
26
|
+
* the failure modes here are asymmetric: a wrong refusal costs one unlucky
|
|
27
|
+
* sign-in, and a wrong acceptance hands an unconfirmed address a session.
|
|
28
|
+
*
|
|
29
|
+
* @param {unknown} value
|
|
30
|
+
* @returns {boolean}
|
|
31
|
+
*/
|
|
32
|
+
function isEmailVerified(value) {
|
|
33
|
+
if (typeof value === "boolean") return value;
|
|
34
|
+
if (typeof value !== "string") return false;
|
|
35
|
+
return value.trim().toLowerCase() === "true";
|
|
36
|
+
}
|
|
37
|
+
|
|
14
38
|
/**
|
|
15
39
|
* POST /api/auth/google
|
|
16
40
|
* Verify Google ID token and issue a custom JWT.
|
|
17
41
|
*/
|
|
18
42
|
export async function POST(request) {
|
|
19
43
|
try {
|
|
44
|
+
const clientId = getGoogleClientId();
|
|
45
|
+
|
|
46
|
+
// Fail closed when unconfigured. This route is public — it does not need
|
|
47
|
+
// your frontend to call it, or anyone to have set up Google sign-in — so
|
|
48
|
+
// "nobody configured it" is not a reason to keep serving. Without a client
|
|
49
|
+
// id there is no audience to compare `tokeninfo.aud` against, and a route
|
|
50
|
+
// that skips the comparison accepts any token Google ever issued to any
|
|
51
|
+
// app for this address. Refusing is the only safe default.
|
|
52
|
+
if (!clientId) {
|
|
53
|
+
return NextResponse.json(
|
|
54
|
+
{ message: "Google sign-in is not configured on this server" },
|
|
55
|
+
{ status: 503 },
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
20
59
|
const { idToken } = await validateBody(request, googleAuthSchema);
|
|
21
60
|
|
|
22
61
|
// Verify the token with Google's tokeninfo endpoint
|
|
@@ -33,6 +72,19 @@ export async function POST(request) {
|
|
|
33
72
|
|
|
34
73
|
const tokenInfo = await tokenInfoResponse.json();
|
|
35
74
|
|
|
75
|
+
// The audience is the OAuth client id the token was minted for. Google
|
|
76
|
+
// verified the signature; it did not verify the token was for *this* app. A
|
|
77
|
+
// token Google issued to a different app for the victim's address is
|
|
78
|
+
// genuine and correctly signed, and must still be refused — otherwise
|
|
79
|
+
// anyone who can get the victim to sign in to any Google app at all can log
|
|
80
|
+
// in as them here.
|
|
81
|
+
if (!isAudienceValid(tokenInfo.aud, clientId)) {
|
|
82
|
+
return NextResponse.json(
|
|
83
|
+
{ message: "Invalid Google token" },
|
|
84
|
+
{ status: 401 },
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
36
88
|
const googleEmail = tokenInfo.email;
|
|
37
89
|
if (!googleEmail) {
|
|
38
90
|
return NextResponse.json(
|
|
@@ -41,6 +93,24 @@ export async function POST(request) {
|
|
|
41
93
|
);
|
|
42
94
|
}
|
|
43
95
|
|
|
96
|
+
// An unverified address proves nothing about who controls the inbox, and
|
|
97
|
+
// this route creates the account on first sight — so without this check the
|
|
98
|
+
// confirmation that is supposed to establish ownership of the address is
|
|
99
|
+
// the attacker's to skip rather than the victim's to perform.
|
|
100
|
+
//
|
|
101
|
+
// It sits after the missing-email `400` on purpose. That one describes the
|
|
102
|
+
// shape of the token rather than reporting that the token was refused, and
|
|
103
|
+
// putting this check ahead of it would change a response operators already
|
|
104
|
+
// read for what it is. What matters for security is the position relative
|
|
105
|
+
// to the database: both checks land before `findByEmail`, so an unverified
|
|
106
|
+
// address can never become a registration or existence oracle.
|
|
107
|
+
if (!isEmailVerified(tokenInfo.email_verified)) {
|
|
108
|
+
return NextResponse.json(
|
|
109
|
+
{ message: "Invalid Google token" },
|
|
110
|
+
{ status: 401 },
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
44
114
|
// Find or create user
|
|
45
115
|
let existingUser = await user.findByEmail(googleEmail);
|
|
46
116
|
if (!existingUser) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createLogger, validateBody } from "@usequark/quark-core";
|
|
1
|
+
import { createLogger, validateBody } from "@usequark/quark-core/core";
|
|
2
2
|
import { NextResponse } from "next/server";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { issueTokenPair, verifyMobileToken } from "../../../../lib/jwt";
|
|
@@ -1,13 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
hashPassword,
|
|
4
|
-
validateBody,
|
|
5
|
-
withCsrfProtection,
|
|
6
|
-
} from "@usequark/quark-core";
|
|
1
|
+
import { hashPassword } from "@usequark/quark-core/auth";
|
|
2
|
+
import { validateBody, withCsrfProtection } from "@usequark/quark-core/core";
|
|
7
3
|
import { user, userRegisterSchema } from "@usequark/quark-db";
|
|
8
|
-
import { JOB_NAMES, JOB_QUEUES } from "@usequark/quark-jobs";
|
|
9
4
|
import { NextResponse } from "next/server";
|
|
10
5
|
import { isSignupEnabled } from "@/lib/auth-signup";
|
|
6
|
+
import { enqueueWelcomeEmail } from "@/lib/enqueue-welcome-email";
|
|
11
7
|
import { handleError } from "../../error-handler";
|
|
12
8
|
|
|
13
9
|
export const POST = withCsrfProtection(async (request) => {
|
|
@@ -46,12 +42,11 @@ export const POST = withCsrfProtection(async (request) => {
|
|
|
46
42
|
// Don't return the password
|
|
47
43
|
const { password: _, ...safeUser } = newUser;
|
|
48
44
|
|
|
49
|
-
// Enqueue welcome email (fire-and-forget - don't block the response)
|
|
45
|
+
// Enqueue welcome email (fire-and-forget - don't block the response).
|
|
46
|
+
// The queue module is reached through a lazily-loaded helper, so BullMQ
|
|
47
|
+
// stays out of this process until a registration actually happens.
|
|
50
48
|
try {
|
|
51
|
-
|
|
52
|
-
await emailQueue.add(JOB_NAMES.SEND_WELCOME_EMAIL, {
|
|
53
|
-
userId: newUser.id,
|
|
54
|
-
});
|
|
49
|
+
await enqueueWelcomeEmail(newUser.id);
|
|
55
50
|
} catch {
|
|
56
51
|
// Non-critical - user is created even if email fails to enqueue
|
|
57
52
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { verifyPassword } from "@usequark/quark-core/auth";
|
|
2
|
+
import { validateBody } from "@usequark/quark-core/core";
|
|
2
3
|
import { user } from "@usequark/quark-db";
|
|
3
4
|
import { NextResponse } from "next/server";
|
|
4
5
|
import { z } from "zod";
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* and returns it to the client for inclusion in request headers.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { generateCsrfToken } from "@usequark/quark-core";
|
|
7
|
+
import { generateCsrfToken } from "@usequark/quark-core/core";
|
|
8
8
|
import { NextResponse } from "next/server";
|
|
9
9
|
import { getCsrfCookieOptions } from "@/lib/csrf-cookie";
|
|
10
10
|
|
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
import {
|
|
8
8
|
ConflictError,
|
|
9
9
|
createLogger,
|
|
10
|
-
createStorage,
|
|
11
10
|
ForbiddenError,
|
|
12
11
|
NotFoundError,
|
|
13
12
|
withCsrfProtection,
|
|
14
|
-
} from "@usequark/quark-core";
|
|
13
|
+
} from "@usequark/quark-core/core";
|
|
14
|
+
import { createStorage } from "@usequark/quark-core/storage";
|
|
15
15
|
import { file } from "@usequark/quark-db";
|
|
16
16
|
import { NextResponse } from "next/server";
|
|
17
17
|
import { requireAuth } from "@/lib/auth-middleware";
|
|
@@ -5,13 +5,15 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
|
-
createStorage,
|
|
9
|
-
generateStorageKey,
|
|
10
|
-
parseMultipart,
|
|
11
8
|
parsePaginationQuery,
|
|
12
9
|
validateFile,
|
|
13
10
|
withCsrfProtection,
|
|
14
|
-
} from "@usequark/quark-core";
|
|
11
|
+
} from "@usequark/quark-core/core";
|
|
12
|
+
import { parseMultipart } from "@usequark/quark-core/multipart";
|
|
13
|
+
import {
|
|
14
|
+
createStorage,
|
|
15
|
+
generateStorageKey,
|
|
16
|
+
} from "@usequark/quark-core/storage";
|
|
15
17
|
import { file } from "@usequark/quark-db";
|
|
16
18
|
import { NextResponse } from "next/server";
|
|
17
19
|
import { requireAuth } from "@/lib/auth-middleware";
|
|
@@ -17,14 +17,14 @@
|
|
|
17
17
|
* what the core module replaces.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import { createLogger } from "@usequark/quark-core/core";
|
|
20
21
|
import {
|
|
21
22
|
checkQueues,
|
|
22
23
|
checkStorage,
|
|
23
|
-
createLogger,
|
|
24
|
-
getRegisteredQueues,
|
|
25
|
-
pingRedis,
|
|
26
24
|
runHealthChecks,
|
|
27
|
-
} from "@usequark/quark-core";
|
|
25
|
+
} from "@usequark/quark-core/health";
|
|
26
|
+
import { getRegisteredQueues } from "@usequark/quark-core/queue";
|
|
27
|
+
import { pingRedis } from "@usequark/quark-core/redis";
|
|
28
28
|
import { pingDatabase } from "@usequark/quark-db";
|
|
29
29
|
import { NextResponse } from "next/server";
|
|
30
30
|
|
|
@@ -2,7 +2,7 @@ import {
|
|
|
2
2
|
ConflictError,
|
|
3
3
|
validateBody,
|
|
4
4
|
withCsrfProtection,
|
|
5
|
-
} from "@usequark/quark-core";
|
|
5
|
+
} from "@usequark/quark-core/core";
|
|
6
6
|
import { user, userUpdateSchema } from "@usequark/quark-db";
|
|
7
7
|
import { NextResponse } from "next/server";
|
|
8
8
|
import { requireRole } from "@/lib/auth-middleware";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { validateBody, withCsrfProtection } from "@usequark/quark-core";
|
|
1
|
+
import { validateBody, withCsrfProtection } from "@usequark/quark-core/core";
|
|
2
2
|
import { user, userUpdateSchema } from "@usequark/quark-db";
|
|
3
3
|
import { NextResponse } from "next/server";
|
|
4
4
|
import { extractBearerPayload } from "../../../../lib/jwt";
|
|
@@ -2,7 +2,7 @@ import {
|
|
|
2
2
|
parsePaginationQuery,
|
|
3
3
|
validateBody,
|
|
4
4
|
withCsrfProtection,
|
|
5
|
-
} from "@usequark/quark-core";
|
|
5
|
+
} from "@usequark/quark-core/core";
|
|
6
6
|
import { user, userCreateSchema } from "@usequark/quark-db";
|
|
7
7
|
import { NextResponse } from "next/server";
|
|
8
8
|
import { requireRole } from "@/lib/auth-middleware";
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { PrismaAdapter } from "@auth/prisma-adapter";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
createLogger,
|
|
5
|
-
verifyPassword,
|
|
6
|
-
} from "@usequark/quark-core";
|
|
2
|
+
import { createAuthConfig, verifyPassword } from "@usequark/quark-core/auth";
|
|
3
|
+
import { createLogger } from "@usequark/quark-core/core";
|
|
7
4
|
import { Prisma, prisma, user } from "@usequark/quark-db";
|
|
8
5
|
import NextAuth from "next-auth";
|
|
9
6
|
import CredentialsProvider from "next-auth/providers/credentials";
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { JOB_NAMES, JOB_QUEUES } from "@usequark/quark-jobs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Loads the queue module on demand.
|
|
5
|
+
*
|
|
6
|
+
* Kept as a named binding rather than an inline `import()` so it can be
|
|
7
|
+
* substituted — see `loadQueue` on {@link enqueueWelcomeEmail}.
|
|
8
|
+
*
|
|
9
|
+
* @returns {Promise<typeof import("@usequark/quark-core/queue")>}
|
|
10
|
+
*/
|
|
11
|
+
function loadQueueModule() {
|
|
12
|
+
return import("@usequark/quark-core/queue");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Enqueues the post-signup welcome email for a newly created user.
|
|
17
|
+
*
|
|
18
|
+
* This lives in its own module for two reasons.
|
|
19
|
+
*
|
|
20
|
+
* The queue module is imported dynamically from inside this file, so callers
|
|
21
|
+
* that import *this* statically never pull BullMQ into their module graph. That
|
|
22
|
+
* matters most for `/api/auth/register`, which is reachable on the web service:
|
|
23
|
+
* a module-scope `createQueue` import both loaded BullMQ at startup and pinned a
|
|
24
|
+
* Redis connection to the web process for as long as the process lived.
|
|
25
|
+
*
|
|
26
|
+
* It also gives the route a static boundary to substitute in tests. `mock.module`
|
|
27
|
+
* intercepts a dynamic import only when the importing module registered the mock,
|
|
28
|
+
* so a dynamic import made by the route itself cannot be mocked from the route's
|
|
29
|
+
* test. A static import of this module can.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} userId - The user to welcome. The worker rejects a job with
|
|
32
|
+
* no userId, so this is required rather than defaulted.
|
|
33
|
+
* @param {() => Promise<{ createQueue: (name: string, options?: object) => object }>} [loadQueue]
|
|
34
|
+
* How to obtain the queue module. Defaults to the dynamic import above;
|
|
35
|
+
* overridden in tests.
|
|
36
|
+
* @returns {Promise<void>}
|
|
37
|
+
* @throws Whatever `createQueue` or `add` throws. The queue is closed either
|
|
38
|
+
* way, so a caller that swallows the error still leaves no connection behind.
|
|
39
|
+
*/
|
|
40
|
+
export async function enqueueWelcomeEmail(userId, loadQueue = loadQueueModule) {
|
|
41
|
+
const { createQueue } = await loadQueue();
|
|
42
|
+
const queue = createQueue(JOB_QUEUES.EMAIL);
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
await queue.add(JOB_NAMES.SEND_WELCOME_EMAIL, { userId });
|
|
46
|
+
} finally {
|
|
47
|
+
// Never let a failing close mask the enqueue outcome — the caller only
|
|
48
|
+
// needs to know whether the job reached the queue.
|
|
49
|
+
await queue.close().catch(() => {});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import { loadConfig } from "@usequark/quark-config";
|
|
4
|
-
import { createLogger } from "@usequark/quark-core";
|
|
4
|
+
import { createLogger } from "@usequark/quark-core/core";
|
|
5
5
|
import { pingDatabase } from "@usequark/quark-db";
|
|
6
6
|
|
|
7
7
|
const logger = createLogger("web-preflight");
|
|
@@ -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
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
"
|
|
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)
|
|
@@ -34,6 +34,21 @@ function readClientId(value) {
|
|
|
34
34
|
return trimmed ? trimmed : undefined;
|
|
35
35
|
}
|
|
36
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
|
+
|
|
37
52
|
/**
|
|
38
53
|
* The Google OAuth client id, or undefined when Google sign-in is not configured.
|
|
39
54
|
*
|
|
@@ -44,12 +59,17 @@ export function getGoogleClientId() {
|
|
|
44
59
|
}
|
|
45
60
|
|
|
46
61
|
/**
|
|
47
|
-
* The Apple
|
|
62
|
+
* The Apple OAuth audience(s), or undefined when Apple sign-in is not configured.
|
|
48
63
|
*
|
|
49
|
-
*
|
|
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}
|
|
50
70
|
*/
|
|
51
|
-
export function
|
|
52
|
-
return
|
|
71
|
+
export function getAppleClientIds() {
|
|
72
|
+
return readClientIdList(process.env.APPLE_CLIENT_ID);
|
|
53
73
|
}
|
|
54
74
|
|
|
55
75
|
/**
|
|
@@ -67,19 +87,23 @@ export function isGoogleAuthEnabled() {
|
|
|
67
87
|
* @returns {boolean}
|
|
68
88
|
*/
|
|
69
89
|
export function isAppleAuthEnabled() {
|
|
70
|
-
return Boolean(
|
|
90
|
+
return Boolean(getAppleClientIds());
|
|
71
91
|
}
|
|
72
92
|
|
|
73
93
|
/**
|
|
74
|
-
* Compares a token's `aud` claim against the expected client id.
|
|
94
|
+
* Compares a token's `aud` claim against the expected client id(s).
|
|
75
95
|
*
|
|
76
96
|
* Used for Google, where the token is verified out of process by the provider's
|
|
77
97
|
* own introspection endpoint and the claims arrive as parsed JSON. Apple
|
|
78
98
|
* verifies the signature locally, so its audience is passed to `jwtVerify`
|
|
79
99
|
* instead and never comes through here.
|
|
80
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
|
+
*
|
|
81
105
|
* @param {unknown} actual - The `aud` claim from the verified token.
|
|
82
|
-
* @param {string | undefined} expected - The configured client id.
|
|
106
|
+
* @param {string | string[] | undefined} expected - The configured client id(s).
|
|
83
107
|
* @returns {boolean} False when either side is missing, so an unconfigured
|
|
84
108
|
* deployment cannot pass by accident.
|
|
85
109
|
*/
|
|
@@ -87,6 +111,12 @@ export function isAudienceValid(actual, expected) {
|
|
|
87
111
|
if (typeof actual !== "string" || actual === "") {
|
|
88
112
|
return false;
|
|
89
113
|
}
|
|
114
|
+
|
|
115
|
+
if (Array.isArray(expected)) {
|
|
116
|
+
if (expected.length === 0) return false;
|
|
117
|
+
return expected.includes(actual);
|
|
118
|
+
}
|
|
119
|
+
|
|
90
120
|
if (typeof expected !== "string" || expected === "") {
|
|
91
121
|
return false;
|
|
92
122
|
}
|
|
@@ -19,5 +19,9 @@ EXPO_PUBLIC_API_URL=http://localhost:3000
|
|
|
19
19
|
# Apple Sign In is configured via EAS and Apple Developer Console.
|
|
20
20
|
# No environment variables needed - it uses native module configuration.
|
|
21
21
|
#
|
|
22
|
-
# The web app must have APPLE_CLIENT_ID set
|
|
23
|
-
#
|
|
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.
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
* Processes email-related background jobs
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { createEmailService } from "@usequark/quark-core/email";
|
|
6
7
|
import {
|
|
7
|
-
createEmailService,
|
|
8
8
|
passwordResetEmail,
|
|
9
9
|
welcomeEmail,
|
|
10
|
-
} from "@usequark/quark-core";
|
|
10
|
+
} from "@usequark/quark-core/email-templates";
|
|
11
11
|
import { prisma } from "@usequark/quark-db";
|
|
12
12
|
import { JOB_NAMES } from "@usequark/quark-jobs";
|
|
13
13
|
import {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Processes file-related background jobs (cleanup, etc.)
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { createStorage } from "@usequark/quark-core";
|
|
6
|
+
import { createStorage } from "@usequark/quark-core/storage";
|
|
7
7
|
import { file } from "@usequark/quark-db";
|
|
8
8
|
import { JOB_NAMES } from "@usequark/quark-jobs";
|
|
9
9
|
|
|
@@ -4,15 +4,15 @@
|
|
|
4
4
|
* Handles job execution, retries, and error tracking
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { createLogger } from "@usequark/quark-core/core";
|
|
8
|
+
import { AppError } from "@usequark/quark-core/errors";
|
|
7
9
|
import {
|
|
8
10
|
checkQueueHealth,
|
|
9
|
-
createLogger,
|
|
10
11
|
createQueue,
|
|
11
12
|
createWorker,
|
|
12
|
-
getRedisEndpoint,
|
|
13
13
|
updateQueueDepths,
|
|
14
|
-
} from "@usequark/quark-core";
|
|
15
|
-
import {
|
|
14
|
+
} from "@usequark/quark-core/queue";
|
|
15
|
+
import { getRedisEndpoint, getRedisUrl } from "@usequark/quark-core/redis";
|
|
16
16
|
import { job, prisma } from "@usequark/quark-db";
|
|
17
17
|
import { JOB_NAMES, JOB_QUEUES } from "@usequark/quark-jobs";
|
|
18
18
|
import { jobHandlers } from "./handlers/index.js";
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Fetches estimated usage and deployment data from Railway's GraphQL API.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { createLogger } from "@usequark/quark-core";
|
|
6
|
+
import { createLogger } from "@usequark/quark-core/core";
|
|
7
7
|
import { AppError } from "@usequark/quark-core/errors";
|
|
8
8
|
|
|
9
9
|
const log = createLogger("lib:railway");
|