@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 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
- 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 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
- 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 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.19");
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-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) => {
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,
@@ -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.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
- 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 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.19");
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. 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
 
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.19"
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"
@@ -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.19"
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 containing HASNA_UPTIME_HOSTED_TOKEN for hosted web auth bootstrap."
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/uptime",
3
- "version": "0.1.19",
3
+ "version": "0.1.20",
4
4
  "description": "Local-first uptime and downtime monitoring service with CLI, MCP, SDK, SQLite persistence, and a dashboard.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",