@stacksjs/ts-cloud 0.7.18 → 0.7.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/cli.js +697 -571
- package/dist/{chunk-h4pdjzq2.js → chunk-nt2hrnva.js} +744 -52
- package/dist/{chunk-vpyd42tp.js → chunk-xgnxz5dz.js} +98 -34
- package/dist/deploy/dashboard-auth.d.ts +82 -0
- package/dist/deploy/dashboard-auth.test.d.ts +1 -0
- package/dist/deploy/dashboard-guard.d.ts +40 -0
- package/dist/deploy/dashboard-login-page.d.ts +16 -0
- package/dist/deploy/dashboard-pages.test.d.ts +1 -0
- package/dist/deploy/dashboard-policy.d.ts +37 -0
- package/dist/deploy/dashboard-policy.test.d.ts +1 -0
- package/dist/deploy/dashboard-scope.d.ts +24 -0
- package/dist/deploy/dashboard-scope.test.d.ts +1 -0
- package/dist/deploy/dashboard-session.d.ts +52 -0
- package/dist/deploy/dashboard-users.d.ts +60 -0
- package/dist/deploy/index.js +2 -2
- package/dist/deploy/local-dashboard-server.d.ts +1 -0
- package/dist/drivers/hetzner/client.d.ts +9 -1
- package/dist/drivers/hetzner/config.d.ts +83 -0
- package/dist/drivers/hetzner/config.test.d.ts +1 -0
- package/dist/drivers/hetzner/factory-wiring.test.d.ts +1 -0
- package/dist/drivers/index.js +1 -1
- package/dist/index.js +2 -2
- package/dist/ui/index.html +4 -4
- package/dist/ui/server/actions.html +4 -4
- package/dist/ui/server/activity.html +1 -1
- package/dist/ui/server/backups.html +4 -4
- package/dist/ui/server/database.html +4 -4
- package/dist/ui/server/deployments.html +4 -4
- package/dist/ui/server/diagnostics.html +1 -1
- package/dist/ui/server/firewall.html +4 -4
- package/dist/ui/server/logs.html +4 -4
- package/dist/ui/server/metrics.html +1 -1
- package/dist/ui/server/security.html +1 -1
- package/dist/ui/server/services.html +4 -4
- package/dist/ui/server/sites.html +4 -4
- package/dist/ui/server/ssh-keys.html +4 -4
- package/dist/ui/server/team.html +1214 -0
- package/dist/ui/server/terminal.html +4 -4
- package/dist/ui/server/workers.html +4 -4
- package/dist/ui/serverless/alarms.html +4 -4
- package/dist/ui/serverless/assets.html +4 -4
- package/dist/ui/serverless/cost.html +1 -1
- package/dist/ui/serverless/data.html +4 -4
- package/dist/ui/serverless/deployments.html +4 -4
- package/dist/ui/serverless/firewall.html +1 -1
- package/dist/ui/serverless/functions.html +4 -4
- package/dist/ui/serverless/logs.html +4 -4
- package/dist/ui/serverless/metrics.html +1 -1
- package/dist/ui/serverless/queues.html +4 -4
- package/dist/ui/serverless/scheduler.html +4 -4
- package/dist/ui/serverless/secrets.html +4 -4
- package/dist/ui/serverless/traces.html +4 -4
- package/dist/ui/serverless.html +4 -4
- package/dist/ui-src/pages/partials/head.stx +7 -0
- package/dist/ui-src/pages/partials/nav.stx +43 -6
- package/dist/ui-src/pages/server/firewall.stx +1 -1
- package/dist/ui-src/pages/server/sites.stx +42 -20
- package/dist/ui-src/pages/server/team.stx +171 -0
- package/package.json +3 -3
|
@@ -1906,10 +1906,70 @@ class AwsDriver {
|
|
|
1906
1906
|
|
|
1907
1907
|
// src/drivers/hetzner/driver.ts
|
|
1908
1908
|
import { existsSync, readFileSync as readFileSync2 } from "node:fs";
|
|
1909
|
-
import { homedir } from "node:os";
|
|
1910
|
-
import { join as join3 } from "node:path";
|
|
1911
1909
|
import { execSync } from "node:child_process";
|
|
1912
1910
|
|
|
1911
|
+
// src/drivers/hetzner/config.ts
|
|
1912
|
+
import { homedir } from "node:os";
|
|
1913
|
+
import { join as join2 } from "node:path";
|
|
1914
|
+
var HETZNER_DEFAULTS = {
|
|
1915
|
+
location: "fsn1",
|
|
1916
|
+
image: "ubuntu-24.04",
|
|
1917
|
+
sshUser: "root",
|
|
1918
|
+
sshPrivateKeyPath: "~/.ssh/id_ed25519"
|
|
1919
|
+
};
|
|
1920
|
+
function expandHome(path) {
|
|
1921
|
+
return path.startsWith("~/") ? join2(homedir(), path.slice(2)) : path;
|
|
1922
|
+
}
|
|
1923
|
+
function env(...names) {
|
|
1924
|
+
for (const name of names) {
|
|
1925
|
+
const value = process.env[name]?.trim();
|
|
1926
|
+
if (value)
|
|
1927
|
+
return value;
|
|
1928
|
+
}
|
|
1929
|
+
return;
|
|
1930
|
+
}
|
|
1931
|
+
function first(...values) {
|
|
1932
|
+
for (const value of values) {
|
|
1933
|
+
const trimmed = value?.trim();
|
|
1934
|
+
if (trimmed)
|
|
1935
|
+
return trimmed;
|
|
1936
|
+
}
|
|
1937
|
+
return;
|
|
1938
|
+
}
|
|
1939
|
+
function resolveHetznerApiToken(explicit, config) {
|
|
1940
|
+
return first(explicit, config?.hetzner?.apiToken, env("HCLOUD_TOKEN", "HETZNER_API_TOKEN"));
|
|
1941
|
+
}
|
|
1942
|
+
function resolveHetznerLocation(config, explicit) {
|
|
1943
|
+
return first(explicit, config?.hetzner?.location, env("HCLOUD_LOCATION", "HETZNER_LOCATION")) ?? HETZNER_DEFAULTS.location;
|
|
1944
|
+
}
|
|
1945
|
+
function resolveHetznerImage(config, explicit) {
|
|
1946
|
+
const compute = config?.infrastructure?.compute;
|
|
1947
|
+
return first(explicit, compute?.image, config?.hetzner?.image, env("HCLOUD_IMAGE", "HETZNER_IMAGE")) ?? HETZNER_DEFAULTS.image;
|
|
1948
|
+
}
|
|
1949
|
+
function resolveHetznerSshUser(config, explicit) {
|
|
1950
|
+
return first(explicit, config?.hetzner?.sshUser, env("HCLOUD_SSH_USER", "HETZNER_SSH_USER")) ?? HETZNER_DEFAULTS.sshUser;
|
|
1951
|
+
}
|
|
1952
|
+
function resolveHetznerSshPrivateKeyPath(config, explicit) {
|
|
1953
|
+
return expandHome(first(explicit, config?.hetzner?.sshPrivateKeyPath, env("HCLOUD_SSH_KEY", "HETZNER_SSH_KEY")) ?? HETZNER_DEFAULTS.sshPrivateKeyPath);
|
|
1954
|
+
}
|
|
1955
|
+
function resolveHetznerSshPublicKeyPath(config, explicit, privateKeyPath) {
|
|
1956
|
+
const explicitPath = first(explicit, config?.hetzner?.sshPublicKeyPath, env("HCLOUD_SSH_PUBLIC_KEY", "HETZNER_SSH_PUBLIC_KEY"));
|
|
1957
|
+
if (explicitPath)
|
|
1958
|
+
return expandHome(explicitPath);
|
|
1959
|
+
return `${privateKeyPath ?? resolveHetznerSshPrivateKeyPath(config)}.pub`;
|
|
1960
|
+
}
|
|
1961
|
+
function resolveHetznerSettings(config, overrides = {}) {
|
|
1962
|
+
const sshPrivateKeyPath = resolveHetznerSshPrivateKeyPath(config, overrides.sshPrivateKeyPath);
|
|
1963
|
+
return {
|
|
1964
|
+
apiToken: resolveHetznerApiToken(overrides.apiToken, config),
|
|
1965
|
+
location: resolveHetznerLocation(config, overrides.location),
|
|
1966
|
+
image: resolveHetznerImage(config, overrides.image),
|
|
1967
|
+
sshUser: resolveHetznerSshUser(config, overrides.sshUser),
|
|
1968
|
+
sshPrivateKeyPath,
|
|
1969
|
+
sshPublicKeyPath: resolveHetznerSshPublicKeyPath(config, overrides.sshPublicKeyPath, sshPrivateKeyPath)
|
|
1970
|
+
};
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1913
1973
|
// src/drivers/hetzner/client.ts
|
|
1914
1974
|
var DEFAULT_API_URL = "https://api.hetzner.cloud/v1";
|
|
1915
1975
|
|
|
@@ -2095,8 +2155,8 @@ class HetznerClient {
|
|
|
2095
2155
|
throw new Error(`Timed out waiting for server ${serverId} to reach running state`);
|
|
2096
2156
|
}
|
|
2097
2157
|
}
|
|
2098
|
-
function
|
|
2099
|
-
const token = configToken
|
|
2158
|
+
function resolveHetznerApiToken2(configToken, config) {
|
|
2159
|
+
const token = resolveHetznerApiToken(configToken, config);
|
|
2100
2160
|
if (!token) {
|
|
2101
2161
|
throw new Error("Hetzner API token required. Set hetzner.apiToken in cloud.config.ts or HCLOUD_TOKEN / HETZNER_API_TOKEN.");
|
|
2102
2162
|
}
|
|
@@ -2138,21 +2198,21 @@ function resolveFleetTopology(compute = {}) {
|
|
|
2138
2198
|
};
|
|
2139
2199
|
}
|
|
2140
2200
|
function buildFleetServicesEnv(servicesPrivateIp, database) {
|
|
2141
|
-
const
|
|
2201
|
+
const env2 = {
|
|
2142
2202
|
REDIS_HOST: servicesPrivateIp,
|
|
2143
2203
|
MEILISEARCH_HOST: `http://${servicesPrivateIp}:7700`
|
|
2144
2204
|
};
|
|
2145
2205
|
if (database?.name) {
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2206
|
+
env2.DB_CONNECTION = database.engine === "postgres" ? "pgsql" : "mysql";
|
|
2207
|
+
env2.DB_HOST = servicesPrivateIp;
|
|
2208
|
+
env2.DB_PORT = String(database.port ?? (database.engine === "postgres" ? 5432 : 3306));
|
|
2209
|
+
env2.DB_DATABASE = database.name;
|
|
2150
2210
|
if (database.username)
|
|
2151
|
-
|
|
2211
|
+
env2.DB_USERNAME = database.username;
|
|
2152
2212
|
if (database.password)
|
|
2153
|
-
|
|
2213
|
+
env2.DB_PASSWORD = database.password;
|
|
2154
2214
|
}
|
|
2155
|
-
return
|
|
2215
|
+
return env2;
|
|
2156
2216
|
}
|
|
2157
2217
|
|
|
2158
2218
|
// src/drivers/hetzner/firewall-rules.ts
|
|
@@ -2206,10 +2266,10 @@ function matchesTsCloudLabels(labels, slug, environment, role = "app") {
|
|
|
2206
2266
|
|
|
2207
2267
|
// src/drivers/hetzner/state.ts
|
|
2208
2268
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2209
|
-
import { join as
|
|
2269
|
+
import { join as join3 } from "node:path";
|
|
2210
2270
|
var STATE_DIR = ".ts-cloud/state";
|
|
2211
2271
|
function driverStatePath(stackName) {
|
|
2212
|
-
return
|
|
2272
|
+
return join3(process.cwd(), STATE_DIR, `${stackName}.json`);
|
|
2213
2273
|
}
|
|
2214
2274
|
async function readDriverState(stackName) {
|
|
2215
2275
|
try {
|
|
@@ -2221,7 +2281,7 @@ async function readDriverState(stackName) {
|
|
|
2221
2281
|
}
|
|
2222
2282
|
async function writeDriverState(stackName, state) {
|
|
2223
2283
|
const path = driverStatePath(stackName);
|
|
2224
|
-
await mkdir(
|
|
2284
|
+
await mkdir(join3(process.cwd(), STATE_DIR), { recursive: true });
|
|
2225
2285
|
await writeFile(path, `${JSON.stringify(state, null, 2)}
|
|
2226
2286
|
`, "utf8");
|
|
2227
2287
|
}
|
|
@@ -2242,9 +2302,6 @@ function formatSshFailure(error) {
|
|
|
2242
2302
|
return `Remote SSH command failed${status}${signal}${output ? `
|
|
2243
2303
|
${output}` : ""}`;
|
|
2244
2304
|
}
|
|
2245
|
-
function expandHome(path) {
|
|
2246
|
-
return path.startsWith("~/") ? join3(homedir(), path.slice(2)) : path;
|
|
2247
|
-
}
|
|
2248
2305
|
|
|
2249
2306
|
class HetznerDriver {
|
|
2250
2307
|
name = "hetzner";
|
|
@@ -2258,12 +2315,18 @@ class HetznerDriver {
|
|
|
2258
2315
|
bootWait;
|
|
2259
2316
|
constructor(options = {}) {
|
|
2260
2317
|
this.client = options.client ?? new HetznerClient({
|
|
2261
|
-
apiToken:
|
|
2318
|
+
apiToken: resolveHetznerApiToken2(options.apiToken)
|
|
2319
|
+
});
|
|
2320
|
+
const settings = resolveHetznerSettings(undefined, {
|
|
2321
|
+
sshPrivateKeyPath: options.sshPrivateKeyPath,
|
|
2322
|
+
sshPublicKeyPath: options.sshPublicKeyPath,
|
|
2323
|
+
sshUser: options.sshUser,
|
|
2324
|
+
location: options.location
|
|
2262
2325
|
});
|
|
2263
|
-
this.sshPrivateKeyPath =
|
|
2264
|
-
this.sshPublicKeyPath =
|
|
2265
|
-
this.sshUser =
|
|
2266
|
-
this.location =
|
|
2326
|
+
this.sshPrivateKeyPath = settings.sshPrivateKeyPath;
|
|
2327
|
+
this.sshPublicKeyPath = settings.sshPublicKeyPath;
|
|
2328
|
+
this.sshUser = settings.sshUser;
|
|
2329
|
+
this.location = settings.location;
|
|
2267
2330
|
this.waitForBoot = options.waitForBoot ?? true;
|
|
2268
2331
|
this.bootWait = {
|
|
2269
2332
|
sshIntervalMs: options.bootWait?.sshIntervalMs ?? 5000,
|
|
@@ -2330,7 +2393,7 @@ class HetznerDriver {
|
|
|
2330
2393
|
});
|
|
2331
2394
|
const userData = wrapCloudInitUserData(bootstrap);
|
|
2332
2395
|
const serverType = resolveHetznerServerType(compute.size);
|
|
2333
|
-
const image =
|
|
2396
|
+
const image = resolveHetznerImage(config);
|
|
2334
2397
|
const firewallName = `${slug}-${environment}-app-fw`;
|
|
2335
2398
|
const { firewall } = await this.ensureFirewall(firewallName, labels, buildHetznerFirewallRules({
|
|
2336
2399
|
allowSsh: compute.allowSsh !== false,
|
|
@@ -2373,7 +2436,7 @@ class HetznerDriver {
|
|
|
2373
2436
|
const compute = config.infrastructure.compute;
|
|
2374
2437
|
const stackName = resolveProjectStackName(config, environment);
|
|
2375
2438
|
const serverType = resolveHetznerServerType(compute.size);
|
|
2376
|
-
const image =
|
|
2439
|
+
const image = resolveHetznerImage(config);
|
|
2377
2440
|
const location = config.hetzner?.location || this.location;
|
|
2378
2441
|
const baked = compute.bakedImage === true;
|
|
2379
2442
|
const existingState = await readDriverState(stackName);
|
|
@@ -2528,7 +2591,7 @@ class HetznerDriver {
|
|
|
2528
2591
|
const compute = config.infrastructure.compute;
|
|
2529
2592
|
const stackName = resolveProjectStackName(config, environment);
|
|
2530
2593
|
const serverType = resolveHetznerServerType(compute.size);
|
|
2531
|
-
const image =
|
|
2594
|
+
const image = resolveHetznerImage(config);
|
|
2532
2595
|
const location = config.hetzner?.location || this.location;
|
|
2533
2596
|
const baked = compute.bakedImage === true;
|
|
2534
2597
|
const sites = config.sites || {};
|
|
@@ -2811,11 +2874,11 @@ class HetznerDriver {
|
|
|
2811
2874
|
role: "app",
|
|
2812
2875
|
stackName: resolveProjectStackName(options.config, options.environment)
|
|
2813
2876
|
});
|
|
2814
|
-
const
|
|
2877
|
+
const first2 = targets[0];
|
|
2815
2878
|
return {
|
|
2816
2879
|
deployStoragePath: "/var/ts-cloud/staging",
|
|
2817
|
-
appInstanceId:
|
|
2818
|
-
appPublicIp:
|
|
2880
|
+
appInstanceId: first2?.id,
|
|
2881
|
+
appPublicIp: first2?.publicIp,
|
|
2819
2882
|
sshUser: this.sshUser
|
|
2820
2883
|
};
|
|
2821
2884
|
}
|
|
@@ -3137,6 +3200,7 @@ function createCloudDriver(options) {
|
|
|
3137
3200
|
return new HetznerDriver({
|
|
3138
3201
|
apiToken: options.config.hetzner?.apiToken,
|
|
3139
3202
|
sshPrivateKeyPath: options.config.hetzner?.sshPrivateKeyPath,
|
|
3203
|
+
sshPublicKeyPath: options.config.hetzner?.sshPublicKeyPath,
|
|
3140
3204
|
sshUser: options.config.hetzner?.sshUser,
|
|
3141
3205
|
location: options.config.hetzner?.location
|
|
3142
3206
|
});
|
|
@@ -3481,8 +3545,8 @@ function createBoxProvisioner(options) {
|
|
|
3481
3545
|
return new AwsBoxProvisioner(options.aws);
|
|
3482
3546
|
}
|
|
3483
3547
|
// src/drivers/shared/env-file.ts
|
|
3484
|
-
function formatEnvFile(
|
|
3485
|
-
return Object.entries(
|
|
3548
|
+
function formatEnvFile(env2) {
|
|
3549
|
+
return Object.entries(env2).map(([k, v]) => `${k}=${quoteEnvValue(String(v))}`).join(`
|
|
3486
3550
|
`);
|
|
3487
3551
|
}
|
|
3488
3552
|
function quoteEnvValue(value) {
|
|
@@ -4047,8 +4111,8 @@ function defaultDeployScriptFor(type) {
|
|
|
4047
4111
|
function substituteBins(line, phpBin) {
|
|
4048
4112
|
return line.replace(/^php\s+/, `${phpBin} `).replace(/(\s)php\s+artisan\s+/g, `$1${phpBin} artisan `);
|
|
4049
4113
|
}
|
|
4050
|
-
function writeSharedEnv(sharedEnvPath,
|
|
4051
|
-
const body = formatEnvFile(
|
|
4114
|
+
function writeSharedEnv(sharedEnvPath, env2) {
|
|
4115
|
+
const body = formatEnvFile(env2);
|
|
4052
4116
|
return [
|
|
4053
4117
|
`cat > ${sharedEnvPath} <<'TS_CLOUD_ENV_EOF'`,
|
|
4054
4118
|
body,
|
|
@@ -4767,4 +4831,4 @@ function buildCloudFrontOriginConfig(options) {
|
|
|
4767
4831
|
CustomErrorResponses: { Quantity: 0 }
|
|
4768
4832
|
};
|
|
4769
4833
|
}
|
|
4770
|
-
export { siteInstallBase, isPhpSite, resolveSiteDeployTarget, resolveSiteKind, validateDeploymentConfig, PANTRY_PROJECT_DIR, DEFAULT_RPX_CERTS_DIR, normalizeRoutePath, deriveRouteId, buildRpxConfig, buildRpxLbConfig, renderRpxLauncher, RPX_DIR, RPX_LAUNCHER_PATH, RPX_SERVICE_NAME, buildRpxProvisionScript, BACKUP_RUNNER_PATH, buildBackupRestoreScript, buildUbuntuBootstrapScript, AwsDriver, HetznerClient, resolveHetznerApiToken, normalizeSshPublicKey, wrapCloudInitUserData, HetznerDriver, LocalBoxDriver, isBoxMode, createCloudDriver, CloudDriverFactory, cloudDrivers, ensureSshKey, ensureFirewall, ensureServer, serverPublicIpv4, buildSshArgs, sshExec, sshExecOrThrow, scpUpload, waitForSsh, waitForCloudInit, UBUNTU_2404_AMI_PARAM, buildBoxUserData, HetznerBoxProvisioner, AwsBoxProvisioner, createBoxProvisioner, releasePaths, buildRollbackScript, resolveExecStart, buildSiteDeployScript, buildStaticSiteDeployScript, buildAwsArtifactFetch, buildLocalArtifactFetch, MANAGEMENT_DASHBOARD_SITE, DASHBOARD_CREDENTIALS_FILE, resolveDashboardAuth, resolveUiSource, ensureManagementDashboard, buildManagementDashboardArtifact, resolveSiteFramework, deploySiteRelease, deployAllComputeSites, reloadRpxGateway, MANAGED_CACHE_POLICY_OPTIMIZED, MANAGED_CACHE_POLICY_DISABLED, MANAGED_ORIGIN_REQUEST_POLICY_ALL_VIEWER, buildCloudFrontOriginConfig };
|
|
4834
|
+
export { siteInstallBase, isPhpSite, resolveSiteDeployTarget, resolveSiteKind, validateDeploymentConfig, PANTRY_PROJECT_DIR, DEFAULT_RPX_CERTS_DIR, normalizeRoutePath, deriveRouteId, buildRpxConfig, buildRpxLbConfig, renderRpxLauncher, RPX_DIR, RPX_LAUNCHER_PATH, RPX_SERVICE_NAME, buildRpxProvisionScript, BACKUP_RUNNER_PATH, buildBackupRestoreScript, buildUbuntuBootstrapScript, AwsDriver, resolveHetznerLocation, HetznerClient, resolveHetznerApiToken2 as resolveHetznerApiToken, normalizeSshPublicKey, wrapCloudInitUserData, HetznerDriver, LocalBoxDriver, isBoxMode, createCloudDriver, CloudDriverFactory, cloudDrivers, ensureSshKey, ensureFirewall, ensureServer, serverPublicIpv4, buildSshArgs, sshExec, sshExecOrThrow, scpUpload, waitForSsh, waitForCloudInit, UBUNTU_2404_AMI_PARAM, buildBoxUserData, HetznerBoxProvisioner, AwsBoxProvisioner, createBoxProvisioner, releasePaths, buildRollbackScript, resolveExecStart, buildSiteDeployScript, buildStaticSiteDeployScript, buildAwsArtifactFetch, buildLocalArtifactFetch, MANAGEMENT_DASHBOARD_SITE, DASHBOARD_CREDENTIALS_FILE, resolveDashboardAuth, resolveUiSource, ensureManagementDashboard, buildManagementDashboardArtifact, resolveSiteFramework, deploySiteRelease, deployAllComputeSites, reloadRpxGateway, MANAGED_CACHE_POLICY_OPTIMIZED, MANAGED_CACHE_POLICY_DISABLED, MANAGED_ORIGIN_REQUEST_POLICY_ALL_VIEWER, buildCloudFrontOriginConfig };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identity and authorization for the management dashboard.
|
|
3
|
+
*
|
|
4
|
+
* The dashboard hosts sites for more than one party: Stacks owns the box, and
|
|
5
|
+
* collaborators are invited to individual sites. That makes authorization a
|
|
6
|
+
* security boundary rather than a convenience — a site collaborator must never
|
|
7
|
+
* reach another tenant's data, and must never reach the box itself.
|
|
8
|
+
*
|
|
9
|
+
* The model has two levels:
|
|
10
|
+
* - Box role: `admin` (owns the box — everything) or `member` (only what they
|
|
11
|
+
* have been granted).
|
|
12
|
+
* - Site grants: a member holds a {@link SiteRole} per site. `owner` may change
|
|
13
|
+
* the site's settings; `collaborator` may view and deploy it.
|
|
14
|
+
*
|
|
15
|
+
* Box-level capabilities (shell, terminal, SSH keys, firewall, databases,
|
|
16
|
+
* cloud-config edits) are **admin-only and never grantable per site**. Each of
|
|
17
|
+
* them yields root on the box, which would hand a single site's collaborator
|
|
18
|
+
* control of every other tenant's site. There is deliberately no grant that
|
|
19
|
+
* unlocks them for a member.
|
|
20
|
+
*
|
|
21
|
+
* Authorization is deny-by-default: {@link authorize} answers `false` for any
|
|
22
|
+
* capability it does not explicitly recognize.
|
|
23
|
+
*/
|
|
24
|
+
/** Box-wide role. `admin` owns the server and everything hosted on it. */
|
|
25
|
+
export type BoxRole = 'admin' | 'member';
|
|
26
|
+
/**
|
|
27
|
+
* A member's role on one site. `owner` can additionally change that site's
|
|
28
|
+
* settings; `collaborator` is limited to viewing and deploying it.
|
|
29
|
+
*/
|
|
30
|
+
export type SiteRole = 'owner' | 'collaborator';
|
|
31
|
+
export interface DashboardUser {
|
|
32
|
+
username: string;
|
|
33
|
+
/** Encoded scrypt hash — see {@link hashPassword}. Never a plaintext password. */
|
|
34
|
+
passwordHash: string;
|
|
35
|
+
role: BoxRole;
|
|
36
|
+
/** Site name → role. Ignored for admins, who reach every site. */
|
|
37
|
+
sites: Record<string, SiteRole>;
|
|
38
|
+
/** Display name shown in the UI. Defaults to the username. */
|
|
39
|
+
name?: string;
|
|
40
|
+
createdAt?: string;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Everything the dashboard can be asked to do.
|
|
44
|
+
*
|
|
45
|
+
* `box:*` capabilities are admin-only. `site:*` capabilities are evaluated
|
|
46
|
+
* against a specific site and may be granted to a member.
|
|
47
|
+
*/
|
|
48
|
+
export type Capability = 'box:read' | 'box:shell' | 'box:ssh' | 'box:firewall' | 'box:database' | 'box:config' | 'box:sites:create' | 'box:sites:delete' | 'box:serverless' | 'box:users' | 'site:read' | 'site:deploy' | 'site:settings';
|
|
49
|
+
/**
|
|
50
|
+
* Capabilities that are never grantable to a member, for any site. Listed
|
|
51
|
+
* explicitly so that adding a `site:*` capability can't silently widen a
|
|
52
|
+
* member's reach into box-level control.
|
|
53
|
+
*/
|
|
54
|
+
export declare function isBoxCapability(capability: Capability): boolean;
|
|
55
|
+
export interface AuthorizeInput {
|
|
56
|
+
user: Pick<DashboardUser, 'role' | 'sites'>;
|
|
57
|
+
capability: Capability;
|
|
58
|
+
/** Required for every `site:*` capability; ignored for `box:*`. */
|
|
59
|
+
site?: string;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The single authorization decision point. Deny-by-default: an unrecognized
|
|
63
|
+
* capability, a missing site, or a site the member holds no grant on all
|
|
64
|
+
* return `false`.
|
|
65
|
+
*/
|
|
66
|
+
export declare function authorize({ user, capability, site }: AuthorizeInput): boolean;
|
|
67
|
+
/** The sites a user may see. Admins see everything, so pass the full list. */
|
|
68
|
+
export declare function visibleSites(user: Pick<DashboardUser, 'role' | 'sites'>, allSites: string[]): string[];
|
|
69
|
+
/**
|
|
70
|
+
* Hash a password for storage: `scrypt$N$r$p$salt$hash` (both salt and hash
|
|
71
|
+
* base64url). The parameters travel with the hash so they can be raised later
|
|
72
|
+
* without invalidating existing credentials.
|
|
73
|
+
*/
|
|
74
|
+
export declare function hashPassword(password: string): string;
|
|
75
|
+
/**
|
|
76
|
+
* Verify a password against an encoded hash, in constant time. Returns false
|
|
77
|
+
* for malformed hashes rather than throwing — a corrupt user record must fail
|
|
78
|
+
* the login, not crash the server.
|
|
79
|
+
*/
|
|
80
|
+
export declare function verifyPassword(password: string, stored: string): boolean;
|
|
81
|
+
/** A URL-safe generated password, for invites and the bootstrap admin. */
|
|
82
|
+
export declare function generatePassword(): string;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The request-time gate: resolve who is calling, then decide whether the
|
|
3
|
+
* {@link routePolicy} for their request permits it.
|
|
4
|
+
*
|
|
5
|
+
* Every dashboard request passes through here before reaching a handler, so
|
|
6
|
+
* this file and {@link import('./dashboard-policy')} together are the complete
|
|
7
|
+
* authorization story for the HTTP API.
|
|
8
|
+
*/
|
|
9
|
+
import type { DashboardUser } from './dashboard-auth';
|
|
10
|
+
/**
|
|
11
|
+
* A synthetic admin used only when auth is explicitly disabled for local
|
|
12
|
+
* development. It is never persisted and never has a password.
|
|
13
|
+
*/
|
|
14
|
+
export declare const LOCAL_ADMIN: DashboardUser;
|
|
15
|
+
export interface GuardOptions {
|
|
16
|
+
cwd: string;
|
|
17
|
+
/** When false, every request is treated as {@link LOCAL_ADMIN}. */
|
|
18
|
+
enabled: boolean;
|
|
19
|
+
secret: string;
|
|
20
|
+
}
|
|
21
|
+
export interface GuardDecision {
|
|
22
|
+
ok: boolean;
|
|
23
|
+
status?: number;
|
|
24
|
+
error?: string;
|
|
25
|
+
/** True when the caller has no valid session at all (vs. lacking a grant). */
|
|
26
|
+
unauthenticated?: boolean;
|
|
27
|
+
}
|
|
28
|
+
export interface DashboardGuard {
|
|
29
|
+
enabled: boolean;
|
|
30
|
+
/** The user for this request, or null when unauthenticated. */
|
|
31
|
+
resolveUser: (req: Request) => DashboardUser | null;
|
|
32
|
+
/** Whether `user` may perform `req`. `site` is required for site-scoped routes. */
|
|
33
|
+
check: (req: Request, pathname: string, user: DashboardUser | null, site?: string) => GuardDecision;
|
|
34
|
+
}
|
|
35
|
+
export declare function createDashboardGuard(options: GuardOptions): DashboardGuard;
|
|
36
|
+
/**
|
|
37
|
+
* Read the site name a site-scoped route acts on, without consuming the body
|
|
38
|
+
* the handler will read (the request is cloned).
|
|
39
|
+
*/
|
|
40
|
+
export declare function siteFromRequest(req: Request, pathname: string): Promise<string | undefined>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The login page.
|
|
3
|
+
*
|
|
4
|
+
* Served directly by the dashboard server rather than built with the rest of
|
|
5
|
+
* the stx UI: it has to render before there is a session, and the built pages
|
|
6
|
+
* are scope-specific (see the per-scope UI cache in `local-dashboard-server`).
|
|
7
|
+
* Keeping it self-contained also means a broken UI build still leaves a way in.
|
|
8
|
+
*
|
|
9
|
+
* It mirrors the cockpit's existing design tokens so the two don't read as
|
|
10
|
+
* different products.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* The page. `serverless` only picks the post-login landing route, matching the
|
|
14
|
+
* redirect the server already does for a serverless deployment.
|
|
15
|
+
*/
|
|
16
|
+
export declare function renderLoginPage(serverless?: boolean): string;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dashboard's route → capability table.
|
|
3
|
+
*
|
|
4
|
+
* This is the whole authorization surface of the HTTP API in one readable
|
|
5
|
+
* place, so it can be reviewed without tracing handlers. {@link routePolicy}
|
|
6
|
+
* maps a method and path to the {@link Capability} it needs.
|
|
7
|
+
*
|
|
8
|
+
* **Fails closed.** A route with no entry resolves to `box:shell` — the most
|
|
9
|
+
* privileged capability, admin-only. Adding a route and forgetting to give it a
|
|
10
|
+
* policy therefore locks members out rather than quietly exposing the route.
|
|
11
|
+
*
|
|
12
|
+
* Site-scoped routes take their site from the request body, so their entry sets
|
|
13
|
+
* `siteFrom: 'body'` and the caller supplies the parsed name.
|
|
14
|
+
*/
|
|
15
|
+
import type { Capability } from './dashboard-auth';
|
|
16
|
+
export interface RoutePolicy {
|
|
17
|
+
capability: Capability;
|
|
18
|
+
/** Where the site name comes from, for `site:*` capabilities. */
|
|
19
|
+
siteFrom?: 'body';
|
|
20
|
+
/** Any authenticated user may call it, regardless of role. */
|
|
21
|
+
anyUser?: boolean;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Routes reachable with no session at all. These necessarily run before there
|
|
25
|
+
* is a user to authorize, so they are listed here explicitly — the set is
|
|
26
|
+
* deliberately tiny and should stay that way.
|
|
27
|
+
*
|
|
28
|
+
* `/login` (the page) is served by the same public path.
|
|
29
|
+
*/
|
|
30
|
+
export declare const PUBLIC_ROUTES: ReadonlySet<string>;
|
|
31
|
+
export declare function isPublicRoute(method: string, pathname: string): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* The policy for a request. Unlisted API routes fail closed to admin-only.
|
|
34
|
+
*/
|
|
35
|
+
export declare function routePolicy(method: string, pathname: string): RoutePolicy;
|
|
36
|
+
/** Every policy entry, for tests and documentation. */
|
|
37
|
+
export declare function allRoutePolicies(): Record<string, RoutePolicy>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Narrow resolved dashboard data to what a user is allowed to see.
|
|
3
|
+
*
|
|
4
|
+
* This runs server-side, before serialization: a member is *sent* less, not
|
|
5
|
+
* merely shown less. Hiding a panel in the UI would leave every other tenant's
|
|
6
|
+
* deploy history and log lines one devtools tab away.
|
|
7
|
+
*
|
|
8
|
+
* Admins get the payload untouched. For a member we keep only their sites and
|
|
9
|
+
* the observability derived from them, and drop the box-level surface
|
|
10
|
+
* (host metrics, services, SSH keys, firewall, backups) outright — none of it
|
|
11
|
+
* is theirs, and some of it (open ports, auth events) is a map of the box.
|
|
12
|
+
*/
|
|
13
|
+
import type { DashboardUser } from './dashboard-auth';
|
|
14
|
+
export interface ScopeOptions {
|
|
15
|
+
user: Pick<DashboardUser, 'role' | 'sites'>;
|
|
16
|
+
/** Project slug, used to match a site to its systemd units. */
|
|
17
|
+
slug: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Return a copy of `data` containing only what `user` may see.
|
|
21
|
+
*/
|
|
22
|
+
export declare function scopeDashboardData(data: Record<string, any>, options: ScopeOptions): Record<string, any>;
|
|
23
|
+
/** Narrow the sanitized cloud config the same way. */
|
|
24
|
+
export declare function scopeCloudConfig(config: Record<string, any>, user: Pick<DashboardUser, 'role' | 'sites'>): Record<string, any>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cookie-backed sessions for the management dashboard.
|
|
3
|
+
*
|
|
4
|
+
* Sessions are stateless signed tokens (`payload.signature`, HMAC-SHA256) so
|
|
5
|
+
* they survive a dashboard restart without a session store. The payload carries
|
|
6
|
+
* only the username and an expiry; every request re-loads the user from the
|
|
7
|
+
* store, so a revoked or downgraded grant takes effect immediately rather than
|
|
8
|
+
* lingering until the token expires.
|
|
9
|
+
*
|
|
10
|
+
* The signing secret is resolved from `TS_CLOUD_DASHBOARD_SECRET`, else
|
|
11
|
+
* generated once and persisted to `.ts-cloud/dashboard-secret`. Rotating it
|
|
12
|
+
* invalidates every outstanding session.
|
|
13
|
+
*/
|
|
14
|
+
export declare const SESSION_COOKIE = "ts_cloud_session";
|
|
15
|
+
export declare const SECRET_FILE: string;
|
|
16
|
+
/** Eight hours: long enough to work through a deploy, short enough to expire. */
|
|
17
|
+
export declare const SESSION_TTL_MS: number;
|
|
18
|
+
export interface SessionPayload {
|
|
19
|
+
/** Username the session belongs to. */
|
|
20
|
+
u: string;
|
|
21
|
+
/** Expiry, epoch milliseconds. */
|
|
22
|
+
exp: number;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Resolve the HMAC signing secret, generating and persisting one on first use.
|
|
26
|
+
* The file is written 0600 — it is equivalent to every dashboard credential.
|
|
27
|
+
*/
|
|
28
|
+
export declare function resolveSessionSecret(cwd: string): string;
|
|
29
|
+
/** Issue a signed session token for `username`. */
|
|
30
|
+
export declare function createSessionToken(username: string, secret: string, ttlMs?: number): string;
|
|
31
|
+
/**
|
|
32
|
+
* Verify a token and return its payload, or null when the signature is invalid,
|
|
33
|
+
* the token is malformed, or it has expired. Signature is compared before the
|
|
34
|
+
* payload is trusted for anything.
|
|
35
|
+
*/
|
|
36
|
+
export declare function verifySessionToken(token: string | undefined, secret: string): SessionPayload | null;
|
|
37
|
+
/** Read one cookie from a request's `Cookie` header. */
|
|
38
|
+
export declare function readCookie(header: string | null, name: string): string | undefined;
|
|
39
|
+
/**
|
|
40
|
+
* Serialize the session cookie. `HttpOnly` keeps it away from page scripts,
|
|
41
|
+
* `SameSite=Lax` blocks cross-site POSTs from carrying it (the dashboard's CSRF
|
|
42
|
+
* defense), and `Secure` is set whenever the dashboard is not on loopback —
|
|
43
|
+
* in production it is always behind TLS.
|
|
44
|
+
*/
|
|
45
|
+
export declare function serializeSessionCookie(token: string, options?: {
|
|
46
|
+
secure: boolean;
|
|
47
|
+
maxAgeMs?: number;
|
|
48
|
+
}): string;
|
|
49
|
+
/** Cookie that clears the session (logout). */
|
|
50
|
+
export declare function clearSessionCookie(options?: {
|
|
51
|
+
secure: boolean;
|
|
52
|
+
}): string;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dashboard's user store: a JSON file of {@link DashboardUser} records at
|
|
3
|
+
* `.ts-cloud/dashboard-users.json` (0600), holding scrypt hashes and site
|
|
4
|
+
* grants. Small on purpose — a box hosts a handful of collaborators, not a
|
|
5
|
+
* directory service.
|
|
6
|
+
*
|
|
7
|
+
* On first use the store bootstraps a single admin so a freshly provisioned
|
|
8
|
+
* dashboard is never reachable without credentials. The generated password is
|
|
9
|
+
* returned to the caller to print once; only its hash is ever written.
|
|
10
|
+
*/
|
|
11
|
+
import type { DashboardUser, SiteRole } from './dashboard-auth';
|
|
12
|
+
export declare const USERS_FILE: string;
|
|
13
|
+
/** Usernames are used in URLs and shell-free contexts; keep them boring. */
|
|
14
|
+
export declare function isValidUsername(value: string): boolean;
|
|
15
|
+
export declare function parseUsersFile(text: string): DashboardUser[];
|
|
16
|
+
export declare function usersFilePath(cwd: string): string;
|
|
17
|
+
export declare function loadUsers(cwd: string): DashboardUser[];
|
|
18
|
+
export declare function saveUsers(cwd: string, users: DashboardUser[]): void;
|
|
19
|
+
export declare function findUser(users: DashboardUser[], username: string): DashboardUser | undefined;
|
|
20
|
+
export interface BootstrapResult {
|
|
21
|
+
users: DashboardUser[];
|
|
22
|
+
/** Set only when an admin was just created — print it once, then it's gone. */
|
|
23
|
+
generated?: {
|
|
24
|
+
username: string;
|
|
25
|
+
password: string;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Ensure at least one admin exists. When the store is empty, create an admin
|
|
30
|
+
* using `TS_CLOUD_UI_PASSWORD` if set, else a generated password that is
|
|
31
|
+
* returned for one-time display.
|
|
32
|
+
*/
|
|
33
|
+
export declare function ensureAdminUser(cwd: string, username?: string): BootstrapResult;
|
|
34
|
+
export interface UpsertMemberInput {
|
|
35
|
+
username: string;
|
|
36
|
+
password?: string;
|
|
37
|
+
name?: string;
|
|
38
|
+
sites: Record<string, SiteRole>;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Create or update a member and their site grants. Returns the generated
|
|
42
|
+
* password when one was minted (a new user without an explicit password).
|
|
43
|
+
*
|
|
44
|
+
* Members are created through this path only — it cannot produce an admin, so
|
|
45
|
+
* an invite flow can never escalate someone to box-wide control.
|
|
46
|
+
*/
|
|
47
|
+
export declare function upsertMember(cwd: string, input: UpsertMemberInput): {
|
|
48
|
+
user: DashboardUser;
|
|
49
|
+
password?: string;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Remove a user. The last admin cannot be removed — that would lock everyone
|
|
53
|
+
* out of the box with no way back in short of editing the file by hand.
|
|
54
|
+
*/
|
|
55
|
+
export declare function removeUser(cwd: string, username: string): {
|
|
56
|
+
ok: boolean;
|
|
57
|
+
error?: string;
|
|
58
|
+
};
|
|
59
|
+
/** Public shape for the UI — never leaks the password hash. */
|
|
60
|
+
export declare function describeUser(user: DashboardUser): Record<string, any>;
|
package/dist/deploy/index.js
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
sanitizeCloudConfig,
|
|
13
13
|
setMaintenance,
|
|
14
14
|
startLocalDashboardServer
|
|
15
|
-
} from "../chunk-
|
|
15
|
+
} from "../chunk-nt2hrnva.js";
|
|
16
16
|
import {
|
|
17
17
|
buildAndPushServerlessImage
|
|
18
18
|
} from "../chunk-qgnyzfmx.js";
|
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
resolveUiSource,
|
|
31
31
|
siteInstallBase,
|
|
32
32
|
validateDeploymentConfig
|
|
33
|
-
} from "../chunk-
|
|
33
|
+
} from "../chunk-xgnxz5dz.js";
|
|
34
34
|
import"../chunk-49nmy775.js";
|
|
35
35
|
import"../chunk-93hjhs78.js";
|
|
36
36
|
import {
|
|
@@ -28,4 +28,5 @@ export interface DashboardAction {
|
|
|
28
28
|
export declare function sanitizeCloudConfig(config: CloudConfig): Record<string, any>;
|
|
29
29
|
export declare function dashboardActions(environment: EnvironmentType): DashboardAction[];
|
|
30
30
|
export declare function resolveDashboardAction(id: string, environment: EnvironmentType): DashboardAction | undefined;
|
|
31
|
+
export declare function isBoxOnlyPage(pathname: string): boolean;
|
|
31
32
|
export declare function startLocalDashboardServer(options?: LocalDashboardServerOptions): Promise<LocalDashboardServer>;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Hetzner Cloud API client
|
|
3
3
|
* @see https://docs.hetzner.cloud/
|
|
4
4
|
*/
|
|
5
|
+
import type { CloudConfig } from '@ts-cloud/core';
|
|
5
6
|
export interface HetznerApiErrorBody {
|
|
6
7
|
error?: {
|
|
7
8
|
code?: string;
|
|
@@ -183,7 +184,14 @@ export declare class HetznerClient {
|
|
|
183
184
|
maxWaitMs?: number;
|
|
184
185
|
}): Promise<HetznerServer>;
|
|
185
186
|
}
|
|
186
|
-
|
|
187
|
+
/**
|
|
188
|
+
* The Hetzner API token, or a clear error when none is configured.
|
|
189
|
+
*
|
|
190
|
+
* Resolution itself lives in {@link import('./config').resolveHetznerApiToken}
|
|
191
|
+
* so the driver, the client and the dashboard cannot drift apart on where a
|
|
192
|
+
* setting comes from.
|
|
193
|
+
*/
|
|
194
|
+
export declare function resolveHetznerApiToken(configToken?: string, config?: CloudConfig): string;
|
|
187
195
|
/**
|
|
188
196
|
* Normalize an OpenSSH public key to its `<type> <base64>` body, dropping the
|
|
189
197
|
* trailing comment. Lets us match a local key against keys already registered
|