@hasna/uptime 0.1.19 → 0.1.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.
- package/CHANGELOG.md +15 -0
- package/README.md +15 -0
- package/dist/api.js +73 -5
- package/dist/cli/index.js +75 -7
- package/dist/cloud-plan.js +1 -1
- package/dist/index.js +74 -6
- package/docs/aws-deployment-runbook.md +26 -3
- 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 +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,21 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.1.20] - 2026-06-28
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Added hosted-token JSON descriptor parsing from
|
|
14
|
+
`HASNA_UPTIME_HOSTED_TOKENS` and JSON-compatible
|
|
15
|
+
`HASNA_UPTIME_HOSTED_TOKEN` values, allowing deployed secrets to provide
|
|
16
|
+
scoped workspace tokens instead of one broad raw token.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Updated hosted auth docs and AWS runbook guidance to prefer scoped static
|
|
21
|
+
operator tokens for zero-count smokes while keeping full production
|
|
22
|
+
identity/RBAC as a live gate.
|
|
23
|
+
|
|
9
24
|
## [0.1.19] - 2026-06-28
|
|
10
25
|
|
|
11
26
|
### 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,85 @@ 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 process.env.HASNA_UPTIME_HOSTED_AUTH_MODE === "production" || false;
|
|
4343
|
+
}
|
|
4276
4344
|
function resolveHostedAllowedOrigins(options) {
|
|
4277
4345
|
const configured = options.hostedAllowedOrigins ?? splitCsv(process.env.HASNA_UPTIME_ALLOWED_ORIGINS);
|
|
4278
4346
|
return configured.map((origin) => normalizeAllowedOrigin(origin)).filter((origin) => Boolean(origin));
|
package/dist/cli/index.js
CHANGED
|
@@ -6856,17 +6856,85 @@ 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 process.env.HASNA_UPTIME_HOSTED_AUTH_MODE === "production" || false;
|
|
6937
|
+
}
|
|
6870
6938
|
function resolveHostedAllowedOrigins(options) {
|
|
6871
6939
|
const configured = options.hostedAllowedOrigins ?? splitCsv(process.env.HASNA_UPTIME_ALLOWED_ORIGINS);
|
|
6872
6940
|
return configured.map((origin) => normalizeAllowedOrigin(origin)).filter((origin) => Boolean(origin));
|
|
@@ -6943,7 +7011,7 @@ function buildAwsDeploymentPlan(options = {}) {
|
|
|
6943
7011
|
const image = clean(options.image, `${imageRepositoryUri}@sha256:<image-digest>`);
|
|
6944
7012
|
const evidenceBucket = clean(options.evidenceBucket, `hasna-${stage}-${prefix}-evidence`);
|
|
6945
7013
|
const hostedSqliteDbPath = clean(options.hostedSqliteDbPath, DEFAULT_HOSTED_SQLITE_DB);
|
|
6946
|
-
const runtimePackageVersion = clean(options.runtimePackageVersion, "0.1.
|
|
7014
|
+
const runtimePackageVersion = clean(options.runtimePackageVersion, "0.1.20");
|
|
6947
7015
|
const protectedAccessMode = options.protectedAccessMode ?? DEFAULT_PROTECTED_ACCESS_MODE;
|
|
6948
7016
|
const protectedAccessUrl = protectedAccessMode === "cloudfront_default_domain" ? "https://<cloudfront-domain>" : `https://${hostname}`;
|
|
6949
7017
|
const cluster = `${prefix}-${stage}`;
|
|
@@ -7756,7 +7824,7 @@ program2.command("restore <backup-path>").description("Restore a verified local
|
|
|
7756
7824
|
fail(error);
|
|
7757
7825
|
}
|
|
7758
7826
|
});
|
|
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-
|
|
7827
|
+
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
7828
|
try {
|
|
7761
7829
|
const { server } = serveUptime({
|
|
7762
7830
|
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.20");
|
|
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,85 @@ 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 process.env.HASNA_UPTIME_HOSTED_AUTH_MODE === "production" || false;
|
|
4343
|
+
}
|
|
4276
4344
|
function resolveHostedAllowedOrigins(options) {
|
|
4277
4345
|
const configured = options.hostedAllowedOrigins ?? splitCsv(process.env.HASNA_UPTIME_ALLOWED_ORIGINS);
|
|
4278
4346
|
return configured.map((origin) => normalizeAllowedOrigin(origin)).filter((origin) => Boolean(origin));
|
|
@@ -4349,7 +4417,7 @@ function buildAwsDeploymentPlan(options = {}) {
|
|
|
4349
4417
|
const image = clean(options.image, `${imageRepositoryUri}@sha256:<image-digest>`);
|
|
4350
4418
|
const evidenceBucket = clean(options.evidenceBucket, `hasna-${stage}-${prefix}-evidence`);
|
|
4351
4419
|
const hostedSqliteDbPath = clean(options.hostedSqliteDbPath, DEFAULT_HOSTED_SQLITE_DB);
|
|
4352
|
-
const runtimePackageVersion = clean(options.runtimePackageVersion, "0.1.
|
|
4420
|
+
const runtimePackageVersion = clean(options.runtimePackageVersion, "0.1.20");
|
|
4353
4421
|
const protectedAccessMode = options.protectedAccessMode ?? DEFAULT_PROTECTED_ACCESS_MODE;
|
|
4354
4422
|
const protectedAccessUrl = protectedAccessMode === "cloudfront_default_domain" ? "https://<cloudfront-domain>" : `https://${hostname}`;
|
|
4355
4423
|
const cluster = `${prefix}-${stage}`;
|
|
@@ -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
|
|
package/infra/aws/main.tf
CHANGED
|
@@ -1153,6 +1153,7 @@ resource "aws_ecs_task_definition" "service" {
|
|
|
1153
1153
|
}] : []
|
|
1154
1154
|
environment = concat([
|
|
1155
1155
|
{ name = "HASNA_UPTIME_MODE", value = "hosted" },
|
|
1156
|
+
{ name = "HASNA_UPTIME_HOSTED_AUTH_MODE", value = "production" },
|
|
1156
1157
|
{ name = "HASNA_UPTIME_WORKSPACE_ID", value = var.workspace_id },
|
|
1157
1158
|
{ name = "HASNA_UPTIME_COMPONENT", value = each.key },
|
|
1158
1159
|
{ name = "HASNA_UPTIME_HOSTNAME", value = var.hostname },
|
|
@@ -19,7 +19,7 @@ alb_ingress_cidr_blocks = []
|
|
|
19
19
|
private_subnet_ids = ["subnet-replace-private-a", "subnet-replace-private-b"]
|
|
20
20
|
private_route_table_ids = ["rtb-replace-private"]
|
|
21
21
|
container_image = "123456789012.dkr.ecr.us-east-1.amazonaws.com/open-uptime@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
22
|
-
runtime_package_version = "0.1.
|
|
22
|
+
runtime_package_version = "0.1.20"
|
|
23
23
|
certificate_arn = null
|
|
24
24
|
hosted_zone_id = null
|
|
25
25
|
app_env_secret_arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:open-uptime/prod/app/env"
|
package/infra/aws/variables.tf
CHANGED
|
@@ -201,7 +201,7 @@ variable "container_image" {
|
|
|
201
201
|
variable "runtime_package_version" {
|
|
202
202
|
description = "Published @hasna/uptime package version that CodeBuild should build into the ECR image."
|
|
203
203
|
type = string
|
|
204
|
-
default = "0.1.
|
|
204
|
+
default = "0.1.20"
|
|
205
205
|
|
|
206
206
|
validation {
|
|
207
207
|
condition = can(regex("^[0-9]+\\.[0-9]+\\.[0-9]+(-[0-9A-Za-z.-]+)?$", var.runtime_package_version))
|
|
@@ -242,7 +242,7 @@ variable "app_env_secret_arn" {
|
|
|
242
242
|
}
|
|
243
243
|
|
|
244
244
|
variable "hosted_token_secret_arn" {
|
|
245
|
-
description = "Secrets Manager/SSM ARN
|
|
245
|
+
description = "Secrets Manager/SSM ARN injected as HASNA_UPTIME_HOSTED_TOKEN. Hosted deployments should store scoped hosted-token JSON descriptors here, not a single broad raw token."
|
|
246
246
|
type = string
|
|
247
247
|
|
|
248
248
|
validation {
|
package/package.json
CHANGED