@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 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
- if (options.hostedTokens?.length)
4266
- return options.hostedTokens;
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: token.trim(),
4272
- scopes: ["uptime:read", "uptime:write", "uptime:probe", "uptime:report"],
4273
- workspaceId: process.env.HASNA_UPTIME_WORKSPACE_ID ?? "default"
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
- if (options.hostedTokens?.length)
6860
- return options.hostedTokens;
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: token.trim(),
6866
- scopes: ["uptime:read", "uptime:write", "uptime:probe", "uptime:report"],
6867
- workspaceId: process.env.HASNA_UPTIME_WORKSPACE_ID ?? "default"
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.19");
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-mode token").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) => {
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,
@@ -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.19");
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
- if (options.hostedTokens?.length)
4266
- return options.hostedTokens;
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: token.trim(),
4272
- scopes: ["uptime:read", "uptime:write", "uptime:probe", "uptime:report"],
4273
- workspaceId: process.env.HASNA_UPTIME_WORKSPACE_ID ?? "default"
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.19");
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. Capture only counts and integrity status, not
334
- monitor targets or secrets:
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