@usequark/quark-create-app 1.23.11
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/README.md +210 -0
- package/package.json +56 -0
- package/src/dependabot-config.test.js +128 -0
- package/src/deploy/adapters/iac.js +203 -0
- package/src/deploy/adapters/index.js +27 -0
- package/src/deploy/adapters/railway.js +508 -0
- package/src/deploy/adapters/railway.test.js +318 -0
- package/src/deploy/contract.js +59 -0
- package/src/deploy/deploy.integration.test.js +183 -0
- package/src/deploy/deploy.js +430 -0
- package/src/deploy/deploy.test.js +105 -0
- package/src/deploy/discovery.js +115 -0
- package/src/deploy/discovery.test.js +141 -0
- package/src/deploy/index.js +38 -0
- package/src/deploy/inspect.js +130 -0
- package/src/index.js +2920 -0
- package/src/mobile-template.test.js +232 -0
- package/src/prepare-hooks.test.js +535 -0
- package/src/scaffold-guards.test.js +140 -0
- package/src/scaffold-output.test.js +228 -0
- package/src/template-scripts.test.js +160 -0
- package/src/utils.js +77 -0
- package/src/utils.test.js +95 -0
- package/src/worker-dockerfile.test.js +85 -0
- package/templates/base-project/.cursor/rules/quark.mdc +172 -0
- package/templates/base-project/.dockerignore +14 -0
- package/templates/base-project/.env.railway.example +87 -0
- package/templates/base-project/.github/copilot-instructions.md +65 -0
- package/templates/base-project/.github/dependabot.yml +31 -0
- package/templates/base-project/.github/skills/project-context/SKILL.md +109 -0
- package/templates/base-project/.github/workflows/ci.yml +116 -0
- package/templates/base-project/.github/workflows/dependabot-auto-merge.yml +22 -0
- package/templates/base-project/.github/workflows/install-windows.yml +27 -0
- package/templates/base-project/.github/workflows/release.yml +67 -0
- package/templates/base-project/.railway/railway.ts +40 -0
- package/templates/base-project/CLAUDE.md +163 -0
- package/templates/base-project/MAIN.md +21 -0
- package/templates/base-project/README.md +164 -0
- package/templates/base-project/apps/web/.env.example +99 -0
- package/templates/base-project/apps/web/Dockerfile +38 -0
- package/templates/base-project/apps/web/biome.json +8 -0
- package/templates/base-project/apps/web/jsconfig.json +9 -0
- package/templates/base-project/apps/web/next.config.js +154 -0
- package/templates/base-project/apps/web/package.json +42 -0
- package/templates/base-project/apps/web/postcss.config.mjs +7 -0
- package/templates/base-project/apps/web/public/.well-known/security.txt +3 -0
- package/templates/base-project/apps/web/public/quark.svg +47 -0
- package/templates/base-project/apps/web/scripts/prepare-standalone.mjs +71 -0
- package/templates/base-project/apps/web/src/app/_components/AsciiPanel.js +103 -0
- package/templates/base-project/apps/web/src/app/_components/HealthIndicator.js +85 -0
- package/templates/base-project/apps/web/src/app/_components/HomeThemeToggle.js +63 -0
- package/templates/base-project/apps/web/src/app/_components/QuarkAnimation.js +168 -0
- package/templates/base-project/apps/web/src/app/_components/SyncedPanels.js +212 -0
- package/templates/base-project/apps/web/src/app/_components/UmamiReplayRecorder.js +198 -0
- package/templates/base-project/apps/web/src/app/_components/UmamiWebVitals.js +39 -0
- package/templates/base-project/apps/web/src/app/api/auth/[...nextauth]/route.js +7 -0
- package/templates/base-project/apps/web/src/app/api/auth/apple/route.js +82 -0
- package/templates/base-project/apps/web/src/app/api/auth/google/route.js +59 -0
- package/templates/base-project/apps/web/src/app/api/auth/refresh/route.js +48 -0
- package/templates/base-project/apps/web/src/app/api/auth/register/route.js +63 -0
- package/templates/base-project/apps/web/src/app/api/auth/token/route.js +44 -0
- package/templates/base-project/apps/web/src/app/api/csrf/route.js +40 -0
- package/templates/base-project/apps/web/src/app/api/device/register/route.js +57 -0
- package/templates/base-project/apps/web/src/app/api/error-handler.js +23 -0
- package/templates/base-project/apps/web/src/app/api/files/[id]/route.js +89 -0
- package/templates/base-project/apps/web/src/app/api/files/route.js +120 -0
- package/templates/base-project/apps/web/src/app/api/health/route.js +160 -0
- package/templates/base-project/apps/web/src/app/api/users/[id]/route.js +54 -0
- package/templates/base-project/apps/web/src/app/api/users/me/route.js +67 -0
- package/templates/base-project/apps/web/src/app/api/users/route.js +45 -0
- package/templates/base-project/apps/web/src/app/auth/error/page.js +83 -0
- package/templates/base-project/apps/web/src/app/auth/forgot-password/page.js +58 -0
- package/templates/base-project/apps/web/src/app/auth/provider-loading.js +25 -0
- package/templates/base-project/apps/web/src/app/auth/register/RegisterPageClient.js +340 -0
- package/templates/base-project/apps/web/src/app/auth/register/page.js +17 -0
- package/templates/base-project/apps/web/src/app/auth/signin/SignInPageClient.js +230 -0
- package/templates/base-project/apps/web/src/app/auth/signin/page.js +10 -0
- package/templates/base-project/apps/web/src/app/favicon.ico +0 -0
- package/templates/base-project/apps/web/src/app/global-error.js +58 -0
- package/templates/base-project/apps/web/src/app/globals.css +1166 -0
- package/templates/base-project/apps/web/src/app/icon.svg +47 -0
- package/templates/base-project/apps/web/src/app/layout.js +57 -0
- package/templates/base-project/apps/web/src/app/manifest.js +12 -0
- package/templates/base-project/apps/web/src/app/not-found.js +39 -0
- package/templates/base-project/apps/web/src/app/page.js +262 -0
- package/templates/base-project/apps/web/src/app/robots.js +23 -0
- package/templates/base-project/apps/web/src/app/sitemap.js +14 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-before-send.js +29 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-config.js +95 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-csp.js +24 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-marketing.js +131 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-replay.js +204 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-web-vitals.js +237 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami.js +57 -0
- package/templates/base-project/apps/web/src/lib/auth-middleware.js +82 -0
- package/templates/base-project/apps/web/src/lib/auth-signup.js +5 -0
- package/templates/base-project/apps/web/src/lib/auth.js +191 -0
- package/templates/base-project/apps/web/src/lib/csrf-cookie.js +11 -0
- package/templates/base-project/apps/web/src/lib/jwt.js +84 -0
- package/templates/base-project/apps/web/src/lib/load-crm-config.js +35 -0
- package/templates/base-project/apps/web/src/lib/normalize-auth-request.js +33 -0
- package/templates/base-project/apps/web/src/lib/proxy-auth.js +80 -0
- package/templates/base-project/apps/web/src/lib/seo/indexing.js +25 -0
- package/templates/base-project/apps/web/src/lib/seo/site-metadata.js +74 -0
- package/templates/base-project/apps/web/src/lib/sitemap-entries.js +22 -0
- package/templates/base-project/apps/web/src/preflight.js +55 -0
- package/templates/base-project/apps/web/src/proxy.js +328 -0
- package/templates/base-project/biome.json +52 -0
- package/templates/base-project/docker-compose.override.yml +17 -0
- package/templates/base-project/docker-compose.yml +37 -0
- package/templates/base-project/docs/FIRST_FEATURE.md +74 -0
- package/templates/base-project/docs/START_HERE.md +70 -0
- package/templates/base-project/docs/adr/README.md +42 -0
- package/templates/base-project/package.json +58 -0
- package/templates/base-project/packages/db/package.json +38 -0
- package/templates/base-project/packages/db/prisma/migrations/20260202061128_initial/migration.sql +202 -0
- package/templates/base-project/packages/db/prisma/migrations/migration_lock.toml +3 -0
- package/templates/base-project/packages/db/prisma/schema.prisma +161 -0
- package/templates/base-project/packages/db/prisma/seed.js +152 -0
- package/templates/base-project/packages/db/prisma.config.js +27 -0
- package/templates/base-project/packages/db/src/client.js +75 -0
- package/templates/base-project/packages/db/src/connection.js +44 -0
- package/templates/base-project/packages/db/src/context.js +275 -0
- package/templates/base-project/packages/db/src/index.js +5 -0
- package/templates/base-project/packages/db/src/ping.js +66 -0
- package/templates/base-project/packages/db/src/queries.js +292 -0
- package/templates/base-project/packages/db/src/schemas.js +30 -0
- package/templates/base-project/packages/db/src/umami.js +299 -0
- package/templates/base-project/pnpm-workspace.yaml +20 -0
- package/templates/base-project/scripts/check-loading.mjs +49 -0
- package/templates/base-project/scripts/check-standards.mjs +186 -0
- package/templates/base-project/scripts/clean-workspace.mjs +479 -0
- package/templates/base-project/scripts/dev-preflight.mjs +48 -0
- package/templates/base-project/scripts/doctor.js +408 -0
- package/templates/base-project/scripts/prepare.js +490 -0
- package/templates/base-project/scripts/run-tests.mjs +71 -0
- package/templates/base-project/skills/add-dashboard/SKILL.md +28 -0
- package/templates/base-project/skills/add-endpoint/SKILL.md +29 -0
- package/templates/base-project/skills/add-model/SKILL.md +28 -0
- package/templates/base-project/skills/ecommerce/SKILL.md +224 -0
- package/templates/base-project/skills/ecommerce-cart/SKILL.md +193 -0
- package/templates/base-project/skills/ecommerce-catalog/SKILL.md +179 -0
- package/templates/base-project/skills/ecommerce-checkout/SKILL.md +195 -0
- package/templates/base-project/skills/i18n/SKILL.md +211 -0
- package/templates/base-project/skills/payment/SKILL.md +280 -0
- package/templates/base-project/skills/quark-skills/SKILL.md +37 -0
- package/templates/base-project/turbo.json +143 -0
- package/templates/config/package.json +20 -0
- package/templates/config/src/app-url.js +134 -0
- package/templates/config/src/environment.js +284 -0
- package/templates/config/src/index.js +16 -0
- package/templates/config/src/load-config.js +157 -0
- package/templates/config/src/rate-limit.js +136 -0
- package/templates/config/src/redis.js +43 -0
- package/templates/config/src/validate-env.js +532 -0
- package/templates/jobs/README.md +52 -0
- package/templates/jobs/package.json +9 -0
- package/templates/jobs/src/definitions.js +13 -0
- package/templates/jobs/src/index.js +1 -0
- package/templates/mobile/.env.example +16 -0
- package/templates/mobile/CHECKLIST.md +40 -0
- package/templates/mobile/README.md +130 -0
- package/templates/mobile/app/(app)/_layout.tsx +83 -0
- package/templates/mobile/app/(app)/index.tsx +147 -0
- package/templates/mobile/app/(app)/profile.tsx +204 -0
- package/templates/mobile/app/(app)/settings.tsx +80 -0
- package/templates/mobile/app/(auth)/_layout.tsx +10 -0
- package/templates/mobile/app/(auth)/sign-in.tsx +140 -0
- package/templates/mobile/app/(auth)/sign-up.tsx +178 -0
- package/templates/mobile/app/+not-found.tsx +32 -0
- package/templates/mobile/app/_layout.tsx +18 -0
- package/templates/mobile/app.json +47 -0
- package/templates/mobile/assets/adaptive-icon.png +0 -0
- package/templates/mobile/assets/icon.png +0 -0
- package/templates/mobile/assets/notification-icon.png +0 -0
- package/templates/mobile/assets/splash.png +0 -0
- package/templates/mobile/biome.json +27 -0
- package/templates/mobile/components/AuthGate.tsx +19 -0
- package/templates/mobile/components/ThemeProvider.tsx +23 -0
- package/templates/mobile/eas.json +29 -0
- package/templates/mobile/hooks/use-auth.ts +127 -0
- package/templates/mobile/lib/api-client.ts +138 -0
- package/templates/mobile/lib/api.ts +83 -0
- package/templates/mobile/lib/auth-providers-google.ts +74 -0
- package/templates/mobile/lib/auth-providers.ts +74 -0
- package/templates/mobile/lib/auth.ts +100 -0
- package/templates/mobile/lib/config.ts +14 -0
- package/templates/mobile/lib/errors.ts +46 -0
- package/templates/mobile/lib/notifications.ts +77 -0
- package/templates/mobile/lib/storage.ts +25 -0
- package/templates/mobile/lib/theme-tokens.ts +16 -0
- package/templates/mobile/package.json +37 -0
- package/templates/mobile/tsconfig.json +10 -0
- package/templates/pwa/app/_components/PWARegister.js +15 -0
- package/templates/pwa/app/manifest.js +32 -0
- package/templates/pwa/public/sw.js +72 -0
- package/templates/ui/README.md +44 -0
- package/templates/ui/package.json +21 -0
- package/templates/ui/src/badge.js +23 -0
- package/templates/ui/src/button.js +111 -0
- package/templates/ui/src/card.js +128 -0
- package/templates/ui/src/checkbox.js +20 -0
- package/templates/ui/src/container.js +13 -0
- package/templates/ui/src/dialog.js +137 -0
- package/templates/ui/src/error-banner.js +15 -0
- package/templates/ui/src/footer.js +239 -0
- package/templates/ui/src/form-field.integration-test.js +142 -0
- package/templates/ui/src/form-field.js +50 -0
- package/templates/ui/src/index.js +24 -0
- package/templates/ui/src/input.js +11 -0
- package/templates/ui/src/label.js +14 -0
- package/templates/ui/src/lightbox.integration-test.js +167 -0
- package/templates/ui/src/lightbox.js +243 -0
- package/templates/ui/src/logo.js +52 -0
- package/templates/ui/src/navbar.js +602 -0
- package/templates/ui/src/password-input.js +31 -0
- package/templates/ui/src/rich-text.js +514 -0
- package/templates/ui/src/select.js +317 -0
- package/templates/ui/src/skeleton.js +10 -0
- package/templates/ui/src/spinner.js +31 -0
- package/templates/ui/src/table.js +169 -0
- package/templates/ui/src/textarea.js +11 -0
- package/templates/ui/src/theme-constants.js +24 -0
- package/templates/ui/src/theme.js +196 -0
- package/templates/ui/src/toast.js +206 -0
- package/templates/worker/Dockerfile +64 -0
- package/templates/worker/README.md +56 -0
- package/templates/worker/package.json +36 -0
- package/templates/worker/src/config/pricing.js +46 -0
- package/templates/worker/src/handlers/email-job-validation.js +36 -0
- package/templates/worker/src/handlers/email.js +98 -0
- package/templates/worker/src/handlers/files.js +59 -0
- package/templates/worker/src/handlers/index.js +20 -0
- package/templates/worker/src/handlers/push-notification.js +234 -0
- package/templates/worker/src/index.js +505 -0
- package/templates/worker/src/lib/railway.js +438 -0
- package/templates/worker/src/lib/tokens.js +81 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { validateBody } from "@techstream/quark-core";
|
|
2
|
+
import { user } from "@techstream/quark-db";
|
|
3
|
+
import { NextResponse } from "next/server";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { issueTokenPair } from "../../../../lib/jwt";
|
|
6
|
+
import { handleError } from "../../error-handler";
|
|
7
|
+
|
|
8
|
+
const googleAuthSchema = z.object({
|
|
9
|
+
idToken: z.string().min(1),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const GOOGLE_TOKENINFO_URL = "https://oauth2.googleapis.com/tokeninfo";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* POST /api/auth/google
|
|
16
|
+
* Verify Google ID token and issue a custom JWT.
|
|
17
|
+
*/
|
|
18
|
+
export async function POST(request) {
|
|
19
|
+
try {
|
|
20
|
+
const { idToken } = await validateBody(request, googleAuthSchema);
|
|
21
|
+
|
|
22
|
+
// Verify the token with Google's tokeninfo endpoint
|
|
23
|
+
const tokenInfoResponse = await fetch(
|
|
24
|
+
`${GOOGLE_TOKENINFO_URL}?id_token=${idToken}`,
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
if (!tokenInfoResponse.ok) {
|
|
28
|
+
return NextResponse.json(
|
|
29
|
+
{ message: "Invalid Google token" },
|
|
30
|
+
{ status: 401 },
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const tokenInfo = await tokenInfoResponse.json();
|
|
35
|
+
|
|
36
|
+
const googleEmail = tokenInfo.email;
|
|
37
|
+
if (!googleEmail) {
|
|
38
|
+
return NextResponse.json(
|
|
39
|
+
{ message: "Google token does not contain an email" },
|
|
40
|
+
{ status: 400 },
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Find or create user
|
|
45
|
+
let existingUser = await user.findByEmail(googleEmail);
|
|
46
|
+
if (!existingUser) {
|
|
47
|
+
existingUser = await user.create({
|
|
48
|
+
email: googleEmail,
|
|
49
|
+
name: tokenInfo.name || null,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const { token, refreshToken, expiresAt } =
|
|
54
|
+
await issueTokenPair(existingUser);
|
|
55
|
+
return NextResponse.json({ token, refreshToken, expiresAt });
|
|
56
|
+
} catch (error) {
|
|
57
|
+
return handleError(error);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createLogger, validateBody } from "@techstream/quark-core";
|
|
2
|
+
import { NextResponse } from "next/server";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { issueTokenPair, verifyMobileToken } from "../../../../lib/jwt";
|
|
5
|
+
import { handleError } from "../../error-handler";
|
|
6
|
+
|
|
7
|
+
const logger = createLogger("auth-refresh");
|
|
8
|
+
|
|
9
|
+
const refreshRequestSchema = z.object({
|
|
10
|
+
refreshToken: z.string().min(1),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* POST /api/auth/refresh
|
|
15
|
+
* Exchange a refresh token for a new access token.
|
|
16
|
+
*/
|
|
17
|
+
export async function POST(request) {
|
|
18
|
+
try {
|
|
19
|
+
const { refreshToken } = await validateBody(request, refreshRequestSchema);
|
|
20
|
+
|
|
21
|
+
const payload = await verifyMobileToken(refreshToken);
|
|
22
|
+
if (!payload) {
|
|
23
|
+
logger.warn("Invalid refresh token");
|
|
24
|
+
return NextResponse.json(
|
|
25
|
+
{ message: "Invalid or expired refresh token" },
|
|
26
|
+
{ status: 401 },
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (payload.type !== "refresh") {
|
|
31
|
+
return NextResponse.json(
|
|
32
|
+
{ message: "Invalid token type" },
|
|
33
|
+
{ status: 401 },
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const { token } = await issueTokenPair({
|
|
38
|
+
id: payload.sub,
|
|
39
|
+
email: payload.email,
|
|
40
|
+
name: payload.name,
|
|
41
|
+
role: payload.role,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return NextResponse.json({ token });
|
|
45
|
+
} catch (error) {
|
|
46
|
+
return handleError(error);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createQueue,
|
|
3
|
+
hashPassword,
|
|
4
|
+
validateBody,
|
|
5
|
+
withCsrfProtection,
|
|
6
|
+
} from "@techstream/quark-core";
|
|
7
|
+
import { user, userRegisterSchema } from "@techstream/quark-db";
|
|
8
|
+
import { JOB_NAMES, JOB_QUEUES } from "@techstream/quark-jobs";
|
|
9
|
+
import { NextResponse } from "next/server";
|
|
10
|
+
import { isSignupEnabled } from "@/lib/auth-signup";
|
|
11
|
+
import { handleError } from "../../error-handler";
|
|
12
|
+
|
|
13
|
+
export const POST = withCsrfProtection(async (request) => {
|
|
14
|
+
if (!isSignupEnabled()) {
|
|
15
|
+
return NextResponse.json(
|
|
16
|
+
{ message: "Registration is disabled." },
|
|
17
|
+
{ status: 403 },
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const data = await validateBody(request, userRegisterSchema);
|
|
23
|
+
|
|
24
|
+
// Check if user exists
|
|
25
|
+
const existing = await user.findByEmail(data.email);
|
|
26
|
+
if (existing) {
|
|
27
|
+
return NextResponse.json(
|
|
28
|
+
{ message: "User with this email already exists" },
|
|
29
|
+
{ status: 409 },
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const hashedPassword = await hashPassword(data.password);
|
|
34
|
+
|
|
35
|
+
// Remove password from data before passing to create (create expects plain data object, but we need to inject hashed password)
|
|
36
|
+
// We need to update the user.create method or pass it manually.
|
|
37
|
+
// The current user.create just takes 'data'.
|
|
38
|
+
|
|
39
|
+
// Prisma create data:
|
|
40
|
+
const newUser = await user.create({
|
|
41
|
+
email: data.email,
|
|
42
|
+
name: data.name,
|
|
43
|
+
password: hashedPassword,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Don't return the password
|
|
47
|
+
const { password: _, ...safeUser } = newUser;
|
|
48
|
+
|
|
49
|
+
// Enqueue welcome email (fire-and-forget - don't block the response)
|
|
50
|
+
try {
|
|
51
|
+
const emailQueue = createQueue(JOB_QUEUES.EMAIL);
|
|
52
|
+
await emailQueue.add(JOB_NAMES.SEND_WELCOME_EMAIL, {
|
|
53
|
+
userId: newUser.id,
|
|
54
|
+
});
|
|
55
|
+
} catch {
|
|
56
|
+
// Non-critical - user is created even if email fails to enqueue
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return NextResponse.json(safeUser, { status: 201 });
|
|
60
|
+
} catch (error) {
|
|
61
|
+
return handleError(error);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { validateBody, verifyPassword } from "@techstream/quark-core";
|
|
2
|
+
import { user } from "@techstream/quark-db";
|
|
3
|
+
import { NextResponse } from "next/server";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { issueTokenPair } from "../../../../lib/jwt";
|
|
6
|
+
import { handleError } from "../../error-handler";
|
|
7
|
+
|
|
8
|
+
const tokenRequestSchema = z.object({
|
|
9
|
+
email: z.string().email(),
|
|
10
|
+
password: z.string().min(1),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* POST /api/auth/token
|
|
15
|
+
* Exchange email + password for JWT tokens.
|
|
16
|
+
* Used by the mobile app for Bearer-token auth.
|
|
17
|
+
*/
|
|
18
|
+
export async function POST(request) {
|
|
19
|
+
try {
|
|
20
|
+
const { email, password } = await validateBody(request, tokenRequestSchema);
|
|
21
|
+
|
|
22
|
+
const existingUser = await user.findByEmail(email);
|
|
23
|
+
if (!existingUser?.password) {
|
|
24
|
+
return NextResponse.json(
|
|
25
|
+
{ message: "Invalid credentials" },
|
|
26
|
+
{ status: 401 },
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const isValid = await verifyPassword(password, existingUser.password);
|
|
31
|
+
if (!isValid) {
|
|
32
|
+
return NextResponse.json(
|
|
33
|
+
{ message: "Invalid credentials" },
|
|
34
|
+
{ status: 401 },
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const { token, refreshToken, expiresAt } =
|
|
39
|
+
await issueTokenPair(existingUser);
|
|
40
|
+
return NextResponse.json({ token, refreshToken, expiresAt });
|
|
41
|
+
} catch (error) {
|
|
42
|
+
return handleError(error);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSRF Token API Endpoint
|
|
3
|
+
* Generates a CSRF token, stores it in a secure HTTP-only cookie,
|
|
4
|
+
* and returns it to the client for inclusion in request headers.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { generateCsrfToken } from "@techstream/quark-core";
|
|
8
|
+
import { NextResponse } from "next/server";
|
|
9
|
+
import { auth } from "@/lib/auth";
|
|
10
|
+
import { getCsrfCookieOptions } from "@/lib/csrf-cookie";
|
|
11
|
+
|
|
12
|
+
export async function GET(request) {
|
|
13
|
+
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
|
+
const csrfToken = generateCsrfToken();
|
|
22
|
+
|
|
23
|
+
const response = NextResponse.json({ csrfToken });
|
|
24
|
+
|
|
25
|
+
// Store the token in a secure HTTP-only cookie so the server can
|
|
26
|
+
// validate it on subsequent state-changing requests.
|
|
27
|
+
response.cookies.set(
|
|
28
|
+
"csrf_token",
|
|
29
|
+
csrfToken,
|
|
30
|
+
getCsrfCookieOptions(request),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
return response;
|
|
34
|
+
} catch (_error) {
|
|
35
|
+
return NextResponse.json(
|
|
36
|
+
{ error: "Failed to generate CSRF token" },
|
|
37
|
+
{ status: 500 },
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { validateBody } from "@techstream/quark-core";
|
|
2
|
+
import { prisma } from "@techstream/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
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { AppError, createLogger } from "@techstream/quark-core";
|
|
2
|
+
import { NextResponse } from "next/server";
|
|
3
|
+
|
|
4
|
+
const logger = createLogger("api");
|
|
5
|
+
|
|
6
|
+
export function handleError(error) {
|
|
7
|
+
logger.error("API Error", { error: error.message, stack: error.stack });
|
|
8
|
+
|
|
9
|
+
if (error instanceof AppError) {
|
|
10
|
+
return NextResponse.json(error.toJSON(), { status: error.statusCode });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Fallback for unhandled errors
|
|
14
|
+
return NextResponse.json(
|
|
15
|
+
{
|
|
16
|
+
name: "InternalServerError",
|
|
17
|
+
message: "An unexpected error occurred",
|
|
18
|
+
statusCode: 500,
|
|
19
|
+
code: "INTERNAL_ERROR",
|
|
20
|
+
},
|
|
21
|
+
{ status: 500 },
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single File API
|
|
3
|
+
* GET /api/files/[id] - Download / serve a file
|
|
4
|
+
* DELETE /api/files/[id] - Delete a file (owner or admin only)
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
createStorage,
|
|
9
|
+
ForbiddenError,
|
|
10
|
+
NotFoundError,
|
|
11
|
+
withCsrfProtection,
|
|
12
|
+
} from "@techstream/quark-core";
|
|
13
|
+
import { file } from "@techstream/quark-db";
|
|
14
|
+
import { NextResponse } from "next/server";
|
|
15
|
+
import { requireAuth } from "@/lib/auth-middleware";
|
|
16
|
+
import { handleError } from "../../error-handler";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* GET /api/files/[id]
|
|
20
|
+
* Serve / download a file by its database ID.
|
|
21
|
+
* Public endpoint (no auth required) - access control is by knowledge of ID.
|
|
22
|
+
*/
|
|
23
|
+
export async function GET(_request, { params }) {
|
|
24
|
+
try {
|
|
25
|
+
const { id } = await params;
|
|
26
|
+
|
|
27
|
+
const record = await file.findById(id);
|
|
28
|
+
if (!record) {
|
|
29
|
+
throw new NotFoundError("File not found");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const storage = createStorage();
|
|
33
|
+
const { body, contentType } = await storage.get(record.storageKey);
|
|
34
|
+
|
|
35
|
+
const headers = new Headers();
|
|
36
|
+
headers.set("Content-Type", contentType || record.mimeType);
|
|
37
|
+
headers.set("Content-Length", String(body.length));
|
|
38
|
+
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
|
39
|
+
|
|
40
|
+
// Inline display for images; attachment download for everything else
|
|
41
|
+
const isImage = record.mimeType.startsWith("image/");
|
|
42
|
+
const safeName = record.originalName.replace(/[\\"\r\n]/g, "_");
|
|
43
|
+
const encodedName = encodeURIComponent(record.originalName);
|
|
44
|
+
headers.set(
|
|
45
|
+
"Content-Disposition",
|
|
46
|
+
isImage
|
|
47
|
+
? `inline; filename="${safeName}"; filename*=UTF-8''${encodedName}`
|
|
48
|
+
: `attachment; filename="${safeName}"; filename*=UTF-8''${encodedName}`,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
return new Response(body, { status: 200, headers });
|
|
52
|
+
} catch (error) {
|
|
53
|
+
return handleError(error);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* DELETE /api/files/[id]
|
|
59
|
+
* Delete a file. Only the uploader or an admin can delete.
|
|
60
|
+
*/
|
|
61
|
+
export const DELETE = withCsrfProtection(async (_request, { params }) => {
|
|
62
|
+
try {
|
|
63
|
+
const session = await requireAuth();
|
|
64
|
+
const { id } = await params;
|
|
65
|
+
|
|
66
|
+
const record = await file.findById(id);
|
|
67
|
+
if (!record) {
|
|
68
|
+
throw new NotFoundError("File not found");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Authorization: uploader or admin
|
|
72
|
+
const isOwner = record.uploadedById === session.user.id;
|
|
73
|
+
const isAdmin = session.user.role === "admin";
|
|
74
|
+
if (!isOwner && !isAdmin) {
|
|
75
|
+
throw new ForbiddenError("You can only delete your own files");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Remove from storage
|
|
79
|
+
const storage = createStorage();
|
|
80
|
+
await storage.delete(record.storageKey);
|
|
81
|
+
|
|
82
|
+
// Remove database record
|
|
83
|
+
await file.delete(record.id);
|
|
84
|
+
|
|
85
|
+
return NextResponse.json({ message: "File deleted" });
|
|
86
|
+
} catch (error) {
|
|
87
|
+
return handleError(error);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File Upload API
|
|
3
|
+
* POST /api/files - Upload a file (multipart/form-data)
|
|
4
|
+
* GET /api/files - List current user's files
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
createStorage,
|
|
9
|
+
generateStorageKey,
|
|
10
|
+
parseMultipart,
|
|
11
|
+
parsePaginationQuery,
|
|
12
|
+
validateFile,
|
|
13
|
+
withCsrfProtection,
|
|
14
|
+
} from "@techstream/quark-core";
|
|
15
|
+
import { file } from "@techstream/quark-db";
|
|
16
|
+
import { NextResponse } from "next/server";
|
|
17
|
+
import { requireAuth } from "@/lib/auth-middleware";
|
|
18
|
+
import { handleError } from "../error-handler";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* POST /api/files
|
|
22
|
+
* Upload one or more files via multipart/form-data.
|
|
23
|
+
* Requires authentication.
|
|
24
|
+
*/
|
|
25
|
+
export const POST = withCsrfProtection(async (request) => {
|
|
26
|
+
try {
|
|
27
|
+
const session = await requireAuth();
|
|
28
|
+
|
|
29
|
+
// Parse multipart body
|
|
30
|
+
const { files: parsedFiles } = await parseMultipart(request);
|
|
31
|
+
|
|
32
|
+
if (parsedFiles.length === 0) {
|
|
33
|
+
return NextResponse.json(
|
|
34
|
+
{ message: "No files provided" },
|
|
35
|
+
{ status: 400 },
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const storage = createStorage();
|
|
40
|
+
const results = [];
|
|
41
|
+
|
|
42
|
+
for (const parsed of parsedFiles) {
|
|
43
|
+
// Validate each file
|
|
44
|
+
const validation = validateFile({
|
|
45
|
+
filename: parsed.filename,
|
|
46
|
+
mimeType: parsed.mimeType,
|
|
47
|
+
size: parsed.size,
|
|
48
|
+
buffer: parsed.buffer,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (!validation.valid) {
|
|
52
|
+
return NextResponse.json(
|
|
53
|
+
{ message: validation.error, filename: parsed.filename },
|
|
54
|
+
{ status: 422 },
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Generate storage key and upload
|
|
59
|
+
const storageKey = generateStorageKey(parsed.filename);
|
|
60
|
+
|
|
61
|
+
await storage.put(storageKey, parsed.buffer, {
|
|
62
|
+
contentType: parsed.mimeType,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Save metadata to database
|
|
66
|
+
const record = await file.create({
|
|
67
|
+
filename: storageKey.split("/").pop(),
|
|
68
|
+
originalName: parsed.filename,
|
|
69
|
+
mimeType: parsed.mimeType,
|
|
70
|
+
size: parsed.size,
|
|
71
|
+
storageKey,
|
|
72
|
+
storageProvider: storage.provider,
|
|
73
|
+
uploadedById: session.user.id,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
results.push({
|
|
77
|
+
id: record.id,
|
|
78
|
+
originalName: record.originalName,
|
|
79
|
+
mimeType: record.mimeType,
|
|
80
|
+
size: record.size,
|
|
81
|
+
url: `/api/files/${record.id}`,
|
|
82
|
+
createdAt: record.createdAt,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const status = results.length === 1 ? 201 : 200;
|
|
87
|
+
const body = results.length === 1 ? results[0] : { files: results };
|
|
88
|
+
|
|
89
|
+
return NextResponse.json(body, { status });
|
|
90
|
+
} catch (error) {
|
|
91
|
+
return handleError(error);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* GET /api/files
|
|
97
|
+
* List files uploaded by the current user.
|
|
98
|
+
*/
|
|
99
|
+
export async function GET(request) {
|
|
100
|
+
try {
|
|
101
|
+
const session = await requireAuth();
|
|
102
|
+
|
|
103
|
+
const { searchParams } = new URL(request.url);
|
|
104
|
+
const { skip, take } = parsePaginationQuery(searchParams);
|
|
105
|
+
const files = await file.findByUploader(session.user.id, { skip, take });
|
|
106
|
+
|
|
107
|
+
const mapped = files.map((f) => ({
|
|
108
|
+
id: f.id,
|
|
109
|
+
originalName: f.originalName,
|
|
110
|
+
mimeType: f.mimeType,
|
|
111
|
+
size: f.size,
|
|
112
|
+
url: `/api/files/${f.id}`,
|
|
113
|
+
createdAt: f.createdAt,
|
|
114
|
+
}));
|
|
115
|
+
|
|
116
|
+
return NextResponse.json(mapped);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
return handleError(error);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health Check Endpoint
|
|
3
|
+
* Verifies the application and its dependencies are functioning correctly.
|
|
4
|
+
* Times out after 5 seconds to prevent hanging.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
createLogger,
|
|
9
|
+
createStorage,
|
|
10
|
+
getRegisteredQueues,
|
|
11
|
+
pingRedis,
|
|
12
|
+
} from "@techstream/quark-core";
|
|
13
|
+
import { pingDatabase } from "@techstream/quark-db";
|
|
14
|
+
import { NextResponse } from "next/server";
|
|
15
|
+
|
|
16
|
+
const logger = createLogger("health");
|
|
17
|
+
|
|
18
|
+
/** Overall timeout for the health check (ms). */
|
|
19
|
+
const HEALTH_CHECK_TIMEOUT = 5000;
|
|
20
|
+
|
|
21
|
+
export async function GET() {
|
|
22
|
+
try {
|
|
23
|
+
const result = await new Promise((resolve, reject) => {
|
|
24
|
+
const timer = setTimeout(
|
|
25
|
+
() => reject(new Error("Health check timed out")),
|
|
26
|
+
HEALTH_CHECK_TIMEOUT,
|
|
27
|
+
);
|
|
28
|
+
runHealthChecks().then(
|
|
29
|
+
(val) => {
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
resolve(val);
|
|
32
|
+
},
|
|
33
|
+
(err) => {
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
reject(err);
|
|
36
|
+
},
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
return NextResponse.json(result, {
|
|
41
|
+
status: result.status === "error" ? 503 : 200,
|
|
42
|
+
});
|
|
43
|
+
} catch (error) {
|
|
44
|
+
logger.error("Health check failed", {
|
|
45
|
+
error: error.message,
|
|
46
|
+
stack: error.stack,
|
|
47
|
+
});
|
|
48
|
+
return NextResponse.json(
|
|
49
|
+
{
|
|
50
|
+
status: "error",
|
|
51
|
+
timestamp: new Date().toISOString(),
|
|
52
|
+
message: "Service health check failed",
|
|
53
|
+
},
|
|
54
|
+
{ status: 500 },
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Runs all individual health checks and aggregates the results.
|
|
61
|
+
* @returns {Promise<{ status: string, timestamp: string, checks: Record<string, object> }>}
|
|
62
|
+
*/
|
|
63
|
+
async function runHealthChecks() {
|
|
64
|
+
const health = {
|
|
65
|
+
status: "ok",
|
|
66
|
+
timestamp: new Date().toISOString(),
|
|
67
|
+
checks: {},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Check database connectivity
|
|
71
|
+
const dbResult = await pingDatabase();
|
|
72
|
+
if (dbResult.status === "ok") {
|
|
73
|
+
health.checks.database = { status: "ok", latencyMs: dbResult.latencyMs };
|
|
74
|
+
} else {
|
|
75
|
+
health.checks.database = {
|
|
76
|
+
status: "error",
|
|
77
|
+
message: dbResult.message,
|
|
78
|
+
};
|
|
79
|
+
health.status = "degraded";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Check Redis connectivity (actual PING)
|
|
83
|
+
const redisResult = await pingRedis();
|
|
84
|
+
if (redisResult.status === "ok") {
|
|
85
|
+
health.checks.redis = { status: "ok", latencyMs: redisResult.latencyMs };
|
|
86
|
+
} else {
|
|
87
|
+
health.checks.redis = {
|
|
88
|
+
status: "error",
|
|
89
|
+
message: redisResult.message,
|
|
90
|
+
};
|
|
91
|
+
health.status = "degraded";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Check storage connectivity
|
|
95
|
+
const storageResult = await checkStorage();
|
|
96
|
+
health.checks.storage = storageResult;
|
|
97
|
+
if (storageResult.status === "error") {
|
|
98
|
+
health.status = "degraded";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Check queue depths (only when queues are registered in this process)
|
|
102
|
+
const registeredQueues = getRegisteredQueues();
|
|
103
|
+
if (registeredQueues.size > 0) {
|
|
104
|
+
const entries = [...registeredQueues.entries()];
|
|
105
|
+
const results = await Promise.allSettled(
|
|
106
|
+
entries.map(([, queue]) =>
|
|
107
|
+
Promise.all([
|
|
108
|
+
queue.getWaitingCount(),
|
|
109
|
+
queue.getActiveCount(),
|
|
110
|
+
queue.getFailedCount(),
|
|
111
|
+
]),
|
|
112
|
+
),
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const queueChecks = {};
|
|
116
|
+
for (let i = 0; i < entries.length; i++) {
|
|
117
|
+
const [name] = entries[i];
|
|
118
|
+
const result = results[i];
|
|
119
|
+
if (result.status === "fulfilled") {
|
|
120
|
+
const [waiting, active, failed] = result.value;
|
|
121
|
+
queueChecks[name] = { status: "ok", waiting, active, failed };
|
|
122
|
+
} else {
|
|
123
|
+
const message =
|
|
124
|
+
process.env.NODE_ENV === "production"
|
|
125
|
+
? "Queue unavailable"
|
|
126
|
+
: result.reason?.message;
|
|
127
|
+
queueChecks[name] = { status: "error", message };
|
|
128
|
+
health.status = "degraded";
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
health.checks.queues = queueChecks;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return health;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Verifies storage is reachable and writable by writing then deleting a
|
|
139
|
+
* small sentinel object. Uses whichever provider is configured via
|
|
140
|
+
* STORAGE_PROVIDER (defaults to "local" when unset).
|
|
141
|
+
* @returns {Promise<{ status: string, provider: string, message?: string }>}
|
|
142
|
+
*/
|
|
143
|
+
async function checkStorage() {
|
|
144
|
+
const provider = process.env.STORAGE_PROVIDER || "local";
|
|
145
|
+
try {
|
|
146
|
+
const storage = createStorage();
|
|
147
|
+
const sentinelKey = ".health-check-sentinel";
|
|
148
|
+
await storage.put(sentinelKey, Buffer.from("ok"), {
|
|
149
|
+
contentType: "text/plain",
|
|
150
|
+
});
|
|
151
|
+
await storage.delete(sentinelKey);
|
|
152
|
+
return { status: "ok", provider };
|
|
153
|
+
} catch (error) {
|
|
154
|
+
const message =
|
|
155
|
+
process.env.NODE_ENV === "production"
|
|
156
|
+
? "Storage unavailable"
|
|
157
|
+
: error.message;
|
|
158
|
+
return { status: "error", provider, message };
|
|
159
|
+
}
|
|
160
|
+
}
|