@usequark/quark-create-app 1.25.12 → 1.25.14
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 +2 -2
- package/src/index.js +84 -89
- package/src/scaffold-warning.js +5 -0
- package/templates/base-project/apps/web/src/lib/auth.js +81 -2
- package/templates/base-project/apps/web/src/lib/enqueue-welcome-email.js +14 -3
- package/templates/base-project/apps/web/src/lib/proxy-auth.js +0 -3
- package/templates/base-project/packages/db/prisma/migrations/20260202061128_initial/migration.sql +1 -12
- package/templates/base-project/packages/db/prisma/schema.prisma +10 -4
- package/templates/base-project/packages/db/src/queries.js +1 -0
- package/templates/jobs/src/definitions.js +0 -2
- package/templates/mobile/app/(app)/_layout.tsx +2 -3
- package/templates/mobile/hooks/use-auth.ts +0 -21
- package/templates/mobile/package.json +0 -2
- package/templates/worker/package.json +1 -2
- package/templates/worker/src/handlers/index.js +0 -2
- package/templates/base-project/apps/web/src/app/api/device/register/route.js +0 -57
- package/templates/mobile/components/AuthGate.tsx +0 -19
- package/templates/mobile/lib/notifications.ts +0 -77
- package/templates/worker/src/handlers/push-notification.js +0 -234
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usequark/quark-create-app",
|
|
3
|
-
"version": "1.25.
|
|
3
|
+
"version": "1.25.14",
|
|
4
4
|
"description": "CLI to scaffold new Quark projects with Next.js, Prisma, BullMQ, and Railway deployment",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"quark",
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"sync-templates:check": "node scripts/sync-templates.js --check",
|
|
60
60
|
"regen-migration": "node scripts/regenerate-template-migration.js",
|
|
61
61
|
"smoke:published": "node scripts/smoke-published.js",
|
|
62
|
-
"test": "node test-cli.js && node --test src/utils.test.js && node --test src/prepare-hooks.test.js && node --test src/scaffold-output.test.js && node --test src/template-scripts.test.js && node --test src/scaffold-guards.test.js && node --test src/template-config.test.js && node --test src/worker-dockerfile.test.js && node --test src/dependabot-config.test.js && node --test 'src/deploy/**/*.test.js' && node --test --test-concurrency=1 'src/deploy/**/*.integration.test.js'",
|
|
62
|
+
"test": "node test-cli.js && node --test src/utils.test.js && node --test src/prepare-hooks.test.js && node --test src/scaffold-warning.test.js && node --test src/scaffold-output.test.js && node --test src/scaffold-drift.test.js && node --test src/mobile-template.test.js && node --test src/template-scripts.test.js && node --test src/scaffold-guards.test.js && node --test src/template-config.test.js && node --test src/worker-dockerfile.test.js && node --test src/dependabot-config.test.js && node --test 'src/deploy/**/*.test.js' && node --test --test-concurrency=1 'src/deploy/**/*.integration.test.js'",
|
|
63
63
|
"test:build": "node test-build.js",
|
|
64
64
|
"test:e2e": "node test-e2e.js",
|
|
65
65
|
"test:e2e:full": "node test-e2e-full.js",
|
package/src/index.js
CHANGED
|
@@ -1219,37 +1219,6 @@ program
|
|
|
1219
1219
|
);
|
|
1220
1220
|
console.log(chalk.green(` ✓ next.config.js patched`));
|
|
1221
1221
|
|
|
1222
|
-
// Step 7d: Strip jobs-related code from register route when jobs not selected
|
|
1223
|
-
if (!features.includes("jobs")) {
|
|
1224
|
-
const registerPath = path.join(
|
|
1225
|
-
targetDir,
|
|
1226
|
-
"apps",
|
|
1227
|
-
"web",
|
|
1228
|
-
"src",
|
|
1229
|
-
"app",
|
|
1230
|
-
"api",
|
|
1231
|
-
"auth",
|
|
1232
|
-
"register",
|
|
1233
|
-
"route.js",
|
|
1234
|
-
);
|
|
1235
|
-
if (await fs.pathExists(registerPath)) {
|
|
1236
|
-
let content = await fs.readFile(registerPath, "utf-8");
|
|
1237
|
-
// Remove the jobs import line
|
|
1238
|
-
content = content.replace(
|
|
1239
|
-
/import\s*\{[^}]*\}\s*from\s*["']@[^/]+\/jobs["'];?\n/,
|
|
1240
|
-
"",
|
|
1241
|
-
);
|
|
1242
|
-
// Remove createQueue from the core import if present
|
|
1243
|
-
content = content.replace(/\tcreateQueue,\n/, "");
|
|
1244
|
-
// Remove the @quark:start:jobs ... @quark:end:jobs block (inclusive)
|
|
1245
|
-
content = content.replace(
|
|
1246
|
-
/[ \t]*\/\/ @quark:start:jobs[\s\S]*?\/\/ @quark:end:jobs\n?/,
|
|
1247
|
-
"",
|
|
1248
|
-
);
|
|
1249
|
-
await fs.writeFile(registerPath, content);
|
|
1250
|
-
}
|
|
1251
|
-
}
|
|
1252
|
-
|
|
1253
1222
|
// Step 8: Create .env.example file
|
|
1254
1223
|
console.log(chalk.cyan("\n 📋 Creating environment configuration..."));
|
|
1255
1224
|
const envExampleTemplate = `# ⚠️ IMPORTANT: Copy this file to .env and fill in the values for your environment.
|
|
@@ -1867,6 +1836,34 @@ async function detectInstalledFeatures(projectDir) {
|
|
|
1867
1836
|
return installed;
|
|
1868
1837
|
}
|
|
1869
1838
|
|
|
1839
|
+
/**
|
|
1840
|
+
* Detect which AI-harness skill directory a project uses.
|
|
1841
|
+
*
|
|
1842
|
+
* The scaffold writes embedded skills to exactly one of HARNESS_SKILL_DIRS and
|
|
1843
|
+
* points the AI-context docs at that path. The drift check rebuilds a reference
|
|
1844
|
+
* scaffold to compare against, so it must pass the same `--harness` the project
|
|
1845
|
+
* was built with — otherwise the reference lands skills in .opencode/skills for
|
|
1846
|
+
* a claude/copilot project and reports those files as drift forever.
|
|
1847
|
+
*
|
|
1848
|
+
* `.github/skills` is checked last on purpose: every scaffold carries
|
|
1849
|
+
* `.github/skills/project-context/SKILL.md` regardless of harness, so its
|
|
1850
|
+
* presence alone says nothing about the selection.
|
|
1851
|
+
*
|
|
1852
|
+
* @param {string} projectDir
|
|
1853
|
+
* @returns {Promise<string>} A key of HARNESS_SKILL_DIRS, defaulting to
|
|
1854
|
+
* DEFAULT_HARNESS when no skill directory exists yet.
|
|
1855
|
+
*/
|
|
1856
|
+
async function detectConfiguredHarness(projectDir) {
|
|
1857
|
+
for (const harness of ["opencode", "claude", "copilot"]) {
|
|
1858
|
+
if (
|
|
1859
|
+
await fs.pathExists(path.join(projectDir, HARNESS_SKILL_DIRS[harness]))
|
|
1860
|
+
) {
|
|
1861
|
+
return harness;
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
return DEFAULT_HARNESS;
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1870
1867
|
async function collectRelativeFilePaths(
|
|
1871
1868
|
rootDir,
|
|
1872
1869
|
currentDir = rootDir,
|
|
@@ -1944,6 +1941,16 @@ async function createReferenceScaffold(projectDir, quarkLink) {
|
|
|
1944
1941
|
...linkedFeatures,
|
|
1945
1942
|
...detectedFeatures,
|
|
1946
1943
|
]);
|
|
1944
|
+
// `mobile` is a post-creation-only feature: `create` rejects it ("run quark
|
|
1945
|
+
// add mobile after scaffolding") while `add mobile` records it in
|
|
1946
|
+
// .quark-link.json like any other package. Forwarding it below therefore
|
|
1947
|
+
// made the reference-scaffold subprocess exit non-zero, which crashed
|
|
1948
|
+
// `update --scaffold-check` with "Could not generate the reference
|
|
1949
|
+
// scaffold" on every project that had ever run `add mobile` — the check
|
|
1950
|
+
// meant to keep projects healthy took the whole command down instead.
|
|
1951
|
+
const scaffoldFeatures = comparisonFeatures.filter(
|
|
1952
|
+
(feature) => feature !== "mobile",
|
|
1953
|
+
);
|
|
1947
1954
|
const allowSignup =
|
|
1948
1955
|
typeof quarkLink.authAllowSignup === "boolean"
|
|
1949
1956
|
? quarkLink.authAllowSignup
|
|
@@ -1957,11 +1964,28 @@ async function createReferenceScaffold(projectDir, quarkLink) {
|
|
|
1957
1964
|
"--no-prompts",
|
|
1958
1965
|
"--skip-install",
|
|
1959
1966
|
"--skip-docker",
|
|
1967
|
+
// A hand-edited or legacy .quark-link.json can leave the optional set
|
|
1968
|
+
// empty (only mobile recorded). `--packages ""` parses as one invalid
|
|
1969
|
+
// feature and the subprocess rejects it, so fall back to the minimum
|
|
1970
|
+
// scaffoldable set rather than failing the check on a technicality.
|
|
1960
1971
|
"--packages",
|
|
1961
|
-
|
|
1972
|
+
scaffoldFeatures.length > 0 ? scaffoldFeatures.join(",") : "ui",
|
|
1962
1973
|
"--signup",
|
|
1963
1974
|
allowSignup ? "enabled" : "disabled",
|
|
1964
1975
|
];
|
|
1976
|
+
// Forward the project's brief and harness so the reference scaffold is
|
|
1977
|
+
// byte-comparable. Without them the reference is built with the default
|
|
1978
|
+
// brief and the default harness directory, so every project with a custom
|
|
1979
|
+
// `--prompt` reports false drift on the brief-seeded doc files, and every
|
|
1980
|
+
// `--harness claude|copilot` project reports false-missing skill files for
|
|
1981
|
+
// the default .opencode/skills directory it never had.
|
|
1982
|
+
if (
|
|
1983
|
+
typeof quarkLink.brief === "string" &&
|
|
1984
|
+
quarkLink.brief.trim().length > 0
|
|
1985
|
+
) {
|
|
1986
|
+
args.push("--prompt", quarkLink.brief);
|
|
1987
|
+
}
|
|
1988
|
+
args.push("--harness", await detectConfiguredHarness(projectDir));
|
|
1965
1989
|
|
|
1966
1990
|
try {
|
|
1967
1991
|
await execa(process.execPath, args, {
|
|
@@ -1975,7 +1999,15 @@ async function createReferenceScaffold(projectDir, quarkLink) {
|
|
|
1975
1999
|
stdio: "pipe",
|
|
1976
2000
|
});
|
|
1977
2001
|
} catch (error) {
|
|
1978
|
-
|
|
2002
|
+
// Keep the subprocess's own error output, drop its stack trace: the
|
|
2003
|
+
// reference scaffold is a CLI invocation whose failure reason is the
|
|
2004
|
+
// line it printed before it exited, and a raw "at ..." dump only makes
|
|
2005
|
+
// a failed check look like a CLI crash.
|
|
2006
|
+
const details = (error.stderr || error.stdout || error.shortMessage || "")
|
|
2007
|
+
.split("\n")
|
|
2008
|
+
.filter((line) => !/^\s*at\s/.test(line))
|
|
2009
|
+
.join("\n")
|
|
2010
|
+
.trim();
|
|
1979
2011
|
await fs.remove(tempRoot);
|
|
1980
2012
|
throw new Error(
|
|
1981
2013
|
details
|
|
@@ -2381,60 +2413,15 @@ program
|
|
|
2381
2413
|
);
|
|
2382
2414
|
}
|
|
2383
2415
|
|
|
2384
|
-
// Add DEVICE model to Prisma schema
|
|
2385
|
-
const schemaPath = path.join(
|
|
2386
|
-
projectDir,
|
|
2387
|
-
"packages/db/prisma/schema.prisma",
|
|
2388
|
-
);
|
|
2389
|
-
if (await fs.pathExists(schemaPath)) {
|
|
2390
|
-
let schema = await fs.readFile(schemaPath, "utf-8");
|
|
2391
|
-
if (!schema.includes("model Device")) {
|
|
2392
|
-
// Insert Device model after AuditLog
|
|
2393
|
-
const deviceModel = `
|
|
2394
|
-
|
|
2395
|
-
// ─── Device Model (Mobile Push Notifications) ────────────────────────────────
|
|
2396
|
-
|
|
2397
|
-
model Device {
|
|
2398
|
-
id String @id @default(cuid())
|
|
2399
|
-
userId String
|
|
2400
|
-
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
2401
|
-
platform String // "ios" | "android"
|
|
2402
|
-
pushToken String
|
|
2403
|
-
deviceId String
|
|
2404
|
-
createdAt DateTime @default(now())
|
|
2405
|
-
updatedAt DateTime @updatedAt
|
|
2406
|
-
|
|
2407
|
-
@@unique([userId, deviceId])
|
|
2408
|
-
@@index([userId])
|
|
2409
|
-
@@index([pushToken])
|
|
2410
|
-
@@index([createdAt])
|
|
2411
|
-
}
|
|
2412
|
-
`;
|
|
2413
|
-
schema = schema.replace(
|
|
2414
|
-
/(\/\/ Audit Log Model[\s\S]*?^}\s)/m,
|
|
2415
|
-
`$1${deviceModel}`,
|
|
2416
|
-
);
|
|
2417
|
-
// Add devices relation to User model
|
|
2418
|
-
schema = schema.replace(
|
|
2419
|
-
/(files\s+File\[\])/,
|
|
2420
|
-
"$1\n devices Device[]",
|
|
2421
|
-
);
|
|
2422
|
-
await fs.writeFile(schemaPath, schema);
|
|
2423
|
-
}
|
|
2424
|
-
}
|
|
2425
|
-
|
|
2426
2416
|
console.log(
|
|
2427
2417
|
chalk.green(" ✓ Mobile app scaffolded (apps/mobile)"),
|
|
2428
2418
|
);
|
|
2429
|
-
console.log(
|
|
2430
|
-
chalk.green(" ✓ DEVICE model added to Prisma schema"),
|
|
2431
|
-
);
|
|
2432
2419
|
console.log(
|
|
2433
2420
|
chalk.green(" ✓ EXPO_PUBLIC_API_URL added to .env.example"),
|
|
2434
2421
|
);
|
|
2435
2422
|
console.log(
|
|
2436
2423
|
chalk.dim(
|
|
2437
|
-
" · API routes (token, refresh
|
|
2424
|
+
" · API routes (token, refresh) already in base template",
|
|
2438
2425
|
),
|
|
2439
2426
|
);
|
|
2440
2427
|
}
|
|
@@ -2604,12 +2591,7 @@ model Device {
|
|
|
2604
2591
|
console.log(chalk.white(" 3. pnpm dev (starts web + worker)"));
|
|
2605
2592
|
console.log(
|
|
2606
2593
|
chalk.white(
|
|
2607
|
-
" 4. pnpm dev:mobile (starts Expo in a second terminal)",
|
|
2608
|
-
),
|
|
2609
|
-
);
|
|
2610
|
-
console.log(
|
|
2611
|
-
chalk.white(
|
|
2612
|
-
" 5. Configure APNs/FCM env vars for push notifications\n",
|
|
2594
|
+
" 4. pnpm dev:mobile (starts Expo in a second terminal)\n",
|
|
2613
2595
|
),
|
|
2614
2596
|
);
|
|
2615
2597
|
}
|
|
@@ -2794,10 +2776,23 @@ program
|
|
|
2794
2776
|
}
|
|
2795
2777
|
|
|
2796
2778
|
if (options.scaffoldCheck) {
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
quarkLink
|
|
2800
|
-
)
|
|
2779
|
+
let scaffoldReport;
|
|
2780
|
+
try {
|
|
2781
|
+
scaffoldReport = await reportScaffoldDrift(process.cwd(), quarkLink);
|
|
2782
|
+
} catch (error) {
|
|
2783
|
+
// A drift check that cannot build its reference scaffold must
|
|
2784
|
+
// fail loudly and cleanly. The old behaviour rethrew into the
|
|
2785
|
+
// command handler, so a subprocess rejection surfaced as a raw
|
|
2786
|
+
// stack trace — indistinguishable from a CLI crash, when what
|
|
2787
|
+
// actually happened is that the check could not run.
|
|
2788
|
+
console.error(
|
|
2789
|
+
chalk.red(
|
|
2790
|
+
`✗ Scaffold check could not run: ${error?.message || error}`,
|
|
2791
|
+
),
|
|
2792
|
+
);
|
|
2793
|
+
process.exitCode = 1;
|
|
2794
|
+
return;
|
|
2795
|
+
}
|
|
2801
2796
|
const hasDrift =
|
|
2802
2797
|
scaffoldReport.changed.length > 0 || scaffoldReport.missing.length > 0;
|
|
2803
2798
|
if (options.failOnDrift && hasDrift) {
|
package/src/scaffold-warning.js
CHANGED
|
@@ -30,6 +30,11 @@ export function warnIfJobsOmitted(features, log = console.log) {
|
|
|
30
30
|
` Deleting a user leaves their uploaded files orphaned in storage — the schema\n keeps the rows rather than removing them — and without a worker nothing sweeps them.`,
|
|
31
31
|
),
|
|
32
32
|
);
|
|
33
|
+
log(
|
|
34
|
+
chalk.dim(
|
|
35
|
+
` Signup welcome emails are also skipped: nothing processes the queue.`,
|
|
36
|
+
),
|
|
37
|
+
);
|
|
33
38
|
log(
|
|
34
39
|
chalk.dim(
|
|
35
40
|
` Add it later with: npx @usequark/quark-create-app add jobs\n`,
|
|
@@ -2,7 +2,7 @@ import { PrismaAdapter } from "@auth/prisma-adapter";
|
|
|
2
2
|
import { createAuthConfig, verifyPassword } from "@usequark/quark-core/auth";
|
|
3
3
|
import { createLogger } from "@usequark/quark-core/core";
|
|
4
4
|
import { AppError } from "@usequark/quark-core/errors";
|
|
5
|
-
import { Prisma, prisma, user } from "@usequark/quark-db";
|
|
5
|
+
import { Prisma, prisma, user as userQueries } from "@usequark/quark-db";
|
|
6
6
|
import NextAuth from "next-auth";
|
|
7
7
|
import CredentialsProvider from "next-auth/providers/credentials";
|
|
8
8
|
import GithubProvider from "next-auth/providers/github";
|
|
@@ -66,7 +66,7 @@ const providers = [
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
try {
|
|
69
|
-
const existingUser = await
|
|
69
|
+
const existingUser = await userQueries.findByEmail(credentials.email);
|
|
70
70
|
|
|
71
71
|
if (!existingUser?.password) {
|
|
72
72
|
return null;
|
|
@@ -156,6 +156,64 @@ if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
|
|
|
156
156
|
);
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Re-validate the session against the database on every request.
|
|
161
|
+
*
|
|
162
|
+
* Web sessions are signed JWTs: a credentials provider forces the JWT
|
|
163
|
+
* strategy (Auth.js rejects `strategy: "database"` when every provider is
|
|
164
|
+
* credentials, and scaffolds always ship one), and the cookie would otherwise
|
|
165
|
+
* stay valid for its full 30 days no matter what happened to the account
|
|
166
|
+
* behind it. That was the review's "logins can't be revoked" finding — banning
|
|
167
|
+
* a user, demoting an admin, or changing a password did nothing until the
|
|
168
|
+
* token expired on its own.
|
|
169
|
+
*
|
|
170
|
+
* The revocation check is a single indexed primary-key read, which is the
|
|
171
|
+
* same cost Auth.js itself pays for database sessions — but it works for
|
|
172
|
+
* credentials apps too:
|
|
173
|
+
*
|
|
174
|
+
* - user gone (deleted / banned by deletion) → reject, cookie cleared
|
|
175
|
+
* - `passwordChangedAt` after the token's iat → reject, cookie cleared
|
|
176
|
+
* - role or profile changed → refreshed into the token,
|
|
177
|
+
* so `requireRole` reads the current role, not the sign-in-time one
|
|
178
|
+
*
|
|
179
|
+
* Returning null from the `jwt` callback is Auth.js's documented "invalidate
|
|
180
|
+
* this session" signal: it clears the cookie and the request continues
|
|
181
|
+
* unauthenticated.
|
|
182
|
+
*/
|
|
183
|
+
async function validateSessionToken(token) {
|
|
184
|
+
if (typeof token.id !== "string" || token.id.length === 0) {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
const account = await userQueries.findById(token.id);
|
|
190
|
+
|
|
191
|
+
if (!account) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (
|
|
196
|
+
account.passwordChangedAt &&
|
|
197
|
+
typeof token.iat === "number" &&
|
|
198
|
+
account.passwordChangedAt.getTime() / 1000 > token.iat
|
|
199
|
+
) {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
token.email = account.email;
|
|
204
|
+
token.name = account.name;
|
|
205
|
+
token.role = account.role;
|
|
206
|
+
return token;
|
|
207
|
+
} catch (err) {
|
|
208
|
+
// Fail closed: an unreachable database invalidates the session rather
|
|
209
|
+
// than letting an unverified cookie through. The next request retries.
|
|
210
|
+
logger.error("Session validation failed", {
|
|
211
|
+
message: err?.message ?? String(err),
|
|
212
|
+
});
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
159
217
|
export function getAuthOptions() {
|
|
160
218
|
return createAuthConfig({
|
|
161
219
|
adapter: PrismaAdapter(prisma),
|
|
@@ -172,7 +230,28 @@ export function getAuthOptions() {
|
|
|
172
230
|
},
|
|
173
231
|
},
|
|
174
232
|
session: {
|
|
233
|
+
// JWT is not a preference here, it is a constraint: Auth.js throws
|
|
234
|
+
// `UnsupportedStrategy` when strategy is "database" and every
|
|
235
|
+
// configured provider is credentials, and the scaffold always ships
|
|
236
|
+
// the credentials provider. Revocation is therefore enforced by
|
|
237
|
+
// `validateSessionToken` above, not by server-side session rows.
|
|
175
238
|
strategy: "jwt",
|
|
239
|
+
maxAge: 30 * 24 * 60 * 60, // 30 days
|
|
240
|
+
},
|
|
241
|
+
callbacks: {
|
|
242
|
+
async jwt({ token, user: signedInUser }) {
|
|
243
|
+
// Sign-in: stamp the identity once; the DB check below is for
|
|
244
|
+
// every subsequent request.
|
|
245
|
+
if (signedInUser) {
|
|
246
|
+
token.id = signedInUser.id;
|
|
247
|
+
token.email = signedInUser.email;
|
|
248
|
+
token.name = signedInUser.name;
|
|
249
|
+
token.role = signedInUser.role || "viewer";
|
|
250
|
+
return token;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return validateSessionToken(token);
|
|
254
|
+
},
|
|
176
255
|
},
|
|
177
256
|
});
|
|
178
257
|
}
|
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Queue and job name for the signup welcome email.
|
|
3
|
+
*
|
|
4
|
+
* These mirror `JOB_QUEUES.EMAIL` and `JOB_NAMES.SEND_WELCOME_EMAIL` in the
|
|
5
|
+
* `@<scope>/jobs` package — an *optional* scaffold package. Importing them from
|
|
6
|
+
* there puts `@<scope>/jobs` in the web module graph of every project, and a
|
|
7
|
+
* project that declines `jobs` has no such dependency to resolve: the web app
|
|
8
|
+
* fails to build before any test can run. The strings are duplicated here on
|
|
9
|
+
* purpose; a `packages/cli` test fails when the two copies diverge.
|
|
10
|
+
*/
|
|
11
|
+
export const EMAIL_QUEUE = "email-queue";
|
|
12
|
+
export const SEND_WELCOME_EMAIL = "send-welcome-email";
|
|
2
13
|
|
|
3
14
|
/**
|
|
4
15
|
* Loads the queue module on demand.
|
|
@@ -39,10 +50,10 @@ function loadQueueModule() {
|
|
|
39
50
|
*/
|
|
40
51
|
export async function enqueueWelcomeEmail(userId, loadQueue = loadQueueModule) {
|
|
41
52
|
const { createQueue } = await loadQueue();
|
|
42
|
-
const queue = createQueue(
|
|
53
|
+
const queue = createQueue(EMAIL_QUEUE);
|
|
43
54
|
|
|
44
55
|
try {
|
|
45
|
-
await queue.add(
|
|
56
|
+
await queue.add(SEND_WELCOME_EMAIL, { userId });
|
|
46
57
|
} finally {
|
|
47
58
|
// Never let a failing close mask the enqueue outcome — the caller only
|
|
48
59
|
// needs to know whether the job reached the queue.
|
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import { getAuthSecret } from "@usequark/quark-core/auth";
|
|
2
|
-
import { getToken } from "next-auth/jwt";
|
|
3
|
-
|
|
4
1
|
const STRICT_AUTH_RATE_LIMIT_ROUTES = new Set([
|
|
5
2
|
// `callback/credentials` is where the next-auth client POSTs a credentials
|
|
6
3
|
// sign-in (next-auth/react posts to `callback/<provider>` for credentials
|
package/templates/base-project/packages/db/prisma/migrations/20260202061128_initial/migration.sql
CHANGED
|
@@ -16,6 +16,7 @@ CREATE TABLE "User" (
|
|
|
16
16
|
"password" TEXT,
|
|
17
17
|
"image" TEXT,
|
|
18
18
|
"role" "UserRole" NOT NULL DEFAULT 'viewer',
|
|
19
|
+
"passwordChangedAt" TIMESTAMP(3),
|
|
19
20
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
20
21
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
21
22
|
|
|
@@ -117,9 +118,6 @@ CREATE TABLE "AuditLog" (
|
|
|
117
118
|
-- CreateIndex
|
|
118
119
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
|
119
120
|
|
|
120
|
-
-- CreateIndex
|
|
121
|
-
CREATE INDEX "User_email_idx" ON "User"("email");
|
|
122
|
-
|
|
123
121
|
-- CreateIndex
|
|
124
122
|
CREATE INDEX "User_createdAt_idx" ON "User"("createdAt");
|
|
125
123
|
|
|
@@ -141,9 +139,6 @@ CREATE INDEX "Session_expires_idx" ON "Session"("expires");
|
|
|
141
139
|
-- CreateIndex
|
|
142
140
|
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
|
|
143
141
|
|
|
144
|
-
-- CreateIndex
|
|
145
|
-
CREATE INDEX "VerificationToken_token_idx" ON "VerificationToken"("token");
|
|
146
|
-
|
|
147
142
|
-- CreateIndex
|
|
148
143
|
CREATE INDEX "VerificationToken_expires_idx" ON "VerificationToken"("expires");
|
|
149
144
|
|
|
@@ -153,12 +148,6 @@ CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationTok
|
|
|
153
148
|
-- CreateIndex
|
|
154
149
|
CREATE INDEX "Job_queue_idx" ON "Job"("queue");
|
|
155
150
|
|
|
156
|
-
-- CreateIndex
|
|
157
|
-
CREATE INDEX "Job_status_idx" ON "Job"("status");
|
|
158
|
-
|
|
159
|
-
-- CreateIndex
|
|
160
|
-
CREATE INDEX "Job_runAt_idx" ON "Job"("runAt");
|
|
161
|
-
|
|
162
151
|
-- CreateIndex
|
|
163
152
|
CREATE INDEX "Job_status_runAt_idx" ON "Job"("status", "runAt");
|
|
164
153
|
|
|
@@ -29,6 +29,10 @@ model User {
|
|
|
29
29
|
password String?
|
|
30
30
|
image String?
|
|
31
31
|
role UserRole @default(viewer)
|
|
32
|
+
// Bumped whenever the account's password changes. Session tokens (web JWTs
|
|
33
|
+
// and mobile refresh tokens) issued before this instant are rejected, so a
|
|
34
|
+
// stolen or pre-change session dies with the password it was born under.
|
|
35
|
+
passwordChangedAt DateTime?
|
|
32
36
|
createdAt DateTime @default(now())
|
|
33
37
|
updatedAt DateTime @updatedAt
|
|
34
38
|
|
|
@@ -37,7 +41,8 @@ model User {
|
|
|
37
41
|
auditLogs AuditLog[]
|
|
38
42
|
files File[]
|
|
39
43
|
|
|
40
|
-
|
|
44
|
+
// `email` is already @unique (User_email_key); a second plain index on the
|
|
45
|
+
// same column only adds write cost.
|
|
41
46
|
@@index([createdAt])
|
|
42
47
|
}
|
|
43
48
|
|
|
@@ -85,8 +90,9 @@ model VerificationToken {
|
|
|
85
90
|
createdAt DateTime @default(now())
|
|
86
91
|
updatedAt DateTime @updatedAt
|
|
87
92
|
|
|
93
|
+
// `token` is already @unique (VerificationToken_token_key); a second plain
|
|
94
|
+
// index on the same column only adds write cost.
|
|
88
95
|
@@unique([identifier, token])
|
|
89
|
-
@@index([token])
|
|
90
96
|
@@index([expires])
|
|
91
97
|
}
|
|
92
98
|
|
|
@@ -106,9 +112,9 @@ model Job {
|
|
|
106
112
|
createdAt DateTime @default(now())
|
|
107
113
|
updatedAt DateTime @updatedAt
|
|
108
114
|
|
|
115
|
+
// (status, runAt) serves status-only queries through its leftmost prefix;
|
|
116
|
+
// single-column [status] and [runAt] indexes were removed as redundant.
|
|
109
117
|
@@index([queue])
|
|
110
|
-
@@index([status])
|
|
111
|
-
@@index([runAt])
|
|
112
118
|
@@index([status, runAt])
|
|
113
119
|
@@index([createdAt])
|
|
114
120
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
export const JOB_QUEUES = {
|
|
2
2
|
EMAIL: "email-queue",
|
|
3
3
|
FILES: "files-queue",
|
|
4
|
-
PUSH: "push-queue",
|
|
5
4
|
DEFAULT: "default-queue",
|
|
6
5
|
};
|
|
7
6
|
|
|
@@ -9,5 +8,4 @@ export const JOB_NAMES = {
|
|
|
9
8
|
SEND_WELCOME_EMAIL: "send-welcome-email",
|
|
10
9
|
SEND_RESET_PASSWORD_EMAIL: "send-reset-password-email",
|
|
11
10
|
CLEANUP_ORPHANED_FILES: "cleanup-orphaned-files",
|
|
12
|
-
SEND_PUSH_NOTIFICATION: "send-push-notification",
|
|
13
11
|
};
|
|
@@ -2,7 +2,6 @@ import { Ionicons } from "@expo/vector-icons";
|
|
|
2
2
|
import { Redirect, Tabs } from "expo-router";
|
|
3
3
|
import { Platform } from "react-native";
|
|
4
4
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
5
|
-
import { AuthGate } from "../../components/AuthGate";
|
|
6
5
|
import { useTheme } from "../../components/ThemeProvider";
|
|
7
6
|
import { useAuth } from "../../hooks/use-auth";
|
|
8
7
|
|
|
@@ -21,7 +20,7 @@ export default function AppLayout() {
|
|
|
21
20
|
}
|
|
22
21
|
|
|
23
22
|
return (
|
|
24
|
-
|
|
23
|
+
<>
|
|
25
24
|
<Tabs
|
|
26
25
|
screenOptions={{
|
|
27
26
|
headerShown: false,
|
|
@@ -78,6 +77,6 @@ export default function AppLayout() {
|
|
|
78
77
|
}}
|
|
79
78
|
/>
|
|
80
79
|
</Tabs>
|
|
81
|
-
|
|
80
|
+
</>
|
|
82
81
|
);
|
|
83
82
|
}
|
|
@@ -10,7 +10,6 @@ interface AuthState {
|
|
|
10
10
|
isLoading: boolean;
|
|
11
11
|
signIn: (email: string, password: string) => Promise<void>;
|
|
12
12
|
signOut: () => Promise<void>;
|
|
13
|
-
registerPushToken: () => Promise<void>;
|
|
14
13
|
}
|
|
15
14
|
|
|
16
15
|
let authStateListeners: Array<() => void> = [];
|
|
@@ -19,7 +18,6 @@ let globalAuthState: AuthState = {
|
|
|
19
18
|
isLoading: true,
|
|
20
19
|
signIn: async () => {},
|
|
21
20
|
signOut: async () => {},
|
|
22
|
-
registerPushToken: async () => {},
|
|
23
21
|
};
|
|
24
22
|
|
|
25
23
|
function notifyListeners() {
|
|
@@ -35,10 +33,6 @@ export function useAuthInit() {
|
|
|
35
33
|
const [, setState] = useState<AuthState>(globalAuthState);
|
|
36
34
|
|
|
37
35
|
useEffect(() => {
|
|
38
|
-
import("../lib/notifications")
|
|
39
|
-
.then((m) => m.configureNotificationHandler())
|
|
40
|
-
.catch(() => {});
|
|
41
|
-
|
|
42
36
|
(async () => {
|
|
43
37
|
try {
|
|
44
38
|
const authenticated = await checkAuth();
|
|
@@ -104,24 +98,9 @@ export function useAuth(): AuthState {
|
|
|
104
98
|
notifyListeners();
|
|
105
99
|
}, []);
|
|
106
100
|
|
|
107
|
-
const registerPushToken = useCallback(async () => {
|
|
108
|
-
try {
|
|
109
|
-
const { registerForPushNotifications, registerDevice } = await import(
|
|
110
|
-
"../lib/notifications"
|
|
111
|
-
);
|
|
112
|
-
const token = await registerForPushNotifications();
|
|
113
|
-
if (token) {
|
|
114
|
-
await registerDevice(token);
|
|
115
|
-
}
|
|
116
|
-
} catch {
|
|
117
|
-
// Non-critical - don't block auth flow
|
|
118
|
-
}
|
|
119
|
-
}, []);
|
|
120
|
-
|
|
121
101
|
return {
|
|
122
102
|
...state,
|
|
123
103
|
signIn,
|
|
124
104
|
signOut,
|
|
125
|
-
registerPushToken,
|
|
126
105
|
};
|
|
127
106
|
}
|
|
@@ -18,8 +18,6 @@
|
|
|
18
18
|
"expo-auth-session": "~57.0.13",
|
|
19
19
|
"expo-constants": "~57.0.19",
|
|
20
20
|
"expo-crypto": "~57.0.3",
|
|
21
|
-
"expo-device": "~57.0.2",
|
|
22
|
-
"expo-notifications": "~57.0.21",
|
|
23
21
|
"expo-router": "~57.0.23",
|
|
24
22
|
"expo-secure-store": "~57.0.4",
|
|
25
23
|
"expo-status-bar": "~57.0.1",
|
|
@@ -10,11 +10,9 @@ import {
|
|
|
10
10
|
handleSendWelcomeEmail,
|
|
11
11
|
} from "./email.js";
|
|
12
12
|
import { handleCleanupOrphanedFiles } from "./files.js";
|
|
13
|
-
import { handleSendPushNotification } from "./push-notification.js";
|
|
14
13
|
|
|
15
14
|
export const jobHandlers = {
|
|
16
15
|
[JOB_NAMES.SEND_WELCOME_EMAIL]: handleSendWelcomeEmail,
|
|
17
16
|
[JOB_NAMES.SEND_RESET_PASSWORD_EMAIL]: handleSendResetPasswordEmail,
|
|
18
17
|
[JOB_NAMES.CLEANUP_ORPHANED_FILES]: handleCleanupOrphanedFiles,
|
|
19
|
-
[JOB_NAMES.SEND_PUSH_NOTIFICATION]: handleSendPushNotification,
|
|
20
18
|
};
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
import { validateBody } from "@usequark/quark-core/core";
|
|
2
|
-
import { prisma } from "@usequark/quark-db";
|
|
3
|
-
import { NextResponse } from "next/server";
|
|
4
|
-
import { z } from "zod";
|
|
5
|
-
import { extractBearerPayload } from "../../../../lib/jwt";
|
|
6
|
-
import { handleError } from "../../error-handler";
|
|
7
|
-
|
|
8
|
-
const deviceRegisterSchema = z.object({
|
|
9
|
-
platform: z.enum(["ios", "android"]),
|
|
10
|
-
pushToken: z.string().min(1),
|
|
11
|
-
deviceId: z.string().min(1),
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* POST /api/device/register
|
|
16
|
-
* Register a mobile device for push notifications.
|
|
17
|
-
* Requires authentication via Bearer token.
|
|
18
|
-
*/
|
|
19
|
-
export async function POST(request) {
|
|
20
|
-
try {
|
|
21
|
-
const payload = await extractBearerPayload(request);
|
|
22
|
-
if (!payload?.sub) {
|
|
23
|
-
return NextResponse.json(
|
|
24
|
-
{ message: "Authentication required" },
|
|
25
|
-
{ status: 401 },
|
|
26
|
-
);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const userId = payload.sub;
|
|
30
|
-
|
|
31
|
-
const { platform, pushToken, deviceId } = await validateBody(
|
|
32
|
-
request,
|
|
33
|
-
deviceRegisterSchema,
|
|
34
|
-
);
|
|
35
|
-
|
|
36
|
-
// Upsert device record
|
|
37
|
-
await prisma.device.upsert({
|
|
38
|
-
where: {
|
|
39
|
-
userId_deviceId: { userId, deviceId },
|
|
40
|
-
},
|
|
41
|
-
update: {
|
|
42
|
-
pushToken,
|
|
43
|
-
platform,
|
|
44
|
-
},
|
|
45
|
-
create: {
|
|
46
|
-
userId,
|
|
47
|
-
platform,
|
|
48
|
-
pushToken,
|
|
49
|
-
deviceId,
|
|
50
|
-
},
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
return NextResponse.json({ success: true });
|
|
54
|
-
} catch (error) {
|
|
55
|
-
return handleError(error);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { type ReactNode, useEffect } from "react";
|
|
2
|
-
import { useAuth } from "../hooks/use-auth";
|
|
3
|
-
|
|
4
|
-
interface AuthGateProps {
|
|
5
|
-
children: ReactNode;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Wraps authenticated screens and ensures push token is registered.
|
|
10
|
-
*/
|
|
11
|
-
export function AuthGate({ children }: AuthGateProps) {
|
|
12
|
-
const { registerPushToken } = useAuth();
|
|
13
|
-
|
|
14
|
-
useEffect(() => {
|
|
15
|
-
registerPushToken();
|
|
16
|
-
}, [registerPushToken]);
|
|
17
|
-
|
|
18
|
-
return <>{children}</>;
|
|
19
|
-
}
|
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
import { Platform } from "react-native";
|
|
2
|
-
import { apiClient } from "./api-client";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Register the device push token with the backend.
|
|
6
|
-
* Must be called after authentication.
|
|
7
|
-
*/
|
|
8
|
-
export async function registerForPushNotifications(): Promise<string | null> {
|
|
9
|
-
const Device = await import("expo-device");
|
|
10
|
-
if (!Device.isDevice) {
|
|
11
|
-
return null;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
const Notifications = await import("expo-notifications");
|
|
15
|
-
|
|
16
|
-
const { status: existingStatus } = await Notifications.getPermissionsAsync();
|
|
17
|
-
let finalStatus = existingStatus;
|
|
18
|
-
|
|
19
|
-
if (existingStatus !== "granted") {
|
|
20
|
-
const { status } = await Notifications.requestPermissionsAsync();
|
|
21
|
-
finalStatus = status;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
if (finalStatus !== "granted") {
|
|
25
|
-
return null;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const tokenData = await Notifications.getExpoPushTokenAsync();
|
|
29
|
-
const pushToken = tokenData.data;
|
|
30
|
-
|
|
31
|
-
if (Platform.OS === "android") {
|
|
32
|
-
Notifications.setNotificationChannelAsync("default", {
|
|
33
|
-
name: "default",
|
|
34
|
-
importance: Notifications.AndroidImportance.MAX,
|
|
35
|
-
vibrationPattern: [0, 250, 250, 250],
|
|
36
|
-
lightColor: "#FF231F7C",
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
return pushToken;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Send the push token to the backend for storage.
|
|
45
|
-
*/
|
|
46
|
-
export async function registerDevice(pushToken: string): Promise<void> {
|
|
47
|
-
const { Platform: PlatformModule } = await import("react-native");
|
|
48
|
-
const Constants = await import("expo-constants");
|
|
49
|
-
const deviceId =
|
|
50
|
-
Constants.default?.installationId ||
|
|
51
|
-
Constants.default?.expoConfig?.extra?.eas?.projectId ||
|
|
52
|
-
"unknown";
|
|
53
|
-
|
|
54
|
-
await apiClient("/api/device/register", {
|
|
55
|
-
method: "POST",
|
|
56
|
-
body: JSON.stringify({
|
|
57
|
-
platform: PlatformModule.OS,
|
|
58
|
-
pushToken,
|
|
59
|
-
deviceId,
|
|
60
|
-
}),
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Configure foreground notification handling.
|
|
66
|
-
*/
|
|
67
|
-
export async function configureNotificationHandler() {
|
|
68
|
-
const Notifications = await import("expo-notifications");
|
|
69
|
-
Notifications.setNotificationHandler({
|
|
70
|
-
handleNotification: async () => ({
|
|
71
|
-
shouldShowBanner: true,
|
|
72
|
-
shouldShowList: true,
|
|
73
|
-
shouldPlaySound: true,
|
|
74
|
-
shouldSetBadge: false,
|
|
75
|
-
}),
|
|
76
|
-
});
|
|
77
|
-
}
|
|
@@ -1,234 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Push Notification Job Handler
|
|
3
|
-
* Sends push notifications via APNs (iOS) and FCM (Android) directly.
|
|
4
|
-
* No Firebase SDK — uses HTTP APIs with service account credentials from env.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { AppError, ValidationError } from "@usequark/quark-core/errors";
|
|
8
|
-
import { prisma } from "@usequark/quark-db";
|
|
9
|
-
import { importPKCS8, SignJWT } from "jose";
|
|
10
|
-
|
|
11
|
-
const APNS_KEY = process.env.APNS_KEY;
|
|
12
|
-
const APNS_KEY_ID = process.env.APNS_KEY_ID;
|
|
13
|
-
const APNS_TEAM_ID = process.env.APNS_TEAM_ID;
|
|
14
|
-
const APNS_BUNDLE_ID = process.env.APNS_BUNDLE_ID;
|
|
15
|
-
const APNS_USE_SANDBOX = process.env.APNS_USE_SANDBOX !== "false";
|
|
16
|
-
|
|
17
|
-
const GCP_SERVICE_ACCOUNT = process.env.GCP_SERVICE_ACCOUNT;
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Job handler for SEND_PUSH_NOTIFICATION
|
|
21
|
-
* @param {import("bullmq").Job} bullJob
|
|
22
|
-
* @param {import("@usequark/quark-core").Logger} logger
|
|
23
|
-
*/
|
|
24
|
-
export async function handleSendPushNotification(bullJob, logger) {
|
|
25
|
-
const { userId, title, body: notificationBody, data } = bullJob.data;
|
|
26
|
-
|
|
27
|
-
if (!userId || !title || !notificationBody) {
|
|
28
|
-
throw new ValidationError("Missing required fields: userId, title, body");
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// Fetch all devices for the user
|
|
32
|
-
const devices = await prisma.device.findMany({
|
|
33
|
-
where: { userId },
|
|
34
|
-
select: { pushToken: true, platform: true },
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
if (devices.length === 0) {
|
|
38
|
-
logger.warn("No devices found for user", { userId });
|
|
39
|
-
return { success: true, sent: 0, failed: 0 };
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
let sent = 0;
|
|
43
|
-
let failed = 0;
|
|
44
|
-
|
|
45
|
-
for (const device of devices) {
|
|
46
|
-
try {
|
|
47
|
-
if (device.platform === "ios") {
|
|
48
|
-
await sendAPNs(device.pushToken, title, notificationBody, data);
|
|
49
|
-
sent++;
|
|
50
|
-
} else if (device.platform === "android") {
|
|
51
|
-
await sendFCM(device.pushToken, title, notificationBody, data);
|
|
52
|
-
sent++;
|
|
53
|
-
}
|
|
54
|
-
} catch (error) {
|
|
55
|
-
logger.error("Failed to send push notification", {
|
|
56
|
-
error: error.message,
|
|
57
|
-
platform: device.platform,
|
|
58
|
-
token: device.pushToken.slice(0, 10),
|
|
59
|
-
});
|
|
60
|
-
failed++;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
logger.info("Push notification batch complete", {
|
|
65
|
-
userId,
|
|
66
|
-
sent,
|
|
67
|
-
failed,
|
|
68
|
-
total: devices.length,
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
return { success: true, sent, failed };
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Generate APNs JWT token for authentication.
|
|
76
|
-
*/
|
|
77
|
-
async function generateAPNsToken() {
|
|
78
|
-
if (!APNS_KEY || !APNS_KEY_ID || !APNS_TEAM_ID) {
|
|
79
|
-
throw new AppError(
|
|
80
|
-
"APNs credentials not configured (APNS_KEY, APNS_KEY_ID, APNS_TEAM_ID)",
|
|
81
|
-
500,
|
|
82
|
-
"APNS_NOT_CONFIGURED",
|
|
83
|
-
);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const privateKey = await importPKCS8(APNS_KEY, "ES256");
|
|
87
|
-
const now = Math.floor(Date.now() / 1000);
|
|
88
|
-
|
|
89
|
-
return new SignJWT({})
|
|
90
|
-
.setProtectedHeader({ alg: "ES256", kid: APNS_KEY_ID })
|
|
91
|
-
.setIssuedAt(now)
|
|
92
|
-
.setExpirationTime("1h")
|
|
93
|
-
.setIssuer(APNS_TEAM_ID)
|
|
94
|
-
.sign(privateKey);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Send push notification via Apple Push Notification service (HTTP/2).
|
|
99
|
-
*/
|
|
100
|
-
async function sendAPNs(token, title, body, data = {}) {
|
|
101
|
-
if (!APNS_KEY) {
|
|
102
|
-
throw new AppError("APNs not configured", 500, "APNS_NOT_CONFIGURED");
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const jwt = await generateAPNsToken();
|
|
106
|
-
const host = APNS_USE_SANDBOX
|
|
107
|
-
? "api.sandbox.push.apple.com"
|
|
108
|
-
: "api.push.apple.com";
|
|
109
|
-
|
|
110
|
-
const payload = {
|
|
111
|
-
aps: {
|
|
112
|
-
alert: { title, body },
|
|
113
|
-
sound: "default",
|
|
114
|
-
"content-available": 1,
|
|
115
|
-
},
|
|
116
|
-
...data,
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
const response = await fetch(`https://${host}/3/device/${token}`, {
|
|
120
|
-
method: "POST",
|
|
121
|
-
headers: {
|
|
122
|
-
authorization: `bearer ${jwt}`,
|
|
123
|
-
"apns-topic": APNS_BUNDLE_ID || "",
|
|
124
|
-
"apns-push-type": "alert",
|
|
125
|
-
"apns-priority": "10",
|
|
126
|
-
"content-type": "application/json",
|
|
127
|
-
},
|
|
128
|
-
body: JSON.stringify(payload),
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
if (!response.ok) {
|
|
132
|
-
const errorBody = await response.text();
|
|
133
|
-
throw new AppError(
|
|
134
|
-
`APNs error ${response.status}: ${errorBody}`,
|
|
135
|
-
502,
|
|
136
|
-
"APNS_ERROR",
|
|
137
|
-
);
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Get a short-lived OAuth2 access token for FCM from a GCP service account.
|
|
143
|
-
*/
|
|
144
|
-
async function getFCMAccessToken() {
|
|
145
|
-
if (!GCP_SERVICE_ACCOUNT) {
|
|
146
|
-
throw new AppError(
|
|
147
|
-
"GCP_SERVICE_ACCOUNT not configured",
|
|
148
|
-
500,
|
|
149
|
-
"FCM_NOT_CONFIGURED",
|
|
150
|
-
);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const serviceAccount = JSON.parse(GCP_SERVICE_ACCOUNT);
|
|
154
|
-
const now = Math.floor(Date.now() / 1000);
|
|
155
|
-
|
|
156
|
-
const privateKey = await importPKCS8(serviceAccount.private_key, "RS256");
|
|
157
|
-
|
|
158
|
-
const jwt = await new SignJWT({
|
|
159
|
-
scope: "https://www.googleapis.com/auth/firebase.messaging",
|
|
160
|
-
})
|
|
161
|
-
.setProtectedHeader({ alg: "RS256", kid: serviceAccount.private_key_id })
|
|
162
|
-
.setIssuedAt(now)
|
|
163
|
-
.setExpirationTime("1h")
|
|
164
|
-
.setIssuer(serviceAccount.client_email)
|
|
165
|
-
.setSubject(serviceAccount.client_email)
|
|
166
|
-
.audience("https://oauth2.googleapis.com/token")
|
|
167
|
-
.sign(privateKey);
|
|
168
|
-
|
|
169
|
-
const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
|
|
170
|
-
method: "POST",
|
|
171
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
172
|
-
body: new URLSearchParams({
|
|
173
|
-
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
174
|
-
assertion: jwt,
|
|
175
|
-
}),
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
if (!tokenResponse.ok) {
|
|
179
|
-
const error = await tokenResponse.text();
|
|
180
|
-
throw new AppError(
|
|
181
|
-
`FCM token exchange failed: ${error}`,
|
|
182
|
-
502,
|
|
183
|
-
"FCM_TOKEN_EXCHANGE_FAILED",
|
|
184
|
-
);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
const { access_token } = await tokenResponse.json();
|
|
188
|
-
return access_token;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Send push notification via Firebase Cloud Messaging HTTP v1 API.
|
|
193
|
-
*/
|
|
194
|
-
async function sendFCM(token, title, body, data = {}) {
|
|
195
|
-
const accessToken = await getFCMAccessToken();
|
|
196
|
-
|
|
197
|
-
const projectId = JSON.parse(GCP_SERVICE_ACCOUNT || "{}").project_id;
|
|
198
|
-
if (!projectId) {
|
|
199
|
-
throw new AppError(
|
|
200
|
-
"GCP project_id not found in service account",
|
|
201
|
-
500,
|
|
202
|
-
"FCM_NOT_CONFIGURED",
|
|
203
|
-
);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const message = {
|
|
207
|
-
token,
|
|
208
|
-
notification: { title, body },
|
|
209
|
-
data: Object.fromEntries(
|
|
210
|
-
Object.entries(data).map(([k, v]) => [k, String(v)]),
|
|
211
|
-
),
|
|
212
|
-
};
|
|
213
|
-
|
|
214
|
-
const response = await fetch(
|
|
215
|
-
`https://fcm.googleapis.com/v1/projects/${projectId}/messages:send`,
|
|
216
|
-
{
|
|
217
|
-
method: "POST",
|
|
218
|
-
headers: {
|
|
219
|
-
authorization: `Bearer ${accessToken}`,
|
|
220
|
-
"content-type": "application/json",
|
|
221
|
-
},
|
|
222
|
-
body: JSON.stringify({ message }),
|
|
223
|
-
},
|
|
224
|
-
);
|
|
225
|
-
|
|
226
|
-
if (!response.ok) {
|
|
227
|
-
const errorBody = await response.text();
|
|
228
|
-
throw new AppError(
|
|
229
|
-
`FCM error ${response.status}: ${errorBody}`,
|
|
230
|
-
502,
|
|
231
|
-
"FCM_ERROR",
|
|
232
|
-
);
|
|
233
|
-
}
|
|
234
|
-
}
|