@stacksjs/ts-cloud 0.7.19 → 0.7.21

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 (35) hide show
  1. package/dist/bin/cli.js +536 -536
  2. package/dist/{chunk-nt2hrnva.js → chunk-eapb9et8.js} +155 -1
  3. package/dist/deploy/dashboard-site-settings.d.ts +55 -0
  4. package/dist/deploy/dashboard-site-settings.test.d.ts +1 -0
  5. package/dist/deploy/dashboard-throttle.d.ts +46 -0
  6. package/dist/deploy/dashboard-throttle.test.d.ts +1 -0
  7. package/dist/deploy/index.js +1 -1
  8. package/dist/index.js +1 -1
  9. package/dist/ui/index.html +3 -3
  10. package/dist/ui/server/actions.html +3 -3
  11. package/dist/ui/server/backups.html +3 -3
  12. package/dist/ui/server/database.html +3 -3
  13. package/dist/ui/server/deployments.html +3 -3
  14. package/dist/ui/server/firewall.html +3 -3
  15. package/dist/ui/server/logs.html +3 -3
  16. package/dist/ui/server/services.html +3 -3
  17. package/dist/ui/server/sites.html +3 -3
  18. package/dist/ui/server/ssh-keys.html +3 -3
  19. package/dist/ui/server/team.html +3 -3
  20. package/dist/ui/server/terminal.html +3 -3
  21. package/dist/ui/server/workers.html +3 -3
  22. package/dist/ui/serverless/alarms.html +3 -3
  23. package/dist/ui/serverless/assets.html +3 -3
  24. package/dist/ui/serverless/data.html +3 -3
  25. package/dist/ui/serverless/deployments.html +3 -3
  26. package/dist/ui/serverless/functions.html +3 -3
  27. package/dist/ui/serverless/logs.html +3 -3
  28. package/dist/ui/serverless/queues.html +3 -3
  29. package/dist/ui/serverless/scheduler.html +3 -3
  30. package/dist/ui/serverless/secrets.html +3 -3
  31. package/dist/ui/serverless/traces.html +3 -3
  32. package/dist/ui/serverless.html +3 -3
  33. package/dist/ui-src/pages/server/deployments.stx +20 -5
  34. package/dist/ui-src/pages/server/logs.stx +26 -8
  35. package/package.json +3 -3
@@ -11803,6 +11803,7 @@ function scopeDashboardData(data, options) {
11803
11803
  environment: data.environment,
11804
11804
  environments: data.environments,
11805
11805
  scoped: true,
11806
+ server: { name: "" },
11806
11807
  sites,
11807
11808
  sitesDetail,
11808
11809
  workers: (data.workers ?? []).filter((w) => allowedSet.has(w.site)),
@@ -11833,6 +11834,135 @@ function scopeCloudConfig(config6, user) {
11833
11834
  };
11834
11835
  }
11835
11836
 
11837
+ // src/deploy/dashboard-site-settings.ts
11838
+ var MEMBER_EDITABLE_SITE_FIELDS = new Set([
11839
+ "name",
11840
+ "ssl",
11841
+ "env",
11842
+ "redirects",
11843
+ "aliases",
11844
+ "domain",
11845
+ "path"
11846
+ ]);
11847
+ var ADMIN_ONLY_SITE_FIELDS = {
11848
+ build: "build runs as a shell command on the server",
11849
+ start: "start runs as a shell command on the server",
11850
+ root: "root is a filesystem path on the shared server",
11851
+ port: "ports are allocated by the server owner",
11852
+ type: "the deploy type is set by the server owner",
11853
+ php: "the PHP runtime is set by the server owner"
11854
+ };
11855
+ function checkMemberSiteFields(body) {
11856
+ const touched = Object.keys(body).filter((key) => body[key] !== undefined);
11857
+ const forbidden = touched.filter((key) => (key in ADMIN_ONLY_SITE_FIELDS));
11858
+ if (forbidden.length) {
11859
+ const reasons = forbidden.map((key) => `${key} (${ADMIN_ONLY_SITE_FIELDS[key]})`).join(", ");
11860
+ return { ok: false, error: `Only the server owner can change ${reasons}.` };
11861
+ }
11862
+ const unknown = touched.filter((key) => !MEMBER_EDITABLE_SITE_FIELDS.has(key));
11863
+ if (unknown.length)
11864
+ return { ok: false, error: `Only the server owner can change ${unknown.join(", ")}.` };
11865
+ return { ok: true };
11866
+ }
11867
+ function normalizeHost(value) {
11868
+ return String(value ?? "").trim().toLowerCase().replace(/\.$/, "");
11869
+ }
11870
+ function normalizePath(value) {
11871
+ const path = String(value ?? "/").trim();
11872
+ if (!path || path === "/")
11873
+ return "/";
11874
+ return (path.startsWith("/") ? path : `/${path}`).replace(/\/+$/, "") || "/";
11875
+ }
11876
+ function siteHosts(site) {
11877
+ const hosts = [normalizeHost(site?.domain)];
11878
+ if (Array.isArray(site?.aliases))
11879
+ hosts.push(...site.aliases.map(normalizeHost));
11880
+ return hosts.filter(Boolean);
11881
+ }
11882
+ function checkRouteConflict({ siteName, body, sites, ownSites }) {
11883
+ const current = sites[siteName] ?? {};
11884
+ const owned = new Set(ownSites);
11885
+ const wantedHosts = new Set;
11886
+ if (body.domain !== undefined)
11887
+ wantedHosts.add(normalizeHost(body.domain));
11888
+ if (Array.isArray(body.aliases))
11889
+ for (const alias of body.aliases)
11890
+ wantedHosts.add(normalizeHost(alias));
11891
+ wantedHosts.delete("");
11892
+ if (wantedHosts.size === 0)
11893
+ return { ok: true };
11894
+ const wantedPath = normalizePath(body.path !== undefined ? body.path : current.path);
11895
+ for (const [otherName, other] of Object.entries(sites)) {
11896
+ if (otherName === siteName || owned.has(otherName))
11897
+ continue;
11898
+ const otherPath = normalizePath(other?.path);
11899
+ for (const host of siteHosts(other)) {
11900
+ if (!wantedHosts.has(host))
11901
+ continue;
11902
+ if (otherPath === wantedPath) {
11903
+ return {
11904
+ ok: false,
11905
+ error: `${host}${wantedPath === "/" ? "" : wantedPath} is already served by another site on this server.`
11906
+ };
11907
+ }
11908
+ }
11909
+ }
11910
+ return { ok: true };
11911
+ }
11912
+
11913
+ // src/deploy/dashboard-throttle.ts
11914
+ var MAX_ATTEMPTS = 8;
11915
+ var LOCKOUT_MS = 15 * 60 * 1000;
11916
+ var WINDOW_MS = 15 * 60 * 1000;
11917
+
11918
+ class LoginThrottle {
11919
+ maxAttempts;
11920
+ lockoutMs;
11921
+ windowMs;
11922
+ attempts = new Map;
11923
+ constructor(maxAttempts = MAX_ATTEMPTS, lockoutMs = LOCKOUT_MS, windowMs = WINDOW_MS) {
11924
+ this.maxAttempts = maxAttempts;
11925
+ this.lockoutMs = lockoutMs;
11926
+ this.windowMs = windowMs;
11927
+ }
11928
+ key(username, address) {
11929
+ return `${username.trim().toLowerCase()}\x00${address}`;
11930
+ }
11931
+ check(username, address, now = Date.now()) {
11932
+ const entry = this.attempts.get(this.key(username, address));
11933
+ if (!entry?.until)
11934
+ return { allowed: true };
11935
+ if (now >= entry.until) {
11936
+ this.attempts.delete(this.key(username, address));
11937
+ return { allowed: true };
11938
+ }
11939
+ return { allowed: false, retryAfterSeconds: Math.ceil((entry.until - now) / 1000) };
11940
+ }
11941
+ recordFailure(username, address, now = Date.now()) {
11942
+ const key = this.key(username, address);
11943
+ const entry = this.attempts.get(key);
11944
+ const stale = !entry || now - entry.last > this.windowMs;
11945
+ const count = stale ? 1 : entry.count + 1;
11946
+ const next = { count, last: now };
11947
+ if (count >= this.maxAttempts)
11948
+ next.until = now + this.lockoutMs;
11949
+ this.attempts.set(key, next);
11950
+ }
11951
+ recordSuccess(username, address) {
11952
+ this.attempts.delete(this.key(username, address));
11953
+ }
11954
+ prune(now = Date.now()) {
11955
+ for (const [key, entry] of this.attempts) {
11956
+ const expired = entry.until ? now >= entry.until : now - entry.last > this.windowMs;
11957
+ if (expired)
11958
+ this.attempts.delete(key);
11959
+ }
11960
+ }
11961
+ get size() {
11962
+ return this.attempts.size;
11963
+ }
11964
+ }
11965
+
11836
11966
  // src/deploy/firewall-config-editor.ts
11837
11967
  var ALWAYS_OPEN = new Set([22, 80, 443]);
11838
11968
  function isValidPort(port) {
@@ -12987,6 +13117,9 @@ async function startLocalDashboardServer(options = {}) {
12987
13117
  const authEnabled = !authDisabled;
12988
13118
  const secret = resolveSessionSecret(cwd);
12989
13119
  const guard = createDashboardGuard({ cwd, enabled: authEnabled, secret });
13120
+ const throttle = new LoginThrottle;
13121
+ const throttleSweep = setInterval(() => throttle.prune(), 5 * 60 * 1000);
13122
+ throttleSweep.unref?.();
12990
13123
  if (authEnabled) {
12991
13124
  const bootstrap = ensureAdminUser(cwd, process.env.TS_CLOUD_UI_USERNAME?.trim() || "admin");
12992
13125
  if (bootstrap.generated) {
@@ -13039,11 +13172,19 @@ async function startLocalDashboardServer(options = {}) {
13039
13172
  const body = await readJsonBody(req);
13040
13173
  const username = String(body.username ?? "").trim();
13041
13174
  const password = String(body.password ?? "");
13175
+ const address = server2.requestIP(req)?.address ?? "unknown";
13176
+ const gate = throttle.check(username, address);
13177
+ if (!gate.allowed) {
13178
+ return json({ ok: false, error: `Too many failed attempts. Try again in ${Math.ceil((gate.retryAfterSeconds ?? 0) / 60)} minute(s).` }, 429, { "retry-after": String(gate.retryAfterSeconds ?? 60) });
13179
+ }
13042
13180
  const user2 = findUser(loadUsers(cwd), username);
13043
13181
  const hash = user2?.passwordHash || DUMMY_HASH;
13044
13182
  const ok = verifyPassword(password, hash) && !!user2;
13045
- if (!ok)
13183
+ if (!ok) {
13184
+ throttle.recordFailure(username, address);
13046
13185
  return json({ ok: false, error: "Incorrect username or password." }, 401);
13186
+ }
13187
+ throttle.recordSuccess(username, address);
13047
13188
  const token = createSessionToken(user2.username, secret);
13048
13189
  return json({ ok: true, user: describeUser(user2) }, 200, {
13049
13190
  "set-cookie": serializeSessionCookie(token, { secure: cookieSecure })
@@ -13247,6 +13388,19 @@ async function startLocalDashboardServer(options = {}) {
13247
13388
  const existing = config6.sites?.[name];
13248
13389
  if (!name || !existing)
13249
13390
  return json({ ok: false, error: `Site '${name}' was not found.` }, 404);
13391
+ if (user.role === "member") {
13392
+ const fields = checkMemberSiteFields(body);
13393
+ if (!fields.ok)
13394
+ return json({ ok: false, error: fields.error }, 403);
13395
+ const conflict = checkRouteConflict({
13396
+ siteName: name,
13397
+ body,
13398
+ sites: config6.sites ?? {},
13399
+ ownSites: Object.keys(user.sites)
13400
+ });
13401
+ if (!conflict.ok)
13402
+ return json({ ok: false, error: conflict.error }, 409);
13403
+ }
13250
13404
  if (body.port !== undefined && body.port !== null && body.port !== "" && (!Number.isInteger(Number(body.port)) || Number(body.port) < 1 || Number(body.port) > 65535))
13251
13405
  return json({ ok: false, error: "Port must be a number between 1 and 65535." }, 422);
13252
13406
  let text2 = await readFile(configPath, "utf8");
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Which site settings a member may change, and route-conflict checks.
3
+ *
4
+ * `site:settings` lets a site owner edit their own site — but a site's config
5
+ * is not all equally harmless on a box shared with other tenants:
6
+ *
7
+ * - `build` and `start` are **shell commands run on the box at deploy time**,
8
+ * as root. A member who could set them would own the whole server, and every
9
+ * other tenant's site with it.
10
+ * - `root` is a filesystem path. Pointed at another site's directory (or `/`),
11
+ * it serves someone else's files to the internet.
12
+ * - `port`, `type` and `php` pick runtime and bind ports, which are the box's
13
+ * to allocate, not one tenant's.
14
+ *
15
+ * Those stay with the box owner. What is left is genuinely the tenant's own:
16
+ * TLS, their app's env, their redirects, and their routing — and routing is
17
+ * still checked against other tenants, since claiming `example.com` when
18
+ * someone else already serves it is hijacking their traffic.
19
+ *
20
+ * An allowlist, not a blocklist: a site field added later is admin-only until
21
+ * someone decides it is safe to hand over.
22
+ */
23
+ /** Fields a member holding `site:settings` on the site may change. */
24
+ export declare const MEMBER_EDITABLE_SITE_FIELDS: ReadonlySet<string>;
25
+ /**
26
+ * Fields only the box owner may change, with why — used to explain the refusal
27
+ * rather than a bare 403.
28
+ */
29
+ export declare const ADMIN_ONLY_SITE_FIELDS: Readonly<Record<string, string>>;
30
+ export interface FieldCheck {
31
+ ok: boolean;
32
+ error?: string;
33
+ }
34
+ /**
35
+ * Whether a member may apply `body` to a site. Refuses the whole request if it
36
+ * touches any admin-only field, rather than silently applying the safe subset —
37
+ * a caller who asked to set `start` should be told it did not happen.
38
+ */
39
+ export declare function checkMemberSiteFields(body: Record<string, any>): FieldCheck;
40
+ export interface RouteConflictInput {
41
+ /** The site being edited. */
42
+ siteName: string;
43
+ /** The requested changes (domain / path / aliases). */
44
+ body: Record<string, any>;
45
+ /** All sites on the box, by name. */
46
+ sites: Record<string, any>;
47
+ /** Site names the editor holds a grant on — conflicts with these are theirs. */
48
+ ownSites: string[];
49
+ }
50
+ /**
51
+ * Refuse a routing change that would claim a host another tenant already
52
+ * serves. Conflicts with the editor's own sites are allowed: moving a domain
53
+ * between two sites you control is your business.
54
+ */
55
+ export declare function checkRouteConflict({ siteName, body, sites, ownSites }: RouteConflictInput): FieldCheck;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Login throttling.
3
+ *
4
+ * The dashboard's login is internet-facing, and it is the door to a box that
5
+ * hosts other people's sites. scrypt makes each guess cost ~50ms, which slows
6
+ * an attacker but does not stop one — and while generated passwords are strong,
7
+ * an operator can set their own via `TS_CLOUD_UI_PASSWORD`.
8
+ *
9
+ * Failures are counted per key (username + client address) and lock that key
10
+ * out for a window once they pass a threshold. In-memory on purpose: the
11
+ * dashboard is a single process per box, and a restart clearing the counters is
12
+ * an acceptable trade for having no store to keep.
13
+ *
14
+ * Keyed on username *and* address so one attacker cannot lock a legitimate
15
+ * operator out of their own box by failing logins against their username
16
+ * (a denial of service dressed up as a security control).
17
+ */
18
+ /** Failures before a key is locked out. */
19
+ export declare const MAX_ATTEMPTS: number;
20
+ /** How long a locked key stays locked. */
21
+ export declare const LOCKOUT_MS: number;
22
+ /** Failures older than this stop counting, so occasional typos never lock. */
23
+ export declare const WINDOW_MS: number;
24
+ export interface ThrottleDecision {
25
+ allowed: boolean;
26
+ /** Seconds until the caller may try again. Only set when blocked. */
27
+ retryAfterSeconds?: number;
28
+ }
29
+ export declare class LoginThrottle {
30
+ private readonly maxAttempts;
31
+ private readonly lockoutMs;
32
+ private readonly windowMs;
33
+ private attempts;
34
+ constructor(maxAttempts?: number, lockoutMs?: number, windowMs?: number);
35
+ private key;
36
+ /** Whether a login attempt for this key may proceed. */
37
+ check(username: string, address: string, now?: number): ThrottleDecision;
38
+ /** Record a failed attempt, locking the key once it passes the threshold. */
39
+ recordFailure(username: string, address: string, now?: number): void;
40
+ /** Clear the counter for a key after a successful login. */
41
+ recordSuccess(username: string, address: string): void;
42
+ /** Drop expired entries so the map cannot grow without bound. */
43
+ prune(now?: number): void;
44
+ /** Tracked keys. For tests and diagnostics. */
45
+ get size(): number;
46
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -12,7 +12,7 @@ import {
12
12
  sanitizeCloudConfig,
13
13
  setMaintenance,
14
14
  startLocalDashboardServer
15
- } from "../chunk-nt2hrnva.js";
15
+ } from "../chunk-eapb9et8.js";
16
16
  import {
17
17
  buildAndPushServerlessImage
18
18
  } from "../chunk-qgnyzfmx.js";
package/dist/index.js CHANGED
@@ -55,7 +55,7 @@ import {
55
55
  sanitizeCloudConfig,
56
56
  setMaintenance,
57
57
  startLocalDashboardServer
58
- } from "./chunk-nt2hrnva.js";
58
+ } from "./chunk-eapb9et8.js";
59
59
  import {
60
60
  buildAndPushServerlessImage
61
61
  } from "./chunk-qgnyzfmx.js";