@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.
Files changed (59) hide show
  1. package/dist/bin/cli.js +697 -571
  2. package/dist/{chunk-h4pdjzq2.js → chunk-nt2hrnva.js} +744 -52
  3. package/dist/{chunk-vpyd42tp.js → chunk-xgnxz5dz.js} +98 -34
  4. package/dist/deploy/dashboard-auth.d.ts +82 -0
  5. package/dist/deploy/dashboard-auth.test.d.ts +1 -0
  6. package/dist/deploy/dashboard-guard.d.ts +40 -0
  7. package/dist/deploy/dashboard-login-page.d.ts +16 -0
  8. package/dist/deploy/dashboard-pages.test.d.ts +1 -0
  9. package/dist/deploy/dashboard-policy.d.ts +37 -0
  10. package/dist/deploy/dashboard-policy.test.d.ts +1 -0
  11. package/dist/deploy/dashboard-scope.d.ts +24 -0
  12. package/dist/deploy/dashboard-scope.test.d.ts +1 -0
  13. package/dist/deploy/dashboard-session.d.ts +52 -0
  14. package/dist/deploy/dashboard-users.d.ts +60 -0
  15. package/dist/deploy/index.js +2 -2
  16. package/dist/deploy/local-dashboard-server.d.ts +1 -0
  17. package/dist/drivers/hetzner/client.d.ts +9 -1
  18. package/dist/drivers/hetzner/config.d.ts +83 -0
  19. package/dist/drivers/hetzner/config.test.d.ts +1 -0
  20. package/dist/drivers/hetzner/factory-wiring.test.d.ts +1 -0
  21. package/dist/drivers/index.js +1 -1
  22. package/dist/index.js +2 -2
  23. package/dist/ui/index.html +4 -4
  24. package/dist/ui/server/actions.html +4 -4
  25. package/dist/ui/server/activity.html +1 -1
  26. package/dist/ui/server/backups.html +4 -4
  27. package/dist/ui/server/database.html +4 -4
  28. package/dist/ui/server/deployments.html +4 -4
  29. package/dist/ui/server/diagnostics.html +1 -1
  30. package/dist/ui/server/firewall.html +4 -4
  31. package/dist/ui/server/logs.html +4 -4
  32. package/dist/ui/server/metrics.html +1 -1
  33. package/dist/ui/server/security.html +1 -1
  34. package/dist/ui/server/services.html +4 -4
  35. package/dist/ui/server/sites.html +4 -4
  36. package/dist/ui/server/ssh-keys.html +4 -4
  37. package/dist/ui/server/team.html +1214 -0
  38. package/dist/ui/server/terminal.html +4 -4
  39. package/dist/ui/server/workers.html +4 -4
  40. package/dist/ui/serverless/alarms.html +4 -4
  41. package/dist/ui/serverless/assets.html +4 -4
  42. package/dist/ui/serverless/cost.html +1 -1
  43. package/dist/ui/serverless/data.html +4 -4
  44. package/dist/ui/serverless/deployments.html +4 -4
  45. package/dist/ui/serverless/firewall.html +1 -1
  46. package/dist/ui/serverless/functions.html +4 -4
  47. package/dist/ui/serverless/logs.html +4 -4
  48. package/dist/ui/serverless/metrics.html +1 -1
  49. package/dist/ui/serverless/queues.html +4 -4
  50. package/dist/ui/serverless/scheduler.html +4 -4
  51. package/dist/ui/serverless/secrets.html +4 -4
  52. package/dist/ui/serverless/traces.html +4 -4
  53. package/dist/ui/serverless.html +4 -4
  54. package/dist/ui-src/pages/partials/head.stx +7 -0
  55. package/dist/ui-src/pages/partials/nav.stx +43 -6
  56. package/dist/ui-src/pages/server/firewall.stx +1 -1
  57. package/dist/ui-src/pages/server/sites.stx +42 -20
  58. package/dist/ui-src/pages/server/team.stx +171 -0
  59. 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-vpyd42tp.js";
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 existsSync16, statSync as statSync3 } from "node:fs";
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 dirname5, extname as extname2, join as join10, normalize } from "node:path";
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
- const provider = configuredProvider(config6);
10161
- if (provider === "hetzner") {
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 = dirname5(fileURLToPath(import.meta.url));
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
- join10(here, "..", "..", "..", "..", ".env"),
12122
- join10(cwd, ".env"),
12123
- join10(cwd, ".env.local"),
12124
- join10(cwd, ".env.production")
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 (!existsSync16(file))
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
- join10(cwd, "config", "cloud.ts"),
12149
- join10(cwd, "config", "cloud.js"),
12150
- join10(cwd, "cloud.config.ts"),
12151
- join10(cwd, "cloud.config.js")
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) => existsSync16(file)) ?? null;
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
- join10(cwd, "packages", "ui"),
12192
- join10(here, "..", "..", "..", "ui"),
12193
- join10(here, "..", "..", "ui"),
12194
- join10(here, "..", "ui-src"),
12195
- join10(here, "..", "..", "ui-src"),
12196
- join10(here, "..", "..", "dist", "ui-src")
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 (existsSync16(join10(dir, "pages")) && existsSync16(join10(dir, "package.json")))
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(join10(tmpdir(), "ts-cloud-dashboard-"));
12210
- const localStx = join10(uiDir, "node_modules", ".bin", "stx");
12211
- const cmd = existsSync16(localStx) ? [localStx, "build", "--pages", "pages", "--out", outDir, "--no-sitemap", "--no-cache"] : ["bunx", "--bun", "@stacksjs/stx", "build", "--pages", "pages", "--out", outDir, "--no-sitemap", "--no-cache"];
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 = join10(uiRoot, normalized);
12351
- if (existsSync16(base) && !statSync3(base).isDirectory())
12928
+ const base = join12(uiRoot, normalized);
12929
+ if (existsSync18(base) && !statSync3(base).isDirectory())
12352
12930
  return base;
12353
- if (!extname2(base) && existsSync16(`${base}.html`))
12931
+ if (!extname2(base) && existsSync18(`${base}.html`))
12354
12932
  return `${base}.html`;
12355
- if (!extname2(base) && existsSync16(join10(base, "index.html")))
12356
- return join10(base, "index.html");
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
- let activeUiRoot = liveUiRoot ?? packagedUi?.uiRoot;
12387
- if (!activeUiRoot)
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: activeUiRoot,
12439
- liveData: !!liveUiRoot,
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
- const rebuilt = await buildLiveUi(cwd, latestData);
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
- return json({ ...sanitizeCloudConfig(config6), environment, environments: availableEnvironments });
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
- const rebuilt = await buildLiveUi(cwd, latestData);
12817
- if (rebuilt)
12818
- activeUiRoot = rebuilt;
12819
- }
12820
- return serveStatic(activeUiRoot, url.pathname);
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);