@hasna/uptime 0.1.19 → 0.1.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.
- package/CHANGELOG.md +28 -0
- package/README.md +15 -0
- package/dist/api.js +76 -5
- package/dist/cli/index.js +78 -7
- package/dist/cloud-plan.js +1 -1
- package/dist/index.js +77 -6
- package/docs/architecture.md +43 -0
- package/docs/aws-deployment-runbook.md +26 -3
- package/docs/aws-runtime-security.md +473 -0
- package/docs/cloud-source-of-truth.md +482 -0
- package/docs/deployment-metadata.example.json +52 -0
- package/docs/monitoring-product-contract.md +493 -0
- package/docs/operational-tracking.md +91 -0
- package/infra/aws/main.tf +1 -0
- package/infra/aws/terraform.tfvars.example +1 -1
- package/infra/aws/variables.tf +2 -2
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,34 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.1.21] - 2026-06-28
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Fixed hosted production auth mode detection in bundled package output so
|
|
14
|
+
`NODE_ENV=production` rejects legacy raw hosted tokens unless scoped hosted
|
|
15
|
+
token JSON is configured.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Included all cloud deployment docs in the npm package so package consumers
|
|
20
|
+
receive the runtime security, source-of-truth, metadata, and runbook context.
|
|
21
|
+
|
|
22
|
+
## [0.1.20] - 2026-06-28
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- Added hosted-token JSON descriptor parsing from
|
|
27
|
+
`HASNA_UPTIME_HOSTED_TOKENS` and JSON-compatible
|
|
28
|
+
`HASNA_UPTIME_HOSTED_TOKEN` values, allowing deployed secrets to provide
|
|
29
|
+
scoped workspace tokens instead of one broad raw token.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- Updated hosted auth docs and AWS runbook guidance to prefer scoped static
|
|
34
|
+
operator tokens for zero-count smokes while keeping full production
|
|
35
|
+
identity/RBAC as a live gate.
|
|
36
|
+
|
|
9
37
|
## [0.1.19] - 2026-06-28
|
|
10
38
|
|
|
11
39
|
### Added
|
package/README.md
CHANGED
|
@@ -93,6 +93,21 @@ non-loopback mutation hosts by default. For a trusted remote bind, set
|
|
|
93
93
|
`Authorization: Bearer <token>` or `X-Uptime-Token: <token>`.
|
|
94
94
|
Hosted mode additionally accepts comma-separated public origins from
|
|
95
95
|
`HASNA_UPTIME_ALLOWED_ORIGINS` for deployments behind a TLS-terminating edge.
|
|
96
|
+
Hosted tokens can be provided as a single legacy token through
|
|
97
|
+
`HASNA_UPTIME_HOSTED_TOKEN`, or as scoped JSON through
|
|
98
|
+
`HASNA_UPTIME_HOSTED_TOKENS`:
|
|
99
|
+
|
|
100
|
+
```json
|
|
101
|
+
{
|
|
102
|
+
"tokens": [
|
|
103
|
+
{ "token": "read-token", "scopes": ["uptime:read"], "workspaceId": "default" },
|
|
104
|
+
{ "token": "write-token", "scopes": ["uptime:write"], "workspaceId": "default" }
|
|
105
|
+
]
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Use scoped JSON for hosted deployments. A single raw hosted token is kept only
|
|
110
|
+
for local compatibility and expands to broad read/write/probe/report scopes.
|
|
96
111
|
Endpoints that accept request bodies require `content-type: application/json`.
|
|
97
112
|
|
|
98
113
|
## Uptime Semantics
|
package/dist/api.js
CHANGED
|
@@ -4262,17 +4262,88 @@ function resolveApiToken(token) {
|
|
|
4262
4262
|
return value?.trim() || undefined;
|
|
4263
4263
|
}
|
|
4264
4264
|
function resolveHostedTokens(options) {
|
|
4265
|
-
|
|
4266
|
-
|
|
4265
|
+
const defaultWorkspaceId = process.env.HASNA_UPTIME_WORKSPACE_ID ?? "default";
|
|
4266
|
+
if (options.hostedTokens?.length) {
|
|
4267
|
+
return normalizeHostedTokenEntries(options.hostedTokens, defaultWorkspaceId);
|
|
4268
|
+
}
|
|
4269
|
+
const configuredTokens = process.env.HASNA_UPTIME_HOSTED_TOKENS;
|
|
4270
|
+
if (configuredTokens?.trim()) {
|
|
4271
|
+
return parseHostedTokensConfig(configuredTokens, defaultWorkspaceId, "HASNA_UPTIME_HOSTED_TOKENS");
|
|
4272
|
+
}
|
|
4267
4273
|
const token = options.hostedToken ?? process.env.HASNA_UPTIME_HOSTED_TOKEN;
|
|
4268
4274
|
if (!token?.trim())
|
|
4269
4275
|
return [];
|
|
4276
|
+
return parseHostedTokenValue(token, defaultWorkspaceId, options.hostedToken ? "--hosted-token" : "HASNA_UPTIME_HOSTED_TOKEN");
|
|
4277
|
+
}
|
|
4278
|
+
var HOSTED_SCOPES = ["uptime:read", "uptime:write", "uptime:probe", "uptime:report", "uptime:admin"];
|
|
4279
|
+
var HOSTED_SCOPE_SET = new Set(HOSTED_SCOPES);
|
|
4280
|
+
var LEGACY_HOSTED_TOKEN_SCOPES = ["uptime:read", "uptime:write", "uptime:probe", "uptime:report"];
|
|
4281
|
+
function parseHostedTokenValue(value, defaultWorkspaceId, source) {
|
|
4282
|
+
const trimmed = value.trim();
|
|
4283
|
+
if (!trimmed)
|
|
4284
|
+
return [];
|
|
4285
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
4286
|
+
return parseHostedTokensConfig(trimmed, defaultWorkspaceId, source);
|
|
4287
|
+
}
|
|
4288
|
+
if (isHostedProductionMode()) {
|
|
4289
|
+
throw new ApiError(`${source} must be scoped hosted token JSON when HASNA_UPTIME_HOSTED_AUTH_MODE=production`, 500);
|
|
4290
|
+
}
|
|
4270
4291
|
return [{
|
|
4271
|
-
token:
|
|
4272
|
-
scopes:
|
|
4273
|
-
workspaceId:
|
|
4292
|
+
token: trimmed,
|
|
4293
|
+
scopes: LEGACY_HOSTED_TOKEN_SCOPES,
|
|
4294
|
+
workspaceId: defaultWorkspaceId
|
|
4274
4295
|
}];
|
|
4275
4296
|
}
|
|
4297
|
+
function parseHostedTokensConfig(value, defaultWorkspaceId, source) {
|
|
4298
|
+
let parsed;
|
|
4299
|
+
try {
|
|
4300
|
+
parsed = JSON.parse(value);
|
|
4301
|
+
} catch {
|
|
4302
|
+
throw new ApiError(`${source} must be valid hosted token JSON`, 500);
|
|
4303
|
+
}
|
|
4304
|
+
const entries = Array.isArray(parsed) ? parsed : isRecord(parsed) && Array.isArray(parsed.tokens) ? parsed.tokens : isRecord(parsed) && typeof parsed.token === "string" ? [parsed] : undefined;
|
|
4305
|
+
if (!entries)
|
|
4306
|
+
throw new ApiError(`${source} must be a token object, token array, or object with tokens[]`, 500);
|
|
4307
|
+
return normalizeHostedTokenEntries(entries, defaultWorkspaceId, source);
|
|
4308
|
+
}
|
|
4309
|
+
function normalizeHostedTokenEntries(entries, defaultWorkspaceId, source = "hostedTokens") {
|
|
4310
|
+
const tokens = entries.map((entry, index) => normalizeHostedTokenEntry(entry, defaultWorkspaceId, `${source}[${index}]`));
|
|
4311
|
+
if (tokens.length === 0)
|
|
4312
|
+
throw new ApiError(`${source} must configure at least one hosted token`, 500);
|
|
4313
|
+
return tokens;
|
|
4314
|
+
}
|
|
4315
|
+
function normalizeHostedTokenEntry(entry, defaultWorkspaceId, source) {
|
|
4316
|
+
if (!isRecord(entry))
|
|
4317
|
+
throw new ApiError(`${source} must be an object`, 500);
|
|
4318
|
+
if (typeof entry.token !== "string" || !entry.token.trim()) {
|
|
4319
|
+
throw new ApiError(`${source}.token is required`, 500);
|
|
4320
|
+
}
|
|
4321
|
+
const scopes = normalizeHostedScopes(entry.scopes, `${source}.scopes`);
|
|
4322
|
+
const workspaceId = typeof entry.workspaceId === "string" && entry.workspaceId.trim() ? entry.workspaceId.trim() : defaultWorkspaceId;
|
|
4323
|
+
return { token: entry.token.trim(), scopes, workspaceId };
|
|
4324
|
+
}
|
|
4325
|
+
function normalizeHostedScopes(value, source) {
|
|
4326
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
4327
|
+
throw new ApiError(`${source} must be a non-empty array`, 500);
|
|
4328
|
+
}
|
|
4329
|
+
const scopes = new Set;
|
|
4330
|
+
for (const scope of value) {
|
|
4331
|
+
if (typeof scope !== "string" || !HOSTED_SCOPE_SET.has(scope)) {
|
|
4332
|
+
throw new ApiError(`${source} contains an invalid hosted scope`, 500);
|
|
4333
|
+
}
|
|
4334
|
+
scopes.add(scope);
|
|
4335
|
+
}
|
|
4336
|
+
return [...scopes];
|
|
4337
|
+
}
|
|
4338
|
+
function isRecord(value) {
|
|
4339
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4340
|
+
}
|
|
4341
|
+
function isHostedProductionMode() {
|
|
4342
|
+
return runtimeEnv("HASNA_UPTIME_HOSTED_AUTH_MODE") === "production" || runtimeEnv("NODE_ENV") === "production";
|
|
4343
|
+
}
|
|
4344
|
+
function runtimeEnv(name) {
|
|
4345
|
+
return process.env[name];
|
|
4346
|
+
}
|
|
4276
4347
|
function resolveHostedAllowedOrigins(options) {
|
|
4277
4348
|
const configured = options.hostedAllowedOrigins ?? splitCsv(process.env.HASNA_UPTIME_ALLOWED_ORIGINS);
|
|
4278
4349
|
return configured.map((origin) => normalizeAllowedOrigin(origin)).filter((origin) => Boolean(origin));
|
package/dist/cli/index.js
CHANGED
|
@@ -6856,17 +6856,88 @@ function resolveApiToken(token) {
|
|
|
6856
6856
|
return value?.trim() || undefined;
|
|
6857
6857
|
}
|
|
6858
6858
|
function resolveHostedTokens(options) {
|
|
6859
|
-
|
|
6860
|
-
|
|
6859
|
+
const defaultWorkspaceId = process.env.HASNA_UPTIME_WORKSPACE_ID ?? "default";
|
|
6860
|
+
if (options.hostedTokens?.length) {
|
|
6861
|
+
return normalizeHostedTokenEntries(options.hostedTokens, defaultWorkspaceId);
|
|
6862
|
+
}
|
|
6863
|
+
const configuredTokens = process.env.HASNA_UPTIME_HOSTED_TOKENS;
|
|
6864
|
+
if (configuredTokens?.trim()) {
|
|
6865
|
+
return parseHostedTokensConfig(configuredTokens, defaultWorkspaceId, "HASNA_UPTIME_HOSTED_TOKENS");
|
|
6866
|
+
}
|
|
6861
6867
|
const token = options.hostedToken ?? process.env.HASNA_UPTIME_HOSTED_TOKEN;
|
|
6862
6868
|
if (!token?.trim())
|
|
6863
6869
|
return [];
|
|
6870
|
+
return parseHostedTokenValue(token, defaultWorkspaceId, options.hostedToken ? "--hosted-token" : "HASNA_UPTIME_HOSTED_TOKEN");
|
|
6871
|
+
}
|
|
6872
|
+
var HOSTED_SCOPES = ["uptime:read", "uptime:write", "uptime:probe", "uptime:report", "uptime:admin"];
|
|
6873
|
+
var HOSTED_SCOPE_SET = new Set(HOSTED_SCOPES);
|
|
6874
|
+
var LEGACY_HOSTED_TOKEN_SCOPES = ["uptime:read", "uptime:write", "uptime:probe", "uptime:report"];
|
|
6875
|
+
function parseHostedTokenValue(value, defaultWorkspaceId, source) {
|
|
6876
|
+
const trimmed = value.trim();
|
|
6877
|
+
if (!trimmed)
|
|
6878
|
+
return [];
|
|
6879
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
6880
|
+
return parseHostedTokensConfig(trimmed, defaultWorkspaceId, source);
|
|
6881
|
+
}
|
|
6882
|
+
if (isHostedProductionMode()) {
|
|
6883
|
+
throw new ApiError(`${source} must be scoped hosted token JSON when HASNA_UPTIME_HOSTED_AUTH_MODE=production`, 500);
|
|
6884
|
+
}
|
|
6864
6885
|
return [{
|
|
6865
|
-
token:
|
|
6866
|
-
scopes:
|
|
6867
|
-
workspaceId:
|
|
6886
|
+
token: trimmed,
|
|
6887
|
+
scopes: LEGACY_HOSTED_TOKEN_SCOPES,
|
|
6888
|
+
workspaceId: defaultWorkspaceId
|
|
6868
6889
|
}];
|
|
6869
6890
|
}
|
|
6891
|
+
function parseHostedTokensConfig(value, defaultWorkspaceId, source) {
|
|
6892
|
+
let parsed;
|
|
6893
|
+
try {
|
|
6894
|
+
parsed = JSON.parse(value);
|
|
6895
|
+
} catch {
|
|
6896
|
+
throw new ApiError(`${source} must be valid hosted token JSON`, 500);
|
|
6897
|
+
}
|
|
6898
|
+
const entries = Array.isArray(parsed) ? parsed : isRecord(parsed) && Array.isArray(parsed.tokens) ? parsed.tokens : isRecord(parsed) && typeof parsed.token === "string" ? [parsed] : undefined;
|
|
6899
|
+
if (!entries)
|
|
6900
|
+
throw new ApiError(`${source} must be a token object, token array, or object with tokens[]`, 500);
|
|
6901
|
+
return normalizeHostedTokenEntries(entries, defaultWorkspaceId, source);
|
|
6902
|
+
}
|
|
6903
|
+
function normalizeHostedTokenEntries(entries, defaultWorkspaceId, source = "hostedTokens") {
|
|
6904
|
+
const tokens = entries.map((entry, index) => normalizeHostedTokenEntry(entry, defaultWorkspaceId, `${source}[${index}]`));
|
|
6905
|
+
if (tokens.length === 0)
|
|
6906
|
+
throw new ApiError(`${source} must configure at least one hosted token`, 500);
|
|
6907
|
+
return tokens;
|
|
6908
|
+
}
|
|
6909
|
+
function normalizeHostedTokenEntry(entry, defaultWorkspaceId, source) {
|
|
6910
|
+
if (!isRecord(entry))
|
|
6911
|
+
throw new ApiError(`${source} must be an object`, 500);
|
|
6912
|
+
if (typeof entry.token !== "string" || !entry.token.trim()) {
|
|
6913
|
+
throw new ApiError(`${source}.token is required`, 500);
|
|
6914
|
+
}
|
|
6915
|
+
const scopes = normalizeHostedScopes(entry.scopes, `${source}.scopes`);
|
|
6916
|
+
const workspaceId = typeof entry.workspaceId === "string" && entry.workspaceId.trim() ? entry.workspaceId.trim() : defaultWorkspaceId;
|
|
6917
|
+
return { token: entry.token.trim(), scopes, workspaceId };
|
|
6918
|
+
}
|
|
6919
|
+
function normalizeHostedScopes(value, source) {
|
|
6920
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
6921
|
+
throw new ApiError(`${source} must be a non-empty array`, 500);
|
|
6922
|
+
}
|
|
6923
|
+
const scopes = new Set;
|
|
6924
|
+
for (const scope of value) {
|
|
6925
|
+
if (typeof scope !== "string" || !HOSTED_SCOPE_SET.has(scope)) {
|
|
6926
|
+
throw new ApiError(`${source} contains an invalid hosted scope`, 500);
|
|
6927
|
+
}
|
|
6928
|
+
scopes.add(scope);
|
|
6929
|
+
}
|
|
6930
|
+
return [...scopes];
|
|
6931
|
+
}
|
|
6932
|
+
function isRecord(value) {
|
|
6933
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6934
|
+
}
|
|
6935
|
+
function isHostedProductionMode() {
|
|
6936
|
+
return runtimeEnv("HASNA_UPTIME_HOSTED_AUTH_MODE") === "production" || runtimeEnv("NODE_ENV") === "production";
|
|
6937
|
+
}
|
|
6938
|
+
function runtimeEnv(name) {
|
|
6939
|
+
return process.env[name];
|
|
6940
|
+
}
|
|
6870
6941
|
function resolveHostedAllowedOrigins(options) {
|
|
6871
6942
|
const configured = options.hostedAllowedOrigins ?? splitCsv(process.env.HASNA_UPTIME_ALLOWED_ORIGINS);
|
|
6872
6943
|
return configured.map((origin) => normalizeAllowedOrigin(origin)).filter((origin) => Boolean(origin));
|
|
@@ -6943,7 +7014,7 @@ function buildAwsDeploymentPlan(options = {}) {
|
|
|
6943
7014
|
const image = clean(options.image, `${imageRepositoryUri}@sha256:<image-digest>`);
|
|
6944
7015
|
const evidenceBucket = clean(options.evidenceBucket, `hasna-${stage}-${prefix}-evidence`);
|
|
6945
7016
|
const hostedSqliteDbPath = clean(options.hostedSqliteDbPath, DEFAULT_HOSTED_SQLITE_DB);
|
|
6946
|
-
const runtimePackageVersion = clean(options.runtimePackageVersion, "0.1.
|
|
7017
|
+
const runtimePackageVersion = clean(options.runtimePackageVersion, "0.1.21");
|
|
6947
7018
|
const protectedAccessMode = options.protectedAccessMode ?? DEFAULT_PROTECTED_ACCESS_MODE;
|
|
6948
7019
|
const protectedAccessUrl = protectedAccessMode === "cloudfront_default_domain" ? "https://<cloudfront-domain>" : `https://${hostname}`;
|
|
6949
7020
|
const cluster = `${prefix}-${stage}`;
|
|
@@ -7756,7 +7827,7 @@ program2.command("restore <backup-path>").description("Restore a verified local
|
|
|
7756
7827
|
fail(error);
|
|
7757
7828
|
}
|
|
7758
7829
|
});
|
|
7759
|
-
program2.command("serve").description("Serve the local API and dashboard").option("--host <host>", "host to bind", "127.0.0.1").option("--port <port>", "port", parseInteger, 3899).option("--check", "run the scheduler while serving").addOption(new Option("--mode <mode>", "runtime mode").choices(["local", "hosted"]).default("local")).option("--api-token <token>", "token required for non-loopback mutation hosts").option("--hosted-token <token>", "scoped hosted-
|
|
7830
|
+
program2.command("serve").description("Serve the local API and dashboard").option("--host <host>", "host to bind", "127.0.0.1").option("--port <port>", "port", parseInteger, 3899).option("--check", "run the scheduler while serving").addOption(new Option("--mode <mode>", "runtime mode").choices(["local", "hosted"]).default("local")).option("--api-token <token>", "token required for non-loopback mutation hosts").option("--hosted-token <token>", "hosted-mode token for local/dev use; deployments should prefer scoped hosted-token JSON in secret env").option("--hosted-sqlite-db <path>", "absolute SQLite database path on hosted cloud-mounted storage").option("--allow-hosted-local-store", "allow hosted mode to use local SQLite as an explicit fallback").option("--allow-unsafe-remote-mutations", "allow state-changing requests from non-loopback hosts without a token").option("-j, --json", "print JSON").action((opts) => {
|
|
7760
7831
|
try {
|
|
7761
7832
|
const { server } = serveUptime({
|
|
7762
7833
|
host: opts.host,
|
package/dist/cloud-plan.js
CHANGED
|
@@ -21,7 +21,7 @@ function buildAwsDeploymentPlan(options = {}) {
|
|
|
21
21
|
const image = clean(options.image, `${imageRepositoryUri}@sha256:<image-digest>`);
|
|
22
22
|
const evidenceBucket = clean(options.evidenceBucket, `hasna-${stage}-${prefix}-evidence`);
|
|
23
23
|
const hostedSqliteDbPath = clean(options.hostedSqliteDbPath, DEFAULT_HOSTED_SQLITE_DB);
|
|
24
|
-
const runtimePackageVersion = clean(options.runtimePackageVersion, "0.1.
|
|
24
|
+
const runtimePackageVersion = clean(options.runtimePackageVersion, "0.1.21");
|
|
25
25
|
const protectedAccessMode = options.protectedAccessMode ?? DEFAULT_PROTECTED_ACCESS_MODE;
|
|
26
26
|
const protectedAccessUrl = protectedAccessMode === "cloudfront_default_domain" ? "https://<cloudfront-domain>" : `https://${hostname}`;
|
|
27
27
|
const cluster = `${prefix}-${stage}`;
|
package/dist/index.js
CHANGED
|
@@ -4262,17 +4262,88 @@ function resolveApiToken(token) {
|
|
|
4262
4262
|
return value?.trim() || undefined;
|
|
4263
4263
|
}
|
|
4264
4264
|
function resolveHostedTokens(options) {
|
|
4265
|
-
|
|
4266
|
-
|
|
4265
|
+
const defaultWorkspaceId = process.env.HASNA_UPTIME_WORKSPACE_ID ?? "default";
|
|
4266
|
+
if (options.hostedTokens?.length) {
|
|
4267
|
+
return normalizeHostedTokenEntries(options.hostedTokens, defaultWorkspaceId);
|
|
4268
|
+
}
|
|
4269
|
+
const configuredTokens = process.env.HASNA_UPTIME_HOSTED_TOKENS;
|
|
4270
|
+
if (configuredTokens?.trim()) {
|
|
4271
|
+
return parseHostedTokensConfig(configuredTokens, defaultWorkspaceId, "HASNA_UPTIME_HOSTED_TOKENS");
|
|
4272
|
+
}
|
|
4267
4273
|
const token = options.hostedToken ?? process.env.HASNA_UPTIME_HOSTED_TOKEN;
|
|
4268
4274
|
if (!token?.trim())
|
|
4269
4275
|
return [];
|
|
4276
|
+
return parseHostedTokenValue(token, defaultWorkspaceId, options.hostedToken ? "--hosted-token" : "HASNA_UPTIME_HOSTED_TOKEN");
|
|
4277
|
+
}
|
|
4278
|
+
var HOSTED_SCOPES = ["uptime:read", "uptime:write", "uptime:probe", "uptime:report", "uptime:admin"];
|
|
4279
|
+
var HOSTED_SCOPE_SET = new Set(HOSTED_SCOPES);
|
|
4280
|
+
var LEGACY_HOSTED_TOKEN_SCOPES = ["uptime:read", "uptime:write", "uptime:probe", "uptime:report"];
|
|
4281
|
+
function parseHostedTokenValue(value, defaultWorkspaceId, source) {
|
|
4282
|
+
const trimmed = value.trim();
|
|
4283
|
+
if (!trimmed)
|
|
4284
|
+
return [];
|
|
4285
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
4286
|
+
return parseHostedTokensConfig(trimmed, defaultWorkspaceId, source);
|
|
4287
|
+
}
|
|
4288
|
+
if (isHostedProductionMode()) {
|
|
4289
|
+
throw new ApiError(`${source} must be scoped hosted token JSON when HASNA_UPTIME_HOSTED_AUTH_MODE=production`, 500);
|
|
4290
|
+
}
|
|
4270
4291
|
return [{
|
|
4271
|
-
token:
|
|
4272
|
-
scopes:
|
|
4273
|
-
workspaceId:
|
|
4292
|
+
token: trimmed,
|
|
4293
|
+
scopes: LEGACY_HOSTED_TOKEN_SCOPES,
|
|
4294
|
+
workspaceId: defaultWorkspaceId
|
|
4274
4295
|
}];
|
|
4275
4296
|
}
|
|
4297
|
+
function parseHostedTokensConfig(value, defaultWorkspaceId, source) {
|
|
4298
|
+
let parsed;
|
|
4299
|
+
try {
|
|
4300
|
+
parsed = JSON.parse(value);
|
|
4301
|
+
} catch {
|
|
4302
|
+
throw new ApiError(`${source} must be valid hosted token JSON`, 500);
|
|
4303
|
+
}
|
|
4304
|
+
const entries = Array.isArray(parsed) ? parsed : isRecord(parsed) && Array.isArray(parsed.tokens) ? parsed.tokens : isRecord(parsed) && typeof parsed.token === "string" ? [parsed] : undefined;
|
|
4305
|
+
if (!entries)
|
|
4306
|
+
throw new ApiError(`${source} must be a token object, token array, or object with tokens[]`, 500);
|
|
4307
|
+
return normalizeHostedTokenEntries(entries, defaultWorkspaceId, source);
|
|
4308
|
+
}
|
|
4309
|
+
function normalizeHostedTokenEntries(entries, defaultWorkspaceId, source = "hostedTokens") {
|
|
4310
|
+
const tokens = entries.map((entry, index) => normalizeHostedTokenEntry(entry, defaultWorkspaceId, `${source}[${index}]`));
|
|
4311
|
+
if (tokens.length === 0)
|
|
4312
|
+
throw new ApiError(`${source} must configure at least one hosted token`, 500);
|
|
4313
|
+
return tokens;
|
|
4314
|
+
}
|
|
4315
|
+
function normalizeHostedTokenEntry(entry, defaultWorkspaceId, source) {
|
|
4316
|
+
if (!isRecord(entry))
|
|
4317
|
+
throw new ApiError(`${source} must be an object`, 500);
|
|
4318
|
+
if (typeof entry.token !== "string" || !entry.token.trim()) {
|
|
4319
|
+
throw new ApiError(`${source}.token is required`, 500);
|
|
4320
|
+
}
|
|
4321
|
+
const scopes = normalizeHostedScopes(entry.scopes, `${source}.scopes`);
|
|
4322
|
+
const workspaceId = typeof entry.workspaceId === "string" && entry.workspaceId.trim() ? entry.workspaceId.trim() : defaultWorkspaceId;
|
|
4323
|
+
return { token: entry.token.trim(), scopes, workspaceId };
|
|
4324
|
+
}
|
|
4325
|
+
function normalizeHostedScopes(value, source) {
|
|
4326
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
4327
|
+
throw new ApiError(`${source} must be a non-empty array`, 500);
|
|
4328
|
+
}
|
|
4329
|
+
const scopes = new Set;
|
|
4330
|
+
for (const scope of value) {
|
|
4331
|
+
if (typeof scope !== "string" || !HOSTED_SCOPE_SET.has(scope)) {
|
|
4332
|
+
throw new ApiError(`${source} contains an invalid hosted scope`, 500);
|
|
4333
|
+
}
|
|
4334
|
+
scopes.add(scope);
|
|
4335
|
+
}
|
|
4336
|
+
return [...scopes];
|
|
4337
|
+
}
|
|
4338
|
+
function isRecord(value) {
|
|
4339
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4340
|
+
}
|
|
4341
|
+
function isHostedProductionMode() {
|
|
4342
|
+
return runtimeEnv("HASNA_UPTIME_HOSTED_AUTH_MODE") === "production" || runtimeEnv("NODE_ENV") === "production";
|
|
4343
|
+
}
|
|
4344
|
+
function runtimeEnv(name) {
|
|
4345
|
+
return process.env[name];
|
|
4346
|
+
}
|
|
4276
4347
|
function resolveHostedAllowedOrigins(options) {
|
|
4277
4348
|
const configured = options.hostedAllowedOrigins ?? splitCsv(process.env.HASNA_UPTIME_ALLOWED_ORIGINS);
|
|
4278
4349
|
return configured.map((origin) => normalizeAllowedOrigin(origin)).filter((origin) => Boolean(origin));
|
|
@@ -4349,7 +4420,7 @@ function buildAwsDeploymentPlan(options = {}) {
|
|
|
4349
4420
|
const image = clean(options.image, `${imageRepositoryUri}@sha256:<image-digest>`);
|
|
4350
4421
|
const evidenceBucket = clean(options.evidenceBucket, `hasna-${stage}-${prefix}-evidence`);
|
|
4351
4422
|
const hostedSqliteDbPath = clean(options.hostedSqliteDbPath, DEFAULT_HOSTED_SQLITE_DB);
|
|
4352
|
-
const runtimePackageVersion = clean(options.runtimePackageVersion, "0.1.
|
|
4423
|
+
const runtimePackageVersion = clean(options.runtimePackageVersion, "0.1.21");
|
|
4353
4424
|
const protectedAccessMode = options.protectedAccessMode ?? DEFAULT_PROTECTED_ACCESS_MODE;
|
|
4354
4425
|
const protectedAccessUrl = protectedAccessMode === "cloudfront_default_domain" ? "https://<cloudfront-domain>" : `https://${hostname}`;
|
|
4355
4426
|
const cluster = `${prefix}-${stage}`;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
Open Uptime has four public surfaces over one local service model:
|
|
4
|
+
|
|
5
|
+
- SDK: `createUptimeClient()`
|
|
6
|
+
- CLI: `uptime`
|
|
7
|
+
- MCP: `uptime-mcp`
|
|
8
|
+
- API/dashboard: `uptime serve`
|
|
9
|
+
|
|
10
|
+
State is stored in SQLite through `UptimeStore`. `UptimeService` owns monitor
|
|
11
|
+
checks, retry policy, incident reconciliation, scheduler ticks, and summaries.
|
|
12
|
+
The CLI, MCP server, and API call the service rather than maintaining separate
|
|
13
|
+
business logic.
|
|
14
|
+
|
|
15
|
+
The local HTTP API is intended for same-origin dashboard use and local
|
|
16
|
+
automation. State-changing API requests reject mismatched browser `Origin`
|
|
17
|
+
headers, and JSON mutation endpoints require `content-type: application/json`.
|
|
18
|
+
|
|
19
|
+
## Data Model
|
|
20
|
+
|
|
21
|
+
- `monitors`: configured HTTP/TCP monitors and current status.
|
|
22
|
+
- `check_results`: immutable check attempts after retry resolution.
|
|
23
|
+
- `incidents`: open/closed downtime windows per monitor.
|
|
24
|
+
|
|
25
|
+
## Check Semantics
|
|
26
|
+
|
|
27
|
+
HTTP monitors are up when the request completes before timeout and the response
|
|
28
|
+
status is either the configured `expectedStatus` or any 2xx/3xx status when no
|
|
29
|
+
specific status is configured. TCP monitors are up when a connection can be
|
|
30
|
+
opened before timeout.
|
|
31
|
+
|
|
32
|
+
Retries happen before a result is recorded. One stored check result represents
|
|
33
|
+
the final outcome for that scheduled check.
|
|
34
|
+
|
|
35
|
+
Monitor interval, timeout, and retry settings are bounded in the store so every
|
|
36
|
+
surface (SDK, CLI, API, and MCP) shares the same protection against runaway
|
|
37
|
+
checks. MCP schemas mirror those bounds for earlier validation.
|
|
38
|
+
|
|
39
|
+
`uptimePercent` is intentionally a check-count availability metric in the first
|
|
40
|
+
release: up stored results divided by all stored results for that monitor. It is
|
|
41
|
+
not elapsed-time SLA accounting. Incident windows are stored separately so a
|
|
42
|
+
later report can add duration-based availability without changing the check
|
|
43
|
+
history model.
|
|
@@ -207,7 +207,7 @@ ids. Use a scoped hosted token only from the operator secret store.
|
|
|
207
207
|
|
|
208
208
|
```bash
|
|
209
209
|
EDGE_URL="$(terraform -chdir="$TF_DIR" output -raw protected_access_url)"
|
|
210
|
-
: "${HOSTED_TOKEN_FILE:?set HOSTED_TOKEN_FILE to a 0600 file containing the scoped hosted token}"
|
|
210
|
+
: "${HOSTED_TOKEN_FILE:?set HOSTED_TOKEN_FILE to a 0600 file containing the scoped read hosted token}"
|
|
211
211
|
HOSTED_TOKEN="$(tr -d '\n' < "$HOSTED_TOKEN_FILE")"
|
|
212
212
|
|
|
213
213
|
curl -fsS "$EDGE_URL/health"
|
|
@@ -225,6 +225,22 @@ Expected results:
|
|
|
225
225
|
- Direct ALB origin access is denied unless it is the approved CloudFront origin
|
|
226
226
|
path.
|
|
227
227
|
|
|
228
|
+
Hosted deployments should store scoped hosted-token JSON in Secrets Manager, not
|
|
229
|
+
a single broad raw token. The runtime accepts `HASNA_UPTIME_HOSTED_TOKENS` JSON
|
|
230
|
+
or JSON-compatible `HASNA_UPTIME_HOSTED_TOKEN` values shaped like:
|
|
231
|
+
|
|
232
|
+
```json
|
|
233
|
+
{
|
|
234
|
+
"tokens": [
|
|
235
|
+
{ "token": "<read-token>", "scopes": ["uptime:read"], "workspaceId": "<workspace-id>" },
|
|
236
|
+
{ "token": "<write-token>", "scopes": ["uptime:write"], "workspaceId": "<workspace-id>" }
|
|
237
|
+
]
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Do not record token values in runbooks, logs, task overrides, or deployment
|
|
242
|
+
evidence.
|
|
243
|
+
|
|
228
244
|
## Logs And Alarms
|
|
229
245
|
|
|
230
246
|
Inspect recent web logs without printing secrets:
|
|
@@ -330,14 +346,21 @@ aws efs create-mount-target \
|
|
|
330
346
|
```
|
|
331
347
|
|
|
332
348
|
Validate the restored `/data/uptime/uptime.db` from a staging host or task with
|
|
333
|
-
read-only SQLite integrity checks.
|
|
334
|
-
|
|
349
|
+
read-only SQLite integrity checks. For a zero-count pre-production deployment
|
|
350
|
+
where `uptime.db` does not exist yet, create a representative restore-drill DB
|
|
351
|
+
with the same SQLite access path and record it separately. Capture only counts
|
|
352
|
+
and integrity status, not monitor targets or secrets:
|
|
335
353
|
|
|
336
354
|
```bash
|
|
337
355
|
sqlite3 /mnt/restore/uptime/uptime.db 'PRAGMA integrity_check;'
|
|
338
356
|
sqlite3 /mnt/restore/uptime/uptime.db 'SELECT COUNT(*) FROM monitors;'
|
|
339
357
|
```
|
|
340
358
|
|
|
359
|
+
Do not count a restore as complete if the task only proves that EFS mounted.
|
|
360
|
+
The evidence must include the restored DB path, `PRAGMA integrity_check = ok`,
|
|
361
|
+
schema version, sanitized table counts, and cleanup proof for the temporary
|
|
362
|
+
mount target and file system.
|
|
363
|
+
|
|
341
364
|
After evidence is recorded, delete the staging mount target and restored file
|
|
342
365
|
system. Never mount the restored file system over production during a drill.
|
|
343
366
|
|