@usequark/quark-create-app 1.25.4 → 1.25.6
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/discovery.js +36 -0
- package/src/deploy/inspect.js +11 -1
- package/src/index.js +6 -1
- package/src/scaffold-warning.js +39 -0
- package/templates/base-project/apps/web/package.json +1 -1
- package/templates/base-project/apps/web/src/app/api/csrf/route.js +21 -10
- package/templates/base-project/apps/web/src/app/api/users/[id]/route.js +28 -2
- package/templates/base-project/apps/web/src/app/auth/register/RegisterPageClient.js +42 -2
- package/templates/base-project/packages/db/prisma/migrations/20260202061128_initial/migration.sql +3 -2
- package/templates/base-project/packages/db/prisma/schema.prisma +21 -11
- package/templates/base-project/packages/db/src/queries.js +42 -7
- package/templates/base-project/pnpm-workspace.yaml +25 -0
- package/templates/base-project/scripts/check-standards.mjs +86 -0
- package/templates/base-project/skills/quark-skills/SKILL.md +1 -6
- package/templates/base-project/turbo.json +2 -1
- package/templates/ui/package.json +1 -1
package/package.json
CHANGED
package/src/deploy/discovery.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import {
|
|
4
4
|
QUARK_DEPLOY_PROJECT_KIND,
|
|
5
5
|
QUARK_SERVICE_CONTRACTS,
|
|
6
|
+
QUARK_SERVICE_KINDS,
|
|
6
7
|
SUPPORTED_QUARK_SERVICE_KINDS,
|
|
7
8
|
} from "./contract.js";
|
|
8
9
|
|
|
@@ -10,6 +11,17 @@ export const QUARK_DEPLOY_DIAGNOSTIC_CODES = Object.freeze({
|
|
|
10
11
|
MISSING_REQUIRED_SERVICE: "missing_required_service",
|
|
11
12
|
});
|
|
12
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Codes for conditions that do not block a deploy but are worth saying out loud.
|
|
16
|
+
*
|
|
17
|
+
* Kept separate from DIAGNOSTIC_CODES because `resolveQuarkDeployProject` throws on
|
|
18
|
+
* any diagnostic, and a worker-less project is a legitimate choice — `jobs` is an
|
|
19
|
+
* optional scaffold feature. Failing the deploy would punish a valid configuration.
|
|
20
|
+
*/
|
|
21
|
+
export const QUARK_DEPLOY_WARNING_CODES = Object.freeze({
|
|
22
|
+
MISSING_WORKER_SERVICE: "missing_worker_service",
|
|
23
|
+
});
|
|
24
|
+
|
|
13
25
|
function resolveProjectPath(projectDir, relativePath) {
|
|
14
26
|
return path.join(projectDir, ...relativePath.split("/"));
|
|
15
27
|
}
|
|
@@ -37,6 +49,17 @@ function createMissingRequiredServiceDiagnostic(serviceContract) {
|
|
|
37
49
|
};
|
|
38
50
|
}
|
|
39
51
|
|
|
52
|
+
function createMissingWorkerWarning() {
|
|
53
|
+
return {
|
|
54
|
+
code: QUARK_DEPLOY_WARNING_CODES.MISSING_WORKER_SERVICE,
|
|
55
|
+
serviceKind: "worker",
|
|
56
|
+
required: false,
|
|
57
|
+
expectedPath: QUARK_SERVICE_CONTRACTS.worker.relativePackageJsonPath,
|
|
58
|
+
message:
|
|
59
|
+
"No Quark worker service found. Scheduled jobs will not run — including CLEANUP_ORPHANED_FILES, which is the only thing that sweeps the File rows left orphaned when a user is deleted (onDelete: SetNull keeps the rows and the blobs). Those rows and blobs accumulate indefinitely. Add the jobs feature (`npx @usequark/quark-create-app add jobs`) if this project uploads files.",
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
40
63
|
function createDiscoveredService(projectDir, serviceContract, packageJson) {
|
|
41
64
|
return {
|
|
42
65
|
kind: serviceContract.kind,
|
|
@@ -68,6 +91,9 @@ export function formatQuarkDeployDiagnostics(diagnostics) {
|
|
|
68
91
|
export async function discoverQuarkDeployProject(projectDir) {
|
|
69
92
|
const services = [];
|
|
70
93
|
const diagnostics = [];
|
|
94
|
+
const warnings = [];
|
|
95
|
+
let sawWeb = false;
|
|
96
|
+
let sawWorker = false;
|
|
71
97
|
|
|
72
98
|
for (const serviceKind of SUPPORTED_QUARK_SERVICE_KINDS) {
|
|
73
99
|
const serviceContract = QUARK_SERVICE_CONTRACTS[serviceKind];
|
|
@@ -85,16 +111,26 @@ export async function discoverQuarkDeployProject(projectDir) {
|
|
|
85
111
|
continue;
|
|
86
112
|
}
|
|
87
113
|
|
|
114
|
+
if (serviceKind === QUARK_SERVICE_KINDS.WEB) sawWeb = true;
|
|
115
|
+
if (serviceKind === QUARK_SERVICE_KINDS.WORKER) sawWorker = true;
|
|
116
|
+
|
|
88
117
|
services.push(
|
|
89
118
|
createDiscoveredService(projectDir, serviceContract, packageJson),
|
|
90
119
|
);
|
|
91
120
|
}
|
|
92
121
|
|
|
122
|
+
// A web project with no worker is valid — `jobs` is optional — but the orphaned
|
|
123
|
+
// file cleanup only runs there, so say so rather than letting it fail silently.
|
|
124
|
+
if (sawWeb && !sawWorker) {
|
|
125
|
+
warnings.push(createMissingWorkerWarning());
|
|
126
|
+
}
|
|
127
|
+
|
|
93
128
|
return {
|
|
94
129
|
kind: QUARK_DEPLOY_PROJECT_KIND,
|
|
95
130
|
rootDir: projectDir,
|
|
96
131
|
services,
|
|
97
132
|
diagnostics,
|
|
133
|
+
warnings,
|
|
98
134
|
};
|
|
99
135
|
}
|
|
100
136
|
|
package/src/deploy/inspect.js
CHANGED
|
@@ -45,13 +45,23 @@ export async function inspectProject(options = {}) {
|
|
|
45
45
|
|
|
46
46
|
// --- Diagnostics ---
|
|
47
47
|
if (discovery.diagnostics.length > 0) {
|
|
48
|
-
output(chalk.yellow(" Diagnostics
|
|
48
|
+
output(chalk.yellow(" Diagnostics"));
|
|
49
49
|
for (const diagnostic of discovery.diagnostics) {
|
|
50
50
|
output(` ${chalk.yellow("⚠")} ${diagnostic.message}`);
|
|
51
51
|
}
|
|
52
52
|
output();
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// --- Warnings ---
|
|
56
|
+
// Non-blocking, so printed after diagnostics and separately from them.
|
|
57
|
+
if (discovery.warnings.length > 0) {
|
|
58
|
+
output(chalk.yellow(" Warnings"));
|
|
59
|
+
for (const warning of discovery.warnings) {
|
|
60
|
+
output(` ${chalk.yellow("⚠")} ${warning.message}`);
|
|
61
|
+
}
|
|
62
|
+
output();
|
|
63
|
+
}
|
|
64
|
+
|
|
55
65
|
// --- Railway readiness ---
|
|
56
66
|
output(chalk.cyan(" Railway Readiness\n"));
|
|
57
67
|
|
package/src/index.js
CHANGED
|
@@ -10,6 +10,7 @@ import { execa } from "execa";
|
|
|
10
10
|
import fs from "fs-extra";
|
|
11
11
|
import prompts from "prompts";
|
|
12
12
|
import { SCAFFOLD_GITIGNORE } from "./scaffold-gitignore.js";
|
|
13
|
+
import { warnIfJobsOmitted } from "./scaffold-warning.js";
|
|
13
14
|
import { findEnclosingGitRepo, formatProjectDisplayName } from "./utils.js";
|
|
14
15
|
|
|
15
16
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -611,7 +612,7 @@ program
|
|
|
611
612
|
)
|
|
612
613
|
.option(
|
|
613
614
|
"--packages <packages>",
|
|
614
|
-
"Comma-separated
|
|
615
|
+
"Comma-separated optional packages to include (ui,jobs,pwa). db, config, ui always scaffolded. Mobile is post-creation: quark add mobile",
|
|
615
616
|
)
|
|
616
617
|
.option(
|
|
617
618
|
"--prompt <prompt>",
|
|
@@ -952,6 +953,8 @@ program
|
|
|
952
953
|
` Selected packages: ${features.join(", ") || "none"} (non-interactive mode)`,
|
|
953
954
|
),
|
|
954
955
|
);
|
|
956
|
+
|
|
957
|
+
warnIfJobsOmitted(features);
|
|
955
958
|
} else if (!options.prompts) {
|
|
956
959
|
// Use defaults when --no-prompts is set without --packages
|
|
957
960
|
console.log(chalk.cyan("\n 🎯 Configuring optional packages..."));
|
|
@@ -1014,6 +1017,8 @@ program
|
|
|
1014
1017
|
|
|
1015
1018
|
features = resolveFeatureSelection(selectedFeatures);
|
|
1016
1019
|
|
|
1020
|
+
warnIfJobsOmitted(features);
|
|
1021
|
+
|
|
1017
1022
|
const requestedFeatures = [...selectedFeatures];
|
|
1018
1023
|
const autoIncluded = features.filter(
|
|
1019
1024
|
(feature) => !requestedFeatures.includes(feature),
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Warn when the `jobs` feature was not selected for a scaffold.
|
|
5
|
+
*
|
|
6
|
+
* `File.uploadedBy` is `onDelete: SetNull`, so deleting a user orphans their file
|
|
7
|
+
* rows rather than removing them. `CLEANUP_ORPHANED_FILES` is the only thing that
|
|
8
|
+
* sweeps those rows and their blobs, and it runs in the worker — which is scaffolded
|
|
9
|
+
* only alongside `jobs`. Declining `jobs` therefore leaves orphaned rows and blobs
|
|
10
|
+
* accumulating indefinitely, with nothing reading `uploadedById = null` to surface
|
|
11
|
+
* them.
|
|
12
|
+
*
|
|
13
|
+
* Said at the point of the decision rather than discovered in production. It is a
|
|
14
|
+
* warning and not a refusal: `jobs` is optional and a project with no uploads has no
|
|
15
|
+
* orphan problem.
|
|
16
|
+
*
|
|
17
|
+
* @param {string[]} features - Resolved feature list for the scaffold.
|
|
18
|
+
* @returns {boolean} Whether the warning was printed.
|
|
19
|
+
*/
|
|
20
|
+
export function warnIfJobsOmitted(features, log = console.log) {
|
|
21
|
+
if (features.includes("jobs")) return false;
|
|
22
|
+
|
|
23
|
+
log(
|
|
24
|
+
chalk.yellow(
|
|
25
|
+
` ⚠ Background jobs omitted: the orphaned-file cleanup will not exist.`,
|
|
26
|
+
),
|
|
27
|
+
);
|
|
28
|
+
log(
|
|
29
|
+
chalk.dim(
|
|
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
|
+
),
|
|
32
|
+
);
|
|
33
|
+
log(
|
|
34
|
+
chalk.dim(
|
|
35
|
+
` Add it later with: npx @usequark/quark-create-app add jobs\n`,
|
|
36
|
+
),
|
|
37
|
+
);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
@@ -6,21 +6,32 @@
|
|
|
6
6
|
|
|
7
7
|
import { generateCsrfToken } from "@usequark/quark-core";
|
|
8
8
|
import { NextResponse } from "next/server";
|
|
9
|
-
import { auth } from "@/lib/auth";
|
|
10
9
|
import { getCsrfCookieOptions } from "@/lib/csrf-cookie";
|
|
11
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Headers applied to every response.
|
|
13
|
+
*
|
|
14
|
+
* `no-store` is load-bearing, not hygiene. The body of this response *is* a
|
|
15
|
+
* write credential: paired with the cookie it sets, it authorises the holder's
|
|
16
|
+
* next mutating request. A shared cache that stored it could hand the token to
|
|
17
|
+
* a second caller, who would then be able to write as the first — so this
|
|
18
|
+
* response must never be cached, by the browser or anything between it and the
|
|
19
|
+
* client. `Vary: Cookie` keeps that true for a cache that keys on request
|
|
20
|
+
* headers rather than obeying `no-store` outright.
|
|
21
|
+
*/
|
|
22
|
+
const NO_STORE_HEADERS = {
|
|
23
|
+
"Cache-Control": "no-store, no-cache, must-revalidate",
|
|
24
|
+
Vary: "Cookie",
|
|
25
|
+
};
|
|
26
|
+
|
|
12
27
|
export async function GET(request) {
|
|
13
28
|
try {
|
|
14
|
-
const session = await auth();
|
|
15
|
-
|
|
16
|
-
if (!session) {
|
|
17
|
-
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
// Generate CSRF token
|
|
21
29
|
const csrfToken = generateCsrfToken();
|
|
22
30
|
|
|
23
|
-
const response = NextResponse.json(
|
|
31
|
+
const response = NextResponse.json(
|
|
32
|
+
{ csrfToken },
|
|
33
|
+
{ headers: NO_STORE_HEADERS },
|
|
34
|
+
);
|
|
24
35
|
|
|
25
36
|
// Store the token in a secure HTTP-only cookie so the server can
|
|
26
37
|
// validate it on subsequent state-changing requests.
|
|
@@ -34,7 +45,7 @@ export async function GET(request) {
|
|
|
34
45
|
} catch (_error) {
|
|
35
46
|
return NextResponse.json(
|
|
36
47
|
{ error: "Failed to generate CSRF token" },
|
|
37
|
-
{ status: 500 },
|
|
48
|
+
{ status: 500, headers: NO_STORE_HEADERS },
|
|
38
49
|
);
|
|
39
50
|
}
|
|
40
51
|
}
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
ConflictError,
|
|
3
|
+
validateBody,
|
|
4
|
+
withCsrfProtection,
|
|
5
|
+
} from "@usequark/quark-core";
|
|
2
6
|
import { user, userUpdateSchema } from "@usequark/quark-db";
|
|
3
7
|
import { NextResponse } from "next/server";
|
|
4
8
|
import { requireRole } from "@/lib/auth-middleware";
|
|
@@ -36,11 +40,33 @@ export const PATCH = withCsrfProtection(async (request, { params }) => {
|
|
|
36
40
|
}
|
|
37
41
|
});
|
|
38
42
|
|
|
43
|
+
/**
|
|
44
|
+
* DELETE /api/users/[id]
|
|
45
|
+
* Delete a user. Admin only.
|
|
46
|
+
*
|
|
47
|
+
* Self-deletion is refused. The route resolves its target purely from the path
|
|
48
|
+
* segment and otherwise only checks the caller's role, so an admin could delete
|
|
49
|
+
* their own row — and if they were the last admin, leave the deployment with no
|
|
50
|
+
* way to administer it. Refusing here rather than warning is deliberate: the
|
|
51
|
+
* request cannot succeed without leaving either the caller or the deployment
|
|
52
|
+
* locked out, so there is no outcome the caller would want.
|
|
53
|
+
*
|
|
54
|
+
* This guard is per-request, not a check on the remaining admin count. Two
|
|
55
|
+
* concurrent deletes of the two last admins can still both pass it. Enforcing
|
|
56
|
+
* that invariant needs a transaction around the count and the delete, which is a
|
|
57
|
+
* larger change than this endpoint's current shape supports.
|
|
58
|
+
*/
|
|
39
59
|
export const DELETE = withCsrfProtection(async (_request, { params }) => {
|
|
40
60
|
try {
|
|
41
|
-
await requireRole("admin");
|
|
61
|
+
const session = await requireRole("admin");
|
|
42
62
|
const { id } = await params;
|
|
43
63
|
|
|
64
|
+
if (session?.user?.id === id) {
|
|
65
|
+
throw new ConflictError(
|
|
66
|
+
"You cannot delete the account you are signed in as",
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
44
70
|
const existingUser = await user.findById(id);
|
|
45
71
|
if (!existingUser) {
|
|
46
72
|
return NextResponse.json({ message: "User not found" }, { status: 404 });
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { clearCsrfToken, getCsrfToken } from "@usequark/quark-core/csrf-client";
|
|
3
4
|
import {
|
|
4
5
|
Button,
|
|
5
6
|
ErrorBanner,
|
|
@@ -98,16 +99,55 @@ function RegisterForm() {
|
|
|
98
99
|
setPasswordError("");
|
|
99
100
|
setLoading(true);
|
|
100
101
|
|
|
101
|
-
|
|
102
|
+
// The CSRF token has to be in hand before the write. `GET /api/csrf` sets
|
|
103
|
+
// the matching httpOnly cookie and returns the same value; the server
|
|
104
|
+
// compares the two, so a request that arrives without the cookie is
|
|
105
|
+
// rejected before the handler runs. This page is pre-authentication, which
|
|
106
|
+
// is why the endpoint mints tokens for callers with no session.
|
|
107
|
+
//
|
|
108
|
+
// Both the token fetch and the write can fail on the network, and this
|
|
109
|
+
// handler has already set `loading`. Without a catch, either one would
|
|
110
|
+
// leave the button spinning forever.
|
|
111
|
+
try {
|
|
112
|
+
const res = await postRegistration(await getCsrfToken());
|
|
113
|
+
|
|
114
|
+
// The cache refetches on a timer, which covers expiry. It cannot cover
|
|
115
|
+
// the server rotating the cookie underneath us — a sign-out, or anything
|
|
116
|
+
// else that clears cookies — and that surfaces as a rejected write with
|
|
117
|
+
// nothing visibly wrong. Drop the stale token and try once more; a
|
|
118
|
+
// second failure is a real failure and falls through to the banner.
|
|
119
|
+
if (res.status === 401) {
|
|
120
|
+
clearCsrfToken();
|
|
121
|
+
return await completeRegistration(
|
|
122
|
+
await postRegistration(await getCsrfToken()),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return await completeRegistration(res);
|
|
127
|
+
} catch {
|
|
128
|
+
setError("Could not reach the server. Try again.");
|
|
129
|
+
setLoading(false);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** POST the form to /api/auth/register with the given token. */
|
|
134
|
+
async function postRegistration(csrfToken) {
|
|
135
|
+
return await fetch("/api/auth/register", {
|
|
102
136
|
method: "POST",
|
|
103
|
-
headers: {
|
|
137
|
+
headers: {
|
|
138
|
+
"Content-Type": "application/json",
|
|
139
|
+
"X-CSRF-Token": csrfToken,
|
|
140
|
+
},
|
|
104
141
|
body: JSON.stringify({
|
|
105
142
|
email: email.trim(),
|
|
106
143
|
password,
|
|
107
144
|
name: name.trim() || undefined,
|
|
108
145
|
}),
|
|
109
146
|
});
|
|
147
|
+
}
|
|
110
148
|
|
|
149
|
+
/** Surface a failure, or sign the new account in. */
|
|
150
|
+
async function completeRegistration(res) {
|
|
111
151
|
if (!res.ok) {
|
|
112
152
|
const body = await res.json().catch(() => ({}));
|
|
113
153
|
setError(body.message || "Registration failed. Try again.");
|
package/templates/base-project/packages/db/prisma/migrations/20260202061128_initial/migration.sql
CHANGED
|
@@ -101,7 +101,8 @@ CREATE TABLE "File" (
|
|
|
101
101
|
-- CreateTable
|
|
102
102
|
CREATE TABLE "AuditLog" (
|
|
103
103
|
"id" TEXT NOT NULL,
|
|
104
|
-
"userId" TEXT
|
|
104
|
+
"userId" TEXT,
|
|
105
|
+
"actorEmail" TEXT,
|
|
105
106
|
"action" TEXT NOT NULL,
|
|
106
107
|
"entity" TEXT NOT NULL,
|
|
107
108
|
"entityId" TEXT NOT NULL,
|
|
@@ -198,5 +199,5 @@ ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId"
|
|
|
198
199
|
ALTER TABLE "File" ADD CONSTRAINT "File_uploadedById_fkey" FOREIGN KEY ("uploadedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
199
200
|
|
|
200
201
|
-- AddForeignKey
|
|
201
|
-
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE
|
|
202
|
+
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
202
203
|
|
|
@@ -141,18 +141,28 @@ model File {
|
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
// Audit Log Model
|
|
144
|
+
//
|
|
145
|
+
// `userId` is nullable and SetNull, not Cascade. An audit record that is deleted
|
|
146
|
+
// along with the user it describes cannot serve as an audit trail — and deleting a
|
|
147
|
+
// user destroys the rows recording what that user did, including the rows recording
|
|
148
|
+
// the deletion itself.
|
|
149
|
+
//
|
|
150
|
+
// `actorEmail` snapshots the actor's address at write time. With SetNull alone the
|
|
151
|
+
// row survives but stops naming who acted, so a trail that outlives its actor would
|
|
152
|
+
// be unattributable. The snapshot keeps the record readable on its own terms.
|
|
144
153
|
model AuditLog {
|
|
145
|
-
id
|
|
146
|
-
userId
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
154
|
+
id String @id @default(cuid())
|
|
155
|
+
userId String?
|
|
156
|
+
actorEmail String?
|
|
157
|
+
action String
|
|
158
|
+
entity String
|
|
159
|
+
entityId String
|
|
160
|
+
changes Json?
|
|
161
|
+
metadata Json?
|
|
162
|
+
createdAt DateTime @default(now())
|
|
163
|
+
updatedAt DateTime @updatedAt
|
|
164
|
+
|
|
165
|
+
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
|
156
166
|
|
|
157
167
|
@@index([userId])
|
|
158
168
|
@@index([action])
|
|
@@ -15,6 +15,22 @@ const USER_SAFE_SELECT = {
|
|
|
15
15
|
updatedAt: true,
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Look up an actor's email for the AuditLog snapshot.
|
|
20
|
+
*
|
|
21
|
+
* The lookup cannot miss: the foreign key rejects a `userId` that names no user,
|
|
22
|
+
* so `onDelete: SetNull` is the only way `userId` becomes null, and it happens after
|
|
23
|
+
* the snapshot was written. A null `userId` here means the caller supplied none.
|
|
24
|
+
*/
|
|
25
|
+
async function resolveActorEmail(userId) {
|
|
26
|
+
if (!userId) return null;
|
|
27
|
+
const actor = await prisma.user.findUnique({
|
|
28
|
+
where: { id: userId },
|
|
29
|
+
select: { email: true },
|
|
30
|
+
});
|
|
31
|
+
return actor?.email ?? null;
|
|
32
|
+
}
|
|
33
|
+
|
|
18
34
|
export { USER_SAFE_SELECT };
|
|
19
35
|
|
|
20
36
|
// User queries
|
|
@@ -188,13 +204,21 @@ export const verificationToken = {
|
|
|
188
204
|
};
|
|
189
205
|
|
|
190
206
|
// AuditLog queries
|
|
207
|
+
//
|
|
208
|
+
// `user` is optional: a deleted user leaves `userId` null but the row survives
|
|
209
|
+
// (see the AuditLog model). Every read therefore returns `actorEmail` alongside,
|
|
210
|
+
// so a caller can name the actor without dereferencing `user`. Render `actorEmail`
|
|
211
|
+
// — `user` is for callers that need the live row, e.g. to follow through to the
|
|
212
|
+
// user's current role.
|
|
213
|
+
const AUDIT_ACTOR_SELECT = { id: true, email: true, name: true };
|
|
214
|
+
|
|
191
215
|
export const auditLog = {
|
|
192
216
|
findAll: (options = {}) => {
|
|
193
217
|
const { skip = 0, take = 50 } = options;
|
|
194
218
|
return prisma.auditLog.findMany({
|
|
195
219
|
skip,
|
|
196
220
|
take,
|
|
197
|
-
include: { user: { select:
|
|
221
|
+
include: { user: { select: AUDIT_ACTOR_SELECT } },
|
|
198
222
|
orderBy: { createdAt: "desc" },
|
|
199
223
|
});
|
|
200
224
|
},
|
|
@@ -204,7 +228,7 @@ export const auditLog = {
|
|
|
204
228
|
where: { userId },
|
|
205
229
|
skip,
|
|
206
230
|
take,
|
|
207
|
-
include: { user: { select:
|
|
231
|
+
include: { user: { select: AUDIT_ACTOR_SELECT } },
|
|
208
232
|
orderBy: { createdAt: "desc" },
|
|
209
233
|
});
|
|
210
234
|
},
|
|
@@ -214,7 +238,7 @@ export const auditLog = {
|
|
|
214
238
|
where: { entity },
|
|
215
239
|
skip,
|
|
216
240
|
take,
|
|
217
|
-
include: { user: { select:
|
|
241
|
+
include: { user: { select: AUDIT_ACTOR_SELECT } },
|
|
218
242
|
orderBy: { createdAt: "desc" },
|
|
219
243
|
});
|
|
220
244
|
},
|
|
@@ -224,14 +248,25 @@ export const auditLog = {
|
|
|
224
248
|
where: { action },
|
|
225
249
|
skip,
|
|
226
250
|
take,
|
|
227
|
-
include: { user: { select:
|
|
251
|
+
include: { user: { select: AUDIT_ACTOR_SELECT } },
|
|
228
252
|
orderBy: { createdAt: "desc" },
|
|
229
253
|
});
|
|
230
254
|
},
|
|
231
|
-
|
|
255
|
+
/**
|
|
256
|
+
* Create an audit record, snapshotting the actor's email.
|
|
257
|
+
*
|
|
258
|
+
* The snapshot is written here rather than left to the caller because it is the
|
|
259
|
+
* only thing that keeps the record attributable once the user row is gone:
|
|
260
|
+
* `onDelete: SetNull` preserves the row but clears `userId`. A caller that
|
|
261
|
+
* forgets the field gets a permanent record naming nobody, so this derives it
|
|
262
|
+
* from `userId` when not supplied.
|
|
263
|
+
*/
|
|
264
|
+
create: async (data) => {
|
|
265
|
+
const actorEmail =
|
|
266
|
+
data.actorEmail ?? (await resolveActorEmail(data.userId));
|
|
232
267
|
return prisma.auditLog.create({
|
|
233
|
-
data,
|
|
234
|
-
include: { user: { select:
|
|
268
|
+
data: { ...data, actorEmail },
|
|
269
|
+
include: { user: { select: AUDIT_ACTOR_SELECT } },
|
|
235
270
|
});
|
|
236
271
|
},
|
|
237
272
|
};
|
|
@@ -11,6 +11,31 @@ onlyBuiltDependencies:
|
|
|
11
11
|
- simple-git-hooks
|
|
12
12
|
|
|
13
13
|
overrides:
|
|
14
|
+
# next-auth@5.0.0-beta.32 and @auth/core@0.41.3 declare nodemailer as an
|
|
15
|
+
# OPTIONAL peer with the range `^7.0.7 || ^8.0.5`. Every advisory against
|
|
16
|
+
# nodemailer needs >=10.0.6, so no version inside that peer range is
|
|
17
|
+
# patched, and neither package has shipped a newer release that widens it.
|
|
18
|
+
#
|
|
19
|
+
# Nothing in this repo uses Auth.js's Nodemailer email provider: auth.js
|
|
20
|
+
# configures Credentials, Github and Google only, and @auth/core's index
|
|
21
|
+
# never imports nodemailer statically (only providers/nodemailer.js does).
|
|
22
|
+
# Outgoing mail goes through @usequark/quark-core, which depends on
|
|
23
|
+
# nodemailer ^10.0.10 directly. Removing the unused peer edge deletes the
|
|
24
|
+
# vulnerable 7.0.13 copy from the tree instead of forcing it two majors past
|
|
25
|
+
# a range its author chose. Delete these two lines if you ever add
|
|
26
|
+
# next-auth/providers/nodemailer.
|
|
27
|
+
"next-auth>nodemailer": "-"
|
|
28
|
+
"@auth/core>nodemailer": "-"
|
|
29
|
+
# stripe@17.7.0 declares `qs: ^6.11.0`, so 6.16.0 is inside its own range and
|
|
30
|
+
# `pnpm update` cannot move it (qs is not a direct dependency of any workspace
|
|
31
|
+
# package). GHSA-g4mx-9h6g-cwc7 / GHSA-hrpp-f998-fjpq both need >=6.16.0.
|
|
32
|
+
qs@>=6.14.2 <=6.15.3: ">=6.16.0"
|
|
33
|
+
# xcode@3.0.1 declares `uuid: ^7.0.3`; GHSA-w5hq-g745-h8pq needs >=11.1.1,
|
|
34
|
+
# four majors ahead. Safe here because xcode only calls uuid.v4() (lib/
|
|
35
|
+
# pbxProject.js), which is unchanged in 11.x, and 11.1.1 still supports
|
|
36
|
+
# CommonJS require(). This is build-time only - xcode is reached through
|
|
37
|
+
# @expo/config-plugins while generating native projects.
|
|
38
|
+
uuid@<11.1.1: ">=11.1.1"
|
|
14
39
|
fast-xml-builder@<1.1.7: ">=1.1.7"
|
|
15
40
|
fast-xml-parser@<5.10.1: ">=5.10.1"
|
|
16
41
|
lodash@>=4.0.0 <=4.17.22: ">=4.17.23"
|
|
@@ -15,6 +15,51 @@ const tsExtensions = new Set([".ts", ".tsx"]);
|
|
|
15
15
|
const throwNewErrorPattern = /\bthrow new Error\s*\(/;
|
|
16
16
|
const consolePattern = /\bconsole\.(?:log|warn|error|info)\s*\(/;
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* `next-auth` and `@auth/core` declare nodemailer as an *optional* peer pinned to
|
|
20
|
+
* `^7.0.7 || ^8.0.5`. No version in that range is free of an advisory, so
|
|
21
|
+
* pnpm-workspace.yaml removes the peer edge entirely instead:
|
|
22
|
+
*
|
|
23
|
+
* "next-auth>nodemailer": "-"
|
|
24
|
+
* "@auth/core>nodemailer": "-"
|
|
25
|
+
*
|
|
26
|
+
* That is what clears 13 of the 21 advisories, and it is safe only while nothing
|
|
27
|
+
* imports Auth.js's Nodemailer provider. The failure mode if that changes is bad:
|
|
28
|
+
* `providers/nodemailer.js` does `import { createTransport } from "nodemailer"` at
|
|
29
|
+
* module scope, so the import throws ERR_MODULE_NOT_FOUND on the next server start -
|
|
30
|
+
* not at build time, and not with any hint that an override caused it.
|
|
31
|
+
*
|
|
32
|
+
* This check turns that into a build failure with the fix attached. It fires on the
|
|
33
|
+
* import specifier rather than on the override, so it stays correct if the override
|
|
34
|
+
* is ever reworded, and it covers both the monorepo and the scaffold template.
|
|
35
|
+
*
|
|
36
|
+
* The pattern has to accept every way a module specifier can be reached, because a
|
|
37
|
+
* guard that misses one spelling is worse than none: it reads as protection while
|
|
38
|
+
* letting the breakage through. All of these resolve the provider and so must fail:
|
|
39
|
+
*
|
|
40
|
+
* import N from "next-auth/providers/nodemailer" // static default
|
|
41
|
+
* import "next-auth/providers/nodemailer" // bare side-effect
|
|
42
|
+
* export { default } from "next-auth/providers/nodemailer"
|
|
43
|
+
* await import("next-auth/providers/nodemailer") // dynamic
|
|
44
|
+
* import("next-auth/providers/nodemailer").then(m => m.default)
|
|
45
|
+
* require("next-auth/providers/nodemailer") // CommonJS
|
|
46
|
+
*
|
|
47
|
+
* The optional `\(` is what the first version got wrong: dynamic `import(` puts a
|
|
48
|
+
* paren between the keyword and the quote, so an alternation of
|
|
49
|
+
* `from|import|require\s*\(` matched the static forms and silently missed every
|
|
50
|
+
* dynamic one.
|
|
51
|
+
*
|
|
52
|
+
* Each keyword carries a leading `\b` so an identifier that merely contains one -
|
|
53
|
+
* `important`, `myrequire` - cannot trigger it, and the specifier is anchored so
|
|
54
|
+
* only this exact module matches.
|
|
55
|
+
*
|
|
56
|
+
* If you genuinely want the provider: delete the two overrides above, then add
|
|
57
|
+
* `nodemailer` (>=10.0.6, past every advisory) to the app's own dependencies and
|
|
58
|
+
* allowlist this check for that file.
|
|
59
|
+
*/
|
|
60
|
+
const authNodemailerProviderPattern =
|
|
61
|
+
/\b(?:from|import|require)\s*\(?\s*["'`]next-auth\/providers\/nodemailer["'`]/;
|
|
62
|
+
|
|
18
63
|
/**
|
|
19
64
|
* Tailwind v4 removed the bare `[--var]` shorthand that v3 accepted. Under v4 a
|
|
20
65
|
* class such as `text-[--navbar-text-muted]` is still a *valid* utility name, so
|
|
@@ -125,6 +170,36 @@ function findMatchingLines(content, pattern) {
|
|
|
125
170
|
return matches;
|
|
126
171
|
}
|
|
127
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Same job as findMatchingLines, but matches against the whole file rather than one
|
|
175
|
+
* line at a time, and reports the line each match starts on.
|
|
176
|
+
*
|
|
177
|
+
* Needed for the nodemailer guard, whose pattern legitimately spans a line break:
|
|
178
|
+
* `import Nodemailer from\n "next-auth/providers/nodemailer";` is valid JavaScript
|
|
179
|
+
* that the line-at-a-time version cannot see, because the keyword and the specifier
|
|
180
|
+
* land on different lines. Line-at-a-time is the right default for the other checks -
|
|
181
|
+
* they only ever match within one line - so this is opt-in.
|
|
182
|
+
*
|
|
183
|
+
* The match is retried with a lastIndex-aware scan because a pattern carrying the `g`
|
|
184
|
+
* flag would otherwise return only the first hit; `d` additionally keeps the index
|
|
185
|
+
* pointing at the start of the match rather than at a capture group.
|
|
186
|
+
*/
|
|
187
|
+
function findMatchingLinesMultiline(content, pattern) {
|
|
188
|
+
const scanner = new RegExp(
|
|
189
|
+
pattern.source,
|
|
190
|
+
pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`,
|
|
191
|
+
);
|
|
192
|
+
const matches = [];
|
|
193
|
+
let match = scanner.exec(content);
|
|
194
|
+
while (match !== null) {
|
|
195
|
+
matches.push(content.slice(0, match.index).split("\n").length);
|
|
196
|
+
// A zero-width match would otherwise loop forever.
|
|
197
|
+
scanner.lastIndex = match.index + (match[0].length || 1);
|
|
198
|
+
match = scanner.exec(content);
|
|
199
|
+
}
|
|
200
|
+
return matches;
|
|
201
|
+
}
|
|
202
|
+
|
|
128
203
|
async function collectFiles(directoryPath) {
|
|
129
204
|
const entries = await readdir(directoryPath, { withFileTypes: true });
|
|
130
205
|
const files = [];
|
|
@@ -213,6 +288,17 @@ async function main() {
|
|
|
213
288
|
);
|
|
214
289
|
}
|
|
215
290
|
}
|
|
291
|
+
|
|
292
|
+
if (isSourceFile(relativePath)) {
|
|
293
|
+
for (const lineNumber of findMatchingLinesMultiline(
|
|
294
|
+
content,
|
|
295
|
+
authNodemailerProviderPattern,
|
|
296
|
+
)) {
|
|
297
|
+
violations.push(
|
|
298
|
+
`${relativePath}:${lineNumber}: next-auth/providers/nodemailer cannot resolve - pnpm-workspace.yaml removes that peer edge to clear 13 nodemailer advisories, and the peer range (^7.0.7 || ^8.0.5) has no patched version. Delete the two nodemailer overrides and add nodemailer >=10.0.6 as a direct dependency to use this provider.`,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
216
302
|
}
|
|
217
303
|
|
|
218
304
|
if (violations.length > 0) {
|
|
@@ -14,18 +14,13 @@ Every skill below ships with this project and is always available. Skills are ab
|
|
|
14
14
|
| add-model | `add-model/SKILL.md` | Add a new Prisma model, query helpers, and tests to the db package. |
|
|
15
15
|
| add-endpoint | `add-endpoint/SKILL.md` | Add an API route (or Server Action) with Zod validation, CSRF, and role guards. |
|
|
16
16
|
| add-dashboard | `add-dashboard/SKILL.md` | Add a metrics/overview dashboard page with metric cards and loading states. |
|
|
17
|
-
| admin-dashboard | `admin-dashboard/SKILL.md` | Build a full admin/back-office area: guarded routes, CRUD screens, audit logging. |
|
|
18
17
|
|
|
19
18
|
## Domain starters
|
|
20
19
|
|
|
21
|
-
These teach the AI to build a complete vertical on demand. No starter code is scaffolded
|
|
20
|
+
These teach the AI to build a complete vertical on demand. No starter code is scaffolded; the AI generates it from the skill.
|
|
22
21
|
|
|
23
22
|
| Skill | Path | Purpose |
|
|
24
23
|
|---|---|---|
|
|
25
|
-
| bookings | `bookings/SKILL.md` | Booking/reservation/appointment/scheduling systems. |
|
|
26
|
-
| crm | `crm/SKILL.md` | Contact/pipeline/customer relationship systems. |
|
|
27
|
-
| cms | `cms/SKILL.md` | Content modeling, authoring, and publishing systems. |
|
|
28
|
-
| ai | `ai/SKILL.md` | AI assistant features (chat, completions) on app data. |
|
|
29
24
|
| payment | `payment/SKILL.md` | Stripe payment integration, checkout, webhooks, fulfillment. |
|
|
30
25
|
| ecommerce | `ecommerce/SKILL.md` | Product catalog, cart, checkout, orders, inventory. Sub-skills: `ecommerce-catalog`, `ecommerce-cart`, `ecommerce-checkout`. |
|
|
31
26
|
| i18n | `i18n/SKILL.md` | Internationalization, locale routing, localized CMS content, hreflang SEO. |
|