@usequark/quark-create-app 1.25.8 → 1.25.10
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/templates/base-project/apps/web/src/app/api/auth/apple/route.js +1 -1
- package/templates/base-project/apps/web/src/app/api/auth/google/route.js +20 -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 +29 -5
- package/templates/base-project/apps/web/src/lib/email-verified.js +28 -0
- 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/config/src/redis.js +1 -1
- package/templates/config/src/validate-env.js +1 -1
- 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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { getAppleClientIds } from "@usequark/quark-config/oauth";
|
|
3
|
-
import { validateBody } from "@usequark/quark-core";
|
|
3
|
+
import { validateBody } from "@usequark/quark-core/core";
|
|
4
4
|
import { user } from "@usequark/quark-db";
|
|
5
5
|
import { importJWK, jwtVerify } from "jose";
|
|
6
6
|
import { NextResponse } from "next/server";
|
|
@@ -2,10 +2,11 @@ import {
|
|
|
2
2
|
getGoogleClientId,
|
|
3
3
|
isAudienceValid,
|
|
4
4
|
} from "@usequark/quark-config/oauth";
|
|
5
|
-
import { validateBody } from "@usequark/quark-core";
|
|
5
|
+
import { validateBody } from "@usequark/quark-core/core";
|
|
6
6
|
import { user } from "@usequark/quark-db";
|
|
7
7
|
import { NextResponse } from "next/server";
|
|
8
8
|
import { z } from "zod";
|
|
9
|
+
import { isEmailVerified } from "../../../../lib/email-verified";
|
|
9
10
|
import { issueTokenPair } from "../../../../lib/jwt";
|
|
10
11
|
import { handleError } from "../../error-handler";
|
|
11
12
|
|
|
@@ -73,6 +74,24 @@ export async function POST(request) {
|
|
|
73
74
|
);
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
// An unverified address proves nothing about who controls the inbox, and
|
|
78
|
+
// this route creates the account on first sight — so without this check the
|
|
79
|
+
// confirmation that is supposed to establish ownership of the address is
|
|
80
|
+
// the attacker's to skip rather than the victim's to perform.
|
|
81
|
+
//
|
|
82
|
+
// It sits after the missing-email `400` on purpose. That one describes the
|
|
83
|
+
// shape of the token rather than reporting that the token was refused, and
|
|
84
|
+
// putting this check ahead of it would change a response operators already
|
|
85
|
+
// read for what it is. What matters for security is the position relative
|
|
86
|
+
// to the database: both checks land before `findByEmail`, so an unverified
|
|
87
|
+
// address can never become a registration or existence oracle.
|
|
88
|
+
if (!isEmailVerified(tokenInfo.email_verified)) {
|
|
89
|
+
return NextResponse.json(
|
|
90
|
+
{ message: "Invalid Google token" },
|
|
91
|
+
{ status: 401 },
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
76
95
|
// Find or create user
|
|
77
96
|
let existingUser = await user.findByEmail(googleEmail);
|
|
78
97
|
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,14 +1,13 @@
|
|
|
1
1
|
import { PrismaAdapter } from "@auth/prisma-adapter";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
verifyPassword,
|
|
6
|
-
} from "@usequark/quark-core";
|
|
2
|
+
import { createAuthConfig, verifyPassword } from "@usequark/quark-core/auth";
|
|
3
|
+
import { createLogger } from "@usequark/quark-core/core";
|
|
4
|
+
import { AppError } from "@usequark/quark-core/errors";
|
|
7
5
|
import { Prisma, prisma, user } from "@usequark/quark-db";
|
|
8
6
|
import NextAuth from "next-auth";
|
|
9
7
|
import CredentialsProvider from "next-auth/providers/credentials";
|
|
10
8
|
import GithubProvider from "next-auth/providers/github";
|
|
11
9
|
import GoogleProvider from "next-auth/providers/google";
|
|
10
|
+
import { isEmailVerified } from "./email-verified";
|
|
12
11
|
|
|
13
12
|
const logger = createLogger({ name: "auth" });
|
|
14
13
|
const STALE_SESSION_PATTERN = /jwtsessionerror|no matching decryption secret/i;
|
|
@@ -128,6 +127,31 @@ if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
|
|
|
128
127
|
GoogleProvider({
|
|
129
128
|
clientId: process.env.GOOGLE_CLIENT_ID,
|
|
130
129
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
|
130
|
+
// The web sign-in creates its account on first sight through the
|
|
131
|
+
// Prisma adapter, so an unconfirmed address here means the same thing it
|
|
132
|
+
// does on the mobile route: the confirmation that is supposed to
|
|
133
|
+
// establish ownership of the address never happened. Google does issue
|
|
134
|
+
// tokens for unconfirmed addresses.
|
|
135
|
+
//
|
|
136
|
+
// The refusal is thrown rather than returned, and that is deliberate.
|
|
137
|
+
// Auth.js has no "null means refuse" contract: `getUserAndAccount` reads
|
|
138
|
+
// `profile.email` straight off whatever the mapper hands back, so a null
|
|
139
|
+
// denial works only because it trips a TypeError that is caught and
|
|
140
|
+
// logged as `OAuthProfileParseError` — indistinguishable, in the log, from
|
|
141
|
+
// a malformed provider response or a user who cancelled. Throwing says
|
|
142
|
+
// why in the one place an operator can read it, and lands in the same
|
|
143
|
+
// catch: no session, no user row, redirect back to the sign-in page.
|
|
144
|
+
profile: (profile) => {
|
|
145
|
+
if (!isEmailVerified(profile.email_verified)) {
|
|
146
|
+
throw new AppError(
|
|
147
|
+
"Google sign-in refused: the account's email address is not verified",
|
|
148
|
+
401,
|
|
149
|
+
"OAUTH_EMAIL_NOT_VERIFIED",
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return profile;
|
|
154
|
+
},
|
|
131
155
|
}),
|
|
132
156
|
);
|
|
133
157
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a provider's `email_verified` claim says the address is confirmed.
|
|
3
|
+
*
|
|
4
|
+
* Providers disagree on the wire format. Google's `tokeninfo` endpoint reports
|
|
5
|
+
* the claim as the *string* `"true"` or `"false"`; the OIDC userinfo that
|
|
6
|
+
* reaches a NextAuth profile mapper carries the same string. The id token's own
|
|
7
|
+
* claim is a boolean. A plain truthiness check would therefore do the exact
|
|
8
|
+
* opposite of the obvious thing: accept `"false"` while refusing every real
|
|
9
|
+
* sign-in. Both spellings mean the same thing and both are accepted; nothing
|
|
10
|
+
* else is.
|
|
11
|
+
*
|
|
12
|
+
* Everything else — a missing claim, a null, a number, `"yes"` — reads as
|
|
13
|
+
* unverified, because the failure modes are asymmetric. A wrong refusal costs
|
|
14
|
+
* one unlucky sign-in. A wrong acceptance hands an unconfirmed address a
|
|
15
|
+
* session on a route or a provider that creates the account on first sight.
|
|
16
|
+
*
|
|
17
|
+
* Shared by both Google sign-in paths — the hand-written `POST /api/auth/google`
|
|
18
|
+
* the mobile app posts to, and the NextAuth provider the web button uses — so
|
|
19
|
+
* there is one definition of "confirmed" rather than two that can drift.
|
|
20
|
+
*
|
|
21
|
+
* @param {unknown} value
|
|
22
|
+
* @returns {boolean}
|
|
23
|
+
*/
|
|
24
|
+
export function isEmailVerified(value) {
|
|
25
|
+
if (typeof value === "boolean") return value;
|
|
26
|
+
if (typeof value !== "string") return false;
|
|
27
|
+
return value.trim().toLowerCase() === "true";
|
|
28
|
+
}
|
|
@@ -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");
|
|
@@ -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");
|