@stacksjs/ts-cloud 0.7.18 → 0.7.20

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