@stacksjs/ts-cloud 0.7.18 → 0.7.19
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-nt2hrnva.js} +744 -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/firewall.stx +1 -1
- 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,68 @@ 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
|
+
sites,
|
|
11807
|
+
sitesDetail,
|
|
11808
|
+
workers: (data.workers ?? []).filter((w) => allowedSet.has(w.site)),
|
|
11809
|
+
serverDeployments: deployments.slice(0, 5),
|
|
11810
|
+
serverDeploymentsDetail: deployments.slice(0, 50),
|
|
11811
|
+
deploymentsEmptyReason: deployments.length ? undefined : "No deployments have been recorded for your sites yet.",
|
|
11812
|
+
serverLogs: logs,
|
|
11813
|
+
serverLogsEmptyReason: logs.length ? undefined : "No recent log entries for your sites.",
|
|
11814
|
+
security: {
|
|
11815
|
+
tlsCertificates,
|
|
11816
|
+
ports: [],
|
|
11817
|
+
firewall: null,
|
|
11818
|
+
authEvents: []
|
|
11819
|
+
},
|
|
11820
|
+
diagnostics: (data.diagnostics ?? []).filter((check) => ["Route conflicts", "TLS certificates"].includes(check.name)),
|
|
11821
|
+
activity
|
|
11822
|
+
};
|
|
11823
|
+
}
|
|
11824
|
+
function scopeCloudConfig(config6, user) {
|
|
11825
|
+
if (user.role === "admin")
|
|
11826
|
+
return config6;
|
|
11827
|
+
const allowed = new Set(visibleSites(user, Object.keys(config6.sites ?? {})));
|
|
11828
|
+
return {
|
|
11829
|
+
project: { name: config6.project?.name },
|
|
11830
|
+
environment: config6.environment,
|
|
11831
|
+
environments: config6.environments,
|
|
11832
|
+
sites: Object.fromEntries(Object.entries(config6.sites ?? {}).filter(([name]) => allowed.has(name)))
|
|
11833
|
+
};
|
|
11834
|
+
}
|
|
11835
|
+
|
|
11273
11836
|
// src/deploy/firewall-config-editor.ts
|
|
11274
11837
|
var ALWAYS_OPEN = new Set([22, 80, 443]);
|
|
11275
11838
|
function isValidPort(port) {
|
|
@@ -12085,7 +12648,7 @@ function createTerminalSession(onData, options = {}) {
|
|
|
12085
12648
|
var DEFAULT_HOST = "127.0.0.1";
|
|
12086
12649
|
var DEFAULT_PORT = 7676;
|
|
12087
12650
|
var MAX_OUTPUT_BYTES3 = 64 * 1024;
|
|
12088
|
-
var here =
|
|
12651
|
+
var here = dirname8(fileURLToPath(import.meta.url));
|
|
12089
12652
|
var contentTypes = {
|
|
12090
12653
|
".html": "text/html; charset=utf-8",
|
|
12091
12654
|
".css": "text/css; charset=utf-8",
|
|
@@ -12098,12 +12661,13 @@ var contentTypes = {
|
|
|
12098
12661
|
".webp": "image/webp",
|
|
12099
12662
|
".ico": "image/x-icon"
|
|
12100
12663
|
};
|
|
12101
|
-
function json(data, status = 200) {
|
|
12664
|
+
function json(data, status = 200, headers = {}) {
|
|
12102
12665
|
return new Response(JSON.stringify(data, null, 2), {
|
|
12103
12666
|
status,
|
|
12104
|
-
headers: { "content-type": "application/json; charset=utf-8" }
|
|
12667
|
+
headers: { "content-type": "application/json; charset=utf-8", ...headers }
|
|
12105
12668
|
});
|
|
12106
12669
|
}
|
|
12670
|
+
var DUMMY_HASH = hashPassword("ts-cloud-dummy-password");
|
|
12107
12671
|
function text(data, status = 200) {
|
|
12108
12672
|
return new Response(data, {
|
|
12109
12673
|
status,
|
|
@@ -12118,13 +12682,13 @@ function selectedEnvironment(config6, requested) {
|
|
|
12118
12682
|
}
|
|
12119
12683
|
async function loadLocalEnv(cwd) {
|
|
12120
12684
|
const candidates = [
|
|
12121
|
-
|
|
12122
|
-
|
|
12123
|
-
|
|
12124
|
-
|
|
12685
|
+
join12(here, "..", "..", "..", "..", ".env"),
|
|
12686
|
+
join12(cwd, ".env"),
|
|
12687
|
+
join12(cwd, ".env.local"),
|
|
12688
|
+
join12(cwd, ".env.production")
|
|
12125
12689
|
];
|
|
12126
12690
|
for (const file of candidates) {
|
|
12127
|
-
if (!
|
|
12691
|
+
if (!existsSync18(file))
|
|
12128
12692
|
continue;
|
|
12129
12693
|
const text2 = await readFile(file, "utf8").catch(() => "");
|
|
12130
12694
|
for (const line of text2.split(`
|
|
@@ -12145,12 +12709,12 @@ async function loadLocalEnv(cwd) {
|
|
|
12145
12709
|
}
|
|
12146
12710
|
function resolveCloudConfigPath(cwd) {
|
|
12147
12711
|
const candidates = [
|
|
12148
|
-
|
|
12149
|
-
|
|
12150
|
-
|
|
12151
|
-
|
|
12712
|
+
join12(cwd, "config", "cloud.ts"),
|
|
12713
|
+
join12(cwd, "config", "cloud.js"),
|
|
12714
|
+
join12(cwd, "cloud.config.ts"),
|
|
12715
|
+
join12(cwd, "cloud.config.js")
|
|
12152
12716
|
];
|
|
12153
|
-
return candidates.find((file) =>
|
|
12717
|
+
return candidates.find((file) => existsSync18(file)) ?? null;
|
|
12154
12718
|
}
|
|
12155
12719
|
function computeSshKeys(config6) {
|
|
12156
12720
|
return config6.infrastructure?.compute?.sshKeys ?? [];
|
|
@@ -12188,15 +12752,15 @@ async function resolveLiveDashboardData(config6, environment) {
|
|
|
12188
12752
|
}
|
|
12189
12753
|
function resolveUiSourceDir(cwd) {
|
|
12190
12754
|
const candidates = [
|
|
12191
|
-
|
|
12192
|
-
|
|
12193
|
-
|
|
12194
|
-
|
|
12195
|
-
|
|
12196
|
-
|
|
12755
|
+
join12(cwd, "packages", "ui"),
|
|
12756
|
+
join12(here, "..", "..", "..", "ui"),
|
|
12757
|
+
join12(here, "..", "..", "ui"),
|
|
12758
|
+
join12(here, "..", "ui-src"),
|
|
12759
|
+
join12(here, "..", "..", "ui-src"),
|
|
12760
|
+
join12(here, "..", "..", "dist", "ui-src")
|
|
12197
12761
|
];
|
|
12198
12762
|
for (const dir of candidates) {
|
|
12199
|
-
if (
|
|
12763
|
+
if (existsSync18(join12(dir, "pages")) && existsSync18(join12(dir, "package.json")))
|
|
12200
12764
|
return dir;
|
|
12201
12765
|
}
|
|
12202
12766
|
return null;
|
|
@@ -12206,9 +12770,9 @@ async function buildLiveUi(cwd, data) {
|
|
|
12206
12770
|
if (!uiDir)
|
|
12207
12771
|
return null;
|
|
12208
12772
|
try {
|
|
12209
|
-
const outDir = mkdtempSync(
|
|
12210
|
-
const localStx =
|
|
12211
|
-
const cmd =
|
|
12773
|
+
const outDir = mkdtempSync(join12(tmpdir(), "ts-cloud-dashboard-"));
|
|
12774
|
+
const localStx = join12(uiDir, "node_modules", ".bin", "stx");
|
|
12775
|
+
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
12776
|
const proc = Bun.spawn(cmd, {
|
|
12213
12777
|
cwd: uiDir,
|
|
12214
12778
|
stdout: "pipe",
|
|
@@ -12341,19 +12905,33 @@ async function runAction(action, options) {
|
|
|
12341
12905
|
stderr: clampOutput3(stderr)
|
|
12342
12906
|
};
|
|
12343
12907
|
}
|
|
12908
|
+
var MEMBER_PAGES = new Set([
|
|
12909
|
+
"/server/sites",
|
|
12910
|
+
"/server/deployments",
|
|
12911
|
+
"/server/logs"
|
|
12912
|
+
]);
|
|
12913
|
+
function isBoxOnlyPage(pathname) {
|
|
12914
|
+
const ext = extname2(pathname);
|
|
12915
|
+
if (ext && ext !== ".html")
|
|
12916
|
+
return false;
|
|
12917
|
+
const clean = pathname.replace(/\.html$/, "").replace(/\/+$/, "") || "/";
|
|
12918
|
+
if (clean === "/")
|
|
12919
|
+
return false;
|
|
12920
|
+
return !MEMBER_PAGES.has(clean);
|
|
12921
|
+
}
|
|
12344
12922
|
function staticPath(uiRoot, pathname) {
|
|
12345
12923
|
const clean = decodeURIComponent(pathname).replace(/^\/+/, "");
|
|
12346
12924
|
const wanted = clean === "" ? "index.html" : clean;
|
|
12347
12925
|
const normalized = normalize(wanted);
|
|
12348
12926
|
if (normalized.startsWith("..") || normalized.includes("/../"))
|
|
12349
12927
|
return null;
|
|
12350
|
-
const base =
|
|
12351
|
-
if (
|
|
12928
|
+
const base = join12(uiRoot, normalized);
|
|
12929
|
+
if (existsSync18(base) && !statSync3(base).isDirectory())
|
|
12352
12930
|
return base;
|
|
12353
|
-
if (!extname2(base) &&
|
|
12931
|
+
if (!extname2(base) && existsSync18(`${base}.html`))
|
|
12354
12932
|
return `${base}.html`;
|
|
12355
|
-
if (!extname2(base) &&
|
|
12356
|
-
return
|
|
12933
|
+
if (!extname2(base) && existsSync18(join12(base, "index.html")))
|
|
12934
|
+
return join12(base, "index.html");
|
|
12357
12935
|
return null;
|
|
12358
12936
|
}
|
|
12359
12937
|
async function serveStatic(uiRoot, pathname) {
|
|
@@ -12381,13 +12959,48 @@ async function startLocalDashboardServer(options = {}) {
|
|
|
12381
12959
|
const configPath = resolveCloudConfigPath(cwd);
|
|
12382
12960
|
const initialData = await resolveLiveDashboardData(config6, environment);
|
|
12383
12961
|
let latestData = initialData;
|
|
12384
|
-
const liveUiRoot = await buildLiveUi(cwd, initialData);
|
|
12385
12962
|
const packagedUi = resolveUiSource(cwd);
|
|
12386
|
-
|
|
12387
|
-
|
|
12963
|
+
const uiCache = new Map;
|
|
12964
|
+
const scopeKey = (user) => user.role === "admin" ? "admin" : `member:${Object.entries(user.sites).sort(([a], [b]) => a.localeCompare(b)).map(([site, role]) => `${site}=${role}`).join(",")}`;
|
|
12965
|
+
async function uiRootFor(user) {
|
|
12966
|
+
const key = scopeKey(user);
|
|
12967
|
+
const cached = uiCache.get(key);
|
|
12968
|
+
if (cached)
|
|
12969
|
+
return cached;
|
|
12970
|
+
const scoped = {
|
|
12971
|
+
...scopeDashboardData(latestData, { user, slug: config6.project.slug }),
|
|
12972
|
+
viewer: { role: user.role }
|
|
12973
|
+
};
|
|
12974
|
+
const root = await buildLiveUi(cwd, scoped) ?? packagedUi?.uiRoot;
|
|
12975
|
+
if (root)
|
|
12976
|
+
uiCache.set(key, root);
|
|
12977
|
+
return root;
|
|
12978
|
+
}
|
|
12979
|
+
const adminUiRoot = await uiRootFor({ username: "", passwordHash: "", role: "admin", sites: {} });
|
|
12980
|
+
if (!adminUiRoot)
|
|
12388
12981
|
throw new Error("ts-cloud dashboard UI not found. Run `bun run build` in ts-cloud or reinstall the package.");
|
|
12389
12982
|
const terminalSessions = new WeakMap;
|
|
12390
12983
|
const terminalEnabled = process.env.TS_CLOUD_DASHBOARD_TERMINAL !== "0";
|
|
12984
|
+
const authDisabled = process.env.TS_CLOUD_DASHBOARD_AUTH === "0";
|
|
12985
|
+
if (authDisabled && options.box)
|
|
12986
|
+
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.");
|
|
12987
|
+
const authEnabled = !authDisabled;
|
|
12988
|
+
const secret = resolveSessionSecret(cwd);
|
|
12989
|
+
const guard = createDashboardGuard({ cwd, enabled: authEnabled, secret });
|
|
12990
|
+
if (authEnabled) {
|
|
12991
|
+
const bootstrap = ensureAdminUser(cwd, process.env.TS_CLOUD_UI_USERNAME?.trim() || "admin");
|
|
12992
|
+
if (bootstrap.generated) {
|
|
12993
|
+
console.warn(`
|
|
12994
|
+
ts-cloud dashboard: created the first admin.
|
|
12995
|
+
username: ${bootstrap.generated.username}
|
|
12996
|
+
password: ${bootstrap.generated.password}
|
|
12997
|
+
Saved (hashed) to .ts-cloud/dashboard-users.json. This password is shown once.
|
|
12998
|
+
`);
|
|
12999
|
+
}
|
|
13000
|
+
} else {
|
|
13001
|
+
console.warn(" ts-cloud dashboard: authentication is DISABLED (TS_CLOUD_DASHBOARD_AUTH=0). Every request runs as an admin.");
|
|
13002
|
+
}
|
|
13003
|
+
const cookieSecure = host !== "127.0.0.1" && host !== "localhost";
|
|
12391
13004
|
const server = Bun.serve({
|
|
12392
13005
|
hostname: host,
|
|
12393
13006
|
port,
|
|
@@ -12422,6 +13035,45 @@ async function startLocalDashboardServer(options = {}) {
|
|
|
12422
13035
|
async fetch(req, server2) {
|
|
12423
13036
|
const url = new URL(req.url);
|
|
12424
13037
|
try {
|
|
13038
|
+
if (url.pathname === "/api/login" && req.method === "POST") {
|
|
13039
|
+
const body = await readJsonBody(req);
|
|
13040
|
+
const username = String(body.username ?? "").trim();
|
|
13041
|
+
const password = String(body.password ?? "");
|
|
13042
|
+
const user2 = findUser(loadUsers(cwd), username);
|
|
13043
|
+
const hash = user2?.passwordHash || DUMMY_HASH;
|
|
13044
|
+
const ok = verifyPassword(password, hash) && !!user2;
|
|
13045
|
+
if (!ok)
|
|
13046
|
+
return json({ ok: false, error: "Incorrect username or password." }, 401);
|
|
13047
|
+
const token = createSessionToken(user2.username, secret);
|
|
13048
|
+
return json({ ok: true, user: describeUser(user2) }, 200, {
|
|
13049
|
+
"set-cookie": serializeSessionCookie(token, { secure: cookieSecure })
|
|
13050
|
+
});
|
|
13051
|
+
}
|
|
13052
|
+
if (url.pathname === "/api/logout" && req.method === "POST") {
|
|
13053
|
+
const cookie = clearSessionCookie({ secure: cookieSecure });
|
|
13054
|
+
if (req.headers.get("accept")?.includes("text/html"))
|
|
13055
|
+
return new Response(null, { status: 302, headers: { location: "/login", "set-cookie": cookie } });
|
|
13056
|
+
return json({ ok: true }, 200, { "set-cookie": cookie });
|
|
13057
|
+
}
|
|
13058
|
+
if (url.pathname === "/login") {
|
|
13059
|
+
return new Response(renderLoginPage(latestData?.mode === "serverless"), {
|
|
13060
|
+
headers: { "content-type": "text/html; charset=utf-8" }
|
|
13061
|
+
});
|
|
13062
|
+
}
|
|
13063
|
+
const user = guard.resolveUser(req);
|
|
13064
|
+
if (!user) {
|
|
13065
|
+
if (!url.pathname.startsWith("/api/") && req.headers.get("accept")?.includes("text/html"))
|
|
13066
|
+
return new Response(null, { status: 302, headers: { location: "/login" } });
|
|
13067
|
+
return json({ ok: false, error: "Sign in to continue." }, 401);
|
|
13068
|
+
}
|
|
13069
|
+
if (url.pathname === "/api/me")
|
|
13070
|
+
return json({ ok: true, user: describeUser(user), authEnabled });
|
|
13071
|
+
if (url.pathname.startsWith("/api/")) {
|
|
13072
|
+
const site = await siteFromRequest(req, url.pathname);
|
|
13073
|
+
const decision = guard.check(req, url.pathname, user, site);
|
|
13074
|
+
if (!decision.ok)
|
|
13075
|
+
return json({ ok: false, error: decision.error }, decision.status ?? 403);
|
|
13076
|
+
}
|
|
12425
13077
|
if (url.pathname === "/api/terminal") {
|
|
12426
13078
|
if (!terminalEnabled)
|
|
12427
13079
|
return json({ ok: false, error: "The web terminal is disabled." }, 403);
|
|
@@ -12435,12 +13087,14 @@ async function startLocalDashboardServer(options = {}) {
|
|
|
12435
13087
|
cwd,
|
|
12436
13088
|
environment,
|
|
12437
13089
|
environments: availableEnvironments,
|
|
12438
|
-
uiRoot:
|
|
12439
|
-
liveData:
|
|
13090
|
+
uiRoot: await uiRootFor(user),
|
|
13091
|
+
liveData: uiCache.size > 0,
|
|
12440
13092
|
terminal: terminalEnabled,
|
|
12441
13093
|
localPackage: import.meta.url.includes("/Code/Libraries/ts-cloud/")
|
|
12442
13094
|
});
|
|
12443
13095
|
}
|
|
13096
|
+
if (url.pathname === "/" && user.role === "member")
|
|
13097
|
+
return new Response(null, { status: 302, headers: { location: "/server/sites" } });
|
|
12444
13098
|
if (url.pathname === "/" && latestData?.mode === "serverless")
|
|
12445
13099
|
return new Response(null, { status: 302, headers: { location: "/serverless" } });
|
|
12446
13100
|
if (url.pathname === "/api/env" && req.method === "POST") {
|
|
@@ -12452,14 +13106,14 @@ async function startLocalDashboardServer(options = {}) {
|
|
|
12452
13106
|
environment = requested;
|
|
12453
13107
|
actions = dashboardActions(environment);
|
|
12454
13108
|
latestData = await resolveLiveDashboardData(config6, environment);
|
|
12455
|
-
|
|
12456
|
-
if (rebuilt)
|
|
12457
|
-
activeUiRoot = rebuilt;
|
|
13109
|
+
uiCache.clear();
|
|
12458
13110
|
}
|
|
12459
13111
|
return json({ ok: true, environment, environments: availableEnvironments });
|
|
12460
13112
|
}
|
|
12461
|
-
if (url.pathname === "/api/config")
|
|
12462
|
-
|
|
13113
|
+
if (url.pathname === "/api/config") {
|
|
13114
|
+
const sanitized = { ...sanitizeCloudConfig(config6), environment, environments: availableEnvironments };
|
|
13115
|
+
return json(scopeCloudConfig(sanitized, user));
|
|
13116
|
+
}
|
|
12463
13117
|
if (url.pathname === "/api/ssh-keys" && req.method === "GET") {
|
|
12464
13118
|
return json({
|
|
12465
13119
|
configPath,
|
|
@@ -12686,7 +13340,42 @@ async function startLocalDashboardServer(options = {}) {
|
|
|
12686
13340
|
}
|
|
12687
13341
|
if (url.pathname === "/api/dashboard-data") {
|
|
12688
13342
|
latestData = await resolveLiveDashboardData(config6, environment);
|
|
12689
|
-
return json(latestData);
|
|
13343
|
+
return json(scopeDashboardData(latestData, { user, slug: config6.project.slug }));
|
|
13344
|
+
}
|
|
13345
|
+
if (url.pathname === "/api/users" && req.method === "GET")
|
|
13346
|
+
return json({ ok: true, users: loadUsers(cwd).map(describeUser) });
|
|
13347
|
+
if (url.pathname === "/api/users" && req.method === "POST") {
|
|
13348
|
+
const body = await readJsonBody(req);
|
|
13349
|
+
const username = String(body.username ?? "").trim();
|
|
13350
|
+
if (!isValidUsername(username))
|
|
13351
|
+
return json({ ok: false, error: "Username must be 2-32 characters: letters, numbers, dot, dash or underscore." }, 422);
|
|
13352
|
+
const requested = body.sites && typeof body.sites === "object" ? body.sites : {};
|
|
13353
|
+
const known = new Set(Object.keys(config6.sites ?? {}));
|
|
13354
|
+
const sites = {};
|
|
13355
|
+
for (const [site, role] of Object.entries(requested)) {
|
|
13356
|
+
if (!known.has(site))
|
|
13357
|
+
return json({ ok: false, error: `Site '${site}' was not found.` }, 404);
|
|
13358
|
+
if (role !== "owner" && role !== "collaborator")
|
|
13359
|
+
return json({ ok: false, error: `Unknown site role '${role}'. Use 'owner' or 'collaborator'.` }, 422);
|
|
13360
|
+
sites[site] = role;
|
|
13361
|
+
}
|
|
13362
|
+
const password = typeof body.password === "string" && body.password.trim() ? body.password.trim() : undefined;
|
|
13363
|
+
if (password && password.length < 12)
|
|
13364
|
+
return json({ ok: false, error: "Password must be at least 12 characters." }, 422);
|
|
13365
|
+
const result = upsertMember(cwd, { username, password, name: typeof body.name === "string" ? body.name : undefined, sites });
|
|
13366
|
+
uiCache.clear();
|
|
13367
|
+
return json({ ok: true, user: describeUser(result.user), password: result.password });
|
|
13368
|
+
}
|
|
13369
|
+
if (url.pathname === "/api/users" && req.method === "DELETE") {
|
|
13370
|
+
const body = await readJsonBody(req);
|
|
13371
|
+
const username = String(body.username ?? "").trim();
|
|
13372
|
+
if (username.toLowerCase() === user.username.toLowerCase())
|
|
13373
|
+
return json({ ok: false, error: "You cannot remove your own account." }, 409);
|
|
13374
|
+
const result = removeUser(cwd, username);
|
|
13375
|
+
if (!result.ok)
|
|
13376
|
+
return json(result, 409);
|
|
13377
|
+
uiCache.clear();
|
|
13378
|
+
return json({ ok: true, username });
|
|
12690
13379
|
}
|
|
12691
13380
|
if (url.pathname === "/api/actions/run" && req.method === "POST") {
|
|
12692
13381
|
const body = await req.json().catch(() => ({}));
|
|
@@ -12809,15 +13498,18 @@ async function startLocalDashboardServer(options = {}) {
|
|
|
12809
13498
|
return json(await controlScheduler(config6, environment, action));
|
|
12810
13499
|
}
|
|
12811
13500
|
const requestedEnv = url.searchParams.get("env");
|
|
12812
|
-
if (requestedEnv && availableEnvironments.includes(requestedEnv) && requestedEnv !== environment) {
|
|
13501
|
+
if (requestedEnv && availableEnvironments.includes(requestedEnv) && requestedEnv !== environment && authorize({ user, capability: "box:config" })) {
|
|
12813
13502
|
environment = requestedEnv;
|
|
12814
13503
|
actions = dashboardActions(environment);
|
|
12815
13504
|
latestData = await resolveLiveDashboardData(config6, environment);
|
|
12816
|
-
|
|
12817
|
-
|
|
12818
|
-
|
|
12819
|
-
|
|
12820
|
-
|
|
13505
|
+
uiCache.clear();
|
|
13506
|
+
}
|
|
13507
|
+
if (user.role === "member" && isBoxOnlyPage(url.pathname))
|
|
13508
|
+
return new Response(null, { status: 302, headers: { location: "/server/sites" } });
|
|
13509
|
+
const uiRoot = await uiRootFor(user);
|
|
13510
|
+
if (!uiRoot)
|
|
13511
|
+
return text("ts-cloud dashboard UI is unavailable.", 503);
|
|
13512
|
+
return serveStatic(uiRoot, url.pathname);
|
|
12821
13513
|
} catch (error2) {
|
|
12822
13514
|
if (options.verbose)
|
|
12823
13515
|
console.error(error2);
|