@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 +30 -0
- package/README.md +15 -0
- package/dist/api.js +73 -5
- package/dist/cli/index.js +92 -11
- package/dist/cloud-plan.d.ts +8 -1
- package/dist/cloud-plan.d.ts.map +1 -1
- package/dist/cloud-plan.js +18 -5
- package/dist/index.js +91 -10
- package/docs/aws-deployment-runbook.md +33 -8
- package/infra/aws/README.md +8 -3
- package/infra/aws/main.tf +48 -3
- package/infra/aws/outputs.tf +8 -0
- package/infra/aws/terraform.tfvars.example +4 -1
- package/infra/aws/variables.tf +87 -2
- package/package.json +1 -1
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
|
-
|
|
4266
|
-
|
|
4265
|
+
const defaultWorkspaceId = process.env.HASNA_UPTIME_WORKSPACE_ID ?? "default";
|
|
4266
|
+
if (options.hostedTokens?.length) {
|
|
4267
|
+
return normalizeHostedTokenEntries(options.hostedTokens, defaultWorkspaceId);
|
|
4268
|
+
}
|
|
4269
|
+
const configuredTokens = process.env.HASNA_UPTIME_HOSTED_TOKENS;
|
|
4270
|
+
if (configuredTokens?.trim()) {
|
|
4271
|
+
return parseHostedTokensConfig(configuredTokens, defaultWorkspaceId, "HASNA_UPTIME_HOSTED_TOKENS");
|
|
4272
|
+
}
|
|
4267
4273
|
const token = options.hostedToken ?? process.env.HASNA_UPTIME_HOSTED_TOKEN;
|
|
4268
4274
|
if (!token?.trim())
|
|
4269
4275
|
return [];
|
|
4276
|
+
return parseHostedTokenValue(token, defaultWorkspaceId, options.hostedToken ? "--hosted-token" : "HASNA_UPTIME_HOSTED_TOKEN");
|
|
4277
|
+
}
|
|
4278
|
+
var HOSTED_SCOPES = ["uptime:read", "uptime:write", "uptime:probe", "uptime:report", "uptime:admin"];
|
|
4279
|
+
var HOSTED_SCOPE_SET = new Set(HOSTED_SCOPES);
|
|
4280
|
+
var LEGACY_HOSTED_TOKEN_SCOPES = ["uptime:read", "uptime:write", "uptime:probe", "uptime:report"];
|
|
4281
|
+
function parseHostedTokenValue(value, defaultWorkspaceId, source) {
|
|
4282
|
+
const trimmed = value.trim();
|
|
4283
|
+
if (!trimmed)
|
|
4284
|
+
return [];
|
|
4285
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
4286
|
+
return parseHostedTokensConfig(trimmed, defaultWorkspaceId, source);
|
|
4287
|
+
}
|
|
4288
|
+
if (isHostedProductionMode()) {
|
|
4289
|
+
throw new ApiError(`${source} must be scoped hosted token JSON when HASNA_UPTIME_HOSTED_AUTH_MODE=production`, 500);
|
|
4290
|
+
}
|
|
4270
4291
|
return [{
|
|
4271
|
-
token:
|
|
4272
|
-
scopes:
|
|
4273
|
-
workspaceId:
|
|
4292
|
+
token: trimmed,
|
|
4293
|
+
scopes: LEGACY_HOSTED_TOKEN_SCOPES,
|
|
4294
|
+
workspaceId: defaultWorkspaceId
|
|
4274
4295
|
}];
|
|
4275
4296
|
}
|
|
4297
|
+
function parseHostedTokensConfig(value, defaultWorkspaceId, source) {
|
|
4298
|
+
let parsed;
|
|
4299
|
+
try {
|
|
4300
|
+
parsed = JSON.parse(value);
|
|
4301
|
+
} catch {
|
|
4302
|
+
throw new ApiError(`${source} must be valid hosted token JSON`, 500);
|
|
4303
|
+
}
|
|
4304
|
+
const entries = Array.isArray(parsed) ? parsed : isRecord(parsed) && Array.isArray(parsed.tokens) ? parsed.tokens : isRecord(parsed) && typeof parsed.token === "string" ? [parsed] : undefined;
|
|
4305
|
+
if (!entries)
|
|
4306
|
+
throw new ApiError(`${source} must be a token object, token array, or object with tokens[]`, 500);
|
|
4307
|
+
return normalizeHostedTokenEntries(entries, defaultWorkspaceId, source);
|
|
4308
|
+
}
|
|
4309
|
+
function normalizeHostedTokenEntries(entries, defaultWorkspaceId, source = "hostedTokens") {
|
|
4310
|
+
const tokens = entries.map((entry, index) => normalizeHostedTokenEntry(entry, defaultWorkspaceId, `${source}[${index}]`));
|
|
4311
|
+
if (tokens.length === 0)
|
|
4312
|
+
throw new ApiError(`${source} must configure at least one hosted token`, 500);
|
|
4313
|
+
return tokens;
|
|
4314
|
+
}
|
|
4315
|
+
function normalizeHostedTokenEntry(entry, defaultWorkspaceId, source) {
|
|
4316
|
+
if (!isRecord(entry))
|
|
4317
|
+
throw new ApiError(`${source} must be an object`, 500);
|
|
4318
|
+
if (typeof entry.token !== "string" || !entry.token.trim()) {
|
|
4319
|
+
throw new ApiError(`${source}.token is required`, 500);
|
|
4320
|
+
}
|
|
4321
|
+
const scopes = normalizeHostedScopes(entry.scopes, `${source}.scopes`);
|
|
4322
|
+
const workspaceId = typeof entry.workspaceId === "string" && entry.workspaceId.trim() ? entry.workspaceId.trim() : defaultWorkspaceId;
|
|
4323
|
+
return { token: entry.token.trim(), scopes, workspaceId };
|
|
4324
|
+
}
|
|
4325
|
+
function normalizeHostedScopes(value, source) {
|
|
4326
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
4327
|
+
throw new ApiError(`${source} must be a non-empty array`, 500);
|
|
4328
|
+
}
|
|
4329
|
+
const scopes = new Set;
|
|
4330
|
+
for (const scope of value) {
|
|
4331
|
+
if (typeof scope !== "string" || !HOSTED_SCOPE_SET.has(scope)) {
|
|
4332
|
+
throw new ApiError(`${source} contains an invalid hosted scope`, 500);
|
|
4333
|
+
}
|
|
4334
|
+
scopes.add(scope);
|
|
4335
|
+
}
|
|
4336
|
+
return [...scopes];
|
|
4337
|
+
}
|
|
4338
|
+
function isRecord(value) {
|
|
4339
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4340
|
+
}
|
|
4341
|
+
function isHostedProductionMode() {
|
|
4342
|
+
return process.env.HASNA_UPTIME_HOSTED_AUTH_MODE === "production" || false;
|
|
4343
|
+
}
|
|
4276
4344
|
function resolveHostedAllowedOrigins(options) {
|
|
4277
4345
|
const configured = options.hostedAllowedOrigins ?? splitCsv(process.env.HASNA_UPTIME_ALLOWED_ORIGINS);
|
|
4278
4346
|
return configured.map((origin) => normalizeAllowedOrigin(origin)).filter((origin) => Boolean(origin));
|
package/dist/cli/index.js
CHANGED
|
@@ -6856,17 +6856,85 @@ function resolveApiToken(token) {
|
|
|
6856
6856
|
return value?.trim() || undefined;
|
|
6857
6857
|
}
|
|
6858
6858
|
function resolveHostedTokens(options) {
|
|
6859
|
-
|
|
6860
|
-
|
|
6859
|
+
const defaultWorkspaceId = process.env.HASNA_UPTIME_WORKSPACE_ID ?? "default";
|
|
6860
|
+
if (options.hostedTokens?.length) {
|
|
6861
|
+
return normalizeHostedTokenEntries(options.hostedTokens, defaultWorkspaceId);
|
|
6862
|
+
}
|
|
6863
|
+
const configuredTokens = process.env.HASNA_UPTIME_HOSTED_TOKENS;
|
|
6864
|
+
if (configuredTokens?.trim()) {
|
|
6865
|
+
return parseHostedTokensConfig(configuredTokens, defaultWorkspaceId, "HASNA_UPTIME_HOSTED_TOKENS");
|
|
6866
|
+
}
|
|
6861
6867
|
const token = options.hostedToken ?? process.env.HASNA_UPTIME_HOSTED_TOKEN;
|
|
6862
6868
|
if (!token?.trim())
|
|
6863
6869
|
return [];
|
|
6870
|
+
return parseHostedTokenValue(token, defaultWorkspaceId, options.hostedToken ? "--hosted-token" : "HASNA_UPTIME_HOSTED_TOKEN");
|
|
6871
|
+
}
|
|
6872
|
+
var HOSTED_SCOPES = ["uptime:read", "uptime:write", "uptime:probe", "uptime:report", "uptime:admin"];
|
|
6873
|
+
var HOSTED_SCOPE_SET = new Set(HOSTED_SCOPES);
|
|
6874
|
+
var LEGACY_HOSTED_TOKEN_SCOPES = ["uptime:read", "uptime:write", "uptime:probe", "uptime:report"];
|
|
6875
|
+
function parseHostedTokenValue(value, defaultWorkspaceId, source) {
|
|
6876
|
+
const trimmed = value.trim();
|
|
6877
|
+
if (!trimmed)
|
|
6878
|
+
return [];
|
|
6879
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
6880
|
+
return parseHostedTokensConfig(trimmed, defaultWorkspaceId, source);
|
|
6881
|
+
}
|
|
6882
|
+
if (isHostedProductionMode()) {
|
|
6883
|
+
throw new ApiError(`${source} must be scoped hosted token JSON when HASNA_UPTIME_HOSTED_AUTH_MODE=production`, 500);
|
|
6884
|
+
}
|
|
6864
6885
|
return [{
|
|
6865
|
-
token:
|
|
6866
|
-
scopes:
|
|
6867
|
-
workspaceId:
|
|
6886
|
+
token: trimmed,
|
|
6887
|
+
scopes: LEGACY_HOSTED_TOKEN_SCOPES,
|
|
6888
|
+
workspaceId: defaultWorkspaceId
|
|
6868
6889
|
}];
|
|
6869
6890
|
}
|
|
6891
|
+
function parseHostedTokensConfig(value, defaultWorkspaceId, source) {
|
|
6892
|
+
let parsed;
|
|
6893
|
+
try {
|
|
6894
|
+
parsed = JSON.parse(value);
|
|
6895
|
+
} catch {
|
|
6896
|
+
throw new ApiError(`${source} must be valid hosted token JSON`, 500);
|
|
6897
|
+
}
|
|
6898
|
+
const entries = Array.isArray(parsed) ? parsed : isRecord(parsed) && Array.isArray(parsed.tokens) ? parsed.tokens : isRecord(parsed) && typeof parsed.token === "string" ? [parsed] : undefined;
|
|
6899
|
+
if (!entries)
|
|
6900
|
+
throw new ApiError(`${source} must be a token object, token array, or object with tokens[]`, 500);
|
|
6901
|
+
return normalizeHostedTokenEntries(entries, defaultWorkspaceId, source);
|
|
6902
|
+
}
|
|
6903
|
+
function normalizeHostedTokenEntries(entries, defaultWorkspaceId, source = "hostedTokens") {
|
|
6904
|
+
const tokens = entries.map((entry, index) => normalizeHostedTokenEntry(entry, defaultWorkspaceId, `${source}[${index}]`));
|
|
6905
|
+
if (tokens.length === 0)
|
|
6906
|
+
throw new ApiError(`${source} must configure at least one hosted token`, 500);
|
|
6907
|
+
return tokens;
|
|
6908
|
+
}
|
|
6909
|
+
function normalizeHostedTokenEntry(entry, defaultWorkspaceId, source) {
|
|
6910
|
+
if (!isRecord(entry))
|
|
6911
|
+
throw new ApiError(`${source} must be an object`, 500);
|
|
6912
|
+
if (typeof entry.token !== "string" || !entry.token.trim()) {
|
|
6913
|
+
throw new ApiError(`${source}.token is required`, 500);
|
|
6914
|
+
}
|
|
6915
|
+
const scopes = normalizeHostedScopes(entry.scopes, `${source}.scopes`);
|
|
6916
|
+
const workspaceId = typeof entry.workspaceId === "string" && entry.workspaceId.trim() ? entry.workspaceId.trim() : defaultWorkspaceId;
|
|
6917
|
+
return { token: entry.token.trim(), scopes, workspaceId };
|
|
6918
|
+
}
|
|
6919
|
+
function normalizeHostedScopes(value, source) {
|
|
6920
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
6921
|
+
throw new ApiError(`${source} must be a non-empty array`, 500);
|
|
6922
|
+
}
|
|
6923
|
+
const scopes = new Set;
|
|
6924
|
+
for (const scope of value) {
|
|
6925
|
+
if (typeof scope !== "string" || !HOSTED_SCOPE_SET.has(scope)) {
|
|
6926
|
+
throw new ApiError(`${source} contains an invalid hosted scope`, 500);
|
|
6927
|
+
}
|
|
6928
|
+
scopes.add(scope);
|
|
6929
|
+
}
|
|
6930
|
+
return [...scopes];
|
|
6931
|
+
}
|
|
6932
|
+
function isRecord(value) {
|
|
6933
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6934
|
+
}
|
|
6935
|
+
function isHostedProductionMode() {
|
|
6936
|
+
return process.env.HASNA_UPTIME_HOSTED_AUTH_MODE === "production" || false;
|
|
6937
|
+
}
|
|
6870
6938
|
function resolveHostedAllowedOrigins(options) {
|
|
6871
6939
|
const configured = options.hostedAllowedOrigins ?? splitCsv(process.env.HASNA_UPTIME_ALLOWED_ORIGINS);
|
|
6872
6940
|
return configured.map((origin) => normalizeAllowedOrigin(origin)).filter((origin) => Boolean(origin));
|
|
@@ -6943,7 +7011,7 @@ function buildAwsDeploymentPlan(options = {}) {
|
|
|
6943
7011
|
const image = clean(options.image, `${imageRepositoryUri}@sha256:<image-digest>`);
|
|
6944
7012
|
const evidenceBucket = clean(options.evidenceBucket, `hasna-${stage}-${prefix}-evidence`);
|
|
6945
7013
|
const hostedSqliteDbPath = clean(options.hostedSqliteDbPath, DEFAULT_HOSTED_SQLITE_DB);
|
|
6946
|
-
const runtimePackageVersion = clean(options.runtimePackageVersion, "0.1.
|
|
7014
|
+
const runtimePackageVersion = clean(options.runtimePackageVersion, "0.1.20");
|
|
6947
7015
|
const protectedAccessMode = options.protectedAccessMode ?? DEFAULT_PROTECTED_ACCESS_MODE;
|
|
6948
7016
|
const protectedAccessUrl = protectedAccessMode === "cloudfront_default_domain" ? "https://<cloudfront-domain>" : `https://${hostname}`;
|
|
6949
7017
|
const cluster = `${prefix}-${stage}`;
|
|
@@ -6986,7 +7054,7 @@ function buildAwsDeploymentPlan(options = {}) {
|
|
|
6986
7054
|
];
|
|
6987
7055
|
return {
|
|
6988
7056
|
kind: "open-uptime.aws-deployment-plan",
|
|
6989
|
-
version:
|
|
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-
|
|
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,
|
package/dist/cloud-plan.d.ts
CHANGED
|
@@ -24,7 +24,7 @@ export interface AwsDeploymentPlanOptions {
|
|
|
24
24
|
}
|
|
25
25
|
export interface AwsDeploymentPlan {
|
|
26
26
|
kind: "open-uptime.aws-deployment-plan";
|
|
27
|
-
version:
|
|
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>;
|
package/dist/cloud-plan.d.ts.map
CHANGED
|
@@ -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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"}
|
package/dist/cloud-plan.js
CHANGED
|
@@ -21,7 +21,7 @@ function buildAwsDeploymentPlan(options = {}) {
|
|
|
21
21
|
const image = clean(options.image, `${imageRepositoryUri}@sha256:<image-digest>`);
|
|
22
22
|
const evidenceBucket = clean(options.evidenceBucket, `hasna-${stage}-${prefix}-evidence`);
|
|
23
23
|
const hostedSqliteDbPath = clean(options.hostedSqliteDbPath, DEFAULT_HOSTED_SQLITE_DB);
|
|
24
|
-
const runtimePackageVersion = clean(options.runtimePackageVersion, "0.1.
|
|
24
|
+
const runtimePackageVersion = clean(options.runtimePackageVersion, "0.1.20");
|
|
25
25
|
const protectedAccessMode = options.protectedAccessMode ?? DEFAULT_PROTECTED_ACCESS_MODE;
|
|
26
26
|
const protectedAccessUrl = protectedAccessMode === "cloudfront_default_domain" ? "https://<cloudfront-domain>" : `https://${hostname}`;
|
|
27
27
|
const cluster = `${prefix}-${stage}`;
|
|
@@ -64,7 +64,7 @@ function buildAwsDeploymentPlan(options = {}) {
|
|
|
64
64
|
];
|
|
65
65
|
return {
|
|
66
66
|
kind: "open-uptime.aws-deployment-plan",
|
|
67
|
-
version:
|
|
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
|
-
|
|
4266
|
-
|
|
4265
|
+
const defaultWorkspaceId = process.env.HASNA_UPTIME_WORKSPACE_ID ?? "default";
|
|
4266
|
+
if (options.hostedTokens?.length) {
|
|
4267
|
+
return normalizeHostedTokenEntries(options.hostedTokens, defaultWorkspaceId);
|
|
4268
|
+
}
|
|
4269
|
+
const configuredTokens = process.env.HASNA_UPTIME_HOSTED_TOKENS;
|
|
4270
|
+
if (configuredTokens?.trim()) {
|
|
4271
|
+
return parseHostedTokensConfig(configuredTokens, defaultWorkspaceId, "HASNA_UPTIME_HOSTED_TOKENS");
|
|
4272
|
+
}
|
|
4267
4273
|
const token = options.hostedToken ?? process.env.HASNA_UPTIME_HOSTED_TOKEN;
|
|
4268
4274
|
if (!token?.trim())
|
|
4269
4275
|
return [];
|
|
4276
|
+
return parseHostedTokenValue(token, defaultWorkspaceId, options.hostedToken ? "--hosted-token" : "HASNA_UPTIME_HOSTED_TOKEN");
|
|
4277
|
+
}
|
|
4278
|
+
var HOSTED_SCOPES = ["uptime:read", "uptime:write", "uptime:probe", "uptime:report", "uptime:admin"];
|
|
4279
|
+
var HOSTED_SCOPE_SET = new Set(HOSTED_SCOPES);
|
|
4280
|
+
var LEGACY_HOSTED_TOKEN_SCOPES = ["uptime:read", "uptime:write", "uptime:probe", "uptime:report"];
|
|
4281
|
+
function parseHostedTokenValue(value, defaultWorkspaceId, source) {
|
|
4282
|
+
const trimmed = value.trim();
|
|
4283
|
+
if (!trimmed)
|
|
4284
|
+
return [];
|
|
4285
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
4286
|
+
return parseHostedTokensConfig(trimmed, defaultWorkspaceId, source);
|
|
4287
|
+
}
|
|
4288
|
+
if (isHostedProductionMode()) {
|
|
4289
|
+
throw new ApiError(`${source} must be scoped hosted token JSON when HASNA_UPTIME_HOSTED_AUTH_MODE=production`, 500);
|
|
4290
|
+
}
|
|
4270
4291
|
return [{
|
|
4271
|
-
token:
|
|
4272
|
-
scopes:
|
|
4273
|
-
workspaceId:
|
|
4292
|
+
token: trimmed,
|
|
4293
|
+
scopes: LEGACY_HOSTED_TOKEN_SCOPES,
|
|
4294
|
+
workspaceId: defaultWorkspaceId
|
|
4274
4295
|
}];
|
|
4275
4296
|
}
|
|
4297
|
+
function parseHostedTokensConfig(value, defaultWorkspaceId, source) {
|
|
4298
|
+
let parsed;
|
|
4299
|
+
try {
|
|
4300
|
+
parsed = JSON.parse(value);
|
|
4301
|
+
} catch {
|
|
4302
|
+
throw new ApiError(`${source} must be valid hosted token JSON`, 500);
|
|
4303
|
+
}
|
|
4304
|
+
const entries = Array.isArray(parsed) ? parsed : isRecord(parsed) && Array.isArray(parsed.tokens) ? parsed.tokens : isRecord(parsed) && typeof parsed.token === "string" ? [parsed] : undefined;
|
|
4305
|
+
if (!entries)
|
|
4306
|
+
throw new ApiError(`${source} must be a token object, token array, or object with tokens[]`, 500);
|
|
4307
|
+
return normalizeHostedTokenEntries(entries, defaultWorkspaceId, source);
|
|
4308
|
+
}
|
|
4309
|
+
function normalizeHostedTokenEntries(entries, defaultWorkspaceId, source = "hostedTokens") {
|
|
4310
|
+
const tokens = entries.map((entry, index) => normalizeHostedTokenEntry(entry, defaultWorkspaceId, `${source}[${index}]`));
|
|
4311
|
+
if (tokens.length === 0)
|
|
4312
|
+
throw new ApiError(`${source} must configure at least one hosted token`, 500);
|
|
4313
|
+
return tokens;
|
|
4314
|
+
}
|
|
4315
|
+
function normalizeHostedTokenEntry(entry, defaultWorkspaceId, source) {
|
|
4316
|
+
if (!isRecord(entry))
|
|
4317
|
+
throw new ApiError(`${source} must be an object`, 500);
|
|
4318
|
+
if (typeof entry.token !== "string" || !entry.token.trim()) {
|
|
4319
|
+
throw new ApiError(`${source}.token is required`, 500);
|
|
4320
|
+
}
|
|
4321
|
+
const scopes = normalizeHostedScopes(entry.scopes, `${source}.scopes`);
|
|
4322
|
+
const workspaceId = typeof entry.workspaceId === "string" && entry.workspaceId.trim() ? entry.workspaceId.trim() : defaultWorkspaceId;
|
|
4323
|
+
return { token: entry.token.trim(), scopes, workspaceId };
|
|
4324
|
+
}
|
|
4325
|
+
function normalizeHostedScopes(value, source) {
|
|
4326
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
4327
|
+
throw new ApiError(`${source} must be a non-empty array`, 500);
|
|
4328
|
+
}
|
|
4329
|
+
const scopes = new Set;
|
|
4330
|
+
for (const scope of value) {
|
|
4331
|
+
if (typeof scope !== "string" || !HOSTED_SCOPE_SET.has(scope)) {
|
|
4332
|
+
throw new ApiError(`${source} contains an invalid hosted scope`, 500);
|
|
4333
|
+
}
|
|
4334
|
+
scopes.add(scope);
|
|
4335
|
+
}
|
|
4336
|
+
return [...scopes];
|
|
4337
|
+
}
|
|
4338
|
+
function isRecord(value) {
|
|
4339
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4340
|
+
}
|
|
4341
|
+
function isHostedProductionMode() {
|
|
4342
|
+
return process.env.HASNA_UPTIME_HOSTED_AUTH_MODE === "production" || false;
|
|
4343
|
+
}
|
|
4276
4344
|
function resolveHostedAllowedOrigins(options) {
|
|
4277
4345
|
const configured = options.hostedAllowedOrigins ?? splitCsv(process.env.HASNA_UPTIME_ALLOWED_ORIGINS);
|
|
4278
4346
|
return configured.map((origin) => normalizeAllowedOrigin(origin)).filter((origin) => Boolean(origin));
|
|
@@ -4349,7 +4417,7 @@ function buildAwsDeploymentPlan(options = {}) {
|
|
|
4349
4417
|
const image = clean(options.image, `${imageRepositoryUri}@sha256:<image-digest>`);
|
|
4350
4418
|
const evidenceBucket = clean(options.evidenceBucket, `hasna-${stage}-${prefix}-evidence`);
|
|
4351
4419
|
const hostedSqliteDbPath = clean(options.hostedSqliteDbPath, DEFAULT_HOSTED_SQLITE_DB);
|
|
4352
|
-
const runtimePackageVersion = clean(options.runtimePackageVersion, "0.1.
|
|
4420
|
+
const runtimePackageVersion = clean(options.runtimePackageVersion, "0.1.20");
|
|
4353
4421
|
const protectedAccessMode = options.protectedAccessMode ?? DEFAULT_PROTECTED_ACCESS_MODE;
|
|
4354
4422
|
const protectedAccessUrl = protectedAccessMode === "cloudfront_default_domain" ? "https://<cloudfront-domain>" : `https://${hostname}`;
|
|
4355
4423
|
const cluster = `${prefix}-${stage}`;
|
|
@@ -4392,7 +4460,7 @@ function buildAwsDeploymentPlan(options = {}) {
|
|
|
4392
4460
|
];
|
|
4393
4461
|
return {
|
|
4394
4462
|
kind: "open-uptime.aws-deployment-plan",
|
|
4395
|
-
version:
|
|
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
|
|
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.
|
|
334
|
-
|
|
349
|
+
read-only SQLite integrity checks. For a zero-count pre-production deployment
|
|
350
|
+
where `uptime.db` does not exist yet, create a representative restore-drill DB
|
|
351
|
+
with the same SQLite access path and record it separately. Capture only counts
|
|
352
|
+
and integrity status, not monitor targets or secrets:
|
|
335
353
|
|
|
336
354
|
```bash
|
|
337
355
|
sqlite3 /mnt/restore/uptime/uptime.db 'PRAGMA integrity_check;'
|
|
338
356
|
sqlite3 /mnt/restore/uptime/uptime.db 'SELECT COUNT(*) FROM monitors;'
|
|
339
357
|
```
|
|
340
358
|
|
|
359
|
+
Do not count a restore as complete if the task only proves that EFS mounted.
|
|
360
|
+
The evidence must include the restored DB path, `PRAGMA integrity_check = ok`,
|
|
361
|
+
schema version, sanitized table counts, and cleanup proof for the temporary
|
|
362
|
+
mount target and file system.
|
|
363
|
+
|
|
341
364
|
After evidence is recorded, delete the staging mount target and restored file
|
|
342
365
|
system. Never mount the restored file system over production during a drill.
|
|
343
366
|
|
|
@@ -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.
|
|
390
|
-
|
|
391
|
-
|
|
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
|
package/infra/aws/README.md
CHANGED
|
@@ -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.
|
|
46
|
-
|
|
47
|
-
|
|
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" {
|
package/infra/aws/outputs.tf
CHANGED
|
@@ -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.
|
|
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"
|
package/infra/aws/variables.tf
CHANGED
|
@@ -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.
|
|
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
|
|
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