@hasna/uptime 0.1.18 → 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,36 @@ 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
+
24
+ ## [0.1.19] - 2026-06-28
25
+
26
+ ### Added
27
+
28
+ - Added optional CloudFront origin verification header binding to the AWS
29
+ Terraform module. When enabled, CloudFront sends a private origin header and
30
+ the ALB listener returns `403` for direct origin requests that do not present
31
+ the matching value.
32
+
33
+ ### Changed
34
+
35
+ - Updated AWS runbooks, deployment metadata, and cloud source-of-truth docs to
36
+ distinguish CloudFront prefix-list narrowing from distribution-bound origin
37
+ access.
38
+
9
39
  ## [0.1.18] - 2026-06-28
10
40
 
11
41
  ### Changed
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.18");
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}`;
@@ -6986,7 +7054,7 @@ function buildAwsDeploymentPlan(options = {}) {
6986
7054
  ];
6987
7055
  return {
6988
7056
  kind: "open-uptime.aws-deployment-plan",
6989
- version: 3,
7057
+ version: 4,
6990
7058
  generatedAt: new Date().toISOString(),
6991
7059
  status: "blocked",
6992
7060
  canApply: false,
@@ -7011,6 +7079,17 @@ function buildAwsDeploymentPlan(options = {}) {
7011
7079
  protectedAccessMode,
7012
7080
  edgeDistribution: protectedAccessMode === "cloudfront_default_domain" ? `${prefix}-${stage}-edge` : undefined,
7013
7081
  protectedAccessUrl,
7082
+ originVerification: protectedAccessMode === "cloudfront_default_domain" ? {
7083
+ mode: "cloudfront_origin_header",
7084
+ requiredBeforeScaleUp: true,
7085
+ headerName: "X-Open-Uptime-Origin-Verify",
7086
+ valueStoredInTerraformState: true,
7087
+ stateAccessWarning: "The origin verification header value is sensitive but is stored in encrypted Terraform state and CloudFront/ALB configuration; restrict state, plan, CloudFront distribution-read, and ELB rule-read access."
7088
+ } : {
7089
+ mode: "alb_tls",
7090
+ requiredBeforeScaleUp: false,
7091
+ valueStoredInTerraformState: false
7092
+ },
7014
7093
  targetGroups: [`${prefix}-${stage}-web-tg`],
7015
7094
  securityGroups: [
7016
7095
  `${prefix}-${stage}-alb-sg`,
@@ -7058,7 +7137,7 @@ function buildAwsDeploymentPlan(options = {}) {
7058
7137
  `Infra PR must declare CodeBuild image builder ${prefix}-${stage}-image-builder for @hasna/uptime@${runtimePackageVersion}.`,
7059
7138
  `Infra PR must declare hardened S3 evidence bucket ${evidenceBucket} with KMS, versioning, lifecycle, and public access block.`,
7060
7139
  `Infra PR must declare encrypted EFS ${prefix}-${stage}-data with access point, mount targets, and AWS Backup plan.`,
7061
- protectedAccessMode === "cloudfront_default_domain" ? "Infra PR must declare CloudFront default-domain HTTPS edge, ALB HTTP listener restricted to CloudFront origin-facing ranges, ECS/Fargate cluster, target groups, security groups, IAM roles, CloudWatch log groups, and Secrets Manager refs." : `Infra PR must declare ECS/Fargate cluster ${cluster}, ALB HTTPS listener, target groups, security groups, IAM roles, CloudWatch log groups, and Secrets Manager refs.`,
7140
+ protectedAccessMode === "cloudfront_default_domain" ? "Infra PR must declare CloudFront default-domain HTTPS edge, ALB HTTP listener restricted to CloudFront origin-facing ranges, CloudFront-only origin verification header binding, ECS/Fargate cluster, target groups, security groups, IAM roles, CloudWatch log groups, and Secrets Manager refs." : `Infra PR must declare ECS/Fargate cluster ${cluster}, ALB HTTPS listener, target groups, security groups, IAM roles, CloudWatch log groups, and Secrets Manager refs.`,
7062
7141
  "Only apply the infra plan from the approved infrastructure repository after review evidence is attached."
7063
7142
  ],
7064
7143
  deploy: [
@@ -7067,7 +7146,7 @@ function buildAwsDeploymentPlan(options = {}) {
7067
7146
  "For the EFS SQLite bridge, do not run migration, scheduler, public-probe, or reporter tasks; keep them at desired count 0 until Postgres and cloud leases exist.",
7068
7147
  `Register task definitions for ${services.map((service) => service.name).join(", ")} using valueFrom secrets.`,
7069
7148
  `Update ECS services in cluster ${cluster} one component at a time through the approved deploy pipeline.`,
7070
- protectedAccessMode === "cloudfront_default_domain" ? "Use the CloudFront default HTTPS domain for first protected access; add custom DNS/certificate only after edge ownership is approved." : `Create Route53/edge record for ${hostname} only after ALB health checks pass and auth denial smokes succeed.`
7149
+ protectedAccessMode === "cloudfront_default_domain" ? "Use the CloudFront default HTTPS domain with origin verification header binding for first protected access; add custom DNS/certificate only after edge ownership is approved." : `Create Route53/edge record for ${hostname} only after ALB health checks pass and auth denial smokes succeed.`
7071
7150
  ],
7072
7151
  rollback: [
7073
7152
  "Keep previous task definition ARNs before each service update.",
@@ -7083,6 +7162,7 @@ function buildAwsDeploymentPlan(options = {}) {
7083
7162
  },
7084
7163
  blockers: [
7085
7164
  "The infrastructure owner repository was not found in this workspace.",
7165
+ protectedAccessMode === "cloudfront_default_domain" ? "CloudFront origin verification header binding must be enabled and direct-origin denial must be proven before web desired count is raised above 0." : "ALB HTTPS ingress policy and auth-denial smokes must be proven before web desired count is raised above 0.",
7086
7166
  "The EFS SQLite bridge is single-writer only: web target desired count is 1 and scheduler/public-probe/reporter targets remain 0 until Postgres and cloud leases exist.",
7087
7167
  "Hosted production auth/RBAC must replace broad static hosted-token operation before exposure.",
7088
7168
  "Public probe execution still needs cloud check-job leases wired to runHostedHttpCheck and live policy-decision log evidence.",
@@ -7092,7 +7172,7 @@ function buildAwsDeploymentPlan(options = {}) {
7092
7172
  "Infrastructure PR/synth/plan from the approved infra repository.",
7093
7173
  "CodeBuild image-builder run, container smoke, and immutable image digest.",
7094
7174
  "ECS task definitions using secrets.valueFrom only.",
7095
- "CloudFront-default-domain or ALB TLS auth-denial smokes, direct-origin denial evidence, and web alarm checks.",
7175
+ "CloudFront-default-domain origin-header config or ALB TLS auth-denial smokes, direct-origin denial evidence, and web alarm checks.",
7096
7176
  "Single-writer ECS evidence: one web task maximum and no scheduler/public-probe/reporter EFS mounts.",
7097
7177
  "EFS encryption, access point, mount-target, AWS Backup, and restore-drill evidence.",
7098
7178
  "S3 bucket KMS, versioning, lifecycle, and public-access-block evidence.",
@@ -7106,6 +7186,7 @@ function buildAwsDeploymentPlan(options = {}) {
7106
7186
  "This plan generator does not call AWS.",
7107
7187
  "Blocked plan output intentionally avoids copy-pastable AWS mutation commands.",
7108
7188
  "Default protected access uses CloudFront's HTTPS default domain so first deploy is not blocked on custom DNS or ACM.",
7189
+ "CloudFront default-domain mode still requires origin verification header binding before live scale-up; the header value is sensitive state/config material, not public documentation.",
7109
7190
  "Hosted runtime uses explicit EFS-backed SQLite at HASNA_UPTIME_HOSTED_SQLITE_DB until the async Postgres adapter exists.",
7110
7191
  "Do not set HASNA_UPTIME_DATABASE_URL for hosted tasks until the Postgres adapter is implemented.",
7111
7192
  "Secrets are represented as secret names/refs and must be injected with valueFrom.",
@@ -7743,7 +7824,7 @@ program2.command("restore <backup-path>").description("Restore a verified local
7743
7824
  fail(error);
7744
7825
  }
7745
7826
  });
7746
- 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) => {
7747
7828
  try {
7748
7829
  const { server } = serveUptime({
7749
7830
  host: opts.host,
@@ -24,7 +24,7 @@ export interface AwsDeploymentPlanOptions {
24
24
  }
25
25
  export interface AwsDeploymentPlan {
26
26
  kind: "open-uptime.aws-deployment-plan";
27
- version: 3;
27
+ version: 4;
28
28
  generatedAt: string;
29
29
  status: "blocked";
30
30
  canApply: false;
@@ -49,6 +49,13 @@ export interface AwsDeploymentPlan {
49
49
  protectedAccessMode: "cloudfront_default_domain" | "alb_https_cert";
50
50
  edgeDistribution?: string;
51
51
  protectedAccessUrl: string;
52
+ originVerification: {
53
+ mode: "cloudfront_origin_header" | "alb_tls";
54
+ requiredBeforeScaleUp: boolean;
55
+ headerName?: string;
56
+ valueStoredInTerraformState: boolean;
57
+ stateAccessWarning?: string;
58
+ };
52
59
  targetGroups: string[];
53
60
  securityGroups: string[];
54
61
  secrets: Record<string, string>;
@@ -1 +1 @@
1
- {"version":3,"file":"cloud-plan.d.ts","sourceRoot":"","sources":["../src/cloud-plan.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"cloud-plan.d.ts","sourceRoot":"","sources":["../src/cloud-plan.ts"],"names":[],"mappings":"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"}
@@ -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.18");
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}`;
@@ -64,7 +64,7 @@ function buildAwsDeploymentPlan(options = {}) {
64
64
  ];
65
65
  return {
66
66
  kind: "open-uptime.aws-deployment-plan",
67
- version: 3,
67
+ version: 4,
68
68
  generatedAt: new Date().toISOString(),
69
69
  status: "blocked",
70
70
  canApply: false,
@@ -89,6 +89,17 @@ function buildAwsDeploymentPlan(options = {}) {
89
89
  protectedAccessMode,
90
90
  edgeDistribution: protectedAccessMode === "cloudfront_default_domain" ? `${prefix}-${stage}-edge` : undefined,
91
91
  protectedAccessUrl,
92
+ originVerification: protectedAccessMode === "cloudfront_default_domain" ? {
93
+ mode: "cloudfront_origin_header",
94
+ requiredBeforeScaleUp: true,
95
+ headerName: "X-Open-Uptime-Origin-Verify",
96
+ valueStoredInTerraformState: true,
97
+ stateAccessWarning: "The origin verification header value is sensitive but is stored in encrypted Terraform state and CloudFront/ALB configuration; restrict state, plan, CloudFront distribution-read, and ELB rule-read access."
98
+ } : {
99
+ mode: "alb_tls",
100
+ requiredBeforeScaleUp: false,
101
+ valueStoredInTerraformState: false
102
+ },
92
103
  targetGroups: [`${prefix}-${stage}-web-tg`],
93
104
  securityGroups: [
94
105
  `${prefix}-${stage}-alb-sg`,
@@ -136,7 +147,7 @@ function buildAwsDeploymentPlan(options = {}) {
136
147
  `Infra PR must declare CodeBuild image builder ${prefix}-${stage}-image-builder for @hasna/uptime@${runtimePackageVersion}.`,
137
148
  `Infra PR must declare hardened S3 evidence bucket ${evidenceBucket} with KMS, versioning, lifecycle, and public access block.`,
138
149
  `Infra PR must declare encrypted EFS ${prefix}-${stage}-data with access point, mount targets, and AWS Backup plan.`,
139
- protectedAccessMode === "cloudfront_default_domain" ? "Infra PR must declare CloudFront default-domain HTTPS edge, ALB HTTP listener restricted to CloudFront origin-facing ranges, ECS/Fargate cluster, target groups, security groups, IAM roles, CloudWatch log groups, and Secrets Manager refs." : `Infra PR must declare ECS/Fargate cluster ${cluster}, ALB HTTPS listener, target groups, security groups, IAM roles, CloudWatch log groups, and Secrets Manager refs.`,
150
+ protectedAccessMode === "cloudfront_default_domain" ? "Infra PR must declare CloudFront default-domain HTTPS edge, ALB HTTP listener restricted to CloudFront origin-facing ranges, CloudFront-only origin verification header binding, ECS/Fargate cluster, target groups, security groups, IAM roles, CloudWatch log groups, and Secrets Manager refs." : `Infra PR must declare ECS/Fargate cluster ${cluster}, ALB HTTPS listener, target groups, security groups, IAM roles, CloudWatch log groups, and Secrets Manager refs.`,
140
151
  "Only apply the infra plan from the approved infrastructure repository after review evidence is attached."
141
152
  ],
142
153
  deploy: [
@@ -145,7 +156,7 @@ function buildAwsDeploymentPlan(options = {}) {
145
156
  "For the EFS SQLite bridge, do not run migration, scheduler, public-probe, or reporter tasks; keep them at desired count 0 until Postgres and cloud leases exist.",
146
157
  `Register task definitions for ${services.map((service) => service.name).join(", ")} using valueFrom secrets.`,
147
158
  `Update ECS services in cluster ${cluster} one component at a time through the approved deploy pipeline.`,
148
- protectedAccessMode === "cloudfront_default_domain" ? "Use the CloudFront default HTTPS domain for first protected access; add custom DNS/certificate only after edge ownership is approved." : `Create Route53/edge record for ${hostname} only after ALB health checks pass and auth denial smokes succeed.`
159
+ protectedAccessMode === "cloudfront_default_domain" ? "Use the CloudFront default HTTPS domain with origin verification header binding for first protected access; add custom DNS/certificate only after edge ownership is approved." : `Create Route53/edge record for ${hostname} only after ALB health checks pass and auth denial smokes succeed.`
149
160
  ],
150
161
  rollback: [
151
162
  "Keep previous task definition ARNs before each service update.",
@@ -161,6 +172,7 @@ function buildAwsDeploymentPlan(options = {}) {
161
172
  },
162
173
  blockers: [
163
174
  "The infrastructure owner repository was not found in this workspace.",
175
+ protectedAccessMode === "cloudfront_default_domain" ? "CloudFront origin verification header binding must be enabled and direct-origin denial must be proven before web desired count is raised above 0." : "ALB HTTPS ingress policy and auth-denial smokes must be proven before web desired count is raised above 0.",
164
176
  "The EFS SQLite bridge is single-writer only: web target desired count is 1 and scheduler/public-probe/reporter targets remain 0 until Postgres and cloud leases exist.",
165
177
  "Hosted production auth/RBAC must replace broad static hosted-token operation before exposure.",
166
178
  "Public probe execution still needs cloud check-job leases wired to runHostedHttpCheck and live policy-decision log evidence.",
@@ -170,7 +182,7 @@ function buildAwsDeploymentPlan(options = {}) {
170
182
  "Infrastructure PR/synth/plan from the approved infra repository.",
171
183
  "CodeBuild image-builder run, container smoke, and immutable image digest.",
172
184
  "ECS task definitions using secrets.valueFrom only.",
173
- "CloudFront-default-domain or ALB TLS auth-denial smokes, direct-origin denial evidence, and web alarm checks.",
185
+ "CloudFront-default-domain origin-header config or ALB TLS auth-denial smokes, direct-origin denial evidence, and web alarm checks.",
174
186
  "Single-writer ECS evidence: one web task maximum and no scheduler/public-probe/reporter EFS mounts.",
175
187
  "EFS encryption, access point, mount-target, AWS Backup, and restore-drill evidence.",
176
188
  "S3 bucket KMS, versioning, lifecycle, and public-access-block evidence.",
@@ -184,6 +196,7 @@ function buildAwsDeploymentPlan(options = {}) {
184
196
  "This plan generator does not call AWS.",
185
197
  "Blocked plan output intentionally avoids copy-pastable AWS mutation commands.",
186
198
  "Default protected access uses CloudFront's HTTPS default domain so first deploy is not blocked on custom DNS or ACM.",
199
+ "CloudFront default-domain mode still requires origin verification header binding before live scale-up; the header value is sensitive state/config material, not public documentation.",
187
200
  "Hosted runtime uses explicit EFS-backed SQLite at HASNA_UPTIME_HOSTED_SQLITE_DB until the async Postgres adapter exists.",
188
201
  "Do not set HASNA_UPTIME_DATABASE_URL for hosted tasks until the Postgres adapter is implemented.",
189
202
  "Secrets are represented as secret names/refs and must be injected with valueFrom.",
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.18");
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}`;
@@ -4392,7 +4460,7 @@ function buildAwsDeploymentPlan(options = {}) {
4392
4460
  ];
4393
4461
  return {
4394
4462
  kind: "open-uptime.aws-deployment-plan",
4395
- version: 3,
4463
+ version: 4,
4396
4464
  generatedAt: new Date().toISOString(),
4397
4465
  status: "blocked",
4398
4466
  canApply: false,
@@ -4417,6 +4485,17 @@ function buildAwsDeploymentPlan(options = {}) {
4417
4485
  protectedAccessMode,
4418
4486
  edgeDistribution: protectedAccessMode === "cloudfront_default_domain" ? `${prefix}-${stage}-edge` : undefined,
4419
4487
  protectedAccessUrl,
4488
+ originVerification: protectedAccessMode === "cloudfront_default_domain" ? {
4489
+ mode: "cloudfront_origin_header",
4490
+ requiredBeforeScaleUp: true,
4491
+ headerName: "X-Open-Uptime-Origin-Verify",
4492
+ valueStoredInTerraformState: true,
4493
+ stateAccessWarning: "The origin verification header value is sensitive but is stored in encrypted Terraform state and CloudFront/ALB configuration; restrict state, plan, CloudFront distribution-read, and ELB rule-read access."
4494
+ } : {
4495
+ mode: "alb_tls",
4496
+ requiredBeforeScaleUp: false,
4497
+ valueStoredInTerraformState: false
4498
+ },
4420
4499
  targetGroups: [`${prefix}-${stage}-web-tg`],
4421
4500
  securityGroups: [
4422
4501
  `${prefix}-${stage}-alb-sg`,
@@ -4464,7 +4543,7 @@ function buildAwsDeploymentPlan(options = {}) {
4464
4543
  `Infra PR must declare CodeBuild image builder ${prefix}-${stage}-image-builder for @hasna/uptime@${runtimePackageVersion}.`,
4465
4544
  `Infra PR must declare hardened S3 evidence bucket ${evidenceBucket} with KMS, versioning, lifecycle, and public access block.`,
4466
4545
  `Infra PR must declare encrypted EFS ${prefix}-${stage}-data with access point, mount targets, and AWS Backup plan.`,
4467
- protectedAccessMode === "cloudfront_default_domain" ? "Infra PR must declare CloudFront default-domain HTTPS edge, ALB HTTP listener restricted to CloudFront origin-facing ranges, ECS/Fargate cluster, target groups, security groups, IAM roles, CloudWatch log groups, and Secrets Manager refs." : `Infra PR must declare ECS/Fargate cluster ${cluster}, ALB HTTPS listener, target groups, security groups, IAM roles, CloudWatch log groups, and Secrets Manager refs.`,
4546
+ protectedAccessMode === "cloudfront_default_domain" ? "Infra PR must declare CloudFront default-domain HTTPS edge, ALB HTTP listener restricted to CloudFront origin-facing ranges, CloudFront-only origin verification header binding, ECS/Fargate cluster, target groups, security groups, IAM roles, CloudWatch log groups, and Secrets Manager refs." : `Infra PR must declare ECS/Fargate cluster ${cluster}, ALB HTTPS listener, target groups, security groups, IAM roles, CloudWatch log groups, and Secrets Manager refs.`,
4468
4547
  "Only apply the infra plan from the approved infrastructure repository after review evidence is attached."
4469
4548
  ],
4470
4549
  deploy: [
@@ -4473,7 +4552,7 @@ function buildAwsDeploymentPlan(options = {}) {
4473
4552
  "For the EFS SQLite bridge, do not run migration, scheduler, public-probe, or reporter tasks; keep them at desired count 0 until Postgres and cloud leases exist.",
4474
4553
  `Register task definitions for ${services.map((service) => service.name).join(", ")} using valueFrom secrets.`,
4475
4554
  `Update ECS services in cluster ${cluster} one component at a time through the approved deploy pipeline.`,
4476
- protectedAccessMode === "cloudfront_default_domain" ? "Use the CloudFront default HTTPS domain for first protected access; add custom DNS/certificate only after edge ownership is approved." : `Create Route53/edge record for ${hostname} only after ALB health checks pass and auth denial smokes succeed.`
4555
+ protectedAccessMode === "cloudfront_default_domain" ? "Use the CloudFront default HTTPS domain with origin verification header binding for first protected access; add custom DNS/certificate only after edge ownership is approved." : `Create Route53/edge record for ${hostname} only after ALB health checks pass and auth denial smokes succeed.`
4477
4556
  ],
4478
4557
  rollback: [
4479
4558
  "Keep previous task definition ARNs before each service update.",
@@ -4489,6 +4568,7 @@ function buildAwsDeploymentPlan(options = {}) {
4489
4568
  },
4490
4569
  blockers: [
4491
4570
  "The infrastructure owner repository was not found in this workspace.",
4571
+ protectedAccessMode === "cloudfront_default_domain" ? "CloudFront origin verification header binding must be enabled and direct-origin denial must be proven before web desired count is raised above 0." : "ALB HTTPS ingress policy and auth-denial smokes must be proven before web desired count is raised above 0.",
4492
4572
  "The EFS SQLite bridge is single-writer only: web target desired count is 1 and scheduler/public-probe/reporter targets remain 0 until Postgres and cloud leases exist.",
4493
4573
  "Hosted production auth/RBAC must replace broad static hosted-token operation before exposure.",
4494
4574
  "Public probe execution still needs cloud check-job leases wired to runHostedHttpCheck and live policy-decision log evidence.",
@@ -4498,7 +4578,7 @@ function buildAwsDeploymentPlan(options = {}) {
4498
4578
  "Infrastructure PR/synth/plan from the approved infra repository.",
4499
4579
  "CodeBuild image-builder run, container smoke, and immutable image digest.",
4500
4580
  "ECS task definitions using secrets.valueFrom only.",
4501
- "CloudFront-default-domain or ALB TLS auth-denial smokes, direct-origin denial evidence, and web alarm checks.",
4581
+ "CloudFront-default-domain origin-header config or ALB TLS auth-denial smokes, direct-origin denial evidence, and web alarm checks.",
4502
4582
  "Single-writer ECS evidence: one web task maximum and no scheduler/public-probe/reporter EFS mounts.",
4503
4583
  "EFS encryption, access point, mount-target, AWS Backup, and restore-drill evidence.",
4504
4584
  "S3 bucket KMS, versioning, lifecycle, and public-access-block evidence.",
@@ -4512,6 +4592,7 @@ function buildAwsDeploymentPlan(options = {}) {
4512
4592
  "This plan generator does not call AWS.",
4513
4593
  "Blocked plan output intentionally avoids copy-pastable AWS mutation commands.",
4514
4594
  "Default protected access uses CloudFront's HTTPS default domain so first deploy is not blocked on custom DNS or ACM.",
4595
+ "CloudFront default-domain mode still requires origin verification header binding before live scale-up; the header value is sensitive state/config material, not public documentation.",
4515
4596
  "Hosted runtime uses explicit EFS-backed SQLite at HASNA_UPTIME_HOSTED_SQLITE_DB until the async Postgres adapter exists.",
4516
4597
  "Do not set HASNA_UPTIME_DATABASE_URL for hosted tasks until the Postgres adapter is implemented.",
4517
4598
  "Secrets are represented as secret names/refs and must be injected with valueFrom.",
@@ -176,8 +176,8 @@ Before setting `desired_counts.web = 1`, verify:
176
176
  - the image is an immutable digest, not a mutable tag or placeholder;
177
177
  - required secrets have `AWSCURRENT` versions;
178
178
  - `HASNA_UPTIME_ALLOWED_ORIGINS` matches the public HTTPS edge origin;
179
- - CloudFront origin access is distribution-bound, not just narrowed to
180
- CloudFront origin-facing ranges;
179
+ - CloudFront origin access is distribution-bound with the CloudFront-only origin
180
+ verification header, not just narrowed to CloudFront origin-facing ranges;
181
181
  - web egress to ECR, Secrets Manager or SSM, CloudWatch Logs, S3, EFS, and any
182
182
  required endpoints has been proven from a real ECS task. Terraform endpoint
183
183
  ids, route tables, and security-group rules are creation evidence only; the
@@ -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
 
@@ -386,9 +409,11 @@ routes are backed by cloud check jobs and cloud audit rows.
386
409
  - Do not expose the ALB directly in CloudFront mode; ALB ingress must be limited
387
410
  to CloudFront origin-facing ranges.
388
411
  - Do not treat CloudFront prefix-list ingress as distribution-bound origin
389
- protection. Before enabling the web task, add CloudFront VPC origin/private
390
- ALB routing or require a CloudFront-only origin header whose secret value is
391
- not stored in Terraform state.
412
+ protection. In `cloudfront_default_domain` mode, enable the module's
413
+ CloudFront-only origin verification header and keep its generated value out of
414
+ the public repo and shared logs. Terraform redacts the sensitive input in CLI
415
+ output, but the value is still stored in encrypted Terraform state, saved plan
416
+ files, and AWS CloudFront/ALB configuration; restrict access accordingly.
392
417
  - Do not treat local SQLite, local project DBs, or private-probe local state as cloud
393
418
  authority after cutover.
394
419
  - Do configure owner/project/environment/service/cost-center tags and AWS
@@ -42,9 +42,14 @@ HTTPS origin so hosted mutation CSRF checks still work through the private HTTP
42
42
  origin hop.
43
43
 
44
44
  CloudFront prefix-list ingress is only a network narrowing control; it is not
45
- bound to one distribution. Add CloudFront VPC origin/private ALB routing or an
46
- ALB origin-header rule with the secret value managed outside Terraform state
47
- before enabling the web task.
45
+ bound to one distribution. Before enabling the web task, set
46
+ `enable_cloudfront_origin_verify_header = true` and provide a high-entropy
47
+ `cloudfront_origin_verify_header_value` from a private operator workflow. The
48
+ module then configures CloudFront to send that header, makes the ALB default
49
+ action return `403`, and forwards only requests with the matching header.
50
+ Terraform marks the value sensitive, but it still lives in encrypted Terraform
51
+ state and in CloudFront/ALB configuration; restrict state, saved plan,
52
+ CloudFront distribution-read, and ELB listener-rule-read access accordingly.
48
53
 
49
54
  All module resources carry owner, project, environment, service, account, app
50
55
  type, and cost-center tags. Set `monthly_budget_limit_usd` plus
package/infra/aws/main.tf CHANGED
@@ -26,6 +26,7 @@ locals {
26
26
  efs_enabled_services = toset(["web"])
27
27
  use_alb_https = var.protected_access_mode == "alb_https_cert"
28
28
  use_cloudfront = var.protected_access_mode == "cloudfront_default_domain"
29
+ use_origin_verify = local.use_cloudfront && var.enable_cloudfront_origin_verify_header
29
30
  services = {
30
31
  web = {
31
32
  desired_count = lookup(var.desired_counts, "web", 0)
@@ -900,10 +901,45 @@ resource "aws_lb_listener" "http_cloudfront" {
900
901
  protocol = "HTTP"
901
902
  tags = local.tags
902
903
 
903
- default_action {
904
+ dynamic "default_action" {
905
+ for_each = local.use_origin_verify ? [] : [1]
906
+ content {
907
+ type = "forward"
908
+ target_group_arn = aws_lb_target_group.web.arn
909
+ }
910
+ }
911
+
912
+ dynamic "default_action" {
913
+ for_each = local.use_origin_verify ? [1] : []
914
+ content {
915
+ type = "fixed-response"
916
+
917
+ fixed_response {
918
+ content_type = "text/plain"
919
+ message_body = "forbidden"
920
+ status_code = "403"
921
+ }
922
+ }
923
+ }
924
+ }
925
+
926
+ resource "aws_lb_listener_rule" "http_cloudfront_origin_verify" {
927
+ count = local.use_origin_verify ? 1 : 0
928
+ listener_arn = aws_lb_listener.http_cloudfront[0].arn
929
+ priority = var.cloudfront_origin_verify_listener_rule_priority
930
+ tags = local.tags
931
+
932
+ action {
904
933
  type = "forward"
905
934
  target_group_arn = aws_lb_target_group.web.arn
906
935
  }
936
+
937
+ condition {
938
+ http_header {
939
+ http_header_name = var.cloudfront_origin_verify_header_name
940
+ values = [var.cloudfront_origin_verify_header_value]
941
+ }
942
+ }
907
943
  }
908
944
 
909
945
  resource "aws_cloudfront_distribution" "open_uptime" {
@@ -918,6 +954,14 @@ resource "aws_cloudfront_distribution" "open_uptime" {
918
954
  domain_name = aws_lb.open_uptime.dns_name
919
955
  origin_id = "${local.prefix}-alb"
920
956
 
957
+ dynamic "custom_header" {
958
+ for_each = local.use_origin_verify ? [1] : []
959
+ content {
960
+ name = var.cloudfront_origin_verify_header_name
961
+ value = var.cloudfront_origin_verify_header_value
962
+ }
963
+ }
964
+
921
965
  custom_origin_config {
922
966
  http_port = 80
923
967
  https_port = 443
@@ -956,7 +1000,7 @@ resource "aws_cloudfront_distribution" "open_uptime" {
956
1000
  cloudfront_default_certificate = true
957
1001
  }
958
1002
 
959
- depends_on = [aws_lb_listener.http_cloudfront]
1003
+ depends_on = [aws_lb_listener.http_cloudfront, aws_lb_listener_rule.http_cloudfront_origin_verify]
960
1004
  }
961
1005
 
962
1006
  resource "aws_route53_record" "open_uptime" {
@@ -1109,6 +1153,7 @@ resource "aws_ecs_task_definition" "service" {
1109
1153
  }] : []
1110
1154
  environment = concat([
1111
1155
  { name = "HASNA_UPTIME_MODE", value = "hosted" },
1156
+ { name = "HASNA_UPTIME_HOSTED_AUTH_MODE", value = "production" },
1112
1157
  { name = "HASNA_UPTIME_WORKSPACE_ID", value = var.workspace_id },
1113
1158
  { name = "HASNA_UPTIME_COMPONENT", value = each.key },
1114
1159
  { name = "HASNA_UPTIME_HOSTNAME", value = var.hostname },
@@ -1173,7 +1218,7 @@ resource "aws_ecs_service" "web" {
1173
1218
  container_port = local.container_port
1174
1219
  }
1175
1220
 
1176
- depends_on = [aws_lb_listener.https, aws_lb_listener.http_cloudfront, aws_efs_mount_target.data]
1221
+ depends_on = [aws_lb_listener.https, aws_lb_listener.http_cloudfront, aws_lb_listener_rule.http_cloudfront_origin_verify, aws_efs_mount_target.data]
1177
1222
  }
1178
1223
 
1179
1224
  resource "aws_ecs_service" "worker" {
@@ -22,6 +22,14 @@ output "protected_access_url" {
22
22
  value = var.protected_access_mode == "cloudfront_default_domain" ? "https://${aws_cloudfront_distribution.open_uptime[0].domain_name}" : "https://${var.hostname}"
23
23
  }
24
24
 
25
+ output "cloudfront_origin_verify_header_enabled" {
26
+ value = local.use_origin_verify
27
+ }
28
+
29
+ output "cloudfront_origin_verify_header_name" {
30
+ value = local.use_origin_verify ? var.cloudfront_origin_verify_header_name : null
31
+ }
32
+
25
33
  output "evidence_bucket" {
26
34
  value = aws_s3_bucket.evidence.bucket
27
35
  }
@@ -11,12 +11,15 @@ workspace_id = "workspace-id"
11
11
  vpc_id = "vpc-xxxxxxxx"
12
12
  ecr_repository_name = "open-uptime"
13
13
  protected_access_mode = "cloudfront_default_domain"
14
+ enable_cloudfront_origin_verify_header = false
15
+ cloudfront_origin_verify_header_name = "X-Open-Uptime-Origin-Verify"
16
+ cloudfront_origin_verify_header_value = null
14
17
  public_subnet_ids = ["subnet-replace-public-a", "subnet-replace-public-b"]
15
18
  alb_ingress_cidr_blocks = []
16
19
  private_subnet_ids = ["subnet-replace-private-a", "subnet-replace-private-b"]
17
20
  private_route_table_ids = ["rtb-replace-private"]
18
21
  container_image = "123456789012.dkr.ecr.us-east-1.amazonaws.com/open-uptime@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
19
- runtime_package_version = "0.1.18"
22
+ runtime_package_version = "0.1.20"
20
23
  certificate_arn = null
21
24
  hosted_zone_id = null
22
25
  app_env_secret_arn = "arn:aws:secretsmanager:us-east-1:123456789012:secret:open-uptime/prod/app/env"
@@ -87,6 +87,91 @@ variable "protected_access_mode" {
87
87
  }
88
88
  }
89
89
 
90
+ variable "enable_cloudfront_origin_verify_header" {
91
+ description = "When true in cloudfront_default_domain mode, CloudFront sends a private origin header and the ALB listener rejects requests missing the matching value."
92
+ type = bool
93
+ default = false
94
+
95
+ validation {
96
+ condition = !var.enable_cloudfront_origin_verify_header || var.protected_access_mode == "cloudfront_default_domain"
97
+ error_message = "enable_cloudfront_origin_verify_header can only be true when protected_access_mode is cloudfront_default_domain."
98
+ }
99
+ }
100
+
101
+ variable "cloudfront_origin_verify_header_name" {
102
+ description = "CloudFront-only origin verification header name used when enable_cloudfront_origin_verify_header is true."
103
+ type = string
104
+ default = "X-Open-Uptime-Origin-Verify"
105
+
106
+ validation {
107
+ condition = (
108
+ can(regex("^[A-Za-z0-9-]+$", var.cloudfront_origin_verify_header_name))
109
+ && !startswith(lower(var.cloudfront_origin_verify_header_name), "x-amz-")
110
+ && !startswith(lower(var.cloudfront_origin_verify_header_name), "x-edge-")
111
+ && !contains([
112
+ "authorization",
113
+ "cache-control",
114
+ "connection",
115
+ "content-length",
116
+ "content-type",
117
+ "cookie",
118
+ "host",
119
+ "if-match",
120
+ "if-modified-since",
121
+ "if-none-match",
122
+ "if-range",
123
+ "if-unmodified-since",
124
+ "max-forwards",
125
+ "origin",
126
+ "pragma",
127
+ "proxy-authenticate",
128
+ "proxy-authorization",
129
+ "proxy-connection",
130
+ "range",
131
+ "request-range",
132
+ "te",
133
+ "trailer",
134
+ "transfer-encoding",
135
+ "upgrade",
136
+ "via",
137
+ "x-real-ip",
138
+ "x-uptime-hosted-token",
139
+ ], lower(var.cloudfront_origin_verify_header_name))
140
+ )
141
+ error_message = "cloudfront_origin_verify_header_name must be a safe CloudFront custom origin header name and must not use reserved, app-forwarded, or viewer-controlled header names."
142
+ }
143
+ }
144
+
145
+ variable "cloudfront_origin_verify_header_value" {
146
+ description = "Sensitive CloudFront-only origin verification header value. Required when enable_cloudfront_origin_verify_header is true."
147
+ type = string
148
+ default = null
149
+ nullable = true
150
+ sensitive = true
151
+
152
+ validation {
153
+ condition = (
154
+ !(var.enable_cloudfront_origin_verify_header && var.protected_access_mode == "cloudfront_default_domain")
155
+ || (
156
+ var.cloudfront_origin_verify_header_value != null
157
+ && can(regex("^[A-Za-z0-9_-]{32,256}$", var.cloudfront_origin_verify_header_value))
158
+ )
159
+ )
160
+ error_message = "cloudfront_origin_verify_header_value is required when origin verification is enabled and must be 32-256 URL-safe characters."
161
+ }
162
+ }
163
+
164
+ variable "cloudfront_origin_verify_listener_rule_priority" {
165
+ description = "ALB listener rule priority for the CloudFront origin verification header rule."
166
+ type = number
167
+ default = 100
168
+
169
+ validation {
170
+ condition = var.cloudfront_origin_verify_listener_rule_priority >= 1 && var.cloudfront_origin_verify_listener_rule_priority <= 50000
171
+ error_message = "cloudfront_origin_verify_listener_rule_priority must be between 1 and 50000."
172
+ }
173
+ }
174
+
90
175
  variable "public_subnet_ids" {
91
176
  description = "Public subnets for the ALB."
92
177
  type = list(string)
@@ -116,7 +201,7 @@ variable "container_image" {
116
201
  variable "runtime_package_version" {
117
202
  description = "Published @hasna/uptime package version that CodeBuild should build into the ECR image."
118
203
  type = string
119
- default = "0.1.18"
204
+ default = "0.1.20"
120
205
 
121
206
  validation {
122
207
  condition = can(regex("^[0-9]+\\.[0-9]+\\.[0-9]+(-[0-9A-Za-z.-]+)?$", var.runtime_package_version))
@@ -157,7 +242,7 @@ variable "app_env_secret_arn" {
157
242
  }
158
243
 
159
244
  variable "hosted_token_secret_arn" {
160
- 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."
161
246
  type = string
162
247
 
163
248
  validation {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/uptime",
3
- "version": "0.1.18",
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",