@stacksjs/ts-cloud 0.7.18 → 0.7.20
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/dist/bin/cli.js +697 -571
- package/dist/{chunk-h4pdjzq2.js → chunk-qdhn1s7j.js} +745 -52
- package/dist/{chunk-vpyd42tp.js → chunk-xgnxz5dz.js} +98 -34
- package/dist/deploy/dashboard-auth.d.ts +82 -0
- package/dist/deploy/dashboard-auth.test.d.ts +1 -0
- package/dist/deploy/dashboard-guard.d.ts +40 -0
- package/dist/deploy/dashboard-login-page.d.ts +16 -0
- package/dist/deploy/dashboard-pages.test.d.ts +1 -0
- package/dist/deploy/dashboard-policy.d.ts +37 -0
- package/dist/deploy/dashboard-policy.test.d.ts +1 -0
- package/dist/deploy/dashboard-scope.d.ts +24 -0
- package/dist/deploy/dashboard-scope.test.d.ts +1 -0
- package/dist/deploy/dashboard-session.d.ts +52 -0
- package/dist/deploy/dashboard-users.d.ts +60 -0
- package/dist/deploy/index.js +2 -2
- package/dist/deploy/local-dashboard-server.d.ts +1 -0
- package/dist/drivers/hetzner/client.d.ts +9 -1
- package/dist/drivers/hetzner/config.d.ts +83 -0
- package/dist/drivers/hetzner/config.test.d.ts +1 -0
- package/dist/drivers/hetzner/factory-wiring.test.d.ts +1 -0
- package/dist/drivers/index.js +1 -1
- package/dist/index.js +2 -2
- package/dist/ui/index.html +4 -4
- package/dist/ui/server/actions.html +4 -4
- package/dist/ui/server/activity.html +1 -1
- package/dist/ui/server/backups.html +4 -4
- package/dist/ui/server/database.html +4 -4
- package/dist/ui/server/deployments.html +4 -4
- package/dist/ui/server/diagnostics.html +1 -1
- package/dist/ui/server/firewall.html +4 -4
- package/dist/ui/server/logs.html +4 -4
- package/dist/ui/server/metrics.html +1 -1
- package/dist/ui/server/security.html +1 -1
- package/dist/ui/server/services.html +4 -4
- package/dist/ui/server/sites.html +4 -4
- package/dist/ui/server/ssh-keys.html +4 -4
- package/dist/ui/server/team.html +1214 -0
- package/dist/ui/server/terminal.html +4 -4
- package/dist/ui/server/workers.html +4 -4
- package/dist/ui/serverless/alarms.html +4 -4
- package/dist/ui/serverless/assets.html +4 -4
- package/dist/ui/serverless/cost.html +1 -1
- package/dist/ui/serverless/data.html +4 -4
- package/dist/ui/serverless/deployments.html +4 -4
- package/dist/ui/serverless/firewall.html +1 -1
- package/dist/ui/serverless/functions.html +4 -4
- package/dist/ui/serverless/logs.html +4 -4
- package/dist/ui/serverless/metrics.html +1 -1
- package/dist/ui/serverless/queues.html +4 -4
- package/dist/ui/serverless/scheduler.html +4 -4
- package/dist/ui/serverless/secrets.html +4 -4
- package/dist/ui/serverless/traces.html +4 -4
- package/dist/ui/serverless.html +4 -4
- package/dist/ui-src/pages/partials/head.stx +7 -0
- package/dist/ui-src/pages/partials/nav.stx +43 -6
- package/dist/ui-src/pages/server/deployments.stx +20 -5
- package/dist/ui-src/pages/server/firewall.stx +1 -1
- package/dist/ui-src/pages/server/logs.stx +26 -8
- package/dist/ui-src/pages/server/sites.stx +42 -20
- package/dist/ui-src/pages/server/team.stx +171 -0
- package/package.json +3 -3
|
@@ -23,11 +23,12 @@ import {
|
|
|
23
23
|
buildRollbackScript,
|
|
24
24
|
createCloudDriver,
|
|
25
25
|
releasePaths,
|
|
26
|
+
resolveHetznerLocation,
|
|
26
27
|
resolveSiteFramework,
|
|
27
28
|
resolveSiteKind,
|
|
28
29
|
resolveUiSource,
|
|
29
30
|
siteInstallBase
|
|
30
|
-
} from "./chunk-
|
|
31
|
+
} from "./chunk-xgnxz5dz.js";
|
|
31
32
|
import {
|
|
32
33
|
artifactKey,
|
|
33
34
|
composeServerlessAppTemplate,
|
|
@@ -1159,11 +1160,11 @@ async function resolveDashboardData(config, environment) {
|
|
|
1159
1160
|
return out;
|
|
1160
1161
|
}
|
|
1161
1162
|
// src/deploy/local-dashboard-server.ts
|
|
1162
|
-
import { existsSync as
|
|
1163
|
+
import { existsSync as existsSync18, statSync as statSync3 } from "node:fs";
|
|
1163
1164
|
import { readFile, writeFile as writeFile4 } from "node:fs/promises";
|
|
1164
1165
|
import { mkdtempSync } from "node:fs";
|
|
1165
1166
|
import { tmpdir } from "node:os";
|
|
1166
|
-
import { dirname as
|
|
1167
|
+
import { dirname as dirname8, extname as extname2, join as join12, normalize } from "node:path";
|
|
1167
1168
|
import { fileURLToPath } from "node:url";
|
|
1168
1169
|
|
|
1169
1170
|
// src/config.ts
|
|
@@ -9896,6 +9897,67 @@ async function getConfig() {
|
|
|
9896
9897
|
var loadCloudConfig = getConfig;
|
|
9897
9898
|
var config5 = defaultConfig4;
|
|
9898
9899
|
|
|
9900
|
+
// src/deploy/dashboard-auth.ts
|
|
9901
|
+
import { randomBytes as randomBytes4, scryptSync, timingSafeEqual } from "node:crypto";
|
|
9902
|
+
var SITE_ROLE_CAPABILITIES = {
|
|
9903
|
+
owner: new Set(["site:read", "site:deploy", "site:settings"]),
|
|
9904
|
+
collaborator: new Set(["site:read", "site:deploy"])
|
|
9905
|
+
};
|
|
9906
|
+
function isBoxCapability(capability) {
|
|
9907
|
+
return capability.startsWith("box:");
|
|
9908
|
+
}
|
|
9909
|
+
function authorize({ user, capability, site }) {
|
|
9910
|
+
if (user.role === "admin")
|
|
9911
|
+
return true;
|
|
9912
|
+
if (isBoxCapability(capability))
|
|
9913
|
+
return false;
|
|
9914
|
+
if (!site)
|
|
9915
|
+
return false;
|
|
9916
|
+
const siteRole = user.sites?.[site];
|
|
9917
|
+
if (!siteRole)
|
|
9918
|
+
return false;
|
|
9919
|
+
return SITE_ROLE_CAPABILITIES[siteRole]?.has(capability) ?? false;
|
|
9920
|
+
}
|
|
9921
|
+
function visibleSites(user, allSites) {
|
|
9922
|
+
if (user.role === "admin")
|
|
9923
|
+
return [...allSites];
|
|
9924
|
+
return allSites.filter((site) => !!user.sites?.[site]);
|
|
9925
|
+
}
|
|
9926
|
+
var SCRYPT_N = 16384;
|
|
9927
|
+
var SCRYPT_R = 8;
|
|
9928
|
+
var SCRYPT_P = 1;
|
|
9929
|
+
var KEY_LEN = 32;
|
|
9930
|
+
var SALT_LEN = 16;
|
|
9931
|
+
function hashPassword(password) {
|
|
9932
|
+
const salt = randomBytes4(SALT_LEN);
|
|
9933
|
+
const hash = scryptSync(password, salt, KEY_LEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P });
|
|
9934
|
+
return `scrypt$${SCRYPT_N}$${SCRYPT_R}$${SCRYPT_P}$${salt.toString("base64url")}$${hash.toString("base64url")}`;
|
|
9935
|
+
}
|
|
9936
|
+
function verifyPassword(password, stored) {
|
|
9937
|
+
try {
|
|
9938
|
+
const parts = stored.split("$");
|
|
9939
|
+
if (parts.length !== 6 || parts[0] !== "scrypt")
|
|
9940
|
+
return false;
|
|
9941
|
+
const [, nRaw, rRaw, pRaw, saltRaw, hashRaw] = parts;
|
|
9942
|
+
const N = Number(nRaw);
|
|
9943
|
+
const r = Number(rRaw);
|
|
9944
|
+
const p = Number(pRaw);
|
|
9945
|
+
if (!Number.isFinite(N) || !Number.isFinite(r) || !Number.isFinite(p))
|
|
9946
|
+
return false;
|
|
9947
|
+
const salt = Buffer.from(saltRaw, "base64url");
|
|
9948
|
+
const expected = Buffer.from(hashRaw, "base64url");
|
|
9949
|
+
if (salt.length === 0 || expected.length === 0)
|
|
9950
|
+
return false;
|
|
9951
|
+
const actual = scryptSync(password, salt, expected.length, { N, r, p, maxmem: 256 * 1024 * 1024 });
|
|
9952
|
+
return timingSafeEqual(actual, expected);
|
|
9953
|
+
} catch {
|
|
9954
|
+
return false;
|
|
9955
|
+
}
|
|
9956
|
+
}
|
|
9957
|
+
function generatePassword() {
|
|
9958
|
+
return randomBytes4(18).toString("base64url");
|
|
9959
|
+
}
|
|
9960
|
+
|
|
9899
9961
|
// src/deploy/dashboard-data-server.ts
|
|
9900
9962
|
import { existsSync as existsSync14, readFileSync as readFileSync3 } from "node:fs";
|
|
9901
9963
|
import { join as join9 } from "node:path";
|
|
@@ -10157,10 +10219,8 @@ function configuredProvider(config6) {
|
|
|
10157
10219
|
return config6.infrastructure?.compute?.provider ?? config6.cloud?.provider ?? config6.provider ?? "aws";
|
|
10158
10220
|
}
|
|
10159
10221
|
function configuredRegion(config6) {
|
|
10160
|
-
|
|
10161
|
-
|
|
10162
|
-
return config6.hetzner?.location ?? process.env.HCLOUD_LOCATION ?? process.env.HETZNER_LOCATION ?? "fsn1";
|
|
10163
|
-
}
|
|
10222
|
+
if (configuredProvider(config6) === "hetzner")
|
|
10223
|
+
return resolveHetznerLocation(config6);
|
|
10164
10224
|
return config6.project.region ?? "us-east-1";
|
|
10165
10225
|
}
|
|
10166
10226
|
function loadLocalState(config6, environment) {
|
|
@@ -10993,6 +11053,447 @@ async function listDatabaseBackups(config6, environment) {
|
|
|
10993
11053
|
return { ...r, backups };
|
|
10994
11054
|
}
|
|
10995
11055
|
|
|
11056
|
+
// src/deploy/dashboard-policy.ts
|
|
11057
|
+
var FAIL_CLOSED = { capability: "box:shell" };
|
|
11058
|
+
var PUBLIC_ROUTES = new Set([
|
|
11059
|
+
"POST /api/login",
|
|
11060
|
+
"POST /api/logout"
|
|
11061
|
+
]);
|
|
11062
|
+
var POLICIES = {
|
|
11063
|
+
"GET /api/health": { capability: "site:read", anyUser: true },
|
|
11064
|
+
"GET /api/dashboard-data": { capability: "site:read", anyUser: true },
|
|
11065
|
+
"GET /api/config": { capability: "site:read", anyUser: true },
|
|
11066
|
+
"GET /api/me": { capability: "site:read", anyUser: true },
|
|
11067
|
+
"POST /api/sites/deploy": { capability: "site:deploy", siteFrom: "body" },
|
|
11068
|
+
"PATCH /api/sites": { capability: "site:settings", siteFrom: "body" },
|
|
11069
|
+
"POST /api/sites": { capability: "box:sites:create" },
|
|
11070
|
+
"DELETE /api/sites": { capability: "box:sites:delete" },
|
|
11071
|
+
"POST /api/env": { capability: "box:config" },
|
|
11072
|
+
"GET /api/ssh-keys": { capability: "box:ssh" },
|
|
11073
|
+
"POST /api/ssh-keys": { capability: "box:ssh" },
|
|
11074
|
+
"DELETE /api/ssh-keys": { capability: "box:ssh" },
|
|
11075
|
+
"GET /api/firewall": { capability: "box:firewall" },
|
|
11076
|
+
"POST /api/firewall": { capability: "box:firewall" },
|
|
11077
|
+
"DELETE /api/firewall": { capability: "box:firewall" },
|
|
11078
|
+
"GET /api/databases": { capability: "box:database" },
|
|
11079
|
+
"POST /api/databases": { capability: "box:database" },
|
|
11080
|
+
"GET /api/databases/backups": { capability: "box:database" },
|
|
11081
|
+
"POST /api/databases/backup": { capability: "box:database" },
|
|
11082
|
+
"POST /api/databases/users": { capability: "box:database" },
|
|
11083
|
+
"GET /api/actions": { capability: "box:read" },
|
|
11084
|
+
"POST /api/actions/run": { capability: "box:shell" },
|
|
11085
|
+
"GET /api/server/operations": { capability: "box:read" },
|
|
11086
|
+
"POST /api/server/operations/run": { capability: "box:shell" },
|
|
11087
|
+
"POST /api/server/command": { capability: "box:shell" },
|
|
11088
|
+
"GET /api/terminal": { capability: "box:shell" },
|
|
11089
|
+
"GET /api/users": { capability: "box:users" },
|
|
11090
|
+
"POST /api/users": { capability: "box:users" },
|
|
11091
|
+
"DELETE /api/users": { capability: "box:users" },
|
|
11092
|
+
"GET /api/serverless/operations": { capability: "box:serverless" },
|
|
11093
|
+
"POST /api/serverless/operations/run": { capability: "box:serverless" },
|
|
11094
|
+
"POST /api/serverless/command": { capability: "box:serverless" },
|
|
11095
|
+
"GET /api/serverless/dlq": { capability: "box:serverless" },
|
|
11096
|
+
"POST /api/serverless/dlq/redrive": { capability: "box:serverless" },
|
|
11097
|
+
"POST /api/serverless/dlq/purge": { capability: "box:serverless" },
|
|
11098
|
+
"GET /api/serverless/secrets": { capability: "box:serverless" },
|
|
11099
|
+
"POST /api/serverless/secrets": { capability: "box:serverless" },
|
|
11100
|
+
"DELETE /api/serverless/secrets": { capability: "box:serverless" },
|
|
11101
|
+
"POST /api/serverless/functions/config": { capability: "box:serverless" },
|
|
11102
|
+
"GET /api/serverless/alarms": { capability: "box:serverless" },
|
|
11103
|
+
"POST /api/serverless/alarms": { capability: "box:serverless" },
|
|
11104
|
+
"DELETE /api/serverless/alarms": { capability: "box:serverless" },
|
|
11105
|
+
"GET /api/serverless/traces": { capability: "box:serverless" },
|
|
11106
|
+
"POST /api/serverless/scheduler": { capability: "box:serverless" }
|
|
11107
|
+
};
|
|
11108
|
+
function routePolicy(method, pathname) {
|
|
11109
|
+
return POLICIES[`${method.toUpperCase()} ${pathname}`] ?? FAIL_CLOSED;
|
|
11110
|
+
}
|
|
11111
|
+
|
|
11112
|
+
// src/deploy/dashboard-session.ts
|
|
11113
|
+
import { createHmac, randomBytes as randomBytes6, timingSafeEqual as timingSafeEqual2 } from "node:crypto";
|
|
11114
|
+
import { chmodSync, existsSync as existsSync16, mkdirSync as mkdirSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync8 } from "node:fs";
|
|
11115
|
+
import { dirname as dirname5, join as join10 } from "node:path";
|
|
11116
|
+
var SESSION_COOKIE = "ts_cloud_session";
|
|
11117
|
+
var SECRET_FILE = join10(".ts-cloud", "dashboard-secret");
|
|
11118
|
+
var SESSION_TTL_MS = 8 * 60 * 60 * 1000;
|
|
11119
|
+
function resolveSessionSecret(cwd) {
|
|
11120
|
+
const fromEnv = process.env.TS_CLOUD_DASHBOARD_SECRET?.trim();
|
|
11121
|
+
if (fromEnv)
|
|
11122
|
+
return fromEnv;
|
|
11123
|
+
const file = join10(cwd, SECRET_FILE);
|
|
11124
|
+
try {
|
|
11125
|
+
if (existsSync16(file)) {
|
|
11126
|
+
const saved = readFileSync4(file, "utf8").trim();
|
|
11127
|
+
if (saved)
|
|
11128
|
+
return saved;
|
|
11129
|
+
}
|
|
11130
|
+
} catch {}
|
|
11131
|
+
const secret = randomBytes6(32).toString("base64url");
|
|
11132
|
+
try {
|
|
11133
|
+
mkdirSync5(dirname5(file), { recursive: true });
|
|
11134
|
+
writeFileSync8(file, `${secret}
|
|
11135
|
+
`);
|
|
11136
|
+
chmodSync(file, 384);
|
|
11137
|
+
} catch {}
|
|
11138
|
+
return secret;
|
|
11139
|
+
}
|
|
11140
|
+
function sign(data, secret) {
|
|
11141
|
+
return createHmac("sha256", secret).update(data).digest("base64url");
|
|
11142
|
+
}
|
|
11143
|
+
function createSessionToken(username, secret, ttlMs = SESSION_TTL_MS) {
|
|
11144
|
+
const payload = { u: username, exp: Date.now() + ttlMs };
|
|
11145
|
+
const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
|
11146
|
+
return `${encoded}.${sign(encoded, secret)}`;
|
|
11147
|
+
}
|
|
11148
|
+
function verifySessionToken(token, secret) {
|
|
11149
|
+
if (!token)
|
|
11150
|
+
return null;
|
|
11151
|
+
const dot = token.lastIndexOf(".");
|
|
11152
|
+
if (dot <= 0)
|
|
11153
|
+
return null;
|
|
11154
|
+
const encoded = token.slice(0, dot);
|
|
11155
|
+
const signature = token.slice(dot + 1);
|
|
11156
|
+
const expected = sign(encoded, secret);
|
|
11157
|
+
const a = Buffer.from(signature);
|
|
11158
|
+
const b = Buffer.from(expected);
|
|
11159
|
+
if (a.length !== b.length || !timingSafeEqual2(a, b))
|
|
11160
|
+
return null;
|
|
11161
|
+
try {
|
|
11162
|
+
const payload = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
|
|
11163
|
+
if (typeof payload?.u !== "string" || typeof payload?.exp !== "number")
|
|
11164
|
+
return null;
|
|
11165
|
+
if (Date.now() >= payload.exp)
|
|
11166
|
+
return null;
|
|
11167
|
+
return payload;
|
|
11168
|
+
} catch {
|
|
11169
|
+
return null;
|
|
11170
|
+
}
|
|
11171
|
+
}
|
|
11172
|
+
function readCookie(header2, name) {
|
|
11173
|
+
if (!header2)
|
|
11174
|
+
return;
|
|
11175
|
+
for (const part of header2.split(";")) {
|
|
11176
|
+
const eq = part.indexOf("=");
|
|
11177
|
+
if (eq < 0)
|
|
11178
|
+
continue;
|
|
11179
|
+
if (part.slice(0, eq).trim() === name)
|
|
11180
|
+
return decodeURIComponent(part.slice(eq + 1).trim());
|
|
11181
|
+
}
|
|
11182
|
+
return;
|
|
11183
|
+
}
|
|
11184
|
+
function serializeSessionCookie(token, options = { secure: true }) {
|
|
11185
|
+
const maxAge = Math.floor((options.maxAgeMs ?? SESSION_TTL_MS) / 1000);
|
|
11186
|
+
const attrs = [
|
|
11187
|
+
`${SESSION_COOKIE}=${encodeURIComponent(token)}`,
|
|
11188
|
+
"Path=/",
|
|
11189
|
+
"HttpOnly",
|
|
11190
|
+
"SameSite=Lax",
|
|
11191
|
+
`Max-Age=${maxAge}`
|
|
11192
|
+
];
|
|
11193
|
+
if (options.secure)
|
|
11194
|
+
attrs.push("Secure");
|
|
11195
|
+
return attrs.join("; ");
|
|
11196
|
+
}
|
|
11197
|
+
function clearSessionCookie(options = { secure: true }) {
|
|
11198
|
+
const attrs = [`${SESSION_COOKIE}=`, "Path=/", "HttpOnly", "SameSite=Lax", "Max-Age=0"];
|
|
11199
|
+
if (options.secure)
|
|
11200
|
+
attrs.push("Secure");
|
|
11201
|
+
return attrs.join("; ");
|
|
11202
|
+
}
|
|
11203
|
+
|
|
11204
|
+
// src/deploy/dashboard-users.ts
|
|
11205
|
+
import { chmodSync as chmodSync2, existsSync as existsSync17, mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync10 } from "node:fs";
|
|
11206
|
+
import { dirname as dirname7, join as join11 } from "node:path";
|
|
11207
|
+
var USERS_FILE = join11(".ts-cloud", "dashboard-users.json");
|
|
11208
|
+
function isValidUsername(value) {
|
|
11209
|
+
return /^[a-z0-9][a-z0-9._-]{1,31}$/i.test(value);
|
|
11210
|
+
}
|
|
11211
|
+
function normalizeUser(raw) {
|
|
11212
|
+
if (!raw || typeof raw.username !== "string" || typeof raw.passwordHash !== "string")
|
|
11213
|
+
return null;
|
|
11214
|
+
const role = raw.role === "admin" ? "admin" : "member";
|
|
11215
|
+
const sites = {};
|
|
11216
|
+
if (raw.sites && typeof raw.sites === "object") {
|
|
11217
|
+
for (const [site, siteRole] of Object.entries(raw.sites)) {
|
|
11218
|
+
if (siteRole === "owner" || siteRole === "collaborator")
|
|
11219
|
+
sites[site] = siteRole;
|
|
11220
|
+
}
|
|
11221
|
+
}
|
|
11222
|
+
return {
|
|
11223
|
+
username: raw.username,
|
|
11224
|
+
passwordHash: raw.passwordHash,
|
|
11225
|
+
role,
|
|
11226
|
+
sites,
|
|
11227
|
+
name: typeof raw.name === "string" ? raw.name : undefined,
|
|
11228
|
+
createdAt: typeof raw.createdAt === "string" ? raw.createdAt : undefined
|
|
11229
|
+
};
|
|
11230
|
+
}
|
|
11231
|
+
function parseUsersFile(text) {
|
|
11232
|
+
try {
|
|
11233
|
+
const parsed = JSON.parse(text);
|
|
11234
|
+
if (!Array.isArray(parsed?.users))
|
|
11235
|
+
return [];
|
|
11236
|
+
return parsed.users.map(normalizeUser).filter((u) => u !== null);
|
|
11237
|
+
} catch {
|
|
11238
|
+
return [];
|
|
11239
|
+
}
|
|
11240
|
+
}
|
|
11241
|
+
function usersFilePath(cwd) {
|
|
11242
|
+
return join11(cwd, USERS_FILE);
|
|
11243
|
+
}
|
|
11244
|
+
function loadUsers(cwd) {
|
|
11245
|
+
const file = usersFilePath(cwd);
|
|
11246
|
+
if (!existsSync17(file))
|
|
11247
|
+
return [];
|
|
11248
|
+
try {
|
|
11249
|
+
return parseUsersFile(readFileSync5(file, "utf8"));
|
|
11250
|
+
} catch {
|
|
11251
|
+
return [];
|
|
11252
|
+
}
|
|
11253
|
+
}
|
|
11254
|
+
function saveUsers(cwd, users) {
|
|
11255
|
+
const file = usersFilePath(cwd);
|
|
11256
|
+
mkdirSync7(dirname7(file), { recursive: true });
|
|
11257
|
+
writeFileSync10(file, `${JSON.stringify({ users }, null, 2)}
|
|
11258
|
+
`);
|
|
11259
|
+
chmodSync2(file, 384);
|
|
11260
|
+
}
|
|
11261
|
+
function findUser(users, username) {
|
|
11262
|
+
const wanted = username.trim().toLowerCase();
|
|
11263
|
+
return users.find((u) => u.username.toLowerCase() === wanted);
|
|
11264
|
+
}
|
|
11265
|
+
function ensureAdminUser(cwd, username = "admin") {
|
|
11266
|
+
const users = loadUsers(cwd);
|
|
11267
|
+
if (users.some((u) => u.role === "admin"))
|
|
11268
|
+
return { users };
|
|
11269
|
+
const password = process.env.TS_CLOUD_UI_PASSWORD?.trim() || generatePassword();
|
|
11270
|
+
const admin = {
|
|
11271
|
+
username,
|
|
11272
|
+
passwordHash: hashPassword(password),
|
|
11273
|
+
role: "admin",
|
|
11274
|
+
sites: {},
|
|
11275
|
+
name: "Administrator",
|
|
11276
|
+
createdAt: new Date().toISOString()
|
|
11277
|
+
};
|
|
11278
|
+
const next = [...users, admin];
|
|
11279
|
+
saveUsers(cwd, next);
|
|
11280
|
+
return { users: next, generated: { username, password } };
|
|
11281
|
+
}
|
|
11282
|
+
function upsertMember(cwd, input) {
|
|
11283
|
+
const users = loadUsers(cwd);
|
|
11284
|
+
const existing = findUser(users, input.username);
|
|
11285
|
+
const password = input.password?.trim() || (existing ? undefined : generatePassword());
|
|
11286
|
+
const user = {
|
|
11287
|
+
username: existing?.username ?? input.username,
|
|
11288
|
+
passwordHash: password ? hashPassword(password) : existing.passwordHash,
|
|
11289
|
+
role: existing?.role ?? "member",
|
|
11290
|
+
sites: input.sites,
|
|
11291
|
+
name: input.name ?? existing?.name,
|
|
11292
|
+
createdAt: existing?.createdAt ?? new Date().toISOString()
|
|
11293
|
+
};
|
|
11294
|
+
const next = existing ? users.map((u) => u.username.toLowerCase() === user.username.toLowerCase() ? user : u) : [...users, user];
|
|
11295
|
+
saveUsers(cwd, next);
|
|
11296
|
+
return { user, password };
|
|
11297
|
+
}
|
|
11298
|
+
function removeUser(cwd, username) {
|
|
11299
|
+
const users = loadUsers(cwd);
|
|
11300
|
+
const target = findUser(users, username);
|
|
11301
|
+
if (!target)
|
|
11302
|
+
return { ok: false, error: `No such user: ${username}` };
|
|
11303
|
+
if (target.role === "admin" && users.filter((u) => u.role === "admin").length === 1)
|
|
11304
|
+
return { ok: false, error: "Cannot remove the last admin." };
|
|
11305
|
+
saveUsers(cwd, users.filter((u) => u.username.toLowerCase() !== target.username.toLowerCase()));
|
|
11306
|
+
return { ok: true };
|
|
11307
|
+
}
|
|
11308
|
+
function describeUser(user) {
|
|
11309
|
+
return {
|
|
11310
|
+
username: user.username,
|
|
11311
|
+
name: user.name ?? user.username,
|
|
11312
|
+
role: user.role,
|
|
11313
|
+
sites: user.sites,
|
|
11314
|
+
siteCount: Object.keys(user.sites).length,
|
|
11315
|
+
createdAt: user.createdAt
|
|
11316
|
+
};
|
|
11317
|
+
}
|
|
11318
|
+
|
|
11319
|
+
// src/deploy/dashboard-guard.ts
|
|
11320
|
+
var LOCAL_ADMIN = {
|
|
11321
|
+
username: "local",
|
|
11322
|
+
passwordHash: "",
|
|
11323
|
+
role: "admin",
|
|
11324
|
+
sites: {},
|
|
11325
|
+
name: "Local (auth disabled)"
|
|
11326
|
+
};
|
|
11327
|
+
function createDashboardGuard(options) {
|
|
11328
|
+
const { cwd, enabled, secret } = options;
|
|
11329
|
+
return {
|
|
11330
|
+
enabled,
|
|
11331
|
+
resolveUser(req) {
|
|
11332
|
+
if (!enabled)
|
|
11333
|
+
return LOCAL_ADMIN;
|
|
11334
|
+
const token = readCookie(req.headers.get("cookie"), SESSION_COOKIE);
|
|
11335
|
+
const payload = verifySessionToken(token, secret);
|
|
11336
|
+
if (!payload)
|
|
11337
|
+
return null;
|
|
11338
|
+
return findUser(loadUsers(cwd), payload.u) ?? null;
|
|
11339
|
+
},
|
|
11340
|
+
check(req, pathname, user, site) {
|
|
11341
|
+
if (!enabled)
|
|
11342
|
+
return { ok: true };
|
|
11343
|
+
if (!user)
|
|
11344
|
+
return { ok: false, status: 401, error: "Sign in to continue.", unauthenticated: true };
|
|
11345
|
+
const policy = routePolicy(req.method, pathname);
|
|
11346
|
+
if (policy.anyUser)
|
|
11347
|
+
return { ok: true };
|
|
11348
|
+
if (policy.siteFrom === "body" && !site) {
|
|
11349
|
+
return { ok: false, status: 400, error: "This request must name a site." };
|
|
11350
|
+
}
|
|
11351
|
+
if (!authorize({ user, capability: policy.capability, site })) {
|
|
11352
|
+
return { ok: false, status: 403, error: "You do not have access to this." };
|
|
11353
|
+
}
|
|
11354
|
+
return { ok: true };
|
|
11355
|
+
}
|
|
11356
|
+
};
|
|
11357
|
+
}
|
|
11358
|
+
async function siteFromRequest(req, pathname) {
|
|
11359
|
+
const policy = routePolicy(req.method, pathname);
|
|
11360
|
+
if (policy.siteFrom !== "body")
|
|
11361
|
+
return;
|
|
11362
|
+
try {
|
|
11363
|
+
const body = await req.clone().json();
|
|
11364
|
+
const name = String(body?.name ?? "").trim();
|
|
11365
|
+
return name || undefined;
|
|
11366
|
+
} catch {
|
|
11367
|
+
return;
|
|
11368
|
+
}
|
|
11369
|
+
}
|
|
11370
|
+
|
|
11371
|
+
// src/deploy/dashboard-login-page.ts
|
|
11372
|
+
var STYLES = `
|
|
11373
|
+
:root {
|
|
11374
|
+
--bg: #0a0e15; --bg2: #0b0f18; --panel: rgba(255,255,255,0.03); --panel-br: rgba(255,255,255,0.072);
|
|
11375
|
+
--txt: #e9edf5; --txt2: #94a1b8; --txt3: #5a6577;
|
|
11376
|
+
--accent: #5a8be0; --accent-ink: #061019;
|
|
11377
|
+
--bad: #f0736e; --badbg: rgba(240,115,110,0.11);
|
|
11378
|
+
}
|
|
11379
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
11380
|
+
html { background: var(--bg); min-height: 100%; }
|
|
11381
|
+
body {
|
|
11382
|
+
font-family: 'Inter', system-ui, sans-serif; color: var(--txt); min-height: 100vh;
|
|
11383
|
+
background:
|
|
11384
|
+
radial-gradient(900px 460px at 88% -8%, rgba(90,139,224,0.06), transparent 62%),
|
|
11385
|
+
linear-gradient(175deg, var(--bg), var(--bg2));
|
|
11386
|
+
-webkit-font-smoothing: antialiased;
|
|
11387
|
+
display: grid; place-items: center; padding: 24px;
|
|
11388
|
+
}
|
|
11389
|
+
.card {
|
|
11390
|
+
width: 100%; max-width: 380px;
|
|
11391
|
+
background: var(--panel); border: 1px solid var(--panel-br); border-radius: 16px;
|
|
11392
|
+
padding: 32px; backdrop-filter: blur(6px);
|
|
11393
|
+
animation: rise .5s cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
11394
|
+
}
|
|
11395
|
+
@keyframes rise { from { opacity: 0; transform: translateY(8px) } to { opacity: 1; transform: none } }
|
|
11396
|
+
@media (prefers-reduced-motion: reduce) { .card { animation: none } }
|
|
11397
|
+
.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 17px; letter-spacing: -0.02em; }
|
|
11398
|
+
.brand .dot { width: 20px; height: 20px; border-radius: 6px; background: var(--accent); box-shadow: inset 0 1px 0 rgba(255,255,255,0.35); }
|
|
11399
|
+
h1 { font-size: 21px; font-weight: 800; letter-spacing: -0.02em; margin-top: 22px; }
|
|
11400
|
+
.sub { color: var(--txt2); font-size: 13.5px; margin-top: 5px; line-height: 1.5; }
|
|
11401
|
+
form { margin-top: 24px; display: grid; gap: 14px; }
|
|
11402
|
+
.field { display: flex; flex-direction: column; gap: 7px; }
|
|
11403
|
+
label { color: var(--txt3); font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.07em; }
|
|
11404
|
+
input {
|
|
11405
|
+
width: 100%; background: rgba(255,255,255,0.05); border: 1px solid var(--panel-br); border-radius: 10px;
|
|
11406
|
+
padding: 10px 12px; color: var(--txt); font-family: inherit; font-size: 14px;
|
|
11407
|
+
}
|
|
11408
|
+
input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(90,139,224,0.12); }
|
|
11409
|
+
button {
|
|
11410
|
+
margin-top: 4px; background: var(--accent); color: var(--accent-ink); border: 0; border-radius: 10px;
|
|
11411
|
+
padding: 11px 16px; font-weight: 700; font-size: 13.5px; cursor: pointer; font-family: inherit;
|
|
11412
|
+
transition: filter .12s, transform .12s;
|
|
11413
|
+
}
|
|
11414
|
+
button:hover { filter: brightness(1.06); }
|
|
11415
|
+
button:active { transform: translateY(1px); }
|
|
11416
|
+
button[disabled] { opacity: 0.6; cursor: default; }
|
|
11417
|
+
.msg {
|
|
11418
|
+
display: none; margin-top: 14px; padding: 10px 12px; border-radius: 10px;
|
|
11419
|
+
background: var(--badbg); border: 1px solid rgba(240,115,110,0.22); color: var(--bad);
|
|
11420
|
+
font-size: 13px; line-height: 1.45;
|
|
11421
|
+
}
|
|
11422
|
+
.msg.shown { display: block; }
|
|
11423
|
+
.note { color: var(--txt3); font-size: 12px; margin-top: 20px; line-height: 1.5; }
|
|
11424
|
+
.note code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--txt2); }
|
|
11425
|
+
`;
|
|
11426
|
+
function renderLoginPage(serverless = false) {
|
|
11427
|
+
const home = serverless ? "/serverless" : "/";
|
|
11428
|
+
return `<!doctype html>
|
|
11429
|
+
<html lang="en">
|
|
11430
|
+
<head>
|
|
11431
|
+
<meta charset="UTF-8">
|
|
11432
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
11433
|
+
<title>Sign in · ts-cloud</title>
|
|
11434
|
+
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect x='3' y='3' width='26' height='26' rx='8' fill='%235a8be0'/%3E%3C/svg%3E">
|
|
11435
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
11436
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
|
11437
|
+
<style>${STYLES}</style>
|
|
11438
|
+
</head>
|
|
11439
|
+
<body>
|
|
11440
|
+
<main class="card">
|
|
11441
|
+
<div class="brand"><span class="dot"></span> ts-cloud</div>
|
|
11442
|
+
<h1>Sign in</h1>
|
|
11443
|
+
<p class="sub">Manage the sites you have been given access to.</p>
|
|
11444
|
+
|
|
11445
|
+
<form id="login" autocomplete="on">
|
|
11446
|
+
<div class="field">
|
|
11447
|
+
<label for="username">Username</label>
|
|
11448
|
+
<input id="username" name="username" autocomplete="username" required autofocus>
|
|
11449
|
+
</div>
|
|
11450
|
+
<div class="field">
|
|
11451
|
+
<label for="password">Password</label>
|
|
11452
|
+
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
|
11453
|
+
</div>
|
|
11454
|
+
<button type="submit" id="submit">Sign in</button>
|
|
11455
|
+
</form>
|
|
11456
|
+
|
|
11457
|
+
<p class="msg" id="msg" role="alert" aria-live="polite"></p>
|
|
11458
|
+
<p class="note">Lost the first admin password? Delete <code>.ts-cloud/dashboard-users.json</code> on the deploy host and restart the dashboard to mint a new one.</p>
|
|
11459
|
+
</main>
|
|
11460
|
+
|
|
11461
|
+
<script>
|
|
11462
|
+
const form = document.getElementById('login')
|
|
11463
|
+
const msg = document.getElementById('msg')
|
|
11464
|
+
const submit = document.getElementById('submit')
|
|
11465
|
+
|
|
11466
|
+
form.addEventListener('submit', async (event) => {
|
|
11467
|
+
event.preventDefault()
|
|
11468
|
+
msg.classList.remove('shown')
|
|
11469
|
+
submit.disabled = true
|
|
11470
|
+
submit.textContent = 'Signing in...'
|
|
11471
|
+
try {
|
|
11472
|
+
const res = await fetch('/api/login', {
|
|
11473
|
+
method: 'POST',
|
|
11474
|
+
headers: { 'content-type': 'application/json' },
|
|
11475
|
+
body: JSON.stringify({
|
|
11476
|
+
username: document.getElementById('username').value,
|
|
11477
|
+
password: document.getElementById('password').value,
|
|
11478
|
+
}),
|
|
11479
|
+
})
|
|
11480
|
+
const body = await res.json().catch(() => ({}))
|
|
11481
|
+
if (!res.ok || !body.ok) throw new Error(body.error || 'Could not sign in.')
|
|
11482
|
+
location.href = ${JSON.stringify(home)}
|
|
11483
|
+
} catch (error) {
|
|
11484
|
+
msg.textContent = (error && error.message) || String(error)
|
|
11485
|
+
msg.classList.add('shown')
|
|
11486
|
+
submit.disabled = false
|
|
11487
|
+
submit.textContent = 'Sign in'
|
|
11488
|
+
document.getElementById('password').value = ''
|
|
11489
|
+
document.getElementById('password').focus()
|
|
11490
|
+
}
|
|
11491
|
+
})
|
|
11492
|
+
</script>
|
|
11493
|
+
</body>
|
|
11494
|
+
</html>`;
|
|
11495
|
+
}
|
|
11496
|
+
|
|
10996
11497
|
// src/drivers/shared/compute-ops.ts
|
|
10997
11498
|
var noopLogger = {
|
|
10998
11499
|
info: () => {},
|
|
@@ -11270,6 +11771,69 @@ async function runServerShellCommand(config6, environment, command) {
|
|
|
11270
11771
|
};
|
|
11271
11772
|
}
|
|
11272
11773
|
|
|
11774
|
+
// src/deploy/dashboard-scope.ts
|
|
11775
|
+
function siteLogSources(slug, site) {
|
|
11776
|
+
return [`${slug}-${site}`, `${slug}-${site}-queues`, `${slug}-${site}-daemons`];
|
|
11777
|
+
}
|
|
11778
|
+
function isVisibleLogSource(source, slug, sites) {
|
|
11779
|
+
return sites.some((site) => siteLogSources(slug, site).includes(source));
|
|
11780
|
+
}
|
|
11781
|
+
function scopeDashboardData(data, options) {
|
|
11782
|
+
const { user, slug } = options;
|
|
11783
|
+
if (user.role === "admin")
|
|
11784
|
+
return data;
|
|
11785
|
+
const allSites = (data.sites ?? []).map((s) => s.name).filter(Boolean);
|
|
11786
|
+
const allowed = visibleSites(user, allSites);
|
|
11787
|
+
const allowedSet = new Set(allowed);
|
|
11788
|
+
const sites = (data.sites ?? []).filter((s) => allowedSet.has(s.name));
|
|
11789
|
+
const sitesDetail = (data.sitesDetail ?? []).filter((s) => allowedSet.has(s.name));
|
|
11790
|
+
const domains = new Set(sitesDetail.map((s) => s.domain).filter(Boolean));
|
|
11791
|
+
const deployments = (data.serverDeploymentsDetail ?? data.serverDeployments ?? []).filter((d) => allowedSet.has(d.site));
|
|
11792
|
+
const logs = (data.serverLogs ?? []).filter((log4) => isVisibleLogSource(log4.source, slug, allowed));
|
|
11793
|
+
const tlsCertificates = (data.security?.tlsCertificates ?? []).filter((cert) => domains.has(cert.domain));
|
|
11794
|
+
const activity = (data.activity ?? []).filter((entry) => {
|
|
11795
|
+
if (entry.type === "deploy")
|
|
11796
|
+
return allowed.some((site) => String(entry.title ?? "").startsWith(`${site} `));
|
|
11797
|
+
if (entry.type === "log")
|
|
11798
|
+
return isVisibleLogSource(String(entry.title ?? "").split(" ")[0], slug, allowed);
|
|
11799
|
+
return false;
|
|
11800
|
+
});
|
|
11801
|
+
return {
|
|
11802
|
+
mode: data.mode,
|
|
11803
|
+
environment: data.environment,
|
|
11804
|
+
environments: data.environments,
|
|
11805
|
+
scoped: true,
|
|
11806
|
+
server: { name: "" },
|
|
11807
|
+
sites,
|
|
11808
|
+
sitesDetail,
|
|
11809
|
+
workers: (data.workers ?? []).filter((w) => allowedSet.has(w.site)),
|
|
11810
|
+
serverDeployments: deployments.slice(0, 5),
|
|
11811
|
+
serverDeploymentsDetail: deployments.slice(0, 50),
|
|
11812
|
+
deploymentsEmptyReason: deployments.length ? undefined : "No deployments have been recorded for your sites yet.",
|
|
11813
|
+
serverLogs: logs,
|
|
11814
|
+
serverLogsEmptyReason: logs.length ? undefined : "No recent log entries for your sites.",
|
|
11815
|
+
security: {
|
|
11816
|
+
tlsCertificates,
|
|
11817
|
+
ports: [],
|
|
11818
|
+
firewall: null,
|
|
11819
|
+
authEvents: []
|
|
11820
|
+
},
|
|
11821
|
+
diagnostics: (data.diagnostics ?? []).filter((check) => ["Route conflicts", "TLS certificates"].includes(check.name)),
|
|
11822
|
+
activity
|
|
11823
|
+
};
|
|
11824
|
+
}
|
|
11825
|
+
function scopeCloudConfig(config6, user) {
|
|
11826
|
+
if (user.role === "admin")
|
|
11827
|
+
return config6;
|
|
11828
|
+
const allowed = new Set(visibleSites(user, Object.keys(config6.sites ?? {})));
|
|
11829
|
+
return {
|
|
11830
|
+
project: { name: config6.project?.name },
|
|
11831
|
+
environment: config6.environment,
|
|
11832
|
+
environments: config6.environments,
|
|
11833
|
+
sites: Object.fromEntries(Object.entries(config6.sites ?? {}).filter(([name]) => allowed.has(name)))
|
|
11834
|
+
};
|
|
11835
|
+
}
|
|
11836
|
+
|
|
11273
11837
|
// src/deploy/firewall-config-editor.ts
|
|
11274
11838
|
var ALWAYS_OPEN = new Set([22, 80, 443]);
|
|
11275
11839
|
function isValidPort(port) {
|
|
@@ -12085,7 +12649,7 @@ function createTerminalSession(onData, options = {}) {
|
|
|
12085
12649
|
var DEFAULT_HOST = "127.0.0.1";
|
|
12086
12650
|
var DEFAULT_PORT = 7676;
|
|
12087
12651
|
var MAX_OUTPUT_BYTES3 = 64 * 1024;
|
|
12088
|
-
var here =
|
|
12652
|
+
var here = dirname8(fileURLToPath(import.meta.url));
|
|
12089
12653
|
var contentTypes = {
|
|
12090
12654
|
".html": "text/html; charset=utf-8",
|
|
12091
12655
|
".css": "text/css; charset=utf-8",
|
|
@@ -12098,12 +12662,13 @@ var contentTypes = {
|
|
|
12098
12662
|
".webp": "image/webp",
|
|
12099
12663
|
".ico": "image/x-icon"
|
|
12100
12664
|
};
|
|
12101
|
-
function json(data, status = 200) {
|
|
12665
|
+
function json(data, status = 200, headers = {}) {
|
|
12102
12666
|
return new Response(JSON.stringify(data, null, 2), {
|
|
12103
12667
|
status,
|
|
12104
|
-
headers: { "content-type": "application/json; charset=utf-8" }
|
|
12668
|
+
headers: { "content-type": "application/json; charset=utf-8", ...headers }
|
|
12105
12669
|
});
|
|
12106
12670
|
}
|
|
12671
|
+
var DUMMY_HASH = hashPassword("ts-cloud-dummy-password");
|
|
12107
12672
|
function text(data, status = 200) {
|
|
12108
12673
|
return new Response(data, {
|
|
12109
12674
|
status,
|
|
@@ -12118,13 +12683,13 @@ function selectedEnvironment(config6, requested) {
|
|
|
12118
12683
|
}
|
|
12119
12684
|
async function loadLocalEnv(cwd) {
|
|
12120
12685
|
const candidates = [
|
|
12121
|
-
|
|
12122
|
-
|
|
12123
|
-
|
|
12124
|
-
|
|
12686
|
+
join12(here, "..", "..", "..", "..", ".env"),
|
|
12687
|
+
join12(cwd, ".env"),
|
|
12688
|
+
join12(cwd, ".env.local"),
|
|
12689
|
+
join12(cwd, ".env.production")
|
|
12125
12690
|
];
|
|
12126
12691
|
for (const file of candidates) {
|
|
12127
|
-
if (!
|
|
12692
|
+
if (!existsSync18(file))
|
|
12128
12693
|
continue;
|
|
12129
12694
|
const text2 = await readFile(file, "utf8").catch(() => "");
|
|
12130
12695
|
for (const line of text2.split(`
|
|
@@ -12145,12 +12710,12 @@ async function loadLocalEnv(cwd) {
|
|
|
12145
12710
|
}
|
|
12146
12711
|
function resolveCloudConfigPath(cwd) {
|
|
12147
12712
|
const candidates = [
|
|
12148
|
-
|
|
12149
|
-
|
|
12150
|
-
|
|
12151
|
-
|
|
12713
|
+
join12(cwd, "config", "cloud.ts"),
|
|
12714
|
+
join12(cwd, "config", "cloud.js"),
|
|
12715
|
+
join12(cwd, "cloud.config.ts"),
|
|
12716
|
+
join12(cwd, "cloud.config.js")
|
|
12152
12717
|
];
|
|
12153
|
-
return candidates.find((file) =>
|
|
12718
|
+
return candidates.find((file) => existsSync18(file)) ?? null;
|
|
12154
12719
|
}
|
|
12155
12720
|
function computeSshKeys(config6) {
|
|
12156
12721
|
return config6.infrastructure?.compute?.sshKeys ?? [];
|
|
@@ -12188,15 +12753,15 @@ async function resolveLiveDashboardData(config6, environment) {
|
|
|
12188
12753
|
}
|
|
12189
12754
|
function resolveUiSourceDir(cwd) {
|
|
12190
12755
|
const candidates = [
|
|
12191
|
-
|
|
12192
|
-
|
|
12193
|
-
|
|
12194
|
-
|
|
12195
|
-
|
|
12196
|
-
|
|
12756
|
+
join12(cwd, "packages", "ui"),
|
|
12757
|
+
join12(here, "..", "..", "..", "ui"),
|
|
12758
|
+
join12(here, "..", "..", "ui"),
|
|
12759
|
+
join12(here, "..", "ui-src"),
|
|
12760
|
+
join12(here, "..", "..", "ui-src"),
|
|
12761
|
+
join12(here, "..", "..", "dist", "ui-src")
|
|
12197
12762
|
];
|
|
12198
12763
|
for (const dir of candidates) {
|
|
12199
|
-
if (
|
|
12764
|
+
if (existsSync18(join12(dir, "pages")) && existsSync18(join12(dir, "package.json")))
|
|
12200
12765
|
return dir;
|
|
12201
12766
|
}
|
|
12202
12767
|
return null;
|
|
@@ -12206,9 +12771,9 @@ async function buildLiveUi(cwd, data) {
|
|
|
12206
12771
|
if (!uiDir)
|
|
12207
12772
|
return null;
|
|
12208
12773
|
try {
|
|
12209
|
-
const outDir = mkdtempSync(
|
|
12210
|
-
const localStx =
|
|
12211
|
-
const cmd =
|
|
12774
|
+
const outDir = mkdtempSync(join12(tmpdir(), "ts-cloud-dashboard-"));
|
|
12775
|
+
const localStx = join12(uiDir, "node_modules", ".bin", "stx");
|
|
12776
|
+
const cmd = existsSync18(localStx) ? [localStx, "build", "--pages", "pages", "--out", outDir, "--no-sitemap", "--no-cache"] : ["bunx", "--bun", "@stacksjs/stx", "build", "--pages", "pages", "--out", outDir, "--no-sitemap", "--no-cache"];
|
|
12212
12777
|
const proc = Bun.spawn(cmd, {
|
|
12213
12778
|
cwd: uiDir,
|
|
12214
12779
|
stdout: "pipe",
|
|
@@ -12341,19 +12906,33 @@ async function runAction(action, options) {
|
|
|
12341
12906
|
stderr: clampOutput3(stderr)
|
|
12342
12907
|
};
|
|
12343
12908
|
}
|
|
12909
|
+
var MEMBER_PAGES = new Set([
|
|
12910
|
+
"/server/sites",
|
|
12911
|
+
"/server/deployments",
|
|
12912
|
+
"/server/logs"
|
|
12913
|
+
]);
|
|
12914
|
+
function isBoxOnlyPage(pathname) {
|
|
12915
|
+
const ext = extname2(pathname);
|
|
12916
|
+
if (ext && ext !== ".html")
|
|
12917
|
+
return false;
|
|
12918
|
+
const clean = pathname.replace(/\.html$/, "").replace(/\/+$/, "") || "/";
|
|
12919
|
+
if (clean === "/")
|
|
12920
|
+
return false;
|
|
12921
|
+
return !MEMBER_PAGES.has(clean);
|
|
12922
|
+
}
|
|
12344
12923
|
function staticPath(uiRoot, pathname) {
|
|
12345
12924
|
const clean = decodeURIComponent(pathname).replace(/^\/+/, "");
|
|
12346
12925
|
const wanted = clean === "" ? "index.html" : clean;
|
|
12347
12926
|
const normalized = normalize(wanted);
|
|
12348
12927
|
if (normalized.startsWith("..") || normalized.includes("/../"))
|
|
12349
12928
|
return null;
|
|
12350
|
-
const base =
|
|
12351
|
-
if (
|
|
12929
|
+
const base = join12(uiRoot, normalized);
|
|
12930
|
+
if (existsSync18(base) && !statSync3(base).isDirectory())
|
|
12352
12931
|
return base;
|
|
12353
|
-
if (!extname2(base) &&
|
|
12932
|
+
if (!extname2(base) && existsSync18(`${base}.html`))
|
|
12354
12933
|
return `${base}.html`;
|
|
12355
|
-
if (!extname2(base) &&
|
|
12356
|
-
return
|
|
12934
|
+
if (!extname2(base) && existsSync18(join12(base, "index.html")))
|
|
12935
|
+
return join12(base, "index.html");
|
|
12357
12936
|
return null;
|
|
12358
12937
|
}
|
|
12359
12938
|
async function serveStatic(uiRoot, pathname) {
|
|
@@ -12381,13 +12960,48 @@ async function startLocalDashboardServer(options = {}) {
|
|
|
12381
12960
|
const configPath = resolveCloudConfigPath(cwd);
|
|
12382
12961
|
const initialData = await resolveLiveDashboardData(config6, environment);
|
|
12383
12962
|
let latestData = initialData;
|
|
12384
|
-
const liveUiRoot = await buildLiveUi(cwd, initialData);
|
|
12385
12963
|
const packagedUi = resolveUiSource(cwd);
|
|
12386
|
-
|
|
12387
|
-
|
|
12964
|
+
const uiCache = new Map;
|
|
12965
|
+
const scopeKey = (user) => user.role === "admin" ? "admin" : `member:${Object.entries(user.sites).sort(([a], [b]) => a.localeCompare(b)).map(([site, role]) => `${site}=${role}`).join(",")}`;
|
|
12966
|
+
async function uiRootFor(user) {
|
|
12967
|
+
const key = scopeKey(user);
|
|
12968
|
+
const cached = uiCache.get(key);
|
|
12969
|
+
if (cached)
|
|
12970
|
+
return cached;
|
|
12971
|
+
const scoped = {
|
|
12972
|
+
...scopeDashboardData(latestData, { user, slug: config6.project.slug }),
|
|
12973
|
+
viewer: { role: user.role }
|
|
12974
|
+
};
|
|
12975
|
+
const root = await buildLiveUi(cwd, scoped) ?? packagedUi?.uiRoot;
|
|
12976
|
+
if (root)
|
|
12977
|
+
uiCache.set(key, root);
|
|
12978
|
+
return root;
|
|
12979
|
+
}
|
|
12980
|
+
const adminUiRoot = await uiRootFor({ username: "", passwordHash: "", role: "admin", sites: {} });
|
|
12981
|
+
if (!adminUiRoot)
|
|
12388
12982
|
throw new Error("ts-cloud dashboard UI not found. Run `bun run build` in ts-cloud or reinstall the package.");
|
|
12389
12983
|
const terminalSessions = new WeakMap;
|
|
12390
12984
|
const terminalEnabled = process.env.TS_CLOUD_DASHBOARD_TERMINAL !== "0";
|
|
12985
|
+
const authDisabled = process.env.TS_CLOUD_DASHBOARD_AUTH === "0";
|
|
12986
|
+
if (authDisabled && options.box)
|
|
12987
|
+
throw new Error("TS_CLOUD_DASHBOARD_AUTH=0 is refused in box mode: the dashboard is internet-facing there, and disabling auth would expose a root shell.");
|
|
12988
|
+
const authEnabled = !authDisabled;
|
|
12989
|
+
const secret = resolveSessionSecret(cwd);
|
|
12990
|
+
const guard = createDashboardGuard({ cwd, enabled: authEnabled, secret });
|
|
12991
|
+
if (authEnabled) {
|
|
12992
|
+
const bootstrap = ensureAdminUser(cwd, process.env.TS_CLOUD_UI_USERNAME?.trim() || "admin");
|
|
12993
|
+
if (bootstrap.generated) {
|
|
12994
|
+
console.warn(`
|
|
12995
|
+
ts-cloud dashboard: created the first admin.
|
|
12996
|
+
username: ${bootstrap.generated.username}
|
|
12997
|
+
password: ${bootstrap.generated.password}
|
|
12998
|
+
Saved (hashed) to .ts-cloud/dashboard-users.json. This password is shown once.
|
|
12999
|
+
`);
|
|
13000
|
+
}
|
|
13001
|
+
} else {
|
|
13002
|
+
console.warn(" ts-cloud dashboard: authentication is DISABLED (TS_CLOUD_DASHBOARD_AUTH=0). Every request runs as an admin.");
|
|
13003
|
+
}
|
|
13004
|
+
const cookieSecure = host !== "127.0.0.1" && host !== "localhost";
|
|
12391
13005
|
const server = Bun.serve({
|
|
12392
13006
|
hostname: host,
|
|
12393
13007
|
port,
|
|
@@ -12422,6 +13036,45 @@ async function startLocalDashboardServer(options = {}) {
|
|
|
12422
13036
|
async fetch(req, server2) {
|
|
12423
13037
|
const url = new URL(req.url);
|
|
12424
13038
|
try {
|
|
13039
|
+
if (url.pathname === "/api/login" && req.method === "POST") {
|
|
13040
|
+
const body = await readJsonBody(req);
|
|
13041
|
+
const username = String(body.username ?? "").trim();
|
|
13042
|
+
const password = String(body.password ?? "");
|
|
13043
|
+
const user2 = findUser(loadUsers(cwd), username);
|
|
13044
|
+
const hash = user2?.passwordHash || DUMMY_HASH;
|
|
13045
|
+
const ok = verifyPassword(password, hash) && !!user2;
|
|
13046
|
+
if (!ok)
|
|
13047
|
+
return json({ ok: false, error: "Incorrect username or password." }, 401);
|
|
13048
|
+
const token = createSessionToken(user2.username, secret);
|
|
13049
|
+
return json({ ok: true, user: describeUser(user2) }, 200, {
|
|
13050
|
+
"set-cookie": serializeSessionCookie(token, { secure: cookieSecure })
|
|
13051
|
+
});
|
|
13052
|
+
}
|
|
13053
|
+
if (url.pathname === "/api/logout" && req.method === "POST") {
|
|
13054
|
+
const cookie = clearSessionCookie({ secure: cookieSecure });
|
|
13055
|
+
if (req.headers.get("accept")?.includes("text/html"))
|
|
13056
|
+
return new Response(null, { status: 302, headers: { location: "/login", "set-cookie": cookie } });
|
|
13057
|
+
return json({ ok: true }, 200, { "set-cookie": cookie });
|
|
13058
|
+
}
|
|
13059
|
+
if (url.pathname === "/login") {
|
|
13060
|
+
return new Response(renderLoginPage(latestData?.mode === "serverless"), {
|
|
13061
|
+
headers: { "content-type": "text/html; charset=utf-8" }
|
|
13062
|
+
});
|
|
13063
|
+
}
|
|
13064
|
+
const user = guard.resolveUser(req);
|
|
13065
|
+
if (!user) {
|
|
13066
|
+
if (!url.pathname.startsWith("/api/") && req.headers.get("accept")?.includes("text/html"))
|
|
13067
|
+
return new Response(null, { status: 302, headers: { location: "/login" } });
|
|
13068
|
+
return json({ ok: false, error: "Sign in to continue." }, 401);
|
|
13069
|
+
}
|
|
13070
|
+
if (url.pathname === "/api/me")
|
|
13071
|
+
return json({ ok: true, user: describeUser(user), authEnabled });
|
|
13072
|
+
if (url.pathname.startsWith("/api/")) {
|
|
13073
|
+
const site = await siteFromRequest(req, url.pathname);
|
|
13074
|
+
const decision = guard.check(req, url.pathname, user, site);
|
|
13075
|
+
if (!decision.ok)
|
|
13076
|
+
return json({ ok: false, error: decision.error }, decision.status ?? 403);
|
|
13077
|
+
}
|
|
12425
13078
|
if (url.pathname === "/api/terminal") {
|
|
12426
13079
|
if (!terminalEnabled)
|
|
12427
13080
|
return json({ ok: false, error: "The web terminal is disabled." }, 403);
|
|
@@ -12435,12 +13088,14 @@ async function startLocalDashboardServer(options = {}) {
|
|
|
12435
13088
|
cwd,
|
|
12436
13089
|
environment,
|
|
12437
13090
|
environments: availableEnvironments,
|
|
12438
|
-
uiRoot:
|
|
12439
|
-
liveData:
|
|
13091
|
+
uiRoot: await uiRootFor(user),
|
|
13092
|
+
liveData: uiCache.size > 0,
|
|
12440
13093
|
terminal: terminalEnabled,
|
|
12441
13094
|
localPackage: import.meta.url.includes("/Code/Libraries/ts-cloud/")
|
|
12442
13095
|
});
|
|
12443
13096
|
}
|
|
13097
|
+
if (url.pathname === "/" && user.role === "member")
|
|
13098
|
+
return new Response(null, { status: 302, headers: { location: "/server/sites" } });
|
|
12444
13099
|
if (url.pathname === "/" && latestData?.mode === "serverless")
|
|
12445
13100
|
return new Response(null, { status: 302, headers: { location: "/serverless" } });
|
|
12446
13101
|
if (url.pathname === "/api/env" && req.method === "POST") {
|
|
@@ -12452,14 +13107,14 @@ async function startLocalDashboardServer(options = {}) {
|
|
|
12452
13107
|
environment = requested;
|
|
12453
13108
|
actions = dashboardActions(environment);
|
|
12454
13109
|
latestData = await resolveLiveDashboardData(config6, environment);
|
|
12455
|
-
|
|
12456
|
-
if (rebuilt)
|
|
12457
|
-
activeUiRoot = rebuilt;
|
|
13110
|
+
uiCache.clear();
|
|
12458
13111
|
}
|
|
12459
13112
|
return json({ ok: true, environment, environments: availableEnvironments });
|
|
12460
13113
|
}
|
|
12461
|
-
if (url.pathname === "/api/config")
|
|
12462
|
-
|
|
13114
|
+
if (url.pathname === "/api/config") {
|
|
13115
|
+
const sanitized = { ...sanitizeCloudConfig(config6), environment, environments: availableEnvironments };
|
|
13116
|
+
return json(scopeCloudConfig(sanitized, user));
|
|
13117
|
+
}
|
|
12463
13118
|
if (url.pathname === "/api/ssh-keys" && req.method === "GET") {
|
|
12464
13119
|
return json({
|
|
12465
13120
|
configPath,
|
|
@@ -12686,7 +13341,42 @@ async function startLocalDashboardServer(options = {}) {
|
|
|
12686
13341
|
}
|
|
12687
13342
|
if (url.pathname === "/api/dashboard-data") {
|
|
12688
13343
|
latestData = await resolveLiveDashboardData(config6, environment);
|
|
12689
|
-
return json(latestData);
|
|
13344
|
+
return json(scopeDashboardData(latestData, { user, slug: config6.project.slug }));
|
|
13345
|
+
}
|
|
13346
|
+
if (url.pathname === "/api/users" && req.method === "GET")
|
|
13347
|
+
return json({ ok: true, users: loadUsers(cwd).map(describeUser) });
|
|
13348
|
+
if (url.pathname === "/api/users" && req.method === "POST") {
|
|
13349
|
+
const body = await readJsonBody(req);
|
|
13350
|
+
const username = String(body.username ?? "").trim();
|
|
13351
|
+
if (!isValidUsername(username))
|
|
13352
|
+
return json({ ok: false, error: "Username must be 2-32 characters: letters, numbers, dot, dash or underscore." }, 422);
|
|
13353
|
+
const requested = body.sites && typeof body.sites === "object" ? body.sites : {};
|
|
13354
|
+
const known = new Set(Object.keys(config6.sites ?? {}));
|
|
13355
|
+
const sites = {};
|
|
13356
|
+
for (const [site, role] of Object.entries(requested)) {
|
|
13357
|
+
if (!known.has(site))
|
|
13358
|
+
return json({ ok: false, error: `Site '${site}' was not found.` }, 404);
|
|
13359
|
+
if (role !== "owner" && role !== "collaborator")
|
|
13360
|
+
return json({ ok: false, error: `Unknown site role '${role}'. Use 'owner' or 'collaborator'.` }, 422);
|
|
13361
|
+
sites[site] = role;
|
|
13362
|
+
}
|
|
13363
|
+
const password = typeof body.password === "string" && body.password.trim() ? body.password.trim() : undefined;
|
|
13364
|
+
if (password && password.length < 12)
|
|
13365
|
+
return json({ ok: false, error: "Password must be at least 12 characters." }, 422);
|
|
13366
|
+
const result = upsertMember(cwd, { username, password, name: typeof body.name === "string" ? body.name : undefined, sites });
|
|
13367
|
+
uiCache.clear();
|
|
13368
|
+
return json({ ok: true, user: describeUser(result.user), password: result.password });
|
|
13369
|
+
}
|
|
13370
|
+
if (url.pathname === "/api/users" && req.method === "DELETE") {
|
|
13371
|
+
const body = await readJsonBody(req);
|
|
13372
|
+
const username = String(body.username ?? "").trim();
|
|
13373
|
+
if (username.toLowerCase() === user.username.toLowerCase())
|
|
13374
|
+
return json({ ok: false, error: "You cannot remove your own account." }, 409);
|
|
13375
|
+
const result = removeUser(cwd, username);
|
|
13376
|
+
if (!result.ok)
|
|
13377
|
+
return json(result, 409);
|
|
13378
|
+
uiCache.clear();
|
|
13379
|
+
return json({ ok: true, username });
|
|
12690
13380
|
}
|
|
12691
13381
|
if (url.pathname === "/api/actions/run" && req.method === "POST") {
|
|
12692
13382
|
const body = await req.json().catch(() => ({}));
|
|
@@ -12809,15 +13499,18 @@ async function startLocalDashboardServer(options = {}) {
|
|
|
12809
13499
|
return json(await controlScheduler(config6, environment, action));
|
|
12810
13500
|
}
|
|
12811
13501
|
const requestedEnv = url.searchParams.get("env");
|
|
12812
|
-
if (requestedEnv && availableEnvironments.includes(requestedEnv) && requestedEnv !== environment) {
|
|
13502
|
+
if (requestedEnv && availableEnvironments.includes(requestedEnv) && requestedEnv !== environment && authorize({ user, capability: "box:config" })) {
|
|
12813
13503
|
environment = requestedEnv;
|
|
12814
13504
|
actions = dashboardActions(environment);
|
|
12815
13505
|
latestData = await resolveLiveDashboardData(config6, environment);
|
|
12816
|
-
|
|
12817
|
-
|
|
12818
|
-
|
|
12819
|
-
|
|
12820
|
-
|
|
13506
|
+
uiCache.clear();
|
|
13507
|
+
}
|
|
13508
|
+
if (user.role === "member" && isBoxOnlyPage(url.pathname))
|
|
13509
|
+
return new Response(null, { status: 302, headers: { location: "/server/sites" } });
|
|
13510
|
+
const uiRoot = await uiRootFor(user);
|
|
13511
|
+
if (!uiRoot)
|
|
13512
|
+
return text("ts-cloud dashboard UI is unavailable.", 503);
|
|
13513
|
+
return serveStatic(uiRoot, url.pathname);
|
|
12821
13514
|
} catch (error2) {
|
|
12822
13515
|
if (options.verbose)
|
|
12823
13516
|
console.error(error2);
|