Compare commits

..

1 Commits

Author SHA1 Message Date
e5f9da6c26 feat(ui): add sortable column headers to domains table 2026-03-06 06:58:29 +00:00
16 changed files with 261 additions and 463 deletions

View File

@@ -1,13 +0,0 @@
node_modules
.next
.git
*.log
logs
.env
.env.*
!.env.example
.env.local
.env.*.local
.vscode
.idea
.DS_Store

View File

@@ -1,22 +1,14 @@
NODE_ENV=production
NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_URL=
SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
NEXT_PUBLIC_APP_URL=http://localhost:3000
JOB_SECRET=
CRON_SECRET=
ENCRYPTION_KEY=
PLESK_CRED_ENC_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
CRON_SHARED_SECRET=
# Existing runtime keys used by current codebase
JOB_SECRET=
CRON_SECRET=
ENCRYPTION_KEY=
STRIPE_PRICE_ID=

View File

@@ -1,32 +0,0 @@
FROM cgr.dev/chainguard/node:20-dev AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM cgr.dev/chainguard/node:20-dev AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM cgr.dev/chainguard/node:20-dev AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM cgr.dev/chainguard/node:20 AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/next.config.mjs ./next.config.mjs
COPY --from=builder /app/.next ./.next
EXPOSE 3000
CMD ["node", "node_modules/next/dist/bin/next", "start", "-H", "0.0.0.0", "-p", "3000"]

85
Jenkinsfile vendored
View File

@@ -1,85 +0,0 @@
pipeline {
agent any
environment {
REGISTRY_IMAGE = "plesk-agency-portal"
IMAGE_LATEST = "${REGISTRY_IMAGE}:latest"
IMAGE_BUILD = "${REGISTRY_IMAGE}:build-${BUILD_NUMBER}"
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Install dependencies') {
steps {
sh 'npm ci'
}
}
stage('Lint') {
steps {
script {
def hasLint = sh(script: "node -e \"const fs=require('fs');const p=require('./package.json');process.exit(p?.scripts?.lint?0:1)\"", returnStatus: true)
if (hasLint == 0) {
sh 'npm run lint'
} else {
echo 'No lint script configured; skipping lint stage.'
}
}
}
}
stage('Build application') {
steps {
sh 'npm run build'
}
}
stage('Build Docker image') {
steps {
sh 'docker build -t ${IMAGE_LATEST} -t ${IMAGE_BUILD} .'
}
}
stage('Deploy') {
steps {
script {
if (!env.DEPLOY_SSH_CREDENTIAL_ID) {
error 'DEPLOY_SSH_CREDENTIAL_ID is required'
}
}
sshagent(credentials: ["${env.DEPLOY_SSH_CREDENTIAL_ID}"]) {
sh '''
set -euo pipefail
: "${DEPLOY_SSH_HOST:?Missing DEPLOY_SSH_HOST}"
: "${DEPLOY_SSH_USER:?Missing DEPLOY_SSH_USER}"
: "${DEPLOY_PATH:?Missing DEPLOY_PATH}"
rsync -az \
docker-compose.prod.yml \
scripts/deploy-prod.sh \
scripts/smoke-check.sh \
scripts/rollback-prod.sh \
${DEPLOY_SSH_USER}@${DEPLOY_SSH_HOST}:${DEPLOY_PATH}/
docker save ${IMAGE_LATEST} ${IMAGE_BUILD} \
| ssh ${DEPLOY_SSH_USER}@${DEPLOY_SSH_HOST} 'docker load'
ssh ${DEPLOY_SSH_USER}@${DEPLOY_SSH_HOST} \
"chmod +x ${DEPLOY_PATH}/deploy-prod.sh ${DEPLOY_PATH}/smoke-check.sh ${DEPLOY_PATH}/rollback-prod.sh && APP_IMAGE='${IMAGE_BUILD}' DEPLOY_PATH='${DEPLOY_PATH}' ${DEPLOY_PATH}/deploy-prod.sh"
'''
}
}
}
stage('Smoke check') {
steps {
sh 'chmod +x scripts/smoke-check.sh && scripts/smoke-check.sh ${APP_BASE_URL}'
}
}
}
}

View File

@@ -9,7 +9,7 @@ export async function POST(req: Request) {
try {
const secret = req.headers.get("x-cron-secret");
if (!env.cronSharedSecret || !secret || secret !== env.cronSharedSecret) {
if (!env.cronSecret || !secret || secret !== env.cronSecret) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

View File

@@ -9,7 +9,7 @@ export async function POST(req: Request) {
try {
const secret = req.headers.get("x-cron-secret");
if (!env.cronSharedSecret || !secret || secret !== env.cronSharedSecret) {
if (!env.cronSecret || !secret || secret !== env.cronSecret) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

View File

@@ -1,13 +0,0 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json(
{
ok: true,
service: "plesk-agency-portal",
timestamp: new Date().toISOString(),
environment: process.env.NODE_ENV ?? "development",
},
{ status: 200 },
);
}

View File

@@ -2,18 +2,12 @@ import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { env } from "@/lib/env";
import type { Database } from "@/lib/types";
export async function GET(request: Request) {
const requestUrl = new URL(request.url);
const code = requestUrl.searchParams.get("code");
const nextParam = requestUrl.searchParams.get("next");
const next =
nextParam && nextParam.startsWith("/") && !nextParam.startsWith("//")
? nextParam
: "/dashboard";
const redirectOrigin = env.appOrigin || requestUrl.origin;
const next = requestUrl.searchParams.get("next") ?? "/dashboard";
if (code) {
const supabase = createRouteHandlerClient<Database>({ cookies });
@@ -21,16 +15,18 @@ export async function GET(request: Request) {
if (error) {
console.error(error);
return NextResponse.redirect(
new URL(`/login?authError=1`, redirectOrigin),
new URL(`/login?authError=1`, requestUrl.origin),
);
}
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.redirect(new URL(`/login?noUser=1`, redirectOrigin));
return NextResponse.redirect(
new URL(`/login?noUser=1`, requestUrl.origin),
);
}
}
return NextResponse.redirect(new URL(next, redirectOrigin));
return NextResponse.redirect(new URL(next, requestUrl.origin));
}

View File

@@ -55,6 +55,15 @@ type Domain = {
updated_at: string;
};
type DomainSortKey =
| "domain"
| "status"
| "hosting"
| "created"
| "aliases"
| "last_seen"
| "actions";
type Props = {
instances: Instance[];
subscriptions: Subscription[];
@@ -198,6 +207,10 @@ export function DashboardControls({
);
const [domainSearch, setDomainSearch] = useState("");
const [domainStatus, setDomainStatus] = useState("all");
const [domainSort, setDomainSort] = useState<{
key: DomainSortKey;
direction: "asc" | "desc";
}>({ key: "domain", direction: "asc" });
const [message, setMessage] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [isConnectPanelOpen, setIsConnectPanelOpen] = useState(!hasInstances);
@@ -295,6 +308,112 @@ export function DashboardControls({
return statusMatches && searchMatches;
});
const sortedDomains = filteredDomains
.map((domain, index) => ({ domain, index }))
.sort((a, b) => {
function compareNullable<T>(
left: T | null | undefined,
right: T | null | undefined,
compare: (leftValue: T, rightValue: T) => number,
) {
const leftMissing = left == null;
const rightMissing = right == null;
if (leftMissing && rightMissing) return 0;
if (leftMissing) return 1;
if (rightMissing) return -1;
return compare(left, right);
}
function compareStrings(
left: string | null | undefined,
right: string | null | undefined,
) {
return compareNullable(left, right, (l, r) =>
l.localeCompare(r, undefined, { sensitivity: "base" }),
);
}
function compareNumbers(
left: number | null | undefined,
right: number | null | undefined,
) {
return compareNullable(left, right, (l, r) => l - r);
}
function toTime(value: string | null | undefined) {
if (!value) return null;
const time = new Date(value).getTime();
return Number.isNaN(time) ? null : time;
}
const leftDomain = a.domain;
const rightDomain = b.domain;
let comparison = 0;
if (domainSort.key === "domain") {
comparison = compareStrings(
leftDomain.domain_name,
rightDomain.domain_name,
);
} else if (domainSort.key === "status") {
comparison = compareStrings(
formatDomainStatusLabel(leftDomain.status),
formatDomainStatusLabel(rightDomain.status),
);
} else if (domainSort.key === "hosting") {
comparison = compareStrings(
leftDomain.hosting_type,
rightDomain.hosting_type,
);
} else if (domainSort.key === "created") {
comparison = compareNumbers(
toTime(leftDomain.source_created_at),
toTime(rightDomain.source_created_at),
);
} else if (domainSort.key === "aliases") {
comparison = compareNumbers(
leftDomain.aliases_count,
rightDomain.aliases_count,
);
} else if (domainSort.key === "last_seen") {
comparison = compareNumbers(
toTime(leftDomain.updated_at),
toTime(rightDomain.updated_at),
);
} else if (domainSort.key === "actions") {
comparison = compareNumbers(
leftDomain.subscription_id ? 1 : 0,
rightDomain.subscription_id ? 1 : 0,
);
}
if (comparison === 0) {
comparison = a.index - b.index;
}
return domainSort.direction === "asc" ? comparison : comparison * -1;
})
.map((entry) => entry.domain);
function toggleDomainSort(key: DomainSortKey) {
setDomainSort((current) => {
if (current.key === key) {
return {
key,
direction: current.direction === "asc" ? "desc" : "asc",
};
}
return { key, direction: "asc" };
});
}
function getSortIndicator(key: DomainSortKey) {
if (domainSort.key !== key) return "↕";
return domainSort.direction === "asc" ? "↑" : "↓";
}
const instanceHealthSummary = instances.reduce(
(acc, instance) => {
const status = (instance.health_status ?? "unknown").toLowerCase();
@@ -1094,24 +1213,143 @@ export function DashboardControls({
<table className="min-w-full text-left text-sm">
<thead>
<tr className="border-b border-slate-200 text-xs uppercase text-slate-500">
<th className="px-2 py-2">Domain</th>
<th className="px-2 py-2">Subscription Status</th>
<th className="px-2 py-2">Hosting</th>
<th className="px-2 py-2">Created</th>
<th className="px-2 py-2">Aliases</th>
<th className="px-2 py-2">Last seen</th>
<th className="px-2 py-2">Actions</th>
<th className="px-2 py-2">
<button
type="button"
onClick={() => toggleDomainSort("domain")}
className="inline-flex items-center gap-1"
>
Domain
<span
className={
domainSort.key === "domain"
? "text-brand-700"
: "text-slate-400"
}
>
{getSortIndicator("domain")}
</span>
</button>
</th>
<th className="px-2 py-2">
<button
type="button"
onClick={() => toggleDomainSort("status")}
className="inline-flex items-center gap-1"
>
Subscription Status
<span
className={
domainSort.key === "status"
? "text-brand-700"
: "text-slate-400"
}
>
{getSortIndicator("status")}
</span>
</button>
</th>
<th className="px-2 py-2">
<button
type="button"
onClick={() => toggleDomainSort("hosting")}
className="inline-flex items-center gap-1"
>
Hosting
<span
className={
domainSort.key === "hosting"
? "text-brand-700"
: "text-slate-400"
}
>
{getSortIndicator("hosting")}
</span>
</button>
</th>
<th className="px-2 py-2">
<button
type="button"
onClick={() => toggleDomainSort("created")}
className="inline-flex items-center gap-1"
>
Created
<span
className={
domainSort.key === "created"
? "text-brand-700"
: "text-slate-400"
}
>
{getSortIndicator("created")}
</span>
</button>
</th>
<th className="px-2 py-2">
<button
type="button"
onClick={() => toggleDomainSort("aliases")}
className="inline-flex items-center gap-1"
>
Aliases
<span
className={
domainSort.key === "aliases"
? "text-brand-700"
: "text-slate-400"
}
>
{getSortIndicator("aliases")}
</span>
</button>
</th>
<th className="px-2 py-2">
<button
type="button"
onClick={() => toggleDomainSort("last_seen")}
className="inline-flex items-center gap-1"
>
Last seen
<span
className={
domainSort.key === "last_seen"
? "text-brand-700"
: "text-slate-400"
}
>
{getSortIndicator("last_seen")}
</span>
</button>
</th>
<th className="px-2 py-2">
<button
type="button"
onClick={() => toggleDomainSort("actions")}
className="inline-flex items-center gap-1"
>
Actions
<span
className={
domainSort.key === "actions"
? "text-brand-700"
: "text-slate-400"
}
>
{getSortIndicator("actions")}
</span>
</button>
</th>
</tr>
</thead>
<tbody>
{filteredDomains.length === 0 ? (
{sortedDomains.length === 0 ? (
<tr>
<td className="px-2 py-3 text-slate-500" colSpan={7}>
No domains found.
</td>
</tr>
) : (
filteredDomains.map((domain) => (
sortedDomains.map((domain) => (
<tr key={domain.id} className="border-b border-slate-100">
<td className="px-2 py-2 font-medium text-slate-900">
<a

View File

@@ -1,24 +0,0 @@
services:
web:
container_name: plesk-agency-portal
image: ${APP_IMAGE:-plesk-agency-portal:latest}
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
env_file:
- .env
ports:
- "3000:3000"
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:3000/api/health').then((r)=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))",
]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s

View File

@@ -1,149 +0,0 @@
# Production Deployment Runtime Foundation
This project can be deployed in a dedicated Proxmox container using Docker Compose. The files added here establish a minimal production runtime baseline without changing application business logic or Supabase security boundaries.
## Dockerfile purpose
The `Dockerfile` uses a multi-stage build on Node 20:
- installs dependencies
- runs `next build`
- prepares a lean runtime image with production dependencies
- runs the app with `next start` on port `3000`
This keeps the runtime image small and focused on serving the built Next.js app.
## docker-compose.prod.yml purpose
`docker-compose.prod.yml` defines one service:
- service name: `web`
- container name: `plesk-agency-portal`
- restart policy: `unless-stopped`
- environment from `.env`
- port mapping `3000:3000`
- health check against `/api/health`
## External Supabase requirement
Supabase remains external to this application container. The app container must only connect to Supabase via environment variables. No local Supabase container is included in this production compose file.
## Health endpoint usage
`GET /api/health` provides a fast, dependency-light runtime check for container orchestration and uptime checks.
Example:
```bash
curl -s http://localhost:3000/api/health
```
Expected response shape:
- `ok: true`
- `service: "plesk-agency-portal"`
- `timestamp`
- `environment`
## Production environment expectations
Before deploying, set production-safe values in `.env` (using `.env.example` as the template), including:
- app URL and `NODE_ENV`
- Supabase public and server credentials
- Plesk credential encryption key
- Stripe secrets
- cron/job shared secrets
Keep secrets out of source control.
## Reverse proxy assumptions (Nginx Proxy Manager)
This app is expected to run behind a reverse proxy in production.
- TLS is terminated at the proxy.
- Proxy forwards standard host/protocol headers (`Host`, `X-Forwarded-Proto`, `X-Forwarded-Host`).
- Public origin is configured explicitly with `NEXT_PUBLIC_APP_URL`.
To keep callback behavior stable behind proxy layers, auth redirect responses use the configured application origin from environment configuration rather than relying only on request host detection.
## Callback URL expectations
- `NEXT_PUBLIC_APP_URL` must be set to the public HTTPS URL used by users.
- Supabase auth redirect/callback configuration must allow:
- `<NEXT_PUBLIC_APP_URL>/auth/callback`
The callback route also validates the `next` parameter as an internal path to prevent unsafe external redirects.
## Cron protection expectations
Operational cron endpoints are protected with a shared secret.
- Required header: `x-cron-secret`
- Server secret source: `CRON_SHARED_SECRET`
- Backward compatibility fallback: `CRON_SECRET`
For new production setups, set `CRON_SHARED_SECRET` and use that value for all cron callers.
## Jenkins pipeline flow
The repository includes a declarative `Jenkinsfile` with the following stages:
1. **Checkout**
2. **Install dependencies** (`npm ci`)
3. **Lint** (runs only when `package.json` contains a `lint` script)
4. **Build application** (`npm run build`)
5. **Build Docker image** (tags `latest` and `build-${BUILD_NUMBER}`)
6. **Deploy** over SSH to the app host
7. **Smoke check** against `/api/health`
## Required Jenkins configuration
Configure these values in Jenkins job/folder/global environment or credentials-backed environment variables:
- `DEPLOY_SSH_HOST` - remote app host
- `DEPLOY_SSH_USER` - SSH user on remote host
- `DEPLOY_SSH_CREDENTIAL_ID` - Jenkins SSH Agent credential ID
- `DEPLOY_PATH` - remote deployment path (for example `/opt/plesk-agency-portal`)
- `APP_BASE_URL` - externally reachable app URL used by smoke check
The pipeline assumes Jenkins credentials are managed outside this repository.
## Deploy flow
Deployment is intentionally simple and bash-based:
1. Jenkins builds Docker image tags locally.
2. Jenkins syncs deployment files (`docker-compose.prod.yml`, deploy/smoke/rollback scripts) to the remote deploy path.
3. Jenkins streams Docker images to the remote host (`docker save | ssh docker load`).
4. Jenkins runs `scripts/deploy-prod.sh` remotely.
5. Script applies the selected image tag via:
```bash
APP_IMAGE=<tag> docker compose -f docker-compose.prod.yml up -d --no-build
```
6. Script prints `docker compose ps` status.
## Rollback flow
Rollback uses `scripts/rollback-prod.sh` with a previously built image tag:
```bash
DEPLOY_PATH=/opt/plesk-agency-portal \
APP_BASE_URL=https://your-app.example.com \
scripts/rollback-prod.sh plesk-agency-portal:build-123
```
Rollback redeploys that image with compose, then immediately reruns smoke checks.
## Smoke check behavior
`scripts/smoke-check.sh`:
- accepts a base URL argument
- requests `${BASE_URL}/api/health`
- fails on non-200 responses
- fails on invalid JSON or missing expected fields (`ok: true`, `service` string)
This gives a quick post-deploy validation gate for both deploy and rollback operations.

View File

@@ -3,18 +3,6 @@ const requiredEnvs = [
"NEXT_PUBLIC_SUPABASE_ANON_KEY",
] as const;
const configuredAppUrl = process.env.NEXT_PUBLIC_APP_URL?.trim();
function getAppOrigin() {
if (!configuredAppUrl) return "http://localhost:3000";
try {
return new URL(configuredAppUrl).origin;
} catch {
return "http://localhost:3000";
}
}
for (const key of requiredEnvs) {
if (!process.env[key]) {
// Keep as runtime warning for smoother local setup.
@@ -24,12 +12,9 @@ for (const key of requiredEnvs) {
}
export const env = {
appUrl: configuredAppUrl ?? "http://localhost:3000",
appOrigin: getAppOrigin(),
appUrl: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
jobSecret: process.env.JOB_SECRET ?? "",
cronSecret: process.env.CRON_SECRET ?? "",
cronSharedSecret:
process.env.CRON_SHARED_SECRET ?? process.env.CRON_SECRET ?? "",
encryptionKey: process.env.ENCRYPTION_KEY ?? "",
pleskCredEncKey: process.env.PLESK_CRED_ENC_KEY ?? "",
stripeSecretKey: process.env.STRIPE_SECRET_KEY ?? "",

View File

@@ -1,15 +1,5 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
eslint: {
// Temporary: repository has existing no-explicit-any violations.
// Keep dedicated lint stage in CI; do not block production build on legacy lint debt.
ignoreDuringBuilds: true,
},
typescript: {
// Temporary: repository has existing unrelated type errors in billing/stripe flows.
// Keep separate typecheck/lint quality gates while allowing production image build.
ignoreBuildErrors: true,
},
experimental: {
serverActions: {
bodySizeLimit: "2mb",

View File

@@ -1,30 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
DEPLOY_PATH="${DEPLOY_PATH:-/opt/plesk-agency-portal}"
APP_IMAGE="${APP_IMAGE:-plesk-agency-portal:latest}"
SOURCE_PATH="${SOURCE_PATH:-${DEPLOY_PATH}}"
mkdir -p "${DEPLOY_PATH}"
for file in docker-compose.prod.yml deploy-prod.sh smoke-check.sh rollback-prod.sh; do
if [ -f "${SOURCE_PATH}/${file}" ]; then
cp "${SOURCE_PATH}/${file}" "${DEPLOY_PATH}/${file}"
fi
done
chmod +x "${DEPLOY_PATH}/deploy-prod.sh" \
"${DEPLOY_PATH}/smoke-check.sh" \
"${DEPLOY_PATH}/rollback-prod.sh" 2>/dev/null || true
if [ ! -f "${DEPLOY_PATH}/docker-compose.prod.yml" ]; then
echo "Missing docker-compose.prod.yml in ${DEPLOY_PATH}" >&2
exit 1
fi
cd "${DEPLOY_PATH}"
APP_IMAGE="${APP_IMAGE}" docker compose -f docker-compose.prod.yml up -d --no-build
docker compose -f docker-compose.prod.yml ps

View File

@@ -1,30 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
PREVIOUS_IMAGE_TAG="${1:-}"
DEPLOY_PATH="${DEPLOY_PATH:-/opt/plesk-agency-portal}"
APP_BASE_URL="${APP_BASE_URL:-}"
if [ -z "${PREVIOUS_IMAGE_TAG}" ]; then
echo "Usage: $0 <previous-image-tag>" >&2
exit 1
fi
if [ -z "${APP_BASE_URL}" ]; then
echo "APP_BASE_URL is required" >&2
exit 1
fi
if [ ! -f "${DEPLOY_PATH}/docker-compose.prod.yml" ]; then
echo "Missing docker-compose.prod.yml in ${DEPLOY_PATH}" >&2
exit 1
fi
cd "${DEPLOY_PATH}"
APP_IMAGE="${PREVIOUS_IMAGE_TAG}" docker compose -f docker-compose.prod.yml up -d --no-build
"${DEPLOY_PATH}/smoke-check.sh" "${APP_BASE_URL}"
docker compose -f docker-compose.prod.yml ps

View File

@@ -1,27 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${1:-}"
if [ -z "${BASE_URL}" ]; then
echo "Usage: $0 <base-url>" >&2
exit 1
fi
URL="${BASE_URL%/}/api/health"
TMP_FILE="$(mktemp)"
trap 'rm -f "${TMP_FILE}"' EXIT
HTTP_CODE=$(curl -sS -o "${TMP_FILE}" -w "%{http_code}" "${URL}")
if [ "${HTTP_CODE}" != "200" ]; then
echo "Smoke check failed: ${URL} returned ${HTTP_CODE}" >&2
cat "${TMP_FILE}" >&2 || true
exit 1
fi
node -e "const fs=require('fs');const d=JSON.parse(fs.readFileSync(process.argv[1],'utf8'));if(d?.ok!==true||typeof d?.service!=='string'){process.exit(1)}" "${TMP_FILE}"
echo "Smoke check passed: ${URL}"